feat: persist skills as message parts like AGENTS.md (#23748)

## Summary

Skills are now discovered once on the first turn (or when the workspace
agent changes) and persisted as `skill` message parts alongside
`context-file` parts. On subsequent turns, the skill index is
reconstructed from persisted parts instead of re-dialing the workspace
agent.

This makes skills consistent with the AGENTS.md pattern and is
groundwork for a future `/context` endpoint that surfaces loaded
workspace context to the frontend.

## Changes

- Add `skill` `ChatMessagePartType` with `SkillName` and
`SkillDescription` fields
- Extend `persistInstructionFiles` to also discover and persist skills
as parts
- Add `skillsFromParts()` to reconstruct skill index from persisted
parts on subsequent turns
- Update `runChat()` to use `skillsFromParts` instead of re-dialing
workspace for skills
- Frontend: handle new `skill` part type (skip rendering, hide
metadata-only messages)

## Before / After

| | AGENTS.md | Skills |
|---|---|---|
| **Before** | Persist as `context-file` parts, reconstruct from parts |
In-memory `skillsCache` only, re-dial workspace on cache miss |
| **After** | Persist as `context-file` parts, reconstruct from parts |
Persist as `skill` parts, reconstruct from parts |

The in-memory `skillsCache` remains for `read_skill`/`read_skill_file`
tool calls that need full skill bodies on demand.

<details><summary>Design context</summary>

This is the first step toward a unified workspace context
representation. Currently:
- Context files are persisted as message parts (works)
- Skills were only in-memory (inconsistent)
- Workspace MCP servers are cached in-memory (future work)

Persisting skills as parts means a future `/context` endpoint can query
both context files and skills from the same message parts in the DB,
without depending on ephemeral server-side caches.
</details>
This commit is contained in:
Kyle Carberry
2026-03-29 21:48:17 -04:00
committed by GitHub
parent f7aa46c4ba
commit 4d2b0a2f82
9 changed files with 309 additions and 115 deletions
+13
View File
@@ -116,6 +116,7 @@ const (
ChatMessagePartTypeFile ChatMessagePartType = "file"
ChatMessagePartTypeFileReference ChatMessagePartType = "file-reference"
ChatMessagePartTypeContextFile ChatMessagePartType = "context-file"
ChatMessagePartTypeSkill ChatMessagePartType = "skill"
)
// AllChatMessagePartTypes returns all known ChatMessagePartType values.
@@ -129,6 +130,7 @@ func AllChatMessagePartTypes() []ChatMessagePartType {
ChatMessagePartTypeFile,
ChatMessagePartTypeFileReference,
ChatMessagePartTypeContextFile,
ChatMessagePartTypeSkill,
}
}
@@ -211,6 +213,16 @@ type ChatMessagePart struct {
// workspace agent. Internal only: same purpose as
// ContextFileOS.
ContextFileDirectory string `json:"context_file_directory,omitempty" typescript:"-"`
// SkillName is the kebab-case name of a discovered skill
// from the workspace's .agents/skills/ directory.
SkillName string `json:"skill_name" variants:"skill"`
// SkillDescription is the short description from the skill's
// SKILL.md frontmatter.
SkillDescription string `json:"skill_description,omitempty" variants:"skill?"`
// SkillDir is the absolute path to the skill directory inside
// the workspace filesystem. Internal only: used by
// read_skill/read_skill_file tools to locate skill files.
SkillDir string `json:"skill_dir,omitempty" typescript:"-"`
}
// StripInternal removes internal-only fields that must not be
@@ -227,6 +239,7 @@ func (p *ChatMessagePart) StripInternal() {
p.ContextFileContent = ""
p.ContextFileOS = ""
p.ContextFileDirectory = ""
p.SkillDir = ""
}
// ChatMessageText builds a text chat message part.
+1
View File
@@ -238,6 +238,7 @@ func TestChatMessagePartVariantTags(t *testing.T) {
"context_file_content": "internal only, stripped before API responses (typescript:\"-\")",
"context_file_os": "internal only, used during prompt expansion (typescript:\"-\")",
"context_file_directory": "internal only, used during prompt expansion (typescript:\"-\")",
"skill_dir": "internal only, used by read_skill tools (typescript:\"-\")",
}
knownTypes := make(map[codersdk.ChatMessagePartType]bool)
for _, pt := range codersdk.AllChatMessagePartTypes() {