From c0f854c28909a0e05173302908a36134f7e8d08e Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 22 Jun 2026 10:00:15 -0600 Subject: [PATCH] feat: report pinned chat context resources on chat API (#26570) Surfaces a chat's pinned workspace-context resources on the single-chat GET and refresh responses, so clients can show *what* context the prompt was built from, not just whether it drifted. ## What's included - **codersdk**: `ChatContextResource` (plus `ChatContextResourceKind` and `ChatContextResourceStatus`) and `ChatContextMCPTool`, and a new `Chat.Context.Resources` field (metadata only, no bodies). It is populated only on the single-chat GET/refresh response; list and watch payloads stay nil to remain lightweight. - **coderd/x/chatd**: `Server.ContextResources`, which builds the metadata-only list from the chat's pinned `chat_context_resources` rows. Non-OK resources (invalid / unreadable / oversize / excluded) are reported with their status and error so the UI can explain why a resource was dropped from the prompt instead of silently omitting it. The shared protojson body decoders are extracted so the prompt and detail paths reuse them. - **coderd**: `getChat` and `refreshChatContext` enrich the response with the resource list. Failures are non-fatal (the chat stays usable without the detail). ## Scope / what's deferred This is an incremental split from #26466. This PR reports only the **resource inventory**. The pinned-context drift *diff* (the per-source `changes` set and the "View changes" dialog) is intentionally deferred to a later split; the existing `dirty` bit already signals that context changed. MCP resources are reported for display only; they are not injected into the prompt (a future RFC item).
Design notes - The resource list is the chat's full pinned inventory (instruction files, skills, and MCP configs/servers), preserving the query's `source ASC` order. OK-but-empty instruction files, OK skills with no name, and untracked kinds (reserved plugin/hook/subagent/command) are skipped. - MCP tool names are reported with the agent's `"__"` prefix stripped so they read as the server exposes them. - The detail is computed on read and attached only on the single-chat GET and refresh responses; list and watch payloads omit it to stay lightweight. - `refreshChatContext` enriches its own response (mirroring `getChat`) so the client reflects a refresh immediately, without a full reload.
Testing - `go test ./coderd/x/chatd/ -run 'TestPinnedContextResources|TestContextResources|TestChatContextDirtyFromAgentPush'` (unit + integration on embedded Postgres) passes. The integration test exercises the GET and refresh enrichment end-to-end. - `go build`, `go vet`, `golangci-lint`, and `gofmt` are clean. - `make gen` regenerated `apidoc`, `swagger.json`, `docs/reference/api/*`, and `typesGenerated.ts`.
--- *This PR was created by Coder Agents on behalf of @kylecarbs.* --- coderd/apidoc/docs.go | 94 ++++ coderd/apidoc/swagger.json | 83 +++ coderd/exp_chats.go | 38 +- coderd/x/chatd/context_integration_test.go | 36 ++ coderd/x/chatd/context_prompt.go | 219 +++++++- .../x/chatd/context_prompt_internal_test.go | 158 ++++++ codersdk/chats.go | 65 +++ docs/reference/api/chats.md | 519 +++++++++++++----- docs/reference/api/schemas.md | 164 +++++- site/src/api/typesGenerated.ts | 96 ++++ 10 files changed, 1319 insertions(+), 153 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index c4ca12fa33..3ddd4fdd33 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -16791,6 +16791,100 @@ 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": { + "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" + }, + "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, 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, tools) are empty.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.ChatContextResourceStatus" + } + ] + }, + "tools": { + "description": "Tools lists the tools exposed by an MCP server. Populated only for the\nmcp_server kind; nil otherwise.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatContextTool" + } + } + } + }, + "codersdk.ChatContextResourceKind": { + "type": "string", + "enum": [ + "instruction_file", + "skill", + "mcp_config", + "mcp_server" + ], + "x-enum-varnames": [ + "ChatContextResourceKindInstructionFile", + "ChatContextResourceKindSkill", + "ChatContextResourceKindMCPConfig", + "ChatContextResourceKindMCPServer" + ] + }, + "codersdk.ChatContextResourceStatus": { + "type": "string", + "enum": [ + "ok", + "oversize", + "unreadable", + "invalid", + "excluded" + ], + "x-enum-varnames": [ + "ChatContextResourceStatusOK", + "ChatContextResourceStatusOversize", + "ChatContextResourceStatusUnreadable", + "ChatContextResourceStatusInvalid", + "ChatContextResourceStatusExcluded" + ] + }, + "codersdk.ChatContextTool": { + "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" } } }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 04112e4cfa..e089738cd3 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -15085,6 +15085,89 @@ "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": { + "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" + }, + "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, 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, tools) are empty.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.ChatContextResourceStatus" + } + ] + }, + "tools": { + "description": "Tools lists the tools exposed by an MCP server. Populated only for the\nmcp_server kind; nil otherwise.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatContextTool" + } + } + } + }, + "codersdk.ChatContextResourceKind": { + "type": "string", + "enum": ["instruction_file", "skill", "mcp_config", "mcp_server"], + "x-enum-varnames": [ + "ChatContextResourceKindInstructionFile", + "ChatContextResourceKindSkill", + "ChatContextResourceKindMCPConfig", + "ChatContextResourceKindMCPServer" + ] + }, + "codersdk.ChatContextResourceStatus": { + "type": "string", + "enum": ["ok", "oversize", "unreadable", "invalid", "excluded"], + "x-enum-varnames": [ + "ChatContextResourceStatusOK", + "ChatContextResourceStatusOversize", + "ChatContextResourceStatusUnreadable", + "ChatContextResourceStatusInvalid", + "ChatContextResourceStatusExcluded" + ] + }, + "codersdk.ChatContextTool": { + "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" } } }, diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 5962ef3319..a025e48b5f 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -2042,6 +2042,23 @@ 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 (metadata only). 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, err := api.chatDaemon.ContextResources(ctx, chat) + if err != nil { + api.Logger.Error(ctx, "failed to compute chat context resources", + slog.F("chat_id", chat.ID), + slog.Error(err), + ) + } else { + sdkChat.Context.Resources = resources + } + } + // For root chats, embed children so callers get a complete // tree in a single response. if !chat.ParentChatID.Valid { @@ -2647,7 +2664,26 @@ 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 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. 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, err := api.chatDaemon.ContextResources(ctx, updated) + if err != nil { + api.Logger.Error(ctx, "failed to compute chat context resources after refresh", + slog.F("chat_id", updated.ID), + slog.Error(err), + ) + } else { + sdkChat.Context.Resources = resources + } + } + + 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 75c8e197be..b6307f6197 100644 --- a/coderd/x/chatd/context_integration_test.go +++ b/coderd/x/chatd/context_integration_test.go @@ -133,6 +133,15 @@ func TestChatContextDirtyFromAgentPush(t *testing.T) { return out } + // Index the GET-only context resources 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 + } + // 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 +169,11 @@ 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. + 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) + // 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 +207,10 @@ 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. + require.Len(t, got.Context.Resources, 1, "resources stay pinned while dirty") + require.Equal(t, agentsSource, got.Context.Resources[0].Source) + // 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) @@ -207,6 +225,16 @@ 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, 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) + // 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") @@ -219,6 +247,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. + 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) + // 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 140055e6b2..6d4b47ba15 100644 --- a/coderd/x/chatd/context_prompt.go +++ b/coderd/x/chatd/context_prompt.go @@ -2,6 +2,8 @@ package chatd import ( "context" + "encoding/json" + "strings" "golang.org/x/xerrors" "google.golang.org/protobuf/encoding/protojson" @@ -18,6 +20,84 @@ 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 +} + +// 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.ChatContextTool { + 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.ChatContextTool, 0, len(tools)) + for _, t := range tools { + name := strings.TrimPrefix(t.GetName(), prefix) + if name == "" { + continue + } + out = append(out, codersdk.ChatContextTool{ + Name: name, + Description: t.GetDescription(), + }) + } + if len(out) == 0 { + return nil + } + return out +} + +// decodeInstructionContent decodes an instruction-file resource body and +// returns its sanitized content. decoded is false when the body cannot be +// decoded, letting the prompt path count it as malformed; content is empty +// when the file sanitizes to nothing, in which case callers skip it. Shared by +// the prompt builder and the API resource listing so both interpret an +// instruction file the same way. +func decodeInstructionContent(body json.RawMessage) (content string, decoded bool) { + decodedBody, ok := decodeInstructionFileBody(body) + if !ok { + return "", false + } + return SanitizePromptText(string(decodedBody.GetContent())), true +} + +// decodeSkillIdentity decodes a skill resource body and returns its name and +// description. decoded is false when the body cannot be decoded, letting the +// prompt path count it as malformed; callers skip a skill with an empty name. +// Shared by the prompt builder and the API resource listing. +func decodeSkillIdentity(body json.RawMessage) (name, description string, decoded bool) { + decodedBody, ok := decodeSkillMetaBody(body) + if !ok { + return "", "", false + } + return decodedBody.GetName(), decodedBody.GetDescription(), 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,12 +207,11 @@ func contextResourcesToPrompt( } switch r.BodyKind { case database.WorkspaceAgentContextBodyKindInstructionFile: - var body agentproto.InstructionFileBody - if err := contextBodyUnmarshalOptions.Unmarshal(r.Body, &body); err != nil { + content, decoded := decodeInstructionContent(r.Body) + if !decoded { malformed++ continue } - content := SanitizePromptText(string(body.GetContent())) if content == "" { continue } @@ -142,12 +221,12 @@ func contextResourcesToPrompt( ContextFileContent: content, }) case database.WorkspaceAgentContextBodyKindSkill: - var body agentproto.SkillMetaBody - if err := contextBodyUnmarshalOptions.Unmarshal(r.Body, &body); err != nil { + name, description, decoded := decodeSkillIdentity(r.Body) + if !decoded { malformed++ continue } - if body.GetName() == "" { + if name == "" { continue } // source is the skill directory. MetaFile is left empty so @@ -156,8 +235,8 @@ func contextResourcesToPrompt( // 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(), + Name: name, + Description: description, Dir: r.Source, }) } @@ -168,3 +247,127 @@ func contextResourcesToPrompt( } return formatSystemInstructions(operatingSystem, directory, contextFileParts), skills, malformed } + +// ContextResources returns the chat's pinned context resource list (metadata +// only). It is read-only and intended for the single-chat GET handler; list +// and watch payloads omit this detail to stay lightweight. +// +// The returned list is the chat's full pinned inventory (instruction files, +// skills, and MCP configs/servers), each stamped with its per-resource status +// so the UI can explain why a resource was dropped from the prompt instead of +// silently omitting it. +func (server *Server) ContextResources( + ctx context.Context, + chat database.Chat, +) ([]codersdk.ChatContextResource, error) { + pinned, err := server.db.ListChatContextResourcesByChatID(ctx, chat.ID) + if err != nil { + return nil, xerrors.Errorf("list chat context resources: %w", err) + } + resources := pinnedContextResources(pinned) + server.logger.Debug(ctx, "computed chat context resources", + slog.F("chat_id", chat.ID), + slog.F("resource_count", len(resources)), + ) + return resources, nil +} + +// pinnedContextResources converts a chat's pinned context rows into the +// metadata-only resource list reported on the chat. It is the reporting +// counterpart to contextResourcesToPrompt: both walk the same rows and share +// the body decoders, but where the prompt builder keeps only OK instruction +// files and skills (and ignores everything else), this surfaces the full +// 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: + content, decoded := decodeInstructionContent(r.Body) + if !decoded || content == "" { + continue + } + out = append(out, codersdk.ChatContextResource{ + Source: r.Source, + Kind: kind, + SizeBytes: r.SizeBytes, + Status: codersdk.ChatContextResourceStatusOK, + }) + case database.WorkspaceAgentContextBodyKindSkill: + name, description, decoded := decodeSkillIdentity(r.Body) + if !decoded || name == "" { + continue + } + out = append(out, codersdk.ChatContextResource{ + Source: r.Source, + Kind: kind, + SizeBytes: r.SizeBytes, + Status: codersdk.ChatContextResourceStatusOK, + SkillName: name, + SkillDescription: description, + }) + case database.WorkspaceAgentContextBodyKindMcpConfig: + out = append(out, codersdk.ChatContextResource{ + Source: r.Source, + Kind: kind, + SizeBytes: r.SizeBytes, + Status: codersdk.ChatContextResourceStatusOK, + }) + case database.WorkspaceAgentContextBodyKindMcpServer: + out = append(out, codersdk.ChatContextResource{ + Source: r.Source, + Kind: kind, + SizeBytes: r.SizeBytes, + Status: codersdk.ChatContextResourceStatusOK, + Tools: mcpToolsFromServerBody(r.Source, r.Body), + }) + } + } + return out +} + +// 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. +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_prompt_internal_test.go b/coderd/x/chatd/context_prompt_internal_test.go index b381366570..1e41124f57 100644 --- a/coderd/x/chatd/context_prompt_internal_test.go +++ b/coderd/x/chatd/context_prompt_internal_test.go @@ -542,3 +542,161 @@ func TestResolveTurnWorkspaceContext(t *testing.T) { require.Error(t, err) }) } + +func TestPinnedContextResources(t *testing.T) { + t.Parallel() + + t.Run("InstructionAndSkillMetadata", func(t *testing.T) { + t.Parallel() + + resources := []database.ChatContextResource{ + instructionResource(t, "/home/coder/AGENTS.md", "be helpful", database.WorkspaceAgentContextResourceStatusOk), + skillResource(t, "/home/coder/.coder/skills/deploy", "deploy", "Deploy the app", database.WorkspaceAgentContextResourceStatusOk), + } + // instructionResource/skillResource leave SizeBytes zero; set one to + // confirm it is carried through. + resources[0].SizeBytes = 10 + + out := pinnedContextResources(resources) + require.Len(t, out, 2) + + require.Equal(t, codersdk.ChatContextResource{ + Source: "/home/coder/AGENTS.md", + Kind: codersdk.ChatContextResourceKindInstructionFile, + SizeBytes: 10, + Status: codersdk.ChatContextResourceStatusOK, + }, out[0]) + + require.Equal(t, codersdk.ChatContextResource{ + Source: "/home/coder/.coder/skills/deploy", + Kind: codersdk.ChatContextResourceKindSkill, + Status: codersdk.ChatContextResourceStatusOK, + SkillName: "deploy", + SkillDescription: "Deploy the app", + }, out[1]) + }) + + t.Run("SkipsOKButEmpty", func(t *testing.T) { + t.Parallel() + + resources := []database.ChatContextResource{ + // OK instruction file with empty content. + instructionResource(t, "/b/AGENTS.md", "", database.WorkspaceAgentContextResourceStatusOk), + // OK skill with no name. + skillResource(t, "/c/skills/x", "", "no name", database.WorkspaceAgentContextResourceStatusOk), + } + require.Empty(t, pinnedContextResources(resources)) + }) + + t.Run("IncludesNonOKWithError", func(t *testing.T) { + t.Parallel() + + oversize := instructionResource(t, "/a/AGENTS.md", "ignored", database.WorkspaceAgentContextResourceStatusOversize) + oversize.SizeBytes = 999 + oversize.Error = "file size exceeds cap" + invalidSkill := skillResource(t, "/c/skills/moo", "", "", database.WorkspaceAgentContextResourceStatusInvalid) + invalidSkill.Error = `front-matter name "x" does not match directory "moo"` + resources := []database.ChatContextResource{oversize, invalidSkill} + + out := pinnedContextResources(resources) + require.Equal(t, []codersdk.ChatContextResource{ + { + Source: "/a/AGENTS.md", + Kind: codersdk.ChatContextResourceKindInstructionFile, + SizeBytes: 999, + Status: codersdk.ChatContextResourceStatusOversize, + Error: "file size exceeds cap", + }, + { + Source: "/c/skills/moo", + Kind: codersdk.ChatContextResourceKindSkill, + Status: codersdk.ChatContextResourceStatusInvalid, + Error: `front-matter name "x" does not match directory "moo"`, + }, + }, out) + }) + + t.Run("IncludesMCPConfigAndServer", func(t *testing.T) { + t.Parallel() + + resources := []database.ChatContextResource{ + { + Source: "/home/coder/.mcp.json", + BodyKind: database.WorkspaceAgentContextBodyKindMcpConfig, + Status: database.WorkspaceAgentContextResourceStatusOk, + SizeBytes: 670, + }, + { + Source: "github", + BodyKind: database.WorkspaceAgentContextBodyKindMcpServer, + Status: database.WorkspaceAgentContextResourceStatusOk, + SizeBytes: 12, + // Tool names carry the "__" prefix the agent adds. + Body: mustMarshalContextBody(t, &agentproto.MCPServerBody{ + ServerName: "github", + Tools: []*agentproto.MCPTool{ + {Name: "github__create", Description: "Create an issue"}, + {Name: "github__search", Description: "Search code"}, + }, + }), + }, + } + out := pinnedContextResources(resources) + require.Equal(t, []codersdk.ChatContextResource{ + { + Source: "/home/coder/.mcp.json", + Kind: codersdk.ChatContextResourceKindMCPConfig, + SizeBytes: 670, + Status: codersdk.ChatContextResourceStatusOK, + }, + { + Source: "github", + Kind: codersdk.ChatContextResourceKindMCPServer, + SizeBytes: 12, + Status: codersdk.ChatContextResourceStatusOK, + // Tool names are reported with the "github__" prefix stripped. + Tools: []codersdk.ChatContextTool{ + {Name: "create", Description: "Create an issue"}, + {Name: "search", Description: "Search code"}, + }, + }, + }, out) + }) +} + +func TestContextResources(t *testing.T) { + t.Parallel() + + t.Run("ReturnsPinnedResources", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + chatID := uuid.New() + db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chatID). + Return([]database.ChatContextResource{ + instructionResource(t, "/home/coder/AGENTS.md", "be helpful", database.WorkspaceAgentContextResourceStatusOk), + }, nil) + server := newPinServer(t, db) + + resources, err := server.ContextResources(context.Background(), database.Chat{ID: chatID}) + require.NoError(t, err) + require.Len(t, resources, 1) + require.Equal(t, "/home/coder/AGENTS.md", resources[0].Source) + require.Equal(t, codersdk.ChatContextResourceKindInstructionFile, resources[0].Kind) + }) + + t.Run("PinnedListError", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + chatID := uuid.New() + db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chatID). + Return(nil, xerrors.New("boom")) + server := newPinServer(t, db) + + _, err := server.ContextResources(context.Background(), database.Chat{ID: chatID}) + require.Error(t, err) + }) +} diff --git a/codersdk/chats.go b/codersdk/chats.go index 0005b5f1d1..dcccc09091 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -168,6 +168,71 @@ 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"` +} + +// 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" + ChatContextResourceKindMCPConfig ChatContextResourceKind = "mcp_config" + ChatContextResourceKindMCPServer ChatContextResourceKind = "mcp_server" +) + +// 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, 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. + 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"` + // Tools lists the tools exposed by an MCP server. Populated only for the + // mcp_server kind; nil otherwise. + Tools []ChatContextTool `json:"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, 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" +) + +// ChatContextTool is one tool exposed by a pinned MCP server, reported on the +// single-chat GET response. Metadata only; the input schema is omitted. +type ChatContextTool 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"` } // ChatFileMetadata contains lightweight metadata about a file diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index 76bb91445c..2259f82e2a 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -41,7 +41,24 @@ Experimental: this endpoint is subject to change. "context": { "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok", + "tools": [ + { + "description": "string", + "name": "string" + } + ] + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -188,130 +205,141 @@ 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. | +| `»» 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. | +| `»»» 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 | | | +| `»»» 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](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, tools) are empty. | +| `»»» tools` | array | false | | 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. | +| `» 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`, `mcp_config`, `mcp_server`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `skill`, `stream_silence_timeout`, `timeout`, `usage_limit` | +| `status` | `completed`, `error`, `excluded`, `interrupting`, `invalid`, `ok`, `oversize`, `paused`, `pending`, `requires_action`, `running`, `unreadable`, `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). @@ -396,7 +424,24 @@ Experimental: this endpoint is subject to change. "context": { "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok", + "tools": [ + { + "description": "string", + "name": "string" + } + ] + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -535,7 +580,24 @@ Experimental: this endpoint is subject to change. "context": { "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok", + "tools": [ + { + "description": "string", + "name": "string" + } + ] + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -825,7 +887,24 @@ Experimental: this endpoint is subject to change. "context": { "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok", + "tools": [ + { + "description": "string", + "name": "string" + } + ] + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -1018,7 +1097,24 @@ Experimental: this endpoint is subject to change. "context": { "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok", + "tools": [ + { + "description": "string", + "name": "string" + } + ] + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -1157,7 +1253,24 @@ Experimental: this endpoint is subject to change. "context": { "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok", + "tools": [ + { + "description": "string", + "name": "string" + } + ] + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -1387,7 +1500,24 @@ Experimental: this endpoint is subject to change. "context": { "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok", + "tools": [ + { + "description": "string", + "name": "string" + } + ] + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -1526,7 +1656,24 @@ Experimental: this endpoint is subject to change. "context": { "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok", + "tools": [ + { + "description": "string", + "name": "string" + } + ] + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -1754,7 +1901,24 @@ Experimental: this endpoint is subject to change. "context": { "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok", + "tools": [ + { + "description": "string", + "name": "string" + } + ] + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -1893,7 +2057,24 @@ Experimental: this endpoint is subject to change. "context": { "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok", + "tools": [ + { + "description": "string", + "name": "string" + } + ] + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -2688,7 +2869,24 @@ Experimental: this endpoint is subject to change. "context": { "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok", + "tools": [ + { + "description": "string", + "name": "string" + } + ] + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -2827,7 +3025,24 @@ Experimental: this endpoint is subject to change. "context": { "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok", + "tools": [ + { + "description": "string", + "name": "string" + } + ] + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -3380,7 +3595,24 @@ Experimental: this endpoint is subject to change. "context": { "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok", + "tools": [ + { + "description": "string", + "name": "string" + } + ] + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -3519,7 +3751,24 @@ Experimental: this endpoint is subject to change. "context": { "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok", + "tools": [ + { + "description": "string", + "name": "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 acecb1f60c..9b3e42de28 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2015,7 +2015,24 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "context": { "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok", + "tools": [ + { + "description": "string", + "name": "string" + } + ] + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -2154,7 +2171,24 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "context": { "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok", + "tools": [ + { + "description": "string", + "name": "string" + } + ] + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { @@ -2431,17 +2465,112 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in { "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok", + "tools": [ + { + "description": "string", + "name": "string" + } + ] + } + ] } ``` ### 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 | +|---------------|-----------------------------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `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 +{ + "error": "string", + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok", + "tools": [ + { + "description": "string", + "name": "string" + } + ] +} +``` + +### Properties + +| 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 | | | +| `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, tools) are empty. | +| `tools` | array of [codersdk.ChatContextTool](#codersdkchatcontexttool) | false | | Tools lists the tools exposed by an MCP server. Populated only for the mcp_server kind; nil otherwise. | + +## codersdk.ChatContextResourceKind + +```json +"instruction_file" +``` + +### Properties + +#### Enumerated Values + +| Value(s) | +|---------------------------------------------------------| +| `instruction_file`, `mcp_config`, `mcp_server`, `skill` | + +## codersdk.ChatContextResourceStatus + +```json +"ok" +``` + +### Properties + +#### Enumerated Values + +| Value(s) | +|-------------------------------------------------------| +| `excluded`, `invalid`, `ok`, `oversize`, `unreadable` | + +## codersdk.ChatContextTool + +```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.ChatDiffContents @@ -3867,7 +3996,24 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "context": { "dirty": true, "dirty_since": "2019-08-24T14:15:22Z", - "error": "string" + "error": "string", + "resources": [ + { + "error": "string", + "kind": "instruction_file", + "size_bytes": 0, + "skill_description": "string", + "skill_name": "string", + "source": "string", + "status": "ok", + "tools": [ + { + "description": "string", + "name": "string" + } + ] + } + ] }, "created_at": "2019-08-24T14:15:22Z", "diff_status": { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 9ed9d80e91..9bbc885540 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1687,6 +1687,13 @@ 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[]; } // From codersdk/chats.go @@ -1711,6 +1718,95 @@ 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, 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; + /** + * 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; + /** + * Tools lists the tools exposed by an MCP server. Populated only for the + * mcp_server kind; nil otherwise. + */ + readonly tools?: readonly ChatContextTool[]; + /** + * 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, 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 +export type ChatContextResourceKind = + | "instruction_file" + | "mcp_config" + | "mcp_server" + | "skill"; + +export const ChatContextResourceKinds: ChatContextResourceKind[] = [ + "instruction_file", + "mcp_config", + "mcp_server", + "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 +/** + * ChatContextTool 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 ChatContextTool { + /** + * 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 /** * ChatCostChatBreakdown contains per-root-chat cost aggregation.