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