mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user