mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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.
832 lines
30 KiB
Go
832 lines
30 KiB
Go
package chatd
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"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"
|
|
"google.golang.org/protobuf/types/known/structpb"
|
|
|
|
"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/codersdk/workspacesdk"
|
|
"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 mcpServerResource(t *testing.T, source string, body *agentproto.MCPServerBody, status database.WorkspaceAgentContextResourceStatus) database.ChatContextResource {
|
|
t.Helper()
|
|
return database.ChatContextResource{
|
|
Source: source,
|
|
BodyKind: database.WorkspaceAgentContextBodyKindMcpServer,
|
|
Body: mustMarshalContextBody(t, body),
|
|
Status: status,
|
|
}
|
|
}
|
|
|
|
func mustStruct(t *testing.T, m map[string]any) *structpb.Struct {
|
|
t.Helper()
|
|
s, err := structpb.NewStruct(m)
|
|
require.NoError(t, err)
|
|
return s
|
|
}
|
|
|
|
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)
|
|
// Meta carries the pushed SKILL.md so read_skill serves the body
|
|
// from the pin without dialing the workspace.
|
|
require.Equal(t, []byte("# deploy"), skills[0].Meta)
|
|
})
|
|
|
|
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)
|
|
|
|
_, _, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, database.WorkspaceAgent{})
|
|
require.Error(t, err)
|
|
})
|
|
|
|
t.Run("NoRowsYieldsNothing", 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, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, database.WorkspaceAgent{})
|
|
require.NoError(t, err)
|
|
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, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, agent)
|
|
require.NoError(t, err)
|
|
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, _, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, database.WorkspaceAgent{})
|
|
require.NoError(t, err)
|
|
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, err := server.pinnedWorkspaceContext(ctx, chat, agent)
|
|
require.NoError(t, err)
|
|
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 yields no instruction or skills.
|
|
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,
|
|
})
|
|
emptyInstruction, emptySkills, err := server.pinnedWorkspaceContext(ctx, unpinnedChat, agent)
|
|
require.NoError(t, err)
|
|
require.Empty(t, emptyInstruction)
|
|
require.Empty(t, emptySkills)
|
|
}
|
|
|
|
// TestResolveTurnWorkspaceContext covers the dispatch that prepareGeneration
|
|
// wires up: the pinned copy when the chat has pinned rows, and nothing for a
|
|
// non-workspace chat or a chat without pinned rows.
|
|
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{})
|
|
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()
|
|
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)
|
|
|
|
instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{OperatingSystem: "linux"})
|
|
require.NoError(t, err)
|
|
require.Contains(t, instruction, "pinned content")
|
|
require.Len(t, skills, 1)
|
|
require.Equal(t, "deploy", skills[0].Name)
|
|
})
|
|
|
|
t.Run("NoPinYieldsNothing", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctrl := gomock.NewController(t)
|
|
db := dbmock.NewMockStore(ctrl)
|
|
chat := workspaceChat()
|
|
// No pinned rows: 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{})
|
|
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{})
|
|
require.Error(t, err)
|
|
})
|
|
}
|
|
|
|
func TestPinnedContextResources(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("InstructionAndSkillMetadata", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
resources := []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),
|
|
}
|
|
// instructionResource/skillResource leave SizeBytes zero; set one to
|
|
// confirm it is carried through.
|
|
resources[0].SizeBytes = 10
|
|
|
|
out := pinnedContextResources(resources)
|
|
require.Len(t, out, 2)
|
|
|
|
require.Equal(t, codersdk.ChatContextResource{
|
|
Source: "/home/coder/AGENTS.md",
|
|
Kind: codersdk.ChatContextResourceKindInstructionFile,
|
|
SizeBytes: 10,
|
|
Status: codersdk.ChatContextResourceStatusOK,
|
|
}, out[0])
|
|
|
|
require.Equal(t, codersdk.ChatContextResource{
|
|
Source: "/home/coder/.coder/skills/deploy",
|
|
Kind: codersdk.ChatContextResourceKindSkill,
|
|
Status: codersdk.ChatContextResourceStatusOK,
|
|
SkillName: "deploy",
|
|
SkillDescription: "Deploy the app",
|
|
}, out[1])
|
|
})
|
|
|
|
t.Run("SkipsOKButEmpty", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
resources := []database.ChatContextResource{
|
|
// OK instruction file with empty content.
|
|
instructionResource(t, "/b/AGENTS.md", "", database.WorkspaceAgentContextResourceStatusOk),
|
|
// OK skill with no name.
|
|
skillResource(t, "/c/skills/x", "", "no name", database.WorkspaceAgentContextResourceStatusOk),
|
|
}
|
|
require.Empty(t, pinnedContextResources(resources))
|
|
})
|
|
|
|
t.Run("IncludesNonOKWithError", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
oversize := instructionResource(t, "/a/AGENTS.md", "ignored", database.WorkspaceAgentContextResourceStatusOversize)
|
|
oversize.SizeBytes = 999
|
|
oversize.Error = "file size exceeds cap"
|
|
invalidSkill := skillResource(t, "/c/skills/moo", "", "", database.WorkspaceAgentContextResourceStatusInvalid)
|
|
invalidSkill.Error = `front-matter name "x" does not match directory "moo"`
|
|
resources := []database.ChatContextResource{oversize, invalidSkill}
|
|
|
|
out := pinnedContextResources(resources)
|
|
require.Equal(t, []codersdk.ChatContextResource{
|
|
{
|
|
Source: "/a/AGENTS.md",
|
|
Kind: codersdk.ChatContextResourceKindInstructionFile,
|
|
SizeBytes: 999,
|
|
Status: codersdk.ChatContextResourceStatusOversize,
|
|
Error: "file size exceeds cap",
|
|
},
|
|
{
|
|
Source: "/c/skills/moo",
|
|
Kind: codersdk.ChatContextResourceKindSkill,
|
|
Status: codersdk.ChatContextResourceStatusInvalid,
|
|
Error: `front-matter name "x" does not match directory "moo"`,
|
|
},
|
|
}, out)
|
|
})
|
|
|
|
t.Run("IncludesMCPConfigAndServer", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
resources := []database.ChatContextResource{
|
|
{
|
|
Source: "/home/coder/.mcp.json",
|
|
BodyKind: database.WorkspaceAgentContextBodyKindMcpConfig,
|
|
Status: database.WorkspaceAgentContextResourceStatusOk,
|
|
SizeBytes: 670,
|
|
},
|
|
{
|
|
Source: "github",
|
|
BodyKind: database.WorkspaceAgentContextBodyKindMcpServer,
|
|
Status: database.WorkspaceAgentContextResourceStatusOk,
|
|
SizeBytes: 12,
|
|
// Tool names carry the "<server>__" prefix the agent adds.
|
|
Body: mustMarshalContextBody(t, &agentproto.MCPServerBody{
|
|
ServerName: "github",
|
|
Tools: []*agentproto.MCPTool{
|
|
{Name: "github__create", Description: "Create an issue"},
|
|
{Name: "github__search", Description: "Search code"},
|
|
},
|
|
}),
|
|
},
|
|
}
|
|
out := pinnedContextResources(resources)
|
|
require.Equal(t, []codersdk.ChatContextResource{
|
|
{
|
|
Source: "/home/coder/.mcp.json",
|
|
Kind: codersdk.ChatContextResourceKindMCPConfig,
|
|
SizeBytes: 670,
|
|
Status: codersdk.ChatContextResourceStatusOK,
|
|
},
|
|
{
|
|
Source: "github",
|
|
Kind: codersdk.ChatContextResourceKindMCPServer,
|
|
SizeBytes: 12,
|
|
Status: codersdk.ChatContextResourceStatusOK,
|
|
// Tool names are reported with the "github__" prefix stripped.
|
|
Tools: []codersdk.ChatContextTool{
|
|
{Name: "create", Description: "Create an issue"},
|
|
{Name: "search", Description: "Search code"},
|
|
},
|
|
},
|
|
}, out)
|
|
})
|
|
}
|
|
|
|
func TestContextResources(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("ReturnsPinnedResources", 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)
|
|
|
|
resources, err := server.ContextResources(context.Background(), database.Chat{ID: chatID})
|
|
require.NoError(t, err)
|
|
require.Len(t, resources, 1)
|
|
require.Equal(t, "/home/coder/AGENTS.md", resources[0].Source)
|
|
require.Equal(t, codersdk.ChatContextResourceKindInstructionFile, resources[0].Kind)
|
|
})
|
|
|
|
t.Run("PinnedListError", 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)
|
|
|
|
_, err := server.ContextResources(context.Background(), database.Chat{ID: chatID})
|
|
require.Error(t, err)
|
|
})
|
|
}
|
|
|
|
func TestWorkspaceMCPToolInfosFromResources(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("BuildsPrefixedToolsFromMCPServers", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
schema := mustStruct(t, map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"title": map[string]any{"type": "string"},
|
|
"body": map[string]any{"type": "string"},
|
|
},
|
|
"required": []any{"title"},
|
|
})
|
|
resources := []database.ChatContextResource{
|
|
// Skipped: a config resource carries no tools.
|
|
{
|
|
Source: "/home/coder/.mcp.json",
|
|
BodyKind: database.WorkspaceAgentContextBodyKindMcpConfig,
|
|
Body: mustMarshalContextBody(t, &agentproto.MCPConfigBody{}),
|
|
Status: database.WorkspaceAgentContextResourceStatusOk,
|
|
},
|
|
mcpServerResource(t, "github", &agentproto.MCPServerBody{
|
|
ServerName: "github",
|
|
Tools: []*agentproto.MCPTool{
|
|
{Name: "create_issue", Description: "Create an issue", InputSchema: schema},
|
|
// Skipped: a tool with no name cannot be addressed.
|
|
{Name: "", Description: "nameless"},
|
|
},
|
|
}, database.WorkspaceAgentContextResourceStatusOk),
|
|
// Skipped: a server that failed to connect is not OK.
|
|
mcpServerResource(t, "broken", &agentproto.MCPServerBody{ServerName: "broken"},
|
|
database.WorkspaceAgentContextResourceStatusUnreadable),
|
|
}
|
|
|
|
infos := workspaceMCPToolInfosFromResources(resources)
|
|
require.Len(t, infos, 1)
|
|
require.Equal(t, "github", infos[0].ServerName)
|
|
// Tool names are re-prefixed with the server name so the workspace
|
|
// agent's MCP proxy routes the call to the owning server.
|
|
require.Equal(t, "github__create_issue", infos[0].Name)
|
|
require.Equal(t, "Create an issue", infos[0].Description)
|
|
require.Equal(t, []string{"title"}, infos[0].Required)
|
|
// Schema is the JSON Schema "properties" sub-map, matching the shape the
|
|
// live discovery path produces; "required" travels separately.
|
|
require.Contains(t, infos[0].Schema, "title")
|
|
require.Contains(t, infos[0].Schema, "body")
|
|
require.NotContains(t, infos[0].Schema, "required")
|
|
})
|
|
|
|
t.Run("FallsBackToSourceWhenServerNameEmpty", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
resources := []database.ChatContextResource{
|
|
mcpServerResource(t, "playwright", &agentproto.MCPServerBody{
|
|
Tools: []*agentproto.MCPTool{{Name: "navigate"}},
|
|
}, database.WorkspaceAgentContextResourceStatusOk),
|
|
}
|
|
infos := workspaceMCPToolInfosFromResources(resources)
|
|
require.Len(t, infos, 1)
|
|
require.Equal(t, "playwright", infos[0].ServerName)
|
|
require.Equal(t, "playwright__navigate", infos[0].Name)
|
|
})
|
|
|
|
t.Run("NoMCPServersYieldsNil", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
resources := []database.ChatContextResource{
|
|
instructionResource(t, "/home/coder/AGENTS.md", "be helpful", database.WorkspaceAgentContextResourceStatusOk),
|
|
}
|
|
require.Empty(t, workspaceMCPToolInfosFromResources(resources))
|
|
})
|
|
}
|
|
|
|
func TestPinnedWorkspaceMCPTools(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// getConn is never dialed by these tests: pinnedWorkspaceMCPTools builds
|
|
// tool definitions from the snapshot and only wires the connection for
|
|
// later execution.
|
|
getConn := func(context.Context) (workspacesdk.AgentConn, error) {
|
|
return nil, xerrors.New("not dialed in this test")
|
|
}
|
|
|
|
t.Run("NoRowsYieldsNoTools", 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)
|
|
|
|
tools, err := server.pinnedWorkspaceMCPTools(context.Background(), database.Chat{ID: chatID}, getConn)
|
|
require.NoError(t, err)
|
|
require.Empty(t, tools)
|
|
})
|
|
|
|
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)
|
|
|
|
_, err := server.pinnedWorkspaceMCPTools(context.Background(), database.Chat{ID: chatID}, getConn)
|
|
require.Error(t, err)
|
|
})
|
|
|
|
t.Run("BuildsToolsFromMCPServers", 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),
|
|
mcpServerResource(t, "github", &agentproto.MCPServerBody{
|
|
ServerName: "github",
|
|
Tools: []*agentproto.MCPTool{
|
|
{Name: "create_issue", Description: "Create an issue"},
|
|
{Name: "search", Description: "Search code"},
|
|
},
|
|
}, database.WorkspaceAgentContextResourceStatusOk),
|
|
}, nil)
|
|
server := newPinServer(t, db)
|
|
|
|
tools, err := server.pinnedWorkspaceMCPTools(context.Background(), database.Chat{ID: chatID}, getConn)
|
|
require.NoError(t, err)
|
|
require.Len(t, tools, 2)
|
|
require.Equal(t, "github__create_issue", tools[0].Info().Name)
|
|
require.Equal(t, "github__search", tools[1].Info().Name)
|
|
})
|
|
|
|
t.Run("PinWithoutMCPServersIsAuthoritative", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctrl := gomock.NewController(t)
|
|
db := dbmock.NewMockStore(ctrl)
|
|
chatID := uuid.New()
|
|
// The chat is pinned (an instruction file is present) but the agent
|
|
// reported no MCP servers: the pin is authoritative, yielding zero
|
|
// tools without a live pull that could resurrect stale tools.
|
|
db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chatID).
|
|
Return([]database.ChatContextResource{
|
|
instructionResource(t, "/home/coder/AGENTS.md", "be helpful", database.WorkspaceAgentContextResourceStatusOk),
|
|
}, nil)
|
|
server := newPinServer(t, db)
|
|
|
|
tools, err := server.pinnedWorkspaceMCPTools(context.Background(), database.Chat{ID: chatID}, getConn)
|
|
require.NoError(t, err)
|
|
require.Empty(t, tools)
|
|
})
|
|
}
|