feat: serve plugin skills in chatd

Skills pushed by the workspace agent with a plugin_name now reach the chat
as plugin-source skills. The prompt index lists them as
"- <alias> (plugin: <name>): <description>", collisions qualify them as
plugin/<plugin>/<name>, and read_skill and read_skill_file serve them from
the pinned SKILL.md and the skill directory like workspace skills.
The skill tools resolve a name to its pinned row by (source, plugin,
name), so two plugins shipping the same skill name resolve to their own
bodies.

Pinned skill names must match the kebab-case skill grammar; rows that
fail are dropped and logged with their sources. Plugin names are not
re-validated on read, since the Agent API rejects a push carrying an
invalid one.

Plugin skill descriptions reach <available-skills> unmodified, as
workspace skill descriptions do on main; sanitizing descriptions from
every source is tracked in CODAGT-1337.

The chatd architecture document gains a Skill index section covering the
block, bare and qualified aliases, the skill tools and plugin skills.

codersdk.ChatContextResource gains PluginName and the plugin kind. The
listing sets PluginName on non-OK skill and mcp_server rows as well as
OK ones, and copies the agent's Error onto OK rows so spec-mandated
manifest warnings (unknown fields, non-object extensions, an ignored
mcp.json or skills directory) reach the API instead of only the
database.

The frontend gains only a "plugin manifest" label for the new kind; the
composer and context indicator changes ship separately.

An integration test runs a real workspace agent against a scan root with
a plugin skill and a plain skill, and checks the pinned rows, the
GET /chats/{id} context listing, and the system prompt, with the
agent-plugins experiment on and off.
This commit is contained in:
Scott Miller
2026-10-15 02:13:14 +00:00
parent 2e1b267b40
commit 258d6cbb9a
17 changed files with 1301 additions and 501 deletions
+10 -4
View File
@@ -21239,7 +21239,7 @@ const docTemplate = `{
"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.",
"description": "Resources lists the chat's pinned context resources of the kinds\nChatContextResourceKind describes, 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"
@@ -21257,6 +21257,10 @@ const docTemplate = `{
"kind": {
"$ref": "#/definitions/codersdk.ChatContextResourceKind"
},
"plugin_name": {
"description": "PluginName is the owning Agent Plugin's name. Set on OK plugin rows\nand on skill and mcp_server rows shipped by a plugin, in any status.",
"type": "string"
},
"size_bytes": {
"description": "SizeBytes is the original payload size in bytes.",
"type": "integer"
@@ -21269,7 +21273,7 @@ const docTemplate = `{
"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.",
"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, the server name for an MCP server\n(\"\u003cplugin_name\u003e/\u003cserver_name\u003e\" for a plugin's server), or the plugin\nroot directory for a plugin.",
"type": "string"
},
"status": {
@@ -21295,13 +21299,15 @@ const docTemplate = `{
"instruction_file",
"skill",
"mcp_config",
"mcp_server"
"mcp_server",
"plugin"
],
"x-enum-varnames": [
"ChatContextResourceKindInstructionFile",
"ChatContextResourceKindSkill",
"ChatContextResourceKindMCPConfig",
"ChatContextResourceKindMCPServer"
"ChatContextResourceKindMCPServer",
"ChatContextResourceKindPlugin"
]
},
"codersdk.ChatContextResourceStatus": {
+15 -4
View File
@@ -19141,7 +19141,7 @@
"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.",
"description": "Resources lists the chat's pinned context resources of the kinds\nChatContextResourceKind describes, 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"
@@ -19159,6 +19159,10 @@
"kind": {
"$ref": "#/definitions/codersdk.ChatContextResourceKind"
},
"plugin_name": {
"description": "PluginName is the owning Agent Plugin's name. Set on OK plugin rows\nand on skill and mcp_server rows shipped by a plugin, in any status.",
"type": "string"
},
"size_bytes": {
"description": "SizeBytes is the original payload size in bytes.",
"type": "integer"
@@ -19171,7 +19175,7 @@
"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.",
"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, the server name for an MCP server\n(\"\u003cplugin_name\u003e/\u003cserver_name\u003e\" for a plugin's server), or the plugin\nroot directory for a plugin.",
"type": "string"
},
"status": {
@@ -19193,12 +19197,19 @@
},
"codersdk.ChatContextResourceKind": {
"type": "string",
"enum": ["instruction_file", "skill", "mcp_config", "mcp_server"],
"enum": [
"instruction_file",
"skill",
"mcp_config",
"mcp_server",
"plugin"
],
"x-enum-varnames": [
"ChatContextResourceKindInstructionFile",
"ChatContextResourceKindSkill",
"ChatContextResourceKindMCPConfig",
"ChatContextResourceKindMCPServer"
"ChatContextResourceKindMCPServer",
"ChatContextResourceKindPlugin"
]
},
"codersdk.ChatContextResourceStatus": {
+8
View File
@@ -1014,6 +1014,14 @@ Root chats in a project share durable memory; other chats have none. The agent s
Project instructions are loaded on every turn, so edits show up in existing chats on their next turn. They're loaded alongside the rest of the turn setup, sanitized again, and added to the system prompt by `buildSystemPrompt` as a `<project-instructions>` block, after the memory guidance and before the user prompt. Subagents get them from their root chat.
##### Skill index
<!-- TODO(jscottmiller): cover organization skills here: `org/<name>`, ordered after personal and before workspace skills. -->
Each turn, chatd merges the user's personal skills with the skills the workspace agent pushed and inserts an `<available-skills>` block into the system prompt, after the chat instruction and before the user prompt. Each entry is a skill alias and its description. A name held by one skill is listed bare; a name held by several is listed as `personal/<name>`, `workspace/<name>`, or `plugin/<plugin>/<name>`. The model passes an alias to `read_skill` to load the body, and to `read_skill_file` to read a supporting file of a workspace or plugin skill.
A pinned skill row with a plugin name is a plugin skill: a skill shipped in an Agent Plugin's `skills/` directory. chatd keeps plugin skills apart from plain workspace skills and identifies each by (plugin, name), so two plugins can ship skills with the same name. Plugin skills follow personal and workspace skills when a name is shared, ordered by plugin name. Each plugin skill entry carries a `(plugin: <name>)` label after its alias, and the qualified-alias hint lists the `plugin/<plugin>/<name>` form only when a plugin skill is listed that way. `read_skill` and `read_skill_file` serve a plugin skill from its own pinned directory and never fall back to a same-named workspace skill.
##### Reasoning effort
Model configs may carry a `reasoning_effort` config (`{default, max}`) inside `chat_model_configs.options`. Users select a per-turn effort when sending or editing a message; the value is stored on `chat_messages.reasoning_effort` and on `chat_queued_messages.reasoning_effort` for queued messages. Queued messages carry the value through promotion, and `chats.last_reasoning_effort` tracks the most recent message that set one, mirroring `last_model_config_id`.
-28
View File
@@ -2699,34 +2699,6 @@ type systemPromptBehaviorContext struct {
isRootChat bool
}
func workspaceSkillsForResolution(workspaceSkills []chattool.SkillMeta) []skillspkg.Skill {
if len(workspaceSkills) == 0 {
return nil
}
resolved := make([]skillspkg.Skill, 0, len(workspaceSkills))
for _, skill := range workspaceSkills {
resolved = append(resolved, skillspkg.Skill{
Name: skill.Name,
Description: skill.Description,
Source: skillspkg.SourceWorkspace,
})
}
return resolved
}
func mergeTurnSkills(
personalSkills []skillspkg.Skill,
organizationSkills []skillspkg.Skill,
workspaceSkills []chattool.SkillMeta,
) []skillspkg.ResolvedSkill {
return skillspkg.MergeSkills(
personalSkills,
organizationSkills,
workspaceSkillsForResolution(workspaceSkills),
nil,
)
}
// buildSystemPrompt applies system-level prompt injections in a fixed
// order: subagent instruction, chat instruction, skill index, memory index,
// project instructions, user prompt, then mode overlay prompts. Project
+2 -2
View File
@@ -1986,7 +1986,7 @@ func TestPersonalSkillsInSystemPrompt(t *testing.T) {
nil,
"",
"",
mergeTurnSkills(
chattool.MergePinnedSkills(
[]skillspkg.Skill{{
Name: "personal-review",
Description: "Personal review process",
@@ -2010,7 +2010,7 @@ func TestPersonalSkillsInSystemPrompt(t *testing.T) {
func TestPersonalAndWorkspaceSkillCollisionInSystemPrompt(t *testing.T) {
t.Parallel()
resolved := mergeTurnSkills(
resolved := chattool.MergePinnedSkills(
[]skillspkg.Skill{{
Name: "deploy",
Description: "Personal deployment process",
+85
View File
@@ -2961,6 +2961,91 @@ func TestSubscribeSnapshotIncludesStatusEvent(t *testing.T) {
require.NotNil(t, snapshot[statusIdx].Status)
}
// TestReadSkillResolvesPersonalSkill checks that read_skill serves a personal
// skill body by its index alias.
func TestReadSkillResolvesPersonalSkill(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitLong)
const (
skillName = "personal-review"
skillBody = "Follow the personal review checklist."
)
var streamedCallCount atomic.Int32
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
if !req.Stream {
return chattest.OpenAINonStreamingResponse("Personal skill test")
}
if streamedCallCount.Add(1) == 1 {
return chattest.OpenAIStreamingResponse(
chattest.OpenAIToolCallChunk("read_skill", `{"name":"`+skillName+`"}`),
)
}
return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("Done.")...)
})
user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL)
//nolint:gocritic // Test seeds a user-owned row; ctx carries no per-user actor.
_, err := db.InsertUserSkill(dbauthz.AsSystemRestricted(ctx), database.InsertUserSkillParams{
ID: uuid.New(),
UserID: user.ID,
Name: skillName,
Description: "Personal review process",
Content: "---\nname: " + skillName + "\ndescription: Personal review process\n---\n" + skillBody + "\n",
})
require.NoError(t, err)
factory := chattest.NewMockAIBridgeTransport(t, openAIURL)
server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) {
cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(factory)
})
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "personal-read-skill",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("Use my review skill."),
},
})
require.NoError(t, err)
var chatResult database.Chat
require.Eventually(t, func() bool {
got, getErr := db.GetChatByID(ctx, chat.ID)
if getErr != nil {
return false
}
chatResult = got
return got.Status == database.ChatStatusWaiting || got.Status == database.ChatStatusError
}, testutil.WaitLong, testutil.IntervalFast)
if chatResult.Status == database.ChatStatusError {
require.FailNowf(t, "chat run failed", "last_error=%q", chatLastErrorMessage(chatResult.LastError))
}
messages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ChatID: chat.ID})
require.NoError(t, err)
var toolMessage *database.ChatMessage
for i := range messages {
if messages[i].Role == database.ChatMessageRoleTool {
toolMessage = &messages[i]
break
}
}
require.NotNil(t, toolMessage, "read_skill should have produced a tool result")
parts, err := chatprompt.ParseContent(*toolMessage)
require.NoError(t, err)
require.Len(t, parts, 1)
require.Equal(t, "read_skill", parts[0].ToolName)
require.False(t, parts[0].IsError, string(parts[0].Result))
require.Contains(t, string(parts[0].Result), skillBody)
}
func TestPersistToolResultWithBinaryData(t *testing.T) {
t.Parallel()
+89 -82
View File
@@ -36,6 +36,8 @@ const (
type SkillMeta struct {
Name string
Description string
// PluginName is empty for a plain workspace skill.
PluginName string
// Dir is the absolute path to the skill directory inside
// the workspace filesystem.
Dir string
@@ -50,6 +52,33 @@ type SkillMeta struct {
Meta []byte
}
// MergePinnedSkills merges personal, organization, and pinned workspace and
// plugin skills into resolved aliases.
func MergePinnedSkills(personal, organization []skillspkg.Skill, pinned []SkillMeta) []skillspkg.ResolvedSkill {
workspace, plugin := splitPinnedSkills(pinned)
return skillspkg.MergeSkills(personal, organization, workspace, plugin)
}
func splitPinnedSkills(pinned []SkillMeta) (workspace, plugin []skillspkg.Skill) {
for _, skill := range pinned {
if skill.PluginName != "" {
plugin = append(plugin, skillspkg.Skill{
Name: skill.Name,
Description: skill.Description,
Source: skillspkg.SourcePlugin,
PluginName: skill.PluginName,
})
continue
}
workspace = append(workspace, skillspkg.Skill{
Name: skill.Name,
Description: skill.Description,
Source: skillspkg.SourceWorkspace,
})
}
return workspace, plugin
}
// SkillContent is the full body of a skill, loaded on demand
// when the model calls read_skill.
type SkillContent struct {
@@ -71,22 +100,31 @@ func FormatResolvedSkillIndex(resolved []skillspkg.ResolvedSkill) string {
entries := make([]skillIndexEntry, 0, len(resolved))
hasQualifiedAlias := false
hasWorkspaceSkill := false
hasQualifiedPluginAlias := false
hasFileBackedSkill := false
for _, s := range resolved {
entries = append(entries, skillIndexEntry{
entry := skillIndexEntry{
Alias: s.Alias,
Description: truncateSkillIndexDescription(s.Description),
})
if s.Source == skillspkg.SourceWorkspace {
hasWorkspaceSkill = true
}
if s.Source == skillspkg.SourcePlugin {
entry.PluginName = s.PluginName
}
entries = append(entries, entry)
if s.Source == skillspkg.SourceWorkspace || s.Source == skillspkg.SourcePlugin {
hasFileBackedSkill = true
}
if s.Alias == s.QualifiedAlias() {
hasQualifiedAlias = true
if s.Source == skillspkg.SourcePlugin {
hasQualifiedPluginAlias = true
}
}
}
return renderSkillIndex(entries, skillIndexFormatOptions{
includeQualifiedAliasInstruction: hasQualifiedAlias,
includeReadSkillFileInstruction: hasWorkspaceSkill,
includeQualifiedAliasInstruction: hasQualifiedAlias,
qualifiedAliasInstructionListsPlugin: hasQualifiedPluginAlias,
includeReadSkillFileInstruction: hasFileBackedSkill,
})
}
@@ -108,11 +146,14 @@ func truncateSkillIndexDescription(description string) string {
type skillIndexEntry struct {
Alias string
Description string
// PluginName is rendered as a "(plugin: <name>)" label after the alias.
PluginName string
}
type skillIndexFormatOptions struct {
includeQualifiedAliasInstruction bool
includeReadSkillFileInstruction bool
includeQualifiedAliasInstruction bool
qualifiedAliasInstructionListsPlugin bool
includeReadSkillFileInstruction bool
}
func renderSkillIndex(entries []skillIndexEntry, opts skillIndexFormatOptions) string {
@@ -129,19 +170,28 @@ func renderSkillIndex(entries []skillIndexEntry, opts skillIndexFormatOptions) s
if opts.includeReadSkillFileInstruction {
_, _ = b.WriteString(
"Use read_skill_file to read supporting files " +
"referenced by a workspace skill.\n",
"referenced by a workspace or plugin skill.\n",
)
}
if opts.includeQualifiedAliasInstruction {
aliasExamples := "personal/name"
if opts.qualifiedAliasInstructionListsPlugin {
aliasExamples = "personal/name or plugin/pluginname/name"
}
_, _ = b.WriteString(
"When a skill is listed with a source prefix, such as " +
"personal/name, pass that qualified alias to read_skill.\n",
aliasExamples + ", pass that qualified alias to read_skill.\n",
)
}
_, _ = b.WriteString("\n")
for _, s := range entries {
_, _ = b.WriteString("- ")
_, _ = b.WriteString(s.Alias)
if s.PluginName != "" {
_, _ = b.WriteString(" (plugin: ")
_, _ = b.WriteString(s.PluginName)
_, _ = b.WriteString(")")
}
if s.Description != "" {
_, _ = b.WriteString(": ")
_, _ = b.WriteString(s.Description)
@@ -311,7 +361,10 @@ const DefaultSkillMetaFile = "SKILL.md"
type ReadSkillOptions struct {
GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error)
GetSkills func() []SkillMeta
ResolveAlias func(string) (skillspkg.ResolvedSkill, error)
// PersonalSkills and OrganizationSkills are merged with GetSkills to
// resolve names and qualified aliases.
PersonalSkills []skillspkg.Skill
OrganizationSkills []skillspkg.Skill
// LoadStoredSkillBody loads the current body of a personal or
// organization skill. It returns skillspkg.ErrSkillNotFound when the
// skill is gone, disabled, or no longer readable by the chat owner.
@@ -340,7 +393,7 @@ func ReadSkill(options ReadSkillOptions) fantasy.AgentTool {
), nil
}
resolved, err := resolveSkillAlias(options, args.Name)
resolved, skill, err := resolveSkillAlias(options, args.Name)
if err != nil {
return skillResolveErrorResponse(args.Name, err), nil
}
@@ -366,10 +419,11 @@ func ReadSkill(options ReadSkillOptions) fantasy.AgentTool {
"body": content.Body,
"files": []string{},
}), nil
case skillspkg.SourceWorkspace:
content, response, ok := readWorkspaceSkillBody(ctx, options, args.Name, resolved.Name)
if ok {
return response, nil
case skillspkg.SourceWorkspace, skillspkg.SourcePlugin:
// The supporting file list is a best-effort live lookup.
content, err := loadPinnedWorkspaceSkillContent(ctx, options, skill)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
// Include the absolute skill directory so the agent can
// reach supporting files with read_file and execute.
@@ -412,22 +466,17 @@ func ReadSkillFile(options ReadSkillOptions) fantasy.AgentTool {
), nil
}
resolved, err := resolveSkillAlias(options, args.Name)
resolved, skill, err := resolveSkillAlias(options, args.Name)
if err != nil {
return skillResolveErrorResponse(args.Name, err), nil
}
if resolved.Source != skillspkg.SourceWorkspace {
if resolved.Source != skillspkg.SourceWorkspace && resolved.Source != skillspkg.SourcePlugin {
return fantasy.NewTextErrorResponse(fmt.Sprintf(
"read_skill_file is not supported for %s skills (no supporting files)",
resolved.Source,
)), nil
}
skill, ok := findSkill(options.GetSkills, resolved.Name)
if !ok {
return skillNotFoundResponse(args.Name), nil
}
// Validate the path early so we reject bad
// inputs before dialing the workspace agent.
if err := validateSkillFilePath(args.Path); err != nil {
@@ -464,57 +513,31 @@ func ReadSkillFile(options ReadSkillOptions) fantasy.AgentTool {
)
}
func resolveSkillAlias(options ReadSkillOptions, name string) (skillspkg.ResolvedSkill, error) {
if options.ResolveAlias != nil {
return options.ResolveAlias(name)
// resolveSkillAlias resolves name against the merged skill set and, for a
// workspace or plugin skill, returns its pinned metadata. Pinned duplicates
// resolve to the first entry.
func resolveSkillAlias(options ReadSkillOptions, name string) (skillspkg.ResolvedSkill, SkillMeta, error) {
var pinned []SkillMeta
if options.GetSkills != nil {
pinned = options.GetSkills()
}
skill, ok := findSkill(options.GetSkills, name)
if !ok {
return skillspkg.ResolvedSkill{}, skillspkg.ErrSkillNotFound
resolved, err := skillspkg.Lookup(MergePinnedSkills(options.PersonalSkills, options.OrganizationSkills, pinned), name)
if err != nil || (resolved.Source != skillspkg.SourceWorkspace && resolved.Source != skillspkg.SourcePlugin) {
return resolved, SkillMeta{}, err
}
return skillspkg.ResolvedSkill{
Skill: skillspkg.Skill{
Name: skill.Name,
Description: skill.Description,
Source: skillspkg.SourceWorkspace,
},
Alias: skill.Name,
}, nil
}
func readWorkspaceSkillBody(
ctx context.Context,
options ReadSkillOptions,
requestedName string,
canonicalName string,
) (SkillContent, fantasy.ToolResponse, bool) {
skill, ok := findSkill(options.GetSkills, canonicalName)
if !ok {
return SkillContent{}, skillNotFoundResponse(requestedName), true
for _, s := range pinned {
if s.Name == resolved.Name && s.PluginName == resolved.PluginName {
return resolved, s, nil
}
}
// The SKILL.md body travels in the workspace context snapshot, so it
// is served from the pin without dialing the workspace. The supporting
// file list is still a best-effort live lookup; see
// loadPinnedWorkspaceSkillContent.
content, err := loadPinnedWorkspaceSkillContent(ctx, options, skill)
if err != nil {
return SkillContent{}, fantasy.NewTextErrorResponse(err.Error()), true
}
return content, fantasy.ToolResponse{}, false
return resolved, SkillMeta{}, nil
}
func skillResolveErrorResponse(name string, err error) fantasy.ToolResponse {
if xerrors.Is(err, skillspkg.ErrSkillNotFound) {
return skillNotFoundResponse(name)
}
if xerrors.Is(err, skillspkg.ErrSkillAmbiguous) {
return fantasy.NewTextErrorResponse(err.Error())
}
return fantasy.NewTextErrorResponse(
fmt.Sprintf("failed to resolve skill %q", name),
)
return fantasy.NewTextErrorResponse(err.Error())
}
func skillNotFoundResponse(name string) fantasy.ToolResponse {
@@ -529,19 +552,3 @@ func nonNilFiles(files []string) []string {
}
return files
}
// findSkill looks up a skill by name in the current skill list.
func findSkill(
getSkills func() []SkillMeta,
name string,
) (SkillMeta, bool) {
if getSkills == nil {
return SkillMeta{}, false
}
for _, s := range getSkills() {
if s.Name == name {
return s, true
}
}
return SkillMeta{}, false
}
+230 -127
View File
@@ -20,6 +20,10 @@ import (
"github.com/coder/coder/v2/codersdk/workspacesdk/agentconnmock"
)
// skillFileReadLimit is the byte limit the skill tools pass to ReadFile:
// maxSkillFileBytes plus one, so an oversize file is detected.
const skillFileReadLimit = int64(512*1024 + 1)
// validSkillMD returns a valid SKILL.md with the given name and
// description.
func validSkillMD(name, description string) string {
@@ -86,7 +90,7 @@ func TestFormatResolvedSkillIndex(t *testing.T) {
assert.Equal(t,
"<available-skills>\n"+
"Use read_skill to load a skill's full instructions before following them.\n"+
"Use read_skill_file to read supporting files referenced by a workspace skill.\n"+
"Use read_skill_file to read supporting files referenced by a workspace or plugin skill.\n"+
"\n"+
"- deep-review: Review\n"+
"</available-skills>",
@@ -157,6 +161,48 @@ func TestFormatResolvedSkillIndex(t *testing.T) {
assert.Contains(t, idx, "- short: Short description\n")
assert.NotContains(t, idx, "tail")
})
t.Run("PluginSkillLabelAndHint", func(t *testing.T) {
t.Parallel()
resolved := []skillspkg.ResolvedSkill{{
Skill: skillspkg.Skill{
Name: "deploy",
Description: "Deploy via acme",
Source: skillspkg.SourcePlugin,
PluginName: "acme",
},
Alias: "deploy",
}}
assert.Equal(t,
"<available-skills>\n"+
"Use read_skill to load a skill's full instructions before following them.\n"+
"Use read_skill_file to read supporting files referenced by a workspace or plugin skill.\n"+
"\n"+
"- deploy (plugin: acme): Deploy via acme\n"+
"</available-skills>",
chattool.FormatResolvedSkillIndex(resolved),
)
})
t.Run("CollidingPluginNames", func(t *testing.T) {
t.Parallel()
resolved := skillspkg.MergeSkills(
nil,
nil,
[]skillspkg.Skill{{Name: "deploy", Description: "Workspace"}},
[]skillspkg.Skill{
{Name: "deploy", Description: "Acme", PluginName: "acme"},
{Name: "deploy", Description: "Beta", PluginName: "beta"},
},
)
idx := chattool.FormatResolvedSkillIndex(resolved)
assert.Contains(t, idx, "- workspace/deploy: Workspace")
assert.Contains(t, idx, "- plugin/acme/deploy (plugin: acme): Acme")
assert.Contains(t, idx, "- plugin/beta/deploy (plugin: beta): Beta")
assert.Contains(t, idx, "When a skill is listed with a source prefix, such as personal/name or plugin/pluginname/name, pass that qualified alias to read_skill.")
})
}
func TestLoadSkillFile(t *testing.T) {
@@ -177,7 +223,7 @@ func TestLoadSkillFile(t *testing.T) {
gomock.Any(),
"/work/.agents/skills/my-skill/roles/reviewer.md",
int64(0),
int64(512*1024+1),
skillFileReadLimit,
).Return(
io.NopCloser(strings.NewReader("review instructions")),
"text/markdown",
@@ -281,7 +327,7 @@ func TestLoadSkillFile(t *testing.T) {
gomock.Any(),
"/work/.agents/skills/my-skill/large.txt",
int64(0),
int64(512*1024+1),
skillFileReadLimit,
).Return(
io.NopCloser(strings.NewReader(bigContent)),
"text/plain",
@@ -383,17 +429,7 @@ func TestReadSkillTool(t *testing.T) {
t.Parallel()
tool := chattool.ReadSkill(chattool.ReadSkillOptions{
ResolveAlias: func(alias string) (skillspkg.ResolvedSkill, error) {
require.Equal(t, "my-skill", alias)
return skillspkg.ResolvedSkill{
Skill: skillspkg.Skill{
Name: "my-skill",
Description: "test",
Source: skillspkg.SourcePersonal,
},
Alias: "my-skill",
}, nil
},
PersonalSkills: personalSkillsForTest("my-skill"),
LoadStoredSkillBody: func(context.Context, skillspkg.Skill) (skillspkg.ParsedSkill, error) {
return skillspkg.ParsedSkill{
Skill: skillspkg.Skill{
@@ -425,17 +461,7 @@ func TestReadSkillTool(t *testing.T) {
var loadedName string
tool := chattool.ReadSkill(chattool.ReadSkillOptions{
ResolveAlias: func(alias string) (skillspkg.ResolvedSkill, error) {
require.Equal(t, "personal/my-skill", alias)
return skillspkg.ResolvedSkill{
Skill: skillspkg.Skill{
Name: "my-skill",
Description: "test",
Source: skillspkg.SourcePersonal,
},
Alias: "personal/my-skill",
}, nil
},
PersonalSkills: personalSkillsForTest("my-skill"),
LoadStoredSkillBody: func(_ context.Context, skill skillspkg.Skill) (skillspkg.ParsedSkill, error) {
loadedName = skill.Name
return skillspkg.ParsedSkill{
@@ -483,17 +509,6 @@ func TestReadSkillTool(t *testing.T) {
return conn, nil
},
GetSkills: func() []chattool.SkillMeta { return skills },
ResolveAlias: func(alias string) (skillspkg.ResolvedSkill, error) {
require.Equal(t, "workspace/my-skill", alias)
return skillspkg.ResolvedSkill{
Skill: skillspkg.Skill{
Name: "my-skill",
Description: "test",
Source: skillspkg.SourceWorkspace,
},
Alias: "workspace/my-skill",
}, nil
},
})
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
@@ -525,36 +540,12 @@ func TestReadSkillTool(t *testing.T) {
workspacesdk.LSResponse{}, nil,
)
resolveAlias := func(alias string) (skillspkg.ResolvedSkill, error) {
switch alias {
case "personal/deploy":
return skillspkg.ResolvedSkill{
Skill: skillspkg.Skill{
Name: "deploy",
Description: "personal deploy",
Source: skillspkg.SourcePersonal,
},
Alias: "personal/deploy",
}, nil
case "workspace/deploy":
return skillspkg.ResolvedSkill{
Skill: skillspkg.Skill{
Name: "deploy",
Description: "workspace deploy",
Source: skillspkg.SourceWorkspace,
},
Alias: "workspace/deploy",
}, nil
default:
return skillspkg.ResolvedSkill{}, skillspkg.ErrSkillNotFound
}
}
tool := chattool.ReadSkill(chattool.ReadSkillOptions{
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
return conn, nil
},
GetSkills: func() []chattool.SkillMeta { return workspaceSkills },
ResolveAlias: resolveAlias,
GetSkills: func() []chattool.SkillMeta { return workspaceSkills },
PersonalSkills: personalSkillsForTest("deploy"),
LoadStoredSkillBody: func(_ context.Context, skill skillspkg.Skill) (skillspkg.ParsedSkill, error) {
require.Equal(t, "deploy", skill.Name)
return skillspkg.ParsedSkill{
@@ -578,9 +569,6 @@ func TestReadSkillTool(t *testing.T) {
workspaceName := responseName(t, workspaceResp)
assert.Equal(t, "workspace/deploy", workspaceName)
assert.Equal(t, "/work/.agents/skills/deploy", responseDir(t, workspaceResp))
workspaceResolved, err := resolveAlias(workspaceName)
require.NoError(t, err)
assert.Equal(t, skillspkg.SourceWorkspace, workspaceResolved.Source)
personalResp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-2",
@@ -591,24 +579,23 @@ func TestReadSkillTool(t *testing.T) {
assert.False(t, personalResp.IsError)
personalName := responseName(t, personalResp)
assert.Equal(t, "personal/deploy", personalName)
personalResolved, err := resolveAlias(personalName)
require.NoError(t, err)
assert.Equal(t, skillspkg.SourcePersonal, personalResolved.Source)
assert.Contains(t, personalResp.Content, "Personal deploy instructions.")
_, err = resolveAlias("deploy")
require.ErrorIs(t, err, skillspkg.ErrSkillNotFound)
bareResp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-3",
Name: "read_skill",
Input: `{"name":"deploy"}`,
})
require.NoError(t, err)
assert.True(t, bareResp.IsError)
assert.Contains(t, bareResp.Content, "skill lookup is ambiguous")
})
t.Run("MissingPersonalSkill", func(t *testing.T) {
t.Parallel()
tool := chattool.ReadSkill(chattool.ReadSkillOptions{
ResolveAlias: func(alias string) (skillspkg.ResolvedSkill, error) {
return skillspkg.ResolvedSkill{
Skill: skillspkg.Skill{Name: alias, Source: skillspkg.SourcePersonal},
Alias: alias,
}, nil
},
PersonalSkills: personalSkillsForTest("missing-skill"),
LoadStoredSkillBody: func(context.Context, skillspkg.Skill) (skillspkg.ParsedSkill, error) {
return skillspkg.ParsedSkill{}, skillspkg.ErrSkillNotFound
},
@@ -628,12 +615,7 @@ func TestReadSkillTool(t *testing.T) {
t.Parallel()
tool := chattool.ReadSkill(chattool.ReadSkillOptions{
ResolveAlias: func(alias string) (skillspkg.ResolvedSkill, error) {
return skillspkg.ResolvedSkill{
Skill: skillspkg.Skill{Name: alias, Source: skillspkg.SourcePersonal},
Alias: alias,
}, nil
},
PersonalSkills: personalSkillsForTest("my-skill"),
LoadStoredSkillBody: func(context.Context, skillspkg.Skill) (skillspkg.ParsedSkill, error) {
return skillspkg.ParsedSkill{}, xerrors.New("synthetic private storage failure")
},
@@ -651,33 +633,10 @@ func TestReadSkillTool(t *testing.T) {
assert.NotContains(t, resp.Content, "synthetic private storage failure")
})
t.Run("ResolveAliasErrorIsSanitized", func(t *testing.T) {
t.Parallel()
tool := chattool.ReadSkill(chattool.ReadSkillOptions{
ResolveAlias: func(string) (skillspkg.ResolvedSkill, error) {
return skillspkg.ResolvedSkill{}, xerrors.New("synthetic private resolver failure")
},
})
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "read_skill",
Input: `{"name":"my-skill"}`,
})
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.Contains(t, resp.Content, `failed to resolve skill "my-skill"`)
assert.NotContains(t, resp.Content, "synthetic private resolver failure")
})
t.Run("AmbiguousLookupSurfacesAliases", func(t *testing.T) {
t.Parallel()
tool := chattool.ReadSkill(chattool.ReadSkillOptions{
ResolveAlias: ambiguousResolveAliasForTest,
})
tool := chattool.ReadSkill(ambiguousSkillOptionsForTest())
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
@@ -733,19 +692,170 @@ func TestReadSkillTool(t *testing.T) {
assert.True(t, resp.IsError)
assert.Contains(t, resp.Content, "required")
})
t.Run("NoSkillsConfigured", func(t *testing.T) {
t.Parallel()
tool := chattool.ReadSkill(chattool.ReadSkillOptions{})
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "read_skill",
Input: `{"name":"my-skill"}`,
})
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.Contains(t, resp.Content, `skill "my-skill" not found`)
})
}
func ambiguousResolveAliasForTest(alias string) (skillspkg.ResolvedSkill, error) {
return skillspkg.Lookup([]skillspkg.ResolvedSkill{
{
Skill: skillspkg.Skill{Name: "deploy", Source: skillspkg.SourcePersonal},
Alias: "personal/deploy",
},
{
Skill: skillspkg.Skill{Name: "deploy", Source: skillspkg.SourceWorkspace},
Alias: "workspace/deploy",
},
}, alias)
func personalSkillsForTest(names ...string) []skillspkg.Skill {
skills := make([]skillspkg.Skill, 0, len(names))
for _, name := range names {
skills = append(skills, skillspkg.Skill{Name: name, Description: "test", Source: skillspkg.SourcePersonal})
}
return skills
}
// ambiguousSkillOptionsForTest pins a workspace "deploy" alongside a
// personal "deploy", so the bare name is ambiguous.
func ambiguousSkillOptionsForTest() chattool.ReadSkillOptions {
pinned := []chattool.SkillMeta{{
Name: "deploy",
Dir: "/work/.agents/skills/deploy",
Meta: []byte(validSkillMD("deploy", "test")),
}}
return chattool.ReadSkillOptions{
GetSkills: func() []chattool.SkillMeta { return pinned },
PersonalSkills: personalSkillsForTest("deploy"),
}
}
// TestReadSkillToolPluginSource covers read_skill and read_skill_file for
// skills shipped inside Agent Plugins, pinned alongside a same-named
// workspace skill.
func TestReadSkillToolPluginSource(t *testing.T) {
t.Parallel()
// Rows are in pinned order (source ASC), so plugin rows come first.
acmeDeploy := chattool.SkillMeta{
Name: "deploy",
Description: "acme deploy",
PluginName: "acme",
Dir: "/work/.agents/plugins/acme/skills/deploy",
Meta: []byte("---\nname: deploy\ndescription: acme deploy\n---\n\nAcme body.\n"),
}
betaDeploy := chattool.SkillMeta{
Name: "deploy",
Description: "beta deploy",
PluginName: "beta",
Dir: "/work/.agents/plugins/beta/skills/deploy",
Meta: []byte("---\nname: deploy\ndescription: beta deploy\n---\n\nBeta body.\n"),
}
workspaceDeploy := chattool.SkillMeta{
Name: "deploy",
Description: "workspace deploy",
Dir: "/work/.agents/skills/deploy",
Meta: []byte("---\nname: deploy\ndescription: workspace deploy\n---\n\nWorkspace body.\n"),
}
pinned := []chattool.SkillMeta{acmeDeploy, betaDeploy, workspaceDeploy}
t.Run("SameNameAcrossPluginsServesDistinctBodies", func(t *testing.T) {
t.Parallel()
tool := chattool.ReadSkill(chattool.ReadSkillOptions{
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
return nil, xerrors.New("workspace is stopped")
},
GetSkills: func() []chattool.SkillMeta { return pinned },
})
read := func(alias string) fantasy.ToolResponse {
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-" + alias,
Name: "read_skill",
Input: `{"name":"` + alias + `"}`,
})
require.NoError(t, err)
require.False(t, resp.IsError, resp.Content)
return resp
}
acme := read("plugin/acme/deploy")
assert.Equal(t, "plugin/acme/deploy", responseName(t, acme))
assert.Contains(t, acme.Content, "Acme body.")
assert.NotContains(t, acme.Content, "Workspace body.")
assert.Equal(t, "/work/.agents/plugins/acme/skills/deploy", responseDir(t, acme))
beta := read("plugin/beta/deploy")
assert.Contains(t, beta.Content, "Beta body.")
assert.Equal(t, "/work/.agents/plugins/beta/skills/deploy", responseDir(t, beta))
workspace := read("workspace/deploy")
assert.Contains(t, workspace.Content, "Workspace body.")
assert.Equal(t, "/work/.agents/skills/deploy", responseDir(t, workspace))
ambiguous, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-bare",
Name: "read_skill",
Input: `{"name":"deploy"}`,
})
require.NoError(t, err)
assert.True(t, ambiguous.IsError)
assert.Contains(t, ambiguous.Content, "plugin/acme/deploy")
assert.Contains(t, ambiguous.Content, "plugin/beta/deploy")
})
t.Run("BareAliasForUniquePluginSkill", func(t *testing.T) {
t.Parallel()
onlyPlugin := []chattool.SkillMeta{acmeDeploy}
tool := chattool.ReadSkill(chattool.ReadSkillOptions{
GetSkills: func() []chattool.SkillMeta { return onlyPlugin },
})
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "read_skill",
Input: `{"name":"deploy"}`,
})
require.NoError(t, err)
assert.False(t, resp.IsError)
assert.Contains(t, resp.Content, "Acme body.")
})
t.Run("ReadSkillFileUsesPluginSkillDir", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
conn := agentconnmock.NewMockAgentConn(ctrl)
conn.EXPECT().ReadFile(
gomock.Any(),
"/work/.agents/plugins/beta/skills/deploy/roles/reviewer.md",
int64(0),
skillFileReadLimit,
).Return(
io.NopCloser(strings.NewReader("beta reviewer guide")),
"text/markdown",
nil,
)
tool := chattool.ReadSkillFile(chattool.ReadSkillOptions{
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
return conn, nil
},
GetSkills: func() []chattool.SkillMeta { return pinned },
})
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "read_skill_file",
Input: `{"name":"plugin/beta/deploy","path":"roles/reviewer.md"}`,
})
require.NoError(t, err)
assert.False(t, resp.IsError, resp.Content)
assert.Contains(t, resp.Content, "beta reviewer guide")
})
}
func TestReadSkillFileTool(t *testing.T) {
@@ -766,7 +876,7 @@ func TestReadSkillFileTool(t *testing.T) {
gomock.Any(),
"/work/.agents/skills/my-skill/roles/reviewer.md",
int64(0),
int64(512*1024+1),
skillFileReadLimit,
).Return(
io.NopCloser(strings.NewReader("reviewer guide")),
"text/markdown",
@@ -794,12 +904,7 @@ func TestReadSkillFileTool(t *testing.T) {
t.Parallel()
tool := chattool.ReadSkillFile(chattool.ReadSkillOptions{
ResolveAlias: func(alias string) (skillspkg.ResolvedSkill, error) {
return skillspkg.ResolvedSkill{
Skill: skillspkg.Skill{Name: alias, Source: skillspkg.SourcePersonal},
Alias: alias,
}, nil
},
PersonalSkills: personalSkillsForTest("my-skill"),
})
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
@@ -815,9 +920,7 @@ func TestReadSkillFileTool(t *testing.T) {
t.Run("AmbiguousLookupSurfacesAliases", func(t *testing.T) {
t.Parallel()
tool := chattool.ReadSkillFile(chattool.ReadSkillOptions{
ResolveAlias: ambiguousResolveAliasForTest,
})
tool := chattool.ReadSkillFile(ambiguousSkillOptionsForTest())
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
+123 -76
View File
@@ -7,6 +7,7 @@ import (
"golang.org/x/xerrors"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/structpb"
"cdr.dev/slog/v3"
@@ -22,25 +23,34 @@ 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 {
// decodeContextBody decodes a protojson resource body into the proto type T.
// ok is false when the body cannot be decoded.
func decodeContextBody[T any, P interface {
*T
proto.Message
}](body json.RawMessage) (P, bool) {
decoded := P(new(T))
if err := contextBodyUnmarshalOptions.Unmarshal(body, decoded); err != nil {
return nil, false
}
return &decoded, true
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
// pinnedSkillMeta converts a decoded skill body into prompt skill metadata.
// ok is false when the name is not a valid kebab-case skill name; the
// agent API does not validate skill names on ingest.
func pinnedSkillMeta(dir string, body *agentproto.SkillMetaBody) (meta chattool.SkillMeta, ok bool) {
name := body.GetName()
if !workspacesdk.SkillNamePattern.MatchString(name) {
return chattool.SkillMeta{}, false
}
return &decoded, true
return chattool.SkillMeta{
Name: name,
Description: body.GetDescription(),
PluginName: body.GetPluginName(),
Dir: dir,
Meta: body.GetMeta(),
}, true
}
// mcpToolNameSeparator joins a server name and a tool name into the
@@ -50,15 +60,10 @@ func decodeSkillMetaBody(body json.RawMessage) (*agentproto.SkillMetaBody, bool)
// (see agent/x/agentmcp ToolNameSep).
const mcpToolNameSeparator = "__"
// 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
}
// mcpToolsFromServerBody returns the tool list of a decoded mcp_server body
// for the chat response, with any leading "<server>__" trimmed from tool
// names. Returns nil when the body has no tools.
func mcpToolsFromServerBody(server string, decoded *agentproto.MCPServerBody) []codersdk.ChatContextTool {
tools := decoded.GetTools()
if len(tools) == 0 {
return nil
@@ -96,8 +101,8 @@ func workspaceMCPToolInfosFromResources(resources []database.ChatContextResource
r.Status != database.WorkspaceAgentContextResourceStatusOk {
continue
}
var decoded agentproto.MCPServerBody
if err := contextBodyUnmarshalOptions.Unmarshal(r.Body, &decoded); err != nil {
decoded, ok := decodeContextBody[agentproto.MCPServerBody](r.Body)
if !ok {
continue
}
server := decoded.GetServerName()
@@ -150,25 +155,13 @@ func splitMCPInputSchema(schema *structpb.Struct) (properties map[string]any, re
// 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)
decodedBody, ok := decodeContextBody[agentproto.InstructionFileBody](body)
if !ok {
return "", false
}
return codersdk.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. A chat
@@ -195,7 +188,7 @@ func (server *Server) pinnedWorkspaceContext(
if directory == "" {
directory = agent.Directory
}
instruction, skills, malformed := contextResourcesToPrompt(resources, agent.OperatingSystem, directory)
instruction, skills, malformed, invalidNameSources := contextResourcesToPrompt(resources, agent.OperatingSystem, directory)
if malformed > 0 {
// A status-OK resource whose body cannot be decoded means the pin
// hydrated content that is now unreadable; surface it so a proto
@@ -206,6 +199,13 @@ func (server *Server) pinnedWorkspaceContext(
slog.F("resource_count", len(resources)),
)
}
if len(invalidNameSources) > 0 {
server.logger.Warn(ctx, "skipped pinned skills with invalid names",
slog.F("chat_id", chat.ID),
slog.F("sources", invalidNameSources),
slog.F("resource_count", len(resources)),
)
}
server.logger.Debug(ctx, "built prompt context from pinned chat resources",
slog.F("chat_id", chat.ID),
slog.F("resource_count", len(resources)),
@@ -238,14 +238,14 @@ func (server *Server) resolveTurnWorkspaceContext(
// operatingSystem and directory annotate the instruction header and are
// omitted when empty. Only OK resources of a prompt body kind contribute;
// other statuses, body kinds, and malformed bodies are skipped. malformed
// counts OK resources whose body failed to decode, so the caller can surface
// an otherwise silent drop. The header is emitted only when at least one
// instruction file has content, so a skill-only pin produces no instruction
// block, matching the per-turn path.
// counts OK resources whose body failed to decode. invalidNameSources lists the
// sources of OK skills dropped for an invalid skill name. The header is emitted
// only when at least one instruction file has content, so a skill-only pin
// produces no instruction block, matching the per-turn path.
func contextResourcesToPrompt(
resources []database.ChatContextResource,
operatingSystem, directory string,
) (instruction string, skills []chattool.SkillMeta, malformed int) {
) (instruction string, skills []chattool.SkillMeta, malformed int, invalidNameSources []string) {
var contextFileParts []codersdk.ChatMessagePart
for _, r := range resources {
if r.Status != database.WorkspaceAgentContextResourceStatusOk {
@@ -267,30 +267,24 @@ func contextResourcesToPrompt(
ContextFileContent: content,
})
case database.WorkspaceAgentContextBodyKindSkill:
decodedBody, ok := decodeSkillMetaBody(r.Body)
decodedBody, ok := decodeContextBody[agentproto.SkillMetaBody](r.Body)
if !ok {
malformed++
continue
}
if decodedBody.GetName() == "" {
meta, ok := pinnedSkillMeta(r.Source, decodedBody)
if !ok {
invalidNameSources = append(invalidNameSources, r.Source)
continue
}
// Source is the skill directory. Meta carries the verbatim
// SKILL.md so read_skill serves the pinned body without
// dialing the workspace.
skills = append(skills, chattool.SkillMeta{
Name: decodedBody.GetName(),
Description: decodedBody.GetDescription(),
Dir: r.Source,
Meta: decodedBody.GetMeta(),
})
skills = append(skills, meta)
}
}
if len(contextFileParts) == 0 {
return "", skills, malformed
return "", skills, malformed, invalidNameSources
}
return formatSystemInstructions(operatingSystem, directory, contextFileParts), skills, malformed
return formatSystemInstructions(operatingSystem, directory, contextFileParts), skills, malformed, invalidNameSources
}
// ContextResources returns the chat's pinned context resource list (metadata
@@ -325,14 +319,16 @@ func (server *Server) ContextResources(
// 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).
// a valid name, OK MCP configs/servers (mcp_server carries its tools and
// plugin name), and OK plugin manifests.
// - 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.
// body-specific fields are empty, except PluginName on skill and
// mcp_server rows.
//
// OK-but-empty instruction files, OK skills with no name, and untracked kinds
// (plugin, and the reserved hook/subagent/command) are skipped. Input order
// OK-but-empty instruction files, OK skills with an invalid skill name, and
// untracked kinds (reserved 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
@@ -343,13 +339,14 @@ func pinnedContextResources(resources []database.ChatContextResource) []codersdk
}
if r.Status != database.WorkspaceAgentContextResourceStatusOk {
// Surface the failure (with its reason) rather than dropping it
// silently; the body is empty for non-OK rows.
// silently. The agent sets the body on every status.
out = append(out, codersdk.ChatContextResource{
Source: r.Source,
Kind: kind,
SizeBytes: r.SizeBytes,
Status: codersdk.ChatContextResourceStatus(r.Status),
Error: r.Error,
Source: r.Source,
Kind: kind,
SizeBytes: r.SizeBytes,
Status: codersdk.ChatContextResourceStatus(r.Status),
Error: r.Error,
PluginName: attributedPluginName(r),
})
continue
}
@@ -364,10 +361,15 @@ func pinnedContextResources(resources []database.ChatContextResource) []codersdk
Kind: kind,
SizeBytes: r.SizeBytes,
Status: codersdk.ChatContextResourceStatusOK,
Error: r.Error,
})
case database.WorkspaceAgentContextBodyKindSkill:
name, description, decoded := decodeSkillIdentity(r.Body)
if !decoded || name == "" {
decodedBody, decoded := decodeContextBody[agentproto.SkillMetaBody](r.Body)
if !decoded {
continue
}
meta, ok := pinnedSkillMeta(r.Source, decodedBody)
if !ok {
continue
}
out = append(out, codersdk.ChatContextResource{
@@ -375,8 +377,10 @@ func pinnedContextResources(resources []database.ChatContextResource) []codersdk
Kind: kind,
SizeBytes: r.SizeBytes,
Status: codersdk.ChatContextResourceStatusOK,
SkillName: name,
SkillDescription: description,
Error: r.Error,
SkillName: meta.Name,
SkillDescription: meta.Description,
PluginName: meta.PluginName,
})
case database.WorkspaceAgentContextBodyKindMcpConfig:
out = append(out, codersdk.ChatContextResource{
@@ -384,14 +388,33 @@ func pinnedContextResources(resources []database.ChatContextResource) []codersdk
Kind: kind,
SizeBytes: r.SizeBytes,
Status: codersdk.ChatContextResourceStatusOK,
Error: r.Error,
})
case database.WorkspaceAgentContextBodyKindMcpServer:
out = append(out, codersdk.ChatContextResource{
resource := codersdk.ChatContextResource{
Source: r.Source,
Kind: kind,
SizeBytes: r.SizeBytes,
Status: codersdk.ChatContextResourceStatusOK,
Tools: mcpToolsFromServerBody(r.Source, r.Body),
Error: r.Error,
}
if decodedBody, decoded := decodeContextBody[agentproto.MCPServerBody](r.Body); decoded {
resource.Tools = mcpToolsFromServerBody(r.Source, decodedBody)
resource.PluginName = decodedBody.GetPluginName()
}
out = append(out, resource)
case database.WorkspaceAgentContextBodyKindPlugin:
decodedBody, decoded := decodeContextBody[agentproto.PluginBody](r.Body)
if !decoded {
continue
}
out = append(out, codersdk.ChatContextResource{
Source: r.Source,
Kind: kind,
SizeBytes: r.SizeBytes,
Status: codersdk.ChatContextResourceStatusOK,
Error: r.Error,
PluginName: decodedBody.GetName(),
})
}
}
@@ -399,9 +422,9 @@ func pinnedContextResources(resources []database.ChatContextResource) []codersdk
}
// 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 (plugin,
// and the reserved hook/subagent/command kinds), which are omitted from the
// resource list.
// on the chat. ok is false only for kinds chatd does not track yet (the
// reserved hook/subagent/command kinds), which are omitted from the resource
// list.
func contextResourceKind(kind database.WorkspaceAgentContextBodyKind) (codersdk.ChatContextResourceKind, bool) {
switch kind {
case database.WorkspaceAgentContextBodyKindInstructionFile:
@@ -412,7 +435,31 @@ func contextResourceKind(kind database.WorkspaceAgentContextBodyKind) (codersdk.
return codersdk.ChatContextResourceKindMCPConfig, true
case database.WorkspaceAgentContextBodyKindMcpServer:
return codersdk.ChatContextResourceKindMCPServer, true
case database.WorkspaceAgentContextBodyKindPlugin:
return codersdk.ChatContextResourceKindPlugin, true
default:
return "", false
}
}
// attributedPluginName returns the plugin name a skill or mcp_server body
// carries. Manifest rows are skipped: the agent names a manifest only when
// it is OK.
func attributedPluginName(r database.ChatContextResource) string {
switch r.BodyKind {
case database.WorkspaceAgentContextBodyKindSkill:
decoded, ok := decodeContextBody[agentproto.SkillMetaBody](r.Body)
if !ok {
return ""
}
return decoded.GetPluginName()
case database.WorkspaceAgentContextBodyKindMcpServer:
decoded, ok := decodeContextBody[agentproto.MCPServerBody](r.Body)
if !ok {
return ""
}
return decoded.GetPluginName()
default:
return ""
}
}
+271 -10
View File
@@ -22,6 +22,8 @@ import (
"github.com/coder/coder/v2/coderd/database/dbmock"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
skillspkg "github.com/coder/coder/v2/coderd/x/skills"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/testutil"
@@ -58,6 +60,35 @@ func skillResource(t *testing.T, source, name, description string, status databa
}
}
func pluginSkillResource(t *testing.T, source, pluginName, name, description string) database.ChatContextResource {
t.Helper()
return database.ChatContextResource{
Source: source,
BodyKind: database.WorkspaceAgentContextBodyKindSkill,
Body: mustMarshalContextBody(t, &agentproto.SkillMetaBody{
Meta: []byte("# " + name),
Name: name,
Description: description,
PluginName: pluginName,
}),
Status: database.WorkspaceAgentContextResourceStatusOk,
}
}
func pluginResource(t *testing.T, source, name string, status database.WorkspaceAgentContextResourceStatus) database.ChatContextResource {
t.Helper()
return database.ChatContextResource{
Source: source,
BodyKind: database.WorkspaceAgentContextBodyKindPlugin,
Body: mustMarshalContextBody(t, &agentproto.PluginBody{
Name: name,
Version: "1.0.0",
Description: "A plugin",
}),
Status: status,
}
}
func mcpServerResource(t *testing.T, source string, body *agentproto.MCPServerBody, status database.WorkspaceAgentContextResourceStatus) database.ChatContextResource {
t.Helper()
return database.ChatContextResource{
@@ -84,7 +115,7 @@ func TestContextResourcesToPrompt(t *testing.T) {
resources := []database.ChatContextResource{
instructionResource(t, "/home/coder/AGENTS.md", "be helpful", database.WorkspaceAgentContextResourceStatusOk),
}
instruction, skills, _ := contextResourcesToPrompt(resources, "linux", "/home/coder")
instruction, skills, _, _ := contextResourcesToPrompt(resources, "linux", "/home/coder")
require.Empty(t, skills)
require.Contains(t, instruction, "<workspace-context>")
@@ -101,7 +132,7 @@ func TestContextResourcesToPrompt(t *testing.T) {
resources := []database.ChatContextResource{
skillResource(t, "/home/coder/.coder/skills/deploy", "deploy", "Deploy the app", database.WorkspaceAgentContextResourceStatusOk),
}
instruction, skills, _ := contextResourcesToPrompt(resources, "linux", "/home/coder")
instruction, skills, _, _ := contextResourcesToPrompt(resources, "linux", "/home/coder")
// Skill-only pins emit no instruction header.
require.Empty(t, instruction)
@@ -121,7 +152,7 @@ func TestContextResourcesToPrompt(t *testing.T) {
instructionResource(t, "/home/coder/AGENTS.md", "be helpful", database.WorkspaceAgentContextResourceStatusInvalid),
skillResource(t, "/home/coder/.coder/skills/deploy", "deploy", "Deploy the app", database.WorkspaceAgentContextResourceStatusOversize),
}
instruction, skills, _ := contextResourcesToPrompt(resources, "linux", "/home/coder")
instruction, skills, _, _ := contextResourcesToPrompt(resources, "linux", "/home/coder")
require.Empty(t, instruction)
require.Empty(t, skills)
@@ -144,7 +175,7 @@ func TestContextResourcesToPrompt(t *testing.T) {
Status: database.WorkspaceAgentContextResourceStatusOk,
},
}
instruction, skills, _ := contextResourcesToPrompt(resources, "linux", "/home/coder")
instruction, skills, _, _ := contextResourcesToPrompt(resources, "linux", "/home/coder")
require.Empty(t, instruction)
require.Empty(t, skills)
@@ -162,7 +193,7 @@ func TestContextResourcesToPrompt(t *testing.T) {
},
instructionResource(t, "/home/coder/CLAUDE.md", "good content", database.WorkspaceAgentContextResourceStatusOk),
}
instruction, skills, malformed := contextResourcesToPrompt(resources, "linux", "/home/coder")
instruction, skills, malformed, _ := contextResourcesToPrompt(resources, "linux", "/home/coder")
require.Empty(t, skills)
require.Equal(t, 1, malformed)
@@ -183,7 +214,7 @@ func TestContextResourcesToPrompt(t *testing.T) {
},
skillResource(t, "/home/coder/.coder/skills/deploy", "deploy", "Deploy the app", database.WorkspaceAgentContextResourceStatusOk),
}
instruction, skills, malformed := contextResourcesToPrompt(resources, "linux", "/home/coder")
instruction, skills, malformed, _ := contextResourcesToPrompt(resources, "linux", "/home/coder")
require.Empty(t, instruction)
require.Equal(t, 1, malformed)
@@ -199,7 +230,7 @@ func TestContextResourcesToPrompt(t *testing.T) {
resources := []database.ChatContextResource{
skillResource(t, "/home/coder/.coder/skills/nameless", "", "no name", database.WorkspaceAgentContextResourceStatusOk),
}
instruction, skills, malformed := contextResourcesToPrompt(resources, "linux", "/home/coder")
instruction, skills, malformed, _ := contextResourcesToPrompt(resources, "linux", "/home/coder")
require.Empty(t, instruction)
require.Empty(t, skills)
@@ -215,7 +246,7 @@ func TestContextResourcesToPrompt(t *testing.T) {
resources := []database.ChatContextResource{
instructionResource(t, "/home/coder/AGENTS.md", " \n\t ", database.WorkspaceAgentContextResourceStatusOk),
}
instruction, skills, malformed := contextResourcesToPrompt(resources, "linux", "/home/coder")
instruction, skills, malformed, _ := contextResourcesToPrompt(resources, "linux", "/home/coder")
require.Empty(t, instruction)
require.Empty(t, skills)
@@ -225,7 +256,7 @@ func TestContextResourcesToPrompt(t *testing.T) {
t.Run("EmptyInput", func(t *testing.T) {
t.Parallel()
instruction, skills, _ := contextResourcesToPrompt(nil, "linux", "/home/coder")
instruction, skills, _, _ := contextResourcesToPrompt(nil, "linux", "/home/coder")
require.Empty(t, instruction)
require.Empty(t, skills)
})
@@ -236,7 +267,7 @@ func TestContextResourcesToPrompt(t *testing.T) {
resources := []database.ChatContextResource{
instructionResource(t, "/home/coder/AGENTS.md", "be helpful", database.WorkspaceAgentContextResourceStatusOk),
}
instruction, _, _ := contextResourcesToPrompt(resources, "", "")
instruction, _, _, _ := contextResourcesToPrompt(resources, "", "")
require.Contains(t, instruction, "<workspace-context>")
require.Contains(t, instruction, "Source: /home/coder/AGENTS.md")
@@ -244,6 +275,84 @@ func TestContextResourcesToPrompt(t *testing.T) {
require.NotContains(t, instruction, "Operating System:")
require.NotContains(t, instruction, "Working Directory:")
})
t.Run("PluginSkillsCarryAttribution", func(t *testing.T) {
t.Parallel()
resources := []database.ChatContextResource{
pluginResource(t, "/home/coder/.coder/plugins/acme", "acme", database.WorkspaceAgentContextResourceStatusOk),
pluginSkillResource(t, "/home/coder/.coder/plugins/acme/skills/deploy", "acme", "deploy", "Deploy via acme"),
skillResource(t, "/home/coder/.coder/skills/review", "review", "Review code", database.WorkspaceAgentContextResourceStatusOk),
}
instruction, skills, malformed, invalidNameSources := contextResourcesToPrompt(resources, "linux", "/home/coder")
// The plugin manifest row itself is not a prompt input.
require.Empty(t, instruction)
require.Zero(t, malformed)
require.Empty(t, invalidNameSources)
require.Len(t, skills, 2)
require.Equal(t, "deploy", skills[0].Name)
require.Equal(t, "acme", skills[0].PluginName)
require.Equal(t, "/home/coder/.coder/plugins/acme/skills/deploy", skills[0].Dir)
require.Equal(t, []byte("# deploy"), skills[0].Meta)
require.Equal(t, "review", skills[1].Name)
require.Empty(t, skills[1].PluginName)
})
t.Run("SkipsInvalidSkillName", func(t *testing.T) {
t.Parallel()
resources := []database.ChatContextResource{
skillResource(t, "/home/coder/.coder/skills/bad", "Bad Name", "", database.WorkspaceAgentContextResourceStatusOk),
skillResource(t, "/home/coder/.coder/skills/slash", "a/b", "", database.WorkspaceAgentContextResourceStatusOk),
skillResource(t, "/home/coder/.coder/skills/empty", "", "", database.WorkspaceAgentContextResourceStatusOk),
skillResource(t, "/home/coder/.coder/skills/good", "good-name", "", database.WorkspaceAgentContextResourceStatusOk),
}
_, skills, malformed, invalidNameSources := contextResourcesToPrompt(resources, "linux", "/home/coder")
require.Zero(t, malformed)
require.Equal(t, []string{"/home/coder/.coder/skills/bad", "/home/coder/.coder/skills/slash", "/home/coder/.coder/skills/empty"}, invalidNameSources)
require.Len(t, skills, 1)
require.Equal(t, "good-name", skills[0].Name)
})
}
func TestMergeTurnSkillsPluginIndex(t *testing.T) {
t.Parallel()
resolved := chattool.MergePinnedSkills(nil, nil, []chattool.SkillMeta{
{Name: "deploy", Description: "Workspace deploy"},
{Name: "deploy", Description: "Acme deploy", PluginName: "acme"},
{Name: "lint", Description: "Acme lint", PluginName: "acme"},
})
idx := chattool.FormatResolvedSkillIndex(resolved)
require.Contains(t, idx, "- workspace/deploy: Workspace deploy\n")
require.Contains(t, idx, "- plugin/acme/deploy (plugin: acme): Acme deploy\n")
require.Contains(t, idx, "- lint (plugin: acme): Acme lint\n")
skill, err := skillspkg.Lookup(resolved, "plugin/acme/deploy")
require.NoError(t, err)
require.Equal(t, skillspkg.SourcePlugin, skill.Source)
require.Equal(t, "acme", skill.PluginName)
}
func TestPinnedWorkspaceContextPluginSkills(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{
pluginSkillResource(t, "/home/coder/.coder/plugins/acme/skills/deploy", "acme", "deploy", "Deploy via acme"),
skillResource(t, "/home/coder/.coder/skills/review", "review", "Review code", database.WorkspaceAgentContextResourceStatusOk),
}, nil)
server := newPinServer(t, db)
_, skills, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, database.WorkspaceAgent{})
require.NoError(t, err)
require.Len(t, skills, 2)
require.Equal(t, "acme", skills[0].PluginName)
require.Equal(t, "review", skills[1].Name)
}
func newPinServer(t *testing.T, db database.Store) *Server {
@@ -584,6 +693,45 @@ func TestPinnedContextResources(t *testing.T) {
}, out)
})
t.Run("NonOKRowsKeepPluginAttribution", func(t *testing.T) {
t.Parallel()
escapedSkill := pluginSkillResource(t, "/home/coder/.coder/plugins/acme/skills/badmd", "acme", "badmd", "")
escapedSkill.Status = database.WorkspaceAgentContextResourceStatusInvalid
escapedSkill.Error = "symlink target escapes scan root"
collision := mcpServerResource(t, "acme/tools", &agentproto.MCPServerBody{
ServerName: "tools",
PluginName: "acme",
}, database.WorkspaceAgentContextResourceStatusUnreadable)
collision.Error = "server name already in use by an earlier config"
brokenManifest := pluginResource(t, "/home/coder/.coder/plugins/broken", "", database.WorkspaceAgentContextResourceStatusInvalid)
brokenManifest.Error = "unsupported schema version"
resources := []database.ChatContextResource{escapedSkill, collision, brokenManifest}
require.Equal(t, []codersdk.ChatContextResource{
{
Source: "/home/coder/.coder/plugins/acme/skills/badmd",
Kind: codersdk.ChatContextResourceKindSkill,
Status: codersdk.ChatContextResourceStatusInvalid,
Error: "symlink target escapes scan root",
PluginName: "acme",
},
{
Source: "acme/tools",
Kind: codersdk.ChatContextResourceKindMCPServer,
Status: codersdk.ChatContextResourceStatusUnreadable,
Error: "server name already in use by an earlier config",
PluginName: "acme",
},
{
Source: "/home/coder/.coder/plugins/broken",
Kind: codersdk.ChatContextResourceKindPlugin,
Status: codersdk.ChatContextResourceStatusInvalid,
Error: "unsupported schema version",
},
}, pinnedContextResources(resources))
})
t.Run("IncludesMCPConfigAndServer", func(t *testing.T) {
t.Parallel()
@@ -630,6 +778,98 @@ func TestPinnedContextResources(t *testing.T) {
},
}, out)
})
t.Run("OKRowsKeepWarnings", func(t *testing.T) {
t.Parallel()
plugin := pluginResource(t, "/home/coder/.coder/plugins/extras", "extras", database.WorkspaceAgentContextResourceStatusOk)
plugin.Error = `extensions must be an object; ignored; unknown field "displayName" ignored`
skill := skillResource(t, "/home/coder/.coder/skills/review", "review", "Review code", database.WorkspaceAgentContextResourceStatusOk)
skill.Error = "description truncated"
resources := []database.ChatContextResource{plugin, skill}
require.Equal(t, []codersdk.ChatContextResource{
{
Source: "/home/coder/.coder/plugins/extras",
Kind: codersdk.ChatContextResourceKindPlugin,
Status: codersdk.ChatContextResourceStatusOK,
Error: `extensions must be an object; ignored; unknown field "displayName" ignored`,
PluginName: "extras",
},
{
Source: "/home/coder/.coder/skills/review",
Kind: codersdk.ChatContextResourceKindSkill,
Status: codersdk.ChatContextResourceStatusOK,
Error: "description truncated",
SkillName: "review",
SkillDescription: "Review code",
},
}, pinnedContextResources(resources))
})
t.Run("PluginRowsCarryPluginName", func(t *testing.T) {
t.Parallel()
invalidPlugin := pluginResource(t, "/home/coder/.coder/plugins/broken", "", database.WorkspaceAgentContextResourceStatusInvalid)
invalidPlugin.Error = "unsupported schema version"
resources := []database.ChatContextResource{
pluginResource(t, "/home/coder/.coder/plugins/acme", "acme", database.WorkspaceAgentContextResourceStatusOk),
invalidPlugin,
pluginSkillResource(t, "/home/coder/.coder/plugins/acme/skills/deploy", "acme", "deploy", "Deploy via acme"),
mcpServerResource(t, "acme/tools", &agentproto.MCPServerBody{
ServerName: "tools",
PluginName: "acme",
Tools: []*agentproto.MCPTool{{Name: "ping"}},
}, database.WorkspaceAgentContextResourceStatusOk),
skillResource(t, "/home/coder/.coder/skills/review", "review", "Review code", database.WorkspaceAgentContextResourceStatusOk),
}
require.Equal(t, []codersdk.ChatContextResource{
{
Source: "/home/coder/.coder/plugins/acme",
Kind: codersdk.ChatContextResourceKindPlugin,
Status: codersdk.ChatContextResourceStatusOK,
PluginName: "acme",
},
{
Source: "/home/coder/.coder/plugins/broken",
Kind: codersdk.ChatContextResourceKindPlugin,
Status: codersdk.ChatContextResourceStatusInvalid,
Error: "unsupported schema version",
},
{
Source: "/home/coder/.coder/plugins/acme/skills/deploy",
Kind: codersdk.ChatContextResourceKindSkill,
Status: codersdk.ChatContextResourceStatusOK,
SkillName: "deploy",
SkillDescription: "Deploy via acme",
PluginName: "acme",
},
{
Source: "acme/tools",
Kind: codersdk.ChatContextResourceKindMCPServer,
Status: codersdk.ChatContextResourceStatusOK,
PluginName: "acme",
Tools: []codersdk.ChatContextTool{{Name: "ping"}},
},
{
Source: "/home/coder/.coder/skills/review",
Kind: codersdk.ChatContextResourceKindSkill,
Status: codersdk.ChatContextResourceStatusOK,
SkillName: "review",
SkillDescription: "Review code",
},
}, pinnedContextResources(resources))
})
t.Run("InvalidSkillName", func(t *testing.T) {
t.Parallel()
resources := []database.ChatContextResource{
skillResource(t, "/s/bad", "Bad Skill", "", database.WorkspaceAgentContextResourceStatusOk),
}
require.Empty(t, pinnedContextResources(resources))
})
}
func TestContextResources(t *testing.T) {
@@ -766,6 +1006,27 @@ func TestWorkspaceMCPToolInfosFromResources(t *testing.T) {
}
require.Empty(t, workspaceMCPToolInfosFromResources(resources))
})
t.Run("PluginServersIncluded", func(t *testing.T) {
t.Parallel()
resources := []database.ChatContextResource{
mcpServerResource(t, "github", &agentproto.MCPServerBody{
ServerName: "github",
Tools: []*agentproto.MCPTool{{Name: "create_issue"}},
}, database.WorkspaceAgentContextResourceStatusOk),
mcpServerResource(t, "acme/tools", &agentproto.MCPServerBody{
ServerName: "tools",
PluginName: "acme",
Tools: []*agentproto.MCPTool{{Name: "ping"}},
}, database.WorkspaceAgentContextResourceStatusOk),
}
infos := workspaceMCPToolInfosFromResources(resources)
require.Len(t, infos, 2)
require.Equal(t, "github__create_issue", infos[0].Name)
require.Equal(t, "tools__ping", infos[1].Name)
})
}
func TestPinnedWorkspaceMCPTools(t *testing.T) {
+8 -13
View File
@@ -327,7 +327,7 @@ func (server *Server) prepareGeneration(
inlineMCPTools []fantasy.AgentTool
inlineMCPSummaries []mcpclient.ConnectSummary
workspaceMCPTools []fantasy.AgentTool
workspaceSkills []chattool.SkillMeta
pinnedSkills []chattool.SkillMeta
personalSkills []skillspkg.Skill
orgSkills []skillspkg.Skill
orgSkillReader rbac.Subject
@@ -382,7 +382,7 @@ func (server *Server) prepareGeneration(
server.ensureChatContextPinnedOnFirstTurn(ctx, workspaceCtx.currentChatSnapshot())
var resolveErr error
instruction, workspaceSkills, resolveErr = server.resolveTurnWorkspaceContext(ctx, chat, agent)
instruction, pinnedSkills, resolveErr = server.resolveTurnWorkspaceContext(ctx, chat, agent)
if resolveErr != nil {
return generationPrepared{}, resolveErr
}
@@ -563,13 +563,7 @@ func (server *Server) prepareGeneration(
if !isRootChat {
subagentInstruction = defaultSubagentInstruction
}
resolvedSkillsFor := func(workspaceSkills []chattool.SkillMeta) []skillspkg.ResolvedSkill {
return mergeTurnSkills(personalSkills, orgSkills, workspaceSkills)
}
resolveSkillAlias := func(alias string) (skillspkg.ResolvedSkill, error) {
return skillspkg.Lookup(resolvedSkillsFor(workspaceSkills), alias)
}
initialResolvedSkills := resolvedSkillsFor(workspaceSkills)
initialResolvedSkills := chattool.MergePinnedSkills(personalSkills, orgSkills, pinnedSkills)
memoryStore, memoryProjectName, hasMemory := server.resolveProjectMemory(ctx, chat)
memoryIndex := ""
if hasMemory {
@@ -651,9 +645,10 @@ func (server *Server) prepareGeneration(
skillOpts := chattool.ReadSkillOptions{
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
GetSkills: func() []chattool.SkillMeta {
return workspaceSkills
return pinnedSkills
},
ResolveAlias: resolveSkillAlias,
PersonalSkills: personalSkills,
OrganizationSkills: orgSkills,
LoadStoredSkillBody: func(ctx context.Context, skill skillspkg.Skill) (skillspkg.ParsedSkill, error) {
if skill.Source == skillspkg.SourceOrganization {
return server.loadOrganizationSkillBody(ctx, orgSkillReader, chat.OrganizationID, skill.Name)
@@ -662,7 +657,7 @@ func (server *Server) prepareGeneration(
},
}
appendCurrentSkillTools := func(current []fantasy.AgentTool) ([]fantasy.AgentTool, bool) {
if len(personalSkills) == 0 && len(orgSkills) == 0 && len(workspaceSkills) == 0 {
if len(personalSkills) == 0 && len(orgSkills) == 0 && len(pinnedSkills) == 0 {
return current, false
}
updated := current
@@ -681,7 +676,7 @@ func (server *Server) prepareGeneration(
updated = append(updated, tool)
}
appendTool(chattool.ReadSkill(skillOpts))
if len(workspaceSkills) > 0 {
if len(pinnedSkills) > 0 {
appendTool(chattool.ReadSkillFile(skillOpts))
}
return updated, changed
+268
View File
@@ -0,0 +1,268 @@
package chatd_test
import (
"os"
"path/filepath"
"sync"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/agent"
"github.com/coder/coder/v2/agent/agentcontext"
"github.com/coder/coder/v2/agent/agentcontextconfig"
"github.com/coder/coder/v2/agent/agenttest"
"github.com/coder/coder/v2/coderd/aibridgedtest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/provisioner/echo"
"github.com/coder/coder/v2/testutil"
)
// TestChatContextPluginSkillsFromAgentPush checks that a real agent's plugin
// and plain skills reach the pinned rows, the chat context listing, and the
// system prompt. The agent discovers plugins only when the agent-plugins
// experiment is enabled.
func TestChatContextPluginSkillsFromAgentPush(t *testing.T) {
t.Parallel()
// The agent also scans the developer's ~/.coder, so the names are
// ones no home directory is expected to hold.
const (
pluginName = "chatd-test-acme"
pluginSkillName = "chatd-test-deploy"
pluginSkillDescription = "Deploy via chatd-test-acme"
plainSkillName = "chatd-test-review"
plainSkillDescription = "Review code"
)
type turnResult struct {
// Canonical on-disk paths; pinned and listed are keyed by source.
pluginSource string
pluginSkillSource string
plainSkillSource string
// pinned holds this test's chat_context_resources rows.
pinned map[string]database.ChatContextResource
// listed holds the GET /chats/{id} context resources.
listed map[string]codersdk.ChatContextResource
// systemPrompt is the concatenated system messages of the first
// streaming LLM request.
systemPrompt string
}
runPluginTurn := func(t *testing.T, enabled bool) turnResult {
t.Helper()
ctx := testutil.Context(t, testutil.WaitSuperLong)
// Registered as a user source, so discovery does not depend on HOME
// or the working directory.
root, err := filepath.EvalSymlinks(t.TempDir())
require.NoError(t, err)
pluginSource := filepath.Join(root, ".agents", "plugins", pluginName)
pluginSkillSource := filepath.Join(pluginSource, "skills", pluginSkillName)
plainSkillSource := filepath.Join(root, ".agents", "skills", plainSkillName)
require.NoError(t, os.MkdirAll(pluginSkillSource, 0o755))
require.NoError(t, os.MkdirAll(plainSkillSource, 0o755))
require.NoError(t, os.WriteFile(
filepath.Join(pluginSource, "plugin.json"),
[]byte(`{"$schema":"`+agentcontext.PluginSchemaV1+`","name":"`+pluginName+`","version":"1.0.0","description":"Acme tools"}`),
0o600,
))
require.NoError(t, os.WriteFile(
filepath.Join(pluginSkillSource, "SKILL.md"),
[]byte("---\nname: "+pluginSkillName+"\ndescription: "+pluginSkillDescription+"\n---\nRun the acme deploy.\n"),
0o600,
))
require.NoError(t, os.WriteFile(
filepath.Join(plainSkillSource, "SKILL.md"),
[]byte("---\nname: "+plainSkillName+"\ndescription: "+plainSkillDescription+"\n---\nReview it.\n"),
0o600,
))
dv := coderdtest.DeploymentValues(t)
if enabled {
dv.Experiments = []string{string(codersdk.ExperimentAgentPlugins)}
}
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
DeploymentValues: dv,
IncludeProvisionerDaemon: true,
})
db := api.Database
aibridgedtest.StartTestAIBridgeDaemon(ctx, t, api, nil)
user := coderdtest.CreateFirstUser(t, client)
expClient := codersdk.NewExperimentalClient(client)
agentToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.PlanComplete,
ProvisionApply: echo.ApplyComplete,
ProvisionGraph: echo.ProvisionGraphWithAgent(agentToken),
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
_ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) {
o.ContextConfig = agentcontextconfig.Config{SkillsDirs: root}
})
coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()
ws, err := client.Workspace(ctx, workspace.ID)
require.NoError(t, err)
require.Len(t, ws.LatestBuild.Resources, 1)
require.Len(t, ws.LatestBuild.Resources[0].Agents, 1)
agentID := ws.LatestBuild.Resources[0].Agents[0].ID
// The chat binds its agent on the first turn and pins that agent's
// pushed snapshot, so the push must land before the turn starts.
wantPushed := []string{plainSkillSource}
if enabled {
wantPushed = append(wantPushed, pluginSource, pluginSkillSource)
}
require.Eventually(t, func() bool {
//nolint:gocritic // Test reads agent-owned rows; ctx carries no per-user actor.
pushed, lerr := db.ListWorkspaceAgentContextResources(dbauthz.AsSystemRestricted(ctx), agentID)
if lerr != nil {
return false
}
have := make(map[string]struct{}, len(pushed))
for _, r := range pushed {
have[r.Source] = struct{}{}
}
for _, want := range wantPushed {
if _, ok := have[want]; !ok {
return false
}
}
return true
}, testutil.WaitSuperLong, testutil.IntervalFast)
var streamedCallsMu sync.Mutex
streamedCalls := make([][]chattest.OpenAIMessage, 0, 1)
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
if !req.Stream {
return chattest.OpenAINonStreamingResponse("plugin test")
}
streamedCallsMu.Lock()
streamedCalls = append(streamedCalls, append([]chattest.OpenAIMessage(nil), req.Messages...))
streamedCallsMu.Unlock()
return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("Got it.")...)
})
coderdtest.CreateOpenAICompatChatModel(t, expClient, openAIURL)
workspaceID := workspace.ID
chat, err := expClient.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
WorkspaceID: &workspaceID,
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "Which skills are available?",
}},
})
require.NoError(t, err)
settled := coderdtest.WaitForChatSettled(ctx, t, api, chat.ID)
require.Equal(t, database.ChatStatusWaiting, settled.Status, "turn should complete without error")
streamedCallsMu.Lock()
recordedCalls := append([][]chattest.OpenAIMessage(nil), streamedCalls...)
streamedCallsMu.Unlock()
require.NotEmpty(t, recordedCalls, "LLM should have received at least one streaming request")
var systemPrompt string
for _, msg := range recordedCalls[0] {
if msg.Role == "system" {
systemPrompt += msg.Content + "\n"
}
}
got, err := expClient.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.NotNil(t, got.Context, "chat should be hydrated after the first turn")
listed := make(map[string]codersdk.ChatContextResource, len(got.Context.Resources))
for _, r := range got.Context.Resources {
listed[r.Source] = r
}
//nolint:gocritic // Test reads the chat-owned rows as the chatd subject; ctx carries no per-user actor.
rows, err := db.ListChatContextResourcesByChatID(dbauthz.AsChatd(ctx), chat.ID)
require.NoError(t, err)
// Built-in scan roots under HOME may contribute rows; only rows
// attributed to the test's scan root are counted.
pinned := make(map[string]database.ChatContextResource, len(rows))
for _, r := range rows {
if r.SourcePath == root {
pinned[r.Source] = r
}
}
return turnResult{
pluginSource: pluginSource,
pluginSkillSource: pluginSkillSource,
plainSkillSource: plainSkillSource,
pinned: pinned,
listed: listed,
systemPrompt: systemPrompt,
}
}
t.Run("ExperimentOn", func(t *testing.T) {
t.Parallel()
res := runPluginTurn(t, true)
require.Len(t, res.pinned, 3, "plugin, plugin skill, and plain skill are pinned")
require.Equal(t, database.WorkspaceAgentContextBodyKindPlugin, res.pinned[res.pluginSource].BodyKind)
require.Equal(t, database.WorkspaceAgentContextBodyKindSkill, res.pinned[res.pluginSkillSource].BodyKind)
require.Equal(t, database.WorkspaceAgentContextBodyKindSkill, res.pinned[res.plainSkillSource].BodyKind)
require.Equal(t, codersdk.ChatContextResourceKindPlugin, res.listed[res.pluginSource].Kind)
require.Equal(t, pluginName, res.listed[res.pluginSource].PluginName)
require.Equal(t, codersdk.ChatContextResourceStatusOK, res.listed[res.pluginSource].Status)
require.Equal(t, codersdk.ChatContextResourceKindSkill, res.listed[res.pluginSkillSource].Kind)
require.Equal(t, pluginSkillName, res.listed[res.pluginSkillSource].SkillName)
require.Equal(t, pluginSkillDescription, res.listed[res.pluginSkillSource].SkillDescription)
require.Equal(t, pluginName, res.listed[res.pluginSkillSource].PluginName)
require.Equal(t, plainSkillName, res.listed[res.plainSkillSource].SkillName)
require.Empty(t, res.listed[res.plainSkillSource].PluginName)
require.Contains(t, res.systemPrompt, "<available-skills>")
require.Contains(t, res.systemPrompt,
"- "+pluginSkillName+" (plugin: "+pluginName+"): "+pluginSkillDescription+"\n",
"plugin skill should be indexed with its plugin label")
require.Contains(t, res.systemPrompt,
"- "+plainSkillName+": "+plainSkillDescription+"\n",
"plain workspace skill should be indexed without a plugin label")
})
t.Run("ExperimentOff", func(t *testing.T) {
t.Parallel()
res := runPluginTurn(t, false)
require.Len(t, res.pinned, 1, "only the plain workspace skill is pinned with the experiment off")
require.Equal(t, database.WorkspaceAgentContextBodyKindSkill, res.pinned[res.plainSkillSource].BodyKind)
require.NotContains(t, res.pinned, res.pluginSource)
require.NotContains(t, res.pinned, res.pluginSkillSource)
require.Equal(t, plainSkillName, res.listed[res.plainSkillSource].SkillName)
require.NotContains(t, res.listed, res.pluginSource)
require.NotContains(t, res.listed, res.pluginSkillSource)
require.Contains(t, res.systemPrompt, "<available-skills>")
require.Contains(t, res.systemPrompt,
"- "+plainSkillName+": "+plainSkillDescription+"\n",
"plain workspace skill should still be indexed")
require.NotContains(t, res.systemPrompt, pluginSkillDescription,
"plugin skill should not reach the prompt with the experiment off")
require.NotContains(t, res.systemPrompt, "(plugin: "+pluginName+")")
})
}
+13 -5
View File
@@ -301,15 +301,16 @@ 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
// Resources lists the chat's pinned context resources of the kinds
// ChatContextResourceKind describes, 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.
// ChatContextResourceKind classifies a pinned context resource. Only the
// kinds that contribute to the prompt, and Agent Plugin manifests, are
// reported.
type ChatContextResourceKind string
const (
@@ -317,6 +318,8 @@ const (
ChatContextResourceKindSkill ChatContextResourceKind = "skill"
ChatContextResourceKindMCPConfig ChatContextResourceKind = "mcp_config"
ChatContextResourceKindMCPServer ChatContextResourceKind = "mcp_server"
// ChatContextResourceKindPlugin is an Agent Plugin manifest (plugin.json).
ChatContextResourceKindPlugin ChatContextResourceKind = "plugin"
)
// ChatContextResource is one pinned workspace-context resource the chat's
@@ -325,7 +328,9 @@ const (
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.
// an MCP config, the server name for an MCP server
// ("<plugin_name>/<server_name>" for a plugin's server), or the plugin
// root directory for a plugin.
Source string `json:"source"`
Kind ChatContextResourceKind `json:"kind"`
// SizeBytes is the original payload size in bytes.
@@ -333,6 +338,9 @@ type ChatContextResource struct {
// SkillName and SkillDescription are populated only for skill kinds.
SkillName string `json:"skill_name,omitempty"`
SkillDescription string `json:"skill_description,omitempty"`
// PluginName is the owning Agent Plugin's name. Set on OK plugin rows
// and on skill and mcp_server rows shipped by a plugin, in any status.
PluginName string `json:"plugin_name,omitempty"`
// Tools lists the tools exposed by an MCP server. Populated only for the
// mcp_server kind; nil otherwise.
Tools []ChatContextTool `json:"tools,omitempty"`
+141 -128
View File
@@ -83,6 +83,7 @@ curl -X GET http://coder-server:8080/api/v2/chats \
{
"error": "string",
"kind": "instruction_file",
"plugin_name": "string",
"size_bytes": 0,
"skill_description": "string",
"skill_name": "string",
@@ -230,137 +231,138 @@ curl -X GET http://coder-server:8080/api/v2/chats \
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). |
| `»» 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 `<server>__` 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 | | Diff status is the primary pull request. It is the ref with the most recent git report. |
| `»» 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 | | |
| `»» git_branch` | string | 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 | | |
| `»» remote_origin` | string | false | | |
| `»» reviewer_count` | integer | false | | |
| `»» stale_at` | string(date-time) | false | | |
| `»» url` | string | false | | |
| `» diff_statuses` | array | false | | Diff statuses lists every ref the chat tracks. The order is stable and follows the first report of each ref. DiffStatus marks the primary. |
| `»» 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 | | |
| `»» git_branch` | string | 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 | | |
| `»» remote_origin` | string | 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 | | |
| `»» size_bytes` | integer | 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 and via UpdateChatRequest.Read. |
| `» id` | string(uuid) | false | | |
| `» inline_mcp_servers` | array | false | | Inline mcp servers lists the inline MCP servers declared on the chat, without headers. Only the single-chat GET sets it. Experimental. |
| `»» allow_in_subagents` | boolean | false | | |
| `»» created_at` | string(date-time) | false | | |
| `»» forward_coder_headers` | boolean | false | | |
| `»» has_custom_headers` | boolean | false | | |
| `»» id` | string(uuid) | false | | |
| `»» slug` | string | false | | |
| `»» tool_allow_list` | array | false | | |
| `»» tool_deny_list` | array | false | | |
| `»» updated_at` | string(date-time) | false | | |
| `»» url` | string | false | | URL is empty unless the chat owner makes the request. |
| `» 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_model_config_id` | string(uuid) | false | | |
| `» last_reasoning_effort` | string | 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 | | |
| `» project_id` | string(uuid) | false | | |
| `» queued_for_capacity` | boolean | false | | Queued for capacity reports that the chat is waiting for a concurrent agent slot. Single-chat reads derive it; list responses leave it 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 | | |
| `» summary` | string | false | | Summary is the persisted whole-chat summary, generated in the background. It is nil until the first summary has been produced. |
| `» title` | string | false | | |
| `» title_source` | [codersdk.ChatTitleSource](schemas.md#codersdkchattitlesource) | false | | Title source is where Title came from. A title write applies only when the current source ranks the same as or lower than the incoming one, in the order fallback, generated, user. |
| `» title_updated_at` | string(date-time) | false | | Title updated at orders title changes. Title writes do not change UpdatedAt. |
| `» 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 lists the chat's pinned context resources of the kinds ChatContextResourceKind describes, 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 | | |
| `»»» plugin_name` | string | false | | Plugin name is the owning Agent Plugin's name. Set on OK plugin rows and on skill and mcp_server rows shipped by a plugin, in any status. |
| `»»» 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, the server name for an MCP server ("<plugin_name>/<server_name>" for a plugin's server), or the plugin root directory for a plugin. |
| `»»» 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 `<server>__` 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 | | Diff status is the primary pull request. It is the ref with the most recent git report. |
| `»» 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 | | |
| `»» git_branch` | string | 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 | | |
| `»» remote_origin` | string | false | | |
| `»» reviewer_count` | integer | false | | |
| `»» stale_at` | string(date-time) | false | | |
| `»» url` | string | false | | |
| `» diff_statuses` | array | false | | Diff statuses lists every ref the chat tracks. The order is stable and follows the first report of each ref. DiffStatus marks the primary. |
| `»» 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 | | |
| `»» git_branch` | string | 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 | | |
| `»» remote_origin` | string | 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 | | |
| `»» size_bytes` | integer | 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 and via UpdateChatRequest.Read. |
| `» id` | string(uuid) | false | | |
| `» inline_mcp_servers` | array | false | | Inline mcp servers lists the inline MCP servers declared on the chat, without headers. Only the single-chat GET sets it. Experimental. |
| `»» allow_in_subagents` | boolean | false | | |
| `»» created_at` | string(date-time) | false | | |
| `»» forward_coder_headers` | boolean | false | | |
| `»» has_custom_headers` | boolean | false | | |
| `»» id` | string(uuid) | false | | |
| `»» slug` | string | false | | |
| `»» tool_allow_list` | array | false | | |
| `»» tool_deny_list` | array | false | | |
| `»» updated_at` | string(date-time) | false | | |
| `»» url` | string | false | | URL is empty unless the chat owner makes the request. |
| `» 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_model_config_id` | string(uuid) | false | | |
| `» last_reasoning_effort` | string | 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 | | |
| `» project_id` | string(uuid) | false | | |
| `» queued_for_capacity` | boolean | false | | Queued for capacity reports that the chat is waiting for a concurrent agent slot. Single-chat reads derive it; list responses leave it 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 | | |
| `» summary` | string | false | | Summary is the persisted whole-chat summary, generated in the background. It is nil until the first summary has been produced. |
| `» title` | string | false | | |
| `» title_source` | [codersdk.ChatTitleSource](schemas.md#codersdkchattitlesource) | false | | Title source is where Title came from. A title write applies only when the current source ranks the same as or lower than the incoming one, in the order fallback, generated, user. |
| `» title_updated_at` | string(date-time) | false | | Title updated at orders title changes. Title writes do not change UpdatedAt. |
| `» 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`, `content_filter`, `generic`, `hook_denied`, `hook_dispatch_failed`, `instruction_file`, `mcp_config`, `mcp_server`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `skill`, `stream_silence_timeout`, `timeout`, `usage_limit` |
| `status` | `error`, `excluded`, `interrupting`, `invalid`, `ok`, `oversize`, `requires_action`, `running`, `unreadable`, `waiting` |
| `plan_mode` | `plan` |
| `title_source` | `fallback`, `generated`, `user` |
| Property | Value(s) |
|----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `client_type` | `api`, `ui` |
| `kind` | `auth`, `config`, `content_filter`, `generic`, `hook_denied`, `hook_dispatch_failed`, `instruction_file`, `mcp_config`, `mcp_server`, `missing_key`, `overloaded`, `plugin`, `provider_disabled`, `rate_limit`, `skill`, `stream_silence_timeout`, `timeout`, `usage_limit` |
| `status` | `error`, `excluded`, `interrupting`, `invalid`, `ok`, `oversize`, `requires_action`, `running`, `unreadable`, `waiting` |
| `plan_mode` | `plan` |
| `title_source` | `fallback`, `generated`, `user` |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
@@ -475,6 +477,7 @@ curl -X POST http://coder-server:8080/api/v2/chats \
{
"error": "string",
"kind": "instruction_file",
"plugin_name": "string",
"size_bytes": 0,
"skill_description": "string",
"skill_name": "string",
@@ -619,6 +622,7 @@ curl -X POST http://coder-server:8080/api/v2/chats \
{
"error": "string",
"kind": "instruction_file",
"plugin_name": "string",
"size_bytes": 0,
"skill_description": "string",
"skill_name": "string",
@@ -1547,6 +1551,7 @@ curl -X GET http://coder-server:8080/api/v2/chats/watch \
{
"error": "string",
"kind": "instruction_file",
"plugin_name": "string",
"size_bytes": 0,
"skill_description": "string",
"skill_name": "string",
@@ -1743,6 +1748,7 @@ curl -X GET http://coder-server:8080/api/v2/chats/{chat} \
{
"error": "string",
"kind": "instruction_file",
"plugin_name": "string",
"size_bytes": 0,
"skill_description": "string",
"skill_name": "string",
@@ -1887,6 +1893,7 @@ curl -X GET http://coder-server:8080/api/v2/chats/{chat} \
{
"error": "string",
"kind": "instruction_file",
"plugin_name": "string",
"size_bytes": 0,
"skill_description": "string",
"skill_name": "string",
@@ -2125,6 +2132,7 @@ curl -X PUT http://coder-server:8080/api/v2/chats/{chat}/context \
{
"error": "string",
"kind": "instruction_file",
"plugin_name": "string",
"size_bytes": 0,
"skill_description": "string",
"skill_name": "string",
@@ -2269,6 +2277,7 @@ curl -X PUT http://coder-server:8080/api/v2/chats/{chat}/context \
{
"error": "string",
"kind": "instruction_file",
"plugin_name": "string",
"size_bytes": 0,
"skill_description": "string",
"skill_name": "string",
@@ -2550,6 +2559,7 @@ curl -X POST http://coder-server:8080/api/v2/chats/{chat}/interrupt \
{
"error": "string",
"kind": "instruction_file",
"plugin_name": "string",
"size_bytes": 0,
"skill_description": "string",
"skill_name": "string",
@@ -2694,6 +2704,7 @@ curl -X POST http://coder-server:8080/api/v2/chats/{chat}/interrupt \
{
"error": "string",
"kind": "instruction_file",
"plugin_name": "string",
"size_bytes": 0,
"skill_description": "string",
"skill_name": "string",
@@ -3750,6 +3761,7 @@ curl -X POST http://coder-server:8080/api/v2/chats/{chat}/reconcile-invalid \
{
"error": "string",
"kind": "instruction_file",
"plugin_name": "string",
"size_bytes": 0,
"skill_description": "string",
"skill_name": "string",
@@ -3894,6 +3906,7 @@ curl -X POST http://coder-server:8080/api/v2/chats/{chat}/reconcile-invalid \
{
"error": "string",
"kind": "instruction_file",
"plugin_name": "string",
"size_bytes": 0,
"skill_description": "string",
"skill_name": "string",
+25 -19
View File
@@ -2292,6 +2292,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
{
"error": "string",
"kind": "instruction_file",
"plugin_name": "string",
"size_bytes": 0,
"skill_description": "string",
"skill_name": "string",
@@ -2436,6 +2437,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
{
"error": "string",
"kind": "instruction_file",
"plugin_name": "string",
"size_bytes": 0,
"skill_description": "string",
"skill_name": "string",
@@ -2775,6 +2777,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
{
"error": "string",
"kind": "instruction_file",
"plugin_name": "string",
"size_bytes": 0,
"skill_description": "string",
"skill_name": "string",
@@ -2793,12 +2796,12 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
### 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). |
| `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. |
| 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 lists the chat's pinned context resources of the kinds ChatContextResourceKind describes, 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
@@ -2806,6 +2809,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
{
"error": "string",
"kind": "instruction_file",
"plugin_name": "string",
"size_bytes": 0,
"skill_description": "string",
"skill_name": "string",
@@ -2822,16 +2826,17 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
### 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. |
| 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 | | |
| `plugin_name` | string | false | | Plugin name is the owning Agent Plugin's name. Set on OK plugin rows and on skill and mcp_server rows shipped by a plugin, in any status. |
| `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, the server name for an MCP server ("<plugin_name>/<server_name>" for a plugin's server), or the plugin root directory for a plugin. |
| `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
@@ -2843,9 +2848,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
#### Enumerated Values
| Value(s) |
|---------------------------------------------------------|
| `instruction_file`, `mcp_config`, `mcp_server`, `skill` |
| Value(s) |
|-------------------------------------------------------------------|
| `instruction_file`, `mcp_config`, `mcp_server`, `plugin`, `skill` |
## codersdk.ChatContextResourceStatus
@@ -5652,6 +5657,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
{
"error": "string",
"kind": "instruction_file",
"plugin_name": "string",
"size_bytes": 0,
"skill_description": "string",
"skill_name": "string",
+12 -3
View File
@@ -2295,8 +2295,8 @@ export interface ChatContext {
*/
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
* Resources lists the chat's pinned context resources of the kinds
* ChatContextResourceKind describes, metadata only (no bodies). It is
* populated only on the single-chat GET response; list and watch
* payloads leave it nil to stay lightweight.
*/
@@ -2335,7 +2335,9 @@ 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.
* an MCP config, the server name for an MCP server
* ("<plugin_name>/<server_name>" for a plugin's server), or the plugin
* root directory for a plugin.
*/
readonly source: string;
readonly kind: ChatContextResourceKind;
@@ -2348,6 +2350,11 @@ export interface ChatContextResource {
*/
readonly skill_name?: string;
readonly skill_description?: string;
/**
* PluginName is the owning Agent Plugin's name. Set on OK plugin rows
* and on skill and mcp_server rows shipped by a plugin, in any status.
*/
readonly plugin_name?: string;
/**
* Tools lists the tools exposed by an MCP server. Populated only for the
* mcp_server kind; nil otherwise.
@@ -2372,12 +2379,14 @@ export type ChatContextResourceKind =
| "instruction_file"
| "mcp_config"
| "mcp_server"
| "plugin"
| "skill";
export const ChatContextResourceKinds: ChatContextResourceKind[] = [
"instruction_file",
"mcp_config",
"mcp_server",
"plugin",
"skill",
];
@@ -86,6 +86,7 @@ const RESOURCE_KIND_LABELS: Record<ChatContextResourceKind, string> = {
skill: "skill",
mcp_config: "MCP config",
mcp_server: "MCP server",
plugin: "plugin manifest",
};
const hasFiniteTokenValue = (value: number | undefined): value is number =>