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:
Kyle Carberry
2026-03-25 17:08:27 +00:00
committed by GitHub
parent 6ce35b4af2
commit d9fc5a5be1
10 changed files with 425 additions and 147 deletions
+26 -1
View File
@@ -1115,6 +1115,28 @@ export interface ChatConfig {
readonly acquire_batch_size: number;
}
// From codersdk/chats.go
export interface ChatContextFilePart {
readonly type: "context-file";
/**
* ContextFilePath is the absolute path of a file loaded into
* the LLM context (e.g. an AGENTS.md instruction file).
*/
readonly context_file_path: string;
/**
* ContextFileTruncated indicates the file exceeded the 64KiB
* instruction file limit and was truncated.
*/
readonly context_file_truncated?: boolean;
/**
* 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.
*/
readonly context_file_agent_id?: string;
}
// From codersdk/chats.go
/**
* ChatCostChatBreakdown contains per-root-chat cost aggregation.
@@ -1375,10 +1397,12 @@ export type ChatMessagePart =
| ChatToolResultPart
| ChatSourcePart
| ChatFilePart
| ChatFileReferencePart;
| ChatFileReferencePart
| ChatContextFilePart;
// From codersdk/chats.go
export type ChatMessagePartType =
| "context-file"
| "file"
| "file-reference"
| "reasoning"
@@ -1388,6 +1412,7 @@ export type ChatMessagePartType =
| "tool-result";
export const ChatMessagePartTypes: ChatMessagePartType[] = [
"context-file",
"file",
"file-reference",
"reasoning",
@@ -437,6 +437,12 @@ const ChatMessageItem = memo<{
return null;
}
// Hide messages that consist entirely of context-file parts.
// These are metadata for the context indicator, not
// conversation content.
if (parts.length > 0 && parts.every((p) => p.type === "context-file")) {
return null;
}
const hasRenderableContent =
parsed.blocks.length > 0 ||
parsed.tools.length > 0 ||
@@ -207,6 +207,11 @@ export const parseMessageContent = (
}
break;
}
case "context-file": {
// Context files are metadata for the context indicator;
// they are not rendered in the conversation timeline.
break;
}
default: {
const _exhaustive: never = part;
break;
@@ -168,6 +168,9 @@ export const applyMessagePartToStreamState = (
// file-reference parts only appear in persisted messages
// from user input, never via SSE streaming.
case "file-reference":
// context-file parts are metadata-only; no streaming
// render needed.
case "context-file":
return prev;
default: {
const _exhaustive: never = part;