Files
coder/coderd/x/chatd/instruction_internal_test.go
T
Kyle Carberry cd56ab9e33 refactor: remove legacy live-read and injected-history chat context paths (#26585)
This PR makes the agent-pushed pinned snapshot
(`chat_context_resources`) the sole source of workspace context for
chats, completing the "Release 5" cleanup. It removes legacy mechanisms
now superseded by the snapshot that agents push over dRPC
(`PushContextState`) and refresh via `chat-context/refresh`.

Removed:

- **Live-read at turn time.** MCP tool discovery, skill live-body reads,
and the instruction/skill history fallback that dialed the workspace on
every turn.
- **Context injected as message history.** The
`persist_workspace_context` generation action and its decision-loop
guard.
- **The legacy write path.** `POST`/`DELETE
/api/v2/workspaceagents/me/experimental/chat-context`, the agentsdk
`AddChatContext`/`ClearChatContext` methods, and the CLI one-shot
writer.
- **The `chats.last_injected_context` column** and all of its plumbing
(migration `000529`, queries, `db2sdk`, `dbauthz`, audit table, and the
frontend `ContextUsageIndicator` fallback).

Subagent context inheritance no longer copies parent context messages;
children now hydrate the parent's pinned `chat_context_resources` on
create, which yields an identical pin for the same workspace and agent.

What stays (still served by the live agent connection, not the
snapshot): `read_skill_file` supporting-file reads, `read_skill`
supporting-file listing, and MCP tool execution.

> [!NOTE]
> Migration `000529` drops `chats.last_injected_context` and recreates
the `chats_expanded` view without it. The down migration restores both.

<details>
<summary>Decision log (D1-D5)</summary>

- **D1 (subagent inheritance):** Re-point inheritance from the legacy
message copy to a pinned hydrate. Children call
`hydrateChatContextOnCreate` instead of copying parent context messages.
- **D2 (`persist_workspace_context`):** Remove the generation action
entirely along with the decision-loop guard it existed to satisfy, since
context is never injected into history anymore.
- **D3 (legacy HTTP + CLI):** Remove the experimental `chat-context`
POST/DELETE endpoints, the agentsdk methods, and the CLI one-shot. The
dRPC push + `chat-context/refresh` replace them.
- **D4 (frontend fallback):** Remove the `last_injected_context`
fallback in `ContextUsageIndicator`; pinned `resources` are the sole
source.
- **D5 (sequencing):** Ship as a single PR rather than a stacked pair.

</details>

---
Coder Agents generated on behalf of @kylecarbs.
2026-06-22 19:26:34 -06:00

290 lines
11 KiB
Go

package chatd
import (
"strings"
"testing"
"charm.land/fantasy"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
"github.com/coder/coder/v2/codersdk"
)
func TestRenderPlanPathPrompt(t *testing.T) {
t.Parallel()
newPromptWithPlaceholder := func() []fantasy.Message {
return []fantasy.Message{
{
Role: fantasy.MessageRoleSystem,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: "<planning>\n" + defaultSystemPromptPlanPathBlockPlaceholder + "\n</planning>"},
},
},
{
Role: fantasy.MessageRoleUser,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: "hello"},
},
},
}
}
messageText := func(t *testing.T, message fantasy.Message) string {
t.Helper()
part, ok := fantasy.AsMessagePart[fantasy.TextPart](message.Content[0])
require.True(t, ok)
return part.Text
}
t.Run("ReplacesPlaceholderWithResolvedHome", func(t *testing.T) {
t.Parallel()
prompt := newPromptWithPlaceholder()
got := renderPlanPathPrompt(prompt, formatPlanPathBlock(
"/Users/dev/.coder/plans/PLAN-chat.md",
"/Users/dev",
))
require.Len(t, got, len(prompt))
text := messageText(t, got[0])
require.Contains(t, text, "Your plan file path for this chat is: /Users/dev/.coder/plans/PLAN-chat.md")
require.Contains(t, text, "Do not use /Users/dev/PLAN.md.")
require.NotContains(t, text, defaultSystemPromptPlanPathBlockPlaceholder)
})
t.Run("FallsBackToLegacySharedPathWhenHomeIsEmpty", func(t *testing.T) {
t.Parallel()
prompt := newPromptWithPlaceholder()
got := renderPlanPathPrompt(prompt, formatPlanPathBlock(
"/home/coder/.coder/plans/PLAN-chat.md",
"",
))
text := messageText(t, got[0])
require.Contains(t, text, "Do not use "+chattool.LegacySharedPlanPath+".")
})
t.Run("LeavesPromptUnchangedWhenPlaceholderMissing", func(t *testing.T) {
t.Parallel()
prompt := []fantasy.Message{
{
Role: fantasy.MessageRoleSystem,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: "base instructions"},
},
},
{
Role: fantasy.MessageRoleSystem,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: "workspace awareness"},
},
},
{
Role: fantasy.MessageRoleUser,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: "hello"},
},
},
}
got := renderPlanPathPrompt(prompt, formatPlanPathBlock(
"/home/coder/.coder/plans/PLAN-chat.md",
"/home/coder",
))
require.Equal(t, prompt, got)
})
t.Run("RemovesPlaceholderWhenPlanPathBlockIsEmpty", func(t *testing.T) {
t.Parallel()
prompt := newPromptWithPlaceholder()
got := renderPlanPathPrompt(prompt, "")
require.Len(t, got, len(prompt))
text := messageText(t, got[0])
require.NotContains(t, text, defaultSystemPromptPlanPathBlockPlaceholder)
require.NotContains(t, text, "<plan-file-path>")
})
}
func TestDefaultSystemPromptContainsVersionControlSafety(t *testing.T) {
t.Parallel()
require.Contains(t, DefaultSystemPrompt, "<version-control-safety>")
require.Contains(t, DefaultSystemPrompt, "</version-control-safety>")
require.Contains(t, DefaultSystemPrompt, "check the current branch and push target")
require.Contains(t, DefaultSystemPrompt, "Do not commit directly to default or protected branches")
require.Contains(t, DefaultSystemPrompt, "including main, master, trunk")
require.Contains(t, DefaultSystemPrompt, "unless the user explicitly confirms after you identify the exact branch")
require.Contains(t, DefaultSystemPrompt, "Do not push when the target would update a default or protected branch unless the user explicitly confirms")
require.Contains(t, DefaultSystemPrompt, "Before asking for confirmation, warn that the push bypasses")
require.Contains(t, DefaultSystemPrompt, "state the exact remote ref that would be updated")
require.Contains(t, DefaultSystemPrompt, "Confirmation must be separate and must name the exact protected branch")
require.Contains(t, DefaultSystemPrompt, "Do not run plain git push while checked out on a default or protected branch")
require.Contains(t, DefaultSystemPrompt, "use an explicit refspec")
require.Contains(t, DefaultSystemPrompt, "create and switch to a feature branch first")
require.Contains(t, DefaultSystemPrompt, "Never treat the original request as confirmation")
}
func TestWorkspaceAwarenessDelaysWorkspaceCreation(t *testing.T) {
t.Parallel()
detached := workspaceDetachedAwareness
require.Contains(t, detached, "No workspace is attached to this chat yet")
require.Contains(t, detached, "Do not create or start a workspace by default")
require.Contains(t, detached, "Only call create_workspace or start_workspace")
require.NotContains(t, detached, "Create one using the create_workspace tool before using workspace tools")
delegated := workspaceDetachedNoCreateAwareness
require.Contains(t, delegated, "This delegated chat cannot create or start a workspace")
require.Contains(t, delegated, "report that need to the parent agent")
require.NotContains(t, delegated, "Only call create_workspace or start_workspace")
attached := workspaceAttachedAwareness
require.Contains(t, attached, "This chat is attached to a workspace")
}
func TestDefaultSystemPromptDelaysWorkspaceCreation(t *testing.T) {
t.Parallel()
require.Contains(t, DefaultSystemPrompt, "Do not create a workspace by default")
require.Contains(t, DefaultSystemPrompt, "Do not clone repositories already present")
require.Contains(t, DefaultSystemPrompt, "including AGENTS.md")
require.NotContains(t, DefaultSystemPrompt, "create and start one first using create_workspace and start_workspace")
}
func TestPlanningOverlayPromptDelaysWorkspaceCreation(t *testing.T) {
t.Parallel()
prompt := PlanningOverlayPrompt()
require.Contains(t, prompt, "do not create one as the first action merely because you are planning")
require.Contains(t, prompt, "Before cloning, inspect the current workspace and reuse existing repositories")
require.NotContains(t, prompt, "create and start one with create_workspace and start_workspace before investigating")
}
func TestInsertSystemInstructionAfterSystemMessages(t *testing.T) {
t.Parallel()
prompt := []fantasy.Message{
{
Role: fantasy.MessageRoleSystem,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: "base"},
},
},
{
Role: fantasy.MessageRoleUser,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: "hello"},
},
},
}
got := chatprompt.InsertSystem(prompt, "project rules")
require.Len(t, got, 3)
require.Equal(t, fantasy.MessageRoleSystem, got[0].Role)
require.Equal(t, fantasy.MessageRoleSystem, got[1].Role)
require.Equal(t, fantasy.MessageRoleUser, got[2].Role)
part, ok := fantasy.AsMessagePart[fantasy.TextPart](got[1].Content[0])
require.True(t, ok)
require.Equal(t, "project rules", part.Text)
}
func TestFormatSystemInstructions(t *testing.T) {
t.Parallel()
t.Run("HomeAndPwdWithAgentContext", func(t *testing.T) {
t.Parallel()
got := formatSystemInstructions("linux", "/home/coder/project", []codersdk.ChatMessagePart{
{Type: codersdk.ChatMessagePartTypeContextFile, ContextFileContent: "home rules", ContextFilePath: "/home/coder/.coder/AGENTS.md"},
{Type: codersdk.ChatMessagePartTypeContextFile, ContextFileContent: "project rules", ContextFilePath: "/home/coder/project/AGENTS.md"},
})
require.Contains(t, got, "Operating System: linux")
require.Contains(t, got, "Working Directory: /home/coder/project")
require.Contains(t, got, "Source: /home/coder/.coder/AGENTS.md")
require.Contains(t, got, "home rules")
require.Contains(t, got, "Source: /home/coder/project/AGENTS.md")
require.Contains(t, got, "project rules")
require.True(t, strings.HasPrefix(got, "<workspace-context>"))
require.True(t, strings.HasSuffix(got, "</workspace-context>"))
})
t.Run("OnlyPwdFile", func(t *testing.T) {
t.Parallel()
got := formatSystemInstructions("", "/home/coder/project", []codersdk.ChatMessagePart{
{Type: codersdk.ChatMessagePartTypeContextFile, ContextFileContent: "project rules", ContextFilePath: "/home/coder/project/AGENTS.md"},
})
require.Contains(t, got, "project rules")
require.Contains(t, got, "Source: /home/coder/project/AGENTS.md")
require.NotContains(t, got, ".coder/AGENTS.md")
})
t.Run("OnlyAgentContext", func(t *testing.T) {
t.Parallel()
got := formatSystemInstructions("darwin", "/Users/dev/repo", nil)
require.Contains(t, got, "Operating System: darwin")
require.Contains(t, got, "Working Directory: /Users/dev/repo")
require.NotContains(t, got, "Source:")
require.True(t, strings.HasPrefix(got, "<workspace-context>"))
require.True(t, strings.HasSuffix(got, "</workspace-context>"))
})
t.Run("OnlyHomeFile", func(t *testing.T) {
t.Parallel()
got := formatSystemInstructions("", "", []codersdk.ChatMessagePart{
{Type: codersdk.ChatMessagePartTypeContextFile, ContextFileContent: "home rules", ContextFilePath: "~/.coder/AGENTS.md"},
})
require.Contains(t, got, "Source: ~/.coder/AGENTS.md")
require.Contains(t, got, "home rules")
require.NotContains(t, got, "Operating System:")
require.NotContains(t, got, "Working Directory:")
})
t.Run("Empty", func(t *testing.T) {
t.Parallel()
got := formatSystemInstructions("", "", nil)
require.Empty(t, got)
})
t.Run("TruncatedFile", func(t *testing.T) {
t.Parallel()
got := formatSystemInstructions("windows", "", []codersdk.ChatMessagePart{
{Type: codersdk.ChatMessagePartTypeContextFile, ContextFileContent: "rules", ContextFilePath: "/path/AGENTS.md", ContextFileTruncated: true},
})
require.Contains(t, got, "truncated to 64KiB")
require.Contains(t, got, "Operating System: windows")
})
t.Run("AgentContextBeforeFiles", func(t *testing.T) {
t.Parallel()
got := formatSystemInstructions("linux", "/home/project", []codersdk.ChatMessagePart{
{Type: codersdk.ChatMessagePartTypeContextFile, ContextFileContent: "home", ContextFilePath: "/home/.coder/AGENTS.md"},
{Type: codersdk.ChatMessagePartTypeContextFile, ContextFileContent: "pwd", ContextFilePath: "/home/project/AGENTS.md"},
})
osIdx := strings.Index(got, "Operating System:")
dirIdx := strings.Index(got, "Working Directory:")
homeSourceIdx := strings.Index(got, "Source: /home/.coder/AGENTS.md")
pwdSourceIdx := strings.Index(got, "Source: /home/project/AGENTS.md")
require.Less(t, osIdx, homeSourceIdx)
require.Less(t, dirIdx, homeSourceIdx)
require.Less(t, homeSourceIdx, pwdSourceIdx)
})
t.Run("EmptySectionsIgnored", func(t *testing.T) {
t.Parallel()
got := formatSystemInstructions("linux", "", []codersdk.ChatMessagePart{
{Type: codersdk.ChatMessagePartTypeContextFile, ContextFileContent: "", ContextFilePath: "/empty"},
{Type: codersdk.ChatMessagePartTypeContextFile, ContextFileContent: "real", ContextFilePath: "/real/AGENTS.md"},
})
require.NotContains(t, got, "Source: /empty")
require.Contains(t, got, "Source: /real/AGENTS.md")
})
}