feat(coderd/x/chatd): consume the pinned chat context in prompt generation (#26558)

## What

`prepareGeneration` now builds the system-prompt instruction block and
workspace skills from a chat's **pinned context copy**
(`chat_context_resources`, populated in #26438) instead of re-scanning
per-turn history, when the chat has a pinned copy. This is the first
production reader of the pin.

Selection is **presence-based, no experiment**: a chat with pinned rows
builds its prompt from the pin; a chat without them falls back to the
existing per-turn history path. The two paths are mutually exclusive, so
older agents that never report context keep their current behavior and
the per-turn pull stays as the fallback.

## How

- `contextResourcesToPrompt` maps the protojson resource bodies
(instruction files and skills) into the instruction block and skill
metadata, skipping non-OK statuses, non-prompt body kinds, and malformed
bodies (the malformed count is logged so a proto/encoding regression
cannot silently drop context).
- `pinnedWorkspaceContext` reads the pin and reports `ok=false` (history
fallback) when there are no pinned rows; read errors propagate. The
bound agent only decorates the instruction header with OS and directory,
so the pin still resolves when the workspace is unreachable.
- `resolveTurnWorkspaceContext` dispatches between the pinned and
history paths; `prepareGeneration` calls it.

## Testing

- `go test ./coderd/x/chatd/` for `TestContextResourcesToPrompt`,
`TestPinnedWorkspaceContext` (incl. `...FromHydratedPin` against real
Postgres), and `TestResolveTurnWorkspaceContext`: pass.
- `make gen` (no drift), `golangci-lint`, `gofmt`, emdash scan, and `go
build`/`go vet` on `./coderd/x/chatd/...`: all clean.

## Scope

This is the foundational backend slice split from #26466 (the full-stack
staging PR). It changes no API surface, schema, proto, or generated
files. The remaining pieces land as follow-ups in dependency order:

1. `ChatContext` drift/diff API (`resources` + `changes`,
`ContextDetail`). This also extracts the body decoders inlined here so
they are shared with the diff path.
2. Context-ring drift indicator, changes dialog, and refresh (UI).
3. In-workspace `coder exp chat context` source CRUD and `refresh`
(CLI).

<details>
<summary>Why this is the first split</summary>

The coderd hydration, the `PUT /chats/{id}/context` refresh endpoint
(#26389), the `chat_context_resources` table (#26430), and the
copy-into-pin logic (#26438) are already merged, as is the agent-side
push (#26526, #26533). Consuming the pin in prompt building is the step
#26438 explicitly deferred, and it is the bottom of the remaining
dependency stack: the drift/diff API, the UI indicator, and the CLI are
only meaningful once the chat actually builds its prompt from the pin.
Keeping it presence-based means it is independently revertable and
leaves the per-turn pull intact as a fallback, matching the RFC's
Release 3 rollout.

The files are taken verbatim from the reviewed #26466 boundary commit
(before the diff-API work began), so the deep-review feedback already
applied there (CRF-1 through CRF-10) is preserved.

</details>

---

*This PR was created by Coder Agents on behalf of @kylecarbs.* Split
from #26466.
This commit is contained in:
Kyle Carberry
2026-06-22 08:51:57 -06:00
committed by GitHub
parent f5cb2e547e
commit 46916bf899
3 changed files with 722 additions and 9 deletions
+170
View File
@@ -0,0 +1,170 @@
package chatd
import (
"context"
"golang.org/x/xerrors"
"google.golang.org/protobuf/encoding/protojson"
"cdr.dev/slog/v3"
agentproto "github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
"github.com/coder/coder/v2/codersdk"
)
// contextBodyUnmarshalOptions reads the protojson resource bodies written by
// the agent context push (coderd/agentapi/context.go). DiscardUnknown keeps
// the reader forward compatible as new body fields are added to the proto.
var contextBodyUnmarshalOptions = protojson.UnmarshalOptions{DiscardUnknown: true}
// pinnedWorkspaceContext builds the system-prompt instruction block and
// workspace skills from the chat's pinned context resources
// (chat_context_resources), populated at hydrate and refresh time.
//
// ok reports whether the caller should use these values instead of the
// per-turn, history-derived path. It is false when the chat has no pinned
// rows (an older agent that never reported context, or a chat not yet
// hydrated), so the caller falls back to the legacy path. When rows exist ok
// is true even if they all filter to empty content, because the pin is then
// the source of truth. A read error is returned rather than swallowed,
// matching the other prompt-input reads in prepareGeneration.
//
// agent only decorates the instruction header with its OS and directory; an
// unresolved (zero-value) agent does not force a fallback, so the pin keeps
// working when the workspace is unreachable.
func (server *Server) pinnedWorkspaceContext(
ctx context.Context,
chat database.Chat,
agent database.WorkspaceAgent,
) (instruction string, skills []chattool.SkillMeta, ok bool, err error) {
resources, err := server.db.ListChatContextResourcesByChatID(ctx, chat.ID)
if err != nil {
return "", nil, false, xerrors.Errorf("list chat context resources: %w", err)
}
if len(resources) == 0 {
return "", nil, false, nil
}
directory := agent.ExpandedDirectory
if directory == "" {
directory = agent.Directory
}
instruction, skills, malformed := contextResourcesToPrompt(resources, agent.OperatingSystem, directory)
if malformed > 0 {
// A status-OK resource whose body cannot be decoded means the pin
// hydrated content that is now unreadable; surface it so a proto
// or encoding regression does not silently drop context.
server.logger.Warn(ctx, "skipped malformed pinned chat context resources",
slog.F("chat_id", chat.ID),
slog.F("malformed_count", malformed),
slog.F("resource_count", len(resources)),
)
}
server.logger.Debug(ctx, "built prompt context from pinned chat resources",
slog.F("chat_id", chat.ID),
slog.F("resource_count", len(resources)),
slog.F("skill_count", len(skills)),
slog.F("has_instruction", instruction != ""),
)
return instruction, skills, true, nil
}
// resolveTurnWorkspaceContext selects the instruction block and workspace
// skills for a turn. It prefers the chat's pinned context copy when the
// workspace agent has reported context, and falls back to the per-turn,
// history-derived context-file and skill parts for older agents that have
// not. The two paths are mutually exclusive. agent is the chat's resolved
// workspace agent, used only to decorate the pinned instruction header. A
// non-workspace chat yields no context.
func (server *Server) resolveTurnWorkspaceContext(
ctx context.Context,
chat database.Chat,
agent database.WorkspaceAgent,
promptRows []database.ChatMessage,
) (instruction string, skills []chattool.SkillMeta, err error) {
if !chat.WorkspaceID.Valid {
return "", nil, nil
}
pinnedInstruction, pinnedSkills, ok, err := server.pinnedWorkspaceContext(ctx, chat, agent)
if err != nil {
return "", nil, err
}
if ok {
return pinnedInstruction, pinnedSkills, nil
}
// History fallback: re-derive the instruction and skills from the
// context-file and skill parts the per-turn pull persisted. Skills are
// included only when context files are present; the pinned path resolves
// them independently.
if _, found := contextFileAgentID(promptRows); found {
return instructionFromContextFiles(promptRows), skillsFromParts(promptRows), nil
}
return "", nil, nil
}
// contextResourcesToPrompt converts a chat's pinned context resources into
// the formatted instruction block and workspace skill metadata, the inverse
// of the protojson bodies written by the agent context push.
//
// operatingSystem and directory annotate the instruction header and are
// omitted when empty. Only OK resources of a prompt body kind contribute;
// other statuses, body kinds, and malformed bodies are skipped. malformed
// counts OK resources whose body failed to decode, so the caller can surface
// an otherwise silent drop. The header is emitted only when at least one
// instruction file has content, so a skill-only pin produces no instruction
// block, matching the per-turn path.
func contextResourcesToPrompt(
resources []database.ChatContextResource,
operatingSystem, directory string,
) (instruction string, skills []chattool.SkillMeta, malformed int) {
var contextFileParts []codersdk.ChatMessagePart
for _, r := range resources {
if r.Status != database.WorkspaceAgentContextResourceStatusOk {
continue
}
switch r.BodyKind {
case database.WorkspaceAgentContextBodyKindInstructionFile:
var body agentproto.InstructionFileBody
if err := contextBodyUnmarshalOptions.Unmarshal(r.Body, &body); err != nil {
malformed++
continue
}
content := SanitizePromptText(string(body.GetContent()))
if content == "" {
continue
}
contextFileParts = append(contextFileParts, codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeContextFile,
ContextFilePath: r.Source,
ContextFileContent: content,
})
case database.WorkspaceAgentContextBodyKindSkill:
var body agentproto.SkillMetaBody
if err := contextBodyUnmarshalOptions.Unmarshal(r.Body, &body); err != nil {
malformed++
continue
}
if body.GetName() == "" {
continue
}
// source is the skill directory. MetaFile is left empty so
// chattool falls back to DefaultSkillMetaFile ("SKILL.md").
// SkillMetaBody carries no meta file name, so a non-default
// CODER_AGENT_EXP_SKILL_META_FILE is not preserved on this
// path, unlike the per-turn discovery path.
skills = append(skills, chattool.SkillMeta{
Name: body.GetName(),
Description: body.GetDescription(),
Dir: r.Source,
})
}
}
if len(contextFileParts) == 0 {
return "", skills, malformed
}
return formatSystemInstructions(operatingSystem, directory, contextFileParts), skills, malformed
}
@@ -0,0 +1,544 @@
package chatd
import (
"context"
"database/sql"
"encoding/json"
"testing"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"golang.org/x/xerrors"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/slogtest"
agentproto "github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbmock"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
func mustMarshalContextBody(t *testing.T, msg proto.Message) json.RawMessage {
t.Helper()
raw, err := protojson.Marshal(msg)
require.NoError(t, err)
return raw
}
func instructionResource(t *testing.T, source, content string, status database.WorkspaceAgentContextResourceStatus) database.ChatContextResource {
t.Helper()
return database.ChatContextResource{
Source: source,
BodyKind: database.WorkspaceAgentContextBodyKindInstructionFile,
Body: mustMarshalContextBody(t, &agentproto.InstructionFileBody{Content: []byte(content)}),
Status: status,
}
}
func skillResource(t *testing.T, source, name, description string, status database.WorkspaceAgentContextResourceStatus) database.ChatContextResource {
t.Helper()
return database.ChatContextResource{
Source: source,
BodyKind: database.WorkspaceAgentContextBodyKindSkill,
Body: mustMarshalContextBody(t, &agentproto.SkillMetaBody{
Meta: []byte("# " + name),
Name: name,
Description: description,
}),
Status: status,
}
}
func TestContextResourcesToPrompt(t *testing.T) {
t.Parallel()
t.Run("InstructionFilesBuildWorkspaceContext", func(t *testing.T) {
t.Parallel()
resources := []database.ChatContextResource{
instructionResource(t, "/home/coder/AGENTS.md", "be helpful", database.WorkspaceAgentContextResourceStatusOk),
}
instruction, skills, _ := contextResourcesToPrompt(resources, "linux", "/home/coder")
require.Empty(t, skills)
require.Contains(t, instruction, "<workspace-context>")
require.Contains(t, instruction, "Operating System: linux")
require.Contains(t, instruction, "Working Directory: /home/coder")
require.Contains(t, instruction, "Source: /home/coder/AGENTS.md")
require.Contains(t, instruction, "be helpful")
require.Contains(t, instruction, "</workspace-context>")
})
t.Run("SkillsBuildMeta", func(t *testing.T) {
t.Parallel()
resources := []database.ChatContextResource{
skillResource(t, "/home/coder/.coder/skills/deploy", "deploy", "Deploy the app", database.WorkspaceAgentContextResourceStatusOk),
}
instruction, skills, _ := contextResourcesToPrompt(resources, "linux", "/home/coder")
// Skill-only pins emit no instruction header.
require.Empty(t, instruction)
require.Len(t, skills, 1)
require.Equal(t, "deploy", skills[0].Name)
require.Equal(t, "Deploy the app", skills[0].Description)
require.Equal(t, "/home/coder/.coder/skills/deploy", skills[0].Dir)
// MetaFile is left empty so chattool defaults to SKILL.md.
require.Empty(t, skills[0].MetaFile)
})
t.Run("SkipsNonOKStatus", func(t *testing.T) {
t.Parallel()
resources := []database.ChatContextResource{
instructionResource(t, "/home/coder/AGENTS.md", "be helpful", database.WorkspaceAgentContextResourceStatusInvalid),
skillResource(t, "/home/coder/.coder/skills/deploy", "deploy", "Deploy the app", database.WorkspaceAgentContextResourceStatusOversize),
}
instruction, skills, _ := contextResourcesToPrompt(resources, "linux", "/home/coder")
require.Empty(t, instruction)
require.Empty(t, skills)
})
t.Run("SkipsUnknownBodyKinds", func(t *testing.T) {
t.Parallel()
resources := []database.ChatContextResource{
{
Source: ".mcp.json",
BodyKind: database.WorkspaceAgentContextBodyKindMcpConfig,
Body: mustMarshalContextBody(t, &agentproto.MCPConfigBody{}),
Status: database.WorkspaceAgentContextResourceStatusOk,
},
{
Source: "playwright",
BodyKind: database.WorkspaceAgentContextBodyKindMcpServer,
Body: mustMarshalContextBody(t, &agentproto.MCPServerBody{ServerName: "playwright"}),
Status: database.WorkspaceAgentContextResourceStatusOk,
},
}
instruction, skills, _ := contextResourcesToPrompt(resources, "linux", "/home/coder")
require.Empty(t, instruction)
require.Empty(t, skills)
})
t.Run("SkipsMalformedBody", func(t *testing.T) {
t.Parallel()
resources := []database.ChatContextResource{
{
Source: "/home/coder/AGENTS.md",
BodyKind: database.WorkspaceAgentContextBodyKindInstructionFile,
Body: json.RawMessage(`{not valid json`),
Status: database.WorkspaceAgentContextResourceStatusOk,
},
instructionResource(t, "/home/coder/CLAUDE.md", "good content", database.WorkspaceAgentContextResourceStatusOk),
}
instruction, skills, malformed := contextResourcesToPrompt(resources, "linux", "/home/coder")
require.Empty(t, skills)
require.Equal(t, 1, malformed)
require.NotContains(t, instruction, "/home/coder/AGENTS.md")
require.Contains(t, instruction, "Source: /home/coder/CLAUDE.md")
require.Contains(t, instruction, "good content")
})
t.Run("SkipsMalformedSkillBody", func(t *testing.T) {
t.Parallel()
resources := []database.ChatContextResource{
{
Source: "/home/coder/.coder/skills/broken",
BodyKind: database.WorkspaceAgentContextBodyKindSkill,
Body: json.RawMessage(`{not valid json`),
Status: database.WorkspaceAgentContextResourceStatusOk,
},
skillResource(t, "/home/coder/.coder/skills/deploy", "deploy", "Deploy the app", database.WorkspaceAgentContextResourceStatusOk),
}
instruction, skills, malformed := contextResourcesToPrompt(resources, "linux", "/home/coder")
require.Empty(t, instruction)
require.Equal(t, 1, malformed)
require.Len(t, skills, 1)
require.Equal(t, "deploy", skills[0].Name)
})
t.Run("SkipsEmptyNameSkill", func(t *testing.T) {
t.Parallel()
// Defensive boundary on the agent's own marshaling: an OK skill with an
// empty name contributes nothing and is not counted as malformed.
resources := []database.ChatContextResource{
skillResource(t, "/home/coder/.coder/skills/nameless", "", "no name", database.WorkspaceAgentContextResourceStatusOk),
}
instruction, skills, malformed := contextResourcesToPrompt(resources, "linux", "/home/coder")
require.Empty(t, instruction)
require.Empty(t, skills)
require.Zero(t, malformed)
})
t.Run("SkipsEmptyInstructionContent", func(t *testing.T) {
t.Parallel()
// Whitespace-only content sanitizes to empty, so the instruction file
// contributes no context-file part, emits no header, and is not counted
// as malformed.
resources := []database.ChatContextResource{
instructionResource(t, "/home/coder/AGENTS.md", " \n\t ", database.WorkspaceAgentContextResourceStatusOk),
}
instruction, skills, malformed := contextResourcesToPrompt(resources, "linux", "/home/coder")
require.Empty(t, instruction)
require.Empty(t, skills)
require.Zero(t, malformed)
})
t.Run("EmptyInput", func(t *testing.T) {
t.Parallel()
instruction, skills, _ := contextResourcesToPrompt(nil, "linux", "/home/coder")
require.Empty(t, instruction)
require.Empty(t, skills)
})
t.Run("OmitsOSDirWhenAgentUnresolved", func(t *testing.T) {
t.Parallel()
resources := []database.ChatContextResource{
instructionResource(t, "/home/coder/AGENTS.md", "be helpful", database.WorkspaceAgentContextResourceStatusOk),
}
instruction, _, _ := contextResourcesToPrompt(resources, "", "")
require.Contains(t, instruction, "<workspace-context>")
require.Contains(t, instruction, "Source: /home/coder/AGENTS.md")
require.Contains(t, instruction, "be helpful")
require.NotContains(t, instruction, "Operating System:")
require.NotContains(t, instruction, "Working Directory:")
})
}
func newPinServer(t *testing.T, db database.Store) *Server {
t.Helper()
return &Server{
db: db,
logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug),
}
}
func TestPinnedWorkspaceContext(t *testing.T) {
t.Parallel()
t.Run("ListError", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
chatID := uuid.New()
db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chatID).
Return(nil, xerrors.New("boom"))
server := newPinServer(t, db)
_, _, ok, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, database.WorkspaceAgent{})
require.Error(t, err)
require.False(t, ok)
})
t.Run("NoRowsFallsBack", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
chatID := uuid.New()
db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chatID).
Return([]database.ChatContextResource{}, nil)
server := newPinServer(t, db)
instruction, skills, ok, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, database.WorkspaceAgent{})
require.NoError(t, err)
require.False(t, ok)
require.Empty(t, instruction)
require.Empty(t, skills)
})
t.Run("RowsPresent", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
chatID := uuid.New()
db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chatID).
Return([]database.ChatContextResource{
instructionResource(t, "/home/coder/AGENTS.md", "be helpful", database.WorkspaceAgentContextResourceStatusOk),
skillResource(t, "/home/coder/.coder/skills/deploy", "deploy", "Deploy the app", database.WorkspaceAgentContextResourceStatusOk),
}, nil)
server := newPinServer(t, db)
agent := database.WorkspaceAgent{OperatingSystem: "linux", ExpandedDirectory: "/home/coder"}
instruction, skills, ok, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, agent)
require.NoError(t, err)
require.True(t, ok)
require.Contains(t, instruction, "Operating System: linux")
require.Contains(t, instruction, "Source: /home/coder/AGENTS.md")
require.Contains(t, instruction, "be helpful")
require.Len(t, skills, 1)
require.Equal(t, "deploy", skills[0].Name)
})
t.Run("RowsPresentUnresolvedAgent", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
chatID := uuid.New()
db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chatID).
Return([]database.ChatContextResource{
instructionResource(t, "/home/coder/AGENTS.md", "be helpful", database.WorkspaceAgentContextResourceStatusOk),
}, nil)
server := newPinServer(t, db)
// Zero-value agent: the pin still resolves, just without the
// OS/directory header.
instruction, _, ok, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, database.WorkspaceAgent{})
require.NoError(t, err)
require.True(t, ok)
require.Contains(t, instruction, "Source: /home/coder/AGENTS.md")
require.NotContains(t, instruction, "Operating System:")
})
}
// TestPinnedWorkspaceContextFromHydratedPin exercises the resolver end to end
// against a real Postgres pin: an agent's pushed context is hydrated into a
// chat's chat_context_resources, then pinnedWorkspaceContext reads that copy.
func TestPinnedWorkspaceContextFromHydratedPin(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitLong)
user := dbgen.User(t, db, database.User{})
org := dbgen.Organization(t, db, database.Organization{})
tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{
OrganizationID: org.ID,
CreatedBy: user.ID,
})
tmpl := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
ActiveVersionID: tv.ID,
CreatedBy: user.ID,
})
ws := dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: user.ID,
OrganizationID: org.ID,
TemplateID: tmpl.ID,
})
pj := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
OrganizationID: org.ID,
CompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now()},
})
dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
WorkspaceID: ws.ID,
TemplateVersionID: tv.ID,
JobID: pj.ID,
Transition: database.WorkspaceTransitionStart,
})
res := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{
Transition: database.WorkspaceTransitionStart,
JobID: pj.ID,
})
agent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{
ResourceID: res.ID,
OperatingSystem: "linux",
Directory: "/home/coder/ws",
})
model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{})
hash := []byte{0x01, 0x02, 0x03}
seedAgentContext(ctx, t, db, agent.ID, "/home/coder/ws/AGENTS.md", hash,
database.WorkspaceAgentContextBodyKindInstructionFile,
mustMarshalContextBody(t, &agentproto.InstructionFileBody{Content: []byte("follow the rules")}))
seedAgentContext(ctx, t, db, agent.ID, "/home/coder/ws/.coder/skills/deploy", hash,
database.WorkspaceAgentContextBodyKindSkill,
mustMarshalContextBody(t, &agentproto.SkillMetaBody{
Meta: []byte("# deploy"),
Name: "deploy",
Description: "Deploy the app",
}))
chat := dbgen.Chat(t, db, database.Chat{
OwnerID: user.ID,
OrganizationID: org.ID,
LastModelConfigID: model.ID,
WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true},
AgentID: uuid.NullUUID{UUID: agent.ID, Valid: true},
Status: database.ChatStatusWaiting,
})
require.NoError(t, db.HydrateAgentChatsContext(ctx, database.HydrateAgentChatsContextParams{
AgentID: agent.ID,
AggregateHash: hash,
}))
rows, err := db.ListChatContextResourcesByChatID(ctx, chat.ID)
require.NoError(t, err)
require.Len(t, rows, 2, "the pin holds the agent's instruction file and skill")
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
server := &Server{db: db, logger: logger}
instruction, skills, ok, err := server.pinnedWorkspaceContext(ctx, chat, agent)
require.NoError(t, err)
require.True(t, ok)
require.Contains(t, instruction, "Operating System: linux")
require.Contains(t, instruction, "Working Directory: /home/coder/ws")
require.Contains(t, instruction, "Source: /home/coder/ws/AGENTS.md")
require.Contains(t, instruction, "follow the rules")
require.Len(t, skills, 1)
require.Equal(t, "deploy", skills[0].Name)
require.Equal(t, "Deploy the app", skills[0].Description)
require.Equal(t, "/home/coder/ws/.coder/skills/deploy", skills[0].Dir)
// A chat created after hydration keeps a NULL pinned hash and no pinned
// rows, so the pin resolves to ok=false and the caller falls back to the
// per-turn history path.
unpinnedChat := dbgen.Chat(t, db, database.Chat{
OwnerID: user.ID,
OrganizationID: org.ID,
LastModelConfigID: model.ID,
WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true},
AgentID: uuid.NullUUID{UUID: agent.ID, Valid: true},
Status: database.ChatStatusWaiting,
})
_, _, ok, err = server.pinnedWorkspaceContext(ctx, unpinnedChat, agent)
require.NoError(t, err)
require.False(t, ok)
}
func historyContextMessage(t *testing.T, agentID uuid.UUID) database.ChatMessage {
t.Helper()
parts := []codersdk.ChatMessagePart{
{
Type: codersdk.ChatMessagePartTypeContextFile,
ContextFileAgentID: uuid.NullUUID{UUID: agentID, Valid: true},
ContextFilePath: "/home/coder/AGENTS.md",
ContextFileContent: "history content",
ContextFileOS: "linux",
ContextFileDirectory: "/home/coder",
},
{
Type: codersdk.ChatMessagePartTypeSkill,
ContextFileAgentID: uuid.NullUUID{UUID: agentID, Valid: true},
SkillName: "history-skill",
SkillDescription: "from history",
},
}
raw, err := json.Marshal(parts)
require.NoError(t, err)
return database.ChatMessage{Content: pqtype.NullRawMessage{RawMessage: raw, Valid: true}}
}
// TestResolveTurnWorkspaceContext covers the dispatch that prepareGeneration
// wires up: the pinned copy when the chat has pinned rows, otherwise the
// per-turn history-derived parts, and nothing for a non-workspace chat.
func TestResolveTurnWorkspaceContext(t *testing.T) {
t.Parallel()
workspaceChat := func() database.Chat {
return database.Chat{ID: uuid.New(), WorkspaceID: uuid.NullUUID{UUID: uuid.New(), Valid: true}}
}
t.Run("NonWorkspaceChatYieldsNothing", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
server := newPinServer(t, db)
instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), database.Chat{ID: uuid.New()}, database.WorkspaceAgent{}, nil)
require.NoError(t, err)
require.Empty(t, instruction)
require.Empty(t, skills)
})
t.Run("PinnedPathWins", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
chat := workspaceChat()
agentID := uuid.New()
db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chat.ID).
Return([]database.ChatContextResource{
instructionResource(t, "/home/coder/AGENTS.md", "pinned content", database.WorkspaceAgentContextResourceStatusOk),
skillResource(t, "/home/coder/.coder/skills/deploy", "deploy", "Deploy the app", database.WorkspaceAgentContextResourceStatusOk),
}, nil)
server := newPinServer(t, db)
// History rows are present too; the pinned path must take precedence.
promptRows := []database.ChatMessage{historyContextMessage(t, agentID)}
instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{OperatingSystem: "linux"}, promptRows)
require.NoError(t, err)
require.Contains(t, instruction, "pinned content")
require.NotContains(t, instruction, "history content")
require.Len(t, skills, 1)
require.Equal(t, "deploy", skills[0].Name)
})
t.Run("HistoryFallbackWhenNoPin", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
chat := workspaceChat()
// No pinned rows: the resolver falls back to the per-turn history path.
db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chat.ID).
Return([]database.ChatContextResource{}, nil)
server := newPinServer(t, db)
agentID := uuid.New()
promptRows := []database.ChatMessage{historyContextMessage(t, agentID)}
instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{}, promptRows)
require.NoError(t, err)
require.Contains(t, instruction, "history content")
require.Len(t, skills, 1)
require.Equal(t, "history-skill", skills[0].Name)
})
t.Run("NoContextWhenHistoryEmpty", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
chat := workspaceChat()
// No pinned rows and no history parts: the turn carries no context.
db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chat.ID).
Return([]database.ChatContextResource{}, nil)
server := newPinServer(t, db)
instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{}, nil)
require.NoError(t, err)
require.Empty(t, instruction)
require.Empty(t, skills)
})
t.Run("PropagatesPinReadError", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
chat := workspaceChat()
db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chat.ID).
Return(nil, xerrors.New("boom"))
server := newPinServer(t, db)
_, _, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{}, nil)
require.Error(t, err)
})
}
+8 -9
View File
@@ -215,8 +215,6 @@ func (server *Server) prepareGeneration(
resolvedUserPrompt string
)
persistedSkills := skillsFromParts(promptRows)
hasContextFiles := false
if chat.WorkspaceID.Valid {
// Resolve the workspace agent so the chat row's AgentID and
// BuildID bindings are up to date before the chatworker
@@ -225,9 +223,14 @@ func (server *Server) prepareGeneration(
// the bound agent has changed, so this is a cheap metadata
// refresh, not a workspace dial. It must not insert chat
// history; only metadata is mutated here.
_, _ = workspaceCtx.getWorkspaceAgent(ctx)
_, found := contextFileAgentID(promptRows)
hasContextFiles = found
agent, _ := workspaceCtx.getWorkspaceAgent(ctx)
var resolveErr error
instruction, workspaceSkills, resolveErr = server.resolveTurnWorkspaceContext(ctx, chat, agent, promptRows)
if resolveErr != nil {
cleanup()
return generationPrepared{}, resolveErr
}
}
var g2 errgroup.Group
@@ -239,10 +242,6 @@ func (server *Server) prepareGeneration(
}
return nil
})
if hasContextFiles {
instruction = instructionFromContextFiles(promptRows)
workspaceSkills = persistedSkills
}
g2.Go(func() error {
personalSkills = server.fetchPersonalSkillMetadata(ctx, chat.OwnerID, logger)
return nil