Files
coder/codersdk/chats_test.go
T
Kyle Carberry d9fc5a5be1 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
2026-03-25 17:08:27 +00:00

421 lines
13 KiB
Go

package codersdk_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/codersdk"
)
func TestChatModelProviderOptions_MarshalJSON_UsesPlainProviderPayload(t *testing.T) {
t.Parallel()
sendReasoning := true
effort := "high"
raw, err := json.Marshal(codersdk.ChatModelProviderOptions{
Anthropic: &codersdk.ChatModelAnthropicProviderOptions{
SendReasoning: &sendReasoning,
Effort: &effort,
},
})
require.NoError(t, err)
require.NotContains(t, string(raw), `"type":"anthropic.options"`)
require.NotContains(t, string(raw), `"data":`)
require.Contains(t, string(raw), `"send_reasoning":true`)
require.Contains(t, string(raw), `"effort":"high"`)
}
func TestChatModelProviderOptions_UnmarshalJSON_ParsesPlainProviderPayloads(t *testing.T) {
t.Parallel()
raw := []byte(`{
"anthropic": {
"send_reasoning": true,
"effort": "high"
}
}`)
var decoded codersdk.ChatModelProviderOptions
err := json.Unmarshal(raw, &decoded)
require.NoError(t, err)
require.NotNil(t, decoded.Anthropic)
require.NotNil(t, decoded.Anthropic.SendReasoning)
require.True(t, *decoded.Anthropic.SendReasoning)
require.NotNil(t, decoded.Anthropic.Effort)
require.Equal(
t,
"high",
*decoded.Anthropic.Effort,
)
}
func TestChatUsageLimitExceededFrom(t *testing.T) {
t.Parallel()
t.Run("ExtractsTyped409", func(t *testing.T) {
t.Parallel()
want := codersdk.ChatUsageLimitExceededResponse{
Response: codersdk.Response{Message: "Chat usage limit exceeded."},
SpentMicros: 123,
LimitMicros: 456,
ResetsAt: time.Date(2026, time.March, 16, 12, 0, 0, 0, time.UTC),
}
srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/api/experimental/chats", r.URL.Path)
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusConflict)
require.NoError(t, json.NewEncoder(rw).Encode(want))
}))
defer srv.Close()
serverURL, err := url.Parse(srv.URL)
require.NoError(t, err)
client := codersdk.NewExperimentalClient(codersdk.New(serverURL))
_, err = client.CreateChat(context.Background(), codersdk.CreateChatRequest{
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "hello",
}},
})
require.Error(t, err)
sdkErr, ok := codersdk.AsError(err)
require.True(t, ok)
require.Equal(t, http.StatusConflict, sdkErr.StatusCode())
require.Equal(t, want.Message, sdkErr.Message)
limitErr := codersdk.ChatUsageLimitExceededFrom(err)
require.NotNil(t, limitErr)
require.Equal(t, want, *limitErr)
})
t.Run("ReturnsNilForNonLimitErrors", func(t *testing.T) {
t.Parallel()
require.Nil(t, codersdk.ChatUsageLimitExceededFrom(codersdk.NewError(http.StatusConflict, codersdk.Response{Message: "plain conflict"})))
srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusBadRequest)
require.NoError(t, json.NewEncoder(rw).Encode(codersdk.Response{Message: "Invalid request."}))
}))
defer srv.Close()
serverURL, err := url.Parse(srv.URL)
require.NoError(t, err)
client := codersdk.NewExperimentalClient(codersdk.New(serverURL))
_, err = client.CreateChat(context.Background(), codersdk.CreateChatRequest{
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "hello",
}},
})
require.Error(t, err)
sdkErr, ok := codersdk.AsError(err)
require.True(t, ok)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Nil(t, codersdk.ChatUsageLimitExceededFrom(err))
})
}
func TestChatMessagePart_StripInternal(t *testing.T) {
t.Parallel()
t.Run("StripsProviderMetadata", func(t *testing.T) {
t.Parallel()
part := codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeToolCall,
ToolCallID: "call-1",
ToolName: "some_tool",
Args: json.RawMessage(`{"key":"value"}`),
ProviderMetadata: json.RawMessage(`{"type":"ephemeral"}`),
}
part.StripInternal()
assert.Nil(t, part.ProviderMetadata)
// Public fields preserved.
assert.Equal(t, codersdk.ChatMessagePartTypeToolCall, part.Type)
assert.Equal(t, "call-1", part.ToolCallID)
assert.Equal(t, "some_tool", part.ToolName)
assert.JSONEq(t, `{"key":"value"}`, string(part.Args))
})
t.Run("StripsFileDataWhenFileIDSet", func(t *testing.T) {
t.Parallel()
id := uuid.New()
part := codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeFile,
FileID: uuid.NullUUID{UUID: id, Valid: true},
MediaType: "image/png",
Data: []byte("binary-payload"),
}
part.StripInternal()
assert.Nil(t, part.Data)
assert.Equal(t, id, part.FileID.UUID)
assert.Equal(t, "image/png", part.MediaType)
})
t.Run("PreservesDataWhenNoFileID", func(t *testing.T) {
t.Parallel()
part := codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeFile,
MediaType: "image/png",
Data: []byte("inline-data"),
}
part.StripInternal()
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")
part.StripInternal()
assert.Equal(t, "hello", part.Text)
assert.Equal(t, codersdk.ChatMessagePartTypeText, part.Type)
})
}
// TestChatMessagePartVariantTags validates the `variants` struct tags
// on ChatMessagePart fields. Every field must either declare variant
// membership or be explicitly excluded, and every known part type
// must appear in at least one tag.
//
// If this test fails, edit the variants struct tags on ChatMessagePart
// in codersdk/chats.go.
func TestChatMessagePartVariantTags(t *testing.T) {
t.Parallel()
const editHint = "edit the variants struct tags on ChatMessagePart in codersdk/chats.go"
// Fields intentionally excluded from all generated variants.
// 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",
"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
}
// Parse all variants tags from the struct and validate them.
typ := reflect.TypeOf(codersdk.ChatMessagePart{})
coveredTypes := make(map[codersdk.ChatMessagePartType]bool)
for i := range typ.NumField() {
f := typ.Field(i)
jsonTag := f.Tag.Get("json")
if jsonTag == "" || jsonTag == "-" {
continue
}
jsonName, _, _ := strings.Cut(jsonTag, ",")
varTag := f.Tag.Get("variants")
if varTag == "" {
assert.Contains(t, excludedFields, jsonName,
"field %s (json:%q) has no variants tag and is not in excludedFields; %s",
f.Name, jsonName, editHint)
continue
}
assert.NotEqual(t, "type", jsonName,
"the discriminant field must not have a variants tag; %s", editHint)
for _, entry := range strings.Split(varTag, ",") {
typeLit := codersdk.ChatMessagePartType(strings.TrimSuffix(entry, "?"))
assert.True(t, knownTypes[typeLit],
"field %s variants tag references unknown type %q; %s",
f.Name, typeLit, editHint)
coveredTypes[typeLit] = true
}
}
// Every known type must appear in at least one variants tag.
for pt := range knownTypes {
assert.True(t, coveredTypes[pt],
"ChatMessagePartType %q is not referenced by any variants tag; %s", pt, editHint)
}
// Enforce the omitempty <-> variants invariant:
// required in any variant => must NOT have omitempty
// optional in all variants => MUST have omitempty
// See the struct comment on ChatMessagePart for rationale.
t.Run("omitempty must match variant optionality", func(t *testing.T) {
t.Parallel()
typ := reflect.TypeOf(codersdk.ChatMessagePart{})
for i := range typ.NumField() {
f := typ.Field(i)
varTag := f.Tag.Get("variants")
if varTag == "" {
continue
}
allOptional := true
for _, entry := range strings.Split(varTag, ",") {
if !strings.HasSuffix(entry, "?") {
allOptional = false
break
}
}
jsonTag := f.Tag.Get("json")
hasOmitEmpty := strings.Contains(jsonTag, "omitempty")
if !allOptional {
assert.False(t, hasOmitEmpty,
"field %s is required in at least one variant but has omitempty in its json tag; "+
"remove omitempty so Go does not silently drop the zero value that TypeScript expects to always be present",
f.Name)
} else {
assert.True(t, hasOmitEmpty,
"field %s is optional in all variants but is missing omitempty in its json tag; "+
"add omitempty to avoid sending zero values for fields the frontend does not expect",
f.Name)
}
}
})
}
func TestModelCostConfig_LegacyNumericJSON(t *testing.T) {
t.Parallel()
var decoded codersdk.ModelCostConfig
err := json.Unmarshal([]byte("{\"input_price_per_million_tokens\": 1.5}"), &decoded)
require.NoError(t, err)
require.NotNil(t, decoded.InputPricePerMillionTokens)
require.True(t, decoded.InputPricePerMillionTokens.Equal(decimal.RequireFromString("1.5")))
}
func TestModelCostConfig_QuotedDecimalJSON(t *testing.T) {
t.Parallel()
var decoded codersdk.ModelCostConfig
err := json.Unmarshal([]byte("{\"input_price_per_million_tokens\": \"1.5\"}"), &decoded)
require.NoError(t, err)
require.NotNil(t, decoded.InputPricePerMillionTokens)
require.True(t, decoded.InputPricePerMillionTokens.Equal(decimal.RequireFromString("1.5")))
}
func TestModelCostConfig_NilVsZero(t *testing.T) {
t.Parallel()
zero := decimal.Zero
raw, err := json.Marshal(struct {
Nil codersdk.ModelCostConfig `json:"nil"`
Zero codersdk.ModelCostConfig `json:"zero"`
}{
Nil: codersdk.ModelCostConfig{},
Zero: codersdk.ModelCostConfig{InputPricePerMillionTokens: &zero},
})
require.NoError(t, err)
require.Contains(t, string(raw), "\"zero\":{\"input_price_per_million_tokens\":\"0\"}")
require.Contains(t, string(raw), "\"nil\":{}")
}
func TestChatModelCallConfig_UnmarshalLegacyPricing(t *testing.T) {
t.Parallel()
var decoded codersdk.ChatModelCallConfig
err := json.Unmarshal([]byte("{\"input_price_per_million_tokens\": 1.5}"), &decoded)
require.NoError(t, err)
require.NotNil(t, decoded.Cost)
require.NotNil(t, decoded.Cost.InputPricePerMillionTokens)
require.True(t, decoded.Cost.InputPricePerMillionTokens.Equal(decimal.RequireFromString("1.5")))
}
func TestChatCostSummary_JSONRoundTrip(t *testing.T) {
t.Parallel()
original := codersdk.ChatCostSummary{
TotalCostMicros: 123,
}
raw, err := json.Marshal(original)
require.NoError(t, err)
var decoded codersdk.ChatCostSummary
err = json.Unmarshal(raw, &decoded)
require.NoError(t, err)
require.Equal(t, original.TotalCostMicros, decoded.TotalCostMicros)
}
//nolint:tparallel,paralleltest
func TestParseChatWorkspaceTTL(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
want time.Duration
wantErr bool
}{
{"Empty_ReturnsDefault", "", 0, false},
{"ValidDuration_Hours", "2h", 2 * time.Hour, false},
{"ValidDuration_HoursAndMinutes", "2h30m", 2*time.Hour + 30*time.Minute, false},
{"ValidDuration_Minutes", "90m", 90 * time.Minute, false},
{"Zero", "0s", 0, false},
{"Negative", "-1h", 0, true},
{"Invalid", "not-a-duration", 0, true},
{"LargeDuration", "720h", 720 * time.Hour, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := codersdk.ParseChatWorkspaceTTL(tc.input)
if tc.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tc.want, got)
})
}
}