From 22c682fd83116dc2619a3031d25fe8656e67ca21 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 17 Jun 2026 15:43:25 +0000 Subject: [PATCH 01/20] feat: consume the pinned chat context copy in prompt generation Add the first production reader of chat_context_resources, the per-chat pinned copy populated in #26438. When the chat-context-pin experiment is enabled and a chat has a pinned copy, prepareGeneration builds the system-prompt instruction block and workspace skills from that copy instead of scanning the per-turn chat history. The pinned and history paths are mutually exclusive, so with the experiment off behavior is unchanged. - ExperimentChatContextPin gates the behavior. chatd.Server now carries the deployment's experiments, wired through chatd.Config from coderd. - contextResourcesToPrompt maps the protojson resource bodies (instruction files and skills) back into the instruction block and skill metadata, skipping non-OK statuses and non-prompt body kinds. - pinnedWorkspaceContext reads the pin, falls back when the experiment is off or the chat has no pinned rows, and propagates read errors. The bound agent only decorates the instruction header with OS and directory, so the pin still resolves when the workspace is unreachable. --- coderd/apidoc/docs.go | 10 +- coderd/apidoc/swagger.json | 10 +- coderd/coderd.go | 1 + coderd/x/chatd/chatd.go | 6 + coderd/x/chatd/context_prompt.go | 126 ++++++ .../x/chatd/context_prompt_internal_test.go | 383 ++++++++++++++++++ coderd/x/chatd/generation_preparer.go | 22 +- codersdk/deployment.go | 4 + docs/reference/api/schemas.md | 6 +- site/src/api/typesGenerated.ts | 2 + 10 files changed, 558 insertions(+), 12 deletions(-) create mode 100644 coderd/x/chatd/context_prompt.go create mode 100644 coderd/x/chatd/context_prompt_internal_test.go diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 66e8b9f231b62..707558c0d2b16 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -19344,11 +19344,13 @@ const docTemplate = `{ "workspace-build-updates", "nats_pubsub", "minimum-implicit-member", - "ai-gateway-cost-control" + "ai-gateway-cost-control", + "chat-context-pin" ], "x-enum-comments": { "ExperimentAIGatewayCostControl": "Enables AI Gateway cost control functionality.", "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", + "ExperimentChatContextPin": "Builds chat prompts from the per-chat pinned workspace context copy instead of the per-turn agent pull.", "ExperimentExample": "This isn't used for anything.", "ExperimentMCPServerHTTP": "Enables the MCP HTTP server functionality.", "ExperimentMinimumImplicitMember": "Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts.", @@ -19368,7 +19370,8 @@ const docTemplate = `{ "Enables publishing workspace build updates to the all builds pubsub channel.", "Enables embedded NATS pubsub.", "Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts.", - "Enables AI Gateway cost control functionality." + "Enables AI Gateway cost control functionality.", + "Builds chat prompts from the per-chat pinned workspace context copy instead of the per-turn agent pull." ], "x-enum-varnames": [ "ExperimentExample", @@ -19380,7 +19383,8 @@ const docTemplate = `{ "ExperimentWorkspaceBuildUpdates", "ExperimentNATSPubsub", "ExperimentMinimumImplicitMember", - "ExperimentAIGatewayCostControl" + "ExperimentAIGatewayCostControl", + "ExperimentChatContextPin" ] }, "codersdk.ExternalAPIKeyScopes": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 8a6ebb0edf366..f5929b8126849 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -17554,11 +17554,13 @@ "workspace-build-updates", "nats_pubsub", "minimum-implicit-member", - "ai-gateway-cost-control" + "ai-gateway-cost-control", + "chat-context-pin" ], "x-enum-comments": { "ExperimentAIGatewayCostControl": "Enables AI Gateway cost control functionality.", "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", + "ExperimentChatContextPin": "Builds chat prompts from the per-chat pinned workspace context copy instead of the per-turn agent pull.", "ExperimentExample": "This isn't used for anything.", "ExperimentMCPServerHTTP": "Enables the MCP HTTP server functionality.", "ExperimentMinimumImplicitMember": "Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts.", @@ -17578,7 +17580,8 @@ "Enables publishing workspace build updates to the all builds pubsub channel.", "Enables embedded NATS pubsub.", "Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts.", - "Enables AI Gateway cost control functionality." + "Enables AI Gateway cost control functionality.", + "Builds chat prompts from the per-chat pinned workspace context copy instead of the per-turn agent pull." ], "x-enum-varnames": [ "ExperimentExample", @@ -17590,7 +17593,8 @@ "ExperimentWorkspaceBuildUpdates", "ExperimentNATSPubsub", "ExperimentMinimumImplicitMember", - "ExperimentAIGatewayCostControl" + "ExperimentAIGatewayCostControl", + "ExperimentChatContextPin" ] }, "codersdk.ExternalAPIKeyScopes": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 48ae8e9267e63..c7b54335c2990 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -829,6 +829,7 @@ func New(options *Options) *API { AllowBYOKSet: true, AIBridgeTransportFactory: &api.AIBridgeTransportFactory, AIGatewayRoutingEnabled: chatAIGatewayRoutingEnabled, + Experiments: api.Experiments, AlwaysEnableDebugLogs: options.DeploymentValues.AI.Chat.DebugLoggingEnabled.Value(), AgentConn: api.agentProvider.AgentConn, AgentInactiveDisconnectTimeout: api.AgentInactiveDisconnectTimeout, diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 33fc87f33b9d4..bddb6b47995b5 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -208,6 +208,7 @@ type Server struct { aibridgeTransportFactory *atomic.Pointer[aibridge.TransportFactory] aiGatewayRoutingEnabled bool + experiments codersdk.Experiments // Configuration pendingChatAcquireInterval time.Duration @@ -3301,6 +3302,10 @@ type Config struct { Clock quartz.Clock AIBridgeTransportFactory *atomic.Pointer[aibridge.TransportFactory] AIGatewayRoutingEnabled bool + // Experiments are the deployment's enabled experiments. Used to + // gate experimental chat behavior such as the pinned workspace + // context prompt path. + Experiments codersdk.Experiments PrometheusRegistry prometheus.Registerer @@ -3397,6 +3402,7 @@ func New(ps pubsub.Pubsub, cfg Config) *Server { }, aibridgeTransportFactory: cfg.AIBridgeTransportFactory, aiGatewayRoutingEnabled: cfg.AIGatewayRoutingEnabled, + experiments: slices.Clone(cfg.Experiments), pendingChatAcquireInterval: pendingChatAcquireInterval, maxChatsPerAcquire: maxChatsPerAcquire, inFlightChatStaleAfter: inFlightChatStaleAfter, diff --git a/coderd/x/chatd/context_prompt.go b/coderd/x/chatd/context_prompt.go new file mode 100644 index 0000000000000..af3a500a7cfbd --- /dev/null +++ b/coderd/x/chatd/context_prompt.go @@ -0,0 +1,126 @@ +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), the per-chat copy populated at hydrate and +// refresh time. It is the first production reader of that copy, gated behind +// ExperimentChatContextPin. +// +// ok reports whether the caller should use the returned values instead of +// the per-turn, history-derived path. It is false when the experiment is off +// or the chat has no pinned rows, so the caller falls back. 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, +// mirroring the other prompt-input reads in prepareGeneration. +// +// agent is optional decoration: its operating system and directory annotate +// the instruction header. 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) { + if !server.experiments.Enabled(codersdk.ExperimentChatContextPin) { + return "", nil, false, nil + } + + 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 = contextResourcesToPrompt(resources, agent.OperatingSystem, directory) + 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 +} + +// contextResourcesToPrompt converts a chat's pinned context resources into +// the formatted instruction block and workspace skill metadata for the +// system prompt. It is 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 contribute; non-OK statuses, unknown +// body kinds (mcp_config, mcp_server, and the reserved kinds), and malformed +// bodies are skipped. The instruction 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) { + 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 { + 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 { + continue + } + if body.GetName() == "" { + continue + } + // source is the skill directory. MetaFile is left empty so + // chattool falls back to DefaultSkillMetaFile ("SKILL.md"), + // matching 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 + } + return formatSystemInstructions(operatingSystem, directory, contextFileParts), skills +} diff --git a/coderd/x/chatd/context_prompt_internal_test.go b/coderd/x/chatd/context_prompt_internal_test.go new file mode 100644 index 0000000000000..50aec87c44585 --- /dev/null +++ b/coderd/x/chatd/context_prompt_internal_test.go @@ -0,0 +1,383 @@ +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" + + "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, "") + 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, "") + }) + + 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 := contextResourcesToPrompt(resources, "linux", "/home/coder") + + require.Empty(t, skills) + 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("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, "") + 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 TestPinnedWorkspaceContext(t *testing.T) { + t.Parallel() + + newServer := func(t *testing.T, db database.Store, enabled bool) *Server { + t.Helper() + var experiments codersdk.Experiments + if enabled { + experiments = codersdk.Experiments{codersdk.ExperimentChatContextPin} + } + return &Server{ + db: db, + logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug), + experiments: experiments, + } + } + + t.Run("ExperimentDisabled", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + // No ListChatContextResourcesByChatID expectation: the gate must + // short-circuit before touching the database. + server := newServer(t, db, false) + + instruction, skills, ok, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: uuid.New()}, database.WorkspaceAgent{}) + require.NoError(t, err) + require.False(t, ok) + require.Empty(t, instruction) + require.Empty(t, skills) + }) + + 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 := newServer(t, db, true) + + _, _, 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 := newServer(t, db, true) + + 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 := newServer(t, db, true) + + 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 := newServer(t, db, true) + + // 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()}, + }) + build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + WorkspaceID: ws.ID, + TemplateVersionID: tv.ID, + JobID: pj.ID, + Transition: database.WorkspaceTransitionStart, + }) + _ = build + 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) + + serverOn := &Server{db: db, logger: logger, experiments: codersdk.Experiments{codersdk.ExperimentChatContextPin}} + instruction, skills, ok, err := serverOn.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) + + // With the experiment off, the hydrated pin is ignored so the caller + // falls back to the per-turn history path. + serverOff := &Server{db: db, logger: logger} + _, _, ok, err = serverOff.pinnedWorkspaceContext(ctx, chat, agent) + require.NoError(t, err) + require.False(t, ok) +} diff --git a/coderd/x/chatd/generation_preparer.go b/coderd/x/chatd/generation_preparer.go index a0aec403ba279..1a6dedb5c7614 100644 --- a/coderd/x/chatd/generation_preparer.go +++ b/coderd/x/chatd/generation_preparer.go @@ -225,9 +225,25 @@ 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) + + // When the chat-context-pin experiment is enabled and the chat + // has a pinned context copy, build the instruction and skills + // from that copy. The pinned and history paths are mutually + // exclusive: hasContextFiles stays false here so the history + // fallback below does not overwrite the pinned values. + pinnedInstruction, pinnedSkills, ok, pinErr := server.pinnedWorkspaceContext(ctx, chat, agent) + if pinErr != nil { + cleanup() + return generationPrepared{}, xerrors.Errorf("load pinned chat context: %w", pinErr) + } + if ok { + instruction = pinnedInstruction + workspaceSkills = pinnedSkills + } else { + _, found := contextFileAgentID(promptRows) + hasContextFiles = found + } } var g2 errgroup.Group diff --git a/codersdk/deployment.go b/codersdk/deployment.go index f1d91d2897849..9998dc4a29559 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -5154,6 +5154,7 @@ const ( ExperimentNATSPubsub Experiment = "nats_pubsub" // Enables embedded NATS pubsub. ExperimentMinimumImplicitMember Experiment = "minimum-implicit-member" // Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts. ExperimentAIGatewayCostControl Experiment = "ai-gateway-cost-control" // Enables AI Gateway cost control functionality. + ExperimentChatContextPin Experiment = "chat-context-pin" // Builds chat prompts from the per-chat pinned workspace context copy instead of the per-turn agent pull. ) func (e Experiment) DisplayName() string { @@ -5178,6 +5179,8 @@ func (e Experiment) DisplayName() string { return "Gateway Accounts (minimum implicit member)" case ExperimentAIGatewayCostControl: return "AI Gateway Cost Control" + case ExperimentChatContextPin: + return "Chat Context Pin" default: // Split on hyphen and convert to title case // e.g. "mcp-server-http" -> "Mcp Server Http" @@ -5198,6 +5201,7 @@ var ExperimentsKnown = Experiments{ ExperimentWorkspaceBuildUpdates, ExperimentMinimumImplicitMember, ExperimentAIGatewayCostControl, + ExperimentChatContextPin, } // ExperimentsSafe should include all experiments that are safe for diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index d691e36f5f8ad..e716afc5ec45e 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -6999,9 +6999,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o #### Enumerated Values -| Value(s) | -|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ai-gateway-cost-control`, `auto-fill-parameters`, `example`, `mcp-server-http`, `minimum-implicit-member`, `nats_pubsub`, `notifications`, `oauth2`, `workspace-build-updates`, `workspace-usage` | +| Value(s) | +|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ai-gateway-cost-control`, `auto-fill-parameters`, `chat-context-pin`, `example`, `mcp-server-http`, `minimum-implicit-member`, `nats_pubsub`, `notifications`, `oauth2`, `workspace-build-updates`, `workspace-usage` | ## codersdk.ExternalAPIKeyScopes diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a5ced43139ded..48a0c6ad7c3e9 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -4380,6 +4380,7 @@ export const EntitlementsWarningHeader = "X-Coder-Entitlements-Warning"; export type Experiment = | "ai-gateway-cost-control" | "auto-fill-parameters" + | "chat-context-pin" | "example" | "mcp-server-http" | "minimum-implicit-member" @@ -4392,6 +4393,7 @@ export type Experiment = export const Experiments: Experiment[] = [ "ai-gateway-cost-control", "auto-fill-parameters", + "chat-context-pin", "example", "mcp-server-http", "minimum-implicit-member", From b2f7f64c60b745a309e211b18b91c709916c1155 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 17 Jun 2026 16:25:23 +0000 Subject: [PATCH 02/20] fix(coderd/x/chatd): address review on the pinned context reader - Log a warn-level diagnostic when a status-OK pinned resource body fails to decode, so a proto or encoding regression cannot silently drop context (contextResourcesToPrompt now reports a malformed count). - Correct the skill MetaFile comment: the proto carries no meta file name, so a non-default CODER_AGENT_EXP_SKILL_META_FILE is not preserved on this path. - Drop the temporal "first production reader" sentence from the doc comment. - Add agentWorkingDir to consolidate the ExpandedDirectory/Directory fallback that recurred three times in chatd. - Cover the malformed skill body skip and remove a dead test assignment. --- coderd/x/chatd/chatd.go | 10 +--- coderd/x/chatd/context_prompt.go | 47 +++++++++++++------ .../x/chatd/context_prompt_internal_test.go | 38 +++++++++++---- 3 files changed, 64 insertions(+), 31 deletions(-) diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index bddb6b47995b5..7ec14beac728a 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -4704,10 +4704,7 @@ func (p *Server) fetchWorkspaceContext( return nil, nil, nil, false } - directory := loadedAgent.ExpandedDirectory - if directory == "" { - directory = loadedAgent.Directory - } + directory := agentWorkingDir(loadedAgent) // Fetch context configuration from the agent. Parts // arrive pre-populated with context-file and skill entries @@ -4805,10 +4802,7 @@ func (p *Server) persistInstructionFiles( } } } - directory := agent.ExpandedDirectory - if directory == "" { - directory = agent.Directory - } + directory := agentWorkingDir(*agent) contextAPIKeyID, _ := aibridge.DelegatedAPIKeyIDFromContext(ctx) if !hasContent { diff --git a/coderd/x/chatd/context_prompt.go b/coderd/x/chatd/context_prompt.go index af3a500a7cfbd..c5c4ddef9e59a 100644 --- a/coderd/x/chatd/context_prompt.go +++ b/coderd/x/chatd/context_prompt.go @@ -18,11 +18,19 @@ import ( // the reader forward compatible as new body fields are added to the proto. var contextBodyUnmarshalOptions = protojson.UnmarshalOptions{DiscardUnknown: true} +// agentWorkingDir returns the agent's working directory, preferring the +// expanded (tilde and env resolved) form and falling back to the raw value. +func agentWorkingDir(agent database.WorkspaceAgent) string { + if agent.ExpandedDirectory != "" { + return agent.ExpandedDirectory + } + return agent.Directory +} + // pinnedWorkspaceContext builds the system-prompt instruction block and // workspace skills from the chat's pinned context resources // (chat_context_resources), the per-chat copy populated at hydrate and -// refresh time. It is the first production reader of that copy, gated behind -// ExperimentChatContextPin. +// refresh time. It is gated behind ExperimentChatContextPin. // // ok reports whether the caller should use the returned values instead of // the per-turn, history-derived path. It is false when the experiment is off @@ -51,11 +59,17 @@ func (server *Server) pinnedWorkspaceContext( return "", nil, false, nil } - directory := agent.ExpandedDirectory - if directory == "" { - directory = agent.Directory + instruction, skills, malformed := contextResourcesToPrompt(resources, agent.OperatingSystem, agentWorkingDir(agent)) + 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)), + ) } - instruction, skills = contextResourcesToPrompt(resources, agent.OperatingSystem, directory) server.logger.Debug(ctx, "built prompt context from pinned chat resources", slog.F("chat_id", chat.ID), slog.F("resource_count", len(resources)), @@ -73,13 +87,14 @@ func (server *Server) pinnedWorkspaceContext( // operatingSystem and directory annotate the instruction header and are // omitted when empty. Only OK resources contribute; non-OK statuses, unknown // body kinds (mcp_config, mcp_server, and the reserved kinds), and malformed -// bodies are skipped. The instruction 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. +// bodies are skipped. malformed counts OK resources whose body failed to +// decode so the caller can surface an otherwise silent drop. The instruction +// 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) { +) (instruction string, skills []chattool.SkillMeta, malformed int) { var contextFileParts []codersdk.ChatMessagePart for _, r := range resources { if r.Status != database.WorkspaceAgentContextResourceStatusOk { @@ -89,6 +104,7 @@ func contextResourcesToPrompt( case database.WorkspaceAgentContextBodyKindInstructionFile: var body agentproto.InstructionFileBody if err := contextBodyUnmarshalOptions.Unmarshal(r.Body, &body); err != nil { + malformed++ continue } content := SanitizePromptText(string(body.GetContent())) @@ -103,14 +119,17 @@ func contextResourcesToPrompt( 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"), - // matching the per-turn discovery path. + // 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(), @@ -120,7 +139,7 @@ func contextResourcesToPrompt( } if len(contextFileParts) == 0 { - return "", skills + return "", skills, malformed } - return formatSystemInstructions(operatingSystem, directory, contextFileParts), skills + return formatSystemInstructions(operatingSystem, directory, contextFileParts), skills, malformed } diff --git a/coderd/x/chatd/context_prompt_internal_test.go b/coderd/x/chatd/context_prompt_internal_test.go index 50aec87c44585..de8e5b10033fc 100644 --- a/coderd/x/chatd/context_prompt_internal_test.go +++ b/coderd/x/chatd/context_prompt_internal_test.go @@ -65,7 +65,7 @@ func TestContextResourcesToPrompt(t *testing.T) { resources := []database.ChatContextResource{ instructionResource(t, "/home/coder/AGENTS.md", "be helpful", database.WorkspaceAgentContextResourceStatusOk), } - instruction, skills := contextResourcesToPrompt(resources, "linux", "/home/coder") + instruction, skills, _ := contextResourcesToPrompt(resources, "linux", "/home/coder") require.Empty(t, skills) require.Contains(t, instruction, "") @@ -82,7 +82,7 @@ func TestContextResourcesToPrompt(t *testing.T) { resources := []database.ChatContextResource{ skillResource(t, "/home/coder/.coder/skills/deploy", "deploy", "Deploy the app", database.WorkspaceAgentContextResourceStatusOk), } - instruction, skills := contextResourcesToPrompt(resources, "linux", "/home/coder") + instruction, skills, _ := contextResourcesToPrompt(resources, "linux", "/home/coder") // Skill-only pins emit no instruction header. require.Empty(t, instruction) @@ -101,7 +101,7 @@ func TestContextResourcesToPrompt(t *testing.T) { 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") + instruction, skills, _ := contextResourcesToPrompt(resources, "linux", "/home/coder") require.Empty(t, instruction) require.Empty(t, skills) @@ -124,7 +124,7 @@ func TestContextResourcesToPrompt(t *testing.T) { Status: database.WorkspaceAgentContextResourceStatusOk, }, } - instruction, skills := contextResourcesToPrompt(resources, "linux", "/home/coder") + instruction, skills, _ := contextResourcesToPrompt(resources, "linux", "/home/coder") require.Empty(t, instruction) require.Empty(t, skills) @@ -142,18 +142,39 @@ func TestContextResourcesToPrompt(t *testing.T) { }, instructionResource(t, "/home/coder/CLAUDE.md", "good content", database.WorkspaceAgentContextResourceStatusOk), } - instruction, skills := contextResourcesToPrompt(resources, "linux", "/home/coder") + 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("EmptyInput", func(t *testing.T) { t.Parallel() - instruction, skills := contextResourcesToPrompt(nil, "linux", "/home/coder") + instruction, skills, _ := contextResourcesToPrompt(nil, "linux", "/home/coder") require.Empty(t, instruction) require.Empty(t, skills) }) @@ -164,7 +185,7 @@ func TestContextResourcesToPrompt(t *testing.T) { resources := []database.ChatContextResource{ instructionResource(t, "/home/coder/AGENTS.md", "be helpful", database.WorkspaceAgentContextResourceStatusOk), } - instruction, _ := contextResourcesToPrompt(resources, "", "") + instruction, _, _ := contextResourcesToPrompt(resources, "", "") require.Contains(t, instruction, "") require.Contains(t, instruction, "Source: /home/coder/AGENTS.md") @@ -313,13 +334,12 @@ func TestPinnedWorkspaceContextFromHydratedPin(t *testing.T) { OrganizationID: org.ID, CompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now()}, }) - build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ WorkspaceID: ws.ID, TemplateVersionID: tv.ID, JobID: pj.ID, Transition: database.WorkspaceTransitionStart, }) - _ = build res := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{ Transition: database.WorkspaceTransitionStart, JobID: pj.ID, From 22df16f08c6b7d36196f7d7623006b12b495695c Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 17 Jun 2026 16:33:37 +0000 Subject: [PATCH 03/20] refactor(coderd/x/chatd): extract turn context dispatch for test coverage Pull the pinned-vs-history selection out of prepareGeneration into resolveTurnWorkspaceContext so the dispatch is unit-testable: the agent binding refresh stays in prepareGeneration, and the helper takes the resolved agent plus prompt rows. This also drops the unconditional skillsFromParts scan, which the pinned path discarded. Add table tests covering pinned precedence, history fallback when the experiment is off, the empty case, and read-error propagation. --- coderd/x/chatd/context_prompt.go | 34 ++++ .../x/chatd/context_prompt_internal_test.go | 151 +++++++++++++++--- coderd/x/chatd/generation_preparer.go | 25 +-- 3 files changed, 171 insertions(+), 39 deletions(-) diff --git a/coderd/x/chatd/context_prompt.go b/coderd/x/chatd/context_prompt.go index c5c4ddef9e59a..a735ba8b05271 100644 --- a/coderd/x/chatd/context_prompt.go +++ b/coderd/x/chatd/context_prompt.go @@ -79,6 +79,40 @@ func (server *Server) pinnedWorkspaceContext( return instruction, skills, true, nil } +// resolveTurnWorkspaceContext selects the instruction block and workspace +// skills for a turn. It prefers the chat's pinned context copy (gated by +// ExperimentChatContextPin) and falls back to the per-turn, history-derived +// context-file and skill parts. 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. The skill + // scan is skipped unless context files are present, matching the pinned + // path that supplies instruction and skills together. + 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 for the // system prompt. It is the inverse of the protojson bodies written by the diff --git a/coderd/x/chatd/context_prompt_internal_test.go b/coderd/x/chatd/context_prompt_internal_test.go index de8e5b10033fc..723d0d08597fa 100644 --- a/coderd/x/chatd/context_prompt_internal_test.go +++ b/coderd/x/chatd/context_prompt_internal_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/google/uuid" + "github.com/sqlc-dev/pqtype" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "golang.org/x/xerrors" @@ -195,21 +196,19 @@ func TestContextResourcesToPrompt(t *testing.T) { }) } -func TestPinnedWorkspaceContext(t *testing.T) { - t.Parallel() +var pinExperimentEnabled = codersdk.Experiments{codersdk.ExperimentChatContextPin} - newServer := func(t *testing.T, db database.Store, enabled bool) *Server { - t.Helper() - var experiments codersdk.Experiments - if enabled { - experiments = codersdk.Experiments{codersdk.ExperimentChatContextPin} - } - return &Server{ - db: db, - logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug), - experiments: experiments, - } +func newPinServer(t *testing.T, db database.Store, experiments codersdk.Experiments) *Server { + t.Helper() + return &Server{ + db: db, + logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug), + experiments: experiments, } +} + +func TestPinnedWorkspaceContext(t *testing.T) { + t.Parallel() t.Run("ExperimentDisabled", func(t *testing.T) { t.Parallel() @@ -218,7 +217,7 @@ func TestPinnedWorkspaceContext(t *testing.T) { db := dbmock.NewMockStore(ctrl) // No ListChatContextResourcesByChatID expectation: the gate must // short-circuit before touching the database. - server := newServer(t, db, false) + server := newPinServer(t, db, nil) instruction, skills, ok, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: uuid.New()}, database.WorkspaceAgent{}) require.NoError(t, err) @@ -235,7 +234,7 @@ func TestPinnedWorkspaceContext(t *testing.T) { chatID := uuid.New() db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chatID). Return(nil, xerrors.New("boom")) - server := newServer(t, db, true) + server := newPinServer(t, db, pinExperimentEnabled) _, _, ok, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, database.WorkspaceAgent{}) require.Error(t, err) @@ -250,7 +249,7 @@ func TestPinnedWorkspaceContext(t *testing.T) { chatID := uuid.New() db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chatID). Return([]database.ChatContextResource{}, nil) - server := newServer(t, db, true) + server := newPinServer(t, db, pinExperimentEnabled) instruction, skills, ok, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, database.WorkspaceAgent{}) require.NoError(t, err) @@ -270,7 +269,7 @@ func TestPinnedWorkspaceContext(t *testing.T) { 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 := newServer(t, db, true) + server := newPinServer(t, db, pinExperimentEnabled) agent := database.WorkspaceAgent{OperatingSystem: "linux", ExpandedDirectory: "/home/coder"} instruction, skills, ok, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, agent) @@ -293,7 +292,7 @@ func TestPinnedWorkspaceContext(t *testing.T) { Return([]database.ChatContextResource{ instructionResource(t, "/home/coder/AGENTS.md", "be helpful", database.WorkspaceAgentContextResourceStatusOk), }, nil) - server := newServer(t, db, true) + server := newPinServer(t, db, pinExperimentEnabled) // Zero-value agent: the pin still resolves, just without the // OS/directory header. @@ -401,3 +400,119 @@ func TestPinnedWorkspaceContextFromHydratedPin(t *testing.T) { 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: pinned copy when the experiment is on and rows exist, 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, pinExperimentEnabled) + + 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, pinExperimentEnabled) + + // 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("HistoryFallbackWhenExperimentOff", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + // Experiment off: pinnedWorkspaceContext short-circuits before any DB + // call, so no ListChatContextResourcesByChatID expectation is set. + server := newPinServer(t, db, nil) + + agentID := uuid.New() + promptRows := []database.ChatMessage{historyContextMessage(t, agentID)} + instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), workspaceChat(), 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) + server := newPinServer(t, db, nil) + + instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), workspaceChat(), 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, pinExperimentEnabled) + + _, _, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{}, nil) + require.Error(t, err) + }) +} diff --git a/coderd/x/chatd/generation_preparer.go b/coderd/x/chatd/generation_preparer.go index 1a6dedb5c7614..f8c71d2b209fb 100644 --- a/coderd/x/chatd/generation_preparer.go +++ b/coderd/x/chatd/generation_preparer.go @@ -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 @@ -227,22 +225,11 @@ func (server *Server) prepareGeneration( // history; only metadata is mutated here. agent, _ := workspaceCtx.getWorkspaceAgent(ctx) - // When the chat-context-pin experiment is enabled and the chat - // has a pinned context copy, build the instruction and skills - // from that copy. The pinned and history paths are mutually - // exclusive: hasContextFiles stays false here so the history - // fallback below does not overwrite the pinned values. - pinnedInstruction, pinnedSkills, ok, pinErr := server.pinnedWorkspaceContext(ctx, chat, agent) - if pinErr != nil { + var resolveErr error + instruction, workspaceSkills, resolveErr = server.resolveTurnWorkspaceContext(ctx, chat, agent, promptRows) + if resolveErr != nil { cleanup() - return generationPrepared{}, xerrors.Errorf("load pinned chat context: %w", pinErr) - } - if ok { - instruction = pinnedInstruction - workspaceSkills = pinnedSkills - } else { - _, found := contextFileAgentID(promptRows) - hasContextFiles = found + return generationPrepared{}, resolveErr } } @@ -255,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 From 8fa6c3c36efd72cd76839598dfd1545ca753def2 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 17 Jun 2026 17:01:22 +0000 Subject: [PATCH 04/20] docs(coderd/x/chatd): correct context comments from round 2 review - The history fallback comment claimed parity with the pinned path for skill gating, but the pinned path resolves skills independently of instruction presence; describe each path's actual behavior. - Replace the Config.Experiments comment that restated the field with a note on nilability and read-only-after-construction. --- coderd/x/chatd/chatd.go | 4 +--- coderd/x/chatd/context_prompt.go | 6 +++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 7ec14beac728a..d109a94d84bc2 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -3302,9 +3302,7 @@ type Config struct { Clock quartz.Clock AIBridgeTransportFactory *atomic.Pointer[aibridge.TransportFactory] AIGatewayRoutingEnabled bool - // Experiments are the deployment's enabled experiments. Used to - // gate experimental chat behavior such as the pinned workspace - // context prompt path. + // Experiments may be nil. It is read-only after New copies it. Experiments codersdk.Experiments PrometheusRegistry prometheus.Registerer diff --git a/coderd/x/chatd/context_prompt.go b/coderd/x/chatd/context_prompt.go index a735ba8b05271..0ac222d8440e7 100644 --- a/coderd/x/chatd/context_prompt.go +++ b/coderd/x/chatd/context_prompt.go @@ -104,9 +104,9 @@ func (server *Server) resolveTurnWorkspaceContext( } // History fallback: re-derive the instruction and skills from the - // context-file and skill parts the per-turn pull persisted. The skill - // scan is skipped unless context files are present, matching the pinned - // path that supplies instruction and skills together. + // 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 } From a0d65351c8773a40bbeac8606316e3c7eae8396a Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 17 Jun 2026 17:47:14 +0000 Subject: [PATCH 05/20] refactor(coderd/x/chatd): select pinned context by presence not experiment The pinned workspace-context path was gated behind ExperimentChatContextPin. Replace the gate with presence-based selection: when a chat has pinned context rows (the agent reported context through the push system), build the prompt from the pin; otherwise fall back to the per-turn history path. Older agents that never report context get legacy compatibility automatically, with no experiment to enable. Removes ExperimentChatContextPin and the Server/Config experiments field that only fed the gate, and regenerates the API docs and types. --- coderd/apidoc/docs.go | 10 +-- coderd/apidoc/swagger.json | 10 +-- coderd/coderd.go | 1 - coderd/x/chatd/chatd.go | 4 - coderd/x/chatd/context_prompt.go | 28 +++---- .../x/chatd/context_prompt_internal_test.go | 83 +++++++++---------- codersdk/deployment.go | 4 - docs/reference/api/schemas.md | 6 +- site/src/api/typesGenerated.ts | 2 - 9 files changed, 61 insertions(+), 87 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index ac061ae075ba9..24eaec094114b 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -19301,14 +19301,12 @@ const docTemplate = `{ "nats_pubsub", "minimum-implicit-member", "ai-gateway-cost-control", - "agent-app-tabs", - "chat-context-pin" + "agent-app-tabs" ], "x-enum-comments": { "ExperimentAIGatewayCostControl": "Enables AI Gateway cost control functionality.", "ExperimentAgentAppTabs": "Enables workspace-app and port preview tabs in the Coder Agents right panel.", "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", - "ExperimentChatContextPin": "Builds chat prompts from the per-chat pinned workspace context copy instead of the per-turn agent pull.", "ExperimentExample": "This isn't used for anything.", "ExperimentMCPServerHTTP": "Enables the MCP HTTP server functionality.", "ExperimentMinimumImplicitMember": "Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts.", @@ -19329,8 +19327,7 @@ const docTemplate = `{ "Enables embedded NATS pubsub.", "Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts.", "Enables AI Gateway cost control functionality.", - "Enables workspace-app and port preview tabs in the Coder Agents right panel.", - "Builds chat prompts from the per-chat pinned workspace context copy instead of the per-turn agent pull." + "Enables workspace-app and port preview tabs in the Coder Agents right panel." ], "x-enum-varnames": [ "ExperimentExample", @@ -19343,8 +19340,7 @@ const docTemplate = `{ "ExperimentNATSPubsub", "ExperimentMinimumImplicitMember", "ExperimentAIGatewayCostControl", - "ExperimentAgentAppTabs", - "ExperimentChatContextPin" + "ExperimentAgentAppTabs" ] }, "codersdk.ExternalAPIKeyScopes": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 1cc4867456e88..26c4aff908ff3 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -17515,14 +17515,12 @@ "nats_pubsub", "minimum-implicit-member", "ai-gateway-cost-control", - "agent-app-tabs", - "chat-context-pin" + "agent-app-tabs" ], "x-enum-comments": { "ExperimentAIGatewayCostControl": "Enables AI Gateway cost control functionality.", "ExperimentAgentAppTabs": "Enables workspace-app and port preview tabs in the Coder Agents right panel.", "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", - "ExperimentChatContextPin": "Builds chat prompts from the per-chat pinned workspace context copy instead of the per-turn agent pull.", "ExperimentExample": "This isn't used for anything.", "ExperimentMCPServerHTTP": "Enables the MCP HTTP server functionality.", "ExperimentMinimumImplicitMember": "Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts.", @@ -17543,8 +17541,7 @@ "Enables embedded NATS pubsub.", "Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts.", "Enables AI Gateway cost control functionality.", - "Enables workspace-app and port preview tabs in the Coder Agents right panel.", - "Builds chat prompts from the per-chat pinned workspace context copy instead of the per-turn agent pull." + "Enables workspace-app and port preview tabs in the Coder Agents right panel." ], "x-enum-varnames": [ "ExperimentExample", @@ -17557,8 +17554,7 @@ "ExperimentNATSPubsub", "ExperimentMinimumImplicitMember", "ExperimentAIGatewayCostControl", - "ExperimentAgentAppTabs", - "ExperimentChatContextPin" + "ExperimentAgentAppTabs" ] }, "codersdk.ExternalAPIKeyScopes": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 5355f629dd80b..601669d321076 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -829,7 +829,6 @@ func New(options *Options) *API { AllowBYOKSet: true, AIBridgeTransportFactory: &api.AIBridgeTransportFactory, AIGatewayRoutingEnabled: chatAIGatewayRoutingEnabled, - Experiments: api.Experiments, AlwaysEnableDebugLogs: options.DeploymentValues.AI.Chat.DebugLoggingEnabled.Value(), AgentConn: api.agentProvider.AgentConn, AgentInactiveDisconnectTimeout: api.AgentInactiveDisconnectTimeout, diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index bb2d14866fcd4..435cec6ee3905 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -210,7 +210,6 @@ type Server struct { aibridgeTransportFactory *atomic.Pointer[aibridge.TransportFactory] aiGatewayRoutingEnabled bool - experiments codersdk.Experiments // Configuration pendingChatAcquireInterval time.Duration @@ -3304,8 +3303,6 @@ type Config struct { Clock quartz.Clock AIBridgeTransportFactory *atomic.Pointer[aibridge.TransportFactory] AIGatewayRoutingEnabled bool - // Experiments may be nil. It is read-only after New copies it. - Experiments codersdk.Experiments PrometheusRegistry prometheus.Registerer @@ -3402,7 +3399,6 @@ func New(ps pubsub.Pubsub, cfg Config) *Server { }, aibridgeTransportFactory: cfg.AIBridgeTransportFactory, aiGatewayRoutingEnabled: cfg.AIGatewayRoutingEnabled, - experiments: slices.Clone(cfg.Experiments), pendingChatAcquireInterval: pendingChatAcquireInterval, maxChatsPerAcquire: maxChatsPerAcquire, inFlightChatStaleAfter: inFlightChatStaleAfter, diff --git a/coderd/x/chatd/context_prompt.go b/coderd/x/chatd/context_prompt.go index 0ac222d8440e7..c0c6539260793 100644 --- a/coderd/x/chatd/context_prompt.go +++ b/coderd/x/chatd/context_prompt.go @@ -30,14 +30,15 @@ func agentWorkingDir(agent database.WorkspaceAgent) string { // pinnedWorkspaceContext builds the system-prompt instruction block and // workspace skills from the chat's pinned context resources // (chat_context_resources), the per-chat copy populated at hydrate and -// refresh time. It is gated behind ExperimentChatContextPin. +// refresh time once the workspace agent has reported context. // // ok reports whether the caller should use the returned values instead of -// the per-turn, history-derived path. It is false when the experiment is off -// or the chat has no pinned rows, so the caller falls back. 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, -// mirroring the other prompt-input reads in prepareGeneration. +// the per-turn, history-derived path. It is false when the chat has no +// pinned rows, as happens for 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, mirroring the other prompt-input reads in prepareGeneration. // // agent is optional decoration: its operating system and directory annotate // the instruction header. An unresolved (zero-value) agent does not force a @@ -47,10 +48,6 @@ func (server *Server) pinnedWorkspaceContext( chat database.Chat, agent database.WorkspaceAgent, ) (instruction string, skills []chattool.SkillMeta, ok bool, err error) { - if !server.experiments.Enabled(codersdk.ExperimentChatContextPin) { - return "", nil, false, nil - } - resources, err := server.db.ListChatContextResourcesByChatID(ctx, chat.ID) if err != nil { return "", nil, false, xerrors.Errorf("list chat context resources: %w", err) @@ -80,11 +77,12 @@ func (server *Server) pinnedWorkspaceContext( } // resolveTurnWorkspaceContext selects the instruction block and workspace -// skills for a turn. It prefers the chat's pinned context copy (gated by -// ExperimentChatContextPin) and falls back to the per-turn, history-derived -// context-file and skill parts. 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. +// 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, diff --git a/coderd/x/chatd/context_prompt_internal_test.go b/coderd/x/chatd/context_prompt_internal_test.go index 723d0d08597fa..faeaf01d3740b 100644 --- a/coderd/x/chatd/context_prompt_internal_test.go +++ b/coderd/x/chatd/context_prompt_internal_test.go @@ -196,36 +196,17 @@ func TestContextResourcesToPrompt(t *testing.T) { }) } -var pinExperimentEnabled = codersdk.Experiments{codersdk.ExperimentChatContextPin} - -func newPinServer(t *testing.T, db database.Store, experiments codersdk.Experiments) *Server { +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), - experiments: experiments, + db: db, + logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug), } } func TestPinnedWorkspaceContext(t *testing.T) { t.Parallel() - t.Run("ExperimentDisabled", func(t *testing.T) { - t.Parallel() - - ctrl := gomock.NewController(t) - db := dbmock.NewMockStore(ctrl) - // No ListChatContextResourcesByChatID expectation: the gate must - // short-circuit before touching the database. - server := newPinServer(t, db, nil) - - instruction, skills, ok, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: uuid.New()}, database.WorkspaceAgent{}) - require.NoError(t, err) - require.False(t, ok) - require.Empty(t, instruction) - require.Empty(t, skills) - }) - t.Run("ListError", func(t *testing.T) { t.Parallel() @@ -234,7 +215,7 @@ func TestPinnedWorkspaceContext(t *testing.T) { chatID := uuid.New() db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chatID). Return(nil, xerrors.New("boom")) - server := newPinServer(t, db, pinExperimentEnabled) + server := newPinServer(t, db) _, _, ok, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, database.WorkspaceAgent{}) require.Error(t, err) @@ -249,7 +230,7 @@ func TestPinnedWorkspaceContext(t *testing.T) { chatID := uuid.New() db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chatID). Return([]database.ChatContextResource{}, nil) - server := newPinServer(t, db, pinExperimentEnabled) + server := newPinServer(t, db) instruction, skills, ok, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, database.WorkspaceAgent{}) require.NoError(t, err) @@ -269,7 +250,7 @@ func TestPinnedWorkspaceContext(t *testing.T) { 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, pinExperimentEnabled) + 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) @@ -292,7 +273,7 @@ func TestPinnedWorkspaceContext(t *testing.T) { Return([]database.ChatContextResource{ instructionResource(t, "/home/coder/AGENTS.md", "be helpful", database.WorkspaceAgentContextResourceStatusOk), }, nil) - server := newPinServer(t, db, pinExperimentEnabled) + server := newPinServer(t, db) // Zero-value agent: the pin still resolves, just without the // OS/directory header. @@ -379,9 +360,9 @@ func TestPinnedWorkspaceContextFromHydratedPin(t *testing.T) { 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} - serverOn := &Server{db: db, logger: logger, experiments: codersdk.Experiments{codersdk.ExperimentChatContextPin}} - instruction, skills, ok, err := serverOn.pinnedWorkspaceContext(ctx, chat, agent) + instruction, skills, ok, err := server.pinnedWorkspaceContext(ctx, chat, agent) require.NoError(t, err) require.True(t, ok) require.Contains(t, instruction, "Operating System: linux") @@ -393,10 +374,18 @@ func TestPinnedWorkspaceContextFromHydratedPin(t *testing.T) { require.Equal(t, "Deploy the app", skills[0].Description) require.Equal(t, "/home/coder/ws/.coder/skills/deploy", skills[0].Dir) - // With the experiment off, the hydrated pin is ignored so the caller - // falls back to the per-turn history path. - serverOff := &Server{db: db, logger: logger} - _, _, ok, err = serverOff.pinnedWorkspaceContext(ctx, chat, agent) + // 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) } @@ -425,8 +414,8 @@ func historyContextMessage(t *testing.T, agentID uuid.UUID) database.ChatMessage } // TestResolveTurnWorkspaceContext covers the dispatch that prepareGeneration -// wires up: pinned copy when the experiment is on and rows exist, otherwise -// the per-turn history-derived parts, and nothing for a non-workspace chat. +// 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() @@ -439,7 +428,7 @@ func TestResolveTurnWorkspaceContext(t *testing.T) { ctrl := gomock.NewController(t) db := dbmock.NewMockStore(ctrl) - server := newPinServer(t, db, pinExperimentEnabled) + server := newPinServer(t, db) instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), database.Chat{ID: uuid.New()}, database.WorkspaceAgent{}, nil) require.NoError(t, err) @@ -459,7 +448,7 @@ func TestResolveTurnWorkspaceContext(t *testing.T) { 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, pinExperimentEnabled) + server := newPinServer(t, db) // History rows are present too; the pinned path must take precedence. promptRows := []database.ChatMessage{historyContextMessage(t, agentID)} @@ -471,18 +460,20 @@ func TestResolveTurnWorkspaceContext(t *testing.T) { require.Equal(t, "deploy", skills[0].Name) }) - t.Run("HistoryFallbackWhenExperimentOff", func(t *testing.T) { + t.Run("HistoryFallbackWhenNoPin", func(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) db := dbmock.NewMockStore(ctrl) - // Experiment off: pinnedWorkspaceContext short-circuits before any DB - // call, so no ListChatContextResourcesByChatID expectation is set. - server := newPinServer(t, db, nil) + 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(), workspaceChat(), database.WorkspaceAgent{}, promptRows) + 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) @@ -494,9 +485,13 @@ func TestResolveTurnWorkspaceContext(t *testing.T) { ctrl := gomock.NewController(t) db := dbmock.NewMockStore(ctrl) - server := newPinServer(t, db, nil) + 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(), workspaceChat(), database.WorkspaceAgent{}, nil) + instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{}, nil) require.NoError(t, err) require.Empty(t, instruction) require.Empty(t, skills) @@ -510,7 +505,7 @@ func TestResolveTurnWorkspaceContext(t *testing.T) { chat := workspaceChat() db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chat.ID). Return(nil, xerrors.New("boom")) - server := newPinServer(t, db, pinExperimentEnabled) + server := newPinServer(t, db) _, _, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{}, nil) require.Error(t, err) diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 9af86fbf5ec84..392270235beb9 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -5155,7 +5155,6 @@ const ( ExperimentMinimumImplicitMember Experiment = "minimum-implicit-member" // Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts. ExperimentAIGatewayCostControl Experiment = "ai-gateway-cost-control" // Enables AI Gateway cost control functionality. ExperimentAgentAppTabs Experiment = "agent-app-tabs" // Enables workspace-app and port preview tabs in the Coder Agents right panel. - ExperimentChatContextPin Experiment = "chat-context-pin" // Builds chat prompts from the per-chat pinned workspace context copy instead of the per-turn agent pull. ) func (e Experiment) DisplayName() string { @@ -5182,8 +5181,6 @@ func (e Experiment) DisplayName() string { return "AI Gateway Cost Control" case ExperimentAgentAppTabs: return "Coder Agents App and Port Tabs" - case ExperimentChatContextPin: - return "Chat Context Pin" default: // Split on hyphen and convert to title case // e.g. "mcp-server-http" -> "Mcp Server Http" @@ -5205,7 +5202,6 @@ var ExperimentsKnown = Experiments{ ExperimentMinimumImplicitMember, ExperimentAIGatewayCostControl, ExperimentAgentAppTabs, - ExperimentChatContextPin, } // ExperimentsSafe should include all experiments that are safe for diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 2bd2caa76502b..8998babc87da7 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -6999,9 +6999,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o #### Enumerated Values -| Value(s) | -|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `agent-app-tabs`, `ai-gateway-cost-control`, `auto-fill-parameters`, `chat-context-pin`, `example`, `mcp-server-http`, `minimum-implicit-member`, `nats_pubsub`, `notifications`, `oauth2`, `workspace-build-updates`, `workspace-usage` | +| Value(s) | +|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `agent-app-tabs`, `ai-gateway-cost-control`, `auto-fill-parameters`, `example`, `mcp-server-http`, `minimum-implicit-member`, `nats_pubsub`, `notifications`, `oauth2`, `workspace-build-updates`, `workspace-usage` | ## codersdk.ExternalAPIKeyScopes diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 53cdd844d314f..9b580dfec6cc7 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -4381,7 +4381,6 @@ export type Experiment = | "ai-gateway-cost-control" | "agent-app-tabs" | "auto-fill-parameters" - | "chat-context-pin" | "example" | "mcp-server-http" | "minimum-implicit-member" @@ -4395,7 +4394,6 @@ export const Experiments: Experiment[] = [ "ai-gateway-cost-control", "agent-app-tabs", "auto-fill-parameters", - "chat-context-pin", "example", "mcp-server-http", "minimum-implicit-member", From 4735aa440bfdd85f7673a8031ace94aa5e827aa1 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 17 Jun 2026 18:20:59 +0000 Subject: [PATCH 06/20] test(coderd/x/chatd): cover empty skill-name and instruction-content guards Adds the two defensive-branch tests from review CRF-10: an OK skill resource with an empty name, and a whitespace-only instruction body, both filter out without being counted as malformed. Also tightens the pinnedWorkspaceContext and contextResourcesToPrompt doc comments per the verbosity note, trimming restatement while keeping the ok-semantics, malformed-count, and MetaFile-divergence invariants. --- coderd/x/chatd/context_prompt.go | 40 +++++++++---------- .../x/chatd/context_prompt_internal_test.go | 31 ++++++++++++++ 2 files changed, 50 insertions(+), 21 deletions(-) diff --git a/coderd/x/chatd/context_prompt.go b/coderd/x/chatd/context_prompt.go index c0c6539260793..1588ea51438ad 100644 --- a/coderd/x/chatd/context_prompt.go +++ b/coderd/x/chatd/context_prompt.go @@ -29,20 +29,19 @@ func agentWorkingDir(agent database.WorkspaceAgent) string { // pinnedWorkspaceContext builds the system-prompt instruction block and // workspace skills from the chat's pinned context resources -// (chat_context_resources), the per-chat copy populated at hydrate and -// refresh time once the workspace agent has reported context. +// (chat_context_resources), populated at hydrate and refresh time. // -// ok reports whether the caller should use the returned values instead of -// the per-turn, history-derived path. It is false when the chat has no -// pinned rows, as happens for 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, mirroring the other prompt-input reads in prepareGeneration. +// 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 is optional decoration: its operating system and directory annotate -// the instruction header. An unresolved (zero-value) agent does not force a -// fallback, so the pin keeps working when the workspace is unreachable. +// 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, @@ -112,17 +111,16 @@ func (server *Server) resolveTurnWorkspaceContext( } // contextResourcesToPrompt converts a chat's pinned context resources into -// the formatted instruction block and workspace skill metadata for the -// system prompt. It is the inverse of the protojson bodies written by the -// agent context push. +// 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 contribute; non-OK statuses, unknown -// body kinds (mcp_config, mcp_server, and the reserved 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 instruction -// 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. +// 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, diff --git a/coderd/x/chatd/context_prompt_internal_test.go b/coderd/x/chatd/context_prompt_internal_test.go index faeaf01d3740b..b381366570fec 100644 --- a/coderd/x/chatd/context_prompt_internal_test.go +++ b/coderd/x/chatd/context_prompt_internal_test.go @@ -172,6 +172,37 @@ func TestContextResourcesToPrompt(t *testing.T) { 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() From 2d8c30c20ae05da2ef0e4a8cc1bb96fd6e6594af Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 17 Jun 2026 18:49:09 +0000 Subject: [PATCH 07/20] refactor(coderd/x/chatd): inline agentWorkingDir at its call sites Drops the agentWorkingDir helper and restores the inline ExpandedDirectory-or-Directory fallback at its three call sites. --- coderd/x/chatd/chatd.go | 10 ++++++++-- coderd/x/chatd/context_prompt.go | 15 +++++---------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 435cec6ee3905..0473f554f16e7 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -4707,7 +4707,10 @@ func (p *Server) fetchWorkspaceContext( return nil, nil, nil, false } - directory := agentWorkingDir(loadedAgent) + directory := loadedAgent.ExpandedDirectory + if directory == "" { + directory = loadedAgent.Directory + } // Fetch context configuration from the agent. Parts // arrive pre-populated with context-file and skill entries @@ -4805,7 +4808,10 @@ func (p *Server) persistInstructionFiles( } } } - directory := agentWorkingDir(*agent) + directory := agent.ExpandedDirectory + if directory == "" { + directory = agent.Directory + } contextAPIKeyID, _ := aibridge.DelegatedAPIKeyIDFromContext(ctx) if !hasContent { diff --git a/coderd/x/chatd/context_prompt.go b/coderd/x/chatd/context_prompt.go index 1588ea51438ad..140055e6b2ee9 100644 --- a/coderd/x/chatd/context_prompt.go +++ b/coderd/x/chatd/context_prompt.go @@ -18,15 +18,6 @@ import ( // the reader forward compatible as new body fields are added to the proto. var contextBodyUnmarshalOptions = protojson.UnmarshalOptions{DiscardUnknown: true} -// agentWorkingDir returns the agent's working directory, preferring the -// expanded (tilde and env resolved) form and falling back to the raw value. -func agentWorkingDir(agent database.WorkspaceAgent) string { - if agent.ExpandedDirectory != "" { - return agent.ExpandedDirectory - } - return agent.Directory -} - // 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. @@ -55,7 +46,11 @@ func (server *Server) pinnedWorkspaceContext( return "", nil, false, nil } - instruction, skills, malformed := contextResourcesToPrompt(resources, agent.OperatingSystem, agentWorkingDir(agent)) + 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 From cf94945edab2df4f52f6d86ba88f80fa49995822 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 17 Jun 2026 20:25:56 +0000 Subject: [PATCH 08/20] feat(coderd/chatd): surface pinned context, drift, and a context diff Extend the workspace-context feature so a chat shows the context it is actually pinned to, flags when that context has drifted from the agent's latest snapshot, and lets the user inspect the edits and refresh. Backend - codersdk.ChatContext gains `resources` (pinned instruction files + skills, metadata only) and `changes` (per-source diff vs the agent's latest snapshot: added/removed/modified, with sanitized old/new bodies for instruction files capped at 64KiB/side; name+description for skills). - chatd.Server.ContextDetail computes both on read; the single-chat GET handler attaches them like files/diff_status, while list and watch payloads stay lightweight. Nothing is persisted. Body decoders are factored out of contextResourcesToPrompt so reported resources match what the prompt injects. Frontend - The context ring shows a yellow drift triangle (distinct error treatment), drives its file/skill list from the pinned resources (falling back to last_injected_context), and adds a "Refresh context" action plus a "View changes" dialog that renders instruction-file edits via the existing DiffViewer and lists skill changes. - experimental.refreshChatContext + a refreshChatContext mutation; mergeWatchedChatSummary now carries context so context_dirty watch events update the cache, and the open chat is refetched to pull the change set. Tests: diff/resource computation and ContextDetail gating (Go unit), the GET resources/changes end to end (integration), the context_dirty cache merge (unit), and indicator stories (clean/dirty/error play). --- coderd/apidoc/docs.go | 90 +++ coderd/apidoc/swagger.json | 83 +++ coderd/exp_chats.go | 19 + coderd/x/chatd/context_detail.go | 240 ++++++++ .../x/chatd/context_detail_internal_test.go | 371 ++++++++++++ coderd/x/chatd/context_integration_test.go | 49 ++ coderd/x/chatd/context_prompt.go | 30 +- codersdk/chats.go | 64 ++ docs/reference/api/chats.md | 564 +++++++++++++----- docs/reference/api/schemas.md | 176 +++++- site/src/api/api.ts | 11 + site/src/api/queries/chats.test.ts | 52 ++ site/src/api/queries/chats.ts | 47 +- site/src/api/typesGenerated.ts | 81 +++ site/src/pages/AgentsPage/AgentChatPage.tsx | 1 + .../pages/AgentsPage/AgentChatPageView.tsx | 3 + site/src/pages/AgentsPage/AgentsPage.tsx | 12 + .../AgentsPage/components/AgentChatInput.tsx | 13 +- .../components/ChatElements/tools/utils.ts | 19 + .../AgentsPage/components/ChatPageContent.tsx | 33 +- .../components/ContextChangesDialog.tsx | 131 ++++ .../ContextUsageIndicator.stories.tsx | 108 ++++ .../components/ContextUsageIndicator.tsx | 252 +++++--- site/src/testHelpers/chatEntities.ts | 55 ++ 24 files changed, 2280 insertions(+), 224 deletions(-) create mode 100644 coderd/x/chatd/context_detail.go create mode 100644 coderd/x/chatd/context_detail_internal_test.go create mode 100644 site/src/pages/AgentsPage/components/ContextChangesDialog.tsx create mode 100644 site/src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 24eaec094114b..f319de3878433 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -16608,6 +16608,13 @@ const docTemplate = `{ "codersdk.ChatContext": { "type": "object", "properties": { + "changes": { + "description": "Changes lists how the pinned context differs from the agent's latest\nsnapshot, by source. It is populated only on the single-chat GET\nresponse and only while the chat is dirty; otherwise nil.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatContextResourceChange" + } + }, "dirty": { "description": "Dirty is true when the agent's latest snapshot hash differs from the\nchat's pinned hash.", "type": "boolean" @@ -16620,9 +16627,92 @@ const docTemplate = `{ "error": { "description": "Error is the snapshot-level error copied from the pinned snapshot\n(empty when healthy).", "type": "string" + }, + "resources": { + "description": "Resources is the chat's pinned context (instruction files and\nskills) the prompt is built from, metadata only (no bodies). It is\npopulated only on the single-chat GET response; list and watch\npayloads leave it nil to stay lightweight.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatContextResource" + } + } + } + }, + "codersdk.ChatContextResource": { + "type": "object", + "properties": { + "kind": { + "$ref": "#/definitions/codersdk.ChatContextResourceKind" + }, + "size_bytes": { + "description": "SizeBytes is the original payload size in bytes.", + "type": "integer" + }, + "skill_description": { + "type": "string" + }, + "skill_name": { + "description": "SkillName and SkillDescription are populated only for skill kinds.", + "type": "string" + }, + "source": { + "description": "Source is the resource locator: the canonical file path for an\ninstruction file, or the skill directory for a skill.", + "type": "string" + } + } + }, + "codersdk.ChatContextResourceChange": { + "type": "object", + "properties": { + "kind": { + "$ref": "#/definitions/codersdk.ChatContextResourceKind" + }, + "new_content": { + "type": "string" + }, + "old_content": { + "description": "OldContent and NewContent carry the sanitized instruction-file bodies\nfor the pinned and snapshot sides, capped for display. Removed changes\nfill OldContent only, added changes fill NewContent only, and modified\nchanges fill both. Empty for skills.", + "type": "string" + }, + "skill_description": { + "type": "string" + }, + "skill_name": { + "description": "SkillName and SkillDescription identify a changed skill: the snapshot\nside for added/modified, the pinned side for removed. Empty for\ninstruction files.", + "type": "string" + }, + "source": { + "description": "Source is the resource locator that differs.", + "type": "string" + }, + "status": { + "$ref": "#/definitions/codersdk.ChatContextResourceChangeStatus" } } }, + "codersdk.ChatContextResourceChangeStatus": { + "type": "string", + "enum": [ + "added", + "removed", + "modified" + ], + "x-enum-varnames": [ + "ChatContextResourceChangeStatusAdded", + "ChatContextResourceChangeStatusRemoved", + "ChatContextResourceChangeStatusModified" + ] + }, + "codersdk.ChatContextResourceKind": { + "type": "string", + "enum": [ + "instruction_file", + "skill" + ], + "x-enum-varnames": [ + "ChatContextResourceKindInstructionFile", + "ChatContextResourceKindSkill" + ] + }, "codersdk.ChatDiffContents": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 26c4aff908ff3..198154eed418d 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -14910,6 +14910,13 @@ "codersdk.ChatContext": { "type": "object", "properties": { + "changes": { + "description": "Changes lists how the pinned context differs from the agent's latest\nsnapshot, by source. It is populated only on the single-chat GET\nresponse and only while the chat is dirty; otherwise nil.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatContextResourceChange" + } + }, "dirty": { "description": "Dirty is true when the agent's latest snapshot hash differs from the\nchat's pinned hash.", "type": "boolean" @@ -14922,9 +14929,85 @@ "error": { "description": "Error is the snapshot-level error copied from the pinned snapshot\n(empty when healthy).", "type": "string" + }, + "resources": { + "description": "Resources is the chat's pinned context (instruction files and\nskills) the prompt is built from, metadata only (no bodies). It is\npopulated only on the single-chat GET response; list and watch\npayloads leave it nil to stay lightweight.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatContextResource" + } + } + } + }, + "codersdk.ChatContextResource": { + "type": "object", + "properties": { + "kind": { + "$ref": "#/definitions/codersdk.ChatContextResourceKind" + }, + "size_bytes": { + "description": "SizeBytes is the original payload size in bytes.", + "type": "integer" + }, + "skill_description": { + "type": "string" + }, + "skill_name": { + "description": "SkillName and SkillDescription are populated only for skill kinds.", + "type": "string" + }, + "source": { + "description": "Source is the resource locator: the canonical file path for an\ninstruction file, or the skill directory for a skill.", + "type": "string" } } }, + "codersdk.ChatContextResourceChange": { + "type": "object", + "properties": { + "kind": { + "$ref": "#/definitions/codersdk.ChatContextResourceKind" + }, + "new_content": { + "type": "string" + }, + "old_content": { + "description": "OldContent and NewContent carry the sanitized instruction-file bodies\nfor the pinned and snapshot sides, capped for display. Removed changes\nfill OldContent only, added changes fill NewContent only, and modified\nchanges fill both. Empty for skills.", + "type": "string" + }, + "skill_description": { + "type": "string" + }, + "skill_name": { + "description": "SkillName and SkillDescription identify a changed skill: the snapshot\nside for added/modified, the pinned side for removed. Empty for\ninstruction files.", + "type": "string" + }, + "source": { + "description": "Source is the resource locator that differs.", + "type": "string" + }, + "status": { + "$ref": "#/definitions/codersdk.ChatContextResourceChangeStatus" + } + } + }, + "codersdk.ChatContextResourceChangeStatus": { + "type": "string", + "enum": ["added", "removed", "modified"], + "x-enum-varnames": [ + "ChatContextResourceChangeStatusAdded", + "ChatContextResourceChangeStatusRemoved", + "ChatContextResourceChangeStatusModified" + ] + }, + "codersdk.ChatContextResourceKind": { + "type": "string", + "enum": ["instruction_file", "skill"], + "x-enum-varnames": [ + "ChatContextResourceKindInstructionFile", + "ChatContextResourceKindSkill" + ] + }, "codersdk.ChatDiffContents": { "type": "object", "properties": { diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 5962ef3319fea..c8fc557761d4f 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -2042,6 +2042,25 @@ func (api *API) getChat(rw http.ResponseWriter, r *http.Request) { sdkChat := db2sdk.Chat(chat, diffStatus, chatFiles) + // Enrich the lightweight context summary with the chat's pinned + // resources and, when it has drifted, the change set against the + // agent's latest snapshot. This detail is computed on read and only + // attached on the single-chat GET; list and watch payloads stay + // lightweight. A failure here is non-fatal: the chat is still usable + // without the detail, so we log and return the rest of the response. + if sdkChat.Context != nil && api.chatDaemon != nil { + resources, changes, err := api.chatDaemon.ContextDetail(ctx, chat) + if err != nil { + api.Logger.Error(ctx, "failed to compute chat context detail", + slog.F("chat_id", chat.ID), + slog.Error(err), + ) + } else { + sdkChat.Context.Resources = resources + sdkChat.Context.Changes = changes + } + } + // For root chats, embed children so callers get a complete // tree in a single response. if !chat.ParentChatID.Valid { diff --git a/coderd/x/chatd/context_detail.go b/coderd/x/chatd/context_detail.go new file mode 100644 index 0000000000000..a21067ebf7f78 --- /dev/null +++ b/coderd/x/chatd/context_detail.go @@ -0,0 +1,240 @@ +package chatd + +import ( + "bytes" + "context" + "encoding/json" + "sort" + "unicode/utf8" + + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/codersdk" +) + +// maxContextChangeContentBytes caps each side of an instruction-file change so +// the single-chat GET response stays bounded. The agent push admits resource +// bodies up to 256KiB; for the on-read diff we surface at most this many bytes +// per side, truncated on a rune boundary. +const maxContextChangeContentBytes = 64 * 1024 + +// ContextDetail computes the chat's pinned context resource list and, when the +// chat has drifted, the per-source change set against the agent's latest +// pushed snapshot. It is read-only and intended for the single-chat GET +// handler; list and watch payloads omit this detail to stay lightweight. +// +// resources mirrors the prompt-injection rules so it equals the context the +// model actually sees: OK instruction files with non-empty content and OK +// skills with a name. changes is nil unless the chat is dirty (and has a +// resolvable agent), so the second read is only paid for when it can differ. +func (server *Server) ContextDetail( + ctx context.Context, + chat database.Chat, +) (resources []codersdk.ChatContextResource, changes []codersdk.ChatContextResourceChange, err error) { + pinned, err := server.db.ListChatContextResourcesByChatID(ctx, chat.ID) + if err != nil { + return nil, nil, xerrors.Errorf("list chat context resources: %w", err) + } + resources = pinnedContextResources(pinned) + + if !chat.ContextDirtySince.Valid || !chat.AgentID.Valid { + return resources, nil, nil + } + snapshot, err := server.db.ListWorkspaceAgentContextResources(ctx, chat.AgentID.UUID) + if err != nil { + return nil, nil, xerrors.Errorf("list workspace agent context resources: %w", err) + } + changes = diffContextResources(pinned, snapshot) + server.logger.Debug(ctx, "computed chat context detail", + slog.F("chat_id", chat.ID), + slog.F("resource_count", len(resources)), + slog.F("change_count", len(changes)), + ) + return resources, changes, nil +} + +// pinnedContextResources converts a chat's pinned context rows into the +// metadata-only resource list reported on the chat. It applies the same +// inclusion rules as contextResourcesToPrompt so the list equals the context +// the prompt is built from: OK instruction files with non-empty (sanitized) +// content and OK skills with a name. Other kinds and statuses are skipped. +// Input order (source ASC from the query) is preserved. +func pinnedContextResources(resources []database.ChatContextResource) []codersdk.ChatContextResource { + var out []codersdk.ChatContextResource + for _, r := range resources { + if r.Status != database.WorkspaceAgentContextResourceStatusOk { + continue + } + switch r.BodyKind { + case database.WorkspaceAgentContextBodyKindInstructionFile: + body, ok := decodeInstructionFileBody(r.Body) + if !ok || SanitizePromptText(string(body.GetContent())) == "" { + continue + } + out = append(out, codersdk.ChatContextResource{ + Source: r.Source, + Kind: codersdk.ChatContextResourceKindInstructionFile, + SizeBytes: r.SizeBytes, + }) + case database.WorkspaceAgentContextBodyKindSkill: + body, ok := decodeSkillMetaBody(r.Body) + if !ok || body.GetName() == "" { + continue + } + out = append(out, codersdk.ChatContextResource{ + Source: r.Source, + Kind: codersdk.ChatContextResourceKindSkill, + SizeBytes: r.SizeBytes, + SkillName: body.GetName(), + SkillDescription: body.GetDescription(), + }) + } + } + return out +} + +// contextResourceSide is the subset of a context resource row needed to diff +// one source across the pinned copy and the agent snapshot. +type contextResourceSide struct { + kind database.WorkspaceAgentContextBodyKind + body json.RawMessage + contentHash []byte +} + +// diffContextResources compares a chat's pinned context against the agent's +// latest snapshot, by source, and returns the changes among prompt kinds +// (instruction files and skills). A source present on both sides with an equal +// content hash is unchanged and omitted; a differing hash is modified; +// pinned-only is removed; snapshot-only is added. Output is ordered by source. +func diffContextResources( + pinned []database.ChatContextResource, + snapshot []database.WorkspaceAgentContextResource, +) []codersdk.ChatContextResourceChange { + pinnedBySource := make(map[string]contextResourceSide, len(pinned)) + for _, r := range pinned { + pinnedBySource[r.Source] = contextResourceSide{kind: r.BodyKind, body: r.Body, contentHash: r.ContentHash} + } + snapshotBySource := make(map[string]contextResourceSide, len(snapshot)) + sources := make([]string, 0, len(pinned)+len(snapshot)) + for _, r := range pinned { + sources = append(sources, r.Source) + } + for _, r := range snapshot { + if _, ok := pinnedBySource[r.Source]; !ok { + sources = append(sources, r.Source) + } + snapshotBySource[r.Source] = contextResourceSide{kind: r.BodyKind, body: r.Body, contentHash: r.ContentHash} + } + sort.Strings(sources) + + var changes []codersdk.ChatContextResourceChange + for _, source := range sources { + pinnedSide, hasPinned := pinnedBySource[source] + snapshotSide, hasSnapshot := snapshotBySource[source] + switch { + case hasPinned && hasSnapshot: + if bytes.Equal(pinnedSide.contentHash, snapshotSide.contentHash) { + continue + } + if change, ok := buildResourceChange(source, codersdk.ChatContextResourceChangeStatusModified, &pinnedSide, &snapshotSide); ok { + changes = append(changes, change) + } + case hasPinned: + if change, ok := buildResourceChange(source, codersdk.ChatContextResourceChangeStatusRemoved, &pinnedSide, nil); ok { + changes = append(changes, change) + } + case hasSnapshot: + if change, ok := buildResourceChange(source, codersdk.ChatContextResourceChangeStatusAdded, nil, &snapshotSide); ok { + changes = append(changes, change) + } + } + } + return changes +} + +// buildResourceChange assembles a change entry for one source. The reported +// kind comes from the side that exists now (snapshot for added/modified, +// pinned for removed); ok is false when that side is not a prompt kind, so +// unrelated resource kinds (e.g. MCP config) are skipped. Instruction-file +// changes carry the sanitized, capped bodies of whichever sides are present; +// skill changes carry the identifying name and description. +func buildResourceChange( + source string, + status codersdk.ChatContextResourceChangeStatus, + pinned, snapshot *contextResourceSide, +) (codersdk.ChatContextResourceChange, bool) { + current := snapshot + if current == nil { + current = pinned + } + kind, ok := promptResourceKind(current.kind) + if !ok { + return codersdk.ChatContextResourceChange{}, false + } + + change := codersdk.ChatContextResourceChange{ + Source: source, + Kind: kind, + Status: status, + } + switch kind { + case codersdk.ChatContextResourceKindInstructionFile: + if pinned != nil { + change.OldContent = cappedInstructionContent(pinned.body) + } + if snapshot != nil { + change.NewContent = cappedInstructionContent(snapshot.body) + } + case codersdk.ChatContextResourceKindSkill: + // Removed skills exist only on the pinned side; otherwise the snapshot + // identifies what a refresh would adopt. + identity := snapshot + if identity == nil { + identity = pinned + } + if body, decoded := decodeSkillMetaBody(identity.body); decoded { + change.SkillName = body.GetName() + change.SkillDescription = body.GetDescription() + } + } + return change, true +} + +// promptResourceKind maps a database body kind to the codersdk kind reported +// on the chat, reporting ok=false for kinds that do not contribute to the +// prompt (and so are not surfaced as context resources or changes). +func promptResourceKind(kind database.WorkspaceAgentContextBodyKind) (codersdk.ChatContextResourceKind, bool) { + switch kind { + case database.WorkspaceAgentContextBodyKindInstructionFile: + return codersdk.ChatContextResourceKindInstructionFile, true + case database.WorkspaceAgentContextBodyKindSkill: + return codersdk.ChatContextResourceKindSkill, true + default: + return "", false + } +} + +// cappedInstructionContent decodes, sanitizes, and length-caps an instruction +// file body for display in a change diff. It returns "" when the body is not a +// decodable instruction file (e.g. a non-OK snapshot with an empty body). +func cappedInstructionContent(body json.RawMessage) string { + decoded, ok := decodeInstructionFileBody(body) + if !ok { + return "" + } + return truncateUTF8(SanitizePromptText(string(decoded.GetContent())), maxContextChangeContentBytes) +} + +// truncateUTF8 returns s truncated to at most n bytes without splitting a +// multi-byte rune. +func truncateUTF8(s string, n int) string { + if len(s) <= n { + return s + } + for n > 0 && !utf8.RuneStart(s[n]) { + n-- + } + return s[:n] +} diff --git a/coderd/x/chatd/context_detail_internal_test.go b/coderd/x/chatd/context_detail_internal_test.go new file mode 100644 index 0000000000000..b92806eb4f925 --- /dev/null +++ b/coderd/x/chatd/context_detail_internal_test.go @@ -0,0 +1,371 @@ +package chatd + +import ( + "context" + "crypto/sha256" + "database/sql" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "golang.org/x/xerrors" + + agentproto "github.com/coder/coder/v2/agent/proto" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbmock" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/codersdk" +) + +func contentHash(s string) []byte { + sum := sha256.Sum256([]byte(s)) + return sum[:] +} + +// pinnedInstruction builds a pinned instruction-file row with an explicit +// content hash so diff tests can control add/modify/remove independently of +// the body bytes. +func pinnedInstruction(t *testing.T, source, content string, hash []byte) database.ChatContextResource { + t.Helper() + return database.ChatContextResource{ + Source: source, + BodyKind: database.WorkspaceAgentContextBodyKindInstructionFile, + Body: mustMarshalContextBody(t, &agentproto.InstructionFileBody{Content: []byte(content)}), + ContentHash: hash, + SizeBytes: int64(len(content)), + Status: database.WorkspaceAgentContextResourceStatusOk, + } +} + +func snapshotInstruction(t *testing.T, source, content string, hash []byte) database.WorkspaceAgentContextResource { + t.Helper() + return database.WorkspaceAgentContextResource{ + Source: source, + BodyKind: database.WorkspaceAgentContextBodyKindInstructionFile, + Body: mustMarshalContextBody(t, &agentproto.InstructionFileBody{Content: []byte(content)}), + ContentHash: hash, + SizeBytes: int64(len(content)), + Status: database.WorkspaceAgentContextResourceStatusOk, + } +} + +func pinnedSkill(t *testing.T, source, name, description string, hash []byte) database.ChatContextResource { + t.Helper() + return database.ChatContextResource{ + Source: source, + BodyKind: database.WorkspaceAgentContextBodyKindSkill, + Body: mustMarshalContextBody(t, &agentproto.SkillMetaBody{Meta: []byte("# " + name), Name: name, Description: description}), + ContentHash: hash, + Status: database.WorkspaceAgentContextResourceStatusOk, + } +} + +func snapshotSkill(t *testing.T, source, name, description string, hash []byte) database.WorkspaceAgentContextResource { + t.Helper() + return database.WorkspaceAgentContextResource{ + Source: source, + BodyKind: database.WorkspaceAgentContextBodyKindSkill, + Body: mustMarshalContextBody(t, &agentproto.SkillMetaBody{Meta: []byte("# " + name), Name: name, Description: description}), + ContentHash: hash, + Status: database.WorkspaceAgentContextResourceStatusOk, + } +} + +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, + }, out[0]) + + require.Equal(t, codersdk.ChatContextResource{ + Source: "/home/coder/.coder/skills/deploy", + Kind: codersdk.ChatContextResourceKindSkill, + SkillName: "deploy", + SkillDescription: "Deploy the app", + }, out[1]) + }) + + t.Run("SkipsNonOKEmptyAndUnknownKinds", func(t *testing.T) { + t.Parallel() + + resources := []database.ChatContextResource{ + // Non-OK instruction file. + instructionResource(t, "/a/AGENTS.md", "ignored", database.WorkspaceAgentContextResourceStatusOversize), + // 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), + // Unknown (non-prompt) kind. + { + Source: ".mcp.json", + BodyKind: database.WorkspaceAgentContextBodyKindMcpConfig, + Status: database.WorkspaceAgentContextResourceStatusOk, + }, + } + require.Empty(t, pinnedContextResources(resources)) + }) +} + +func TestDiffContextResources(t *testing.T) { + t.Parallel() + + t.Run("AddedModifiedRemovedUnchanged", func(t *testing.T) { + t.Parallel() + + pinned := []database.ChatContextResource{ + pinnedInstruction(t, "/keep.md", "same", contentHash("same")), + pinnedInstruction(t, "/edit.md", "old body", contentHash("old body")), + pinnedInstruction(t, "/gone.md", "removed body", contentHash("removed body")), + } + snapshot := []database.WorkspaceAgentContextResource{ + snapshotInstruction(t, "/keep.md", "same", contentHash("same")), + snapshotInstruction(t, "/edit.md", "new body", contentHash("new body")), + snapshotInstruction(t, "/new.md", "added body", contentHash("added body")), + } + + changes := diffContextResources(pinned, snapshot) + // Ordered by source: /edit.md, /gone.md, /new.md. /keep.md is omitted. + require.Len(t, changes, 3) + + require.Equal(t, codersdk.ChatContextResourceChange{ + Source: "/edit.md", + Kind: codersdk.ChatContextResourceKindInstructionFile, + Status: codersdk.ChatContextResourceChangeStatusModified, + OldContent: "old body", + NewContent: "new body", + }, changes[0]) + + require.Equal(t, codersdk.ChatContextResourceChange{ + Source: "/gone.md", + Kind: codersdk.ChatContextResourceKindInstructionFile, + Status: codersdk.ChatContextResourceChangeStatusRemoved, + OldContent: "removed body", + }, changes[1]) + + require.Equal(t, codersdk.ChatContextResourceChange{ + Source: "/new.md", + Kind: codersdk.ChatContextResourceKindInstructionFile, + Status: codersdk.ChatContextResourceChangeStatusAdded, + NewContent: "added body", + }, changes[2]) + }) + + t.Run("SkillIdentitySides", func(t *testing.T) { + t.Parallel() + + pinned := []database.ChatContextResource{ + pinnedSkill(t, "/skills/edit", "edit-old", "old desc", contentHash("edit-old")), + pinnedSkill(t, "/skills/gone", "gone", "leaving", contentHash("gone")), + } + snapshot := []database.WorkspaceAgentContextResource{ + snapshotSkill(t, "/skills/edit", "edit-new", "new desc", contentHash("edit-new")), + snapshotSkill(t, "/skills/add", "added", "joining", contentHash("added")), + } + + changes := diffContextResources(pinned, snapshot) + require.Len(t, changes, 3) + + // Modified skill reports the snapshot identity (what a refresh adopts). + require.Equal(t, codersdk.ChatContextResourceChange{ + Source: "/skills/add", + Kind: codersdk.ChatContextResourceKindSkill, + Status: codersdk.ChatContextResourceChangeStatusAdded, + SkillName: "added", + SkillDescription: "joining", + }, changes[0]) + require.Equal(t, codersdk.ChatContextResourceChange{ + Source: "/skills/edit", + Kind: codersdk.ChatContextResourceKindSkill, + Status: codersdk.ChatContextResourceChangeStatusModified, + SkillName: "edit-new", + SkillDescription: "new desc", + }, changes[1]) + // Removed skill reports the pinned identity (only side that exists). + require.Equal(t, codersdk.ChatContextResourceChange{ + Source: "/skills/gone", + Kind: codersdk.ChatContextResourceKindSkill, + Status: codersdk.ChatContextResourceChangeStatusRemoved, + SkillName: "gone", + SkillDescription: "leaving", + }, changes[2]) + }) + + t.Run("SkipsNonPromptKinds", func(t *testing.T) { + t.Parallel() + + pinned := []database.ChatContextResource{ + {Source: ".mcp.json", BodyKind: database.WorkspaceAgentContextBodyKindMcpConfig, ContentHash: contentHash("old")}, + } + snapshot := []database.WorkspaceAgentContextResource{ + {Source: ".mcp.json", BodyKind: database.WorkspaceAgentContextBodyKindMcpConfig, ContentHash: contentHash("new")}, + } + require.Empty(t, diffContextResources(pinned, snapshot)) + }) + + t.Run("SanitizesAndCapsContent", func(t *testing.T) { + t.Parallel() + + // CRLF is normalized by SanitizePromptText, and content beyond the cap + // is truncated. + large := strings.Repeat("a", maxContextChangeContentBytes+500) + pinned := []database.ChatContextResource{ + pinnedInstruction(t, "/a.md", "line1\r\nline2", contentHash("old")), + pinnedInstruction(t, "/big.md", large, contentHash("big-old")), + } + snapshot := []database.WorkspaceAgentContextResource{ + snapshotInstruction(t, "/a.md", "line1\r\nchanged", contentHash("new")), + snapshotInstruction(t, "/big.md", large+"-changed", contentHash("big-new")), + } + + changes := diffContextResources(pinned, snapshot) + require.Len(t, changes, 2) + require.Equal(t, "line1\nline2", changes[0].OldContent) + require.Equal(t, "line1\nchanged", changes[0].NewContent) + require.Len(t, changes[1].OldContent, maxContextChangeContentBytes) + require.Len(t, changes[1].NewContent, maxContextChangeContentBytes) + }) +} + +func TestTruncateUTF8(t *testing.T) { + t.Parallel() + + require.Equal(t, "abc", truncateUTF8("abc", 10)) + require.Equal(t, "abc", truncateUTF8("abc", 3)) + require.Equal(t, "ab", truncateUTF8("abc", 2)) + require.Equal(t, "", truncateUTF8("abc", 0)) + + // "é" is two bytes (0xC3 0xA9); a cap landing inside it backs off so the + // rune is not split. + require.Equal(t, "a", truncateUTF8("aé", 2)) + require.Equal(t, "aé", truncateUTF8("aé", 3)) +} + +func TestContextDetail(t *testing.T) { + t.Parallel() + + t.Run("NotDirtySkipsSnapshotRead", 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) + // No ListWorkspaceAgentContextResources call is configured: a clean + // chat must not read the snapshot. + server := newPinServer(t, db) + + resources, changes, err := server.ContextDetail(context.Background(), database.Chat{ID: chatID}) + require.NoError(t, err) + require.Len(t, resources, 1) + require.Nil(t, changes) + }) + + t.Run("DirtyComputesChanges", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + chatID := uuid.New() + agentID := uuid.New() + db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chatID). + Return([]database.ChatContextResource{ + pinnedInstruction(t, "/home/coder/AGENTS.md", "old", contentHash("old")), + }, nil) + db.EXPECT().ListWorkspaceAgentContextResources(gomock.Any(), agentID). + Return([]database.WorkspaceAgentContextResource{ + snapshotInstruction(t, "/home/coder/AGENTS.md", "new", contentHash("new")), + }, nil) + server := newPinServer(t, db) + + chat := database.Chat{ + ID: chatID, + AgentID: uuid.NullUUID{UUID: agentID, Valid: true}, + ContextDirtySince: sql.NullTime{Time: dbtime.Now(), Valid: true}, + } + resources, changes, err := server.ContextDetail(context.Background(), chat) + require.NoError(t, err) + require.Len(t, resources, 1) + require.Len(t, changes, 1) + require.Equal(t, codersdk.ChatContextResourceChangeStatusModified, changes[0].Status) + require.Equal(t, "old", changes[0].OldContent) + require.Equal(t, "new", changes[0].NewContent) + }) + + t.Run("DirtyWithoutAgentSkipsSnapshot", 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) + + chat := database.Chat{ + ID: chatID, + ContextDirtySince: sql.NullTime{Time: dbtime.Now(), Valid: true}, + } + resources, changes, err := server.ContextDetail(context.Background(), chat) + require.NoError(t, err) + require.Empty(t, resources) + require.Nil(t, changes) + }) + + 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.ContextDetail(context.Background(), database.Chat{ID: chatID}) + require.Error(t, err) + }) + + t.Run("SnapshotListError", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + chatID := uuid.New() + agentID := uuid.New() + db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chatID). + Return([]database.ChatContextResource{}, nil) + db.EXPECT().ListWorkspaceAgentContextResources(gomock.Any(), agentID). + Return(nil, xerrors.New("boom")) + server := newPinServer(t, db) + + chat := database.Chat{ + ID: chatID, + AgentID: uuid.NullUUID{UUID: agentID, Valid: true}, + ContextDirtySince: sql.NullTime{Time: dbtime.Now(), Valid: true}, + } + _, _, err := server.ContextDetail(context.Background(), chat) + require.Error(t, err) + }) +} diff --git a/coderd/x/chatd/context_integration_test.go b/coderd/x/chatd/context_integration_test.go index 75c8e197be23b..ca1ff75873dba 100644 --- a/coderd/x/chatd/context_integration_test.go +++ b/coderd/x/chatd/context_integration_test.go @@ -133,6 +133,22 @@ func TestChatContextDirtyFromAgentPush(t *testing.T) { return out } + // Index the GET-only context detail (resources + changes) by source. + resourcesBySource := func(resources []codersdk.ChatContextResource) map[string]codersdk.ChatContextResource { + out := make(map[string]codersdk.ChatContextResource, len(resources)) + for _, r := range resources { + out[r.Source] = r + } + return out + } + changesBySource := func(changes []codersdk.ChatContextResourceChange) map[string]codersdk.ChatContextResourceChange { + out := make(map[string]codersdk.ChatContextResourceChange, len(changes)) + for _, c := range changes { + out[c.Source] = c + } + return out + } + // Connect as the agent and push the initial snapshot. The push runs the // hydrate/dirty fan-out synchronously inside its transaction, so the chat // reflects the change by the time the RPC returns. @@ -160,6 +176,13 @@ func TestChatContextDirtyFromAgentPush(t *testing.T) { require.False(t, got.Context.Dirty, "initial hydration is clean") require.Nil(t, got.Context.DirtySince) + // The single-chat GET surfaces the pinned resources; a clean chat carries + // no change set. + require.Len(t, got.Context.Resources, 1, "GET reports the pinned resources") + require.Equal(t, agentsSource, got.Context.Resources[0].Source) + require.Equal(t, codersdk.ChatContextResourceKindInstructionFile, got.Context.Resources[0].Kind) + require.Empty(t, got.Context.Changes, "a clean chat has no changes") + // The initial push also copied the agent's resources onto the chat. pinned := pinnedResources(chat.ID) require.Len(t, pinned, 1, "initial hydration copies the agent's resources") @@ -193,6 +216,24 @@ func TestChatContextDirtyFromAgentPush(t *testing.T) { require.Empty(t, got.Context.Error, "dirty marking leaves the pinned hash and error unchanged") requireChatContextNil(otherChat.ID, "agent-less chat unaffected by the dirty fan-out") + // While dirty the GET still reports the pinned (hashA) resources, plus a + // change set computed against the agent's latest (hashB) snapshot: the + // instruction file is modified (with old/new bodies) and the skill is + // added. + require.Len(t, got.Context.Resources, 1, "resources stay pinned while dirty") + require.Equal(t, agentsSource, got.Context.Resources[0].Source) + dirtyChanges := changesBySource(got.Context.Changes) + require.Len(t, dirtyChanges, 2, "GET reports the per-source change set while dirty") + agentsChange := dirtyChanges[agentsSource] + require.Equal(t, codersdk.ChatContextResourceChangeStatusModified, agentsChange.Status) + require.Equal(t, codersdk.ChatContextResourceKindInstructionFile, agentsChange.Kind) + require.Equal(t, "hello-v1", agentsChange.OldContent) + require.Equal(t, "hello-v2", agentsChange.NewContent) + skillChange := dirtyChanges[skillSource] + require.Equal(t, codersdk.ChatContextResourceChangeStatusAdded, skillChange.Status) + require.Equal(t, codersdk.ChatContextResourceKindSkill, skillChange.Kind) + require.Equal(t, "example", skillChange.SkillName) + // The dirty fan-out must NOT re-copy resources: the chat keeps the bodies // from its pinned (hashA) snapshot until it is refreshed. pinned = pinnedResources(chat.ID) @@ -219,6 +260,14 @@ func TestChatContextDirtyFromAgentPush(t *testing.T) { require.NotNil(t, got.Context) require.False(t, got.Context.Dirty) + // Refresh advanced the pin to hashB, so the GET now reports both pinned + // resources and, being clean again, no changes. + refreshedResources := resourcesBySource(got.Context.Resources) + require.Len(t, refreshedResources, 2, "refresh re-pins both resources for the GET") + require.Equal(t, codersdk.ChatContextResourceKindInstructionFile, refreshedResources[agentsSource].Kind) + require.Equal(t, codersdk.ChatContextResourceKindSkill, refreshedResources[skillSource].Kind) + require.Equal(t, "example", refreshedResources[skillSource].SkillName) + require.Empty(t, got.Context.Changes, "a refreshed chat has no changes") // Re-pushing the now-pinned hash proves the refresh advanced the pin to // hashB: a matching hash must not re-dirty the chat. resp, err = aAPI.PushContextState(ctx, &agentproto.PushContextStateRequest{ diff --git a/coderd/x/chatd/context_prompt.go b/coderd/x/chatd/context_prompt.go index 140055e6b2ee9..d744433b50609 100644 --- a/coderd/x/chatd/context_prompt.go +++ b/coderd/x/chatd/context_prompt.go @@ -2,6 +2,7 @@ package chatd import ( "context" + "encoding/json" "golang.org/x/xerrors" "google.golang.org/protobuf/encoding/protojson" @@ -18,6 +19,27 @@ import ( // the reader forward compatible as new body fields are added to the proto. var contextBodyUnmarshalOptions = protojson.UnmarshalOptions{DiscardUnknown: true} +// decodeInstructionFileBody decodes a protojson instruction-file resource +// body. ok is false when the body cannot be decoded, letting callers count it +// as malformed rather than silently treating it as empty. +func decodeInstructionFileBody(body json.RawMessage) (*agentproto.InstructionFileBody, bool) { + var decoded agentproto.InstructionFileBody + if err := contextBodyUnmarshalOptions.Unmarshal(body, &decoded); err != nil { + return nil, false + } + return &decoded, true +} + +// decodeSkillMetaBody decodes a protojson skill resource body. ok is false +// when the body cannot be decoded. +func decodeSkillMetaBody(body json.RawMessage) (*agentproto.SkillMetaBody, bool) { + var decoded agentproto.SkillMetaBody + if err := contextBodyUnmarshalOptions.Unmarshal(body, &decoded); err != nil { + return nil, false + } + return &decoded, 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. @@ -127,8 +149,8 @@ func contextResourcesToPrompt( } switch r.BodyKind { case database.WorkspaceAgentContextBodyKindInstructionFile: - var body agentproto.InstructionFileBody - if err := contextBodyUnmarshalOptions.Unmarshal(r.Body, &body); err != nil { + body, decoded := decodeInstructionFileBody(r.Body) + if !decoded { malformed++ continue } @@ -142,8 +164,8 @@ func contextResourcesToPrompt( ContextFileContent: content, }) case database.WorkspaceAgentContextBodyKindSkill: - var body agentproto.SkillMetaBody - if err := contextBodyUnmarshalOptions.Unmarshal(r.Body, &body); err != nil { + body, decoded := decodeSkillMetaBody(r.Body) + if !decoded { malformed++ continue } diff --git a/codersdk/chats.go b/codersdk/chats.go index 0005b5f1d1608..f0bc6c591b869 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -168,6 +168,70 @@ type ChatContext struct { // Error is the snapshot-level error copied from the pinned snapshot // (empty when healthy). Error string `json:"error,omitempty"` + // Resources is the chat's pinned context (instruction files and + // skills) the prompt is built from, metadata only (no bodies). It is + // populated only on the single-chat GET response; list and watch + // payloads leave it nil to stay lightweight. + Resources []ChatContextResource `json:"resources,omitempty"` + // Changes lists how the pinned context differs from the agent's latest + // snapshot, by source. It is populated only on the single-chat GET + // response and only while the chat is dirty; otherwise nil. + Changes []ChatContextResourceChange `json:"changes,omitempty"` +} + +// ChatContextResourceKind classifies a pinned context resource the prompt +// uses. Only the kinds that contribute to the prompt are reported. +type ChatContextResourceKind string + +const ( + ChatContextResourceKindInstructionFile ChatContextResourceKind = "instruction_file" + ChatContextResourceKindSkill ChatContextResourceKind = "skill" +) + +// ChatContextResource is one pinned workspace-context resource the chat's +// prompt is built from. It is metadata only; bodies are omitted. Reported +// only on the single-chat GET response. +type ChatContextResource struct { + // Source is the resource locator: the canonical file path for an + // instruction file, or the skill directory for a skill. + Source string `json:"source"` + Kind ChatContextResourceKind `json:"kind"` + // SizeBytes is the original payload size in bytes. + SizeBytes int64 `json:"size_bytes"` + // SkillName and SkillDescription are populated only for skill kinds. + SkillName string `json:"skill_name,omitempty"` + SkillDescription string `json:"skill_description,omitempty"` +} + +// ChatContextResourceChangeStatus classifies how a source differs between the +// chat's pinned context and the agent's latest snapshot. +type ChatContextResourceChangeStatus string + +const ( + ChatContextResourceChangeStatusAdded ChatContextResourceChangeStatus = "added" + ChatContextResourceChangeStatusRemoved ChatContextResourceChangeStatus = "removed" + ChatContextResourceChangeStatusModified ChatContextResourceChangeStatus = "modified" +) + +// ChatContextResourceChange is one source-level difference between the chat's +// pinned context and the agent's latest snapshot. Reported only on the +// single-chat GET response while the chat is dirty. +type ChatContextResourceChange struct { + // Source is the resource locator that differs. + Source string `json:"source"` + Kind ChatContextResourceKind `json:"kind"` + Status ChatContextResourceChangeStatus `json:"status"` + // OldContent and NewContent carry the sanitized instruction-file bodies + // for the pinned and snapshot sides, capped for display. Removed changes + // fill OldContent only, added changes fill NewContent only, and modified + // changes fill both. Empty for skills. + OldContent string `json:"old_content,omitempty"` + NewContent string `json:"new_content,omitempty"` + // SkillName and SkillDescription identify a changed skill: the snapshot + // side for added/modified, the pinned side for removed. Empty for + // instruction files. + SkillName string `json:"skill_name,omitempty"` + SkillDescription string `json:"skill_description,omitempty"` } // ChatFileMetadata contains lightweight metadata about a file diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index f3597c44627ca..b3968b2febffc 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -37,9 +37,29 @@ Experimental: this endpoint is subject to change. ], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -186,130 +206,144 @@ Experimental: this endpoint is subject to change. Status Code **200** -| Name | Type | Required | Restrictions | Description | -|-----------------------------------|------------------------------------------------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `[array item]` | array | false | | | -| `» agent_id` | string(uuid) | false | | | -| `» archived` | boolean | false | | | -| `» build_id` | string(uuid) | false | | | -| `» children` | [codersdk.Chat](schemas.md#codersdkchat) | false | | Children holds child (subagent) chats nested under this root chat. Always initialized to an empty slice so the JSON field is present as []. Child chats cannot create their own subagents, so nesting depth is capped at 1 and this slice is always empty for child chats. | -| `» client_type` | [codersdk.ChatClientType](schemas.md#codersdkchatclienttype) | false | | | -| `» context` | [codersdk.ChatContext](schemas.md#codersdkchatcontext) | false | | Context reports the chat's pinned workspace-context state and whether it has drifted from the agent's latest pushed snapshot. Nil when the chat has no pinned context yet. | -| `»» dirty` | boolean | false | | Dirty is true when the agent's latest snapshot hash differs from the chat's pinned hash. | -| `»» dirty_since` | string(date-time) | false | | Dirty since is when drift was first detected; nil when not dirty. | -| `»» error` | string | false | | Error is the snapshot-level error copied from the pinned snapshot (empty when healthy). | -| `» created_at` | string(date-time) | false | | | -| `» diff_status` | [codersdk.ChatDiffStatus](schemas.md#codersdkchatdiffstatus) | false | | | -| `»» additions` | integer | false | | | -| `»» approved` | boolean | false | | | -| `»» author_avatar_url` | string | false | | | -| `»» author_login` | string | false | | | -| `»» base_branch` | string | false | | | -| `»» changed_files` | integer | false | | | -| `»» changes_requested` | boolean | false | | | -| `»» chat_id` | string(uuid) | false | | | -| `»» commits` | integer | false | | | -| `»» deletions` | integer | false | | | -| `»» head_branch` | string | false | | | -| `»» pr_number` | integer | false | | | -| `»» pull_request_draft` | boolean | false | | | -| `»» pull_request_state` | string | false | | | -| `»» pull_request_title` | string | false | | | -| `»» refreshed_at` | string(date-time) | false | | | -| `»» reviewer_count` | integer | false | | | -| `»» stale_at` | string(date-time) | false | | | -| `»» url` | string | false | | | -| `» files` | array | false | | | -| `»» created_at` | string(date-time) | false | | | -| `»» id` | string(uuid) | false | | | -| `»» mime_type` | string | false | | | -| `»» name` | string | false | | | -| `»» organization_id` | string(uuid) | false | | | -| `»» owner_id` | string(uuid) | false | | | -| `» has_unread` | boolean | false | | Has unread is true when assistant messages exist beyond the owner's read cursor, which updates on stream connect and disconnect. | -| `» id` | string(uuid) | false | | | -| `» labels` | object | false | | | -| `»» [any property]` | string | false | | | -| `» last_error` | [codersdk.ChatError](schemas.md#codersdkchaterror) | false | | | -| `»» detail` | string | false | | Detail is optional provider-specific context shown alongside the normalized error message when available. | -| `»» kind` | [codersdk.ChatErrorKind](schemas.md#codersdkchaterrorkind) | false | | Kind classifies the error for consistent client rendering. | -| `»» message` | string | false | | Message is the normalized, user-facing error message. | -| `»» provider` | string | false | | Provider identifies the upstream model provider when known. | -| `»» retryable` | boolean | false | | Retryable reports whether the underlying error is transient. | -| `»» status_code` | integer | false | | Status code is the best-effort upstream HTTP status code. | -| `» last_injected_context` | array | false | | Last injected context holds the most recently persisted injected context parts (AGENTS.md files and skills). It is updated only when context changes, on first workspace attach or agent change. | -| `»» args` | array | false | | | -| `»» args_delta` | string | false | | | -| `»» completed_at` | string(date-time) | false | | Completed at is the time a reasoning part finished streaming, so reasoning duration can be computed as completed_at minus created_at. For interrupted reasoning, this is the interruption time. Absent when reasoning timestamp data was not recorded (e.g. messages persisted before this feature was added). | -| `»» content` | string | false | | The code content from the diff that was commented on. | -| `»» context_file_agent_id` | [uuid.NullUUID](schemas.md#uuidnulluuid) | false | | Context file agent ID is the workspace agent that provided this context file. Used to detect when the agent changes (e.g. workspace rebuilt) so instruction files can be re-persisted with fresh content. | -| `»»» uuid` | string | false | | | -| `»»» valid` | boolean | false | | Valid is true if UUID is not NULL | -| `»» context_file_content` | string | false | | Context file content holds the file content sent to the LLM. Internal only: stripped before API responses to keep payloads small. The backend reads it when building the prompt via partsToMessageParts. | -| `»» context_file_directory` | string | false | | Context file directory is the working directory of the workspace agent. Internal only: same purpose as ContextFileOS. | -| `»» context_file_os` | string | false | | Context file os is the operating system of the workspace agent. Internal only: used during prompt expansion so the LLM knows the OS even on turns where InsertSystem is not called. | -| `»» context_file_path` | string | false | | Context file path is the absolute path of a file loaded into the LLM context (e.g. an AGENTS.md instruction file). | -| `»» context_file_skill_meta_file` | string | false | | Context file skill meta file is the basename of the skill meta file (e.g. "SKILL.md") at the time of persistence. Internal only: restored on subsequent turns so the read_skill tool uses the correct filename even when the agent configured a non-default value. | -| `»» context_file_truncated` | boolean | false | | Context file truncated indicates the file exceeded the 64KiB instruction file limit and was truncated. | -| `»» created_at` | string(date-time) | false | | Created at is the timestamp this part carries. The semantics depend on the part type: for tool-call and tool-result parts it is the time the call was emitted or the result was produced (tool duration is the result's created_at minus the call's created_at); for reasoning parts it is the time reasoning started streaming. | -| `»» data` | array | false | | | -| `»» end_line` | integer | false | | | -| `»» file_id` | [uuid.NullUUID](schemas.md#uuidnulluuid) | false | | | -| `»»» uuid` | string | false | | | -| `»»» valid` | boolean | false | | Valid is true if UUID is not NULL | -| `»» file_name` | string | false | | | -| `»» is_error` | boolean | false | | | -| `»» is_media` | boolean | false | | | -| `»» mcp_server_config_id` | [uuid.NullUUID](schemas.md#uuidnulluuid) | false | | | -| `»»» uuid` | string | false | | | -| `»»» valid` | boolean | false | | Valid is true if UUID is not NULL | -| `»» media_type` | string | false | | | -| `»» name` | string | false | | | -| `»» parsed_commands` | array | false | | Parsed commands holds parsed programs from an execute tool call's shell command, one entry per simple command in source order. Each entry is [program] or [program, arg] where arg is the first non-flag positional argument. Program names are normalized to their base name (e.g. /usr/bin/go becomes go). Only populated when ToolName is "execute" and the command parses successfully; nil otherwise. | -| `»» provider_executed` | boolean | false | | Provider executed indicates the tool call was executed by the provider (e.g. Anthropic computer use). | -| `»» provider_metadata` | array | false | | Provider metadata holds provider-specific response metadata (e.g. Anthropic cache control hints) as raw JSON. Internal only: stripped by db2sdk before API responses. | -| `»» result` | array | false | | | -| `»» result_delta` | string | false | | | -| `»» result_reset` | boolean | false | | | -| `»» signature` | string | false | | | -| `»» skill_description` | string | false | | Skill description is the short description from the skill's SKILL.md frontmatter. | -| `»» skill_dir` | string | false | | Skill dir is the absolute path to the skill directory inside the workspace filesystem. Internal only: used by read_skill/read_skill_file tools to locate skill files. | -| `»» skill_name` | string | false | | Skill name is the kebab-case name of a discovered skill from the workspace's .agents/skills/ directory. | -| `»» source_id` | string | false | | | -| `»» start_line` | integer | false | | | -| `»» text` | string | false | | | -| `»» title` | string | false | | | -| `»» tool_call_id` | string | false | | | -| `»» tool_name` | string | false | | | -| `»» type` | [codersdk.ChatMessagePartType](schemas.md#codersdkchatmessageparttype) | false | | | -| `»» url` | string | false | | | -| `» last_model_config_id` | string(uuid) | false | | | -| `» last_turn_summary` | string | false | | | -| `» mcp_server_ids` | array | false | | | -| `» organization_id` | string(uuid) | false | | | -| `» owner_id` | string(uuid) | false | | | -| `» owner_name` | string | false | | | -| `» owner_username` | string | false | | | -| `» parent_chat_id` | string(uuid) | false | | | -| `» pin_order` | integer | false | | | -| `» plan_mode` | [codersdk.ChatPlanMode](schemas.md#codersdkchatplanmode) | false | | | -| `» root_chat_id` | string(uuid) | false | | | -| `» shared` | boolean | false | | Shared is true when this chat's root chat has explicit user or group ACL entries. | -| `» status` | [codersdk.ChatStatus](schemas.md#codersdkchatstatus) | false | | | -| `» title` | string | false | | | -| `» updated_at` | string(date-time) | false | | | -| `» warnings` | array | false | | | -| `» workspace_id` | string(uuid) | false | | | +| Name | Type | Required | Restrictions | Description | +|-----------------------------------|------------------------------------------------------------------------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `[array item]` | array | false | | | +| `» agent_id` | string(uuid) | false | | | +| `» archived` | boolean | false | | | +| `» build_id` | string(uuid) | false | | | +| `» children` | [codersdk.Chat](schemas.md#codersdkchat) | false | | Children holds child (subagent) chats nested under this root chat. Always initialized to an empty slice so the JSON field is present as []. Child chats cannot create their own subagents, so nesting depth is capped at 1 and this slice is always empty for child chats. | +| `» client_type` | [codersdk.ChatClientType](schemas.md#codersdkchatclienttype) | false | | | +| `» context` | [codersdk.ChatContext](schemas.md#codersdkchatcontext) | false | | Context reports the chat's pinned workspace-context state and whether it has drifted from the agent's latest pushed snapshot. Nil when the chat has no pinned context yet. | +| `»» changes` | array | false | | Changes lists how the pinned context differs from the agent's latest snapshot, by source. It is populated only on the single-chat GET response and only while the chat is dirty; otherwise nil. | +| `»»» kind` | [codersdk.ChatContextResourceKind](schemas.md#codersdkchatcontextresourcekind) | false | | | +| `»»» new_content` | string | false | | | +| `»»» old_content` | string | false | | Old content and NewContent carry the sanitized instruction-file bodies for the pinned and snapshot sides, capped for display. Removed changes fill OldContent only, added changes fill NewContent only, and modified changes fill both. Empty for skills. | +| `»»» skill_description` | string | false | | | +| `»»» skill_name` | string | false | | Skill name and SkillDescription identify a changed skill: the snapshot side for added/modified, the pinned side for removed. Empty for instruction files. | +| `»»» source` | string | false | | Source is the resource locator that differs. | +| `»»» status` | [codersdk.ChatContextResourceChangeStatus](schemas.md#codersdkchatcontextresourcechangestatus) | false | | | +| `»» dirty` | boolean | false | | Dirty is true when the agent's latest snapshot hash differs from the chat's pinned hash. | +| `»» dirty_since` | string(date-time) | false | | Dirty since is when drift was first detected; nil when not dirty. | +| `»» error` | string | false | | Error is the snapshot-level error copied from the pinned snapshot (empty when healthy). | +| `»» resources` | array | false | | Resources is the chat's pinned context (instruction files and skills) the prompt is built from, metadata only (no bodies). It is populated only on the single-chat GET response; list and watch payloads leave it nil to stay lightweight. | +| `»»» kind` | [codersdk.ChatContextResourceKind](schemas.md#codersdkchatcontextresourcekind) | false | | | +| `»»» size_bytes` | integer | false | | Size bytes is the original payload size in bytes. | +| `»»» skill_description` | string | false | | | +| `»»» skill_name` | string | false | | Skill name and SkillDescription are populated only for skill kinds. | +| `»»» source` | string | false | | Source is the resource locator: the canonical file path for an instruction file, or the skill directory for a skill. | +| `» created_at` | string(date-time) | false | | | +| `» diff_status` | [codersdk.ChatDiffStatus](schemas.md#codersdkchatdiffstatus) | false | | | +| `»» additions` | integer | false | | | +| `»» approved` | boolean | false | | | +| `»» author_avatar_url` | string | false | | | +| `»» author_login` | string | false | | | +| `»» base_branch` | string | false | | | +| `»» changed_files` | integer | false | | | +| `»» changes_requested` | boolean | false | | | +| `»» chat_id` | string(uuid) | false | | | +| `»» commits` | integer | false | | | +| `»» deletions` | integer | false | | | +| `»» head_branch` | string | false | | | +| `»» pr_number` | integer | false | | | +| `»» pull_request_draft` | boolean | false | | | +| `»» pull_request_state` | string | false | | | +| `»» pull_request_title` | string | false | | | +| `»» refreshed_at` | string(date-time) | false | | | +| `»» reviewer_count` | integer | false | | | +| `»» stale_at` | string(date-time) | false | | | +| `»» url` | string | false | | | +| `» files` | array | false | | | +| `»» created_at` | string(date-time) | false | | | +| `»» id` | string(uuid) | false | | | +| `»» mime_type` | string | false | | | +| `»» name` | string | false | | | +| `»» organization_id` | string(uuid) | false | | | +| `»» owner_id` | string(uuid) | false | | | +| `» has_unread` | boolean | false | | Has unread is true when assistant messages exist beyond the owner's read cursor, which updates on stream connect and disconnect. | +| `» id` | string(uuid) | false | | | +| `» labels` | object | false | | | +| `»» [any property]` | string | false | | | +| `» last_error` | [codersdk.ChatError](schemas.md#codersdkchaterror) | false | | | +| `»» detail` | string | false | | Detail is optional provider-specific context shown alongside the normalized error message when available. | +| `»» kind` | [codersdk.ChatErrorKind](schemas.md#codersdkchaterrorkind) | false | | Kind classifies the error for consistent client rendering. | +| `»» message` | string | false | | Message is the normalized, user-facing error message. | +| `»» provider` | string | false | | Provider identifies the upstream model provider when known. | +| `»» retryable` | boolean | false | | Retryable reports whether the underlying error is transient. | +| `»» status_code` | integer | false | | Status code is the best-effort upstream HTTP status code. | +| `» last_injected_context` | array | false | | Last injected context holds the most recently persisted injected context parts (AGENTS.md files and skills). It is updated only when context changes, on first workspace attach or agent change. | +| `»» args` | array | false | | | +| `»» args_delta` | string | false | | | +| `»» completed_at` | string(date-time) | false | | Completed at is the time a reasoning part finished streaming, so reasoning duration can be computed as completed_at minus created_at. For interrupted reasoning, this is the interruption time. Absent when reasoning timestamp data was not recorded (e.g. messages persisted before this feature was added). | +| `»» content` | string | false | | The code content from the diff that was commented on. | +| `»» context_file_agent_id` | [uuid.NullUUID](schemas.md#uuidnulluuid) | false | | Context file agent ID is the workspace agent that provided this context file. Used to detect when the agent changes (e.g. workspace rebuilt) so instruction files can be re-persisted with fresh content. | +| `»»» uuid` | string | false | | | +| `»»» valid` | boolean | false | | Valid is true if UUID is not NULL | +| `»» context_file_content` | string | false | | Context file content holds the file content sent to the LLM. Internal only: stripped before API responses to keep payloads small. The backend reads it when building the prompt via partsToMessageParts. | +| `»» context_file_directory` | string | false | | Context file directory is the working directory of the workspace agent. Internal only: same purpose as ContextFileOS. | +| `»» context_file_os` | string | false | | Context file os is the operating system of the workspace agent. Internal only: used during prompt expansion so the LLM knows the OS even on turns where InsertSystem is not called. | +| `»» context_file_path` | string | false | | Context file path is the absolute path of a file loaded into the LLM context (e.g. an AGENTS.md instruction file). | +| `»» context_file_skill_meta_file` | string | false | | Context file skill meta file is the basename of the skill meta file (e.g. "SKILL.md") at the time of persistence. Internal only: restored on subsequent turns so the read_skill tool uses the correct filename even when the agent configured a non-default value. | +| `»» context_file_truncated` | boolean | false | | Context file truncated indicates the file exceeded the 64KiB instruction file limit and was truncated. | +| `»» created_at` | string(date-time) | false | | Created at is the timestamp this part carries. The semantics depend on the part type: for tool-call and tool-result parts it is the time the call was emitted or the result was produced (tool duration is the result's created_at minus the call's created_at); for reasoning parts it is the time reasoning started streaming. | +| `»» data` | array | false | | | +| `»» end_line` | integer | false | | | +| `»» file_id` | [uuid.NullUUID](schemas.md#uuidnulluuid) | false | | | +| `»»» uuid` | string | false | | | +| `»»» valid` | boolean | false | | Valid is true if UUID is not NULL | +| `»» file_name` | string | false | | | +| `»» is_error` | boolean | false | | | +| `»» is_media` | boolean | false | | | +| `»» mcp_server_config_id` | [uuid.NullUUID](schemas.md#uuidnulluuid) | false | | | +| `»»» uuid` | string | false | | | +| `»»» valid` | boolean | false | | Valid is true if UUID is not NULL | +| `»» media_type` | string | false | | | +| `»» name` | string | false | | | +| `»» parsed_commands` | array | false | | Parsed commands holds parsed programs from an execute tool call's shell command, one entry per simple command in source order. Each entry is [program] or [program, arg] where arg is the first non-flag positional argument. Program names are normalized to their base name (e.g. /usr/bin/go becomes go). Only populated when ToolName is "execute" and the command parses successfully; nil otherwise. | +| `»» provider_executed` | boolean | false | | Provider executed indicates the tool call was executed by the provider (e.g. Anthropic computer use). | +| `»» provider_metadata` | array | false | | Provider metadata holds provider-specific response metadata (e.g. Anthropic cache control hints) as raw JSON. Internal only: stripped by db2sdk before API responses. | +| `»» result` | array | false | | | +| `»» result_delta` | string | false | | | +| `»» result_reset` | boolean | false | | | +| `»» signature` | string | false | | | +| `»» skill_description` | string | false | | Skill description is the short description from the skill's SKILL.md frontmatter. | +| `»» skill_dir` | string | false | | Skill dir is the absolute path to the skill directory inside the workspace filesystem. Internal only: used by read_skill/read_skill_file tools to locate skill files. | +| `»» skill_name` | string | false | | Skill name is the kebab-case name of a discovered skill from the workspace's .agents/skills/ directory. | +| `»» source_id` | string | false | | | +| `»» start_line` | integer | false | | | +| `»» text` | string | false | | | +| `»» title` | string | false | | | +| `»» tool_call_id` | string | false | | | +| `»» tool_name` | string | false | | | +| `»» type` | [codersdk.ChatMessagePartType](schemas.md#codersdkchatmessageparttype) | false | | | +| `»» url` | string | false | | | +| `» last_model_config_id` | string(uuid) | false | | | +| `» last_turn_summary` | string | false | | | +| `» mcp_server_ids` | array | false | | | +| `» organization_id` | string(uuid) | false | | | +| `» owner_id` | string(uuid) | false | | | +| `» owner_name` | string | false | | | +| `» owner_username` | string | false | | | +| `» parent_chat_id` | string(uuid) | false | | | +| `» pin_order` | integer | false | | | +| `» plan_mode` | [codersdk.ChatPlanMode](schemas.md#codersdkchatplanmode) | false | | | +| `» root_chat_id` | string(uuid) | false | | | +| `» shared` | boolean | false | | Shared is true when this chat's root chat has explicit user or group ACL entries. | +| `» status` | [codersdk.ChatStatus](schemas.md#codersdkchatstatus) | false | | | +| `» title` | string | false | | | +| `» updated_at` | string(date-time) | false | | | +| `» warnings` | array | false | | | +| `» workspace_id` | string(uuid) | false | | | #### Enumerated Values -| Property | Value(s) | -|---------------|-------------------------------------------------------------------------------------------------------------------------------------------------| -| `client_type` | `api`, `ui` | -| `kind` | `auth`, `config`, `generic`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `stream_silence_timeout`, `timeout`, `usage_limit` | -| `type` | `context-file`, `file`, `file-reference`, `reasoning`, `skill`, `source`, `text`, `tool-call`, `tool-result` | -| `plan_mode` | `plan` | -| `status` | `completed`, `error`, `interrupting`, `paused`, `pending`, `requires_action`, `running`, `waiting` | +| Property | Value(s) | +|---------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `client_type` | `api`, `ui` | +| `kind` | `auth`, `config`, `generic`, `instruction_file`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `skill`, `stream_silence_timeout`, `timeout`, `usage_limit` | +| `status` | `added`, `completed`, `error`, `interrupting`, `modified`, `paused`, `pending`, `removed`, `requires_action`, `running`, `waiting` | +| `type` | `context-file`, `file`, `file-reference`, `reasoning`, `skill`, `source`, `text`, `tool-call`, `tool-result` | +| `plan_mode` | `plan` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -392,9 +426,29 @@ Experimental: this endpoint is subject to change. "children": [], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -531,9 +585,29 @@ Experimental: this endpoint is subject to change. ], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -821,9 +895,29 @@ Experimental: this endpoint is subject to change. ], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -1014,9 +1108,29 @@ Experimental: this endpoint is subject to change. "children": [], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -1153,9 +1267,29 @@ Experimental: this endpoint is subject to change. ], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -1383,9 +1517,29 @@ Experimental: this endpoint is subject to change. "children": [], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -1522,9 +1676,29 @@ Experimental: this endpoint is subject to change. ], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -1750,9 +1924,29 @@ Experimental: this endpoint is subject to change. "children": [], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -1889,9 +2083,29 @@ Experimental: this endpoint is subject to change. ], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -2684,9 +2898,29 @@ Experimental: this endpoint is subject to change. "children": [], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -2823,9 +3057,29 @@ Experimental: this endpoint is subject to change. ], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -3376,9 +3630,29 @@ Experimental: this endpoint is subject to change. "children": [], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -3515,9 +3789,29 @@ Experimental: this endpoint is subject to change. ], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 8998babc87da7..b6fff3ae4a601 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1926,9 +1926,29 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "children": [], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -2065,9 +2085,29 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -2342,19 +2382,117 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ```json { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string" + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------------|-----------------------------------------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `changes` | array of [codersdk.ChatContextResourceChange](#codersdkchatcontextresourcechange) | false | | Changes lists how the pinned context differs from the agent's latest snapshot, by source. It is populated only on the single-chat GET response and only while the chat is dirty; otherwise nil. | +| `dirty` | boolean | false | | Dirty is true when the agent's latest snapshot hash differs from the chat's pinned hash. | +| `dirty_since` | string | false | | Dirty since is when drift was first detected; nil when not dirty. | +| `error` | string | false | | Error is the snapshot-level error copied from the pinned snapshot (empty when healthy). | +| `resources` | array of [codersdk.ChatContextResource](#codersdkchatcontextresource) | false | | Resources is the chat's pinned context (instruction files and skills) the prompt is built from, metadata only (no bodies). It is populated only on the single-chat GET response; list and watch payloads leave it nil to stay lightweight. | + +## codersdk.ChatContextResource + +```json +{ + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------------------|----------------------------------------------------------------------|----------|--------------|----------------------------------------------------------------------------------------------------------------------| +| `kind` | [codersdk.ChatContextResourceKind](#codersdkchatcontextresourcekind) | false | | | +| `size_bytes` | integer | false | | Size bytes is the original payload size in bytes. | +| `skill_description` | string | false | | | +| `skill_name` | string | false | | Skill name and SkillDescription are populated only for skill kinds. | +| `source` | string | false | | Source is the resource locator: the canonical file path for an instruction file, or the skill directory for a skill. | + +## codersdk.ChatContextResourceChange + +```json +{ + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|---------------|---------|----------|--------------|------------------------------------------------------------------------------------------| -| `dirty` | boolean | false | | Dirty is true when the agent's latest snapshot hash differs from the chat's pinned hash. | -| `dirty_since` | string | false | | Dirty since is when drift was first detected; nil when not dirty. | -| `error` | string | false | | Error is the snapshot-level error copied from the pinned snapshot (empty when healthy). | +| Name | Type | Required | Restrictions | Description | +|---------------------|--------------------------------------------------------------------------------------|----------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `kind` | [codersdk.ChatContextResourceKind](#codersdkchatcontextresourcekind) | false | | | +| `new_content` | string | false | | | +| `old_content` | string | false | | Old content and NewContent carry the sanitized instruction-file bodies for the pinned and snapshot sides, capped for display. Removed changes fill OldContent only, added changes fill NewContent only, and modified changes fill both. Empty for skills. | +| `skill_description` | string | false | | | +| `skill_name` | string | false | | Skill name and SkillDescription identify a changed skill: the snapshot side for added/modified, the pinned side for removed. Empty for instruction files. | +| `source` | string | false | | Source is the resource locator that differs. | +| `status` | [codersdk.ChatContextResourceChangeStatus](#codersdkchatcontextresourcechangestatus) | false | | | + +## codersdk.ChatContextResourceChangeStatus + +```json +"added" +``` + +### Properties + +#### Enumerated Values + +| Value(s) | +|--------------------------------| +| `added`, `modified`, `removed` | + +## codersdk.ChatContextResourceKind + +```json +"instruction_file" +``` + +### Properties + +#### Enumerated Values + +| Value(s) | +|-----------------------------| +| `instruction_file`, `skill` | ## codersdk.ChatDiffContents @@ -3778,9 +3916,29 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ], "client_type": "ui", "context": { + "changes": [ + { + "kind": "instruction_file", + "new_content": "string", + "old_content": "string", + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "added" + } + ], "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string" + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 34eafbf592b68..1476ac2283e97 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3377,6 +3377,17 @@ class ExperimentalApiMethods { return response.data; }; + /** + * Re-pins the chat to its agent's latest context snapshot and clears + * the dirty marker. Returns the updated chat. + */ + refreshChatContext = async (chatId: string): Promise => { + const response = await this.axios.put( + `/api/experimental/chats/${chatId}/context`, + ); + return response.data; + }; + deleteChatQueuedMessage = async ( chatId: string, queuedMessageId: number, diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index 81890268dec55..fcc643b3f80e1 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -2062,6 +2062,58 @@ describe("updateChildInParentCache", () => { }); describe("mergeWatchedChatSummary", () => { + it("applies context_dirty flags while preserving the pinned resource list", () => { + const cachedChat = makeChat("chat-1", { + updated_at: "2025-01-01T00:00:00.000Z", + context: { + dirty: false, + resources: [ + { source: "/AGENTS.md", kind: "instruction_file", size_bytes: 10 }, + ], + }, + }); + const watchedChat = makeChat("chat-1", { + // Drift is tracked outside updated_at, so an older event timestamp + // still applies the dirty flags. + updated_at: "2024-12-31T00:00:00.000Z", + context: { dirty: true, dirty_since: "2025-01-02T00:00:00.000Z" }, + }); + + expect( + mergeWatchedChatSummary(cachedChat, watchedChat, { + eventKind: "context_dirty", + }).context, + ).toEqual({ + dirty: true, + dirty_since: "2025-01-02T00:00:00.000Z", + // The lightweight watch payload omits resources; the merge keeps the + // pinned list a prior single-chat GET populated. + resources: [ + { source: "/AGENTS.md", kind: "instruction_file", size_bytes: 10 }, + ], + }); + }); + + it("leaves context untouched for non-context events", () => { + const context = { dirty: true, dirty_since: "2025-01-02T00:00:00.000Z" }; + const cachedChat = makeChat("chat-1", { + status: "pending", + updated_at: "2025-01-01T00:00:00.000Z", + context, + }); + const watchedChat = makeChat("chat-1", { + status: "running", + updated_at: "2025-01-01T00:05:00.000Z", + context: { dirty: false }, + }); + + expect( + mergeWatchedChatSummary(cachedChat, watchedChat, { + eventKind: "status_change", + }).context, + ).toBe(context); + }); + it("merges fresh status updates without clobbering a newer title snapshot", () => { const cachedChat = makeChat("chat-1", { status: "pending", diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 28a9c62aa7c5c..b18620417f8b4 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -310,6 +310,7 @@ export const mergeWatchedChatSummary = ( const isStatusEvent = eventKind === "status_change"; const isSummaryEvent = eventKind === "summary_change"; const isDiffStatusEvent = eventKind === "diff_status_change"; + const isContextDirtyEvent = eventKind === "context_dirty"; const updatedAtComparison = compareUpdatedAtInstants( cachedChat.updated_at, watchedChat.updated_at, @@ -325,6 +326,15 @@ export const mergeWatchedChatSummary = ( const nextDiffStatus = isDiffStatusEvent ? watchedChat.diff_status : cachedChat.diff_status; + // Context drift is tracked outside chats.updated_at (it is driven by + // agent context pushes), so apply context_dirty payloads regardless of + // the summary timestamp. Merge rather than replace so the pinned + // resources/changes a single-chat GET populated are preserved while the + // dirty flags update; the open chat refetches the full detail. + const nextContext = + isContextDirtyEvent && watchedChat.context + ? { ...cachedChat.context, ...watchedChat.context } + : cachedChat.context; const nextWorkspaceId = isFreshEnough ? (watchedChat.workspace_id ?? cachedChat.workspace_id) : cachedChat.workspace_id; @@ -358,7 +368,8 @@ export const mergeWatchedChatSummary = ( nextLastModelConfigId === cachedChat.last_model_config_id && nextLastTurnSummary === cachedChat.last_turn_summary && nextHasUnread === cachedChat.has_unread && - nextUpdatedAt === cachedChat.updated_at + nextUpdatedAt === cachedChat.updated_at && + nextContext === cachedChat.context ) { return cachedChat; } @@ -374,6 +385,7 @@ export const mergeWatchedChatSummary = ( last_turn_summary: nextLastTurnSummary, has_unread: nextHasUnread, updated_at: nextUpdatedAt, + context: nextContext, }; }; @@ -1344,6 +1356,39 @@ export const interruptChat = (queryClient: QueryClient, chatId: string) => ({ }, }); +/** + * Re-pins the chat to its agent's latest context snapshot, clearing the + * dirty marker. On success the returned chat (carrying the freshly pinned + * resources) is written into the open-chat cache, and the lightweight + * context flags are propagated across the list caches so the dirty + * indicator clears in the sidebar too. + */ +export const refreshChatContext = ( + queryClient: QueryClient, + chatId: string, +) => ({ + mutationFn: () => API.experimental.refreshChatContext(chatId), + onSuccess: (updatedChat: TypesGen.Chat) => { + queryClient.setQueryData(chatKey(chatId), (cached) => + cached ? { ...cached, context: updatedChat.context } : updatedChat, + ); + const applyContext = (chat: TypesGen.Chat): TypesGen.Chat => + chat.id === chatId ? { ...chat, context: updatedChat.context } : chat; + updateInfiniteChatsCache(queryClient, (chats) => { + let changed = false; + const next = chats.map((chat) => { + const updated = applyContext(chat); + if (updated !== chat) { + changed = true; + } + return updated; + }); + return changed ? next : chats; + }); + updateChildInParentCache(queryClient, applyContext, chatId); + }, +}); + export const deleteChatQueuedMessage = ( queryClient: QueryClient, chatId: string, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 9b580dfec6cc7..5a9ffee0fd3dd 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1614,6 +1614,19 @@ export interface ChatContext { * (empty when healthy). */ readonly error?: string; + /** + * Resources is the chat's pinned context (instruction files and + * skills) the prompt is built from, metadata only (no bodies). It is + * populated only on the single-chat GET response; list and watch + * payloads leave it nil to stay lightweight. + */ + readonly resources?: readonly ChatContextResource[]; + /** + * Changes lists how the pinned context differs from the agent's latest + * snapshot, by source. It is populated only on the single-chat GET + * response and only while the chat is dirty; otherwise nil. + */ + readonly changes?: readonly ChatContextResourceChange[]; } // From codersdk/chats.go @@ -1638,6 +1651,74 @@ export interface ChatContextFilePart { readonly context_file_agent_id?: string; } +// From codersdk/chats.go +/** + * ChatContextResource is one pinned workspace-context resource the chat's + * prompt is built from. It is metadata only; bodies are omitted. Reported + * only on the single-chat GET response. + */ +export interface ChatContextResource { + /** + * Source is the resource locator: the canonical file path for an + * instruction file, or the skill directory for a skill. + */ + readonly source: string; + readonly kind: ChatContextResourceKind; + /** + * SizeBytes is the original payload size in bytes. + */ + readonly size_bytes: number; + /** + * SkillName and SkillDescription are populated only for skill kinds. + */ + readonly skill_name?: string; + readonly skill_description?: string; +} + +// From codersdk/chats.go +/** + * ChatContextResourceChange is one source-level difference between the chat's + * pinned context and the agent's latest snapshot. Reported only on the + * single-chat GET response while the chat is dirty. + */ +export interface ChatContextResourceChange { + /** + * Source is the resource locator that differs. + */ + readonly source: string; + readonly kind: ChatContextResourceKind; + readonly status: ChatContextResourceChangeStatus; + /** + * OldContent and NewContent carry the sanitized instruction-file bodies + * for the pinned and snapshot sides, capped for display. Removed changes + * fill OldContent only, added changes fill NewContent only, and modified + * changes fill both. Empty for skills. + */ + readonly old_content?: string; + readonly new_content?: string; + /** + * SkillName and SkillDescription identify a changed skill: the snapshot + * side for added/modified, the pinned side for removed. Empty for + * instruction files. + */ + readonly skill_name?: string; + readonly skill_description?: string; +} + +// From codersdk/chats.go +export type ChatContextResourceChangeStatus = "added" | "modified" | "removed"; + +export const ChatContextResourceChangeStatuses: ChatContextResourceChangeStatus[] = + ["added", "modified", "removed"]; + +// From codersdk/chats.go +export type ChatContextResourceKind = "instruction_file" | "skill"; + +export const ChatContextResourceKinds: ChatContextResourceKind[] = [ + "instruction_file", + "skill", +]; + // From codersdk/chats.go /** * ChatCostChatBreakdown contains per-root-chat cost aggregation. diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 3c6f85e725db2..80aa853169096 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -1674,6 +1674,7 @@ const AgentChatPage: FC = () => { onMCPSelectionChange={handleMCPSelectionChange} onMCPAuthComplete={handleMCPAuthComplete} lastInjectedContext={chatQuery.data?.last_injected_context} + chatContext={chatQuery.data?.context} /> ); }; diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index bf23e0b4fa390..46b7879bf28f5 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -216,6 +216,7 @@ interface AgentChatPageViewProps { desktopChatId?: string; lastInjectedContext?: readonly TypesGen.ChatMessagePart[]; + chatContext?: TypesGen.ChatContext; } const UnavailableTabMessage: FC<{ message: string }> = ({ message }) => ( @@ -373,6 +374,7 @@ export const AgentChatPageView: FC = ({ onMCPAuthComplete, desktopChatId, lastInjectedContext, + chatContext, }) => { const queryClient = useQueryClient(); const { proxy } = useProxy(); @@ -964,6 +966,7 @@ export const AgentChatPageView: FC = ({ onMCPSelectionChange={onMCPSelectionChange} onMCPAuthComplete={onMCPAuthComplete} lastInjectedContext={lastInjectedContext} + chatContext={chatContext} workspace={workspace} workspaceAgent={workspaceAgent} chatId={agentId} diff --git a/site/src/pages/AgentsPage/AgentsPage.tsx b/site/src/pages/AgentsPage/AgentsPage.tsx index 0584a6bf6488d..e13879dbd92db 100644 --- a/site/src/pages/AgentsPage/AgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentsPage.tsx @@ -645,6 +645,18 @@ const AgentsPage: FC = () => { if (shouldInvalidateFilteredChatList(updatedChat, chatEvent.kind)) { void invalidateChatListQueries(queryClient); } + if (chatEvent.kind === "context_dirty") { + // The watch payload carries only the lightweight + // context flags (the merge above applies them); + // refetch the open chat to pull the pinned + // resources and the change set the single-chat GET + // computes. Only the active chat has an observer, + // so other chats are merely marked stale. + void queryClient.invalidateQueries({ + queryKey: chatKey(updatedChat.id), + exact: true, + }); + } } }); return ws; diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index ea6f59dc5fdfc..809e829c6ff3d 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -162,6 +162,11 @@ interface AgentChatInputProps { // Pass `null` to render fallback values (e.g. when limit is unknown). // Omit entirely to hide the indicator. contextUsage?: AgentContextUsage | null; + // Re-pins the chat to the workspace's latest context snapshot, + // surfaced by the context indicator when the pinned context has + // drifted. + onRefreshContext?: () => void; + isRefreshingContext?: boolean; attachments?: readonly File[]; onAttach?: (files: File[]) => void; onRemoveAttachment?: (attachment: number | File) => void; @@ -367,6 +372,8 @@ export const AgentChatInput: FC = ({ onCancelHistoryEdit, userPromptHistory = [], contextUsage, + onRefreshContext, + isRefreshingContext, attachments = [], onAttach, onRemoveAttachment, @@ -1537,7 +1544,11 @@ export const AgentChatInput: FC = ({ )} {contextUsage !== undefined && ( - + )} {isStreaming && onInterrupt && ( + {onRefreshContext && ( + + )} + + + + ); +}; diff --git a/site/src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx b/site/src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx new file mode 100644 index 0000000000000..66b780a452a3b --- /dev/null +++ b/site/src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx @@ -0,0 +1,108 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, waitFor, within } from "storybook/test"; +import { + MockChatContextClean, + MockChatContextDirty, +} from "#/testHelpers/chatEntities"; +import { ContextUsageIndicator } from "./ContextUsageIndicator"; + +const meta: Meta = { + title: "pages/AgentsPage/ContextUsageIndicator", + component: ContextUsageIndicator, + args: { + onRefreshContext: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +// Clean pin: the ring carries no change marker and the popover lists the +// pinned resources. +export const Clean: Story = { + args: { + usage: { + usedTokens: 12_000, + contextLimitTokens: 200_000, + context: MockChatContextClean, + }, + }, + play: async ({ canvasElement }) => { + const button = within(canvasElement).getByRole("button"); + expect(button.getAttribute("aria-label") ?? "").not.toContain( + "Context changed", + ); + + await userEvent.hover(button); + const body = within(document.body); + await waitFor(() => expect(body.getByText("Context files")).toBeVisible()); + // The list is driven by the pinned resources. + expect(body.getByText("AGENTS.md")).toBeVisible(); + expect(body.getByText("deploy")).toBeVisible(); + // A clean pin offers no refresh affordance. + expect(body.queryByRole("button", { name: "Refresh context" })).toBeNull(); + }, +}; + +// Drifted pin: the ring announces a change, and the popover surfaces refresh +// and a way into the changes dialog. +export const Dirty: Story = { + args: { + usage: { + usedTokens: 12_000, + contextLimitTokens: 200_000, + context: MockChatContextDirty, + }, + }, + play: async ({ canvasElement, args }) => { + const button = within(canvasElement).getByRole("button"); + expect(button.getAttribute("aria-label") ?? "").toContain( + "Context changed", + ); + + await userEvent.hover(button); + const body = within(document.body); + await waitFor(() => + expect(body.getByText("Context changed")).toBeVisible(), + ); + + // Refresh from the popover invokes the handler. + await userEvent.click( + body.getByRole("button", { name: "Refresh context" }), + ); + expect(args.onRefreshContext).toHaveBeenCalledTimes(1); + + // "View changes" opens the diff dialog. + await userEvent.click(body.getByRole("button", { name: "View changes" })); + await waitFor(() => + expect(body.getByText("Context changes")).toBeVisible(), + ); + // The modified skill is listed by name in the dialog. + expect(body.getByText("Deploy the app to production.")).toBeVisible(); + }, +}; + +// Snapshot-level error: the ring shows a distinct error treatment and the +// popover surfaces the error message. +export const SnapshotError: Story = { + args: { + usage: { + usedTokens: 12_000, + contextLimitTokens: 200_000, + context: { + dirty: false, + error: "failed to read AGENTS.md: permission denied", + resources: MockChatContextClean.resources, + }, + }, + }, + play: async ({ canvasElement }) => { + const button = within(canvasElement).getByRole("button"); + await userEvent.hover(button); + const body = within(document.body); + await waitFor(() => expect(body.getByText("Context error")).toBeVisible()); + expect( + body.getByText("failed to read AGENTS.md: permission denied"), + ).toBeVisible(); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx b/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx index 3f36656751d2b..66d3001a15820 100644 --- a/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx +++ b/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx @@ -1,11 +1,13 @@ -import { FileIcon, ZapIcon } from "lucide-react"; +import { FileIcon, TriangleAlertIcon, ZapIcon } from "lucide-react"; import { type FC, useRef, useState } from "react"; -import type { ChatMessagePart } from "#/api/typesGenerated"; +import type { ChatContext, ChatMessagePart } from "#/api/typesGenerated"; +import { Button } from "#/components/Button/Button"; import { Popover, PopoverContent, PopoverTrigger, } from "#/components/Popover/Popover"; +import { Spinner } from "#/components/Spinner/Spinner"; import { Tooltip, TooltipContent, @@ -15,6 +17,7 @@ import { import { cn } from "#/utils/cn"; import { isMobileViewport } from "#/utils/mobile"; import { getPathBasename } from "../utils/path"; +import { ContextChangesDialog } from "./ContextChangesDialog"; import { SvgRingProgress } from "./SvgRingProgress"; export interface AgentContextUsage { @@ -25,12 +28,25 @@ export interface AgentContextUsage { readonly cacheReadTokens?: number; readonly cacheCreationTokens?: number; readonly reasoningTokens?: number; - // Percentage (0–100) at which the context will be compacted. + // Percentage (0-100) at which the context will be compacted. readonly compressionThreshold?: number; - // Last injected context parts (AGENTS.md files and skills). + // Last injected context parts (AGENTS.md files and skills). Used as a + // fallback to list the context when the chat's pinned resources have not + // loaded yet. readonly lastInjectedContext?: readonly ChatMessagePart[]; + // Pinned workspace-context state: the resources the chat is built from and + // whether they have drifted from the agent's latest snapshot. + readonly context?: ChatContext; } +// Normalized popover entries, sourced from either the chat's pinned context +// resources or, as a fallback, the last injected context parts. +type ContextFileItem = { readonly path: string; readonly truncated?: boolean }; +type ContextSkillItem = { + readonly name: string; + readonly description?: string; +}; + const hasFiniteTokenValue = (value: number | undefined): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0; @@ -72,10 +88,13 @@ const RING_STROKE = 2.5; // the user time to move into the popover content. const HOVER_CLOSE_DELAY_MS = 150; -export const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({ - usage, -}) => { +export const ContextUsageIndicator: FC<{ + usage: AgentContextUsage | null; + onRefreshContext?: () => void; + isRefreshingContext?: boolean; +}> = ({ usage, onRefreshContext, isRefreshingContext }) => { const [open, setOpen] = useState(false); + const [changesOpen, setChangesOpen] = useState(false); const closeTimerRef = useRef | null>(null); const cancelClose = () => { @@ -117,21 +136,57 @@ export const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({ ? Math.min(Math.max(percentUsed, 0), 100) : 100; const toneClassName = getIndicatorToneClassName(percentUsed); + + const context = usage?.context; + const isDirty = context?.dirty ?? false; + const contextError = context?.error ?? ""; + const hasContextError = contextError !== ""; + const changes = context?.changes ?? []; + const pinnedResources = context?.resources; + + // Drive the listed context from the chat's pinned resources, falling back + // to the last injected context parts while the pin has not loaded. + const usePinned = (pinnedResources?.length ?? 0) > 0; + const fileItems: readonly ContextFileItem[] = usePinned + ? (pinnedResources ?? []) + .filter((resource) => resource.kind === "instruction_file") + .map((resource) => ({ path: resource.source })) + : (usage?.lastInjectedContext ?? []) + .filter((part) => part.type === "context-file") + .map((part) => ({ + path: part.context_file_path, + truncated: part.context_file_truncated, + })); + const skillItems: readonly ContextSkillItem[] = usePinned + ? (pinnedResources ?? []) + .filter((resource) => resource.kind === "skill") + .map((resource) => ({ + name: resource.skill_name || getPathBasename(resource.source), + description: resource.skill_description, + })) + : (usage?.lastInjectedContext ?? []) + .filter((part) => part.type === "skill") + .map((part) => ({ + name: part.skill_name, + description: part.skill_description, + })); + const hasContextList = fileItems.length > 0 || skillItems.length > 0; + const ariaLabel = hasPercent - ? `Context usage ${percentLabel}. ${formatTokenCount(usedTokens)} of ${formatTokenCount(contextLimitTokens)} tokens used.` - : "Context usage"; + ? `Context usage ${percentLabel}. ${formatTokenCount(usedTokens)} of ${formatTokenCount(contextLimitTokens)} tokens used.${isDirty ? " Context changed." : ""}` + : isDirty + ? "Context usage. Context changed." + : "Context usage"; - // Extract context files and skills from lastInjectedContext. - const contextFiles = - usage?.lastInjectedContext?.filter((p) => p.type === "context-file") ?? []; - const skills = - usage?.lastInjectedContext?.filter((p) => p.type === "skill") ?? []; - const hasInjectedContext = contextFiles.length > 0 || skills.length > 0; + const openChanges = () => { + setChangesOpen(true); + setOpen(false); + }; const panelContent = (
{hasPercent - ? `${percentLabel} – ${formatTokenCountCompact(usedTokens)} / ${formatTokenCountCompact(contextLimitTokens)} context used` + ? `${percentLabel} - ${formatTokenCountCompact(usedTokens)} / ${formatTokenCountCompact(contextLimitTokens)} context used` : "Context usage unavailable"} {hasPercent && usage?.compressionThreshold !== undefined && @@ -140,56 +195,49 @@ export const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({ {`Compacts at ${usage.compressionThreshold}%`}
)} - {hasInjectedContext && ( + {hasContextList && (
- {contextFiles.length > 0 && ( + {fileItems.length > 0 && (
Context files - {contextFiles.map((part) => { - if (part.type !== "context-file") return null; - return ( -
- - - {getPathBasename(part.context_file_path)} + {fileItems.map((file) => ( +
+ + + {getPathBasename(file.path)} + + {file.truncated && ( + + (truncated) - {part.context_file_truncated && ( - - (truncated) - - )} -
- ); - })} + )} +
+ ))}
)} - {skills.length > 0 && ( + {skillItems.length > 0 && (
Skills - {skills.map((part) => { - if (part.type !== "skill") return null; + {skillItems.map((skill) => { const row = (
- {part.skill_name} + {skill.name}
); - if (!part.skill_description) { - return
{row}
; + if (!skill.description) { + return
{row}
; } return ( - +
{row}
@@ -198,7 +246,7 @@ export const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({ sideOffset={4} className="max-w-48 text-xs" > - {part.skill_description} + {skill.description}
); @@ -208,6 +256,45 @@ export const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({ )}
)} + {(isDirty || hasContextError) && ( +
+ {hasContextError ? ( + + + Context error + + ) : ( + + + Context changed + + )} + {hasContextError ? ( + {contextError} + ) : ( + + The workspace context changed since this chat was pinned. + + )} +
+ {changes.length > 0 && ( + + )} + {onRefreshContext && ( + + )} +
+
+ )}
); @@ -225,44 +312,71 @@ export const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({ progressClassName="stroke-current" className={cn("size-icon-sm", toneClassName)} /> + {(isDirty || hasContextError) && ( + + )} ); + const changesDialog = ( + + ); + // On mobile, a tap toggles the popover. On desktop, hover opens // it like a dropdown menu and skill descriptions appear as // nested tooltips to the right (same pattern as ModelSelector). if (isMobileViewport()) { return ( - - {triggerButton} + <> + + {triggerButton} + + {panelContent} + + + {changesDialog} + + ); + } + + return ( + <> + + +
+ {triggerButton} +
+
e.preventDefault()} > {panelContent}
- ); - } - - return ( - - -
- {triggerButton} -
-
- e.preventDefault()} - > - {panelContent} - -
+ {changesDialog} + ); }; diff --git a/site/src/testHelpers/chatEntities.ts b/site/src/testHelpers/chatEntities.ts index cba8d717591cc..b071a6f60182a 100644 --- a/site/src/testHelpers/chatEntities.ts +++ b/site/src/testHelpers/chatEntities.ts @@ -1,5 +1,8 @@ import type { Chat, + ChatContext, + ChatContextResource, + ChatContextResourceChange, ChatMessage, ChatQueuedMessage, MCPServerConfig, @@ -30,6 +33,58 @@ export const MockChat: Chat = { children: [], }; +// Pinned workspace-context resources the prompt is built from. +const MockChatContextResources: ChatContextResource[] = [ + { + source: "/home/coder/AGENTS.md", + kind: "instruction_file", + size_bytes: 248, + }, + { + source: "/home/coder/.coder/skills/deploy", + kind: "skill", + size_bytes: 96, + skill_name: "deploy", + skill_description: "Deploy the app to staging.", + }, +]; + +// Per-source differences between the pinned context and the latest snapshot. +const MockChatContextChanges: ChatContextResourceChange[] = [ + { + source: "/home/coder/AGENTS.md", + kind: "instruction_file", + status: "modified", + old_content: "# AGENTS\n\nBe concise.\n", + new_content: "# AGENTS\n\nBe concise and cite sources.\n", + }, + { + source: "/home/coder/docs/CONTEXT.md", + kind: "instruction_file", + status: "added", + new_content: "# Context\n\nProject overview.\n", + }, + { + source: "/home/coder/.coder/skills/deploy", + kind: "skill", + status: "modified", + skill_name: "deploy", + skill_description: "Deploy the app to production.", + }, +]; + +export const MockChatContextClean: ChatContext = { + dirty: false, + resources: MockChatContextResources, +}; + +export const MockChatContextDirty: ChatContext = { + dirty: true, + dirty_since: "2024-01-02T00:00:00Z", + resources: MockChatContextResources, + changes: MockChatContextChanges, +}; + export const MockMCPServerConfig: MCPServerConfig = { id: "mcp-1", display_name: "MCP Server", From 0c295e55aa60920894ecd65e7f44c0278034c7b6 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 17 Jun 2026 20:58:18 +0000 Subject: [PATCH 09/20] feat(cli): add `coder exp chat context show` and `refresh` Add two user-facing subcommands under `coder exp chat context`: - `show ` reports the workspace context a chat is pinned to (instruction files + skills), whether it has drifted from the agent's latest snapshot, and the per-source change set when dirty. Supports `-o text` (default, with resource/change tables) and `-o json` (the raw chat.context object). - `refresh ` re-pins the chat to the agent's latest snapshot and clears the drift marker. Both use the experimental user client (GetChat / RefreshChatContext). renderChatContextText is factored to take an io.Writer and covered by a unit test (clean, dirty-with-changes, and no-context cases). --- cli/exp_chat.go | 177 +++++++++++++++++++++++++++++++++- cli/exp_chat_internal_test.go | 101 +++++++++++++++++++ 2 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 cli/exp_chat_internal_test.go diff --git a/cli/exp_chat.go b/cli/exp_chat.go index 61c017f172e5f..f34aa44e3fe43 100644 --- a/cli/exp_chat.go +++ b/cli/exp_chat.go @@ -1,14 +1,19 @@ package cli import ( + "encoding/json" "fmt" + "io" "os" "path/filepath" + "time" "github.com/google/uuid" "golang.org/x/xerrors" "github.com/coder/coder/v2/agent/agentcontextconfig" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/serpent" ) @@ -31,17 +36,187 @@ func (r *RootCmd) chatContextCommand() *serpent.Command { return &serpent.Command{ Use: "context", Short: "Manage chat context", - Long: "Add or clear context files and skills for an active chat session.", + Long: "Inspect, refresh, add, or clear the workspace context (instruction " + + "files and skills) for a chat.", Handler: func(i *serpent.Invocation) error { return i.Command.HelpHandler(i) }, Children: []*serpent.Command{ + r.chatContextShowCommand(), + r.chatContextRefreshCommand(), r.chatContextAddCommand(), r.chatContextClearCommand(), }, } } +// chatContextResourceRow is the table view of a pinned context resource. +type chatContextResourceRow struct { + Source string `table:"source,default_sort"` + Kind string `table:"kind"` + Size int64 `table:"size bytes"` + Skill string `table:"skill"` +} + +// chatContextChangeRow is the table view of one source-level context change. +type chatContextChangeRow struct { + Status string `table:"status,default_sort"` + Kind string `table:"kind"` + Source string `table:"source"` + Skill string `table:"skill"` +} + +func (r *RootCmd) chatContextShowCommand() *serpent.Command { + var outputFormat string + cmd := &serpent.Command{ + Use: "show ", + Short: "Show a chat's pinned workspace context and any drift", + Long: "Display the workspace context a chat is pinned to (instruction files " + + "and skills), whether it has drifted from the agent's latest snapshot, " + + "and the per-source changes when it has.", + Middleware: serpent.Chain(serpent.RequireNArgs(1)), + Options: serpent.OptionSet{{ + Name: "output", + Flag: "output", + FlagShorthand: "o", + Default: "text", + Description: "Output format. Supported values: text, json.", + Value: serpent.EnumOf(&outputFormat, "text", "json"), + }}, + Handler: func(inv *serpent.Invocation) error { + ctx := inv.Context() + client, err := r.InitClient(inv) + if err != nil { + return err + } + chatID, err := uuid.Parse(inv.Args[0]) + if err != nil { + return xerrors.Errorf("invalid chat ID %q: %w", inv.Args[0], err) + } + + exp := codersdk.NewExperimentalClient(client) + chat, err := exp.GetChat(ctx, chatID) + if err != nil { + return xerrors.Errorf("get chat: %w", err) + } + + if outputFormat == "json" { + // Emit the context object directly; it is null when the chat + // has no pinned context yet. + out, err := json.MarshalIndent(chat.Context, "", " ") + if err != nil { + return xerrors.Errorf("marshal chat context: %w", err) + } + _, _ = fmt.Fprintln(inv.Stdout, string(out)) + return nil + } + return renderChatContextText(inv.Stdout, chat) + }, + } + return cmd +} + +func renderChatContextText(out io.Writer, chat codersdk.Chat) error { + if chat.Context == nil { + _, _ = fmt.Fprintf(out, "Chat %s has no pinned workspace context.\n", chat.ID) + return nil + } + cc := chat.Context + + status := "clean" + if cc.Dirty { + status = "drifted" + if cc.DirtySince != nil { + status = fmt.Sprintf("drifted (since %s)", cc.DirtySince.Format(time.RFC3339)) + } + } + _, _ = fmt.Fprintf(out, "Context for chat %s\n", chat.ID) + _, _ = fmt.Fprintf(out, " Status: %s\n", status) + if cc.Error != "" { + _, _ = fmt.Fprintf(out, " Error: %s\n", cc.Error) + } + + resourceRows := make([]chatContextResourceRow, 0, len(cc.Resources)) + for _, res := range cc.Resources { + resourceRows = append(resourceRows, chatContextResourceRow{ + Source: res.Source, + Kind: string(res.Kind), + Size: res.SizeBytes, + Skill: res.SkillName, + }) + } + _, _ = fmt.Fprintf(out, "\nPinned resources (%d)\n", len(resourceRows)) + if len(resourceRows) == 0 { + _, _ = fmt.Fprintln(out, " (none)") + } else { + tbl, err := cliui.DisplayTable(resourceRows, "source", nil) + if err != nil { + return xerrors.Errorf("render resources: %w", err) + } + _, _ = fmt.Fprintln(out, tbl) + } + + if !cc.Dirty { + return nil + } + + changeRows := make([]chatContextChangeRow, 0, len(cc.Changes)) + for _, change := range cc.Changes { + changeRows = append(changeRows, chatContextChangeRow{ + Status: string(change.Status), + Kind: string(change.Kind), + Source: change.Source, + Skill: change.SkillName, + }) + } + _, _ = fmt.Fprintf(out, "\nChanges vs latest snapshot (%d)\n", len(changeRows)) + if len(changeRows) == 0 { + _, _ = fmt.Fprintln(out, " (none)") + } else { + tbl, err := cliui.DisplayTable(changeRows, "status", nil) + if err != nil { + return xerrors.Errorf("render changes: %w", err) + } + _, _ = fmt.Fprintln(out, tbl) + } + _, _ = fmt.Fprintf(out, "Run 'coder chat context refresh %s' to adopt the latest context.\n", chat.ID) + return nil +} + +func (r *RootCmd) chatContextRefreshCommand() *serpent.Command { + cmd := &serpent.Command{ + Use: "refresh ", + Short: "Refresh a chat's workspace context to the latest snapshot", + Long: "Re-pin a chat to the workspace agent's latest context snapshot and " + + "clear the drift marker. The chat's next turn uses the refreshed context.", + Middleware: serpent.Chain(serpent.RequireNArgs(1)), + Handler: func(inv *serpent.Invocation) error { + ctx := inv.Context() + client, err := r.InitClient(inv) + if err != nil { + return err + } + chatID, err := uuid.Parse(inv.Args[0]) + if err != nil { + return xerrors.Errorf("invalid chat ID %q: %w", inv.Args[0], err) + } + + exp := codersdk.NewExperimentalClient(client) + chat, err := exp.RefreshChatContext(ctx, chatID) + if err != nil { + return xerrors.Errorf("refresh chat context: %w", err) + } + + _, _ = fmt.Fprintf(inv.Stdout, "Refreshed context for chat %s.\n", chatID) + if chat.Context != nil && chat.Context.Error != "" { + _, _ = fmt.Fprintf(inv.Stdout, "Snapshot reported an error: %s\n", chat.Context.Error) + } + return nil + }, + } + return cmd +} + func (*RootCmd) chatContextAddCommand() *serpent.Command { var ( dir string diff --git a/cli/exp_chat_internal_test.go b/cli/exp_chat_internal_test.go new file mode 100644 index 0000000000000..e1bac3eb9a30f --- /dev/null +++ b/cli/exp_chat_internal_test.go @@ -0,0 +1,101 @@ +package cli + +import ( + "bytes" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/codersdk" +) + +func TestRenderChatContextText(t *testing.T) { + t.Parallel() + + chatID := uuid.MustParse("11111111-1111-4111-8111-111111111111") + + t.Run("NoPinnedContext", func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + require.NoError(t, renderChatContextText(&buf, codersdk.Chat{ID: chatID})) + require.Contains(t, buf.String(), "has no pinned workspace context") + }) + + t.Run("CleanListsResourcesWithoutChanges", func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + require.NoError(t, renderChatContextText(&buf, codersdk.Chat{ + ID: chatID, + Context: &codersdk.ChatContext{ + Dirty: false, + Resources: []codersdk.ChatContextResource{ + { + Source: "/home/coder/AGENTS.md", + Kind: codersdk.ChatContextResourceKindInstructionFile, + SizeBytes: 12, + }, + { + Source: "/home/coder/.coder/skills/deploy", + Kind: codersdk.ChatContextResourceKindSkill, + SizeBytes: 34, + SkillName: "deploy", + }, + }, + }, + })) + out := buf.String() + require.Contains(t, out, "Status: clean") + require.Contains(t, out, "/home/coder/AGENTS.md") + require.Contains(t, out, "deploy") + // A clean chat shows no change section or refresh hint. + require.NotContains(t, out, "Changes vs latest snapshot") + require.NotContains(t, out, "refresh") + }) + + t.Run("DirtyShowsChangesAndRefreshHint", func(t *testing.T) { + t.Parallel() + + since := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) + var buf bytes.Buffer + require.NoError(t, renderChatContextText(&buf, codersdk.Chat{ + ID: chatID, + Context: &codersdk.ChatContext{ + Dirty: true, + DirtySince: &since, + Error: "two sources failed to resolve", + Resources: []codersdk.ChatContextResource{ + { + Source: "/home/coder/AGENTS.md", + Kind: codersdk.ChatContextResourceKindInstructionFile, + }, + }, + Changes: []codersdk.ChatContextResourceChange{ + { + Source: "/home/coder/AGENTS.md", + Kind: codersdk.ChatContextResourceKindInstructionFile, + Status: codersdk.ChatContextResourceChangeStatusModified, + OldContent: "old", + NewContent: "new", + }, + { + Source: "/home/coder/.coder/skills/deploy", + Kind: codersdk.ChatContextResourceKindSkill, + Status: codersdk.ChatContextResourceChangeStatusAdded, + SkillName: "deploy", + }, + }, + }, + })) + out := buf.String() + require.Contains(t, out, "Status: drifted (since 2024-01-02T03:04:05Z)") + require.Contains(t, out, "two sources failed to resolve") + require.Contains(t, out, "Changes vs latest snapshot (2)") + require.Contains(t, out, "modified") + require.Contains(t, out, "added") + require.Contains(t, out, "chat context refresh 11111111-1111-4111-8111-111111111111") + }) +} From aa5e95d7d6c32729dac5b23c14a083aa8d23784f Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 17 Jun 2026 21:21:21 +0000 Subject: [PATCH 10/20] fix(site): drop empty-name entries from the context popover The chat context popover lists pinned instruction files and skills, falling back to the agent's last injected context when the pin has not loaded. That fallback can contain an empty context-file marker (no path), which rendered as a nameless row under a "Context files" heading. Filter out file entries with no path and skill entries with no name in both the pinned and fallback branches, so an empty marker no longer produces a blank row (and the "Context files" section is hidden when it would otherwise be empty). Add a regression play story covering the drifted, no-pinned-resources fallback case. --- .../ContextUsageIndicator.stories.tsx | 31 ++++++++++ .../components/ContextUsageIndicator.tsx | 57 +++++++++++-------- site/src/testHelpers/chatEntities.ts | 8 +++ 3 files changed, 73 insertions(+), 23 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx b/site/src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx index 66b780a452a3b..51591d2babd03 100644 --- a/site/src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx +++ b/site/src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx @@ -3,6 +3,7 @@ import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import { MockChatContextClean, MockChatContextDirty, + MockLastInjectedContextEmptyFile, } from "#/testHelpers/chatEntities"; import { ContextUsageIndicator } from "./ContextUsageIndicator"; @@ -82,6 +83,36 @@ export const Dirty: Story = { }, }; +// Regression: a dirty pin whose pinned resources have not loaded falls back to +// the agent's injected context, which can carry an empty context-file marker. +// The popover must skip it rather than render a nameless "Context files" row, +// while still surfacing the drift affordances. +export const DirtyEmptyInjectedContext: Story = { + args: { + usage: { + usedTokens: 12_000, + contextLimitTokens: 200_000, + lastInjectedContext: MockLastInjectedContextEmptyFile, + context: { + dirty: true, + changes: MockChatContextDirty.changes, + }, + }, + }, + play: async ({ canvasElement }) => { + const button = within(canvasElement).getByRole("button"); + await userEvent.hover(button); + const body = within(document.body); + // The drift affordances still render. + await waitFor(() => + expect(body.getByText("Context changed")).toBeVisible(), + ); + expect(body.getByRole("button", { name: "Refresh context" })).toBeVisible(); + // The empty injected marker must not produce a nameless file list. + expect(body.queryByText("Context files")).toBeNull(); + }, +}; + // Snapshot-level error: the ring shows a distinct error treatment and the // popover surfaces the error message. export const SnapshotError: Story = { diff --git a/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx b/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx index 66d3001a15820..950ab93138328 100644 --- a/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx +++ b/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx @@ -147,29 +147,40 @@ export const ContextUsageIndicator: FC<{ // Drive the listed context from the chat's pinned resources, falling back // to the last injected context parts while the pin has not loaded. const usePinned = (pinnedResources?.length ?? 0) > 0; - const fileItems: readonly ContextFileItem[] = usePinned - ? (pinnedResources ?? []) - .filter((resource) => resource.kind === "instruction_file") - .map((resource) => ({ path: resource.source })) - : (usage?.lastInjectedContext ?? []) - .filter((part) => part.type === "context-file") - .map((part) => ({ - path: part.context_file_path, - truncated: part.context_file_truncated, - })); - const skillItems: readonly ContextSkillItem[] = usePinned - ? (pinnedResources ?? []) - .filter((resource) => resource.kind === "skill") - .map((resource) => ({ - name: resource.skill_name || getPathBasename(resource.source), - description: resource.skill_description, - })) - : (usage?.lastInjectedContext ?? []) - .filter((part) => part.type === "skill") - .map((part) => ({ - name: part.skill_name, - description: part.skill_description, - })); + const fileItems: readonly ContextFileItem[] = ( + usePinned + ? (pinnedResources ?? []) + .filter((resource) => resource.kind === "instruction_file") + .map((resource) => ({ path: resource.source })) + : (usage?.lastInjectedContext ?? []) + .filter((part) => part.type === "context-file") + .map((part) => ({ + path: part.context_file_path, + truncated: part.context_file_truncated, + })) + ) + // Drop entries with no usable path. The injected-context fallback can + // carry an empty context-file marker, which would otherwise render as a + // nameless "Context files" row. + .filter((file) => file.path.trim().length > 0); + const skillItems: readonly ContextSkillItem[] = ( + usePinned + ? (pinnedResources ?? []) + .filter((resource) => resource.kind === "skill") + .map((resource) => ({ + name: resource.skill_name || getPathBasename(resource.source), + description: resource.skill_description, + })) + : (usage?.lastInjectedContext ?? []) + .filter((part) => part.type === "skill") + .map((part) => ({ + name: part.skill_name, + description: part.skill_description, + })) + ) + // Drop entries with no usable name so an empty skill marker never renders + // as a blank row. + .filter((skill) => skill.name.trim().length > 0); const hasContextList = fileItems.length > 0 || skillItems.length > 0; const ariaLabel = hasPercent diff --git a/site/src/testHelpers/chatEntities.ts b/site/src/testHelpers/chatEntities.ts index b071a6f60182a..d25e4949aef9e 100644 --- a/site/src/testHelpers/chatEntities.ts +++ b/site/src/testHelpers/chatEntities.ts @@ -4,6 +4,7 @@ import type { ChatContextResource, ChatContextResourceChange, ChatMessage, + ChatMessagePart, ChatQueuedMessage, MCPServerConfig, } from "#/api/typesGenerated"; @@ -85,6 +86,13 @@ export const MockChatContextDirty: ChatContext = { changes: MockChatContextChanges, }; +// Injected-context fallback whose only context-file marker has no path. The +// agent emits this empty placeholder for skill-only additions; the context +// indicator must skip it rather than render a nameless "Context files" row. +export const MockLastInjectedContextEmptyFile: readonly ChatMessagePart[] = [ + { type: "context-file", context_file_path: "" }, +]; + export const MockMCPServerConfig: MCPServerConfig = { id: "mcp-1", display_name: "MCP Server", From 473940572363282ea28b17c6f3be1e3e1e994583 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 17 Jun 2026 22:37:38 +0000 Subject: [PATCH 11/20] feat(cli,agentsocket): manage workspace context sources over the agent socket Aligns 'coder exp chat context' with the Workspace Context Sources RFC. Previously 'add' only did a one-shot inject into a chat; sources could not be listed, inspected, or removed, and there was no in-workspace path to the agent's context Manager. Agent socket (agent/agentsocket): - Extend the DRPC proto with ContextSources/GetContextSource/ AddContextSource/RemoveContextSource/GetContextSnapshot/ResyncContext. - Add a ContextManager interface + WithContextManager option; implement the RPCs against *agentcontext.Manager and wire it in initSocketServer. - Add typed client methods + display structs. CLI (coder exp chat context): - list / show / add / remove : agent-local source CRUD over the socket (in-workspace; no coderd, no tailnet). - add --chat : keeps the legacy one-shot inject. - refresh []: refreshes that chat (anywhere); no-arg, in workspace, re-resolves the agent (resync barrier) then refreshes every drifted chat. - show (chat drift) is replaced by show (source); chat drift remains visible in the UI and via refresh . Tests: agentsocket context RPC round-trip with a fake manager; parseChatID. --- agent/agent.go | 1 + agent/agentsocket/client.go | 126 +- agent/agentsocket/context_test.go | 180 +++ agent/agentsocket/proto/agentsocket.pb.go | 1261 ++++++++++++++++- agent/agentsocket/proto/agentsocket.proto | 85 ++ .../agentsocket/proto/agentsocket_drpc.pb.go | 242 +++- agent/agentsocket/server.go | 5 +- agent/agentsocket/service.go | 128 +- cli/exp_chat.go | 448 +++--- cli/exp_chat_internal_test.go | 95 +- 10 files changed, 2215 insertions(+), 356 deletions(-) create mode 100644 agent/agentsocket/context_test.go diff --git a/agent/agent.go b/agent/agent.go index c8a62fd2da54c..c9c3cc71c8563 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -554,6 +554,7 @@ func (a *agent) initSocketServer() { server, err := agentsocket.NewServer( a.logger.Named("socket"), agentsocket.WithPath(a.socketPath), + agentsocket.WithContextManager(a.contextManager), ) if err != nil { a.logger.Error(a.hardCtx, "failed to create socket server", slog.Error(err), slog.F("path", a.socketPath)) diff --git a/agent/agentsocket/client.go b/agent/agentsocket/client.go index d4a3a41f4cafb..c038edf1fd21c 100644 --- a/agent/agentsocket/client.go +++ b/agent/agentsocket/client.go @@ -16,7 +16,8 @@ import ( type Option func(*options) type options struct { - path string + path string + contextManager ContextManager } // WithPath sets the socket path. If not provided or empty, the client will @@ -30,6 +31,14 @@ func WithPath(path string) Option { } } +// WithContextManager supplies the workspace-context Manager the server uses to +// serve context source CRUD. Server-only; ignored by the client. +func WithContextManager(cm ContextManager) Option { + return func(opts *options) { + opts.contextManager = cm + } +} + // Client provides a client for communicating with the workspace agentsocket API. type Client struct { client proto.DRPCAgentSocketClient @@ -157,6 +166,92 @@ func (c *Client) UpdateAppStatus(ctx context.Context, req *agentproto.UpdateAppS return c.client.UpdateAppStatus(ctx, req) } +// ContextSources lists the workspace-context sources registered on the agent. +func (c *Client) ContextSources(ctx context.Context) ([]ContextSource, error) { + resp, err := c.client.ContextSources(ctx, &proto.ContextSourcesRequest{}) + if err != nil { + return nil, err + } + sources := make([]ContextSource, 0, len(resp.Sources)) + for _, s := range resp.Sources { + sources = append(sources, ContextSource{Path: s.GetPath()}) + } + return sources, nil +} + +// GetContextSource returns a single registered source. The path is +// canonicalized by the agent before matching. +func (c *Client) GetContextSource(ctx context.Context, path string) (ContextSource, error) { + resp, err := c.client.GetContextSource(ctx, &proto.GetContextSourceRequest{Path: path}) + if err != nil { + return ContextSource{}, err + } + return ContextSource{Path: resp.GetSource().GetPath()}, nil +} + +// AddContextSource registers a new scan root on the agent. +func (c *Client) AddContextSource(ctx context.Context, path string) (ContextSource, error) { + resp, err := c.client.AddContextSource(ctx, &proto.AddContextSourceRequest{Path: path}) + if err != nil { + return ContextSource{}, err + } + return ContextSource{Path: resp.GetSource().GetPath()}, nil +} + +// RemoveContextSource removes a previously-registered scan root. +func (c *Client) RemoveContextSource(ctx context.Context, path string) error { + _, err := c.client.RemoveContextSource(ctx, &proto.RemoveContextSourceRequest{Path: path}) + return err +} + +// GetContextSnapshot returns the agent's current resolved snapshot without +// forcing a re-walk. +func (c *Client) GetContextSnapshot(ctx context.Context) (ContextSnapshot, error) { + resp, err := c.client.GetContextSnapshot(ctx, &proto.ContextSnapshotRequest{}) + if err != nil { + return ContextSnapshot{}, err + } + return contextSnapshotFromProto(resp.GetSnapshot()), nil +} + +// ResyncContext forces a re-walk and synchronous push, returning the resulting +// snapshot. Use it as a barrier before fanning out a refresh. +func (c *Client) ResyncContext(ctx context.Context) (ContextSnapshot, error) { + resp, err := c.client.ResyncContext(ctx, &proto.ResyncContextRequest{}) + if err != nil { + return ContextSnapshot{}, err + } + return contextSnapshotFromProto(resp.GetSnapshot()), nil +} + +func contextSnapshotFromProto(s *proto.ContextSnapshot) ContextSnapshot { + if s == nil { + return ContextSnapshot{} + } + out := ContextSnapshot{ + Version: s.GetVersion(), + AggregateHash: s.GetAggregateHash(), + Resources: make([]ContextResource, 0, len(s.GetResources())), + PayloadBytes: s.GetPayloadBytes(), + SnapshotError: s.GetSnapshotError(), + } + for _, r := range s.GetResources() { + out.Resources = append(out.Resources, ContextResource{ + ID: r.GetId(), + Kind: r.GetKind(), + Source: r.GetSource(), + SourcePath: r.GetSourcePath(), + ContentHash: r.GetContentHash(), + SizeBytes: r.GetSizeBytes(), + Status: r.GetStatus(), + Error: r.GetError(), + Name: r.GetName(), + Description: r.GetDescription(), + }) + } + return out +} + // SyncStatusResponse contains the status information for a unit. type SyncStatusResponse struct { UnitName unit.ID `table:"unit,default_sort" json:"unit_name"` @@ -179,3 +274,32 @@ type DependencyInfo struct { CurrentStatus unit.Status `table:"current status" json:"current_status"` IsSatisfied bool `table:"satisfied" json:"is_satisfied"` } + +// ContextSource is a registered workspace-context scan root. +type ContextSource struct { + Path string `table:"path,default_sort" json:"path"` +} + +// ContextResource is a resolved workspace-context resource. Payload bytes are +// never carried over the socket. +type ContextResource struct { + Kind string `table:"kind,default_sort" json:"kind"` + Name string `table:"name" json:"name"` + Source string `table:"source" json:"source"` + SourcePath string `table:"source path" json:"source_path"` + Status string `table:"status" json:"status"` + SizeBytes uint64 `table:"size bytes" json:"size_bytes"` + Error string `table:"error" json:"error"` + Description string `table:"-" json:"description"` + ID string `table:"-" json:"id"` + ContentHash string `table:"-" json:"content_hash"` +} + +// ContextSnapshot is the agent's resolved workspace-context state. +type ContextSnapshot struct { + Version uint64 `json:"version"` + AggregateHash string `json:"aggregate_hash"` + Resources []ContextResource `json:"resources"` + PayloadBytes uint64 `json:"payload_bytes"` + SnapshotError string `json:"snapshot_error"` +} diff --git a/agent/agentsocket/context_test.go b/agent/agentsocket/context_test.go new file mode 100644 index 0000000000000..423d9300dcd4d --- /dev/null +++ b/agent/agentsocket/context_test.go @@ -0,0 +1,180 @@ +package agentsocket_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/agent/agentcontext" + "github.com/coder/coder/v2/agent/agentsocket" + "github.com/coder/coder/v2/testutil" +) + +// fakeContextManager is an in-memory agentsocket.ContextManager for tests. +type fakeContextManager struct { + sources []agentcontext.Source + snapshot agentcontext.Snapshot + resyncErr error + resynced bool +} + +func (f *fakeContextManager) Sources() []agentcontext.Source { return f.sources } + +func (f *fakeContextManager) HasSource(path string) (string, bool) { + for _, s := range f.sources { + if s.Path == path { + return s.Path, true + } + } + return "", false +} + +func (f *fakeContextManager) AddSource(s agentcontext.Source) (agentcontext.Source, error) { + for _, existing := range f.sources { + if existing.Path == s.Path { + return existing, nil + } + } + f.sources = append(f.sources, s) + return s, nil +} + +func (f *fakeContextManager) RemoveSource(path string) error { + for i, s := range f.sources { + if s.Path == path { + f.sources = append(f.sources[:i], f.sources[i+1:]...) + return nil + } + } + return agentcontext.ErrSourceNotFound +} + +func (f *fakeContextManager) Snapshot() agentcontext.Snapshot { return f.snapshot } + +func (f *fakeContextManager) Resync(_ context.Context) (agentcontext.Snapshot, error) { + if f.resyncErr != nil { + return agentcontext.Snapshot{}, f.resyncErr + } + f.resynced = true + return f.snapshot, nil +} + +func TestDRPCAgentSocketService_Context(t *testing.T) { + t.Parallel() + + t.Run("SourceCRUDAndSnapshot", func(t *testing.T) { + t.Parallel() + + const sourcePath = "/home/coder/project" + cm := &fakeContextManager{ + snapshot: agentcontext.Snapshot{ + Version: 7, + Resources: []agentcontext.Resource{{ + ID: "instruction_file:" + sourcePath + "/AGENTS.md", + Kind: agentcontext.KindInstructionFile, + Source: sourcePath + "/AGENTS.md", + SourcePath: sourcePath, + SizeBytes: 42, + Status: agentcontext.StatusOK, + Description: "be concise", + }, { + // A built-in resource (no source path) the show filter must skip. + ID: "instruction_file:/home/coder/.coder/AGENTS.md", + Kind: agentcontext.KindInstructionFile, + Source: "/home/coder/.coder/AGENTS.md", + Status: agentcontext.StatusOK, + }}, + }, + } + + socketPath := testutil.AgentSocketPath(t) + ctx := testutil.Context(t, testutil.WaitShort) + server, err := agentsocket.NewServer( + slog.Make().Leveled(slog.LevelDebug), + agentsocket.WithPath(socketPath), + agentsocket.WithContextManager(cm), + ) + require.NoError(t, err) + defer server.Close() + + client := newSocketClient(ctx, t, socketPath) + + // Add a source. + src, err := client.AddContextSource(ctx, sourcePath) + require.NoError(t, err) + require.Equal(t, sourcePath, src.Path) + + // It shows up in the list. + sources, err := client.ContextSources(ctx) + require.NoError(t, err) + require.Len(t, sources, 1) + require.Equal(t, sourcePath, sources[0].Path) + + // Get the registered source. + got, err := client.GetContextSource(ctx, sourcePath) + require.NoError(t, err) + require.Equal(t, sourcePath, got.Path) + + // Getting an unregistered source errors. + _, err = client.GetContextSource(ctx, "/nope") + require.Error(t, err) + + // Snapshot carries resources with their source path stamped. + snap, err := client.GetContextSnapshot(ctx) + require.NoError(t, err) + require.EqualValues(t, 7, snap.Version) + require.Len(t, snap.Resources, 2) + require.Equal(t, agentcontext.KindInstructionFile.String(), snap.Resources[0].Kind) + require.Equal(t, sourcePath, snap.Resources[0].SourcePath) + require.EqualValues(t, 42, snap.Resources[0].SizeBytes) + + // Remove the source; removing again reports not found. + require.NoError(t, client.RemoveContextSource(ctx, sourcePath)) + err = client.RemoveContextSource(ctx, sourcePath) + require.Error(t, err) + require.Contains(t, err.Error(), "not found") + }) + + t.Run("Resync", func(t *testing.T) { + t.Parallel() + + cm := &fakeContextManager{snapshot: agentcontext.Snapshot{Version: 3}} + socketPath := testutil.AgentSocketPath(t) + ctx := testutil.Context(t, testutil.WaitShort) + server, err := agentsocket.NewServer( + slog.Make().Leveled(slog.LevelDebug), + agentsocket.WithPath(socketPath), + agentsocket.WithContextManager(cm), + ) + require.NoError(t, err) + defer server.Close() + + client := newSocketClient(ctx, t, socketPath) + + snap, err := client.ResyncContext(ctx) + require.NoError(t, err) + require.EqualValues(t, 3, snap.Version) + require.True(t, cm.resynced) + }) + + t.Run("NoManagerErrors", func(t *testing.T) { + t.Parallel() + + socketPath := testutil.AgentSocketPath(t) + ctx := testutil.Context(t, testutil.WaitShort) + // No WithContextManager: the context RPCs must fail cleanly. + server, err := agentsocket.NewServer( + slog.Make().Leveled(slog.LevelDebug), + agentsocket.WithPath(socketPath), + ) + require.NoError(t, err) + defer server.Close() + + client := newSocketClient(ctx, t, socketPath) + + _, err = client.ContextSources(ctx) + require.Error(t, err) + }) +} diff --git a/agent/agentsocket/proto/agentsocket.pb.go b/agent/agentsocket/proto/agentsocket.pb.go index 298664b2d95f7..a724b87c99a1a 100644 --- a/agent/agentsocket/proto/agentsocket.pb.go +++ b/agent/agentsocket/proto/agentsocket.pb.go @@ -795,6 +795,787 @@ func (x *SyncListResponse) GetUnits() []*UnitInfo { return nil } +// ContextSource is a user-declared scan root the agent watches for +// workspace context (instruction files, skills, MCP configs) in +// addition to its built-in defaults. Identity is the canonical path. +type ContextSource struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` +} + +func (x *ContextSource) Reset() { + *x = ContextSource{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ContextSource) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ContextSource) ProtoMessage() {} + +func (x *ContextSource) ProtoReflect() protoreflect.Message { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[16] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ContextSource.ProtoReflect.Descriptor instead. +func (*ContextSource) Descriptor() ([]byte, []int) { + return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{16} +} + +func (x *ContextSource) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type ContextSourcesRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *ContextSourcesRequest) Reset() { + *x = ContextSourcesRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ContextSourcesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ContextSourcesRequest) ProtoMessage() {} + +func (x *ContextSourcesRequest) ProtoReflect() protoreflect.Message { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[17] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ContextSourcesRequest.ProtoReflect.Descriptor instead. +func (*ContextSourcesRequest) Descriptor() ([]byte, []int) { + return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{17} +} + +type ContextSourcesResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Sources []*ContextSource `protobuf:"bytes,1,rep,name=sources,proto3" json:"sources,omitempty"` +} + +func (x *ContextSourcesResponse) Reset() { + *x = ContextSourcesResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ContextSourcesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ContextSourcesResponse) ProtoMessage() {} + +func (x *ContextSourcesResponse) ProtoReflect() protoreflect.Message { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[18] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ContextSourcesResponse.ProtoReflect.Descriptor instead. +func (*ContextSourcesResponse) Descriptor() ([]byte, []int) { + return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{18} +} + +func (x *ContextSourcesResponse) GetSources() []*ContextSource { + if x != nil { + return x.Sources + } + return nil +} + +type GetContextSourceRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` +} + +func (x *GetContextSourceRequest) Reset() { + *x = GetContextSourceRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetContextSourceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetContextSourceRequest) ProtoMessage() {} + +func (x *GetContextSourceRequest) ProtoReflect() protoreflect.Message { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[19] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetContextSourceRequest.ProtoReflect.Descriptor instead. +func (*GetContextSourceRequest) Descriptor() ([]byte, []int) { + return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{19} +} + +func (x *GetContextSourceRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type GetContextSourceResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Source *ContextSource `protobuf:"bytes,1,opt,name=source,proto3" json:"source,omitempty"` +} + +func (x *GetContextSourceResponse) Reset() { + *x = GetContextSourceResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetContextSourceResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetContextSourceResponse) ProtoMessage() {} + +func (x *GetContextSourceResponse) ProtoReflect() protoreflect.Message { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[20] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetContextSourceResponse.ProtoReflect.Descriptor instead. +func (*GetContextSourceResponse) Descriptor() ([]byte, []int) { + return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{20} +} + +func (x *GetContextSourceResponse) GetSource() *ContextSource { + if x != nil { + return x.Source + } + return nil +} + +type AddContextSourceRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` +} + +func (x *AddContextSourceRequest) Reset() { + *x = AddContextSourceRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AddContextSourceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddContextSourceRequest) ProtoMessage() {} + +func (x *AddContextSourceRequest) ProtoReflect() protoreflect.Message { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[21] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddContextSourceRequest.ProtoReflect.Descriptor instead. +func (*AddContextSourceRequest) Descriptor() ([]byte, []int) { + return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{21} +} + +func (x *AddContextSourceRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type AddContextSourceResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Source *ContextSource `protobuf:"bytes,1,opt,name=source,proto3" json:"source,omitempty"` +} + +func (x *AddContextSourceResponse) Reset() { + *x = AddContextSourceResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AddContextSourceResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddContextSourceResponse) ProtoMessage() {} + +func (x *AddContextSourceResponse) ProtoReflect() protoreflect.Message { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[22] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddContextSourceResponse.ProtoReflect.Descriptor instead. +func (*AddContextSourceResponse) Descriptor() ([]byte, []int) { + return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{22} +} + +func (x *AddContextSourceResponse) GetSource() *ContextSource { + if x != nil { + return x.Source + } + return nil +} + +type RemoveContextSourceRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` +} + +func (x *RemoveContextSourceRequest) Reset() { + *x = RemoveContextSourceRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *RemoveContextSourceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveContextSourceRequest) ProtoMessage() {} + +func (x *RemoveContextSourceRequest) ProtoReflect() protoreflect.Message { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[23] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveContextSourceRequest.ProtoReflect.Descriptor instead. +func (*RemoveContextSourceRequest) Descriptor() ([]byte, []int) { + return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{23} +} + +func (x *RemoveContextSourceRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type RemoveContextSourceResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *RemoveContextSourceResponse) Reset() { + *x = RemoveContextSourceResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *RemoveContextSourceResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveContextSourceResponse) ProtoMessage() {} + +func (x *RemoveContextSourceResponse) ProtoReflect() protoreflect.Message { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[24] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveContextSourceResponse.ProtoReflect.Descriptor instead. +func (*RemoveContextSourceResponse) Descriptor() ([]byte, []int) { + return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{24} +} + +// ContextResource is the on-wire form of a resolved context resource. +// Payload bytes are never sent over the socket; they ship to coderd via +// the drpc PushContextState path. Mirrors agentcontext.Resource minus +// the payload. +type ContextResource struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"` + Source string `protobuf:"bytes,3,opt,name=source,proto3" json:"source,omitempty"` + SourcePath string `protobuf:"bytes,4,opt,name=source_path,json=sourcePath,proto3" json:"source_path,omitempty"` + ContentHash string `protobuf:"bytes,5,opt,name=content_hash,json=contentHash,proto3" json:"content_hash,omitempty"` + SizeBytes uint64 `protobuf:"varint,6,opt,name=size_bytes,json=sizeBytes,proto3" json:"size_bytes,omitempty"` + Status string `protobuf:"bytes,7,opt,name=status,proto3" json:"status,omitempty"` + Error string `protobuf:"bytes,8,opt,name=error,proto3" json:"error,omitempty"` + Name string `protobuf:"bytes,9,opt,name=name,proto3" json:"name,omitempty"` + Description string `protobuf:"bytes,10,opt,name=description,proto3" json:"description,omitempty"` +} + +func (x *ContextResource) Reset() { + *x = ContextResource{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ContextResource) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ContextResource) ProtoMessage() {} + +func (x *ContextResource) ProtoReflect() protoreflect.Message { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[25] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ContextResource.ProtoReflect.Descriptor instead. +func (*ContextResource) Descriptor() ([]byte, []int) { + return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{25} +} + +func (x *ContextResource) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ContextResource) GetKind() string { + if x != nil { + return x.Kind + } + return "" +} + +func (x *ContextResource) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +func (x *ContextResource) GetSourcePath() string { + if x != nil { + return x.SourcePath + } + return "" +} + +func (x *ContextResource) GetContentHash() string { + if x != nil { + return x.ContentHash + } + return "" +} + +func (x *ContextResource) GetSizeBytes() uint64 { + if x != nil { + return x.SizeBytes + } + return 0 +} + +func (x *ContextResource) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *ContextResource) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *ContextResource) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ContextResource) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +// ContextSnapshot is the agent's resolved context state. +type ContextSnapshot struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Version uint64 `protobuf:"varint,1,opt,name=version,proto3" json:"version,omitempty"` + AggregateHash string `protobuf:"bytes,2,opt,name=aggregate_hash,json=aggregateHash,proto3" json:"aggregate_hash,omitempty"` + Resources []*ContextResource `protobuf:"bytes,3,rep,name=resources,proto3" json:"resources,omitempty"` + PayloadBytes uint64 `protobuf:"varint,4,opt,name=payload_bytes,json=payloadBytes,proto3" json:"payload_bytes,omitempty"` + SnapshotError string `protobuf:"bytes,5,opt,name=snapshot_error,json=snapshotError,proto3" json:"snapshot_error,omitempty"` +} + +func (x *ContextSnapshot) Reset() { + *x = ContextSnapshot{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ContextSnapshot) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ContextSnapshot) ProtoMessage() {} + +func (x *ContextSnapshot) ProtoReflect() protoreflect.Message { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[26] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ContextSnapshot.ProtoReflect.Descriptor instead. +func (*ContextSnapshot) Descriptor() ([]byte, []int) { + return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{26} +} + +func (x *ContextSnapshot) GetVersion() uint64 { + if x != nil { + return x.Version + } + return 0 +} + +func (x *ContextSnapshot) GetAggregateHash() string { + if x != nil { + return x.AggregateHash + } + return "" +} + +func (x *ContextSnapshot) GetResources() []*ContextResource { + if x != nil { + return x.Resources + } + return nil +} + +func (x *ContextSnapshot) GetPayloadBytes() uint64 { + if x != nil { + return x.PayloadBytes + } + return 0 +} + +func (x *ContextSnapshot) GetSnapshotError() string { + if x != nil { + return x.SnapshotError + } + return "" +} + +type ContextSnapshotRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *ContextSnapshotRequest) Reset() { + *x = ContextSnapshotRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ContextSnapshotRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ContextSnapshotRequest) ProtoMessage() {} + +func (x *ContextSnapshotRequest) ProtoReflect() protoreflect.Message { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[27] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ContextSnapshotRequest.ProtoReflect.Descriptor instead. +func (*ContextSnapshotRequest) Descriptor() ([]byte, []int) { + return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{27} +} + +type ContextSnapshotResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Snapshot *ContextSnapshot `protobuf:"bytes,1,opt,name=snapshot,proto3" json:"snapshot,omitempty"` +} + +func (x *ContextSnapshotResponse) Reset() { + *x = ContextSnapshotResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ContextSnapshotResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ContextSnapshotResponse) ProtoMessage() {} + +func (x *ContextSnapshotResponse) ProtoReflect() protoreflect.Message { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[28] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ContextSnapshotResponse.ProtoReflect.Descriptor instead. +func (*ContextSnapshotResponse) Descriptor() ([]byte, []int) { + return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{28} +} + +func (x *ContextSnapshotResponse) GetSnapshot() *ContextSnapshot { + if x != nil { + return x.Snapshot + } + return nil +} + +type ResyncContextRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *ResyncContextRequest) Reset() { + *x = ResyncContextRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ResyncContextRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResyncContextRequest) ProtoMessage() {} + +func (x *ResyncContextRequest) ProtoReflect() protoreflect.Message { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[29] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResyncContextRequest.ProtoReflect.Descriptor instead. +func (*ResyncContextRequest) Descriptor() ([]byte, []int) { + return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{29} +} + +type ResyncContextResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Snapshot *ContextSnapshot `protobuf:"bytes,1,opt,name=snapshot,proto3" json:"snapshot,omitempty"` +} + +func (x *ResyncContextResponse) Reset() { + *x = ResyncContextResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ResyncContextResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResyncContextResponse) ProtoMessage() {} + +func (x *ResyncContextResponse) ProtoReflect() protoreflect.Message { + mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[30] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResyncContextResponse.ProtoReflect.Descriptor instead. +func (*ResyncContextResponse) Descriptor() ([]byte, []int) { + return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{30} +} + +func (x *ResyncContextResponse) GetSnapshot() *ContextSnapshot { + if x != nil { + return x.Snapshot + } + return nil +} + var File_agent_agentsocket_proto_agentsocket_proto protoreflect.FileDescriptor var file_agent_agentsocket_proto_agentsocket_proto_rawDesc = []byte{ @@ -858,59 +1639,180 @@ var file_agent_agentsocket_proto_agentsocket_proto_rawDesc = []byte{ 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x34, 0x0a, 0x05, 0x75, 0x6e, 0x69, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x6e, 0x69, - 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x05, 0x75, 0x6e, 0x69, 0x74, 0x73, 0x32, 0xfa, 0x05, 0x0a, - 0x0b, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x12, 0x4d, 0x0a, 0x04, - 0x50, 0x69, 0x6e, 0x67, 0x12, 0x21, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, - 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x69, 0x6e, 0x67, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, - 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x50, - 0x69, 0x6e, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5c, 0x0a, 0x09, 0x53, - 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72, 0x74, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, + 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x05, 0x75, 0x6e, 0x69, 0x74, 0x73, 0x22, 0x23, 0x0a, 0x0d, + 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x12, 0x0a, + 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, + 0x68, 0x22, 0x17, 0x0a, 0x15, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x57, 0x0a, 0x16, 0x43, 0x6f, + 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3d, 0x0a, 0x07, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, + 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, + 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, + 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x07, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x73, 0x22, 0x2d, 0x0a, 0x17, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, + 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, + 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, + 0x74, 0x68, 0x22, 0x57, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, + 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, + 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x23, + 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, + 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x52, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x22, 0x2d, 0x0a, 0x17, 0x41, + 0x64, 0x64, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x22, 0x57, 0x0a, 0x18, 0x41, 0x64, + 0x64, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, + 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, + 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x06, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x22, 0x30, 0x0a, 0x1a, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x43, 0x6f, 0x6e, + 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x70, 0x61, 0x74, 0x68, 0x22, 0x1d, 0x0a, 0x1b, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x43, + 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x94, 0x02, 0x0a, 0x0f, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, + 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x16, 0x0a, 0x06, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x70, + 0x61, 0x74, 0x68, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x50, 0x61, 0x74, 0x68, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, + 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, + 0x74, 0x65, 0x6e, 0x74, 0x48, 0x61, 0x73, 0x68, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x69, 0x7a, 0x65, + 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x04, 0x52, 0x09, 0x73, 0x69, + 0x7a, 0x65, 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, + 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, + 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, + 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x09, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, + 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, + 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0xe3, 0x01, 0x0a, 0x0f, + 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x12, + 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, + 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x67, 0x67, + 0x72, 0x65, 0x67, 0x61, 0x74, 0x65, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0d, 0x61, 0x67, 0x67, 0x72, 0x65, 0x67, 0x61, 0x74, 0x65, 0x48, 0x61, 0x73, 0x68, + 0x12, 0x43, 0x0a, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x03, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, + 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, + 0x78, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x09, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, + 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0c, 0x70, 0x61, + 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x73, 0x6e, + 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x05, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0d, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x45, 0x72, 0x72, 0x6f, + 0x72, 0x22, 0x18, 0x0a, 0x16, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6e, 0x61, 0x70, + 0x73, 0x68, 0x6f, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x5c, 0x0a, 0x17, 0x43, + 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x41, 0x0a, 0x08, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, + 0x6f, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, - 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, - 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72, - 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x08, 0x53, 0x79, 0x6e, - 0x63, 0x57, 0x61, 0x6e, 0x74, 0x12, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, - 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, - 0x63, 0x57, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x63, + 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x52, + 0x08, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x22, 0x16, 0x0a, 0x14, 0x52, 0x65, 0x73, + 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x22, 0x5a, 0x0a, 0x15, 0x52, 0x65, 0x73, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x65, + 0x78, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x41, 0x0a, 0x08, 0x73, 0x6e, + 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, - 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x57, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x65, 0x0a, 0x0c, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, - 0x6c, 0x65, 0x74, 0x65, 0x12, 0x29, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, - 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, - 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x2a, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, - 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, - 0x65, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5c, 0x0a, 0x09, 0x53, - 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, + 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6e, 0x61, 0x70, 0x73, + 0x68, 0x6f, 0x74, 0x52, 0x08, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x32, 0xa6, 0x0b, + 0x0a, 0x0b, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x12, 0x4d, 0x0a, + 0x04, 0x50, 0x69, 0x6e, 0x67, 0x12, 0x21, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, + 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x69, 0x6e, + 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, - 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, - 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, - 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5f, 0x0a, 0x0a, 0x53, 0x79, 0x6e, - 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, - 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, - 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x28, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, - 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, - 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x08, 0x53, 0x79, - 0x6e, 0x63, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, + 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5c, 0x0a, 0x09, + 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72, 0x74, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, + 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, + 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, + 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, + 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x08, 0x53, 0x79, + 0x6e, 0x63, 0x57, 0x61, 0x6e, 0x74, 0x12, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, - 0x6e, 0x63, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, + 0x6e, 0x63, 0x57, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, + 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, + 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x57, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x65, 0x0a, 0x0c, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, + 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x29, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, + 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, + 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x2a, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, + 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, + 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5c, 0x0a, 0x09, + 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, + 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, + 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, + 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, + 0x64, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5f, 0x0a, 0x0a, 0x53, 0x79, + 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, + 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, + 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x28, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, + 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, + 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x08, 0x53, + 0x79, 0x6e, 0x63, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, + 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, + 0x79, 0x6e, 0x63, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, + 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, + 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x62, 0x0a, 0x0f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, + 0x41, 0x70, 0x70, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, + 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, + 0x65, 0x41, 0x70, 0x70, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, + 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x53, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6b, 0x0a, 0x0e, 0x43, 0x6f, + 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x2b, 0x2e, 0x63, + 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, + 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x63, 0x6f, 0x64, 0x65, + 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, + 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x71, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x43, 0x6f, + 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x2d, 0x2e, 0x63, 0x6f, + 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, + 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e, 0x2e, 0x63, 0x6f, 0x64, + 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, + 0x31, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x71, 0x0a, 0x10, 0x41, 0x64, + 0x64, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x2d, + 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, + 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x64, 0x64, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, + 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, - 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x62, 0x0a, 0x0f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, - 0x70, 0x70, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, - 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, - 0x41, 0x70, 0x70, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, - 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x53, 0x74, 0x61, 0x74, 0x75, - 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x33, 0x5a, 0x31, 0x67, 0x69, 0x74, - 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, - 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2f, 0x61, 0x67, 0x65, - 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x64, 0x64, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x7a, 0x0a, + 0x13, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x12, 0x30, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, + 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x6d, 0x6f, + 0x76, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x31, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, + 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, + 0x6d, 0x6f, 0x76, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x71, 0x0a, 0x12, 0x47, 0x65, 0x74, + 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x12, + 0x2c, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, + 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6e, + 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2d, 0x2e, + 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, + 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x53, 0x6e, 0x61, 0x70, + 0x73, 0x68, 0x6f, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a, 0x0d, + 0x52, 0x65, 0x73, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x12, 0x2a, 0x2e, + 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, + 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x65, + 0x78, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2b, 0x2e, 0x63, 0x6f, 0x64, 0x65, + 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, + 0x2e, 0x52, 0x65, 0x73, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x33, 0x5a, 0x31, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, + 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, + 0x2f, 0x76, 0x32, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, + 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x33, } var ( @@ -925,7 +1827,7 @@ func file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP() []byte { return file_agent_agentsocket_proto_agentsocket_proto_rawDescData } -var file_agent_agentsocket_proto_agentsocket_proto_msgTypes = make([]protoimpl.MessageInfo, 16) +var file_agent_agentsocket_proto_agentsocket_proto_msgTypes = make([]protoimpl.MessageInfo, 31) var file_agent_agentsocket_proto_agentsocket_proto_goTypes = []interface{}{ (*PingRequest)(nil), // 0: coder.agentsocket.v1.PingRequest (*PingResponse)(nil), // 1: coder.agentsocket.v1.PingResponse @@ -943,33 +1845,66 @@ var file_agent_agentsocket_proto_agentsocket_proto_goTypes = []interface{}{ (*SyncListRequest)(nil), // 13: coder.agentsocket.v1.SyncListRequest (*UnitInfo)(nil), // 14: coder.agentsocket.v1.UnitInfo (*SyncListResponse)(nil), // 15: coder.agentsocket.v1.SyncListResponse - (*proto.UpdateAppStatusRequest)(nil), // 16: coder.agent.v2.UpdateAppStatusRequest - (*proto.UpdateAppStatusResponse)(nil), // 17: coder.agent.v2.UpdateAppStatusResponse + (*ContextSource)(nil), // 16: coder.agentsocket.v1.ContextSource + (*ContextSourcesRequest)(nil), // 17: coder.agentsocket.v1.ContextSourcesRequest + (*ContextSourcesResponse)(nil), // 18: coder.agentsocket.v1.ContextSourcesResponse + (*GetContextSourceRequest)(nil), // 19: coder.agentsocket.v1.GetContextSourceRequest + (*GetContextSourceResponse)(nil), // 20: coder.agentsocket.v1.GetContextSourceResponse + (*AddContextSourceRequest)(nil), // 21: coder.agentsocket.v1.AddContextSourceRequest + (*AddContextSourceResponse)(nil), // 22: coder.agentsocket.v1.AddContextSourceResponse + (*RemoveContextSourceRequest)(nil), // 23: coder.agentsocket.v1.RemoveContextSourceRequest + (*RemoveContextSourceResponse)(nil), // 24: coder.agentsocket.v1.RemoveContextSourceResponse + (*ContextResource)(nil), // 25: coder.agentsocket.v1.ContextResource + (*ContextSnapshot)(nil), // 26: coder.agentsocket.v1.ContextSnapshot + (*ContextSnapshotRequest)(nil), // 27: coder.agentsocket.v1.ContextSnapshotRequest + (*ContextSnapshotResponse)(nil), // 28: coder.agentsocket.v1.ContextSnapshotResponse + (*ResyncContextRequest)(nil), // 29: coder.agentsocket.v1.ResyncContextRequest + (*ResyncContextResponse)(nil), // 30: coder.agentsocket.v1.ResyncContextResponse + (*proto.UpdateAppStatusRequest)(nil), // 31: coder.agent.v2.UpdateAppStatusRequest + (*proto.UpdateAppStatusResponse)(nil), // 32: coder.agent.v2.UpdateAppStatusResponse } var file_agent_agentsocket_proto_agentsocket_proto_depIdxs = []int32{ 11, // 0: coder.agentsocket.v1.SyncStatusResponse.dependencies:type_name -> coder.agentsocket.v1.DependencyInfo 14, // 1: coder.agentsocket.v1.SyncListResponse.units:type_name -> coder.agentsocket.v1.UnitInfo - 0, // 2: coder.agentsocket.v1.AgentSocket.Ping:input_type -> coder.agentsocket.v1.PingRequest - 2, // 3: coder.agentsocket.v1.AgentSocket.SyncStart:input_type -> coder.agentsocket.v1.SyncStartRequest - 4, // 4: coder.agentsocket.v1.AgentSocket.SyncWant:input_type -> coder.agentsocket.v1.SyncWantRequest - 6, // 5: coder.agentsocket.v1.AgentSocket.SyncComplete:input_type -> coder.agentsocket.v1.SyncCompleteRequest - 8, // 6: coder.agentsocket.v1.AgentSocket.SyncReady:input_type -> coder.agentsocket.v1.SyncReadyRequest - 10, // 7: coder.agentsocket.v1.AgentSocket.SyncStatus:input_type -> coder.agentsocket.v1.SyncStatusRequest - 13, // 8: coder.agentsocket.v1.AgentSocket.SyncList:input_type -> coder.agentsocket.v1.SyncListRequest - 16, // 9: coder.agentsocket.v1.AgentSocket.UpdateAppStatus:input_type -> coder.agent.v2.UpdateAppStatusRequest - 1, // 10: coder.agentsocket.v1.AgentSocket.Ping:output_type -> coder.agentsocket.v1.PingResponse - 3, // 11: coder.agentsocket.v1.AgentSocket.SyncStart:output_type -> coder.agentsocket.v1.SyncStartResponse - 5, // 12: coder.agentsocket.v1.AgentSocket.SyncWant:output_type -> coder.agentsocket.v1.SyncWantResponse - 7, // 13: coder.agentsocket.v1.AgentSocket.SyncComplete:output_type -> coder.agentsocket.v1.SyncCompleteResponse - 9, // 14: coder.agentsocket.v1.AgentSocket.SyncReady:output_type -> coder.agentsocket.v1.SyncReadyResponse - 12, // 15: coder.agentsocket.v1.AgentSocket.SyncStatus:output_type -> coder.agentsocket.v1.SyncStatusResponse - 15, // 16: coder.agentsocket.v1.AgentSocket.SyncList:output_type -> coder.agentsocket.v1.SyncListResponse - 17, // 17: coder.agentsocket.v1.AgentSocket.UpdateAppStatus:output_type -> coder.agent.v2.UpdateAppStatusResponse - 10, // [10:18] is the sub-list for method output_type - 2, // [2:10] is the sub-list for method input_type - 2, // [2:2] is the sub-list for extension type_name - 2, // [2:2] is the sub-list for extension extendee - 0, // [0:2] is the sub-list for field type_name + 16, // 2: coder.agentsocket.v1.ContextSourcesResponse.sources:type_name -> coder.agentsocket.v1.ContextSource + 16, // 3: coder.agentsocket.v1.GetContextSourceResponse.source:type_name -> coder.agentsocket.v1.ContextSource + 16, // 4: coder.agentsocket.v1.AddContextSourceResponse.source:type_name -> coder.agentsocket.v1.ContextSource + 25, // 5: coder.agentsocket.v1.ContextSnapshot.resources:type_name -> coder.agentsocket.v1.ContextResource + 26, // 6: coder.agentsocket.v1.ContextSnapshotResponse.snapshot:type_name -> coder.agentsocket.v1.ContextSnapshot + 26, // 7: coder.agentsocket.v1.ResyncContextResponse.snapshot:type_name -> coder.agentsocket.v1.ContextSnapshot + 0, // 8: coder.agentsocket.v1.AgentSocket.Ping:input_type -> coder.agentsocket.v1.PingRequest + 2, // 9: coder.agentsocket.v1.AgentSocket.SyncStart:input_type -> coder.agentsocket.v1.SyncStartRequest + 4, // 10: coder.agentsocket.v1.AgentSocket.SyncWant:input_type -> coder.agentsocket.v1.SyncWantRequest + 6, // 11: coder.agentsocket.v1.AgentSocket.SyncComplete:input_type -> coder.agentsocket.v1.SyncCompleteRequest + 8, // 12: coder.agentsocket.v1.AgentSocket.SyncReady:input_type -> coder.agentsocket.v1.SyncReadyRequest + 10, // 13: coder.agentsocket.v1.AgentSocket.SyncStatus:input_type -> coder.agentsocket.v1.SyncStatusRequest + 13, // 14: coder.agentsocket.v1.AgentSocket.SyncList:input_type -> coder.agentsocket.v1.SyncListRequest + 31, // 15: coder.agentsocket.v1.AgentSocket.UpdateAppStatus:input_type -> coder.agent.v2.UpdateAppStatusRequest + 17, // 16: coder.agentsocket.v1.AgentSocket.ContextSources:input_type -> coder.agentsocket.v1.ContextSourcesRequest + 19, // 17: coder.agentsocket.v1.AgentSocket.GetContextSource:input_type -> coder.agentsocket.v1.GetContextSourceRequest + 21, // 18: coder.agentsocket.v1.AgentSocket.AddContextSource:input_type -> coder.agentsocket.v1.AddContextSourceRequest + 23, // 19: coder.agentsocket.v1.AgentSocket.RemoveContextSource:input_type -> coder.agentsocket.v1.RemoveContextSourceRequest + 27, // 20: coder.agentsocket.v1.AgentSocket.GetContextSnapshot:input_type -> coder.agentsocket.v1.ContextSnapshotRequest + 29, // 21: coder.agentsocket.v1.AgentSocket.ResyncContext:input_type -> coder.agentsocket.v1.ResyncContextRequest + 1, // 22: coder.agentsocket.v1.AgentSocket.Ping:output_type -> coder.agentsocket.v1.PingResponse + 3, // 23: coder.agentsocket.v1.AgentSocket.SyncStart:output_type -> coder.agentsocket.v1.SyncStartResponse + 5, // 24: coder.agentsocket.v1.AgentSocket.SyncWant:output_type -> coder.agentsocket.v1.SyncWantResponse + 7, // 25: coder.agentsocket.v1.AgentSocket.SyncComplete:output_type -> coder.agentsocket.v1.SyncCompleteResponse + 9, // 26: coder.agentsocket.v1.AgentSocket.SyncReady:output_type -> coder.agentsocket.v1.SyncReadyResponse + 12, // 27: coder.agentsocket.v1.AgentSocket.SyncStatus:output_type -> coder.agentsocket.v1.SyncStatusResponse + 15, // 28: coder.agentsocket.v1.AgentSocket.SyncList:output_type -> coder.agentsocket.v1.SyncListResponse + 32, // 29: coder.agentsocket.v1.AgentSocket.UpdateAppStatus:output_type -> coder.agent.v2.UpdateAppStatusResponse + 18, // 30: coder.agentsocket.v1.AgentSocket.ContextSources:output_type -> coder.agentsocket.v1.ContextSourcesResponse + 20, // 31: coder.agentsocket.v1.AgentSocket.GetContextSource:output_type -> coder.agentsocket.v1.GetContextSourceResponse + 22, // 32: coder.agentsocket.v1.AgentSocket.AddContextSource:output_type -> coder.agentsocket.v1.AddContextSourceResponse + 24, // 33: coder.agentsocket.v1.AgentSocket.RemoveContextSource:output_type -> coder.agentsocket.v1.RemoveContextSourceResponse + 28, // 34: coder.agentsocket.v1.AgentSocket.GetContextSnapshot:output_type -> coder.agentsocket.v1.ContextSnapshotResponse + 30, // 35: coder.agentsocket.v1.AgentSocket.ResyncContext:output_type -> coder.agentsocket.v1.ResyncContextResponse + 22, // [22:36] is the sub-list for method output_type + 8, // [8:22] is the sub-list for method input_type + 8, // [8:8] is the sub-list for extension type_name + 8, // [8:8] is the sub-list for extension extendee + 0, // [0:8] is the sub-list for field type_name } func init() { file_agent_agentsocket_proto_agentsocket_proto_init() } @@ -1170,6 +2105,186 @@ func file_agent_agentsocket_proto_agentsocket_proto_init() { return nil } } + file_agent_agentsocket_proto_agentsocket_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ContextSource); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_agentsocket_proto_agentsocket_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ContextSourcesRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_agentsocket_proto_agentsocket_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ContextSourcesResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_agentsocket_proto_agentsocket_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetContextSourceRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_agentsocket_proto_agentsocket_proto_msgTypes[20].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetContextSourceResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_agentsocket_proto_agentsocket_proto_msgTypes[21].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AddContextSourceRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_agentsocket_proto_agentsocket_proto_msgTypes[22].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*AddContextSourceResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_agentsocket_proto_agentsocket_proto_msgTypes[23].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*RemoveContextSourceRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_agentsocket_proto_agentsocket_proto_msgTypes[24].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*RemoveContextSourceResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_agentsocket_proto_agentsocket_proto_msgTypes[25].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ContextResource); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_agentsocket_proto_agentsocket_proto_msgTypes[26].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ContextSnapshot); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_agentsocket_proto_agentsocket_proto_msgTypes[27].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ContextSnapshotRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_agentsocket_proto_agentsocket_proto_msgTypes[28].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ContextSnapshotResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_agentsocket_proto_agentsocket_proto_msgTypes[29].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ResyncContextRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_agent_agentsocket_proto_agentsocket_proto_msgTypes[30].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ResyncContextResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } } type x struct{} out := protoimpl.TypeBuilder{ @@ -1177,7 +2292,7 @@ func file_agent_agentsocket_proto_agentsocket_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_agent_agentsocket_proto_agentsocket_proto_rawDesc, NumEnums: 0, - NumMessages: 16, + NumMessages: 31, NumExtensions: 0, NumServices: 1, }, diff --git a/agent/agentsocket/proto/agentsocket.proto b/agent/agentsocket/proto/agentsocket.proto index ad0bbe0f7ec00..7fc9817f42b40 100644 --- a/agent/agentsocket/proto/agentsocket.proto +++ b/agent/agentsocket/proto/agentsocket.proto @@ -70,6 +70,79 @@ message SyncListResponse { repeated UnitInfo units = 1; } +// ContextSource is a user-declared scan root the agent watches for +// workspace context (instruction files, skills, MCP configs) in +// addition to its built-in defaults. Identity is the canonical path. +message ContextSource { + string path = 1; +} + +message ContextSourcesRequest {} + +message ContextSourcesResponse { + repeated ContextSource sources = 1; +} + +message GetContextSourceRequest { + string path = 1; +} + +message GetContextSourceResponse { + ContextSource source = 1; +} + +message AddContextSourceRequest { + string path = 1; +} + +message AddContextSourceResponse { + ContextSource source = 1; +} + +message RemoveContextSourceRequest { + string path = 1; +} + +message RemoveContextSourceResponse {} + +// ContextResource is the on-wire form of a resolved context resource. +// Payload bytes are never sent over the socket; they ship to coderd via +// the drpc PushContextState path. Mirrors agentcontext.Resource minus +// the payload. +message ContextResource { + string id = 1; + string kind = 2; + string source = 3; + string source_path = 4; + string content_hash = 5; + uint64 size_bytes = 6; + string status = 7; + string error = 8; + string name = 9; + string description = 10; +} + +// ContextSnapshot is the agent's resolved context state. +message ContextSnapshot { + uint64 version = 1; + string aggregate_hash = 2; + repeated ContextResource resources = 3; + uint64 payload_bytes = 4; + string snapshot_error = 5; +} + +message ContextSnapshotRequest {} + +message ContextSnapshotResponse { + ContextSnapshot snapshot = 1; +} + +message ResyncContextRequest {} + +message ResyncContextResponse { + ContextSnapshot snapshot = 1; +} + // AgentSocket provides direct access to the agent over local IPC. service AgentSocket { // Ping the agent to check if it is alive. @@ -88,4 +161,16 @@ service AgentSocket { rpc SyncList(SyncListRequest) returns (SyncListResponse); // Update app status, forwarded to coderd. rpc UpdateAppStatus(coder.agent.v2.UpdateAppStatusRequest) returns (coder.agent.v2.UpdateAppStatusResponse); + // List the workspace context sources registered on the agent. + rpc ContextSources(ContextSourcesRequest) returns (ContextSourcesResponse); + // Get a single registered context source by path. + rpc GetContextSource(GetContextSourceRequest) returns (GetContextSourceResponse); + // Register a new context source (additional scan root). + rpc AddContextSource(AddContextSourceRequest) returns (AddContextSourceResponse); + // Remove a previously-registered context source. + rpc RemoveContextSource(RemoveContextSourceRequest) returns (RemoveContextSourceResponse); + // Return the agent's current resolved context snapshot without forcing a re-walk. + rpc GetContextSnapshot(ContextSnapshotRequest) returns (ContextSnapshotResponse); + // Force a re-walk and synchronous push, returning the resulting snapshot (barrier). + rpc ResyncContext(ResyncContextRequest) returns (ResyncContextResponse); } diff --git a/agent/agentsocket/proto/agentsocket_drpc.pb.go b/agent/agentsocket/proto/agentsocket_drpc.pb.go index 04836e6f87db7..664443072d250 100644 --- a/agent/agentsocket/proto/agentsocket_drpc.pb.go +++ b/agent/agentsocket/proto/agentsocket_drpc.pb.go @@ -47,6 +47,12 @@ type DRPCAgentSocketClient interface { SyncStatus(ctx context.Context, in *SyncStatusRequest) (*SyncStatusResponse, error) SyncList(ctx context.Context, in *SyncListRequest) (*SyncListResponse, error) UpdateAppStatus(ctx context.Context, in *proto1.UpdateAppStatusRequest) (*proto1.UpdateAppStatusResponse, error) + ContextSources(ctx context.Context, in *ContextSourcesRequest) (*ContextSourcesResponse, error) + GetContextSource(ctx context.Context, in *GetContextSourceRequest) (*GetContextSourceResponse, error) + AddContextSource(ctx context.Context, in *AddContextSourceRequest) (*AddContextSourceResponse, error) + RemoveContextSource(ctx context.Context, in *RemoveContextSourceRequest) (*RemoveContextSourceResponse, error) + GetContextSnapshot(ctx context.Context, in *ContextSnapshotRequest) (*ContextSnapshotResponse, error) + ResyncContext(ctx context.Context, in *ResyncContextRequest) (*ResyncContextResponse, error) } type drpcAgentSocketClient struct { @@ -131,6 +137,60 @@ func (c *drpcAgentSocketClient) UpdateAppStatus(ctx context.Context, in *proto1. return out, nil } +func (c *drpcAgentSocketClient) ContextSources(ctx context.Context, in *ContextSourcesRequest) (*ContextSourcesResponse, error) { + out := new(ContextSourcesResponse) + err := c.cc.Invoke(ctx, "/coder.agentsocket.v1.AgentSocket/ContextSources", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, in, out) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *drpcAgentSocketClient) GetContextSource(ctx context.Context, in *GetContextSourceRequest) (*GetContextSourceResponse, error) { + out := new(GetContextSourceResponse) + err := c.cc.Invoke(ctx, "/coder.agentsocket.v1.AgentSocket/GetContextSource", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, in, out) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *drpcAgentSocketClient) AddContextSource(ctx context.Context, in *AddContextSourceRequest) (*AddContextSourceResponse, error) { + out := new(AddContextSourceResponse) + err := c.cc.Invoke(ctx, "/coder.agentsocket.v1.AgentSocket/AddContextSource", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, in, out) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *drpcAgentSocketClient) RemoveContextSource(ctx context.Context, in *RemoveContextSourceRequest) (*RemoveContextSourceResponse, error) { + out := new(RemoveContextSourceResponse) + err := c.cc.Invoke(ctx, "/coder.agentsocket.v1.AgentSocket/RemoveContextSource", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, in, out) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *drpcAgentSocketClient) GetContextSnapshot(ctx context.Context, in *ContextSnapshotRequest) (*ContextSnapshotResponse, error) { + out := new(ContextSnapshotResponse) + err := c.cc.Invoke(ctx, "/coder.agentsocket.v1.AgentSocket/GetContextSnapshot", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, in, out) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *drpcAgentSocketClient) ResyncContext(ctx context.Context, in *ResyncContextRequest) (*ResyncContextResponse, error) { + out := new(ResyncContextResponse) + err := c.cc.Invoke(ctx, "/coder.agentsocket.v1.AgentSocket/ResyncContext", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, in, out) + if err != nil { + return nil, err + } + return out, nil +} + type DRPCAgentSocketServer interface { Ping(context.Context, *PingRequest) (*PingResponse, error) SyncStart(context.Context, *SyncStartRequest) (*SyncStartResponse, error) @@ -140,6 +200,12 @@ type DRPCAgentSocketServer interface { SyncStatus(context.Context, *SyncStatusRequest) (*SyncStatusResponse, error) SyncList(context.Context, *SyncListRequest) (*SyncListResponse, error) UpdateAppStatus(context.Context, *proto1.UpdateAppStatusRequest) (*proto1.UpdateAppStatusResponse, error) + ContextSources(context.Context, *ContextSourcesRequest) (*ContextSourcesResponse, error) + GetContextSource(context.Context, *GetContextSourceRequest) (*GetContextSourceResponse, error) + AddContextSource(context.Context, *AddContextSourceRequest) (*AddContextSourceResponse, error) + RemoveContextSource(context.Context, *RemoveContextSourceRequest) (*RemoveContextSourceResponse, error) + GetContextSnapshot(context.Context, *ContextSnapshotRequest) (*ContextSnapshotResponse, error) + ResyncContext(context.Context, *ResyncContextRequest) (*ResyncContextResponse, error) } type DRPCAgentSocketUnimplementedServer struct{} @@ -176,9 +242,33 @@ func (s *DRPCAgentSocketUnimplementedServer) UpdateAppStatus(context.Context, *p return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented) } +func (s *DRPCAgentSocketUnimplementedServer) ContextSources(context.Context, *ContextSourcesRequest) (*ContextSourcesResponse, error) { + return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented) +} + +func (s *DRPCAgentSocketUnimplementedServer) GetContextSource(context.Context, *GetContextSourceRequest) (*GetContextSourceResponse, error) { + return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented) +} + +func (s *DRPCAgentSocketUnimplementedServer) AddContextSource(context.Context, *AddContextSourceRequest) (*AddContextSourceResponse, error) { + return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented) +} + +func (s *DRPCAgentSocketUnimplementedServer) RemoveContextSource(context.Context, *RemoveContextSourceRequest) (*RemoveContextSourceResponse, error) { + return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented) +} + +func (s *DRPCAgentSocketUnimplementedServer) GetContextSnapshot(context.Context, *ContextSnapshotRequest) (*ContextSnapshotResponse, error) { + return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented) +} + +func (s *DRPCAgentSocketUnimplementedServer) ResyncContext(context.Context, *ResyncContextRequest) (*ResyncContextResponse, error) { + return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented) +} + type DRPCAgentSocketDescription struct{} -func (DRPCAgentSocketDescription) NumMethods() int { return 8 } +func (DRPCAgentSocketDescription) NumMethods() int { return 14 } func (DRPCAgentSocketDescription) Method(n int) (string, drpc.Encoding, drpc.Receiver, interface{}, bool) { switch n { @@ -254,6 +344,60 @@ func (DRPCAgentSocketDescription) Method(n int) (string, drpc.Encoding, drpc.Rec in1.(*proto1.UpdateAppStatusRequest), ) }, DRPCAgentSocketServer.UpdateAppStatus, true + case 8: + return "/coder.agentsocket.v1.AgentSocket/ContextSources", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, + func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) { + return srv.(DRPCAgentSocketServer). + ContextSources( + ctx, + in1.(*ContextSourcesRequest), + ) + }, DRPCAgentSocketServer.ContextSources, true + case 9: + return "/coder.agentsocket.v1.AgentSocket/GetContextSource", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, + func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) { + return srv.(DRPCAgentSocketServer). + GetContextSource( + ctx, + in1.(*GetContextSourceRequest), + ) + }, DRPCAgentSocketServer.GetContextSource, true + case 10: + return "/coder.agentsocket.v1.AgentSocket/AddContextSource", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, + func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) { + return srv.(DRPCAgentSocketServer). + AddContextSource( + ctx, + in1.(*AddContextSourceRequest), + ) + }, DRPCAgentSocketServer.AddContextSource, true + case 11: + return "/coder.agentsocket.v1.AgentSocket/RemoveContextSource", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, + func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) { + return srv.(DRPCAgentSocketServer). + RemoveContextSource( + ctx, + in1.(*RemoveContextSourceRequest), + ) + }, DRPCAgentSocketServer.RemoveContextSource, true + case 12: + return "/coder.agentsocket.v1.AgentSocket/GetContextSnapshot", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, + func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) { + return srv.(DRPCAgentSocketServer). + GetContextSnapshot( + ctx, + in1.(*ContextSnapshotRequest), + ) + }, DRPCAgentSocketServer.GetContextSnapshot, true + case 13: + return "/coder.agentsocket.v1.AgentSocket/ResyncContext", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, + func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) { + return srv.(DRPCAgentSocketServer). + ResyncContext( + ctx, + in1.(*ResyncContextRequest), + ) + }, DRPCAgentSocketServer.ResyncContext, true default: return "", nil, nil, nil, false } @@ -390,3 +534,99 @@ func (x *drpcAgentSocket_UpdateAppStatusStream) SendAndClose(m *proto1.UpdateApp } return x.CloseSend() } + +type DRPCAgentSocket_ContextSourcesStream interface { + drpc.Stream + SendAndClose(*ContextSourcesResponse) error +} + +type drpcAgentSocket_ContextSourcesStream struct { + drpc.Stream +} + +func (x *drpcAgentSocket_ContextSourcesStream) SendAndClose(m *ContextSourcesResponse) error { + if err := x.MsgSend(m, drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}); err != nil { + return err + } + return x.CloseSend() +} + +type DRPCAgentSocket_GetContextSourceStream interface { + drpc.Stream + SendAndClose(*GetContextSourceResponse) error +} + +type drpcAgentSocket_GetContextSourceStream struct { + drpc.Stream +} + +func (x *drpcAgentSocket_GetContextSourceStream) SendAndClose(m *GetContextSourceResponse) error { + if err := x.MsgSend(m, drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}); err != nil { + return err + } + return x.CloseSend() +} + +type DRPCAgentSocket_AddContextSourceStream interface { + drpc.Stream + SendAndClose(*AddContextSourceResponse) error +} + +type drpcAgentSocket_AddContextSourceStream struct { + drpc.Stream +} + +func (x *drpcAgentSocket_AddContextSourceStream) SendAndClose(m *AddContextSourceResponse) error { + if err := x.MsgSend(m, drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}); err != nil { + return err + } + return x.CloseSend() +} + +type DRPCAgentSocket_RemoveContextSourceStream interface { + drpc.Stream + SendAndClose(*RemoveContextSourceResponse) error +} + +type drpcAgentSocket_RemoveContextSourceStream struct { + drpc.Stream +} + +func (x *drpcAgentSocket_RemoveContextSourceStream) SendAndClose(m *RemoveContextSourceResponse) error { + if err := x.MsgSend(m, drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}); err != nil { + return err + } + return x.CloseSend() +} + +type DRPCAgentSocket_GetContextSnapshotStream interface { + drpc.Stream + SendAndClose(*ContextSnapshotResponse) error +} + +type drpcAgentSocket_GetContextSnapshotStream struct { + drpc.Stream +} + +func (x *drpcAgentSocket_GetContextSnapshotStream) SendAndClose(m *ContextSnapshotResponse) error { + if err := x.MsgSend(m, drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}); err != nil { + return err + } + return x.CloseSend() +} + +type DRPCAgentSocket_ResyncContextStream interface { + drpc.Stream + SendAndClose(*ResyncContextResponse) error +} + +type drpcAgentSocket_ResyncContextStream struct { + drpc.Stream +} + +func (x *drpcAgentSocket_ResyncContextStream) SendAndClose(m *ResyncContextResponse) error { + if err := x.MsgSend(m, drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}); err != nil { + return err + } + return x.CloseSend() +} diff --git a/agent/agentsocket/server.go b/agent/agentsocket/server.go index 380b792da1d0c..605feeec05a39 100644 --- a/agent/agentsocket/server.go +++ b/agent/agentsocket/server.go @@ -44,8 +44,9 @@ func NewServer(logger slog.Logger, opts ...Option) (*Server, error) { logger: logger, path: options.path, service: &DRPCAgentSocketService{ - logger: logger, - unitManager: unit.NewManager(), + logger: logger, + unitManager: unit.NewManager(), + contextManager: options.contextManager, }, } diff --git a/agent/agentsocket/service.go b/agent/agentsocket/service.go index 97be1b45c5865..4e6c94fc3f314 100644 --- a/agent/agentsocket/service.go +++ b/agent/agentsocket/service.go @@ -2,12 +2,14 @@ package agentsocket import ( "context" + "encoding/hex" "errors" "sync" "golang.org/x/xerrors" "cdr.dev/slog/v3" + "github.com/coder/coder/v2/agent/agentcontext" "github.com/coder/coder/v2/agent/agentsocket/proto" agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/agent/unit" @@ -16,14 +18,29 @@ import ( var _ proto.DRPCAgentSocketServer = (*DRPCAgentSocketService)(nil) var ( - ErrUnitManagerNotAvailable = xerrors.New("unit manager not available") - ErrAgentAPINotConnected = xerrors.New("agent not connected to coderd") + ErrUnitManagerNotAvailable = xerrors.New("unit manager not available") + ErrAgentAPINotConnected = xerrors.New("agent not connected to coderd") + ErrContextManagerNotAvailable = xerrors.New("context manager not available") + ErrContextSourceNotFound = xerrors.New("context source not found") ) +// ContextManager is the subset of *agentcontext.Manager the socket +// service needs to serve workspace-context source CRUD. It is an +// interface so tests can supply a fake. +type ContextManager interface { + Sources() []agentcontext.Source + HasSource(path string) (canonical string, ok bool) + AddSource(s agentcontext.Source) (agentcontext.Source, error) + RemoveSource(path string) error + Snapshot() agentcontext.Snapshot + Resync(ctx context.Context) (agentcontext.Snapshot, error) +} + // DRPCAgentSocketService implements the DRPC agent socket service. type DRPCAgentSocketService struct { - unitManager *unit.Manager - logger slog.Logger + unitManager *unit.Manager + contextManager ContextManager + logger slog.Logger mu sync.Mutex agentAPI agentproto.DRPCAgentClient28 @@ -210,3 +227,106 @@ func (s *DRPCAgentSocketService) UpdateAppStatus(ctx context.Context, req *agent } return api.UpdateAppStatus(ctx, req) } + +// ContextSources lists the workspace-context sources registered on the agent. +func (s *DRPCAgentSocketService) ContextSources(_ context.Context, _ *proto.ContextSourcesRequest) (*proto.ContextSourcesResponse, error) { + if s.contextManager == nil { + return nil, ErrContextManagerNotAvailable + } + sources := s.contextManager.Sources() + out := &proto.ContextSourcesResponse{Sources: make([]*proto.ContextSource, 0, len(sources))} + for _, src := range sources { + out.Sources = append(out.Sources, &proto.ContextSource{Path: src.Path}) + } + return out, nil +} + +// GetContextSource returns a single registered source, canonicalizing the +// requested path before matching. +func (s *DRPCAgentSocketService) GetContextSource(_ context.Context, req *proto.GetContextSourceRequest) (*proto.GetContextSourceResponse, error) { + if s.contextManager == nil { + return nil, ErrContextManagerNotAvailable + } + canonical, ok := s.contextManager.HasSource(req.Path) + if !ok { + return nil, xerrors.Errorf("%q: %w", req.Path, ErrContextSourceNotFound) + } + return &proto.GetContextSourceResponse{Source: &proto.ContextSource{Path: canonical}}, nil +} + +// AddContextSource registers a new scan root and triggers a re-resolve. +func (s *DRPCAgentSocketService) AddContextSource(_ context.Context, req *proto.AddContextSourceRequest) (*proto.AddContextSourceResponse, error) { + if s.contextManager == nil { + return nil, ErrContextManagerNotAvailable + } + src, err := s.contextManager.AddSource(agentcontext.Source{Path: req.Path}) + if err != nil { + return nil, xerrors.Errorf("add context source: %w", err) + } + return &proto.AddContextSourceResponse{Source: &proto.ContextSource{Path: src.Path}}, nil +} + +// RemoveContextSource removes a previously-registered scan root. +func (s *DRPCAgentSocketService) RemoveContextSource(_ context.Context, req *proto.RemoveContextSourceRequest) (*proto.RemoveContextSourceResponse, error) { + if s.contextManager == nil { + return nil, ErrContextManagerNotAvailable + } + if err := s.contextManager.RemoveSource(req.Path); err != nil { + if errors.Is(err, agentcontext.ErrSourceNotFound) { + return nil, xerrors.Errorf("%q: %w", req.Path, ErrContextSourceNotFound) + } + return nil, xerrors.Errorf("remove context source: %w", err) + } + return &proto.RemoveContextSourceResponse{}, nil +} + +// GetContextSnapshot returns the agent's current resolved snapshot without +// forcing a re-walk. +func (s *DRPCAgentSocketService) GetContextSnapshot(_ context.Context, _ *proto.ContextSnapshotRequest) (*proto.ContextSnapshotResponse, error) { + if s.contextManager == nil { + return nil, ErrContextManagerNotAvailable + } + return &proto.ContextSnapshotResponse{Snapshot: contextSnapshotToProto(s.contextManager.Snapshot())}, nil +} + +// ResyncContext forces a re-walk and synchronous push, returning the +// resulting snapshot. Callers use it as a barrier before fanning out a +// refresh. +func (s *DRPCAgentSocketService) ResyncContext(ctx context.Context, _ *proto.ResyncContextRequest) (*proto.ResyncContextResponse, error) { + if s.contextManager == nil { + return nil, ErrContextManagerNotAvailable + } + snap, err := s.contextManager.Resync(ctx) + if err != nil { + return nil, xerrors.Errorf("resync context: %w", err) + } + return &proto.ResyncContextResponse{Snapshot: contextSnapshotToProto(snap)}, nil +} + +// contextSnapshotToProto converts an agentcontext.Snapshot to its on-wire +// form. Payload bytes are intentionally omitted; they reach coderd via the +// drpc PushContextState path. +func contextSnapshotToProto(s agentcontext.Snapshot) *proto.ContextSnapshot { + out := &proto.ContextSnapshot{ + Version: s.Version, + AggregateHash: hex.EncodeToString(s.AggregateHash[:]), + Resources: make([]*proto.ContextResource, 0, len(s.Resources)), + PayloadBytes: s.PayloadBytes, + SnapshotError: s.SnapshotError, + } + for _, r := range s.Resources { + out.Resources = append(out.Resources, &proto.ContextResource{ + Id: r.ID, + Kind: r.Kind.String(), + Source: r.Source, + SourcePath: r.SourcePath, + ContentHash: hex.EncodeToString(r.ContentHash[:]), + SizeBytes: r.SizeBytes, + Status: r.Status.String(), + Error: r.Error, + Name: r.Name, + Description: r.Description, + }) + } + return out +} diff --git a/cli/exp_chat.go b/cli/exp_chat.go index f34aa44e3fe43..f9d7ff132087a 100644 --- a/cli/exp_chat.go +++ b/cli/exp_chat.go @@ -1,17 +1,16 @@ package cli import ( - "encoding/json" + "context" "fmt" - "io" "os" "path/filepath" - "time" "github.com/google/uuid" "golang.org/x/xerrors" "github.com/coder/coder/v2/agent/agentcontextconfig" + "github.com/coder/coder/v2/agent/agentsocket" "github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" @@ -33,275 +32,336 @@ func (r *RootCmd) chatCommand() *serpent.Command { } func (r *RootCmd) chatContextCommand() *serpent.Command { + // socketPath is shared by the in-workspace source commands (list, show, + // add, remove) and the no-argument refresh, which all talk to the agent's + // local IPC socket. + var socketPath string return &serpent.Command{ Use: "context", - Short: "Manage chat context", - Long: "Inspect, refresh, add, or clear the workspace context (instruction " + - "files and skills) for a chat.", + Short: "Manage workspace context", + Long: "Inspect and manage the workspace context sources (instruction files, " + + "skills, and MCP configs) the agent resolves, and refresh a chat to the " + + "agent's latest snapshot.\n\nThe list, show, add, and remove commands manage " + + "agent-local sources and must be run from inside the workspace.", Handler: func(i *serpent.Invocation) error { return i.Command.HelpHandler(i) }, Children: []*serpent.Command{ - r.chatContextShowCommand(), - r.chatContextRefreshCommand(), - r.chatContextAddCommand(), + r.chatContextListCommand(&socketPath), + r.chatContextShowCommand(&socketPath), + r.chatContextAddCommand(&socketPath), + r.chatContextRemoveCommand(&socketPath), + r.chatContextRefreshCommand(&socketPath), r.chatContextClearCommand(), }, + Options: serpent.OptionSet{{ + Flag: "socket-path", + Env: "CODER_AGENT_SOCKET_PATH", + Description: "Path to the agent socket used by the in-workspace source commands.", + Value: serpent.StringOf(&socketPath), + }}, } } -// chatContextResourceRow is the table view of a pinned context resource. -type chatContextResourceRow struct { - Source string `table:"source,default_sort"` - Kind string `table:"kind"` - Size int64 `table:"size bytes"` - Skill string `table:"skill"` -} - -// chatContextChangeRow is the table view of one source-level context change. -type chatContextChangeRow struct { - Status string `table:"status,default_sort"` - Kind string `table:"kind"` - Source string `table:"source"` - Skill string `table:"skill"` +// dialAgentContextSocket connects to the workspace agent's local IPC socket. +// It is only reachable from inside the workspace. +func dialAgentContextSocket(ctx context.Context, socketPath string) (*agentsocket.Client, error) { + opts := []agentsocket.Option{} + if socketPath != "" { + opts = append(opts, agentsocket.WithPath(socketPath)) + } + client, err := agentsocket.NewClient(ctx, opts...) + if err != nil { + return nil, xerrors.Errorf("connect to agent socket (run this from inside the workspace): %w", err) + } + return client, nil } -func (r *RootCmd) chatContextShowCommand() *serpent.Command { - var outputFormat string +func (*RootCmd) chatContextListCommand(socketPath *string) *serpent.Command { + formatter := cliui.NewOutputFormatter( + cliui.TableFormat([]agentsocket.ContextSource{}, []string{"path"}), + cliui.JSONFormat(), + ) cmd := &serpent.Command{ - Use: "show ", - Short: "Show a chat's pinned workspace context and any drift", - Long: "Display the workspace context a chat is pinned to (instruction files " + - "and skills), whether it has drifted from the agent's latest snapshot, " + - "and the per-source changes when it has.", - Middleware: serpent.Chain(serpent.RequireNArgs(1)), - Options: serpent.OptionSet{{ - Name: "output", - Flag: "output", - FlagShorthand: "o", - Default: "text", - Description: "Output format. Supported values: text, json.", - Value: serpent.EnumOf(&outputFormat, "text", "json"), - }}, + Use: "list", + Short: "List the workspace context sources registered on the agent", + Long: "List the additional scan roots registered on this workspace's agent. " + + "Built-in defaults (the working directory, ~/.coder, ~/.claude) are always " + + "scanned and are not shown here.\n\nMust be run from inside the workspace.", + Middleware: serpent.RequireNArgs(0), Handler: func(inv *serpent.Invocation) error { ctx := inv.Context() - client, err := r.InitClient(inv) + client, err := dialAgentContextSocket(ctx, *socketPath) if err != nil { return err } - chatID, err := uuid.Parse(inv.Args[0]) + defer client.Close() + + sources, err := client.ContextSources(ctx) + if err != nil { + return xerrors.Errorf("list context sources: %w", err) + } + if len(sources) == 0 && formatter.FormatID() == "table" { + cliui.Info(inv.Stdout, "No context sources registered.") + return nil + } + out, err := formatter.Format(ctx, sources) if err != nil { - return xerrors.Errorf("invalid chat ID %q: %w", inv.Args[0], err) + return xerrors.Errorf("format output: %w", err) } + _, _ = fmt.Fprintln(inv.Stdout, out) + return nil + }, + } + formatter.AttachOptions(&cmd.Options) + return cmd +} - exp := codersdk.NewExperimentalClient(client) - chat, err := exp.GetChat(ctx, chatID) +func (*RootCmd) chatContextShowCommand(socketPath *string) *serpent.Command { + formatter := cliui.NewOutputFormatter( + cliui.TableFormat( + []agentsocket.ContextResource{}, + []string{"kind", "name", "source", "status", "size bytes", "error"}, + ), + cliui.JSONFormat(), + ) + cmd := &serpent.Command{ + Use: "show ", + Short: "Show a context source and the resources it contributes", + Long: "Show a registered context source and the resources the agent currently " + + "resolves from it (instruction files, skills, MCP configs), including any " + + "that failed to read or parse.\n\nMust be run from inside the workspace.", + Middleware: serpent.RequireNArgs(1), + Handler: func(inv *serpent.Invocation) error { + ctx := inv.Context() + client, err := dialAgentContextSocket(ctx, *socketPath) if err != nil { - return xerrors.Errorf("get chat: %w", err) + return err } + defer client.Close() - if outputFormat == "json" { - // Emit the context object directly; it is null when the chat - // has no pinned context yet. - out, err := json.MarshalIndent(chat.Context, "", " ") - if err != nil { - return xerrors.Errorf("marshal chat context: %w", err) + src, err := client.GetContextSource(ctx, inv.Args[0]) + if err != nil { + return xerrors.Errorf("get context source: %w", err) + } + snap, err := client.GetContextSnapshot(ctx) + if err != nil { + return xerrors.Errorf("get context snapshot: %w", err) + } + resources := make([]agentsocket.ContextResource, 0, len(snap.Resources)) + for _, res := range snap.Resources { + if res.SourcePath == src.Path { + resources = append(resources, res) } - _, _ = fmt.Fprintln(inv.Stdout, string(out)) - return nil } - return renderChatContextText(inv.Stdout, chat) + + if formatter.FormatID() == "table" { + cliui.Infof(inv.Stdout, "Source: %s (%d resources)", src.Path, len(resources)) + } + out, err := formatter.Format(ctx, resources) + if err != nil { + return xerrors.Errorf("format output: %w", err) + } + _, _ = fmt.Fprintln(inv.Stdout, out) + return nil }, } + formatter.AttachOptions(&cmd.Options) return cmd } -func renderChatContextText(out io.Writer, chat codersdk.Chat) error { - if chat.Context == nil { - _, _ = fmt.Fprintf(out, "Chat %s has no pinned workspace context.\n", chat.ID) - return nil - } - cc := chat.Context - - status := "clean" - if cc.Dirty { - status = "drifted" - if cc.DirtySince != nil { - status = fmt.Sprintf("drifted (since %s)", cc.DirtySince.Format(time.RFC3339)) - } +func (*RootCmd) chatContextAddCommand(socketPath *string) *serpent.Command { + var chatID string + agentAuth := &AgentAuth{} + cmd := &serpent.Command{ + Use: "add ", + Short: "Register a workspace context source", + Long: "Register a path as an additional context source on this workspace's agent. " + + "The agent treats it as an extra scan root, applying the same discovery rules " + + "it uses for the working directory: AGENTS.md / CLAUDE.md / .cursorrules, " + + ".agents/skills//SKILL.md, and .mcp.json are picked up now and as they " + + "appear. Any change to a recognized file dirties this workspace's chats until " + + "you refresh.\n\nA path may be a file or a directory. Must be run from inside " + + "the workspace.\n\nPass --chat to keep the legacy one-shot behavior: read " + + "context from the path once and inject it into a single chat without " + + "registering a source.", + Middleware: serpent.RequireNArgs(1), + Handler: func(inv *serpent.Invocation) error { + ctx := inv.Context() + ctx, stop := inv.SignalNotifyContext(ctx, StopSignals...) + defer stop() + + // Legacy one-shot inject into a single chat. + if chatID != "" { + return addChatContextOneShot(ctx, inv, agentAuth, inv.Args[0], chatID) + } + + // Source registration (default). + client, err := dialAgentContextSocket(ctx, *socketPath) + if err != nil { + return err + } + defer client.Close() + + src, err := client.AddContextSource(ctx, inv.Args[0]) + if err != nil { + return xerrors.Errorf("add context source: %w", err) + } + _, _ = fmt.Fprintf(inv.Stdout, "Registered context source %s\n", src.Path) + return nil + }, + Options: serpent.OptionSet{{ + Name: "Chat ID", + Flag: "chat", + Env: "CODER_CHAT_ID", + Description: "Inject context from into a single chat (legacy one-shot) instead of registering a source. Auto-detected from CODER_CHAT_ID, the only active chat, or the only top-level active chat.", + Value: serpent.StringOf(&chatID), + }}, } - _, _ = fmt.Fprintf(out, "Context for chat %s\n", chat.ID) - _, _ = fmt.Fprintf(out, " Status: %s\n", status) - if cc.Error != "" { - _, _ = fmt.Fprintf(out, " Error: %s\n", cc.Error) + agentAuth.AttachOptions(cmd, false) + return cmd +} + +// addChatContextOneShot preserves the legacy `add --chat` behavior: read +// context files and skills from a directory and inject them into a single +// chat via coderd, without registering a persistent source. +func addChatContextOneShot(ctx context.Context, inv *serpent.Invocation, agentAuth *AgentAuth, path, chatID string) error { + client, err := agentAuth.CreateClient() + if err != nil { + return xerrors.Errorf("create agent client: %w", err) } - resourceRows := make([]chatContextResourceRow, 0, len(cc.Resources)) - for _, res := range cc.Resources { - resourceRows = append(resourceRows, chatContextResourceRow{ - Source: res.Source, - Kind: string(res.Kind), - Size: res.SizeBytes, - Skill: res.SkillName, - }) + resolvedDir, err := filepath.Abs(path) + if err != nil { + return xerrors.Errorf("resolve directory: %w", err) + } + info, err := os.Stat(resolvedDir) + if err != nil { + return xerrors.Errorf("cannot read directory %q: %w", resolvedDir, err) } - _, _ = fmt.Fprintf(out, "\nPinned resources (%d)\n", len(resourceRows)) - if len(resourceRows) == 0 { - _, _ = fmt.Fprintln(out, " (none)") - } else { - tbl, err := cliui.DisplayTable(resourceRows, "source", nil) - if err != nil { - return xerrors.Errorf("render resources: %w", err) - } - _, _ = fmt.Fprintln(out, tbl) + if !info.IsDir() { + return xerrors.Errorf("--chat one-shot inject requires a directory, but %q is a file", resolvedDir) } - if !cc.Dirty { + parts := agentcontextconfig.ContextPartsFromDir(resolvedDir) + if len(parts) == 0 { + _, _ = fmt.Fprintln(inv.Stderr, "No context files or skills found in "+resolvedDir) return nil } - changeRows := make([]chatContextChangeRow, 0, len(cc.Changes)) - for _, change := range cc.Changes { - changeRows = append(changeRows, chatContextChangeRow{ - Status: string(change.Status), - Kind: string(change.Kind), - Source: change.Source, - Skill: change.SkillName, - }) + resolvedChatID, err := parseChatID(chatID) + if err != nil { + return err } - _, _ = fmt.Fprintf(out, "\nChanges vs latest snapshot (%d)\n", len(changeRows)) - if len(changeRows) == 0 { - _, _ = fmt.Fprintln(out, " (none)") - } else { - tbl, err := cliui.DisplayTable(changeRows, "status", nil) - if err != nil { - return xerrors.Errorf("render changes: %w", err) - } - _, _ = fmt.Fprintln(out, tbl) + + resp, err := client.AddChatContext(ctx, agentsdk.AddChatContextRequest{ + ChatID: resolvedChatID, + Parts: parts, + }) + if err != nil { + return xerrors.Errorf("add chat context: %w", err) } - _, _ = fmt.Fprintf(out, "Run 'coder chat context refresh %s' to adopt the latest context.\n", chat.ID) + + _, _ = fmt.Fprintf(inv.Stdout, "Added %d context part(s) to chat %s\n", resp.Count, resp.ChatID) return nil } -func (r *RootCmd) chatContextRefreshCommand() *serpent.Command { +func (*RootCmd) chatContextRemoveCommand(socketPath *string) *serpent.Command { cmd := &serpent.Command{ - Use: "refresh ", - Short: "Refresh a chat's workspace context to the latest snapshot", - Long: "Re-pin a chat to the workspace agent's latest context snapshot and " + - "clear the drift marker. The chat's next turn uses the refreshed context.", - Middleware: serpent.Chain(serpent.RequireNArgs(1)), + Use: "remove ", + Short: "Remove a workspace context source", + Long: "Remove a previously-registered context source from this workspace's agent " + + "and re-resolve. Built-in default scan roots cannot be removed.\n\nMust be run " + + "from inside the workspace.", + Middleware: serpent.RequireNArgs(1), Handler: func(inv *serpent.Invocation) error { ctx := inv.Context() - client, err := r.InitClient(inv) + client, err := dialAgentContextSocket(ctx, *socketPath) if err != nil { return err } - chatID, err := uuid.Parse(inv.Args[0]) - if err != nil { - return xerrors.Errorf("invalid chat ID %q: %w", inv.Args[0], err) - } - - exp := codersdk.NewExperimentalClient(client) - chat, err := exp.RefreshChatContext(ctx, chatID) - if err != nil { - return xerrors.Errorf("refresh chat context: %w", err) - } + defer client.Close() - _, _ = fmt.Fprintf(inv.Stdout, "Refreshed context for chat %s.\n", chatID) - if chat.Context != nil && chat.Context.Error != "" { - _, _ = fmt.Fprintf(inv.Stdout, "Snapshot reported an error: %s\n", chat.Context.Error) + if err := client.RemoveContextSource(ctx, inv.Args[0]); err != nil { + return xerrors.Errorf("remove context source: %w", err) } + _, _ = fmt.Fprintf(inv.Stdout, "Removed context source %s\n", inv.Args[0]) return nil }, } return cmd } -func (*RootCmd) chatContextAddCommand() *serpent.Command { - var ( - dir string - chatID string - ) - agentAuth := &AgentAuth{} +func (r *RootCmd) chatContextRefreshCommand(socketPath *string) *serpent.Command { cmd := &serpent.Command{ - Use: "add", - Short: "Add context to an active chat", - Long: "Read instruction files and discover skills from a directory, then add " + - "them as context to an active chat session. Multiple calls " + - "are additive.", + Use: "refresh []", + Short: "Refresh chat context to the agent's latest snapshot", + Long: "Re-pin a chat to the workspace agent's latest context snapshot and clear " + + "its drift marker. The chat's next turn uses the refreshed context.\n\nWith a " + + " argument, refreshes that chat and works from anywhere.\n\nWith no " + + "argument, run from inside the workspace: forces the agent to re-resolve its " + + "sources (catching freshly-cloned repos and startup-script writes the watcher " + + "has not seen yet), then refreshes every drifted chat.", + Middleware: serpent.RequireRangeArgs(0, 1), Handler: func(inv *serpent.Invocation) error { ctx := inv.Context() - ctx, stop := inv.SignalNotifyContext(ctx, StopSignals...) - defer stop() - - if dir == "" && inv.Environ.Get("CODER") != "true" { - return xerrors.New("this command must be run inside a Coder workspace (set --dir to override)") - } - - client, err := agentAuth.CreateClient() + client, err := r.InitClient(inv) if err != nil { - return xerrors.Errorf("create agent client: %w", err) + return err } + exp := codersdk.NewExperimentalClient(client) - resolvedDir := dir - if resolvedDir == "" { - resolvedDir, err = os.Getwd() + if len(inv.Args) == 1 { + chatID, err := uuid.Parse(inv.Args[0]) if err != nil { - return xerrors.Errorf("get working directory: %w", err) + return xerrors.Errorf("invalid chat ID %q: %w", inv.Args[0], err) + } + chat, err := exp.RefreshChatContext(ctx, chatID) + if err != nil { + return xerrors.Errorf("refresh chat context: %w", err) + } + _, _ = fmt.Fprintf(inv.Stdout, "Refreshed context for chat %s.\n", chatID) + if chat.Context != nil && chat.Context.Error != "" { + _, _ = fmt.Fprintf(inv.Stdout, "Snapshot reported an error: %s\n", chat.Context.Error) } - } - resolvedDir, err = filepath.Abs(resolvedDir) - if err != nil { - return xerrors.Errorf("resolve directory: %w", err) - } - info, err := os.Stat(resolvedDir) - if err != nil { - return xerrors.Errorf("cannot read directory %q: %w", resolvedDir, err) - } - if !info.IsDir() { - return xerrors.Errorf("%q is not a directory", resolvedDir) - } - - parts := agentcontextconfig.ContextPartsFromDir(resolvedDir) - if len(parts) == 0 { - _, _ = fmt.Fprintln(inv.Stderr, "No context files or skills found in "+resolvedDir) return nil } - // Resolve chat ID from flag or auto-detect. - resolvedChatID, err := parseChatID(chatID) - if err != nil { - return err + // No argument: re-resolve the agent's sources (in-workspace only), + // then fan out a refresh to every drifted chat. + if sock, serr := dialAgentContextSocket(ctx, *socketPath); serr == nil { + defer sock.Close() + snap, rerr := sock.ResyncContext(ctx) + if rerr != nil { + return xerrors.Errorf("re-resolve agent context: %w", rerr) + } + _, _ = fmt.Fprintf(inv.Stdout, "Re-resolved agent context (version %d, %d resources).\n", snap.Version, len(snap.Resources)) + if snap.SnapshotError != "" { + _, _ = fmt.Fprintf(inv.Stdout, "Snapshot reported an error: %s\n", snap.SnapshotError) + } + } else { + _, _ = fmt.Fprintln(inv.Stderr, "Not inside a workspace; skipping agent re-resolve.") } - resp, err := client.AddChatContext(ctx, agentsdk.AddChatContextRequest{ - ChatID: resolvedChatID, - Parts: parts, - }) + chats, err := exp.ListChats(ctx, nil) if err != nil { - return xerrors.Errorf("add chat context: %w", err) + return xerrors.Errorf("list chats: %w", err) } - - _, _ = fmt.Fprintf(inv.Stdout, "Added %d context part(s) to chat %s\n", resp.Count, resp.ChatID) + refreshed := 0 + for _, c := range chats { + if c.Context == nil || !c.Context.Dirty { + continue + } + if _, err := exp.RefreshChatContext(ctx, c.ID); err != nil { + _, _ = fmt.Fprintf(inv.Stderr, "Failed to refresh chat %s: %v\n", c.ID, err) + continue + } + refreshed++ + } + _, _ = fmt.Fprintf(inv.Stdout, "Refreshed %d drifted chat(s).\n", refreshed) return nil }, - Options: serpent.OptionSet{ - { - Name: "Directory", - Flag: "dir", - Description: "Directory to read context files and skills from. Defaults to the current working directory.", - Value: serpent.StringOf(&dir), - }, - { - Name: "Chat ID", - Flag: "chat", - Env: "CODER_CHAT_ID", - Description: "Chat ID to add context to. Auto-detected from CODER_CHAT_ID, the only active chat, or the only top-level active chat.", - Value: serpent.StringOf(&chatID), - }, - }, } - agentAuth.AttachOptions(cmd, false) return cmd } diff --git a/cli/exp_chat_internal_test.go b/cli/exp_chat_internal_test.go index e1bac3eb9a30f..8f0efc0232e12 100644 --- a/cli/exp_chat_internal_test.go +++ b/cli/exp_chat_internal_test.go @@ -1,101 +1,34 @@ package cli import ( - "bytes" "testing" - "time" "github.com/google/uuid" "github.com/stretchr/testify/require" - - "github.com/coder/coder/v2/codersdk" ) -func TestRenderChatContextText(t *testing.T) { +func TestParseChatID(t *testing.T) { t.Parallel() - chatID := uuid.MustParse("11111111-1111-4111-8111-111111111111") - - t.Run("NoPinnedContext", func(t *testing.T) { + t.Run("EmptyIsNil", func(t *testing.T) { t.Parallel() - - var buf bytes.Buffer - require.NoError(t, renderChatContextText(&buf, codersdk.Chat{ID: chatID})) - require.Contains(t, buf.String(), "has no pinned workspace context") + got, err := parseChatID("") + require.NoError(t, err) + require.Equal(t, uuid.Nil, got) }) - t.Run("CleanListsResourcesWithoutChanges", func(t *testing.T) { + t.Run("ValidUUID", func(t *testing.T) { t.Parallel() - - var buf bytes.Buffer - require.NoError(t, renderChatContextText(&buf, codersdk.Chat{ - ID: chatID, - Context: &codersdk.ChatContext{ - Dirty: false, - Resources: []codersdk.ChatContextResource{ - { - Source: "/home/coder/AGENTS.md", - Kind: codersdk.ChatContextResourceKindInstructionFile, - SizeBytes: 12, - }, - { - Source: "/home/coder/.coder/skills/deploy", - Kind: codersdk.ChatContextResourceKindSkill, - SizeBytes: 34, - SkillName: "deploy", - }, - }, - }, - })) - out := buf.String() - require.Contains(t, out, "Status: clean") - require.Contains(t, out, "/home/coder/AGENTS.md") - require.Contains(t, out, "deploy") - // A clean chat shows no change section or refresh hint. - require.NotContains(t, out, "Changes vs latest snapshot") - require.NotContains(t, out, "refresh") + want := uuid.MustParse("11111111-1111-4111-8111-111111111111") + got, err := parseChatID(want.String()) + require.NoError(t, err) + require.Equal(t, want, got) }) - t.Run("DirtyShowsChangesAndRefreshHint", func(t *testing.T) { + t.Run("InvalidErrors", func(t *testing.T) { t.Parallel() - - since := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) - var buf bytes.Buffer - require.NoError(t, renderChatContextText(&buf, codersdk.Chat{ - ID: chatID, - Context: &codersdk.ChatContext{ - Dirty: true, - DirtySince: &since, - Error: "two sources failed to resolve", - Resources: []codersdk.ChatContextResource{ - { - Source: "/home/coder/AGENTS.md", - Kind: codersdk.ChatContextResourceKindInstructionFile, - }, - }, - Changes: []codersdk.ChatContextResourceChange{ - { - Source: "/home/coder/AGENTS.md", - Kind: codersdk.ChatContextResourceKindInstructionFile, - Status: codersdk.ChatContextResourceChangeStatusModified, - OldContent: "old", - NewContent: "new", - }, - { - Source: "/home/coder/.coder/skills/deploy", - Kind: codersdk.ChatContextResourceKindSkill, - Status: codersdk.ChatContextResourceChangeStatusAdded, - SkillName: "deploy", - }, - }, - }, - })) - out := buf.String() - require.Contains(t, out, "Status: drifted (since 2024-01-02T03:04:05Z)") - require.Contains(t, out, "two sources failed to resolve") - require.Contains(t, out, "Changes vs latest snapshot (2)") - require.Contains(t, out, "modified") - require.Contains(t, out, "added") - require.Contains(t, out, "chat context refresh 11111111-1111-4111-8111-111111111111") + _, err := parseChatID("not-a-uuid") + require.Error(t, err) + require.Contains(t, err.Error(), "invalid chat ID") }) } From 92fd5ad8956b2bdecb887e4863f1e21906cdd596 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 17 Jun 2026 23:30:17 +0000 Subject: [PATCH 12/20] fix(cli,chatd,site): accept relative context paths and surface MCP resources coder exp chat context add/show/remove now resolve a relative source path (e.g. ./) to an absolute path before sending it to the agent, which requires canonical absolute paths. A leading ~ is preserved for the agent to expand against its own home. chatd now includes mcp_config and mcp_server rows in a chat's pinned context list and in the drift diff, instead of filtering to instruction files and skills only. MCP changes carry only source, kind, and status (no body diff). The context popover gains an MCP section (config by file basename, server by name) and the changes dialog lists MCP changes alongside skills. --- cli/exp_chat.go | 41 ++++++++++++-- cli/exp_chat_internal_test.go | 43 +++++++++++++++ coderd/apidoc/docs.go | 8 ++- coderd/apidoc/swagger.json | 6 ++- coderd/x/chatd/context_detail.go | 50 +++++++++++------ .../x/chatd/context_detail_internal_test.go | 54 ++++++++++++++++--- codersdk/chats.go | 2 + docs/reference/api/chats.md | 14 ++--- docs/reference/api/schemas.md | 6 +-- site/src/api/typesGenerated.ts | 8 ++- .../components/ContextChangesDialog.tsx | 14 +++-- .../ContextUsageIndicator.stories.tsx | 4 ++ .../components/ContextUsageIndicator.tsx | 44 ++++++++++++++- site/src/testHelpers/chatEntities.ts | 10 ++++ 14 files changed, 253 insertions(+), 51 deletions(-) diff --git a/cli/exp_chat.go b/cli/exp_chat.go index f9d7ff132087a..a3a7063f3a166 100644 --- a/cli/exp_chat.go +++ b/cli/exp_chat.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/google/uuid" "golang.org/x/xerrors" @@ -63,6 +64,26 @@ func (r *RootCmd) chatContextCommand() *serpent.Command { } } +// resolveContextSourcePath makes a user-supplied source path absolute so the +// agent (which requires absolute, canonical paths) accepts it. A leading ~ is +// preserved for the agent to expand against its own home directory; other +// relative paths are resolved against the CLI's working directory, which shares +// the workspace filesystem with the agent. +func resolveContextSourcePath(p string) (string, error) { + p = strings.TrimSpace(p) + if p == "" { + return "", xerrors.New("path is empty") + } + if p == "~" || strings.HasPrefix(p, "~/") { + return p, nil + } + abs, err := filepath.Abs(p) + if err != nil { + return "", xerrors.Errorf("resolve path %q: %w", p, err) + } + return abs, nil +} + // dialAgentContextSocket connects to the workspace agent's local IPC socket. // It is only reachable from inside the workspace. func dialAgentContextSocket(ctx context.Context, socketPath string) (*agentsocket.Client, error) { @@ -140,7 +161,11 @@ func (*RootCmd) chatContextShowCommand(socketPath *string) *serpent.Command { } defer client.Close() - src, err := client.GetContextSource(ctx, inv.Args[0]) + path, err := resolveContextSourcePath(inv.Args[0]) + if err != nil { + return err + } + src, err := client.GetContextSource(ctx, path) if err != nil { return xerrors.Errorf("get context source: %w", err) } @@ -197,13 +222,17 @@ func (*RootCmd) chatContextAddCommand(socketPath *string) *serpent.Command { } // Source registration (default). + path, err := resolveContextSourcePath(inv.Args[0]) + if err != nil { + return err + } client, err := dialAgentContextSocket(ctx, *socketPath) if err != nil { return err } defer client.Close() - src, err := client.AddContextSource(ctx, inv.Args[0]) + src, err := client.AddContextSource(ctx, path) if err != nil { return xerrors.Errorf("add context source: %w", err) } @@ -282,10 +311,14 @@ func (*RootCmd) chatContextRemoveCommand(socketPath *string) *serpent.Command { } defer client.Close() - if err := client.RemoveContextSource(ctx, inv.Args[0]); err != nil { + path, err := resolveContextSourcePath(inv.Args[0]) + if err != nil { + return err + } + if err := client.RemoveContextSource(ctx, path); err != nil { return xerrors.Errorf("remove context source: %w", err) } - _, _ = fmt.Fprintf(inv.Stdout, "Removed context source %s\n", inv.Args[0]) + _, _ = fmt.Fprintf(inv.Stdout, "Removed context source %s\n", path) return nil }, } diff --git a/cli/exp_chat_internal_test.go b/cli/exp_chat_internal_test.go index 8f0efc0232e12..68dbec0e7eeb2 100644 --- a/cli/exp_chat_internal_test.go +++ b/cli/exp_chat_internal_test.go @@ -1,6 +1,7 @@ package cli import ( + "path/filepath" "testing" "github.com/google/uuid" @@ -32,3 +33,45 @@ func TestParseChatID(t *testing.T) { require.Contains(t, err.Error(), "invalid chat ID") }) } + +func TestResolveContextSourcePath(t *testing.T) { + t.Parallel() + + t.Run("EmptyErrors", func(t *testing.T) { + t.Parallel() + _, err := resolveContextSourcePath(" ") + require.Error(t, err) + require.Contains(t, err.Error(), "empty") + }) + + t.Run("PreservesTilde", func(t *testing.T) { + t.Parallel() + // A leading ~ is left for the agent to expand against its own home. + got, err := resolveContextSourcePath("~") + require.NoError(t, err) + require.Equal(t, "~", got) + + got, err = resolveContextSourcePath(" ~/skills/deploy ") + require.NoError(t, err) + require.Equal(t, "~/skills/deploy", got) + }) + + t.Run("KeepsAbsolute", func(t *testing.T) { + t.Parallel() + got, err := resolveContextSourcePath("/home/coder/AGENTS.md") + require.NoError(t, err) + require.Equal(t, "/home/coder/AGENTS.md", got) + }) + + t.Run("MakesRelativeAbsolute", func(t *testing.T) { + t.Parallel() + // "./" was the reported failure: a relative path must be resolved to an + // absolute one before it reaches the agent. + got, err := resolveContextSourcePath("./") + require.NoError(t, err) + require.True(t, filepath.IsAbs(got), "want absolute, got %q", got) + want, err := filepath.Abs("./") + require.NoError(t, err) + require.Equal(t, want, got) + }) +} diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index f319de3878433..695c638fca575 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -16706,11 +16706,15 @@ const docTemplate = `{ "type": "string", "enum": [ "instruction_file", - "skill" + "skill", + "mcp_config", + "mcp_server" ], "x-enum-varnames": [ "ChatContextResourceKindInstructionFile", - "ChatContextResourceKindSkill" + "ChatContextResourceKindSkill", + "ChatContextResourceKindMCPConfig", + "ChatContextResourceKindMCPServer" ] }, "codersdk.ChatDiffContents": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 198154eed418d..7a97f25a345e8 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -15002,10 +15002,12 @@ }, "codersdk.ChatContextResourceKind": { "type": "string", - "enum": ["instruction_file", "skill"], + "enum": ["instruction_file", "skill", "mcp_config", "mcp_server"], "x-enum-varnames": [ "ChatContextResourceKindInstructionFile", - "ChatContextResourceKindSkill" + "ChatContextResourceKindSkill", + "ChatContextResourceKindMCPConfig", + "ChatContextResourceKindMCPServer" ] }, "codersdk.ChatDiffContents": { diff --git a/coderd/x/chatd/context_detail.go b/coderd/x/chatd/context_detail.go index a21067ebf7f78..54ee289c9fee5 100644 --- a/coderd/x/chatd/context_detail.go +++ b/coderd/x/chatd/context_detail.go @@ -25,9 +25,8 @@ const maxContextChangeContentBytes = 64 * 1024 // pushed snapshot. It is read-only and intended for the single-chat GET // handler; list and watch payloads omit this detail to stay lightweight. // -// resources mirrors the prompt-injection rules so it equals the context the -// model actually sees: OK instruction files with non-empty content and OK -// skills with a name. changes is nil unless the chat is dirty (and has a +// resources lists the chat's full pinned inventory (instruction files, skills, +// and MCP configs/servers); changes is nil unless the chat is dirty (and has a // resolvable agent), so the second read is only paid for when it can differ. func (server *Server) ContextDetail( ctx context.Context, @@ -56,11 +55,11 @@ func (server *Server) ContextDetail( } // pinnedContextResources converts a chat's pinned context rows into the -// metadata-only resource list reported on the chat. It applies the same -// inclusion rules as contextResourcesToPrompt so the list equals the context -// the prompt is built from: OK instruction files with non-empty (sanitized) -// content and OK skills with a name. Other kinds and statuses are skipped. -// Input order (source ASC from the query) is preserved. +// metadata-only resource list reported on the chat. It surfaces the full +// pinned inventory the user can act on: OK instruction files with non-empty +// (sanitized) content, OK skills with a name, and OK MCP configs/servers. +// Non-OK rows and empty instruction files are skipped. Input order (source ASC +// from the query) is preserved. func pinnedContextResources(resources []database.ChatContextResource) []codersdk.ChatContextResource { var out []codersdk.ChatContextResource for _, r := range resources { @@ -90,6 +89,18 @@ func pinnedContextResources(resources []database.ChatContextResource) []codersdk SkillName: body.GetName(), SkillDescription: body.GetDescription(), }) + case database.WorkspaceAgentContextBodyKindMcpConfig: + out = append(out, codersdk.ChatContextResource{ + Source: r.Source, + Kind: codersdk.ChatContextResourceKindMCPConfig, + SizeBytes: r.SizeBytes, + }) + case database.WorkspaceAgentContextBodyKindMcpServer: + out = append(out, codersdk.ChatContextResource{ + Source: r.Source, + Kind: codersdk.ChatContextResourceKindMCPServer, + SizeBytes: r.SizeBytes, + }) } } return out @@ -156,10 +167,10 @@ func diffContextResources( // buildResourceChange assembles a change entry for one source. The reported // kind comes from the side that exists now (snapshot for added/modified, -// pinned for removed); ok is false when that side is not a prompt kind, so -// unrelated resource kinds (e.g. MCP config) are skipped. Instruction-file -// changes carry the sanitized, capped bodies of whichever sides are present; -// skill changes carry the identifying name and description. +// pinned for removed); ok is false only for kinds chatd does not track. An +// instruction-file change carries the sanitized, capped bodies of whichever +// sides are present; a skill change carries the identifying name and +// description; MCP config/server changes carry only source, kind, and status. func buildResourceChange( source string, status codersdk.ChatContextResourceChangeStatus, @@ -169,7 +180,7 @@ func buildResourceChange( if current == nil { current = pinned } - kind, ok := promptResourceKind(current.kind) + kind, ok := contextResourceKind(current.kind) if !ok { return codersdk.ChatContextResourceChange{}, false } @@ -202,15 +213,20 @@ func buildResourceChange( return change, true } -// promptResourceKind maps a database body kind to the codersdk kind reported -// on the chat, reporting ok=false for kinds that do not contribute to the -// prompt (and so are not surfaced as context resources or changes). -func promptResourceKind(kind database.WorkspaceAgentContextBodyKind) (codersdk.ChatContextResourceKind, bool) { +// contextResourceKind maps a database body kind to the codersdk kind reported +// on the chat. ok is false only for kinds chatd does not track yet (the +// reserved plugin/hook/subagent/command kinds), which are omitted from the +// resource list and change set. +func contextResourceKind(kind database.WorkspaceAgentContextBodyKind) (codersdk.ChatContextResourceKind, bool) { switch kind { case database.WorkspaceAgentContextBodyKindInstructionFile: return codersdk.ChatContextResourceKindInstructionFile, true case database.WorkspaceAgentContextBodyKindSkill: return codersdk.ChatContextResourceKindSkill, true + case database.WorkspaceAgentContextBodyKindMcpConfig: + return codersdk.ChatContextResourceKindMCPConfig, true + case database.WorkspaceAgentContextBodyKindMcpServer: + return codersdk.ChatContextResourceKindMCPServer, true default: return "", false } diff --git a/coderd/x/chatd/context_detail_internal_test.go b/coderd/x/chatd/context_detail_internal_test.go index b92806eb4f925..ad890738565ba 100644 --- a/coderd/x/chatd/context_detail_internal_test.go +++ b/coderd/x/chatd/context_detail_internal_test.go @@ -104,7 +104,7 @@ func TestPinnedContextResources(t *testing.T) { }, out[1]) }) - t.Run("SkipsNonOKEmptyAndUnknownKinds", func(t *testing.T) { + t.Run("SkipsNonOKAndEmpty", func(t *testing.T) { t.Parallel() resources := []database.ChatContextResource{ @@ -114,15 +114,47 @@ func TestPinnedContextResources(t *testing.T) { instructionResource(t, "/b/AGENTS.md", "", database.WorkspaceAgentContextResourceStatusOk), // OK skill with no name. skillResource(t, "/c/skills/x", "", "no name", database.WorkspaceAgentContextResourceStatusOk), - // Unknown (non-prompt) kind. + // Non-OK MCP config. { - Source: ".mcp.json", + Source: "/d/.mcp.json", BodyKind: database.WorkspaceAgentContextBodyKindMcpConfig, - Status: database.WorkspaceAgentContextResourceStatusOk, + Status: database.WorkspaceAgentContextResourceStatusUnreadable, }, } require.Empty(t, pinnedContextResources(resources)) }) + + 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, + }, + } + out := pinnedContextResources(resources) + require.Equal(t, []codersdk.ChatContextResource{ + { + Source: "/home/coder/.mcp.json", + Kind: codersdk.ChatContextResourceKindMCPConfig, + SizeBytes: 670, + }, + { + Source: "github", + Kind: codersdk.ChatContextResourceKindMCPServer, + SizeBytes: 12, + }, + }, out) + }) } func TestDiffContextResources(t *testing.T) { @@ -209,16 +241,22 @@ func TestDiffContextResources(t *testing.T) { }, changes[2]) }) - t.Run("SkipsNonPromptKinds", func(t *testing.T) { + t.Run("IncludesMCPChanges", func(t *testing.T) { t.Parallel() pinned := []database.ChatContextResource{ - {Source: ".mcp.json", BodyKind: database.WorkspaceAgentContextBodyKindMcpConfig, ContentHash: contentHash("old")}, + {Source: "/p/.mcp.json", BodyKind: database.WorkspaceAgentContextBodyKindMcpConfig, ContentHash: contentHash("old")}, } snapshot := []database.WorkspaceAgentContextResource{ - {Source: ".mcp.json", BodyKind: database.WorkspaceAgentContextBodyKindMcpConfig, ContentHash: contentHash("new")}, + {Source: "/p/.mcp.json", BodyKind: database.WorkspaceAgentContextBodyKindMcpConfig, ContentHash: contentHash("new")}, } - require.Empty(t, diffContextResources(pinned, snapshot)) + changes := diffContextResources(pinned, snapshot) + // MCP changes carry only source, kind, and status (no body diff). + require.Equal(t, []codersdk.ChatContextResourceChange{{ + Source: "/p/.mcp.json", + Kind: codersdk.ChatContextResourceKindMCPConfig, + Status: codersdk.ChatContextResourceChangeStatusModified, + }}, changes) }) t.Run("SanitizesAndCapsContent", func(t *testing.T) { diff --git a/codersdk/chats.go b/codersdk/chats.go index f0bc6c591b869..c6f9e8968d931 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -186,6 +186,8 @@ type ChatContextResourceKind string const ( ChatContextResourceKindInstructionFile ChatContextResourceKind = "instruction_file" ChatContextResourceKindSkill ChatContextResourceKind = "skill" + ChatContextResourceKindMCPConfig ChatContextResourceKind = "mcp_config" + ChatContextResourceKindMCPServer ChatContextResourceKind = "mcp_server" ) // ChatContextResource is one pinned workspace-context resource the chat's diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index b3968b2febffc..47780b5571cb5 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -337,13 +337,13 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|---------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `client_type` | `api`, `ui` | -| `kind` | `auth`, `config`, `generic`, `instruction_file`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `skill`, `stream_silence_timeout`, `timeout`, `usage_limit` | -| `status` | `added`, `completed`, `error`, `interrupting`, `modified`, `paused`, `pending`, `removed`, `requires_action`, `running`, `waiting` | -| `type` | `context-file`, `file`, `file-reference`, `reasoning`, `skill`, `source`, `text`, `tool-call`, `tool-result` | -| `plan_mode` | `plan` | +| Property | Value(s) | +|---------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `client_type` | `api`, `ui` | +| `kind` | `auth`, `config`, `generic`, `instruction_file`, `mcp_config`, `mcp_server`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `skill`, `stream_silence_timeout`, `timeout`, `usage_limit` | +| `status` | `added`, `completed`, `error`, `interrupting`, `modified`, `paused`, `pending`, `removed`, `requires_action`, `running`, `waiting` | +| `type` | `context-file`, `file`, `file-reference`, `reasoning`, `skill`, `source`, `text`, `tool-call`, `tool-result` | +| `plan_mode` | `plan` | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index b6fff3ae4a601..53e155a4acd5a 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2490,9 +2490,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in #### Enumerated Values -| Value(s) | -|-----------------------------| -| `instruction_file`, `skill` | +| Value(s) | +|---------------------------------------------------------| +| `instruction_file`, `mcp_config`, `mcp_server`, `skill` | ## codersdk.ChatDiffContents diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 5a9ffee0fd3dd..0acf5513e91bc 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1712,10 +1712,16 @@ export const ChatContextResourceChangeStatuses: ChatContextResourceChangeStatus[ ["added", "modified", "removed"]; // From codersdk/chats.go -export type ChatContextResourceKind = "instruction_file" | "skill"; +export type ChatContextResourceKind = + | "instruction_file" + | "mcp_config" + | "mcp_server" + | "skill"; export const ChatContextResourceKinds: ChatContextResourceKind[] = [ "instruction_file", + "mcp_config", + "mcp_server", "skill", ]; diff --git a/site/src/pages/AgentsPage/components/ContextChangesDialog.tsx b/site/src/pages/AgentsPage/components/ContextChangesDialog.tsx index 61efb5e047563..98948ba5c01a0 100644 --- a/site/src/pages/AgentsPage/components/ContextChangesDialog.tsx +++ b/site/src/pages/AgentsPage/components/ContextChangesDialog.tsx @@ -34,8 +34,8 @@ const CHANGE_LABELS: Record< /** * Renders the differences between a chat's pinned workspace context and the * agent's latest snapshot. Instruction-file edits are shown as a diff (reusing - * the chat DiffViewer); skill changes are shown as a labeled list since they - * carry only a name and description. A refresh action re-pins to the latest + * the chat DiffViewer); skill and MCP changes are shown as a labeled list since + * they carry only a name/source. A refresh action re-pins to the latest * snapshot. */ export const ContextChangesDialog: FC = ({ @@ -48,7 +48,11 @@ export const ContextChangesDialog: FC = ({ const fileChanges = changes.filter( (change) => change.kind === "instruction_file", ); - const skillChanges = changes.filter((change) => change.kind === "skill"); + // Skills and MCP configs/servers have no textual body to diff, so they are + // listed by name/source rather than rendered through the DiffViewer. + const listChanges = changes.filter( + (change) => change.kind !== "instruction_file", + ); // Concatenate per-file patches into one diff string and let useParsedDiff // perform the (memoized) parse, rather than parsing in render. @@ -74,9 +78,9 @@ export const ContextChangesDialog: FC = ({ - {skillChanges.length > 0 && ( + {listChanges.length > 0 && (
    - {skillChanges.map((change) => { + {listChanges.map((change) => { const label = CHANGE_LABELS[change.status]; return (
  • typeof value === "number" && Number.isFinite(value) && value >= 0; @@ -181,7 +185,30 @@ export const ContextUsageIndicator: FC<{ // Drop entries with no usable name so an empty skill marker never renders // as a blank row. .filter((skill) => skill.name.trim().length > 0); - const hasContextList = fileItems.length > 0 || skillItems.length > 0; + // MCP configs/servers are only ever surfaced from the chat's pinned + // resources; there is no injected-context fallback for them. An MCP server's + // source is its server name, while an MCP config's source is its file path. + const mcpItems: readonly ContextMcpItem[] = ( + usePinned + ? (pinnedResources ?? []) + .filter( + (resource) => + resource.kind === "mcp_config" || resource.kind === "mcp_server", + ) + .map((resource) => ({ + name: + resource.kind === "mcp_server" + ? resource.source + : getPathBasename(resource.source), + source: resource.source, + })) + : [] + ) + // Drop entries with no usable name so an empty MCP marker never renders as + // a blank row. + .filter((mcp) => mcp.name.trim().length > 0); + const hasContextList = + fileItems.length > 0 || skillItems.length > 0 || mcpItems.length > 0; const ariaLabel = hasPercent ? `Context usage ${percentLabel}. ${formatTokenCount(usedTokens)} of ${formatTokenCount(contextLimitTokens)} tokens used.${isDirty ? " Context changed." : ""}` @@ -265,6 +292,19 @@ export const ContextUsageIndicator: FC<{ )} + {mcpItems.length > 0 && ( +
    + MCP + {mcpItems.map((mcp) => ( +
    + + + {mcp.name} + +
    + ))} +
    + )} )} {(isDirty || hasContextError) && ( diff --git a/site/src/testHelpers/chatEntities.ts b/site/src/testHelpers/chatEntities.ts index d25e4949aef9e..2b30136b26289 100644 --- a/site/src/testHelpers/chatEntities.ts +++ b/site/src/testHelpers/chatEntities.ts @@ -48,6 +48,16 @@ const MockChatContextResources: ChatContextResource[] = [ skill_name: "deploy", skill_description: "Deploy the app to staging.", }, + { + source: "/home/coder/.mcp.json", + kind: "mcp_config", + size_bytes: 184, + }, + { + source: "github", + kind: "mcp_server", + size_bytes: 512, + }, ]; // Per-source differences between the pinned context and the latest snapshot. From 2c83aff810733303cc2bc38fcd68e35b8988332f Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 17 Jun 2026 23:56:42 +0000 Subject: [PATCH 13/20] feat(agent): produce MCP server resources in the workspace-context snapshot Wire the MCP manager's live tool cache into the agentcontext resolver via a new ManagerOptions.MCP provider, so each connected MCP server becomes a KindMCPServer resource (with its tools) in the snapshot alongside instruction files and skills. Previously Resolver.MCP was never set, so only the .mcp.json file appeared (as an mcp_config resource) and servers/tools never reached coderd or the UI. agent/x/agentmcp.Manager: export CachedTools (a non-blocking copy of the tool cache) and add SetOnToolsChanged, fired after a reload writes the cache so the agentcontext manager re-resolves when the live tool set changes. Only servers exposing at least one tool are surfaced; a connected server with no tools, or a failed connection, is not shown. The wire/proto and coderd storage path for KindMCPServer already existed; this change only produces the resources. --- agent/agent.go | 9 ++ agent/agentcontext/manager.go | 11 ++ agent/agentcontext/manager_test.go | 31 +++++ agent/contextmcp.go | 117 ++++++++++++++++++ agent/contextmcp_internal_test.go | 86 +++++++++++++ agent/x/agentmcp/api_internal_test.go | 2 +- agent/x/agentmcp/cachedtools_internal_test.go | 52 ++++++++ .../x/agentmcp/configwatcher_internal_test.go | 14 +-- agent/x/agentmcp/manager.go | 41 +++++- agent/x/agentmcp/reload_internal_test.go | 30 ++--- 10 files changed, 366 insertions(+), 27 deletions(-) create mode 100644 agent/contextmcp.go create mode 100644 agent/contextmcp_internal_test.go create mode 100644 agent/x/agentmcp/cachedtools_internal_test.go diff --git a/agent/agent.go b/agent/agent.go index c9c3cc71c8563..6c1a66ba762b1 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -513,7 +513,16 @@ func (a *agent) init() { Clock: a.clock, WorkingDir: workingDirFn, InitialSources: initialContextSources(a.contextConfig, workingDirFn), + // Surface live MCP servers (and their tools) in the + // snapshot by reading the MCP manager's cached tool list + // on every resolve. + MCP: mcpContextProvider{cachedTools: a.mcpManager.CachedTools}, }) + // Re-resolve the context snapshot whenever the MCP tool set + // changes (e.g. a .mcp.json edit reconnects servers) so MCP + // server resources track the live tools. Wired after both + // managers exist; the MCP manager fires this outside its lock. + a.mcpManager.SetOnToolsChanged(a.contextManager.Trigger) a.contextAPI = agentcontext.NewAPI(a.contextManager) a.reconnectingPTYServer = reconnectingpty.NewServer( a.logger.Named("reconnecting-pty"), diff --git a/agent/agentcontext/manager.go b/agent/agentcontext/manager.go index decefdd1c4e93..51c3d79181a47 100644 --- a/agent/agentcontext/manager.go +++ b/agent/agentcontext/manager.go @@ -38,6 +38,12 @@ type ManagerOptions struct { // Tests use this to inject MCP providers and tighten // caps. Resolver *Resolver + // MCP, when non-nil, supplies live MCP server resources to + // the resolver. It is applied to the resolver (default or + // injected via Resolver) so MCP servers participate in + // every snapshot. An MCP set directly on an injected + // Resolver takes precedence. + MCP MCPProvider // Debounce overrides the watcher's debounce window. Debounce time.Duration } @@ -118,6 +124,11 @@ func NewManager(opts ManagerOptions) *Manager { if resolver == nil { resolver = &Resolver{} } + // Apply the MCP provider to whichever resolver is used, unless + // an injected resolver already set one (which takes precedence). + if opts.MCP != nil && resolver.MCP == nil { + resolver.MCP = opts.MCP + } m := &Manager{ logger: opts.Logger, diff --git a/agent/agentcontext/manager_test.go b/agent/agentcontext/manager_test.go index ce96490a56bf2..ed921ba1954ed 100644 --- a/agent/agentcontext/manager_test.go +++ b/agent/agentcontext/manager_test.go @@ -395,3 +395,34 @@ func TestManager_SubscribeBroadcastOnChange(t *testing.T) { t.Fatal("expected subscriber to be notified") } } + +// TestManager_MCPProviderOptionAppliesToSnapshot verifies that an MCP +// provider supplied via ManagerOptions.MCP contributes KindMCPServer +// resources (with their tools) to the resolved snapshot. +func TestManager_MCPProviderOptionAppliesToSnapshot(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + m := newTestManager(t, agentcontext.ManagerOptions{ + WorkingDir: func() string { return dir }, + MCP: &fakeMCPProvider{resources: []agentcontext.Resource{{ + ID: "mcp_server:fs", + Kind: agentcontext.KindMCPServer, + Source: "fs", + Name: "fs", + Status: agentcontext.StatusOK, + Tools: []agentcontext.MCPTool{{Name: "fs__read", Description: "Read"}}, + }}}, + }) + + snap := m.Snapshot() + var found bool + for _, r := range snap.Resources { + if r.Kind == agentcontext.KindMCPServer && r.Source == "fs" { + found = true + require.Len(t, r.Tools, 1) + require.Equal(t, "fs__read", r.Tools[0].Name) + } + } + require.True(t, found, "expected MCP server resource in snapshot") +} diff --git a/agent/contextmcp.go b/agent/contextmcp.go new file mode 100644 index 0000000000000..686ba659b96e5 --- /dev/null +++ b/agent/contextmcp.go @@ -0,0 +1,117 @@ +package agent + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "slices" + "strings" + + "github.com/coder/coder/v2/agent/agentcontext" + "github.com/coder/coder/v2/codersdk/workspacesdk" +) + +// mcpContextProvider adapts the agent's MCP manager to the +// agentcontext.MCPProvider seam. It reads the manager's cached tool +// list (never blocking) and groups it into one KindMCPServer resource +// per server, so live MCP servers and their tools appear in the +// workspace-context snapshot alongside instruction files and skills. +type mcpContextProvider struct { + // cachedTools returns the current MCP tool cache without blocking. + // It is *agentmcp.Manager.CachedTools in production. + cachedTools func() []workspacesdk.MCPToolInfo +} + +// MCPResources implements agentcontext.MCPProvider. It must never block; +// the resolver calls it on every re-resolve. +func (p mcpContextProvider) MCPResources() []agentcontext.Resource { + if p.cachedTools == nil { + return nil + } + return buildMCPServerResources(p.cachedTools()) +} + +// buildMCPServerResources groups a flat MCP tool list by server name and +// returns one KindMCPServer resource per server. Servers are emitted in +// name order, and tools within a server in name order, so the resource ID +// list and content hashes are deterministic across resolves. Only servers +// that expose at least one tool are surfaced; a server's .mcp.json entry +// still appears separately as a KindMCPConfig resource. +func buildMCPServerResources(tools []workspacesdk.MCPToolInfo) []agentcontext.Resource { + if len(tools) == 0 { + return nil + } + byServer := make(map[string][]workspacesdk.MCPToolInfo) + for _, t := range tools { + if t.ServerName == "" { + continue + } + byServer[t.ServerName] = append(byServer[t.ServerName], t) + } + servers := make([]string, 0, len(byServer)) + for name := range byServer { + servers = append(servers, name) + } + slices.Sort(servers) + + resources := make([]agentcontext.Resource, 0, len(servers)) + for _, server := range servers { + serverTools := byServer[server] + slices.SortFunc(serverTools, func(a, b workspacesdk.MCPToolInfo) int { + return strings.Compare(a.Name, b.Name) + }) + converted := make([]agentcontext.MCPTool, 0, len(serverTools)) + for _, t := range serverTools { + converted = append(converted, agentcontext.MCPTool{ + Name: t.Name, + Description: t.Description, + InputSchema: t.Schema, + }) + } + resources = append(resources, agentcontext.Resource{ + ID: resourceID(agentcontext.KindMCPServer, server), + Kind: agentcontext.KindMCPServer, + Source: server, + Name: server, + Status: agentcontext.StatusOK, + ContentHash: hashMCPServer(server, converted), + Tools: converted, + }) + } + return resources +} + +// resourceID mirrors agentcontext's unexported ID scheme +// (":") so MCP server resources sort and dedup +// consistently with filesystem-resolved resources. +func resourceID(kind agentcontext.ResourceKind, source string) string { + return kind.String() + ":" + source +} + +// hashMCPServer produces a deterministic content hash over a server's +// identity and full tool set (name, description, and input schema) so any +// tool-set change flips the snapshot's aggregate hash and re-pins dirty +// chats. The schema is encoded with encoding/json, which sorts map keys. +func hashMCPServer(server string, tools []agentcontext.MCPTool) [32]byte { + h := sha256.New() + writeHashField(h, server) + for _, t := range tools { + writeHashField(h, t.Name) + writeHashField(h, t.Description) + if len(t.InputSchema) > 0 { + if schema, err := json.Marshal(t.InputSchema); err == nil { + writeHashField(h, string(schema)) + } + } + } + var sum [32]byte + copy(sum[:], h.Sum(nil)) + return sum +} + +// writeHashField writes a length-prefixed field so adjacent fields cannot +// be confused by concatenation (e.g. "ab"+"c" vs "a"+"bc"). +func writeHashField(h io.Writer, s string) { + _, _ = fmt.Fprintf(h, "%d:%s", len(s), s) +} diff --git a/agent/contextmcp_internal_test.go b/agent/contextmcp_internal_test.go new file mode 100644 index 0000000000000..0b917241a0cdc --- /dev/null +++ b/agent/contextmcp_internal_test.go @@ -0,0 +1,86 @@ +package agent + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/agent/agentcontext" + "github.com/coder/coder/v2/codersdk/workspacesdk" +) + +func TestBuildMCPServerResources(t *testing.T) { + t.Parallel() + + t.Run("Empty", func(t *testing.T) { + t.Parallel() + require.Nil(t, buildMCPServerResources(nil)) + require.Nil(t, buildMCPServerResources([]workspacesdk.MCPToolInfo{})) + }) + + t.Run("GroupsByServerSortedWithTools", func(t *testing.T) { + t.Parallel() + tools := []workspacesdk.MCPToolInfo{ + {ServerName: "github", Name: "github__search", Description: "Search"}, + {ServerName: "fs", Name: "fs__read", Description: "Read", Schema: map[string]any{"type": "object"}}, + {ServerName: "github", Name: "github__create", Description: "Create"}, + // Dropped: a tool with no server cannot be grouped. + {ServerName: "", Name: "orphan"}, + } + got := buildMCPServerResources(tools) + require.Len(t, got, 2) + + // Servers are emitted in name order: fs, then github. + require.Equal(t, "fs", got[0].Source) + require.Equal(t, "fs", got[0].Name) + require.Equal(t, agentcontext.KindMCPServer, got[0].Kind) + require.Equal(t, "mcp_server:fs", got[0].ID) + require.Equal(t, agentcontext.StatusOK, got[0].Status) + require.NotEqual(t, [32]byte{}, got[0].ContentHash) + require.Len(t, got[0].Tools, 1) + require.Equal(t, "fs__read", got[0].Tools[0].Name) + require.Equal(t, map[string]any{"type": "object"}, got[0].Tools[0].InputSchema) + + require.Equal(t, "github", got[1].Source) + require.Len(t, got[1].Tools, 2) + // Tools within a server are sorted by name: create, then search. + require.Equal(t, "github__create", got[1].Tools[0].Name) + require.Equal(t, "github__search", got[1].Tools[1].Name) + }) + + t.Run("ContentHashStableAndToolSensitive", func(t *testing.T) { + t.Parallel() + base := []workspacesdk.MCPToolInfo{ + {ServerName: "fs", Name: "fs__read", Description: "Read"}, + } + h1 := buildMCPServerResources(base)[0].ContentHash + // Identical input is hashed identically. + require.Equal(t, h1, buildMCPServerResources(base)[0].ContentHash) + // A description change flips the hash. + require.NotEqual(t, h1, buildMCPServerResources([]workspacesdk.MCPToolInfo{ + {ServerName: "fs", Name: "fs__read", Description: "Read files"}, + })[0].ContentHash) + // Adding a tool flips the hash. + require.NotEqual(t, h1, buildMCPServerResources([]workspacesdk.MCPToolInfo{ + {ServerName: "fs", Name: "fs__read", Description: "Read"}, + {ServerName: "fs", Name: "fs__write", Description: "Write"}, + })[0].ContentHash) + // A schema change flips the hash. + require.NotEqual(t, h1, buildMCPServerResources([]workspacesdk.MCPToolInfo{ + {ServerName: "fs", Name: "fs__read", Description: "Read", Schema: map[string]any{"type": "object"}}, + })[0].ContentHash) + }) + + t.Run("ProviderDelegates", func(t *testing.T) { + t.Parallel() + // A nil cache source yields no resources rather than panicking. + require.Nil(t, mcpContextProvider{}.MCPResources()) + + p := mcpContextProvider{cachedTools: func() []workspacesdk.MCPToolInfo { + return []workspacesdk.MCPToolInfo{{ServerName: "fs", Name: "fs__read"}} + }} + got := p.MCPResources() + require.Len(t, got, 1) + require.Equal(t, "fs", got[0].Source) + }) +} diff --git a/agent/x/agentmcp/api_internal_test.go b/agent/x/agentmcp/api_internal_test.go index 42689475119b1..d507043d623be 100644 --- a/agent/x/agentmcp/api_internal_test.go +++ b/agent/x/agentmcp/api_internal_test.go @@ -174,7 +174,7 @@ func TestHandleListTools_ReloadsAfterStartupSettled(t *testing.T) { t.Cleanup(func() { _ = m.Close() }) // No prior m.Reload: snapshot empty and tools unset. - require.Empty(t, m.cachedTools(), "manager should start with no tools") + require.Empty(t, m.CachedTools(), "manager should start with no tools") api := NewAPI(logger, m, func() []string { return []string{configPath} diff --git a/agent/x/agentmcp/cachedtools_internal_test.go b/agent/x/agentmcp/cachedtools_internal_test.go new file mode 100644 index 0000000000000..df67e22a8d154 --- /dev/null +++ b/agent/x/agentmcp/cachedtools_internal_test.go @@ -0,0 +1,52 @@ +package agentmcp + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3" + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/agent/agentexec" + "github.com/coder/coder/v2/testutil" +) + +// TestManager_CachedToolsAndOnToolsChanged verifies the non-blocking +// CachedTools accessor returns an independent copy of the cache and that a +// reload which writes the cache fires the onToolsChanged hook (used to +// re-resolve the workspace-context snapshot). +func TestManager_CachedToolsAndOnToolsChanged(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) + dir := t.TempDir() + + m := NewManager(ctx, logger, agentexec.DefaultExecer, nil) + m.MarkStartupSettled() + t.Cleanup(func() { _ = m.Close() }) + + // CachedTools never blocks and is empty before the first reload. + require.Empty(t, m.CachedTools()) + + changed := make(chan struct{}, 8) + m.SetOnToolsChanged(func() { changed <- struct{}{} }) + + _, entry := fakeMCPServerConfig(t, "srv") + configPath := writeMCPConfig(t, dir, map[string]mcpServerEntry{"srv": entry}) + + tools, err := m.Tools(ctx, []string{configPath}) + require.NoError(t, err) + require.Len(t, tools, 1) + + // The reload that produced the tools fired the change hook. + testutil.RequireReceive(ctx, t, changed) + + // CachedTools reflects the same set, returned as an independent copy + // so callers cannot mutate the manager's cache. + cached := m.CachedTools() + require.Len(t, cached, 1) + require.Equal(t, tools[0].Name, cached[0].Name) + cached[0].Name = "mutated" + require.NotEqual(t, "mutated", m.CachedTools()[0].Name) +} diff --git a/agent/x/agentmcp/configwatcher_internal_test.go b/agent/x/agentmcp/configwatcher_internal_test.go index 4b93242ed35af..07a8245d8ed12 100644 --- a/agent/x/agentmcp/configwatcher_internal_test.go +++ b/agent/x/agentmcp/configwatcher_internal_test.go @@ -38,7 +38,7 @@ func awaitTools(ctx context.Context, t *testing.T, m *Manager, pred func([]works t.Helper() var final []workspacesdk.MCPToolInfo testutil.Eventually(ctx, t, func(context.Context) bool { - final = m.cachedTools() + final = m.CachedTools() return pred(final) }, testutil.IntervalFast) return final @@ -74,7 +74,7 @@ func TestWatcher_LateFileTriggersReload(t *testing.T) { // First Reload arms the watcher but finds nothing on disk. require.NoError(t, m.Reload(ctx, []string{configPath})) - require.Empty(t, m.cachedTools(), "manager should start with no tools") + require.Empty(t, m.CachedTools(), "manager should start with no tools") // Write the file after the manager has already settled. The // watcher must observe the Create event, debounce it, and @@ -114,7 +114,7 @@ func TestWatcher_RewriteTriggersReload(t *testing.T) { t.Cleanup(func() { _ = m.Close() }) require.NoError(t, m.Reload(ctx, []string{configPath})) - tools := m.cachedTools() + tools := m.CachedTools() require.Len(t, tools, 1) assert.Contains(t, tools[0].Name, "srv") @@ -153,14 +153,14 @@ func TestWatcher_RemovalTransitionsToEmpty(t *testing.T) { t.Cleanup(func() { _ = m.Close() }) require.NoError(t, m.Reload(ctx, []string{configPath})) - require.Len(t, m.cachedTools(), 1) + require.Len(t, m.CachedTools(), 1) require.NoError(t, os.Remove(configPath)) awaitTools(ctx, t, m, func(tools []workspacesdk.MCPToolInfo) bool { return len(tools) == 0 }) - assert.Empty(t, m.cachedTools()) + assert.Empty(t, m.CachedTools()) } // TestWatcher_DebouncesBurst uses the quartz mock clock to @@ -288,7 +288,7 @@ func TestWatcher_DualAgentHTTPNoStall(t *testing.T) { // First Reload races ahead of the host agent: empty config. require.NoError(t, m.Reload(ctx, []string{configPath})) - require.Empty(t, m.cachedTools()) + require.Empty(t, m.CachedTools()) api := NewAPI(logger, m, func() []string { return []string{configPath} }) @@ -347,7 +347,7 @@ func TestWatcher_LateParentDirTriggersReload(t *testing.T) { t.Cleanup(func() { _ = m.Close() }) require.NoError(t, m.Reload(ctx, []string{configPath})) - require.Empty(t, m.cachedTools()) + require.Empty(t, m.CachedTools()) // Create the missing parent directory. fsnotify will deliver // a Create event on root; handleEvent must release the root diff --git a/agent/x/agentmcp/manager.go b/agent/x/agentmcp/manager.go index cd6a7051515fd..d3daf7cf670d9 100644 --- a/agent/x/agentmcp/manager.go +++ b/agent/x/agentmcp/manager.go @@ -128,6 +128,13 @@ type Manager struct { // in-flight reload (for example, to verify Close()'s // shutdown ordering does not stall on a stuck connect). connectStartedHook func() + + // onToolsChanged is invoked (outside m.mu) after a reload + // writes a new tool cache, letting listeners such as the + // agentcontext manager re-resolve so MCP server resources + // track the live tool set. Guarded by m.mu; nil until set + // via SetOnToolsChanged. + onToolsChanged func() } // serverEntry pairs a server config with its connected client. @@ -189,6 +196,17 @@ func (m *Manager) MarkStartupSettled() { m.startupOnce.Do(func() { close(m.startupSettled) }) } +// SetOnToolsChanged registers a callback invoked after a reload +// updates the cached tool set. The callback runs outside the +// manager lock and must not block; it is typically wired to the +// agentcontext manager's Trigger so MCP server resources are +// re-resolved when tools change. Passing nil clears the hook. +func (m *Manager) SetOnToolsChanged(fn func()) { + m.mu.Lock() + defer m.mu.Unlock() + m.onToolsChanged = fn +} + // Tools returns the current MCP tool cache after startup-safe config // synchronization. // @@ -209,13 +227,13 @@ func (m *Manager) Tools(ctx context.Context, paths []string) ([]workspacesdk.MCP return m.toolsAfterReloadError(err) } if !started { - return normalizeTools(m.cachedTools()), nil + return normalizeTools(m.CachedTools()), nil } if err := m.waitReload(ctx, ch, toolsReloadTimeout); err != nil { return m.toolsAfterReloadError(err) } - return normalizeTools(m.cachedTools()), nil + return normalizeTools(m.CachedTools()), nil } func (m *Manager) waitForStartupSettled(ctx context.Context) error { @@ -728,8 +746,13 @@ func captureSnapshot(paths []string) map[string]fileSnapshot { return snap } -// cachedTools returns the cached tool list. Thread-safe. -func (m *Manager) cachedTools() []workspacesdk.MCPToolInfo { +// CachedTools returns a copy of the current tool cache. It is +// thread-safe and non-blocking (no startup-settle wait or reload), +// intended for callers that must never block, e.g. the agentcontext +// resolver's MCP provider invoked on every re-resolve. The cache is +// empty until the first reload completes; SetOnToolsChanged lets +// callers learn when it is populated. +func (m *Manager) CachedTools() []workspacesdk.MCPToolInfo { m.mu.RLock() defer m.mu.RUnlock() @@ -858,11 +881,21 @@ func (m *Manager) RefreshTools(ctx context.Context) error { // Skip the write if the server map changed since the // snapshot. A doReload that bumped the generation will // produce a correct tool list; this write would be stale. + changed := false if m.serverGen == gen { m.tools = merged + changed = true } + cb := m.onToolsChanged m.mu.Unlock() + // Notify listeners outside the lock so a re-resolve can pick up + // the new tool set. Fired only when this reload actually wrote + // the cache; listeners dedupe via the snapshot aggregate hash. + if changed && cb != nil { + cb() + } + return errors.Join(errs...) } diff --git a/agent/x/agentmcp/reload_internal_test.go b/agent/x/agentmcp/reload_internal_test.go index 1557b336e8fee..a68e7d746d07e 100644 --- a/agent/x/agentmcp/reload_internal_test.go +++ b/agent/x/agentmcp/reload_internal_test.go @@ -220,7 +220,7 @@ func TestSnapshotChanged_MultipleConfigFiles(t *testing.T) { require.NoError(t, err) // Tools from both files should be present. - tools := m.cachedTools() + tools := m.CachedTools() require.Len(t, tools, 2, "should have tools from both config files") assert.Contains(t, tools[0].Name, "srv1", "first tool should be from first config") @@ -246,7 +246,7 @@ func TestReload(t *testing.T) { err := m.Reload(ctx, []string{configPath}) require.NoError(t, err) - tools := m.cachedTools() + tools := m.CachedTools() require.Len(t, tools, 1, "should have one tool from the fake server") assert.Contains(t, tools[0].Name, "echo") @@ -293,7 +293,7 @@ func TestReload(t *testing.T) { assert.NoError(t, err, "caller %d should not fail", i) } - tools := m.cachedTools() + tools := m.CachedTools() require.Len(t, tools, 1) }) @@ -340,7 +340,7 @@ func TestReload(t *testing.T) { // First reload. err := m.Reload(ctx, []string{configPath}) require.NoError(t, err) - tools1 := m.cachedTools() + tools1 := m.CachedTools() require.Len(t, tools1, 1) assert.Contains(t, tools1[0].Name, "srv1") @@ -352,7 +352,7 @@ func TestReload(t *testing.T) { assert.True(t, m.SnapshotChanged([]string{configPath})) err = m.Reload(ctx, []string{configPath}) require.NoError(t, err) - tools2 := m.cachedTools() + tools2 := m.CachedTools() require.Len(t, tools2, 1) assert.Contains(t, tools2[0].Name, "srv2") }) @@ -393,14 +393,14 @@ func TestReload(t *testing.T) { err := m.Reload(ctx, []string{configPath}) require.NoError(t, err) - require.Len(t, m.cachedTools(), 1) + require.Len(t, m.CachedTools(), 1) // Delete config file. require.NoError(t, os.Remove(configPath)) err = m.Reload(ctx, []string{configPath}) require.NoError(t, err) - assert.Empty(t, m.cachedTools(), "tools should be empty after config deleted") + assert.Empty(t, m.CachedTools(), "tools should be empty after config deleted") // Subsequent reload finds snapshot unchanged. assert.False(t, m.SnapshotChanged([]string{configPath})) @@ -451,7 +451,7 @@ func TestDifferentialReload(t *testing.T) { "unchanged server should reuse client pointer") // Both servers should have tools. - tools := m.cachedTools() + tools := m.CachedTools() require.Len(t, tools, 2) }) @@ -505,7 +505,7 @@ func TestDifferentialReload(t *testing.T) { err := m.Reload(ctx, []string{configPath}) require.NoError(t, err) - require.Len(t, m.cachedTools(), 2) + require.Len(t, m.CachedTools(), 2) // Capture srvB's client before removal. m.mu.RLock() @@ -519,7 +519,7 @@ func TestDifferentialReload(t *testing.T) { err = m.Reload(ctx, []string{configPath}) require.NoError(t, err) - tools := m.cachedTools() + tools := m.CachedTools() require.Len(t, tools, 1) assert.Contains(t, tools[0].Name, "srvA") @@ -545,7 +545,7 @@ func TestDifferentialReload(t *testing.T) { err := m.Reload(ctx, []string{configPath}) require.NoError(t, err) - require.Len(t, m.cachedTools(), 1) + require.Len(t, m.CachedTools(), 1) m.mu.RLock() origClient := m.servers["srv"].client @@ -568,7 +568,7 @@ func TestDifferentialReload(t *testing.T) { "failed connect should retain old client") // Tools should still work. - tools := m.cachedTools() + tools := m.CachedTools() require.Len(t, tools, 1) }) @@ -586,7 +586,7 @@ func TestDifferentialReload(t *testing.T) { err := m.Reload(ctx, []string{configPath}) require.NoError(t, err) - tools := m.cachedTools() + tools := m.CachedTools() require.Len(t, tools, 1) toolName := tools[0].Name @@ -637,7 +637,7 @@ func TestReload_FirstBootPath(t *testing.T) { err := m.Reload(ctx, []string{configPath}) require.NoError(t, err) - tools := m.cachedTools() + tools := m.CachedTools() require.Len(t, tools, 1) assert.Contains(t, tools[0].Name, "echo") } @@ -709,7 +709,7 @@ func TestClose_SuppressesSubprocessExitError(t *testing.T) { err := m.Reload(ctx, []string{configPath}) require.NoError(t, err) - require.Len(t, m.cachedTools(), 1, "server should be connected") + require.Len(t, m.CachedTools(), 1, "server should be connected") // Close kills the subprocess. The ExitError guard should // suppress the "signal: killed" error. From f4ca2ac14bb26a395b19bb6e52ffce5e9f69d391 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 18 Jun 2026 00:06:13 +0000 Subject: [PATCH 14/20] feat(chatd,site): surface MCP server tools in the chat context UI Decode the pinned mcp_server body and report its tools (with the agent's "__" name prefix stripped) on the single-chat GET, via a new codersdk.ChatContextMCPTool list on ChatContextResource. The context popover now lists each MCP server's tools beneath it (wrench icon, description tooltip), mirroring how skills are shown. Tool decoding is defensive: a missing or malformed body yields no tools rather than breaking the pinned-context list. --- coderd/apidoc/docs.go | 22 ++++- coderd/apidoc/swagger.json | 22 ++++- coderd/x/chatd/context_detail.go | 1 + .../x/chatd/context_detail_internal_test.go | 13 +++ coderd/x/chatd/context_prompt.go | 32 +++++++ codersdk/chats.go | 16 +++- docs/reference/api/chats.md | 89 ++++++++++++++++++- docs/reference/api/schemas.md | 61 +++++++++++-- site/src/api/typesGenerated.ts | 25 +++++- .../ContextUsageIndicator.stories.tsx | 3 + .../components/ContextUsageIndicator.tsx | 66 +++++++++++--- site/src/testHelpers/chatEntities.ts | 7 ++ 12 files changed, 335 insertions(+), 22 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 695c638fca575..20285c8710409 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -16637,12 +16637,32 @@ const docTemplate = `{ } } }, + "codersdk.ChatContextMCPTool": { + "type": "object", + "properties": { + "description": { + "description": "Description is the tool's human-readable summary; may be empty.", + "type": "string" + }, + "name": { + "description": "Name is the tool name with the \"\u003cserver\u003e__\" prefix the agent adds\nstripped, so it reads as the server exposes it.", + "type": "string" + } + } + }, "codersdk.ChatContextResource": { "type": "object", "properties": { "kind": { "$ref": "#/definitions/codersdk.ChatContextResourceKind" }, + "mcp_tools": { + "description": "McpTools lists the tools exposed by an MCP server. Populated only for\nthe mcp_server kind; nil otherwise.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatContextMCPTool" + } + }, "size_bytes": { "description": "SizeBytes is the original payload size in bytes.", "type": "integer" @@ -16655,7 +16675,7 @@ const docTemplate = `{ "type": "string" }, "source": { - "description": "Source is the resource locator: the canonical file path for an\ninstruction file, or the skill directory for a skill.", + "description": "Source is the resource locator: the canonical file path for an\ninstruction file, the skill directory for a skill, the file path for\nan MCP config, or the server name for an MCP server.", "type": "string" } } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 7a97f25a345e8..d859cbc99e097 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -14939,12 +14939,32 @@ } } }, + "codersdk.ChatContextMCPTool": { + "type": "object", + "properties": { + "description": { + "description": "Description is the tool's human-readable summary; may be empty.", + "type": "string" + }, + "name": { + "description": "Name is the tool name with the \"\u003cserver\u003e__\" prefix the agent adds\nstripped, so it reads as the server exposes it.", + "type": "string" + } + } + }, "codersdk.ChatContextResource": { "type": "object", "properties": { "kind": { "$ref": "#/definitions/codersdk.ChatContextResourceKind" }, + "mcp_tools": { + "description": "McpTools lists the tools exposed by an MCP server. Populated only for\nthe mcp_server kind; nil otherwise.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatContextMCPTool" + } + }, "size_bytes": { "description": "SizeBytes is the original payload size in bytes.", "type": "integer" @@ -14957,7 +14977,7 @@ "type": "string" }, "source": { - "description": "Source is the resource locator: the canonical file path for an\ninstruction file, or the skill directory for a skill.", + "description": "Source is the resource locator: the canonical file path for an\ninstruction file, the skill directory for a skill, the file path for\nan MCP config, or the server name for an MCP server.", "type": "string" } } diff --git a/coderd/x/chatd/context_detail.go b/coderd/x/chatd/context_detail.go index 54ee289c9fee5..01aa660ceed0d 100644 --- a/coderd/x/chatd/context_detail.go +++ b/coderd/x/chatd/context_detail.go @@ -100,6 +100,7 @@ func pinnedContextResources(resources []database.ChatContextResource) []codersdk Source: r.Source, Kind: codersdk.ChatContextResourceKindMCPServer, SizeBytes: r.SizeBytes, + McpTools: mcpToolsFromServerBody(r.Source, r.Body), }) } } diff --git a/coderd/x/chatd/context_detail_internal_test.go b/coderd/x/chatd/context_detail_internal_test.go index ad890738565ba..c67f6ab34fb43 100644 --- a/coderd/x/chatd/context_detail_internal_test.go +++ b/coderd/x/chatd/context_detail_internal_test.go @@ -139,6 +139,14 @@ func TestPinnedContextResources(t *testing.T) { BodyKind: database.WorkspaceAgentContextBodyKindMcpServer, Status: database.WorkspaceAgentContextResourceStatusOk, SizeBytes: 12, + // Tool names carry the "__" 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) @@ -152,6 +160,11 @@ func TestPinnedContextResources(t *testing.T) { Source: "github", Kind: codersdk.ChatContextResourceKindMCPServer, SizeBytes: 12, + // Tool names are reported with the "github__" prefix stripped. + McpTools: []codersdk.ChatContextMCPTool{ + {Name: "create", Description: "Create an issue"}, + {Name: "search", Description: "Search code"}, + }, }, }, out) }) diff --git a/coderd/x/chatd/context_prompt.go b/coderd/x/chatd/context_prompt.go index d744433b50609..da885218ce120 100644 --- a/coderd/x/chatd/context_prompt.go +++ b/coderd/x/chatd/context_prompt.go @@ -3,6 +3,7 @@ package chatd import ( "context" "encoding/json" + "strings" "golang.org/x/xerrors" "google.golang.org/protobuf/encoding/protojson" @@ -40,6 +41,37 @@ func decodeSkillMetaBody(body json.RawMessage) (*agentproto.SkillMetaBody, bool) return &decoded, true } +// mcpToolsFromServerBody decodes a stored mcp_server resource body and returns +// its tool list for the chat response. The agent prefixes each tool name with +// "__"; that prefix is stripped so the name reads as the server +// exposes it. Returns nil when the body has no tools or cannot be decoded. +func mcpToolsFromServerBody(server string, body json.RawMessage) []codersdk.ChatContextMCPTool { + var decoded agentproto.MCPServerBody + if err := contextBodyUnmarshalOptions.Unmarshal(body, &decoded); err != nil { + return nil + } + tools := decoded.GetTools() + if len(tools) == 0 { + return nil + } + prefix := server + "__" + out := make([]codersdk.ChatContextMCPTool, 0, len(tools)) + for _, t := range tools { + name := strings.TrimPrefix(t.GetName(), prefix) + if name == "" { + continue + } + out = append(out, codersdk.ChatContextMCPTool{ + Name: name, + Description: t.GetDescription(), + }) + } + if len(out) == 0 { + return nil + } + return out +} + // 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. diff --git a/codersdk/chats.go b/codersdk/chats.go index c6f9e8968d931..6c487958189b7 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -195,7 +195,8 @@ const ( // only on the single-chat GET response. type ChatContextResource struct { // Source is the resource locator: the canonical file path for an - // instruction file, or the skill directory for a skill. + // instruction file, the skill directory for a skill, the file path for + // an MCP config, or the server name for an MCP server. Source string `json:"source"` Kind ChatContextResourceKind `json:"kind"` // SizeBytes is the original payload size in bytes. @@ -203,6 +204,19 @@ type ChatContextResource struct { // SkillName and SkillDescription are populated only for skill kinds. SkillName string `json:"skill_name,omitempty"` SkillDescription string `json:"skill_description,omitempty"` + // McpTools lists the tools exposed by an MCP server. Populated only for + // the mcp_server kind; nil otherwise. + McpTools []ChatContextMCPTool `json:"mcp_tools,omitempty"` +} + +// ChatContextMCPTool is one tool exposed by a pinned MCP server, reported on +// the single-chat GET response. Metadata only; the input schema is omitted. +type ChatContextMCPTool struct { + // Name is the tool name with the "__" prefix the agent adds + // stripped, so it reads as the server exposes it. + Name string `json:"name"` + // Description is the tool's human-readable summary; may be empty. + Description string `json:"description,omitempty"` } // ChatContextResourceChangeStatus classifies how a source differs between the diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index 47780b5571cb5..d24d9120d235a 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -54,6 +54,12 @@ Experimental: this endpoint is subject to change. "resources": [ { "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], "size_bytes": 0, "skill_description": "string", "skill_name": "string", @@ -228,10 +234,13 @@ Status Code **200** | `»» error` | string | false | | Error is the snapshot-level error copied from the pinned snapshot (empty when healthy). | | `»» resources` | array | false | | Resources is the chat's pinned context (instruction files and skills) the prompt is built from, metadata only (no bodies). It is populated only on the single-chat GET response; list and watch payloads leave it nil to stay lightweight. | | `»»» kind` | [codersdk.ChatContextResourceKind](schemas.md#codersdkchatcontextresourcekind) | false | | | +| `»»» mcp_tools` | array | false | | Mcp tools lists the tools exposed by an MCP server. Populated only for the mcp_server kind; nil otherwise. | +| `»»»» description` | string | false | | Description is the tool's human-readable summary; may be empty. | +| `»»»» name` | string | false | | Name is the tool name with the "__" prefix the agent adds stripped, so it reads as the server exposes it. | | `»»» size_bytes` | integer | false | | Size bytes is the original payload size in bytes. | | `»»» skill_description` | string | false | | | | `»»» skill_name` | string | false | | Skill name and SkillDescription are populated only for skill kinds. | -| `»»» source` | string | false | | Source is the resource locator: the canonical file path for an instruction file, or the skill directory for a skill. | +| `»»» source` | string | false | | Source is the resource locator: the canonical file path for an instruction file, the skill directory for a skill, the file path for an MCP config, or the server name for an MCP server. | | `» created_at` | string(date-time) | false | | | | `» diff_status` | [codersdk.ChatDiffStatus](schemas.md#codersdkchatdiffstatus) | false | | | | `»» additions` | integer | false | | | @@ -443,6 +452,12 @@ Experimental: this endpoint is subject to change. "resources": [ { "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], "size_bytes": 0, "skill_description": "string", "skill_name": "string", @@ -602,6 +617,12 @@ Experimental: this endpoint is subject to change. "resources": [ { "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], "size_bytes": 0, "skill_description": "string", "skill_name": "string", @@ -912,6 +933,12 @@ Experimental: this endpoint is subject to change. "resources": [ { "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], "size_bytes": 0, "skill_description": "string", "skill_name": "string", @@ -1125,6 +1152,12 @@ Experimental: this endpoint is subject to change. "resources": [ { "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], "size_bytes": 0, "skill_description": "string", "skill_name": "string", @@ -1284,6 +1317,12 @@ Experimental: this endpoint is subject to change. "resources": [ { "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], "size_bytes": 0, "skill_description": "string", "skill_name": "string", @@ -1534,6 +1573,12 @@ Experimental: this endpoint is subject to change. "resources": [ { "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], "size_bytes": 0, "skill_description": "string", "skill_name": "string", @@ -1693,6 +1738,12 @@ Experimental: this endpoint is subject to change. "resources": [ { "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], "size_bytes": 0, "skill_description": "string", "skill_name": "string", @@ -1941,6 +1992,12 @@ Experimental: this endpoint is subject to change. "resources": [ { "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], "size_bytes": 0, "skill_description": "string", "skill_name": "string", @@ -2100,6 +2157,12 @@ Experimental: this endpoint is subject to change. "resources": [ { "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], "size_bytes": 0, "skill_description": "string", "skill_name": "string", @@ -2915,6 +2978,12 @@ Experimental: this endpoint is subject to change. "resources": [ { "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], "size_bytes": 0, "skill_description": "string", "skill_name": "string", @@ -3074,6 +3143,12 @@ Experimental: this endpoint is subject to change. "resources": [ { "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], "size_bytes": 0, "skill_description": "string", "skill_name": "string", @@ -3647,6 +3722,12 @@ Experimental: this endpoint is subject to change. "resources": [ { "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], "size_bytes": 0, "skill_description": "string", "skill_name": "string", @@ -3806,6 +3887,12 @@ Experimental: this endpoint is subject to change. "resources": [ { "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], "size_bytes": 0, "skill_description": "string", "skill_name": "string", diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 53e155a4acd5a..e58a873050834 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1943,6 +1943,12 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "resources": [ { "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], "size_bytes": 0, "skill_description": "string", "skill_name": "string", @@ -2102,6 +2108,12 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "resources": [ { "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], "size_bytes": 0, "skill_description": "string", "skill_name": "string", @@ -2399,6 +2411,12 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "resources": [ { "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], "size_bytes": 0, "skill_description": "string", "skill_name": "string", @@ -2418,11 +2436,33 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `error` | string | false | | Error is the snapshot-level error copied from the pinned snapshot (empty when healthy). | | `resources` | array of [codersdk.ChatContextResource](#codersdkchatcontextresource) | false | | Resources is the chat's pinned context (instruction files and skills) the prompt is built from, metadata only (no bodies). It is populated only on the single-chat GET response; list and watch payloads leave it nil to stay lightweight. | +## codersdk.ChatContextMCPTool + +```json +{ + "description": "string", + "name": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------------|--------|----------|--------------|-------------------------------------------------------------------------------------------------------------------| +| `description` | string | false | | Description is the tool's human-readable summary; may be empty. | +| `name` | string | false | | Name is the tool name with the "__" prefix the agent adds stripped, so it reads as the server exposes it. | + ## codersdk.ChatContextResource ```json { "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], "size_bytes": 0, "skill_description": "string", "skill_name": "string", @@ -2432,13 +2472,14 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ### Properties -| Name | Type | Required | Restrictions | Description | -|---------------------|----------------------------------------------------------------------|----------|--------------|----------------------------------------------------------------------------------------------------------------------| -| `kind` | [codersdk.ChatContextResourceKind](#codersdkchatcontextresourcekind) | false | | | -| `size_bytes` | integer | false | | Size bytes is the original payload size in bytes. | -| `skill_description` | string | false | | | -| `skill_name` | string | false | | Skill name and SkillDescription are populated only for skill kinds. | -| `source` | string | false | | Source is the resource locator: the canonical file path for an instruction file, or the skill directory for a skill. | +| Name | Type | Required | Restrictions | Description | +|---------------------|----------------------------------------------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `kind` | [codersdk.ChatContextResourceKind](#codersdkchatcontextresourcekind) | false | | | +| `mcp_tools` | array of [codersdk.ChatContextMCPTool](#codersdkchatcontextmcptool) | false | | Mcp tools lists the tools exposed by an MCP server. Populated only for the mcp_server kind; nil otherwise. | +| `size_bytes` | integer | false | | Size bytes is the original payload size in bytes. | +| `skill_description` | string | false | | | +| `skill_name` | string | false | | Skill name and SkillDescription are populated only for skill kinds. | +| `source` | string | false | | Source is the resource locator: the canonical file path for an instruction file, the skill directory for a skill, the file path for an MCP config, or the server name for an MCP server. | ## codersdk.ChatContextResourceChange @@ -3933,6 +3974,12 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "resources": [ { "kind": "instruction_file", + "mcp_tools": [ + { + "description": "string", + "name": "string" + } + ], "size_bytes": 0, "skill_description": "string", "skill_name": "string", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 0acf5513e91bc..f3d2b995029bb 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1651,6 +1651,23 @@ export interface ChatContextFilePart { readonly context_file_agent_id?: string; } +// From codersdk/chats.go +/** + * ChatContextMCPTool is one tool exposed by a pinned MCP server, reported on + * the single-chat GET response. Metadata only; the input schema is omitted. + */ +export interface ChatContextMCPTool { + /** + * Name is the tool name with the "__" prefix the agent adds + * stripped, so it reads as the server exposes it. + */ + readonly name: string; + /** + * Description is the tool's human-readable summary; may be empty. + */ + readonly description?: string; +} + // From codersdk/chats.go /** * ChatContextResource is one pinned workspace-context resource the chat's @@ -1660,7 +1677,8 @@ export interface ChatContextFilePart { export interface ChatContextResource { /** * Source is the resource locator: the canonical file path for an - * instruction file, or the skill directory for a skill. + * instruction file, the skill directory for a skill, the file path for + * an MCP config, or the server name for an MCP server. */ readonly source: string; readonly kind: ChatContextResourceKind; @@ -1673,6 +1691,11 @@ export interface ChatContextResource { */ readonly skill_name?: string; readonly skill_description?: string; + /** + * McpTools lists the tools exposed by an MCP server. Populated only for + * the mcp_server kind; nil otherwise. + */ + readonly mcp_tools?: readonly ChatContextMCPTool[]; } // From codersdk/chats.go diff --git a/site/src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx b/site/src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx index c917c052a6c12..03ac3283a876e 100644 --- a/site/src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx +++ b/site/src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx @@ -44,6 +44,9 @@ export const Clean: Story = { expect(body.getByText("MCP")).toBeVisible(); expect(body.getByText(".mcp.json")).toBeVisible(); expect(body.getByText("github")).toBeVisible(); + // MCP server tools are listed under their server. + expect(body.getByText("search_issues")).toBeVisible(); + expect(body.getByText("create_issue")).toBeVisible(); // A clean pin offers no refresh affordance. expect(body.queryByRole("button", { name: "Refresh context" })).toBeNull(); }, diff --git a/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx b/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx index 56ff54443f5d0..9329b8421d785 100644 --- a/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx +++ b/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx @@ -1,6 +1,16 @@ -import { FileIcon, PlugIcon, TriangleAlertIcon, ZapIcon } from "lucide-react"; +import { + FileIcon, + PlugIcon, + TriangleAlertIcon, + WrenchIcon, + ZapIcon, +} from "lucide-react"; import { type FC, useRef, useState } from "react"; -import type { ChatContext, ChatMessagePart } from "#/api/typesGenerated"; +import type { + ChatContext, + ChatContextMCPTool, + ChatMessagePart, +} from "#/api/typesGenerated"; import { Button } from "#/components/Button/Button"; import { Popover, @@ -49,6 +59,7 @@ type ContextSkillItem = { type ContextMcpItem = { readonly name: string; readonly source: string; + readonly tools: readonly ChatContextMCPTool[]; }; const hasFiniteTokenValue = (value: number | undefined): value is number => @@ -201,6 +212,7 @@ export const ContextUsageIndicator: FC<{ ? resource.source : getPathBasename(resource.source), source: resource.source, + tools: resource.mcp_tools ?? [], })) : [] ) @@ -295,14 +307,48 @@ export const ContextUsageIndicator: FC<{ {mcpItems.length > 0 && (
    MCP - {mcpItems.map((mcp) => ( -
    - - - {mcp.name} - -
    - ))} + + {mcpItems.map((mcp) => ( +
    +
    + + {mcp.name} +
    + {mcp.tools.length > 0 && ( +
    + {mcp.tools.map((tool) => { + const row = ( +
    + + {tool.name} +
    + ); + if (!tool.description) { + return
    {row}
    ; + } + return ( + + +
    {row}
    +
    + + {tool.description} + +
    + ); + })} +
    + )} +
    + ))} +
    )} diff --git a/site/src/testHelpers/chatEntities.ts b/site/src/testHelpers/chatEntities.ts index 2b30136b26289..c28013ba1245e 100644 --- a/site/src/testHelpers/chatEntities.ts +++ b/site/src/testHelpers/chatEntities.ts @@ -57,6 +57,13 @@ const MockChatContextResources: ChatContextResource[] = [ source: "github", kind: "mcp_server", size_bytes: 512, + mcp_tools: [ + { + name: "search_issues", + description: "Search issues and pull requests.", + }, + { name: "create_issue", description: "Open a new issue." }, + ], }, ]; From d9bbee6ddbd1a85763d74e23cf3b5804f1e34fd5 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 18 Jun 2026 00:21:10 +0000 Subject: [PATCH 15/20] feat(chatd,site): surface per-resource error states in the chat context pinnedContextResources now reports non-OK resources (invalid, unreadable, oversize, excluded) with their Status and Error instead of dropping them silently, and stamps Status on healthy resources. codersdk.ChatContextResource gains Status and Error (ChatContextResourceStatus mirrors the agent resolver's per-resource status, already stored end-to-end). The context popover lists problem resources in a new Issues section (warning icon, kind and status, and the agent's error message). For example, a skill rejected because its front-matter name does not match its directory now shows that reason instead of silently vanishing from the list and read_skill. --- coderd/apidoc/docs.go | 29 +++++++ coderd/apidoc/swagger.json | 23 +++++ coderd/x/chatd/context_detail.go | 47 ++++++++--- .../x/chatd/context_detail_internal_test.go | 42 ++++++++-- codersdk/chats.go | 20 +++++ docs/reference/api/chats.md | 60 ++++++++++---- docs/reference/api/schemas.md | 52 +++++++++--- site/src/api/queries/chats.test.ts | 7 +- site/src/api/typesGenerated.ts | 28 +++++++ .../ContextUsageIndicator.stories.tsx | 8 ++ .../components/ContextUsageIndicator.tsx | 83 ++++++++++++++++++- site/src/testHelpers/chatEntities.ts | 13 +++ 12 files changed, 359 insertions(+), 53 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 20285c8710409..613cc09d9f16b 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -16653,6 +16653,10 @@ const docTemplate = `{ "codersdk.ChatContextResource": { "type": "object", "properties": { + "error": { + "description": "Error explains a non-ok Status; empty when healthy. May also carry a\nnon-fatal warning when Status is ok.", + "type": "string" + }, "kind": { "$ref": "#/definitions/codersdk.ChatContextResourceKind" }, @@ -16677,6 +16681,14 @@ const docTemplate = `{ "source": { "description": "Source is the resource locator: the canonical file path for an\ninstruction file, the skill directory for a skill, the file path for\nan MCP config, or the server name for an MCP server.", "type": "string" + }, + "status": { + "description": "Status is the resource's health. Non-ok resources (invalid, unreadable,\noversize, excluded) are still reported so the UI can surface why a\nresource was dropped from the prompt instead of silently omitting it;\ntheir body-specific fields (skill name, MCP tools) are empty.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.ChatContextResourceStatus" + } + ] } } }, @@ -16737,6 +16749,23 @@ const docTemplate = `{ "ChatContextResourceKindMCPServer" ] }, + "codersdk.ChatContextResourceStatus": { + "type": "string", + "enum": [ + "ok", + "oversize", + "unreadable", + "invalid", + "excluded" + ], + "x-enum-varnames": [ + "ChatContextResourceStatusOK", + "ChatContextResourceStatusOversize", + "ChatContextResourceStatusUnreadable", + "ChatContextResourceStatusInvalid", + "ChatContextResourceStatusExcluded" + ] + }, "codersdk.ChatDiffContents": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index d859cbc99e097..66e8faba56428 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -14955,6 +14955,10 @@ "codersdk.ChatContextResource": { "type": "object", "properties": { + "error": { + "description": "Error explains a non-ok Status; empty when healthy. May also carry a\nnon-fatal warning when Status is ok.", + "type": "string" + }, "kind": { "$ref": "#/definitions/codersdk.ChatContextResourceKind" }, @@ -14979,6 +14983,14 @@ "source": { "description": "Source is the resource locator: the canonical file path for an\ninstruction file, the skill directory for a skill, the file path for\nan MCP config, or the server name for an MCP server.", "type": "string" + }, + "status": { + "description": "Status is the resource's health. Non-ok resources (invalid, unreadable,\noversize, excluded) are still reported so the UI can surface why a\nresource was dropped from the prompt instead of silently omitting it;\ntheir body-specific fields (skill name, MCP tools) are empty.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.ChatContextResourceStatus" + } + ] } } }, @@ -15030,6 +15042,17 @@ "ChatContextResourceKindMCPServer" ] }, + "codersdk.ChatContextResourceStatus": { + "type": "string", + "enum": ["ok", "oversize", "unreadable", "invalid", "excluded"], + "x-enum-varnames": [ + "ChatContextResourceStatusOK", + "ChatContextResourceStatusOversize", + "ChatContextResourceStatusUnreadable", + "ChatContextResourceStatusInvalid", + "ChatContextResourceStatusExcluded" + ] + }, "codersdk.ChatDiffContents": { "type": "object", "properties": { diff --git a/coderd/x/chatd/context_detail.go b/coderd/x/chatd/context_detail.go index 01aa660ceed0d..3d5aa82b1a662 100644 --- a/coderd/x/chatd/context_detail.go +++ b/coderd/x/chatd/context_detail.go @@ -56,50 +56,75 @@ func (server *Server) ContextDetail( // pinnedContextResources converts a chat's pinned context rows into the // metadata-only resource list reported on the chat. It surfaces the full -// pinned inventory the user can act on: OK instruction files with non-empty -// (sanitized) content, OK skills with a name, and OK MCP configs/servers. -// Non-OK rows and empty instruction files are skipped. Input order (source ASC +// pinned inventory the user can act on, each stamped with its Status: +// +// - OK instruction files with non-empty (sanitized) content, OK skills with +// a name, and OK MCP configs/servers (mcp_server carries its tools). +// - Non-OK rows (invalid, unreadable, oversize, excluded) of a tracked kind, +// carrying Status and Error so the UI can explain why the resource was +// dropped from the prompt instead of silently omitting it. Their +// body-specific fields are empty. +// +// OK-but-empty instruction files, OK skills with no name, and untracked kinds +// (reserved plugin/hook/subagent/command) are skipped. Input order (source ASC // from the query) is preserved. func pinnedContextResources(resources []database.ChatContextResource) []codersdk.ChatContextResource { var out []codersdk.ChatContextResource for _, r := range resources { + kind, ok := contextResourceKind(r.BodyKind) + if !ok { + continue + } if r.Status != database.WorkspaceAgentContextResourceStatusOk { + // Surface the failure (with its reason) rather than dropping it + // silently; the body is empty for non-OK rows. + out = append(out, codersdk.ChatContextResource{ + Source: r.Source, + Kind: kind, + SizeBytes: r.SizeBytes, + Status: codersdk.ChatContextResourceStatus(r.Status), + Error: r.Error, + }) continue } switch r.BodyKind { case database.WorkspaceAgentContextBodyKindInstructionFile: - body, ok := decodeInstructionFileBody(r.Body) - if !ok || SanitizePromptText(string(body.GetContent())) == "" { + body, decoded := decodeInstructionFileBody(r.Body) + if !decoded || SanitizePromptText(string(body.GetContent())) == "" { continue } out = append(out, codersdk.ChatContextResource{ Source: r.Source, - Kind: codersdk.ChatContextResourceKindInstructionFile, + Kind: kind, SizeBytes: r.SizeBytes, + Status: codersdk.ChatContextResourceStatusOK, }) case database.WorkspaceAgentContextBodyKindSkill: - body, ok := decodeSkillMetaBody(r.Body) - if !ok || body.GetName() == "" { + body, decoded := decodeSkillMetaBody(r.Body) + if !decoded || body.GetName() == "" { continue } out = append(out, codersdk.ChatContextResource{ Source: r.Source, - Kind: codersdk.ChatContextResourceKindSkill, + Kind: kind, SizeBytes: r.SizeBytes, + Status: codersdk.ChatContextResourceStatusOK, SkillName: body.GetName(), SkillDescription: body.GetDescription(), }) case database.WorkspaceAgentContextBodyKindMcpConfig: out = append(out, codersdk.ChatContextResource{ Source: r.Source, - Kind: codersdk.ChatContextResourceKindMCPConfig, + Kind: kind, SizeBytes: r.SizeBytes, + Status: codersdk.ChatContextResourceStatusOK, }) case database.WorkspaceAgentContextBodyKindMcpServer: out = append(out, codersdk.ChatContextResource{ Source: r.Source, - Kind: codersdk.ChatContextResourceKindMCPServer, + Kind: kind, SizeBytes: r.SizeBytes, + Status: codersdk.ChatContextResourceStatusOK, McpTools: mcpToolsFromServerBody(r.Source, r.Body), }) } diff --git a/coderd/x/chatd/context_detail_internal_test.go b/coderd/x/chatd/context_detail_internal_test.go index c67f6ab34fb43..badf6e323c7c9 100644 --- a/coderd/x/chatd/context_detail_internal_test.go +++ b/coderd/x/chatd/context_detail_internal_test.go @@ -94,36 +94,58 @@ func TestPinnedContextResources(t *testing.T) { 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("SkipsNonOKAndEmpty", func(t *testing.T) { + t.Run("SkipsOKButEmpty", func(t *testing.T) { t.Parallel() resources := []database.ChatContextResource{ - // Non-OK instruction file. - instructionResource(t, "/a/AGENTS.md", "ignored", database.WorkspaceAgentContextResourceStatusOversize), // 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), - // Non-OK MCP config. - { - Source: "/d/.mcp.json", - BodyKind: database.WorkspaceAgentContextBodyKindMcpConfig, - Status: database.WorkspaceAgentContextResourceStatusUnreadable, - }, } 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() @@ -155,11 +177,13 @@ func TestPinnedContextResources(t *testing.T) { 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. McpTools: []codersdk.ChatContextMCPTool{ {Name: "create", Description: "Create an issue"}, diff --git a/codersdk/chats.go b/codersdk/chats.go index 6c487958189b7..b9311098171ec 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -207,8 +207,28 @@ type ChatContextResource struct { // McpTools lists the tools exposed by an MCP server. Populated only for // the mcp_server kind; nil otherwise. McpTools []ChatContextMCPTool `json:"mcp_tools,omitempty"` + // Status is the resource's health. Non-ok resources (invalid, unreadable, + // oversize, excluded) are still reported so the UI can surface why a + // resource was dropped from the prompt instead of silently omitting it; + // their body-specific fields (skill name, MCP tools) are empty. + Status ChatContextResourceStatus `json:"status"` + // Error explains a non-ok Status; empty when healthy. May also carry a + // non-fatal warning when Status is ok. + Error string `json:"error,omitempty"` } +// ChatContextResourceStatus is the health of a pinned context resource, +// mirroring the agent resolver's per-resource status. +type ChatContextResourceStatus string + +const ( + ChatContextResourceStatusOK ChatContextResourceStatus = "ok" + ChatContextResourceStatusOversize ChatContextResourceStatus = "oversize" + ChatContextResourceStatusUnreadable ChatContextResourceStatus = "unreadable" + ChatContextResourceStatusInvalid ChatContextResourceStatus = "invalid" + ChatContextResourceStatusExcluded ChatContextResourceStatus = "excluded" +) + // ChatContextMCPTool is one tool exposed by a pinned MCP server, reported on // the single-chat GET response. Metadata only; the input schema is omitted. type ChatContextMCPTool struct { diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index d24d9120d235a..eb5fdefe33a0c 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -53,6 +53,7 @@ Experimental: this endpoint is subject to change. "error": "string", "resources": [ { + "error": "string", "kind": "instruction_file", "mcp_tools": [ { @@ -63,7 +64,8 @@ Experimental: this endpoint is subject to change. "size_bytes": 0, "skill_description": "string", "skill_name": "string", - "source": "string" + "source": "string", + "status": "ok" } ] }, @@ -233,6 +235,7 @@ Status Code **200** | `»» dirty_since` | string(date-time) | false | | Dirty since is when drift was first detected; nil when not dirty. | | `»» error` | string | false | | Error is the snapshot-level error copied from the pinned snapshot (empty when healthy). | | `»» resources` | array | false | | Resources is the chat's pinned context (instruction files and skills) the prompt is built from, metadata only (no bodies). It is populated only on the single-chat GET response; list and watch payloads leave it nil to stay lightweight. | +| `»»» error` | string | false | | Error explains a non-ok Status; empty when healthy. May also carry a non-fatal warning when Status is ok. | | `»»» kind` | [codersdk.ChatContextResourceKind](schemas.md#codersdkchatcontextresourcekind) | false | | | | `»»» mcp_tools` | array | false | | Mcp tools lists the tools exposed by an MCP server. Populated only for the mcp_server kind; nil otherwise. | | `»»»» description` | string | false | | Description is the tool's human-readable summary; may be empty. | @@ -241,6 +244,7 @@ Status Code **200** | `»»» skill_description` | string | false | | | | `»»» skill_name` | string | false | | Skill name and SkillDescription are populated only for skill kinds. | | `»»» source` | string | false | | Source is the resource locator: the canonical file path for an instruction file, the skill directory for a skill, the file path for an MCP config, or the server name for an MCP server. | +| `»»» status` | [codersdk.ChatContextResourceStatus](schemas.md#codersdkchatcontextresourcestatus) | false | | Status is the resource's health. Non-ok resources (invalid, unreadable, oversize, excluded) are still reported so the UI can surface why a resource was dropped from the prompt instead of silently omitting it; their body-specific fields (skill name, MCP tools) are empty. | | `» created_at` | string(date-time) | false | | | | `» diff_status` | [codersdk.ChatDiffStatus](schemas.md#codersdkchatdiffstatus) | false | | | | `»» additions` | integer | false | | | @@ -350,7 +354,7 @@ Status Code **200** |---------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `client_type` | `api`, `ui` | | `kind` | `auth`, `config`, `generic`, `instruction_file`, `mcp_config`, `mcp_server`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `skill`, `stream_silence_timeout`, `timeout`, `usage_limit` | -| `status` | `added`, `completed`, `error`, `interrupting`, `modified`, `paused`, `pending`, `removed`, `requires_action`, `running`, `waiting` | +| `status` | `added`, `completed`, `error`, `excluded`, `interrupting`, `invalid`, `modified`, `ok`, `oversize`, `paused`, `pending`, `removed`, `requires_action`, `running`, `unreadable`, `waiting` | | `type` | `context-file`, `file`, `file-reference`, `reasoning`, `skill`, `source`, `text`, `tool-call`, `tool-result` | | `plan_mode` | `plan` | @@ -451,6 +455,7 @@ Experimental: this endpoint is subject to change. "error": "string", "resources": [ { + "error": "string", "kind": "instruction_file", "mcp_tools": [ { @@ -461,7 +466,8 @@ Experimental: this endpoint is subject to change. "size_bytes": 0, "skill_description": "string", "skill_name": "string", - "source": "string" + "source": "string", + "status": "ok" } ] }, @@ -616,6 +622,7 @@ Experimental: this endpoint is subject to change. "error": "string", "resources": [ { + "error": "string", "kind": "instruction_file", "mcp_tools": [ { @@ -626,7 +633,8 @@ Experimental: this endpoint is subject to change. "size_bytes": 0, "skill_description": "string", "skill_name": "string", - "source": "string" + "source": "string", + "status": "ok" } ] }, @@ -932,6 +940,7 @@ Experimental: this endpoint is subject to change. "error": "string", "resources": [ { + "error": "string", "kind": "instruction_file", "mcp_tools": [ { @@ -942,7 +951,8 @@ Experimental: this endpoint is subject to change. "size_bytes": 0, "skill_description": "string", "skill_name": "string", - "source": "string" + "source": "string", + "status": "ok" } ] }, @@ -1151,6 +1161,7 @@ Experimental: this endpoint is subject to change. "error": "string", "resources": [ { + "error": "string", "kind": "instruction_file", "mcp_tools": [ { @@ -1161,7 +1172,8 @@ Experimental: this endpoint is subject to change. "size_bytes": 0, "skill_description": "string", "skill_name": "string", - "source": "string" + "source": "string", + "status": "ok" } ] }, @@ -1316,6 +1328,7 @@ Experimental: this endpoint is subject to change. "error": "string", "resources": [ { + "error": "string", "kind": "instruction_file", "mcp_tools": [ { @@ -1326,7 +1339,8 @@ Experimental: this endpoint is subject to change. "size_bytes": 0, "skill_description": "string", "skill_name": "string", - "source": "string" + "source": "string", + "status": "ok" } ] }, @@ -1572,6 +1586,7 @@ Experimental: this endpoint is subject to change. "error": "string", "resources": [ { + "error": "string", "kind": "instruction_file", "mcp_tools": [ { @@ -1582,7 +1597,8 @@ Experimental: this endpoint is subject to change. "size_bytes": 0, "skill_description": "string", "skill_name": "string", - "source": "string" + "source": "string", + "status": "ok" } ] }, @@ -1737,6 +1753,7 @@ Experimental: this endpoint is subject to change. "error": "string", "resources": [ { + "error": "string", "kind": "instruction_file", "mcp_tools": [ { @@ -1747,7 +1764,8 @@ Experimental: this endpoint is subject to change. "size_bytes": 0, "skill_description": "string", "skill_name": "string", - "source": "string" + "source": "string", + "status": "ok" } ] }, @@ -1991,6 +2009,7 @@ Experimental: this endpoint is subject to change. "error": "string", "resources": [ { + "error": "string", "kind": "instruction_file", "mcp_tools": [ { @@ -2001,7 +2020,8 @@ Experimental: this endpoint is subject to change. "size_bytes": 0, "skill_description": "string", "skill_name": "string", - "source": "string" + "source": "string", + "status": "ok" } ] }, @@ -2156,6 +2176,7 @@ Experimental: this endpoint is subject to change. "error": "string", "resources": [ { + "error": "string", "kind": "instruction_file", "mcp_tools": [ { @@ -2166,7 +2187,8 @@ Experimental: this endpoint is subject to change. "size_bytes": 0, "skill_description": "string", "skill_name": "string", - "source": "string" + "source": "string", + "status": "ok" } ] }, @@ -2977,6 +2999,7 @@ Experimental: this endpoint is subject to change. "error": "string", "resources": [ { + "error": "string", "kind": "instruction_file", "mcp_tools": [ { @@ -2987,7 +3010,8 @@ Experimental: this endpoint is subject to change. "size_bytes": 0, "skill_description": "string", "skill_name": "string", - "source": "string" + "source": "string", + "status": "ok" } ] }, @@ -3142,6 +3166,7 @@ Experimental: this endpoint is subject to change. "error": "string", "resources": [ { + "error": "string", "kind": "instruction_file", "mcp_tools": [ { @@ -3152,7 +3177,8 @@ Experimental: this endpoint is subject to change. "size_bytes": 0, "skill_description": "string", "skill_name": "string", - "source": "string" + "source": "string", + "status": "ok" } ] }, @@ -3721,6 +3747,7 @@ Experimental: this endpoint is subject to change. "error": "string", "resources": [ { + "error": "string", "kind": "instruction_file", "mcp_tools": [ { @@ -3731,7 +3758,8 @@ Experimental: this endpoint is subject to change. "size_bytes": 0, "skill_description": "string", "skill_name": "string", - "source": "string" + "source": "string", + "status": "ok" } ] }, @@ -3886,6 +3914,7 @@ Experimental: this endpoint is subject to change. "error": "string", "resources": [ { + "error": "string", "kind": "instruction_file", "mcp_tools": [ { @@ -3896,7 +3925,8 @@ Experimental: this endpoint is subject to change. "size_bytes": 0, "skill_description": "string", "skill_name": "string", - "source": "string" + "source": "string", + "status": "ok" } ] }, diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index e58a873050834..ba5b59848bd83 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1942,6 +1942,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "error": "string", "resources": [ { + "error": "string", "kind": "instruction_file", "mcp_tools": [ { @@ -1952,7 +1953,8 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "size_bytes": 0, "skill_description": "string", "skill_name": "string", - "source": "string" + "source": "string", + "status": "ok" } ] }, @@ -2107,6 +2109,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "error": "string", "resources": [ { + "error": "string", "kind": "instruction_file", "mcp_tools": [ { @@ -2117,7 +2120,8 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "size_bytes": 0, "skill_description": "string", "skill_name": "string", - "source": "string" + "source": "string", + "status": "ok" } ] }, @@ -2410,6 +2414,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "error": "string", "resources": [ { + "error": "string", "kind": "instruction_file", "mcp_tools": [ { @@ -2420,7 +2425,8 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "size_bytes": 0, "skill_description": "string", "skill_name": "string", - "source": "string" + "source": "string", + "status": "ok" } ] } @@ -2456,6 +2462,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ```json { + "error": "string", "kind": "instruction_file", "mcp_tools": [ { @@ -2466,20 +2473,23 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "size_bytes": 0, "skill_description": "string", "skill_name": "string", - "source": "string" + "source": "string", + "status": "ok" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|---------------------|----------------------------------------------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `kind` | [codersdk.ChatContextResourceKind](#codersdkchatcontextresourcekind) | false | | | -| `mcp_tools` | array of [codersdk.ChatContextMCPTool](#codersdkchatcontextmcptool) | false | | Mcp tools lists the tools exposed by an MCP server. Populated only for the mcp_server kind; nil otherwise. | -| `size_bytes` | integer | false | | Size bytes is the original payload size in bytes. | -| `skill_description` | string | false | | | -| `skill_name` | string | false | | Skill name and SkillDescription are populated only for skill kinds. | -| `source` | string | false | | Source is the resource locator: the canonical file path for an instruction file, the skill directory for a skill, the file path for an MCP config, or the server name for an MCP server. | +| Name | Type | Required | Restrictions | Description | +|---------------------|--------------------------------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `error` | string | false | | Error explains a non-ok Status; empty when healthy. May also carry a non-fatal warning when Status is ok. | +| `kind` | [codersdk.ChatContextResourceKind](#codersdkchatcontextresourcekind) | false | | | +| `mcp_tools` | array of [codersdk.ChatContextMCPTool](#codersdkchatcontextmcptool) | false | | Mcp tools lists the tools exposed by an MCP server. Populated only for the mcp_server kind; nil otherwise. | +| `size_bytes` | integer | false | | Size bytes is the original payload size in bytes. | +| `skill_description` | string | false | | | +| `skill_name` | string | false | | Skill name and SkillDescription are populated only for skill kinds. | +| `source` | string | false | | Source is the resource locator: the canonical file path for an instruction file, the skill directory for a skill, the file path for an MCP config, or the server name for an MCP server. | +| `status` | [codersdk.ChatContextResourceStatus](#codersdkchatcontextresourcestatus) | false | | Status is the resource's health. Non-ok resources (invalid, unreadable, oversize, excluded) are still reported so the UI can surface why a resource was dropped from the prompt instead of silently omitting it; their body-specific fields (skill name, MCP tools) are empty. | ## codersdk.ChatContextResourceChange @@ -2535,6 +2545,20 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in |---------------------------------------------------------| | `instruction_file`, `mcp_config`, `mcp_server`, `skill` | +## codersdk.ChatContextResourceStatus + +```json +"ok" +``` + +### Properties + +#### Enumerated Values + +| Value(s) | +|-------------------------------------------------------| +| `excluded`, `invalid`, `ok`, `oversize`, `unreadable` | + ## codersdk.ChatDiffContents ```json @@ -3973,6 +3997,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "error": "string", "resources": [ { + "error": "string", "kind": "instruction_file", "mcp_tools": [ { @@ -3983,7 +4008,8 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "size_bytes": 0, "skill_description": "string", "skill_name": "string", - "source": "string" + "source": "string", + "status": "ok" } ] }, diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index fcc643b3f80e1..493d5058e4e60 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -2068,7 +2068,12 @@ describe("mergeWatchedChatSummary", () => { context: { dirty: false, resources: [ - { source: "/AGENTS.md", kind: "instruction_file", size_bytes: 10 }, + { + source: "/AGENTS.md", + kind: "instruction_file", + size_bytes: 10, + status: "ok", + }, ], }, }); diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index f3d2b995029bb..8bf4aae6081bc 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1696,6 +1696,18 @@ export interface ChatContextResource { * the mcp_server kind; nil otherwise. */ readonly mcp_tools?: readonly ChatContextMCPTool[]; + /** + * Status is the resource's health. Non-ok resources (invalid, unreadable, + * oversize, excluded) are still reported so the UI can surface why a + * resource was dropped from the prompt instead of silently omitting it; + * their body-specific fields (skill name, MCP tools) are empty. + */ + readonly status: ChatContextResourceStatus; + /** + * Error explains a non-ok Status; empty when healthy. May also carry a + * non-fatal warning when Status is ok. + */ + readonly error?: string; } // From codersdk/chats.go @@ -1748,6 +1760,22 @@ export const ChatContextResourceKinds: ChatContextResourceKind[] = [ "skill", ]; +// From codersdk/chats.go +export type ChatContextResourceStatus = + | "excluded" + | "invalid" + | "ok" + | "oversize" + | "unreadable"; + +export const ChatContextResourceStatuses: ChatContextResourceStatus[] = [ + "excluded", + "invalid", + "ok", + "oversize", + "unreadable", +]; + // From codersdk/chats.go /** * ChatCostChatBreakdown contains per-root-chat cost aggregation. diff --git a/site/src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx b/site/src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx index 03ac3283a876e..02b00e679a47a 100644 --- a/site/src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx +++ b/site/src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx @@ -47,6 +47,14 @@ export const Clean: Story = { // MCP server tools are listed under their server. expect(body.getByText("search_issues")).toBeVisible(); expect(body.getByText("create_issue")).toBeVisible(); + // Invalid resources are surfaced as issues with their error, not + // silently dropped. + expect(body.getByText("Issues")).toBeVisible(); + expect( + body.getByText( + 'front-matter name "coder-review" does not match directory "moo"', + ), + ).toBeVisible(); // A clean pin offers no refresh affordance. expect(body.queryByRole("button", { name: "Refresh context" })).toBeNull(); }, diff --git a/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx b/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx index 9329b8421d785..3b4127ea7acc7 100644 --- a/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx +++ b/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx @@ -9,6 +9,8 @@ import { type FC, useRef, useState } from "react"; import type { ChatContext, ChatContextMCPTool, + ChatContextResourceKind, + ChatContextResourceStatus, ChatMessagePart, } from "#/api/typesGenerated"; import { Button } from "#/components/Button/Button"; @@ -61,6 +63,23 @@ type ContextMcpItem = { readonly source: string; readonly tools: readonly ChatContextMCPTool[]; }; +// A pinned resource the agent could not use, surfaced with its error so the +// failure is visible instead of silent. +type ContextIssueItem = { + readonly name: string; + readonly kind: ChatContextResourceKind; + readonly status: ChatContextResourceStatus; + readonly error: string; + readonly source: string; +}; + +// Human-readable label per resource kind, used in the issues list. +const RESOURCE_KIND_LABELS: Record = { + instruction_file: "file", + skill: "skill", + mcp_config: "MCP config", + mcp_server: "MCP server", +}; const hasFiniteTokenValue = (value: number | undefined): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0; @@ -165,7 +184,10 @@ export const ContextUsageIndicator: FC<{ const fileItems: readonly ContextFileItem[] = ( usePinned ? (pinnedResources ?? []) - .filter((resource) => resource.kind === "instruction_file") + .filter( + (resource) => + resource.kind === "instruction_file" && resource.status === "ok", + ) .map((resource) => ({ path: resource.source })) : (usage?.lastInjectedContext ?? []) .filter((part) => part.type === "context-file") @@ -181,7 +203,9 @@ export const ContextUsageIndicator: FC<{ const skillItems: readonly ContextSkillItem[] = ( usePinned ? (pinnedResources ?? []) - .filter((resource) => resource.kind === "skill") + .filter( + (resource) => resource.kind === "skill" && resource.status === "ok", + ) .map((resource) => ({ name: resource.skill_name || getPathBasename(resource.source), description: resource.skill_description, @@ -204,7 +228,9 @@ export const ContextUsageIndicator: FC<{ ? (pinnedResources ?? []) .filter( (resource) => - resource.kind === "mcp_config" || resource.kind === "mcp_server", + (resource.kind === "mcp_config" || + resource.kind === "mcp_server") && + resource.status === "ok", ) .map((resource) => ({ name: @@ -219,8 +245,30 @@ export const ContextUsageIndicator: FC<{ // Drop entries with no usable name so an empty MCP marker never renders as // a blank row. .filter((mcp) => mcp.name.trim().length > 0); + // Pinned resources the agent could not use (invalid skill, unreadable or + // oversize file) are surfaced as issues with their error so the failure is + // visible rather than a silent omission. Pinned-only; the injected-context + // fallback has no status. + const issueItems: readonly ContextIssueItem[] = ( + usePinned ? (pinnedResources ?? []) : [] + ) + .filter((resource) => resource.status !== "ok") + .map((resource) => ({ + name: + resource.skill_name || + getPathBasename(resource.source) || + resource.source, + kind: resource.kind, + status: resource.status, + error: resource.error ?? "", + source: resource.source, + })) + .filter((issue) => issue.name.trim().length > 0); const hasContextList = - fileItems.length > 0 || skillItems.length > 0 || mcpItems.length > 0; + fileItems.length > 0 || + skillItems.length > 0 || + mcpItems.length > 0 || + issueItems.length > 0; const ariaLabel = hasPercent ? `Context usage ${percentLabel}. ${formatTokenCount(usedTokens)} of ${formatTokenCount(contextLimitTokens)} tokens used.${isDirty ? " Context changed." : ""}` @@ -351,6 +399,33 @@ export const ContextUsageIndicator: FC<{ )} + {issueItems.length > 0 && ( +
    + + + Issues + + {issueItems.map((issue) => ( +
    + + {issue.name}{" "} + + ({RESOURCE_KIND_LABELS[issue.kind]}: {issue.status}) + + + {issue.error && ( + + {issue.error} + + )} +
    + ))} +
    + )} )} {(isDirty || hasContextError) && ( diff --git a/site/src/testHelpers/chatEntities.ts b/site/src/testHelpers/chatEntities.ts index c28013ba1245e..2827eb3641fe2 100644 --- a/site/src/testHelpers/chatEntities.ts +++ b/site/src/testHelpers/chatEntities.ts @@ -40,11 +40,13 @@ const MockChatContextResources: ChatContextResource[] = [ source: "/home/coder/AGENTS.md", kind: "instruction_file", size_bytes: 248, + status: "ok", }, { source: "/home/coder/.coder/skills/deploy", kind: "skill", size_bytes: 96, + status: "ok", skill_name: "deploy", skill_description: "Deploy the app to staging.", }, @@ -52,11 +54,13 @@ const MockChatContextResources: ChatContextResource[] = [ source: "/home/coder/.mcp.json", kind: "mcp_config", size_bytes: 184, + status: "ok", }, { source: "github", kind: "mcp_server", size_bytes: 512, + status: "ok", mcp_tools: [ { name: "search_issues", @@ -65,6 +69,15 @@ const MockChatContextResources: ChatContextResource[] = [ { name: "create_issue", description: "Open a new issue." }, ], }, + { + // An invalid skill the agent rejected: surfaced as an issue with its + // error rather than silently dropped. + source: "/home/coder/test/.agents/skills/moo", + kind: "skill", + size_bytes: 356, + status: "invalid", + error: 'front-matter name "coder-review" does not match directory "moo"', + }, ]; // Per-source differences between the pinned context and the latest snapshot. From 5d8fd6300d5ffae2e0e7539ce15475230401f2d8 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 18 Jun 2026 01:21:25 +0000 Subject: [PATCH 16/20] feat(agent): surface failed MCP connects and invalid .mcp.json as context issues --- agent/agent.go | 8 +- agent/agentcontext/resolve.go | 41 ++++++ agent/agentcontext/resolve_test.go | 51 ++++++++ agent/contextmcp.go | 112 +++++++++++------ agent/contextmcp_internal_test.go | 117 ++++++++++++++---- .../x/agentmcp/cachedservers_internal_test.go | 69 +++++++++++ agent/x/agentmcp/manager.go | 108 +++++++++++++++- 7 files changed, 442 insertions(+), 64 deletions(-) create mode 100644 agent/x/agentmcp/cachedservers_internal_test.go diff --git a/agent/agent.go b/agent/agent.go index 6c1a66ba762b1..8ea9d162cf9d3 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -513,10 +513,10 @@ func (a *agent) init() { Clock: a.clock, WorkingDir: workingDirFn, InitialSources: initialContextSources(a.contextConfig, workingDirFn), - // Surface live MCP servers (and their tools) in the - // snapshot by reading the MCP manager's cached tool list - // on every resolve. - MCP: mcpContextProvider{cachedTools: a.mcpManager.CachedTools}, + // Surface live MCP servers (their tools, and any that + // failed to connect) in the snapshot by reading the MCP + // manager's per-server health on every resolve. + MCP: mcpContextProvider{cachedServers: a.mcpManager.CachedServers}, }) // Re-resolve the context snapshot whenever the MCP tool set // changes (e.g. a .mcp.json edit reconnects servers) so MCP diff --git a/agent/agentcontext/resolve.go b/agent/agentcontext/resolve.go index d55680b8dd204..9e48b1722a294 100644 --- a/agent/agentcontext/resolve.go +++ b/agent/agentcontext/resolve.go @@ -1,8 +1,10 @@ package agentcontext import ( + "bytes" "context" "crypto/sha256" + "encoding/json" "errors" "fmt" "io" @@ -15,6 +17,8 @@ import ( "strconv" "strings" + "golang.org/x/xerrors" + "github.com/coder/coder/v2/codersdk/workspacesdk" ) @@ -472,9 +476,46 @@ func (r *Resolver) readMCPConfig(scanRoot, path string, info fs.FileInfo, userSo return res } res.ContentHash = sha256.Sum256(data) + // A .mcp.json with broken JSON yields no MCP servers at all; the + // agentmcp manager logs and skips it, so the failure is otherwise + // invisible. Flag structural problems here as StatusInvalid so the + // chat context surfaces them as an issue rather than silently + // dropping every server in the file. + if err := validateMCPConfig(data); err != nil { + res.Status = StatusInvalid + res.Error = err.Error() + } return res } +// validateMCPConfig performs lightweight structural validation of a +// .mcp.json document so syntactically broken files surface as +// StatusInvalid instead of silently producing no MCP servers. It is +// deliberately self-contained and does not import the agentmcp +// package: it only checks that the document is valid JSON shaped like +// {"mcpServers": {: {...}}}. Individual server fields +// (command/url/env/...) are not validated here; the MCP manager owns +// that when it connects. An absent or empty mcpServers map is valid. +func validateMCPConfig(data []byte) error { + var shape struct { + MCPServers map[string]json.RawMessage `json:"mcpServers"` + } + if err := json.Unmarshal(data, &shape); err != nil { + return err + } + // Each server entry must be a JSON object; a scalar or array + // entry is a structural error the MCP manager would reject. + // The top-level Unmarshal above already rejects malformed JSON, + // so a well-formed value starting with '{' is a complete object. + for name, raw := range shape.MCPServers { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || trimmed[0] != '{' { + return xerrors.Errorf("server %q must be a JSON object", name) + } + } + return nil +} + // readFileResource is the shared plumbing for kinds whose only // difference is the enum stamped on the Resource: build the // Resource header, enforce the per-resource size cap, read the diff --git a/agent/agentcontext/resolve_test.go b/agent/agentcontext/resolve_test.go index aa7d6090a72c1..eeb2a3c5a2749 100644 --- a/agent/agentcontext/resolve_test.go +++ b/agent/agentcontext/resolve_test.go @@ -149,6 +149,57 @@ func TestResolver_MCPConfigEmitted(t *testing.T) { require.Equal(t, uint64(len(contents)), got.SizeBytes) } +// TestResolver_MCPConfigValidation confirms that a structurally +// broken .mcp.json surfaces as StatusInvalid (so the chat context +// shows it as an issue) while well-formed configs stay StatusOK. +// The resolver intentionally validates only JSON shape, not +// individual server fields, which the MCP manager owns. +func TestResolver_MCPConfigValidation(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + contents string + wantOK bool + }{ + {name: "ValidObjectEntry", contents: `{"mcpServers":{"github":{"command":"gh"}}}`, wantOK: true}, + {name: "EmptyBraces", contents: `{}`, wantOK: true}, + {name: "EmptyServers", contents: `{"mcpServers":{}}`, wantOK: true}, + {name: "UnknownTopLevelKeysIgnored", contents: `{"other":1,"mcpServers":{"a":{"url":"http://x"}}}`, wantOK: true}, + {name: "TrailingComma", contents: `{"mcpServers":{"a":{"command":"x"},}}`, wantOK: false}, + {name: "Truncated", contents: `{"mcpServers":`, wantOK: false}, + {name: "Empty", contents: ``, wantOK: false}, + {name: "ScalarEntry", contents: `{"mcpServers":{"github":"nope"}}`, wantOK: false}, + {name: "ArrayEntry", contents: `{"mcpServers":{"github":[]}}`, wantOK: false}, + {name: "ServersNotObject", contents: `{"mcpServers":[]}`, wantOK: false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, ".mcp.json"), tc.contents) + + r := &agentcontext.Resolver{} + snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}}) + require.Len(t, snap.Resources, 1) + got := snap.Resources[0] + require.Equal(t, agentcontext.KindMCPConfig, got.Kind) + // The hash is populated regardless of validity so a fix is + // detectable as a change. + require.NotEqual(t, [32]byte{}, got.ContentHash) + + if tc.wantOK { + require.Equal(t, agentcontext.StatusOK, got.Status) + require.Empty(t, got.Error) + } else { + require.Equal(t, agentcontext.StatusInvalid, got.Status) + require.NotEmpty(t, got.Error, "invalid config must carry an error message") + } + }) + } +} + // TestResolver_SymlinkInsideScanRootAllowed exercises the // monorepo case where AGENTS.md is symlinked to shared content // inside the same workspace tree. The target lives under the diff --git a/agent/contextmcp.go b/agent/contextmcp.go index 686ba659b96e5..7af060be6304b 100644 --- a/agent/contextmcp.go +++ b/agent/contextmcp.go @@ -9,55 +9,79 @@ import ( "strings" "github.com/coder/coder/v2/agent/agentcontext" + "github.com/coder/coder/v2/agent/x/agentmcp" "github.com/coder/coder/v2/codersdk/workspacesdk" ) // mcpContextProvider adapts the agent's MCP manager to the -// agentcontext.MCPProvider seam. It reads the manager's cached tool -// list (never blocking) and groups it into one KindMCPServer resource -// per server, so live MCP servers and their tools appear in the -// workspace-context snapshot alongside instruction files and skills. +// agentcontext.MCPProvider seam. It reads the manager's per-server +// health snapshot (never blocking) and turns each server into one +// KindMCPServer resource, so live MCP servers and their tools appear +// in the workspace-context snapshot alongside instruction files and +// skills. Servers that failed to connect surface as non-OK resources +// so they are no longer silently dropped. type mcpContextProvider struct { - // cachedTools returns the current MCP tool cache without blocking. - // It is *agentmcp.Manager.CachedTools in production. - cachedTools func() []workspacesdk.MCPToolInfo + // cachedServers returns the current per-server MCP snapshot + // without blocking. It is *agentmcp.Manager.CachedServers in + // production. + cachedServers func() []agentmcp.ServerStatus } // MCPResources implements agentcontext.MCPProvider. It must never block; // the resolver calls it on every re-resolve. func (p mcpContextProvider) MCPResources() []agentcontext.Resource { - if p.cachedTools == nil { + if p.cachedServers == nil { return nil } - return buildMCPServerResources(p.cachedTools()) + return buildMCPServerResources(p.cachedServers()) } -// buildMCPServerResources groups a flat MCP tool list by server name and -// returns one KindMCPServer resource per server. Servers are emitted in -// name order, and tools within a server in name order, so the resource ID -// list and content hashes are deterministic across resolves. Only servers -// that expose at least one tool are surfaced; a server's .mcp.json entry -// still appears separately as a KindMCPConfig resource. -func buildMCPServerResources(tools []workspacesdk.MCPToolInfo) []agentcontext.Resource { - if len(tools) == 0 { +// buildMCPServerResources turns a per-server MCP snapshot into one +// KindMCPServer resource per server. Servers are emitted in name order, +// and tools within a server in name order, so the resource ID list and +// content hashes are deterministic across resolves. +// +// A connected server that exposes at least one tool becomes a +// StatusOK resource carrying its tools. A server that failed to connect +// becomes a StatusUnreadable resource carrying the connection error, so +// it appears in the snapshot's issues instead of vanishing. A connected +// server with no tools yet is skipped until its tools arrive (a later +// re-resolve, driven by onToolsChanged, surfaces it). A server's +// .mcp.json entry still appears separately as a KindMCPConfig resource. +func buildMCPServerResources(servers []agentmcp.ServerStatus) []agentcontext.Resource { + if len(servers) == 0 { return nil } - byServer := make(map[string][]workspacesdk.MCPToolInfo) - for _, t := range tools { - if t.ServerName == "" { + sorted := slices.Clone(servers) + slices.SortFunc(sorted, func(a, b agentmcp.ServerStatus) int { + return strings.Compare(a.Name, b.Name) + }) + + resources := make([]agentcontext.Resource, 0, len(sorted)) + for _, s := range sorted { + if s.Name == "" { continue } - byServer[t.ServerName] = append(byServer[t.ServerName], t) - } - servers := make([]string, 0, len(byServer)) - for name := range byServer { - servers = append(servers, name) - } - slices.Sort(servers) - - resources := make([]agentcontext.Resource, 0, len(servers)) - for _, server := range servers { - serverTools := byServer[server] + if !s.Connected { + errMsg := s.Err + if errMsg == "" { + errMsg = "failed to connect" + } + resources = append(resources, agentcontext.Resource{ + ID: resourceID(agentcontext.KindMCPServer, s.Name), + Kind: agentcontext.KindMCPServer, + Source: s.Name, + Name: s.Name, + Status: agentcontext.StatusUnreadable, + Error: errMsg, + ContentHash: hashMCPServerError(s.Name, errMsg), + }) + continue + } + if len(s.Tools) == 0 { + continue + } + serverTools := slices.Clone(s.Tools) slices.SortFunc(serverTools, func(a, b workspacesdk.MCPToolInfo) int { return strings.Compare(a.Name, b.Name) }) @@ -70,15 +94,18 @@ func buildMCPServerResources(tools []workspacesdk.MCPToolInfo) []agentcontext.Re }) } resources = append(resources, agentcontext.Resource{ - ID: resourceID(agentcontext.KindMCPServer, server), + ID: resourceID(agentcontext.KindMCPServer, s.Name), Kind: agentcontext.KindMCPServer, - Source: server, - Name: server, + Source: s.Name, + Name: s.Name, Status: agentcontext.StatusOK, - ContentHash: hashMCPServer(server, converted), + ContentHash: hashMCPServer(s.Name, converted), Tools: converted, }) } + if len(resources) == 0 { + return nil + } return resources } @@ -110,6 +137,21 @@ func hashMCPServer(server string, tools []agentcontext.MCPTool) [32]byte { return sum } +// hashMCPServerError produces a deterministic content hash for a +// failed-to-connect server. The "unreadable" discriminator keeps a +// failed server's hash distinct from an OK server's, so a server that +// transitions between connected and failed (or whose error text +// changes) flips the aggregate hash and re-pins dirty chats. +func hashMCPServerError(server, errMsg string) [32]byte { + h := sha256.New() + writeHashField(h, "unreadable") + writeHashField(h, server) + writeHashField(h, errMsg) + var sum [32]byte + copy(sum[:], h.Sum(nil)) + return sum +} + // writeHashField writes a length-prefixed field so adjacent fields cannot // be confused by concatenation (e.g. "ab"+"c" vs "a"+"bc"). func writeHashField(h io.Writer, s string) { diff --git a/agent/contextmcp_internal_test.go b/agent/contextmcp_internal_test.go index 0b917241a0cdc..60c98ab24b867 100644 --- a/agent/contextmcp_internal_test.go +++ b/agent/contextmcp_internal_test.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/coder/v2/agent/agentcontext" + "github.com/coder/coder/v2/agent/x/agentmcp" "github.com/coder/coder/v2/codersdk/workspacesdk" ) @@ -15,19 +16,23 @@ func TestBuildMCPServerResources(t *testing.T) { t.Run("Empty", func(t *testing.T) { t.Parallel() require.Nil(t, buildMCPServerResources(nil)) - require.Nil(t, buildMCPServerResources([]workspacesdk.MCPToolInfo{})) + require.Nil(t, buildMCPServerResources([]agentmcp.ServerStatus{})) }) t.Run("GroupsByServerSortedWithTools", func(t *testing.T) { t.Parallel() - tools := []workspacesdk.MCPToolInfo{ - {ServerName: "github", Name: "github__search", Description: "Search"}, - {ServerName: "fs", Name: "fs__read", Description: "Read", Schema: map[string]any{"type": "object"}}, - {ServerName: "github", Name: "github__create", Description: "Create"}, - // Dropped: a tool with no server cannot be grouped. - {ServerName: "", Name: "orphan"}, + servers := []agentmcp.ServerStatus{ + {Name: "github", Connected: true, Tools: []workspacesdk.MCPToolInfo{ + {Name: "github__search", Description: "Search"}, + {Name: "github__create", Description: "Create"}, + }}, + {Name: "fs", Connected: true, Tools: []workspacesdk.MCPToolInfo{ + {Name: "fs__read", Description: "Read", Schema: map[string]any{"type": "object"}}, + }}, + // Dropped: a server with no name cannot be addressed. + {Name: "", Connected: true, Tools: []workspacesdk.MCPToolInfo{{Name: "orphan"}}}, } - got := buildMCPServerResources(tools) + got := buildMCPServerResources(servers) require.Len(t, got, 2) // Servers are emitted in name order: fs, then github. @@ -48,26 +53,89 @@ func TestBuildMCPServerResources(t *testing.T) { require.Equal(t, "github__search", got[1].Tools[1].Name) }) + t.Run("ConnectedWithoutToolsSkipped", func(t *testing.T) { + t.Parallel() + // A connected server that has not yet reported any tools is + // not surfaced; a later re-resolve picks it up once tools + // arrive. + require.Nil(t, buildMCPServerResources([]agentmcp.ServerStatus{ + {Name: "fs", Connected: true}, + })) + }) + + t.Run("FailedServerSurfacesAsIssue", func(t *testing.T) { + t.Parallel() + got := buildMCPServerResources([]agentmcp.ServerStatus{ + {Name: "broken", Connected: false, Err: "initialize \"broken\": exec: no such file"}, + }) + require.Len(t, got, 1) + require.Equal(t, agentcontext.KindMCPServer, got[0].Kind) + require.Equal(t, "broken", got[0].Source) + require.Equal(t, "broken", got[0].Name) + require.Equal(t, "mcp_server:broken", got[0].ID) + require.Equal(t, agentcontext.StatusUnreadable, got[0].Status) + require.Equal(t, "initialize \"broken\": exec: no such file", got[0].Error) + require.Empty(t, got[0].Tools) + require.NotEqual(t, [32]byte{}, got[0].ContentHash) + }) + + t.Run("FailedServerWithoutErrorGetsDefault", func(t *testing.T) { + t.Parallel() + got := buildMCPServerResources([]agentmcp.ServerStatus{ + {Name: "broken", Connected: false}, + }) + require.Len(t, got, 1) + require.Equal(t, agentcontext.StatusUnreadable, got[0].Status) + require.Equal(t, "failed to connect", got[0].Error) + }) + t.Run("ContentHashStableAndToolSensitive", func(t *testing.T) { t.Parallel() - base := []workspacesdk.MCPToolInfo{ - {ServerName: "fs", Name: "fs__read", Description: "Read"}, + base := []agentmcp.ServerStatus{ + {Name: "fs", Connected: true, Tools: []workspacesdk.MCPToolInfo{ + {Name: "fs__read", Description: "Read"}, + }}, } h1 := buildMCPServerResources(base)[0].ContentHash // Identical input is hashed identically. require.Equal(t, h1, buildMCPServerResources(base)[0].ContentHash) // A description change flips the hash. - require.NotEqual(t, h1, buildMCPServerResources([]workspacesdk.MCPToolInfo{ - {ServerName: "fs", Name: "fs__read", Description: "Read files"}, + require.NotEqual(t, h1, buildMCPServerResources([]agentmcp.ServerStatus{ + {Name: "fs", Connected: true, Tools: []workspacesdk.MCPToolInfo{ + {Name: "fs__read", Description: "Read files"}, + }}, })[0].ContentHash) // Adding a tool flips the hash. - require.NotEqual(t, h1, buildMCPServerResources([]workspacesdk.MCPToolInfo{ - {ServerName: "fs", Name: "fs__read", Description: "Read"}, - {ServerName: "fs", Name: "fs__write", Description: "Write"}, + require.NotEqual(t, h1, buildMCPServerResources([]agentmcp.ServerStatus{ + {Name: "fs", Connected: true, Tools: []workspacesdk.MCPToolInfo{ + {Name: "fs__read", Description: "Read"}, + {Name: "fs__write", Description: "Write"}, + }}, })[0].ContentHash) // A schema change flips the hash. - require.NotEqual(t, h1, buildMCPServerResources([]workspacesdk.MCPToolInfo{ - {ServerName: "fs", Name: "fs__read", Description: "Read", Schema: map[string]any{"type": "object"}}, + require.NotEqual(t, h1, buildMCPServerResources([]agentmcp.ServerStatus{ + {Name: "fs", Connected: true, Tools: []workspacesdk.MCPToolInfo{ + {Name: "fs__read", Description: "Read", Schema: map[string]any{"type": "object"}}, + }}, + })[0].ContentHash) + }) + + t.Run("FailedServerHashErrorSensitive", func(t *testing.T) { + t.Parallel() + h1 := buildMCPServerResources([]agentmcp.ServerStatus{ + {Name: "fs", Connected: false, Err: "boom"}, + })[0].ContentHash + // The error text participates in the hash so a changed error + // re-pins dirty chats. + require.NotEqual(t, h1, buildMCPServerResources([]agentmcp.ServerStatus{ + {Name: "fs", Connected: false, Err: "different"}, + })[0].ContentHash) + // A failed server hashes differently from a connected one, so + // the connected->failed transition flips the aggregate hash. + require.NotEqual(t, h1, buildMCPServerResources([]agentmcp.ServerStatus{ + {Name: "fs", Connected: true, Tools: []workspacesdk.MCPToolInfo{ + {Name: "fs__read", Description: "boom"}, + }}, })[0].ContentHash) }) @@ -76,11 +144,18 @@ func TestBuildMCPServerResources(t *testing.T) { // A nil cache source yields no resources rather than panicking. require.Nil(t, mcpContextProvider{}.MCPResources()) - p := mcpContextProvider{cachedTools: func() []workspacesdk.MCPToolInfo { - return []workspacesdk.MCPToolInfo{{ServerName: "fs", Name: "fs__read"}} + p := mcpContextProvider{cachedServers: func() []agentmcp.ServerStatus { + return []agentmcp.ServerStatus{ + {Name: "fs", Connected: true, Tools: []workspacesdk.MCPToolInfo{{Name: "fs__read"}}}, + {Name: "broken", Connected: false, Err: "nope"}, + } }} got := p.MCPResources() - require.Len(t, got, 1) - require.Equal(t, "fs", got[0].Source) + require.Len(t, got, 2) + // Emitted in name order: broken (failed), then fs (ok). + require.Equal(t, "broken", got[0].Source) + require.Equal(t, agentcontext.StatusUnreadable, got[0].Status) + require.Equal(t, "fs", got[1].Source) + require.Equal(t, agentcontext.StatusOK, got[1].Status) }) } diff --git a/agent/x/agentmcp/cachedservers_internal_test.go b/agent/x/agentmcp/cachedservers_internal_test.go new file mode 100644 index 0000000000000..1d5bdb75e8dd5 --- /dev/null +++ b/agent/x/agentmcp/cachedservers_internal_test.go @@ -0,0 +1,69 @@ +package agentmcp + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3" + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/agent/agentexec" + "github.com/coder/coder/v2/testutil" +) + +// TestManager_CachedServers verifies the non-blocking CachedServers +// accessor reports every configured server, including one that failed +// to connect (which never enters m.servers and would otherwise be +// invisible to the workspace-context snapshot), and that a closed +// manager reports no servers. +func TestManager_CachedServers(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) + dir := t.TempDir() + + m := NewManager(ctx, logger, agentexec.DefaultExecer, nil) + m.MarkStartupSettled() + t.Cleanup(func() { _ = m.Close() }) + + // CachedServers never blocks and is empty before the first reload. + require.Empty(t, m.CachedServers()) + + // One healthy server (re-exec fake) plus one whose binary does + // not exist, so its connect fails. + _, good := fakeMCPServerConfig(t, "good") + bad := mcpServerEntry{Command: "/nonexistent/agentmcp-binary"} + configPath := writeMCPConfig(t, dir, map[string]mcpServerEntry{ + "good": good, + "bad": bad, + }) + + // Reload succeeds: per-server connect failures are logged and + // swallowed, not fatal. + require.NoError(t, m.Reload(ctx, []string{configPath})) + + byName := make(map[string]ServerStatus) + for _, s := range m.CachedServers() { + byName[s.Name] = s + } + require.Len(t, byName, 2, "both configured servers must be reported") + + gotGood, ok := byName["good"] + require.True(t, ok) + require.True(t, gotGood.Connected) + require.Empty(t, gotGood.Err) + require.Len(t, gotGood.Tools, 1, "connected server exposes its tools") + require.True(t, strings.HasPrefix(gotGood.Tools[0].Name, "good"+ToolNameSep)) + + gotBad, ok := byName["bad"] + require.True(t, ok, "a server that failed to connect must still be reported") + require.False(t, gotBad.Connected) + require.NotEmpty(t, gotBad.Err, "failed server must carry a connect error") + require.Empty(t, gotBad.Tools) + + // After Close the manager reports no servers. + require.NoError(t, m.Close()) + require.Empty(t, m.CachedServers()) +} diff --git a/agent/x/agentmcp/manager.go b/agent/x/agentmcp/manager.go index d3daf7cf670d9..30993b16d577c 100644 --- a/agent/x/agentmcp/manager.go +++ b/agent/x/agentmcp/manager.go @@ -135,6 +135,33 @@ type Manager struct { // track the live tool set. Guarded by m.mu; nil until set // via SetOnToolsChanged. onToolsChanged func() + + // serverHealth records, per configured server name, whether the + // last reload connected it and the connect error otherwise. It + // is rebuilt at the end of every reload and read (joined with + // the tool cache) by CachedServers so failed-to-connect servers + // surface in the workspace-context snapshot instead of vanishing. + // Guarded by m.mu. + serverHealth map[string]serverHealthEntry +} + +// serverHealthEntry is the per-server connection outcome recorded +// after a reload. err is non-empty only when connected is false. +type serverHealthEntry struct { + connected bool + err string +} + +// ServerStatus is a non-blocking snapshot of a single configured MCP +// server's connection health and current tool set, suitable for +// surfacing in the workspace-context UI. Connected servers carry their +// Tools (possibly empty before the first tool list arrives); servers +// that failed to connect carry a non-empty Err and no Tools. +type ServerStatus struct { + Name string + Connected bool + Err string + Tools []workspacesdk.MCPToolInfo } // serverEntry pairs a server config with its connected client. @@ -162,6 +189,7 @@ func NewManager( execer: execer, updateEnv: updateEnv, servers: make(map[string]*serverEntry), + serverHealth: make(map[string]serverHealthEntry), snapshot: make(map[string]fileSnapshot), startupSettled: make(chan struct{}), closedCh: make(chan struct{}), @@ -526,13 +554,19 @@ func (m *Manager) doReload(ctx context.Context, mcpConfigFiles []string) error { return err } - connected := m.connectAll(ctx, diff.toConnect) + connected, failedConnects := m.connectAll(ctx, diff.toConnect) replaced, err := m.installServers(wanted, diff, connected, snap) if err != nil { return err } + // Record per-server connection health after installServers + // commits the new server map, so CachedServers can surface + // servers that failed to connect (which never enter m.servers + // and would otherwise vanish from the snapshot). + m.recordServerHealth(wanted, failedConnects) + // Close removed and replaced servers outside the lock to // avoid leaking child processes and to avoid blocking // concurrent readers on subprocess I/O. @@ -636,8 +670,10 @@ func (m *Manager) classifyServers(wanted map[string]ServerConfig) (*serverDiff, } // connectAll runs connectServer in parallel for the given configs. -// Failed connects are logged and skipped. -func (m *Manager) connectAll(ctx context.Context, toConnect []ServerConfig) []connectedServer { +// Failed connects are logged and skipped; their server name and +// error string are returned in the failed map so the caller can +// record per-server health for the workspace-context snapshot. +func (m *Manager) connectAll(ctx context.Context, toConnect []ServerConfig) ([]connectedServer, map[string]string) { logger := m.logger.With(agentchat.Fields(ctx)...) if hook := m.connectStartedHook; hook != nil { @@ -647,6 +683,7 @@ func (m *Manager) connectAll(ctx context.Context, toConnect []ServerConfig) []co var ( mu sync.Mutex connected []connectedServer + failed = make(map[string]string) ) var eg errgroup.Group for _, cfg := range toConnect { @@ -658,6 +695,9 @@ func (m *Manager) connectAll(ctx context.Context, toConnect []ServerConfig) []co slog.F("transport", cfg.Transport), slog.Error(err), ) + mu.Lock() + failed[cfg.Name] = err.Error() + mu.Unlock() return nil // Don't fail the group. } mu.Lock() @@ -669,7 +709,7 @@ func (m *Manager) connectAll(ctx context.Context, toConnect []ServerConfig) []co }) } _ = eg.Wait() - return connected + return connected, failed } // installServers builds the new server map from diff.keep and the @@ -759,6 +799,63 @@ func (m *Manager) CachedTools() []workspacesdk.MCPToolInfo { return slices.Clone(m.tools) } +// recordServerHealth rebuilds the per-server health map from the +// committed server set. A wanted server present in m.servers is +// connected (a connect that failed but retained a prior working +// client still counts as connected); one that is absent failed to +// connect and carries its connect error. Read by CachedServers. +func (m *Manager) recordServerHealth(wanted map[string]ServerConfig, failedConnects map[string]string) { + m.mu.Lock() + defer m.mu.Unlock() + + // A concurrent Close clears the server set and health; do not + // repopulate after that. + if m.closed { + return + } + + health := make(map[string]serverHealthEntry, len(wanted)) + for name := range wanted { + if _, ok := m.servers[name]; ok { + health[name] = serverHealthEntry{connected: true} + continue + } + msg := failedConnects[name] + if msg == "" { + msg = "failed to connect" + } + health[name] = serverHealthEntry{connected: false, err: msg} + } + m.serverHealth = health +} + +// CachedServers returns a non-blocking per-server snapshot joining the +// recorded connection health with the cached tool set. Connected +// servers carry their current tools; servers that failed to connect +// carry a non-empty Err and no tools. Like CachedTools it never blocks +// (no startup-settle wait or reload) and is intended for the +// agentcontext MCP provider, which runs on every re-resolve. +func (m *Manager) CachedServers() []ServerStatus { + m.mu.RLock() + defer m.mu.RUnlock() + + byServer := make(map[string][]workspacesdk.MCPToolInfo, len(m.serverHealth)) + for _, t := range m.tools { + byServer[t.ServerName] = append(byServer[t.ServerName], t) + } + + out := make([]ServerStatus, 0, len(m.serverHealth)) + for name, h := range m.serverHealth { + out = append(out, ServerStatus{ + Name: name, + Connected: h.connected, + Err: h.err, + Tools: slices.Clone(byServer[name]), + }) + } + return out +} + // CallTool proxies a tool call to the appropriate MCP server. func (m *Manager) CallTool(ctx context.Context, req workspacesdk.CallMCPToolRequest) (workspacesdk.CallMCPToolResponse, error) { serverName, originalName, err := splitToolName(req.ToolName) @@ -941,6 +1038,9 @@ func (m *Manager) Close() error { } } m.servers = make(map[string]*serverEntry) + // Drop recorded health so a closed manager reports no servers + // via CachedServers. + m.serverHealth = nil // Prevent an in-flight RefreshTools from repopulating tools // after Close clears the cache. m.serverGen++ From 9cd6523d55acb1efa829fee6fed69400d6bc996a Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 18 Jun 2026 02:45:28 +0000 Subject: [PATCH 17/20] fix(coderd): hydrate context resources in the refresh-context response --- coderd/exp_chats.go | 23 +++++++++++++++++++++- coderd/x/chatd/context_integration_test.go | 11 +++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index c8fc557761d4f..afb35e9b5552f 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -2666,7 +2666,28 @@ func (api *API) refreshChatContext(rw http.ResponseWriter, r *http.Request) { return } - httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Chat(updated, nil, nil)) + sdkChat := db2sdk.Chat(updated, nil, nil) + + // Enrich the context summary with the freshly pinned resources (and any + // change set) so the client reflects the refresh immediately, without a + // full reload. This mirrors getChat; we pass the re-pinned chat so the + // detail reflects the post-refresh state (dirty marker cleared, so + // changes is nil). A failure here is non-fatal: the refresh already + // succeeded, so we log and return the rest of the response. + if sdkChat.Context != nil && api.chatDaemon != nil { + resources, changes, err := api.chatDaemon.ContextDetail(ctx, updated) + if err != nil { + api.Logger.Error(ctx, "failed to compute chat context detail after refresh", + slog.F("chat_id", updated.ID), + slog.Error(err), + ) + } else { + sdkChat.Context.Resources = resources + sdkChat.Context.Changes = changes + } + } + + httpapi.Write(ctx, rw, http.StatusOK, sdkChat) } // patchChat updates a chat resource. Supports updating labels, diff --git a/coderd/x/chatd/context_integration_test.go b/coderd/x/chatd/context_integration_test.go index ca1ff75873dba..b15ad1267b868 100644 --- a/coderd/x/chatd/context_integration_test.go +++ b/coderd/x/chatd/context_integration_test.go @@ -248,6 +248,17 @@ func TestChatContextDirtyFromAgentPush(t *testing.T) { require.False(t, refreshed.Context.Dirty, "refresh clears the dirty marker") require.Equal(t, snapshotError, refreshed.Context.Error, "refresh re-pins the snapshot error") + // The refresh response itself must carry the freshly pinned resources + // (and no change set), so the client reflects the refresh without a + // full reload. A regression here blanks the context indicator until + // the page is reloaded (which re-fetches via GET). + refreshRespResources := resourcesBySource(refreshed.Context.Resources) + require.Len(t, refreshRespResources, 2, "refresh response includes the re-pinned resources") + require.Equal(t, codersdk.ChatContextResourceKindInstructionFile, refreshRespResources[agentsSource].Kind) + require.Equal(t, codersdk.ChatContextResourceKindSkill, refreshRespResources[skillSource].Kind) + require.Equal(t, "example", refreshRespResources[skillSource].SkillName) + require.Empty(t, refreshed.Context.Changes, "a freshly refreshed chat has no changes") + // Refresh re-pinned the agent's current resources (the hashB set). pinned = pinnedResources(chat.ID) require.Len(t, pinned, 2, "refresh re-pins the agent's current resources") From 38d1244168cf0d7a69c11aeebd0c2673c6c0a5ec Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 18 Jun 2026 03:08:52 +0000 Subject: [PATCH 18/20] fix(agent): connect MCP servers from context-source .mcp.json files --- agent/agent.go | 84 ++++++++++++++++++++++++++++++- agent/contextmcp_internal_test.go | 41 +++++++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index 8ea9d162cf9d3..f0196b933ba7e 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -1555,8 +1555,18 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context, // This runs inside the tracked goroutine so it // is properly awaited on shutdown. a.mcpManager.MarkStartupSettled() - if mcpErr := a.mcpManager.Reload(a.gracefulCtx, a.contextConfigAPI.MCPConfigFiles()); mcpErr != nil { - a.logger.Warn(ctx, "failed to reload workspace MCP servers", slog.Error(mcpErr)) + // Keep the MCP manager's connected servers in sync + // with the .mcp.json files the context resolver + // discovers: the manifest working directory plus any + // context sources added at runtime. A .mcp.json that + // lives in an added source (not the working dir) + // would otherwise never be connected. Sources can be + // added after startup, so this watches for the agent's + // lifetime in its own tracked goroutine. + if mcpErr := a.trackGoroutine(func() { + a.syncMCPServersFromContext(a.gracefulCtx) + }); mcpErr != nil { + a.logger.Warn(ctx, "failed to start workspace MCP context sync", slog.Error(mcpErr)) } }) if err != nil { @@ -1567,6 +1577,76 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context, } } +// syncMCPServersFromContext keeps the MCP manager's connected servers +// in sync with the .mcp.json files the context resolver discovers. The +// resolver scans the manifest working directory, built-in roots, and any +// context sources added at runtime, so a .mcp.json contributed by an +// added source (rather than living in the working directory) is +// connected too. It reloads on every context-snapshot change, deduping +// by the discovered path set so unrelated snapshot churn (e.g. MCP tool +// updates, which also bump the snapshot) does not cause reload churn. +func (a *agent) syncMCPServersFromContext(ctx context.Context) { + changes, unsubscribe := a.contextManager.SubscribeChanges() + defer unsubscribe() + + // The statically configured MCP paths (manifest working directory + // plus CODER_AGENT_EXP_MCP_CONFIG_FILES) are stable for the agent's + // lifetime once startup has settled, so resolve them once. + configured := a.contextConfigAPI.MCPConfigFiles() + + var last []string + reload := func() { + paths := mcpConfigPaths(configured, a.contextManager.Snapshot()) + if slices.Equal(paths, last) { + return + } + last = paths + if err := a.mcpManager.Reload(ctx, paths); err != nil { + a.logger.Warn(ctx, "failed to reload workspace MCP servers", slog.Error(err)) + } + } + + // Pick up any sources discovered before we subscribed. + reload() + for { + select { + case <-ctx.Done(): + return + case <-changes: + reload() + } + } +} + +// mcpConfigPaths unions the statically configured MCP config files with +// the .mcp.json files the resolver discovered in the snapshot (working +// directory, built-in roots, and runtime-managed context sources) and +// returns a sorted, deduplicated list. The union keeps legacy +// manifest-directory discovery working while also connecting servers +// declared in .mcp.json files contributed by added context sources. +func mcpConfigPaths(configured []string, snap agentcontext.Snapshot) []string { + seen := make(map[string]struct{}, len(configured)+len(snap.Resources)) + add := func(p string) { + if p != "" { + seen[p] = struct{}{} + } + } + for _, p := range configured { + add(p) + } + for _, r := range snap.Resources { + if r.Kind == agentcontext.KindMCPConfig { + add(r.Source) + } + } + paths := make([]string, 0, len(seen)) + for p := range seen { + paths = append(paths, p) + } + slices.Sort(paths) + return paths +} + func (a *agent) createDevcontainer( ctx context.Context, aAPI proto.DRPCAgentClient28, diff --git a/agent/contextmcp_internal_test.go b/agent/contextmcp_internal_test.go index 60c98ab24b867..8fbe651008f4a 100644 --- a/agent/contextmcp_internal_test.go +++ b/agent/contextmcp_internal_test.go @@ -159,3 +159,44 @@ func TestBuildMCPServerResources(t *testing.T) { require.Equal(t, agentcontext.StatusOK, got[1].Status) }) } + +func TestMCPConfigPaths(t *testing.T) { + t.Parallel() + + t.Run("OnlySnapshotSources", func(t *testing.T) { + t.Parallel() + // Regression for the case where the manifest working directory is + // empty (so the statically configured set is empty) but a + // .mcp.json was contributed by a context source added at runtime. + // The MCP manager must still be told about it. + got := mcpConfigPaths(nil, agentcontext.Snapshot{Resources: []agentcontext.Resource{ + {Kind: agentcontext.KindMCPConfig, Source: "/home/coder/test/.mcp.json"}, + }}) + require.Equal(t, []string{"/home/coder/test/.mcp.json"}, got) + }) + + t.Run("UnionSortedDeduped", func(t *testing.T) { + t.Parallel() + snap := agentcontext.Snapshot{Resources: []agentcontext.Resource{ + {Kind: agentcontext.KindMCPConfig, Source: "/work/.mcp.json"}, + {Kind: agentcontext.KindMCPConfig, Source: "/added/.mcp.json"}, + // Same path as a configured entry: deduped. + {Kind: agentcontext.KindMCPConfig, Source: "/cfg/.mcp.json"}, + // Other kinds and empty sources are ignored. + {Kind: agentcontext.KindInstructionFile, Source: "/work/AGENTS.md"}, + {Kind: agentcontext.KindMCPServer, Source: "go-language-server"}, + {Kind: agentcontext.KindMCPConfig, Source: ""}, + }} + got := mcpConfigPaths([]string{"/cfg/.mcp.json", "/work/.mcp.json"}, snap) + require.Equal(t, []string{ + "/added/.mcp.json", + "/cfg/.mcp.json", + "/work/.mcp.json", + }, got) + }) + + t.Run("Empty", func(t *testing.T) { + t.Parallel() + require.Empty(t, mcpConfigPaths(nil, agentcontext.Snapshot{})) + }) +} From 94ac11ccf0327ebaac050139a3778f9f43cb26bd Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 18 Jun 2026 03:38:53 +0000 Subject: [PATCH 19/20] fix(cli,coderd): authenticate in-workspace chat context refresh with the agent token --- cli/exp_chat.go | 67 +++++------ cli/exp_chat_test.go | 34 +++--- coderd/coderd.go | 1 + coderd/workspaceagents.go | 63 ++++++++++ coderd/x/chatd/context_integration_test.go | 127 +++++++++++++++++++++ codersdk/agentsdk/agentsdk.go | 26 +++++ 6 files changed, 266 insertions(+), 52 deletions(-) diff --git a/cli/exp_chat.go b/cli/exp_chat.go index a3a7063f3a166..3e23139a695ce 100644 --- a/cli/exp_chat.go +++ b/cli/exp_chat.go @@ -326,6 +326,7 @@ func (*RootCmd) chatContextRemoveCommand(socketPath *string) *serpent.Command { } func (r *RootCmd) chatContextRefreshCommand(socketPath *string) *serpent.Command { + agentAuth := &AgentAuth{} cmd := &serpent.Command{ Use: "refresh []", Short: "Refresh chat context to the agent's latest snapshot", @@ -334,21 +335,24 @@ func (r *RootCmd) chatContextRefreshCommand(socketPath *string) *serpent.Command " argument, refreshes that chat and works from anywhere.\n\nWith no " + "argument, run from inside the workspace: forces the agent to re-resolve its " + "sources (catching freshly-cloned repos and startup-script writes the watcher " + - "has not seen yet), then refreshes every drifted chat.", + "has not seen yet), then refreshes every drifted chat. This path authenticates " + + "with the agent token, so it does not require 'coder login'.", Middleware: serpent.RequireRangeArgs(0, 1), Handler: func(inv *serpent.Invocation) error { ctx := inv.Context() - client, err := r.InitClient(inv) - if err != nil { - return err - } - exp := codersdk.NewExperimentalClient(client) + // With a argument: refresh that specific chat through the + // user-facing API. Works from anywhere with a logged-in CLI. if len(inv.Args) == 1 { chatID, err := uuid.Parse(inv.Args[0]) if err != nil { return xerrors.Errorf("invalid chat ID %q: %w", inv.Args[0], err) } + client, err := r.InitClient(inv) + if err != nil { + return err + } + exp := codersdk.NewExperimentalClient(client) chat, err := exp.RefreshChatContext(ctx, chatID) if err != nil { return xerrors.Errorf("refresh chat context: %w", err) @@ -360,41 +364,38 @@ func (r *RootCmd) chatContextRefreshCommand(socketPath *string) *serpent.Command return nil } - // No argument: re-resolve the agent's sources (in-workspace only), - // then fan out a refresh to every drifted chat. - if sock, serr := dialAgentContextSocket(ctx, *socketPath); serr == nil { - defer sock.Close() - snap, rerr := sock.ResyncContext(ctx) - if rerr != nil { - return xerrors.Errorf("re-resolve agent context: %w", rerr) - } - _, _ = fmt.Fprintf(inv.Stdout, "Re-resolved agent context (version %d, %d resources).\n", snap.Version, len(snap.Resources)) - if snap.SnapshotError != "" { - _, _ = fmt.Fprintf(inv.Stdout, "Snapshot reported an error: %s\n", snap.SnapshotError) - } - } else { - _, _ = fmt.Fprintln(inv.Stderr, "Not inside a workspace; skipping agent re-resolve.") + // No argument: in-workspace. Re-resolve the agent's sources over + // the local context socket, then ask the agent (using its own + // token) to re-pin every drifted chat. Neither step needs a + // logged-in user session. + sock, err := dialAgentContextSocket(ctx, *socketPath) + if err != nil { + return xerrors.Errorf("connect to agent context socket "+ + "(run inside the workspace, or pass a ID): %w", err) + } + defer sock.Close() + snap, err := sock.ResyncContext(ctx) + if err != nil { + return xerrors.Errorf("re-resolve agent context: %w", err) + } + _, _ = fmt.Fprintf(inv.Stdout, "Re-resolved agent context (version %d, %d resources).\n", snap.Version, len(snap.Resources)) + if snap.SnapshotError != "" { + _, _ = fmt.Fprintf(inv.Stdout, "Snapshot reported an error: %s\n", snap.SnapshotError) } - chats, err := exp.ListChats(ctx, nil) + agentClient, err := agentAuth.CreateClient() if err != nil { - return xerrors.Errorf("list chats: %w", err) + return xerrors.Errorf("create agent client: %w", err) } - refreshed := 0 - for _, c := range chats { - if c.Context == nil || !c.Context.Dirty { - continue - } - if _, err := exp.RefreshChatContext(ctx, c.ID); err != nil { - _, _ = fmt.Fprintf(inv.Stderr, "Failed to refresh chat %s: %v\n", c.ID, err) - continue - } - refreshed++ + resp, err := agentClient.RefreshChatContext(ctx) + if err != nil { + return xerrors.Errorf("refresh chat context: %w", err) } - _, _ = fmt.Fprintf(inv.Stdout, "Refreshed %d drifted chat(s).\n", refreshed) + _, _ = fmt.Fprintf(inv.Stdout, "Refreshed %d drifted chat(s).\n", resp.Refreshed) return nil }, } + agentAuth.AttachOptions(cmd, false) return cmd } diff --git a/cli/exp_chat_test.go b/cli/exp_chat_test.go index 30696c6ecad48..f204db3301013 100644 --- a/cli/exp_chat_test.go +++ b/cli/exp_chat_test.go @@ -1,6 +1,7 @@ package cli_test import ( + "path/filepath" "testing" "github.com/stretchr/testify/require" @@ -11,36 +12,31 @@ import ( func TestExpChatContextAdd(t *testing.T) { t.Parallel() - t.Run("RequiresWorkspaceOrDir", func(t *testing.T) { + t.Run("RequiresPathArgument", func(t *testing.T) { t.Parallel() + // `add` registers a context source identified by , so the path + // argument is required and a bare invocation is a usage error. inv, _ := clitest.New(t, "exp", "chat", "context", "add") err := inv.Run() require.Error(t, err) - require.Contains(t, err.Error(), "this command must be run inside a Coder workspace") + require.Contains(t, err.Error(), "wanted 1 args but got 0") }) - t.Run("AllowsExplicitDir", func(t *testing.T) { + t.Run("RequiresWorkspaceSocket", func(t *testing.T) { t.Parallel() - inv, _ := clitest.New(t, "exp", "chat", "context", "add", "--dir", t.TempDir()) + // Source registration talks to the agent over its local socket, so + // outside a workspace it fails to connect rather than silently doing + // nothing. Point at a socket path that does not exist so the dial + // fails deterministically (and never touches a real agent socket). + missingSocket := filepath.Join(t.TempDir(), "agent.sock") + inv, _ := clitest.New(t, "exp", "chat", "context", "add", t.TempDir(), + "--socket-path", missingSocket) err := inv.Run() - if err != nil { - require.NotContains(t, err.Error(), "this command must be run inside a Coder workspace") - } - }) - - t.Run("AllowsWorkspaceEnv", func(t *testing.T) { - t.Parallel() - - inv, _ := clitest.New(t, "exp", "chat", "context", "add") - inv.Environ.Set("CODER", "true") - - err := inv.Run() - if err != nil { - require.NotContains(t, err.Error(), "this command must be run inside a Coder workspace") - } + require.Error(t, err) + require.Contains(t, err.Error(), "inside the workspace") }) } diff --git a/coderd/coderd.go b/coderd/coderd.go index 601669d321076..50f40241332d6 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1789,6 +1789,7 @@ func New(options *Options) *API { r.Route("/experimental", func(r chi.Router) { r.Post("/chat-context", api.workspaceAgentAddChatContext) r.Delete("/chat-context", api.workspaceAgentClearChatContext) + r.Post("/chat-context/refresh", api.workspaceAgentRefreshChatContext) }) r.Route("/tasks/{task}", func(r chi.Router) { r.Post("/log-snapshot", api.postWorkspaceAgentTaskLogSnapshot) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 0dc91010ccfab..f4c0aa0c99074 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -2691,6 +2691,69 @@ func (api *API) workspaceAgentClearChatContext(rw http.ResponseWriter, r *http.R }) } +// workspaceAgentRefreshChatContext re-pins every drifted chat bound to the +// calling agent to the agent's latest context snapshot, clearing their +// drift markers. It backs the in-workspace `coder exp chat context refresh` +// (no chat argument), which uses the agent token rather than a user +// session, mirroring workspaceAgentClearChatContext's auth model. +// +// @x-apidocgen {"skip": true} +func (api *API) workspaceAgentRefreshChatContext(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + workspaceAgent := httpmw.WorkspaceAgent(r) + + // Chats are processed by the chat daemon; without it there is + // nothing to refresh. + if api.chatDaemon == nil { + httpapi.Write(ctx, rw, http.StatusOK, agentsdk.RefreshChatContextResponse{}) + return + } + + // Use system context for chat operations since the workspace agent + // scope does not include chat resources. + //nolint:gocritic // Agent needs system access to read/write chat resources. + sysCtx := dbauthz.AsSystemRestricted(ctx) + workspace, err := api.Database.GetWorkspaceByAgentID(sysCtx, workspaceAgent.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to determine workspace from agent token.", + Detail: err.Error(), + }) + return + } + + chats, err := api.Database.GetActiveChatsByAgentID(sysCtx, workspaceAgent.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to list chats for agent.", + Detail: err.Error(), + }) + return + } + + refreshed := 0 + for _, chat := range chats { + // Only re-pin chats owned by this workspace's owner that have + // drifted from the agent's latest snapshot. + if chat.OwnerID != workspace.OwnerID || !chat.ContextDirtySince.Valid { + continue + } + if _, err := api.chatDaemon.RefreshChatContext(sysCtx, chat); err != nil { + api.Logger.Warn(ctx, "failed to refresh chat context for agent", + slog.F("chat_id", chat.ID), + slog.F("agent_id", workspaceAgent.ID), + slog.Error(err), + ) + continue + } + refreshed++ + } + + httpapi.Write(ctx, rw, http.StatusOK, agentsdk.RefreshChatContextResponse{ + Refreshed: refreshed, + }) +} + var ( errNoActiveChats = xerrors.New("no active chats found") errChatNotFound = xerrors.New("chat not found") diff --git a/coderd/x/chatd/context_integration_test.go b/coderd/x/chatd/context_integration_test.go index b15ad1267b868..7e6092517941f 100644 --- a/coderd/x/chatd/context_integration_test.go +++ b/coderd/x/chatd/context_integration_test.go @@ -293,3 +293,130 @@ func TestChatContextDirtyFromAgentPush(t *testing.T) { require.NotNil(t, got.Context) require.False(t, got.Context.Dirty, "re-push of the pinned hash stays clean") } + +// TestChatContextRefreshFromAgentToken covers the in-workspace +// `coder exp chat context refresh` (no chat argument) path, which authenticates +// with the agent token instead of a user session. The agent endpoint re-pins +// every drifted chat bound to the calling agent to its latest snapshot and +// clears the drift marker, returning how many were refreshed. A chat bound to +// no agent must stay untouched, guarding the agent-scoped query. +func TestChatContextRefreshFromAgentToken(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: directChatRoutingDeploymentValues(t), + IncludeProvisionerDaemon: true, + }) + user := coderdtest.CreateFirstUser(t, client) + expClient := codersdk.NewExperimentalClient(client) + + // Build a workspace with an agent via the echo provisioner so the agent + // token is accepted by the agent middleware backing the endpoint. + agentToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ApplyComplete, + ProvisionGraph: echo.ProvisionGraphWithAgent(agentToken), + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + ws, err := client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + require.Len(t, ws.LatestBuild.Resources, 1) + require.Len(t, ws.LatestBuild.Resources[0].Agents, 1) + agentID := ws.LatestBuild.Resources[0].Agents[0].ID + + // A chat bound to the agent, plus an unrelated chat bound to no agent that + // must stay untouched by the agent-scoped refresh. + model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{}) + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + WorkspaceID: uuid.NullUUID{UUID: workspace.ID, Valid: true}, + AgentID: uuid.NullUUID{UUID: agentID, Valid: true}, + LastModelConfigID: model.ID, + Status: database.ChatStatusWaiting, + }) + otherChat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + LastModelConfigID: model.ID, + Status: database.ChatStatusWaiting, + }) + + agentsSource := "/home/coder/workspace/AGENTS.md" + instructionResource := func(content string, hash []byte) *agentproto.ContextResource { + return &agentproto.ContextResource{ + Source: agentsSource, + ContentHash: hash, + SizeBytes: uint64(len(content)), + Status: agentproto.ContextResource_OK, + Body: &agentproto.ContextResource_InstructionFile{ + InstructionFile: &agentproto.InstructionFileBody{Content: []byte(content)}, + }, + } + } + + // The agent token drives both the DRPC push and the REST refresh. + agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(agentToken)) + aAPI, _, err := agentClient.ConnectRPC210(ctx) + require.NoError(t, err) + defer func() { _ = aAPI.DRPCConn().Close() }() + + // Initial push hydrates the chat to a clean context. + resp, err := aAPI.PushContextState(ctx, &agentproto.PushContextStateRequest{ + Version: 1, + Initial: true, + AggregateHash: []byte{0x01}, + Resources: []*agentproto.ContextResource{instructionResource("hello-v1", []byte{0x11})}, + }) + require.NoError(t, err) + require.True(t, resp.GetAccepted()) + + // With nothing dirty, the agent-token refresh is a no-op. + refresh, err := agentClient.RefreshChatContext(ctx) + require.NoError(t, err) + require.Equal(t, 0, refresh.Refreshed, "no dirty chats to refresh") + + // A second push with a different hash drifts the bound chat dirty. + resp, err = aAPI.PushContextState(ctx, &agentproto.PushContextStateRequest{ + Version: 2, + AggregateHash: []byte{0x02}, + Resources: []*agentproto.ContextResource{instructionResource("hello-v2", []byte{0x22})}, + }) + require.NoError(t, err) + require.True(t, resp.GetAccepted()) + + got, err := expClient.GetChat(ctx, chat.ID) + require.NoError(t, err) + require.NotNil(t, got.Context) + require.True(t, got.Context.Dirty, "second push drifts the chat dirty") + + // The agent-token refresh re-pins every drifted chat bound to the agent. + refresh, err = agentClient.RefreshChatContext(ctx) + require.NoError(t, err) + require.Equal(t, 1, refresh.Refreshed, "the drifted chat is re-pinned") + + got, err = expClient.GetChat(ctx, chat.ID) + require.NoError(t, err) + require.NotNil(t, got.Context) + require.False(t, got.Context.Dirty, "refresh clears the dirty marker") + require.Len(t, got.Context.Resources, 1) + require.Equal(t, agentsSource, got.Context.Resources[0].Source) + + // The agent-less chat is never returned by the agent-scoped query, so it + // must stay unhydrated throughout. + other, err := expClient.GetChat(ctx, otherChat.ID) + require.NoError(t, err) + require.Nil(t, other.Context, "agent-less chat stays untouched") + + // A follow-up refresh with nothing dirty is a no-op again. + refresh, err = agentClient.RefreshChatContext(ctx) + require.NoError(t, err) + require.Equal(t, 0, refresh.Refreshed, "nothing left to refresh") +} diff --git a/codersdk/agentsdk/agentsdk.go b/codersdk/agentsdk/agentsdk.go index 815f175240925..27ca1135651f8 100644 --- a/codersdk/agentsdk/agentsdk.go +++ b/codersdk/agentsdk/agentsdk.go @@ -1021,6 +1021,13 @@ type ClearChatContextResponse struct { ChatID uuid.UUID `json:"chat_id"` } +// RefreshChatContextResponse is the response for refreshing chat context. +type RefreshChatContextResponse struct { + // Refreshed is the number of drifted chats that were re-pinned to the + // agent's latest context snapshot. + Refreshed int `json:"refreshed"` +} + // AddChatContext adds context-file and skill parts to an active chat. func (c *Client) AddChatContext(ctx context.Context, req AddChatContextRequest) (AddChatContextResponse, error) { res, err := c.SDK.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/me/experimental/chat-context", req) @@ -1052,3 +1059,22 @@ func (c *Client) ClearChatContext(ctx context.Context, req ClearChatContextReque var resp ClearChatContextResponse return resp, json.NewDecoder(res.Body).Decode(&resp) } + +// RefreshChatContext re-pins every drifted chat bound to this agent to the +// agent's latest context snapshot, clearing their drift markers. It backs +// the in-workspace `coder exp chat context refresh` (no chat argument), +// which authenticates with the agent token rather than a user session. +func (c *Client) RefreshChatContext(ctx context.Context) (RefreshChatContextResponse, error) { + res, err := c.SDK.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/me/experimental/chat-context/refresh", nil) + if err != nil { + return RefreshChatContextResponse{}, xerrors.Errorf("execute request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return RefreshChatContextResponse{}, codersdk.ReadBodyAsError(res) + } + + var resp RefreshChatContextResponse + return resp, json.NewDecoder(res.Body).Decode(&resp) +} From a96951b9dc01769f997f8f4d9f252f2f42a520ed Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 18 Jun 2026 04:44:44 +0000 Subject: [PATCH 20/20] fix(agent): exclude MCP resources from the chat-context drift hash --- agent/agentcontext/resolve.go | 35 +++++++++++++++++-- agent/agentcontext/resolve_test.go | 55 ++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/agent/agentcontext/resolve.go b/agent/agentcontext/resolve.go index 9e48b1722a294..96b1b76da1f86 100644 --- a/agent/agentcontext/resolve.go +++ b/agent/agentcontext/resolve.go @@ -168,7 +168,10 @@ func (r *Resolver) ResolveContext(ctx context.Context, roots []ScanRoot) Snapsho payloadBytes += uint64(len(r.Payload)) } - hash := ComputeAggregateHash(resources) + // The drift hash covers only pinned prompt content; MCP resources are + // excluded (see driftResources). Snapshot.Resources still carries the + // full set so MCP servers stay visible in the chat-context UI. + hash := ComputeAggregateHash(driftResources(resources)) snap := Snapshot{ Resources: resources, @@ -979,8 +982,12 @@ type Snapshot struct { Version uint64 // AggregateHash is sha256 over a canonical encoding of // (ID, Kind, Source, ContentHash, Status) for every - // resource. Identical inputs always produce identical - // hashes; see ComputeAggregateHash. + // drift-relevant resource. MCP resources (KindMCPConfig and + // KindMCPServer) are excluded because they describe live, + // agent-global runtime capabilities discovered at turn time, + // not pinned prompt content; see driftResources. Identical + // inputs always produce identical hashes; see + // ComputeAggregateHash. AggregateHash [32]byte // Resources is sorted by ID for deterministic encoding. Resources []Resource @@ -993,6 +1000,28 @@ type Snapshot struct { SnapshotError string } +// driftResources returns the subset of resources that participate in +// chat-context drift detection. MCP resources (the .mcp.json config and +// connected MCP servers) are deliberately excluded: an agent connects to +// its MCP servers asynchronously after startup, and the chat model +// discovers their tools live at turn time (coderd's ListMCPTools path), +// not from pinned prompt content. Hashing them would dirty an +// already-hydrated chat the moment a server finished connecting, even +// though nothing the user pinned changed. Instruction files and skills, +// whose content is pinned into the chat, stay drift-relevant. +func driftResources(resources []Resource) []Resource { + out := make([]Resource, 0, len(resources)) + for _, r := range resources { + switch r.Kind { + case KindMCPConfig, KindMCPServer: + continue + default: + out = append(out, r) + } + } + return out +} + // ComputeAggregateHash produces the deterministic snapshot // aggregate hash for the supplied resources. The caller does // not need to pre-sort; the function sorts a copy of the slice diff --git a/agent/agentcontext/resolve_test.go b/agent/agentcontext/resolve_test.go index eeb2a3c5a2749..67d055be52d95 100644 --- a/agent/agentcontext/resolve_test.go +++ b/agent/agentcontext/resolve_test.go @@ -428,6 +428,61 @@ func TestResolver_MCPProviderResources(t *testing.T) { require.Equal(t, "GitHub MCP server", got.Description) } +// TestResolver_MCPExcludedFromAggregateHash verifies MCP resources are +// surfaced in the snapshot but do not contribute to the drift hash. MCP +// servers connect asynchronously after agent startup and their tools are +// discovered live at turn time, so a server connecting (or changing its +// tools) must not dirty an already-hydrated chat. Pinned content +// (instruction files, skills) still drives the hash. +func TestResolver_MCPExcludedFromAggregateHash(t *testing.T) { + t.Parallel() + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "AGENTS.md"), "# Project rules\n\nDo the thing.") + roots := []agentcontext.ScanRoot{{Path: dir}} + + mcpRes := func(payload string) agentcontext.Resource { + return agentcontext.Resource{ + ID: "mcp_server:github", + Kind: agentcontext.KindMCPServer, + Source: "github", + Status: agentcontext.StatusOK, + Payload: []byte(payload), + ContentHash: sha256.Sum256([]byte(payload)), + Description: "GitHub MCP server", + } + } + + // No MCP servers connected yet (the moment right after startup). + noMCP := (&agentcontext.Resolver{}).Resolve(roots) + // A server has connected and contributes a resource. + withMCP := (&agentcontext.Resolver{ + MCP: &fakeMCPProvider{resources: []agentcontext.Resource{mcpRes("tools-v1")}}, + }).Resolve(roots) + // The same server later re-resolves with a different tool list. + withMCPChanged := (&agentcontext.Resolver{ + MCP: &fakeMCPProvider{resources: []agentcontext.Resource{mcpRes("tools-v2-added-a-tool")}}, + }).Resolve(roots) + + // The MCP resource is still surfaced for display. + gotMCP := findResource(t, withMCP.Resources, agentcontext.KindMCPServer, "github") + require.Equal(t, "GitHub MCP server", gotMCP.Description) + + // A server connecting (and later changing its tools) must not change the + // drift hash: it is identical with no MCP, with MCP, and with different + // MCP tools. + require.Equal(t, noMCP.AggregateHash, withMCP.AggregateHash, + "MCP server connecting must not change the drift hash") + require.Equal(t, withMCP.AggregateHash, withMCPChanged.AggregateHash, + "MCP tool changes must not change the drift hash") + + // Sanity: instruction file content still drives the hash (same path, + // different content). + mustWriteFile(t, filepath.Join(dir, "AGENTS.md"), "# Different rules\n") + changed := (&agentcontext.Resolver{}).Resolve(roots) + require.NotEqual(t, noMCP.AggregateHash, changed.AggregateHash, + "instruction file content must still drive the drift hash") +} + // TestResolver_MCPProviderRespectsAggregateByteCap guards the // contract that a single oversized MCP payload cannot blow past // MaxSnapshotBytes with StatusOK.