mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: persist chat instruction files as context-file message parts (#23592)
## Summary Introduces a new `context-file` ChatMessagePart type for persisting workspace instruction files (AGENTS.md) as durable, frontend-visible message parts. This is the foundation for showing loaded context files in the chat input's context indicator tooltip. ### Problem Previously, instruction files were resolved transiently on every turn via `resolveInstructions()` → `InsertSystem()` and injected into the in-memory prompt without persistence. The frontend had no knowledge that instruction files were loaded into context, and there was no way to surface this information to users. ### Solution Instruction files are now read **once** when a workspace is first attached to a chat (matching how [openai/codex handles it](https://developers.openai.com/codex/guides/agents-md)) and persisted as `user`-role, `both`-visibility message parts with a new `context-file` type. This ensures: - **Durability**: survives page refresh (data is in the DB, returned by `getChatMessages`) - **Cache-friendly**: `user`-role avoids the system-message hoisting that providers do, keeping the instruction content in a stable position for prompt caching - **Frontend-visible**: the frontend receives paths and truncation status for future context indicator rendering - **Extensible**: the same pattern works for Skills (future) ### Key changes | Layer | Change | |---|---| | **SDK** (`codersdk/chats.go`) | Add `ChatMessagePartTypeContextFile` with `context_file_path`, `context_file_content` (internal, stripped from API), `context_file_truncated` fields | | **Prompt expansion** (`chatprompt`) | Expand `context-file` parts to `<workspace-context>` text blocks in `partsToMessageParts()` | | **Chat engine** (`chatd.go`) | Add `persistInstructionFiles()`, called on first turn with a workspace. Remove per-turn `resolveInstructions()` + `InsertSystem()` from `processChat()` and `ReloadMessages` | | **Frontend** | Ignore `context-file` parts in `messageParsing.ts` and `streamState.ts` (no rendering yet — follow-up will add tooltip display) | ### How it works 1. On each turn, `processChat` checks if any loaded message contains `context-file` parts 2. If not (first turn with a workspace), reads AGENTS.md files via the workspace agent connection and persists them 3. For this first turn, also injects the instruction text into the prompt (since messages were loaded before persistence) 4. On all subsequent turns, `ConvertMessagesWithFiles()` encounters the persisted `context-file` parts and expands them into text automatically — no extra resolution needed
This commit is contained in:
@@ -107,6 +107,7 @@ const (
|
||||
ChatMessagePartTypeSource ChatMessagePartType = "source"
|
||||
ChatMessagePartTypeFile ChatMessagePartType = "file"
|
||||
ChatMessagePartTypeFileReference ChatMessagePartType = "file-reference"
|
||||
ChatMessagePartTypeContextFile ChatMessagePartType = "context-file"
|
||||
)
|
||||
|
||||
// AllChatMessagePartTypes returns all known ChatMessagePartType values.
|
||||
@@ -119,6 +120,7 @@ func AllChatMessagePartTypes() []ChatMessagePartType {
|
||||
ChatMessagePartTypeSource,
|
||||
ChatMessagePartTypeFile,
|
||||
ChatMessagePartTypeFileReference,
|
||||
ChatMessagePartTypeContextFile,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +177,31 @@ type ChatMessagePart struct {
|
||||
// ProviderExecuted indicates the tool call was executed by
|
||||
// the provider (e.g. Anthropic computer use).
|
||||
ProviderExecuted bool `json:"provider_executed,omitempty" variants:"tool-call?,tool-result?"`
|
||||
// ContextFilePath is the absolute path of a file loaded into
|
||||
// the LLM context (e.g. an AGENTS.md instruction file).
|
||||
ContextFilePath string `json:"context_file_path" variants:"context-file"`
|
||||
// ContextFileContent holds the file content sent to the LLM.
|
||||
// Internal only: stripped before API responses to keep
|
||||
// payloads small. The backend reads it when building the
|
||||
// prompt via partsToMessageParts.
|
||||
ContextFileContent string `json:"context_file_content,omitempty" typescript:"-"`
|
||||
// ContextFileTruncated indicates the file exceeded the 64KiB
|
||||
// instruction file limit and was truncated.
|
||||
ContextFileTruncated bool `json:"context_file_truncated,omitempty" variants:"context-file?"`
|
||||
// ContextFileAgentID is the workspace agent that provided
|
||||
// this context file. Used to detect when the agent changes
|
||||
// (e.g. workspace rebuilt) so instruction files can be
|
||||
// re-persisted with fresh content.
|
||||
ContextFileAgentID uuid.NullUUID `json:"context_file_agent_id,omitempty" format:"uuid" variants:"context-file?"`
|
||||
// ContextFileOS is the operating system of the workspace
|
||||
// agent. Internal only: used during prompt expansion so
|
||||
// the LLM knows the OS even on turns where InsertSystem
|
||||
// is not called.
|
||||
ContextFileOS string `json:"context_file_os,omitempty" typescript:"-"`
|
||||
// ContextFileDirectory is the working directory of the
|
||||
// workspace agent. Internal only: same purpose as
|
||||
// ContextFileOS.
|
||||
ContextFileDirectory string `json:"context_file_directory,omitempty" typescript:"-"`
|
||||
}
|
||||
|
||||
// StripInternal removes internal-only fields that must not be
|
||||
@@ -188,6 +215,9 @@ func (p *ChatMessagePart) StripInternal() {
|
||||
if p.FileID.Valid {
|
||||
p.Data = nil
|
||||
}
|
||||
p.ContextFileContent = ""
|
||||
p.ContextFileOS = ""
|
||||
p.ContextFileDirectory = ""
|
||||
}
|
||||
|
||||
// ChatMessageText builds a text chat message part.
|
||||
|
||||
+29
-5
@@ -184,6 +184,28 @@ func TestChatMessagePart_StripInternal(t *testing.T) {
|
||||
assert.Equal(t, []byte("inline-data"), part.Data)
|
||||
})
|
||||
|
||||
t.Run("StripsContextFileContent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
agentID := uuid.New()
|
||||
part := codersdk.ChatMessagePart{
|
||||
Type: codersdk.ChatMessagePartTypeContextFile,
|
||||
ContextFilePath: "/home/coder/AGENTS.md",
|
||||
ContextFileContent: "large content",
|
||||
ContextFileAgentID: uuid.NullUUID{UUID: agentID, Valid: true},
|
||||
ContextFileOS: "linux",
|
||||
ContextFileDirectory: "/home/coder/project",
|
||||
}
|
||||
part.StripInternal()
|
||||
// Internal fields stripped.
|
||||
assert.Empty(t, part.ContextFileContent)
|
||||
assert.Empty(t, part.ContextFileOS)
|
||||
assert.Empty(t, part.ContextFileDirectory)
|
||||
// Public fields preserved.
|
||||
assert.Equal(t, "/home/coder/AGENTS.md", part.ContextFilePath)
|
||||
assert.Equal(t, agentID, part.ContextFileAgentID.UUID)
|
||||
assert.True(t, part.ContextFileAgentID.Valid)
|
||||
})
|
||||
|
||||
t.Run("NoopOnCleanPart", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
part := codersdk.ChatMessageText("hello")
|
||||
@@ -209,12 +231,14 @@ func TestChatMessagePartVariantTags(t *testing.T) {
|
||||
// If you add a new field to ChatMessagePart, either add a
|
||||
// variants tag or add it here with a comment explaining why.
|
||||
excludedFields := map[string]string{
|
||||
"type": "discriminant, added automatically by codegen",
|
||||
"signature": "added in #22290, never populated by any code path",
|
||||
"result_delta": "added in #22290, never populated by any code path",
|
||||
"provider_metadata": "internal only, stripped by db2sdk before API responses",
|
||||
"type": "discriminant, added automatically by codegen",
|
||||
"signature": "added in #22290, never populated by any code path",
|
||||
"result_delta": "added in #22290, never populated by any code path",
|
||||
"provider_metadata": "internal only, stripped by db2sdk before API responses",
|
||||
"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:\"-\")",
|
||||
}
|
||||
|
||||
knownTypes := make(map[codersdk.ChatMessagePartType]bool)
|
||||
for _, pt := range codersdk.AllChatMessagePartTypes() {
|
||||
knownTypes[pt] = true
|
||||
|
||||
Reference in New Issue
Block a user