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).

<details>
<summary>Design notes</summary>

- 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 `"<server>__"` 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.
</details>

<details>
<summary>Testing</summary>

- `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`.
</details>

---
*This PR was created by Coder Agents on behalf of @kylecarbs.*
This commit is contained in:
Kyle Carberry
2026-06-22 10:00:15 -06:00
committed by GitHub
parent 401aa58eeb
commit c0f854c289
10 changed files with 1319 additions and 153 deletions
+94
View File
@@ -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"
}
}
},
+83
View File
@@ -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"
}
}
},
+37 -1
View File
@@ -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,
@@ -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{
+211 -8
View File
@@ -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
// "<server>__"; 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
}
}
@@ -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 "<server>__" prefix the agent adds.
Body: mustMarshalContextBody(t, &agentproto.MCPServerBody{
ServerName: "github",
Tools: []*agentproto.MCPTool{
{Name: "github__create", Description: "Create an issue"},
{Name: "github__search", Description: "Search code"},
},
}),
},
}
out := pinnedContextResources(resources)
require.Equal(t, []codersdk.ChatContextResource{
{
Source: "/home/coder/.mcp.json",
Kind: codersdk.ChatContextResourceKindMCPConfig,
SizeBytes: 670,
Status: codersdk.ChatContextResourceStatusOK,
},
{
Source: "github",
Kind: codersdk.ChatContextResourceKindMCPServer,
SizeBytes: 12,
Status: codersdk.ChatContextResourceStatusOK,
// Tool names are reported with the "github__" prefix stripped.
Tools: []codersdk.ChatContextTool{
{Name: "create", Description: "Create an issue"},
{Name: "search", Description: "Search code"},
},
},
}, out)
})
}
func TestContextResources(t *testing.T) {
t.Parallel()
t.Run("ReturnsPinnedResources", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
chatID := uuid.New()
db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chatID).
Return([]database.ChatContextResource{
instructionResource(t, "/home/coder/AGENTS.md", "be helpful", database.WorkspaceAgentContextResourceStatusOk),
}, nil)
server := newPinServer(t, db)
resources, err := server.ContextResources(context.Background(), database.Chat{ID: chatID})
require.NoError(t, err)
require.Len(t, resources, 1)
require.Equal(t, "/home/coder/AGENTS.md", resources[0].Source)
require.Equal(t, codersdk.ChatContextResourceKindInstructionFile, resources[0].Kind)
})
t.Run("PinnedListError", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
chatID := uuid.New()
db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chatID).
Return(nil, xerrors.New("boom"))
server := newPinServer(t, db)
_, err := server.ContextResources(context.Background(), database.Chat{ID: chatID})
require.Error(t, err)
})
}