Files
coder/coderd/x/chatd/subagent_internal_test.go
T
Michael Suchacz c17bed25e0 feat: wire chat lifecycle hooks into chatd (#27429)
Wires chat lifecycle hooks into chatd, gated by the
`agent-lifecycle-hooks` experiment. Part of the lifecycle hooks stack
(#27401, #27428, #27430). See `docs/admin/setup/chat-lifecycle-hooks.md`
for the consumer-facing contract.

## Summary

When a hook URL is configured, chatd dispatches `session_start`,
`user_prompt_submit`, `pre_tool_use`, `post_tool_use`, `pre_compact`,
`post_compact`, and `stop` events to the consumer and applies its
responses.

## Design

- **Stateless**: Coder stores no hook dispatch or decision state.
Delivery is at least once; consumers deduplicate on stable payload
identifiers (chat ID, event type, tool-use ID) and answer duplicates
with the same decision.
- **Admission-time prompt effects**: `user_prompt_submit` dispatches
exactly once per submission (create, send, queue, edit, subagent spawn)
and folds its effects into the stored prompt as typed message parts:
original-or-overridden user parts, then model-only `hook-context`, then
a user-visible `hook-notice`. Hook context is stripped from every
client-facing conversion; hook notices are excluded from model prompts.
The server rejects hook parts in client-submitted content.
- **Tool gating**: `pre_tool_use` allow can override tool input; deny
becomes a synthetic denied tool result, with any returned model context
persisted as a model-only transcript row so it never reaches clients.
The denial text identifies an external policy (the deployment's
lifecycle hook) as the source and marks the decision as persistent, so
the model explains the denial instead of retrying it or misreporting it
as an infrastructure failure.
- **Fail closed**: a dispatch failure rejects the triggering request or
moves the chat to the error state in the same transaction as the
affected step, so a runnable state is never published with unapproved
content.
- **Admission before persistence**: `pre_tool_use` is dispatched for the
calls the model produced, before the assistant message is stored. See
"Staged tool admission" below.
- **Fresh dispatch per tool call**: every non-provider-executed tool
call is decided by its own `pre_tool_use` dispatch; Coder never reuses
an earlier decision on the consumer's behalf. Retries re-dispatch the
same logical event.

## Structure

All hook dispatch flows through one seam: entry points build a
`chathooks.Chat` (chat identity) and a `chathooks.Message` (event
details) and call `Trigger.Trigger`, the only component that talks to
the dispatcher. The integration lives in the `coderd/x/chatd/chathooks`
subpackage, split by responsibility:

- `trigger.go`: the trigger seam; builds the wire envelope per event,
normalizes deny into a typed error, and holds the package's single
enabled-check.
- `effects.go`: pure conversion of hook results into transcript rows and
prompt parts.
- `errors.go`: failure classification (dispatch error messages, denial
mapping, tool-result dispatch-failure scanning).
- `tooluse.go`: the tool-call gate (`pre_tool_use` preflight,
`post_tool_use` payloads, applying admitted input to the step).

Server-bound glue stays in `coderd/x/chatd/hook_server.go`: the
chat-parking dispatch error handlers, the step-commit row insertion
wrappers, and the dynamic post-tool-use state loader, which depends on
chatd validation types.

This PR adopts the `codersdk/x/agenthooks` and
`coderd/x/agenthooks/dispatch` import paths introduced at the tip of
#27401; intermediate commits still reference the pre-move paths and are
not individually buildable.

## Staged tool admission

`pre_tool_use` originally ran at tool execution time, which is after the
assistant message carrying the tool call was already committed. An
`input_override` therefore had to rewrite stored message content in
place. @hugodutka pointed out that chatd treats message content as
immutable, and that the rewrite was a shortcut rather than a
requirement.

It was also a correctness problem in its own right: the rewrite only
updated the database, so the transcript could show one input while a
different one had executed.

The hook now runs before the step is persisted:

```text
provider stream ends (tool calls complete, in memory)
  -> pre_tool_use dispatch per call
  -> ONE transaction: assistant row with admitted inputs, synthetic denials, hook rows
  -> execute
```

The step is inserted once, carrying the input the tool runs with.
`UpdateChatMessageContentByID` and `Tx.UpdateMessageContent` are deleted
from #27428, so message content stays immutable.

Two consequences, both intentional:

- **Clients converge rather than wait.** Tool-call parts still stream
live, so a rewritten call briefly shows the model's proposed input
before the committed message replaces it. The chat store already clears
stream state when an assistant message arrives, so the stored input wins
with no frontend change and no added latency before tool cards appear.
- **A call already in history was already admitted.** Execution consumes
the stored input instead of dispatching a second decision, which keeps
one dispatch and one set of hook effects per call. A consumer policy
change between admission and execution applies to later calls, not to
calls already admitted.

The per-chat debug endpoint still records the provider's original tool
input. Its purpose is to report provider behavior, and it requires an
explicit per-chat debug flag; the invariant here covers the transcript.

## Configuration

Adds `chat-hook-url`, `chat-hook-secret`, `chat-hook-timeout`, and
`chat-hook-enabled` deployment options with startup validation. The
flags are hidden from `coder server --help` while the feature is
experimental; the setup guide documents them.

## Tool input validation

Built-in tool arguments reach a consumer as raw JSON with key spelling
preserved, but the tools decode those bytes with Go, which matches
struct fields case-insensitively and keeps the last match. A policy
reading `path` could therefore authorize one value while the tool
executed another, and a lone case variant such as `{"PATH":"/secret"}`
was invisible to a policy checking for `path`.

Coder now rejects a built-in tool call whose input repeats a key or
spells a schema property with different capitalization, before the
`pre_tool_use` dispatch, so a consumer is never asked to authorize bytes
whose meaning depends on the reader. Rejected calls produce an error
result the model can retry; unambiguous calls in the same batch still
run. A consumer-authored `input_override` is rechecked after the
dispatch and fails the turn closed, because the model cannot correct it.
Dynamic and MCP inputs are excluded because the client and the workspace
agent execute those calls rather than coderd.

Two paths needed more than a schema check. Execution resolves a
deprecated tool name to its canonical tool, so validation resolves
aliases first. The `edit_files` decoder also reads `search` and
`replace`, which its schema does not advertise, so those aliases are now
matched exactly and their case variants ignored.

A hook denial now returns a structured 403 carrying `kind:
"hook_denied"`, mirroring the dispatch-failure response that already
carries its own kind. Without it a client cannot tell a policy decision
apart from a generic failure, and the chat UI titled a denial "Request
failed". Adding a kind needs no migration: `ChatErrorKind` is persisted
only inside the JSONB `chats.last_error` column, whose decoder accepts
unknown kinds.

The hook docs also correct the tool-input convergence window. A batch
dispatches sequentially before the assistant row commits, so the
original input stays visible for a span that scales with the number of
tool calls in the step rather than a single hook timeout.

> This PR was written by Mux, an AI coding agent, on Mike's behalf.
2026-07-29 11:39:12 +00:00

4835 lines
150 KiB
Go

package chatd
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"sync"
"sync/atomic"
"testing"
"time"
"charm.land/fantasy"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/sqlc-dev/pqtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/aibridge"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/pubsub"
coderdpubsub "github.com/coder/coder/v2/coderd/pubsub"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/x/agenthooks/dispatch"
"github.com/coder/coder/v2/coderd/x/chatd/chathooks"
"github.com/coder/coder/v2/coderd/x/chatd/chatloop"
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
"github.com/coder/quartz"
)
func TestSubagentFallbackChatTitle(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
want string
}{
{
name: "EmptyPrompt",
input: "",
want: "New Chat",
},
{
name: "ShortPrompt",
input: "Open Firefox",
want: "Open Firefox",
},
{
name: "LongPrompt",
input: "Please open the Firefox browser and navigate to the settings page",
want: "Please open the Firefox browser and...",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := subagentFallbackChatTitle(tt.input)
assert.Equal(t, tt.want, got)
})
}
}
type internalTestServerConfig struct {
logger slog.Logger
clock quartz.Clock
startWorker bool
experiments codersdk.Experiments
transportFactory *atomic.Pointer[aibridge.TransportFactory]
}
type internalTestServerOpt func(*internalTestServerConfig)
func withInternalTestServerClock(clk quartz.Clock) internalTestServerOpt {
return func(cfg *internalTestServerConfig) {
cfg.clock = clk
}
}
func withInternalTestServerLogger(logger slog.Logger) internalTestServerOpt {
return func(cfg *internalTestServerConfig) {
cfg.logger = logger
}
}
func withInternalTestServerWorker() internalTestServerOpt {
return func(cfg *internalTestServerConfig) {
cfg.startWorker = true
}
}
func withInternalTestServerExperiments(experiments codersdk.Experiments) internalTestServerOpt {
return func(cfg *internalTestServerConfig) {
cfg.experiments = experiments
}
}
// withInternalTestServerTransportFactory wires an [aibridge.TransportFactory]
// into the server's Config so tests that drive real model generation through
// runSubagentTool or processChat can control the HTTP transport AI Gateway
// routing uses.
func withInternalTestServerTransportFactory(factory aibridge.TransportFactory) internalTestServerOpt {
return func(cfg *internalTestServerConfig) {
cfg.transportFactory = aibridgeTestFactoryPointer(factory)
}
}
func experimentsOrDefault(experiments codersdk.Experiments) codersdk.Experiments {
if experiments == nil {
return codersdk.ExperimentsKnown
}
return experiments
}
// newInternalTestServer creates a passive Server for internal tests with
// custom provider API keys. Pass withInternalTestServerWorker to start the
// background chat worker for tests that need real execution.
func newInternalTestServer(
t *testing.T,
db database.Store,
ps pubsub.Pubsub,
keys chatprovider.ProviderAPIKeys,
opts ...internalTestServerOpt,
) *Server {
t.Helper()
cfg := internalTestServerConfig{
logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}),
}
for _, opt := range opts {
opt(&cfg)
}
server := New(ps, Config{
Logger: cfg.logger,
Database: db,
ReplicaID: uuid.New(),
Clock: cfg.clock,
// Use a very long interval so the background loop
// does not interfere with test assertions.
PendingChatAcquireInterval: testutil.WaitLong,
ProviderAPIKeys: keys,
Experiments: experimentsOrDefault(cfg.experiments),
AIBridgeTransportFactory: cfg.transportFactory,
})
if cfg.startWorker {
server.Start()
}
t.Cleanup(func() {
require.NoError(t, server.Close())
})
return server
}
type subscribeFailingPubsub struct {
pubsub.Pubsub
}
func (subscribeFailingPubsub) Subscribe(_ string, _ pubsub.Listener) (func(), error) {
return nil, xerrors.New("subscribe disabled")
}
func (subscribeFailingPubsub) SubscribeWithErr(_ string, _ pubsub.ListenerWithErr) (func(), error) {
return nil, xerrors.New("subscribe disabled")
}
type subagentTestLogSink struct {
mu sync.Mutex
entries []slog.SinkEntry
}
func (s *subagentTestLogSink) LogEntry(_ context.Context, entry slog.SinkEntry) {
s.mu.Lock()
defer s.mu.Unlock()
s.entries = append(s.entries, entry)
}
func (*subagentTestLogSink) Sync() {}
func (s *subagentTestLogSink) entriesAtLevelWithMessage(
level slog.Level,
message string,
) []slog.SinkEntry {
s.mu.Lock()
defer s.mu.Unlock()
entries := make([]slog.SinkEntry, 0, len(s.entries))
for _, entry := range s.entries {
if entry.Level == level && entry.Message == message {
entries = append(entries, entry)
}
}
return entries
}
// seedInternalChatDeps inserts an OpenAI provider and model config
// into the database and returns the created user, organization,
// and model. This deliberately does NOT create an Anthropic
// provider.
func seedInternalChatDeps(
t *testing.T,
db database.Store,
) (database.User, database.Organization, database.ChatModelConfig) {
t.Helper()
user := dbgen.User(t, db, database.User{})
org := dbgen.Organization(t, db, database.Organization{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
UserID: user.ID,
OrganizationID: org.ID,
})
provider := dbgen.ChatProvider(t, db, database.ChatProvider{
Provider: "openai",
DisplayName: "OpenAI",
})
model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
IsDefault: true,
})
return user, org, model
}
// insertEnabledAnthropicProvider inserts an enabled Anthropic provider for
// the current test user so computer_use flows keep Anthropic credentials
// after provider-key pruning.
func insertEnabledAnthropicProvider(
t *testing.T,
db database.Store,
userID uuid.UUID,
) {
t.Helper()
dbgen.ChatProvider(t, db, database.ChatProvider{
Provider: "anthropic",
DisplayName: "Anthropic",
APIKey: "test-anthropic-key",
CreatedBy: uuid.NullUUID{UUID: userID, Valid: true},
})
}
func insertInternalAIProvider(
t *testing.T,
db database.Store,
providerType database.AIProviderType,
apiKey string,
enabled bool,
) database.AIProvider {
t.Helper()
return dbgen.AIProviderWithOptionalKey(t, db, database.AIProvider{
Type: providerType,
}, apiKey, func(params *database.InsertAIProviderParams) {
params.Enabled = enabled
})
}
func TestCreateChildSubagentChatDispatchesUserPromptSubmit(t *testing.T) {
t.Parallel()
newFixture := func(t *testing.T, handler http.HandlerFunc) (context.Context, database.Store, database.Chat, *Server) {
t.Helper()
ctx := testutil.Context(t, testutil.WaitShort)
db, _ := dbtestutil.NewDB(t)
user := dbgen.User(t, db, database.User{})
org := dbgen.Organization(t, db, database.Organization{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: user.ID, OrganizationID: org.ID})
model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{})
parent := dbgen.Chat(t, db, database.Chat{
OrganizationID: org.ID,
OwnerID: user.ID,
LastModelConfigID: model.ID,
})
apiKey, _ := dbgen.APIKey(t, db, database.APIKey{UserID: user.ID})
ctx = aibridge.WithDelegatedAPIKeyID(ctx, apiKey.ID)
consumer := httptest.NewServer(handler)
t.Cleanup(consumer.Close)
server := &Server{
db: db,
logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}),
hooks: chathooks.NewTrigger(dispatch.New(
slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}),
consumer.Client(),
consumer.URL,
"test-hook-secret-32-bytes-minimum!!",
time.Second,
"test-deployment",
"test-version",
prometheus.NewRegistry(),
)),
}
return ctx, db, parent, server
}
t.Run("Rewrite", func(t *testing.T) {
t.Parallel()
var meta struct {
sync.Mutex
parentChatID string
prompt string
}
ctx, db, parent, server := newFixture(t, func(rw http.ResponseWriter, r *http.Request) {
var request struct {
Type string `json:"type"`
Meta struct {
ParentChatID *uuid.UUID `json:"parent_chat_id"`
} `json:"meta"`
Data struct {
Prompt string `json:"prompt"`
} `json:"data"`
}
require.NoError(t, json.NewDecoder(r.Body).Decode(&request))
require.Equal(t, "user_prompt_submit", request.Type)
meta.Lock()
if request.Meta.ParentChatID != nil {
meta.parentChatID = request.Meta.ParentChatID.String()
}
meta.prompt = request.Data.Prompt
meta.Unlock()
rw.Header().Set("Content-Type", "application/json")
_, _ = rw.Write([]byte(`{
"permission": {"decision": "allow", "input_override": {"prompt": "REVIEWED: inspect"}}
}`))
})
child, err := server.createChildSubagentChatWithOptions(ctx, parent, "inspect the workspace", "", childSubagentChatOptions{})
require.NoError(t, err)
meta.Lock()
require.Equal(t, parent.ID.String(), meta.parentChatID, "spawn dispatch must identify the parent chat")
require.Equal(t, "inspect the workspace", meta.prompt)
meta.Unlock()
messages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ChatID: child.ID})
require.NoError(t, err)
var childUserMessage database.ChatMessage
for _, message := range messages {
if message.Role == database.ChatMessageRoleUser {
childUserMessage = message
break
}
}
require.NotZero(t, childUserMessage.ID)
require.True(t, childUserMessage.Content.Valid)
require.Contains(t, string(childUserMessage.Content.RawMessage), "REVIEWED: inspect",
"the hook rewrite must land as the child's initial prompt")
require.NotContains(t, string(childUserMessage.Content.RawMessage), "inspect the workspace")
})
t.Run("DenyRefusesSpawn", func(t *testing.T) {
t.Parallel()
ctx, db, parent, server := newFixture(t, func(rw http.ResponseWriter, _ *http.Request) {
rw.Header().Set("Content-Type", "application/json")
_, _ = rw.Write([]byte(`{"permission": {"decision": "deny", "reason": "spawn blocked"}, "user_message": "not allowed"}`))
})
_, err := server.createChildSubagentChatWithOptions(ctx, parent, "exfiltrate secrets", "", childSubagentChatOptions{})
var denied *chathooks.UserPromptDeniedError
require.ErrorAs(t, err, &denied)
require.Equal(t, "not allowed", denied.UserMessage)
chats, err := db.GetChildChatsByParentIDs(ctx, database.GetChildChatsByParentIDsParams{
ParentIds: []uuid.UUID{parent.ID},
})
require.NoError(t, err)
require.Empty(t, chats, "a denied spawn must not create a child chat")
})
t.Run("DispatchFailurePropagatesFromSpawnTool", func(t *testing.T) {
t.Parallel()
ctx, db, parent, server := newFixture(t, func(rw http.ResponseWriter, _ *http.Request) {
http.Error(rw, "hook consumer down", http.StatusInternalServerError)
})
tools := server.subagentTools(ctx, func() database.Chat { return parent }, parent.LastModelConfigID)
tool := findToolByName(tools, spawnAgentToolName)
require.NotNil(t, tool)
input, err := json.Marshal(spawnAgentArgs{
Type: subagentTypeExplore,
Prompt: "inspect the workspace",
Title: "sub",
})
require.NoError(t, err)
_, runErr := tool.Run(ctx, fantasy.ToolCall{
ID: uuid.NewString(),
Name: spawnAgentToolName,
Input: string(input),
})
var hookErr *dispatch.Error
require.ErrorAs(t, runErr, &hookErr,
"dispatch failures must fail closed, not degrade to a tool error the model can ignore")
chats, err := db.GetChildChatsByParentIDs(ctx, database.GetChildChatsByParentIDsParams{
ParentIds: []uuid.UUID{parent.ID},
})
require.NoError(t, err)
require.Empty(t, chats)
})
}
func TestResolveUserProviderAPIKeys_AIProvider(t *testing.T) {
t.Parallel()
t.Run("UserKeyWinsWhenBYOKEnabled", func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, _, _ := seedInternalChatDeps(t, db)
provider := insertInternalAIProvider(t, db, database.AIProviderTypeOpenai, "provider-api-key", true)
now := time.Now()
_, err := db.UpsertUserAIProviderKey(ctx, database.UpsertUserAIProviderKeyParams{
ID: uuid.New(),
UserID: user.ID,
AIProviderID: provider.ID,
APIKey: "user-api-key",
CreatedAt: now,
UpdatedAt: now,
})
require.NoError(t, err)
keys, err := server.resolveUserProviderAPIKeys(ctx, user.ID, provider.ID)
require.NoError(t, err)
require.Equal(t, "user-api-key", keys.APIKey("openai"))
// The expected URL is dbgen's default AIProvider BaseUrl.
require.Equal(t, "invalid://test.invalid/", keys.BaseURL("openai"))
})
t.Run("ProviderKeyUsedWhenBYOKDisabled", func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
server.allowBYOK = false
ctx := chatdTestContext(t)
user, _, _ := seedInternalChatDeps(t, db)
provider := insertInternalAIProvider(t, db, database.AIProviderTypeOpenai, "provider-api-key", true)
now := time.Now()
_, err := db.UpsertUserAIProviderKey(ctx, database.UpsertUserAIProviderKeyParams{
ID: uuid.New(),
UserID: user.ID,
AIProviderID: provider.ID,
APIKey: "user-api-key",
CreatedAt: now,
UpdatedAt: now,
})
require.NoError(t, err)
keys, err := server.resolveUserProviderAPIKeys(ctx, user.ID, provider.ID)
require.NoError(t, err)
require.Equal(t, "provider-api-key", keys.APIKey("openai"))
})
t.Run("ProviderTypeUsesAIProvider", func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, _, _ := seedInternalChatDeps(t, db)
insertInternalAIProvider(t, db, database.AIProviderTypeAzure, "provider-api-key", true)
keys, err := server.resolveUserProviderAPIKeysForProviderType(ctx, user.ID, "azure")
require.NoError(t, err)
require.Equal(t, "provider-api-key", keys.APIKey("azure"))
})
t.Run("BedrockUsesAmbientAuth", func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, _, _ := seedInternalChatDeps(t, db)
provider := insertInternalAIProvider(t, db, database.AIProviderTypeBedrock, "", true)
keys, err := server.resolveUserProviderAPIKeys(ctx, user.ID, provider.ID)
require.NoError(t, err)
require.True(t, keys.HasProvider("bedrock"))
require.Empty(t, keys.APIKey("bedrock"))
})
t.Run("RejectsAmbiguousProviderTypeWithoutSelectedProvider", func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, _, _ := seedInternalChatDeps(t, db)
insertInternalAIProvider(t, db, database.AIProviderTypeOpenai, "first-provider-api-key", true)
insertInternalAIProvider(t, db, database.AIProviderTypeOpenai, "second-provider-api-key", true)
keys, err := server.resolveUserProviderAPIKeys(ctx, user.ID, uuid.Nil)
require.ErrorContains(t, err, "multiple enabled AI providers use provider type")
require.Equal(t, chatprovider.ProviderAPIKeys{}, keys)
})
}
func TestResolveChatModel_AIProviderDisabled(t *testing.T) {
t.Parallel()
ctx := chatdTestContext(t)
db, ps := dbtestutil.NewDB(t)
user, org, _ := seedInternalChatDeps(t, db)
provider := insertInternalAIProvider(t, db, database.AIProviderTypeOpenai, "provider-api-key", false)
modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
Model: "gpt-4o-mini",
AIProviderID: uuid.NullUUID{
UUID: provider.ID,
Valid: true,
},
})
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: org.ID,
OwnerID: user.ID,
LastModelConfigID: modelConfig.ID,
})
model, config, _, debugEnabled, resolvedProvider, resolvedModel, err := server.resolveChatModel(ctx, chat, modelBuildOptions{})
require.ErrorContains(t, err, "is disabled")
require.Nil(t, model)
require.Equal(t, database.ChatModelConfig{}, config)
require.False(t, debugEnabled)
require.Empty(t, resolvedProvider)
require.Empty(t, resolvedModel)
}
func TestResolveUserProviderAPIKeys_PreservesAnthropicKeyFromDBProvider(t *testing.T) {
t.Parallel()
t.Run("PreservesDBProviderKeyWithoutFallback", func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, _, _ := seedInternalChatDeps(t, db)
insertEnabledAnthropicProvider(t, db, user.ID)
keys, err := server.resolveUserProviderAPIKeys(ctx, user.ID, uuid.Nil)
require.NoError(t, err)
require.Equal(t, "test-anthropic-key", keys.Anthropic)
require.Equal(t, "test-anthropic-key", keys.APIKey("anthropic"))
require.Equal(t, "test-anthropic-key", keys.ByProvider["anthropic"])
})
t.Run("PrunesFallbackKeyWithoutEnabledProvider", func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{
Anthropic: "test-anthropic-key",
})
ctx := chatdTestContext(t)
user, _, _ := seedInternalChatDeps(t, db)
keys, err := server.resolveUserProviderAPIKeys(ctx, user.ID, uuid.Nil)
require.NoError(t, err)
require.Empty(t, keys.Anthropic)
require.Empty(t, keys.APIKey("anthropic"))
_, ok := keys.ByProvider["anthropic"]
require.False(t, ok)
})
}
func insertInternalChatModelConfig(
t *testing.T,
db database.Store,
model string,
enabled bool,
) database.ChatModelConfig {
return insertInternalChatModelConfigForProvider(
t,
db,
"openai",
model,
enabled,
)
}
func insertInternalChatProvider(
t *testing.T,
db database.Store,
userID uuid.UUID,
provider string,
apiKey string,
centralAPIKeyEnabled bool,
allowUserAPIKey bool,
allowCentralAPIKeyFallback bool,
) database.AIProvider {
t.Helper()
providerConfig := dbgen.AIProvider(t, db, database.AIProvider{
Type: database.AIProviderType(provider),
Name: "test-" + uuid.NewString(),
DisplayName: sql.NullString{String: provider, Valid: true},
})
if apiKey != "" {
dbgen.AIProviderKey(t, db, database.AIProviderKey{
ProviderID: providerConfig.ID,
APIKey: apiKey,
})
}
return providerConfig
}
func insertInternalChatModelConfigForProvider(
t *testing.T,
db database.Store,
provider string,
model string,
enabled bool,
) database.ChatModelConfig {
t.Helper()
return insertInternalChatModelConfigWithOptions(
t,
db,
provider,
model,
enabled,
json.RawMessage(`{}`),
)
}
func insertInternalChatModelConfigWithOptions(
t *testing.T,
db database.Store,
provider string,
model string,
enabled bool,
options json.RawMessage,
) database.ChatModelConfig {
t.Helper()
// Reuse the newest AI provider of this type (creating a bare credential-less
// one only when none exists) so the config links the provider already
// carrying the test's credentials, or lack thereof, rather than a fresh one.
providers, err := db.GetAIProviders(context.Background(), database.GetAIProvidersParams{IncludeDisabled: true})
require.NoError(t, err)
var aiProvider database.AIProvider
for _, candidate := range providers {
if candidate.Type != database.AIProviderType(provider) {
continue
}
if aiProvider.ID == uuid.Nil || candidate.CreatedAt.After(aiProvider.CreatedAt) {
aiProvider = candidate
}
}
if aiProvider.ID == uuid.Nil {
aiProvider = dbgen.AIProvider(t, db, database.AIProvider{
Type: database.AIProviderType(provider),
})
}
modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
AIProviderID: uuid.NullUUID{UUID: aiProvider.ID, Valid: true},
Model: model,
DisplayName: model,
Options: options,
}, func(p *database.InsertChatModelConfigParams) {
p.Enabled = enabled
})
return modelConfig
}
func insertInternalMCPServerConfig(
t *testing.T,
db database.Store,
userID uuid.UUID,
slug string,
allowInPlanMode bool,
) database.MCPServerConfig {
t.Helper()
return dbgen.MCPServerConfig(t, db, database.MCPServerConfig{
DisplayName: slug,
Slug: slug,
Url: "https://" + slug + ".example.com",
AllowInPlanMode: allowInPlanMode,
CreatedBy: uuid.NullUUID{UUID: userID, Valid: true},
UpdatedBy: uuid.NullUUID{UUID: userID, Valid: true},
})
}
func seedWorkspaceBinding(
t *testing.T,
db database.Store,
userID uuid.UUID,
) (database.WorkspaceTable, database.WorkspaceBuild, database.WorkspaceAgent) {
t.Helper()
org := dbgen.Organization(t, db, database.Organization{})
tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{
OrganizationID: org.ID,
CreatedBy: userID,
})
tpl := dbgen.Template(t, db, database.Template{
CreatedBy: userID,
OrganizationID: org.ID,
ActiveVersionID: tv.ID,
})
workspace := dbgen.Workspace(t, db, database.WorkspaceTable{
TemplateID: tpl.ID,
OwnerID: userID,
OrganizationID: org.ID,
})
job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
InitiatorID: userID,
OrganizationID: org.ID,
})
build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
TemplateVersionID: tv.ID,
WorkspaceID: workspace.ID,
JobID: job.ID,
})
resource := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{
Transition: database.WorkspaceTransitionStart,
JobID: job.ID,
})
agent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ResourceID: resource.ID})
return workspace, build, agent
}
// findToolByName returns the tool with the given name from the
// slice, or nil if no match is found.
func findToolByName(tools []fantasy.AgentTool, name string) fantasy.AgentTool {
for _, tool := range tools {
if tool.Info().Name == name {
return tool
}
}
return nil
}
func chatdTestContext(t *testing.T) context.Context {
t.Helper()
return dbauthz.AsChatd(testutil.Context(t, testutil.WaitLong))
}
func systemRestrictedTestContext(t *testing.T) context.Context {
t.Helper()
return dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitLong))
}
func enableInternalChatPersonalModelOverrides(
t *testing.T,
db database.Store,
) {
t.Helper()
require.NoError(
t,
db.UpsertChatPersonalModelOverridesEnabled(
systemRestrictedTestContext(t),
true,
),
)
}
func upsertInternalUserChatPersonalModelOverride(
t *testing.T,
db database.Store,
userID uuid.UUID,
overrideContext codersdk.ChatPersonalModelOverrideContext,
raw string,
) {
t.Helper()
require.NoError(
t,
db.UpsertUserChatPersonalModelOverride(
systemRestrictedTestContext(t),
database.UpsertUserChatPersonalModelOverrideParams{
UserID: userID,
Key: ChatPersonalModelOverrideKey(overrideContext),
Value: raw,
},
),
)
}
func TestCreateChildSubagentChatInheritsWorkspaceBinding(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
workspace, build, agent := seedWorkspaceBinding(t, db, user.ID)
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
WorkspaceID: uuid.NullUUID{
UUID: workspace.ID,
Valid: true,
},
BuildID: uuid.NullUUID{
UUID: build.ID,
Valid: true,
},
AgentID: uuid.NullUUID{
UUID: agent.ID,
Valid: true,
},
Title: "bound-parent",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
})
require.NoError(t, err)
parentChat, err := db.GetChatByID(ctx, parent.ID)
require.NoError(t, err)
child, err := server.createChildSubagentChatWithOptions(ctx, parentChat, "inspect bindings", "", childSubagentChatOptions{})
require.NoError(t, err)
childChat, err := db.GetChatByID(ctx, child.ID)
require.NoError(t, err)
require.Equal(t, parentChat.OrganizationID, childChat.OrganizationID)
require.Equal(t, parentChat.WorkspaceID, childChat.WorkspaceID)
require.Equal(t, parentChat.BuildID, childChat.BuildID)
require.Equal(t, parentChat.AgentID, childChat.AgentID)
}
func createInternalParentChat(
ctx context.Context,
t *testing.T,
server *Server,
db database.Store,
orgID uuid.UUID,
userID uuid.UUID,
modelConfigID uuid.UUID,
title string,
) database.Chat {
t.Helper()
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: orgID,
OwnerID: userID,
Title: title,
ModelConfigID: modelConfigID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
})
require.NoError(t, err)
parentChat, err := db.GetChatByID(ctx, parent.ID)
require.NoError(t, err)
return parentChat
}
func runSubagentTool(
ctx context.Context,
t *testing.T,
server *Server,
parentChat database.Chat,
currentModelConfigID uuid.UUID,
toolName string,
args any,
) fantasy.ToolResponse {
t.Helper()
tools := server.subagentTools(
ctx,
func() database.Chat { return parentChat },
currentModelConfigID,
)
tool := findToolByName(tools, toolName)
require.NotNil(t, tool, "%s tool must be present", toolName)
input, err := json.Marshal(args)
require.NoError(t, err)
resp, err := tool.Run(ctx, fantasy.ToolCall{
ID: uuid.NewString(),
Name: toolName,
Input: string(input),
})
require.NoError(t, err)
return resp
}
func runSpawnAgentTool(
ctx context.Context,
t *testing.T,
server *Server,
parentChat database.Chat,
args spawnAgentArgs,
) fantasy.ToolResponse {
t.Helper()
return runSubagentTool(
ctx,
t,
server,
parentChat,
parentChat.LastModelConfigID,
spawnAgentToolName,
args,
)
}
func requireSpawnAgentResponse(t *testing.T, resp fantasy.ToolResponse) struct {
ChatID string `json:"chat_id"`
SubagentType string `json:"type"`
} {
t.Helper()
require.False(t, resp.IsError, "expected success but got: %s", resp.Content)
var result struct {
ChatID string `json:"chat_id"`
SubagentType string `json:"type"`
}
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
require.NotEmpty(t, result.ChatID, "response must contain chat_id")
require.NotEmpty(t, result.SubagentType, "response must contain type")
return result
}
func requireSpawnAgentChildChatID(t *testing.T, resp fantasy.ToolResponse) uuid.UUID {
t.Helper()
require.False(t, resp.IsError, "expected success but got: %s", resp.Content)
var result struct {
ChatID string `json:"chat_id"`
}
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
require.NotEmpty(t, result.ChatID, "response must contain chat_id")
childID, err := uuid.Parse(result.ChatID)
require.NoError(t, err)
return childID
}
func requireToolResponseMap(
t *testing.T,
resp fantasy.ToolResponse,
wantError bool,
) map[string]any {
t.Helper()
require.Equal(t, wantError, resp.IsError, "unexpected tool error state: %s", resp.Content)
var result map[string]any
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
return result
}
func TestCreateChildSubagentChatCopiesPlanMode(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
planMode := database.NullChatPlanMode{
ChatPlanMode: database.ChatPlanModePlan,
Valid: true,
}
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "plan-parent",
ModelConfigID: model.ID,
PlanMode: planMode,
InitialUserContent: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("plan this change"),
},
})
require.NoError(t, err)
parentChat, err := db.GetChatByID(ctx, parent.ID)
require.NoError(t, err)
require.Equal(t, planMode, parentChat.PlanMode)
child, err := server.createChildSubagentChatWithOptions(ctx, parentChat, "inspect bindings", "", childSubagentChatOptions{})
require.NoError(t, err)
childChat, err := db.GetChatByID(ctx, child.ID)
require.NoError(t, err)
require.Equal(t, planMode, childChat.PlanMode)
}
func TestSpawnAgent_GeneralInheritsParentModelWhenOmitted(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, model.ID, "parent-inherited-model",
)
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: subagentTypeGeneral,
Prompt: "delegate work",
})
result := requireSpawnAgentResponse(t, resp)
require.Equal(t, subagentTypeGeneral, result.SubagentType)
childID, err := uuid.Parse(result.ChatID)
require.NoError(t, err)
childChat, err := db.GetChatByID(ctx, childID)
require.NoError(t, err)
require.Equal(t, parentChat.LastModelConfigID, childChat.LastModelConfigID)
}
func TestSpawnAgent_GeneralUsesConfiguredModelOverride(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
overrideModel := insertInternalChatModelConfig(
t, db, "general-override-"+uuid.NewString(), true,
)
require.NoError(t, db.UpsertChatGeneralModelOverride(ctx, overrideModel.ID.String()))
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, model.ID, "parent-general-override",
)
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: subagentTypeGeneral,
Prompt: "delegate general work",
})
childID := requireSpawnAgentChildChatID(t, resp)
childChat, err := db.GetChatByID(ctx, childID)
require.NoError(t, err)
require.Equal(t, overrideModel.ID, childChat.LastModelConfigID)
require.False(t, childChat.PlanMode.Valid)
}
func TestSpawnAgent_GeneralHonorsPersonalModelOverrides(t *testing.T) {
t.Parallel()
tests := []struct {
name string
enablePersonalOverride bool
personalRaw func(database.ChatModelConfig) string
personalModel func(context.Context, *testing.T, database.Store, uuid.UUID) database.ChatModelConfig
wantModelID func(
database.ChatModelConfig,
database.ChatModelConfig,
database.ChatModelConfig,
) uuid.UUID
}{
{
name: "UnsetUsesDeploymentOverride",
enablePersonalOverride: true,
wantModelID: func(_, deploymentModel, _ database.ChatModelConfig) uuid.UUID {
return deploymentModel.ID
},
},
{
name: "DeploymentDefaultUsesDeploymentOverride",
enablePersonalOverride: true,
personalRaw: func(database.ChatModelConfig) string {
return string(codersdk.ChatPersonalModelOverrideModeDeploymentDefault)
},
wantModelID: func(_, deploymentModel, _ database.ChatModelConfig) uuid.UUID {
return deploymentModel.ID
},
},
{
name: "ChatDefaultBypassesDeploymentOverride",
enablePersonalOverride: true,
personalRaw: func(database.ChatModelConfig) string {
return string(codersdk.ChatPersonalModelOverrideModeChatDefault)
},
wantModelID: func(parentModel, _, _ database.ChatModelConfig) uuid.UUID {
return parentModel.ID
},
},
{
name: "ModelUsesPersonalOverride",
enablePersonalOverride: true,
personalRaw: func(personalModel database.ChatModelConfig) string {
return string(codersdk.ChatPersonalModelOverrideModeModel) + ":" +
personalModel.ID.String()
},
wantModelID: func(_, _, personalModel database.ChatModelConfig) uuid.UUID {
return personalModel.ID
},
},
{
name: "AdminFlagOffIgnoresPersonalOverride",
personalRaw: func(database.ChatModelConfig) string {
return string(codersdk.ChatPersonalModelOverrideModeChatDefault)
},
wantModelID: func(_, deploymentModel, _ database.ChatModelConfig) uuid.UUID {
return deploymentModel.ID
},
},
{
name: "DisabledPersonalModelFallsBackToDeploymentOverride",
enablePersonalOverride: true,
personalModel: func(
ctx context.Context,
t *testing.T,
db database.Store,
userID uuid.UUID,
) database.ChatModelConfig {
return insertInternalChatModelConfig(
t,
db,
"general-personal-disabled-"+uuid.NewString(),
false,
)
},
personalRaw: func(personalModel database.ChatModelConfig) string {
return string(codersdk.ChatPersonalModelOverrideModeModel) + ":" +
personalModel.ID.String()
},
wantModelID: func(_, deploymentModel, _ database.ChatModelConfig) uuid.UUID {
return deploymentModel.ID
},
},
{
name: "MissingCredentialsFallsBackToDeploymentOverride",
enablePersonalOverride: true,
personalModel: func(
ctx context.Context,
t *testing.T,
db database.Store,
userID uuid.UUID,
) database.ChatModelConfig {
insertInternalChatProvider(
t,
db,
userID,
"openai-compat",
"",
false,
true,
false,
)
return insertInternalChatModelConfigForProvider(
t,
db,
"openai-compat",
"gpt-4o-mini",
true,
)
},
personalRaw: func(personalModel database.ChatModelConfig) string {
return string(codersdk.ChatPersonalModelOverrideModeModel) + ":" +
personalModel.ID.String()
},
wantModelID: func(_, deploymentModel, _ database.ChatModelConfig) uuid.UUID {
return deploymentModel.ID
},
},
{
name: "MalformedValueUsesDeploymentOverride",
enablePersonalOverride: true,
personalRaw: func(database.ChatModelConfig) string {
return "model:not-a-uuid"
},
wantModelID: func(_, deploymentModel, _ database.ChatModelConfig) uuid.UUID {
return deploymentModel.ID
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, parentModel := seedInternalChatDeps(t, db)
deploymentModel := insertInternalChatModelConfig(
t,
db,
"general-deployment-"+uuid.NewString(),
true,
)
require.NoError(t, db.UpsertChatGeneralModelOverride(ctx, deploymentModel.ID.String()))
personalModel := insertInternalChatModelConfig(
t,
db,
"general-personal-"+uuid.NewString(),
true,
)
if tt.personalModel != nil {
personalModel = tt.personalModel(ctx, t, db, user.ID)
}
if tt.enablePersonalOverride {
enableInternalChatPersonalModelOverrides(t, db)
}
if tt.personalRaw != nil {
upsertInternalUserChatPersonalModelOverride(
t,
db,
user.ID,
codersdk.ChatPersonalModelOverrideContextGeneral,
tt.personalRaw(personalModel),
)
}
parentChat := createInternalParentChat(
ctx,
t,
server,
db,
org.ID,
user.ID,
parentModel.ID,
"parent-general-personal-override",
)
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: subagentTypeGeneral,
Prompt: "delegate general work",
})
childID := requireSpawnAgentChildChatID(t, resp)
childChat, err := db.GetChatByID(ctx, childID)
require.NoError(t, err)
require.Equal(
t,
tt.wantModelID(parentModel, deploymentModel, personalModel),
childChat.LastModelConfigID,
)
require.False(t, childChat.PlanMode.Valid)
})
}
}
func TestSpawnAgent_GeneralOverrideLogsAndFallsBackWhenCredentialsUnavailable(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
logSink := &subagentTestLogSink{}
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).AppendSinks(logSink)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{}, withInternalTestServerLogger(logger))
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
insertInternalChatProvider(
t,
db,
user.ID,
"openai-compat",
"",
false,
true,
false,
)
overrideModel := insertInternalChatModelConfigForProvider(
t,
db,
"openai-compat",
"gpt-4o-mini",
true,
)
require.NoError(t, db.UpsertChatGeneralModelOverride(ctx, overrideModel.ID.String()))
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "parent-general-credentials-fallback",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("delegate work"),
},
})
require.NoError(t, err)
parentChat, err := db.GetChatByID(ctx, parent.ID)
require.NoError(t, err)
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: subagentTypeGeneral,
Prompt: "inspect provider credentials",
})
childID := requireSpawnAgentChildChatID(t, resp)
childChat, err := db.GetChatByID(ctx, childID)
require.NoError(t, err)
require.Equal(t, model.ID, childChat.LastModelConfigID)
require.False(t, childChat.PlanMode.Valid)
require.Len(t, logSink.entriesAtLevelWithMessage(
slog.LevelInfo,
"model override credentials are unavailable, ignoring",
), 1)
}
func TestSpawnAgent_GeneralOverrideLogsAndFallsBackWhenProviderDisabled(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
logSink := &subagentTestLogSink{}
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).AppendSinks(logSink)
server := newInternalTestServer(
t,
db,
ps,
chatprovider.ProviderAPIKeys{
ByProvider: map[string]string{
"openai-compat": "fallback-key",
},
},
withInternalTestServerLogger(logger),
)
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
dbgen.ChatProvider(t, db, database.ChatProvider{
Provider: "openai-compat",
DisplayName: "openai-compat",
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
}, func(p *database.InsertChatProviderParams) {
p.APIKey = ""
p.Enabled = false
p.CentralApiKeyEnabled = false
p.AllowUserApiKey = true
p.AllowCentralApiKeyFallback = false
})
overrideModel := insertInternalChatModelConfigForProvider(
t,
db,
"openai-compat",
"gpt-4o-mini",
true,
)
require.NoError(t, db.UpsertChatGeneralModelOverride(ctx, overrideModel.ID.String()))
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "parent-general-disabled-provider-fallback",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("delegate work"),
},
})
require.NoError(t, err)
parentChat, err := db.GetChatByID(ctx, parent.ID)
require.NoError(t, err)
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: subagentTypeGeneral,
Prompt: "inspect disabled providers",
})
childID := requireSpawnAgentChildChatID(t, resp)
childChat, err := db.GetChatByID(ctx, childID)
require.NoError(t, err)
require.Equal(t, model.ID, childChat.LastModelConfigID)
require.False(t, childChat.PlanMode.Valid)
require.Len(t, logSink.entriesAtLevelWithMessage(
slog.LevelInfo,
"model override is unavailable, ignoring",
), 1)
}
func TestResolveConfiguredModelOverride_AcceptsAmbientCredentialsProvider(
t *testing.T,
) {
t.Parallel()
logSink := &subagentTestLogSink{}
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).AppendSinks(logSink)
server := &Server{logger: logger}
ctx := chatdTestContext(t)
ownerID := uuid.New()
modelConfig := database.ChatModelConfig{
ID: uuid.New(),
Model: "anthropic.claude-haiku-4-5-20251001-v1:0",
DisplayName: "Ambient Bedrock Override",
Enabled: true,
}
resolvedModelConfig, _, reasoningEffort, ok, err := server.resolveConfiguredModelOverride(
ctx,
"plan",
modelConfig.ID.String(),
ownerID,
func(
_ context.Context,
configuredModelConfigID uuid.UUID,
) (database.ChatModelConfig, string, error) {
require.Equal(t, modelConfig.ID, configuredModelConfigID)
return modelConfig, "bedrock", nil
},
func(
_ context.Context,
resolvedOwnerID uuid.UUID,
_ uuid.UUID,
) (chatprovider.ProviderAPIKeys, error) {
require.Equal(t, ownerID, resolvedOwnerID)
return chatprovider.ProviderAPIKeys{
ByProvider: map[string]string{"bedrock": ""},
}, nil
},
modelOverrideFailureModeSoft,
)
require.NoError(t, err)
require.Nil(t, reasoningEffort)
require.True(t, ok)
require.Equal(t, modelConfig, resolvedModelConfig)
require.Empty(t, logSink.entriesAtLevelWithMessage(
slog.LevelInfo,
"model override credentials are unavailable, ignoring",
))
}
func TestWithResolvedReasoningEffort(t *testing.T) {
t.Parallel()
baseOptions, err := json.Marshal(codersdk.ChatModelCallConfig{
MaxOutputTokens: ptr.Ref(int64(123)),
ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{
Default: ptr.Ref(codersdk.ChatModelReasoningEffortLow),
Max: ptr.Ref(codersdk.ChatModelReasoningEffortHigh),
},
})
require.NoError(t, err)
t.Run("NilEffortReturnsOriginalConfig", func(t *testing.T) {
t.Parallel()
modelConfig := database.ChatModelConfig{Options: baseOptions}
require.Equal(t, modelConfig, withResolvedReasoningEffort(modelConfig, nil))
})
t.Run("InvalidJSONReturnsOriginalConfig", func(t *testing.T) {
t.Parallel()
modelConfig := database.ChatModelConfig{Options: []byte(`{`)}
require.Equal(t, modelConfig, withResolvedReasoningEffort(modelConfig, ptr.Ref(codersdk.ChatModelReasoningEffortHigh)))
})
t.Run("NoReasoningConfigReturnsOriginalConfig", func(t *testing.T) {
t.Parallel()
modelConfig := database.ChatModelConfig{Options: []byte(`{"max_output_tokens":123}`)}
require.Equal(t, modelConfig, withResolvedReasoningEffort(modelConfig, ptr.Ref(codersdk.ChatModelReasoningEffortHigh)))
})
t.Run("ClampsRequestedEffortAndPreservesOtherOptions", func(t *testing.T) {
t.Parallel()
modelConfig := database.ChatModelConfig{Options: baseOptions}
got := withResolvedReasoningEffort(modelConfig, ptr.Ref(codersdk.ChatModelReasoningEffortXHigh))
var callConfig codersdk.ChatModelCallConfig
require.NoError(t, json.Unmarshal(got.Options, &callConfig))
require.Equal(t, ptr.Ref(int64(123)), callConfig.MaxOutputTokens)
require.Equal(t, ptr.Ref(codersdk.ChatModelReasoningEffortHigh), callConfig.ReasoningEffort.Default)
require.Equal(t, ptr.Ref(codersdk.ChatModelReasoningEffortHigh), callConfig.ReasoningEffort.Max)
})
}
func TestCreateChildSubagentChat_StoresReasoningEffortOverride(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, model.ID, "parent-effort-override",
)
child, err := server.createChildSubagentChatWithOptions(
ctx,
parentChat,
"delegate work",
"",
childSubagentChatOptions{reasoningEffortOverride: ptr.Ref("high")},
)
require.NoError(t, err)
childChat, err := db.GetChatByID(ctx, child.ID)
require.NoError(t, err)
require.Equal(t, database.NullChatReasoningEffort{ChatReasoningEffort: database.ChatReasoningEffortHigh, Valid: true}, childChat.LastReasoningEffort)
}
func TestCreateChildSubagentChat_OverrideWorksWhenParentHasNoModel(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
overrideModel := insertInternalChatModelConfig(
t, db, "override-no-parent-model-"+uuid.NewString(), true,
)
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, model.ID, "parent-no-model",
)
// The chats table enforces a foreign key for last_model_config_id, so
// use a synthetic parent value here to exercise the override path.
parentChat.LastModelConfigID = uuid.Nil
child, err := server.createChildSubagentChatWithOptions(
ctx,
parentChat,
"delegate work",
"",
childSubagentChatOptions{modelConfigIDOverride: &overrideModel.ID},
)
require.NoError(t, err)
childChat, err := db.GetChatByID(ctx, child.ID)
require.NoError(t, err)
require.Equal(t, overrideModel.ID, childChat.LastModelConfigID)
}
func TestSpawnAgent_ExplicitModelConfigID(t *testing.T) {
t.Parallel()
for _, subagentType := range []string{subagentTypeGeneral, subagentTypeExplore} {
t.Run(subagentType, func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, parentModel := seedInternalChatDeps(t, db)
selectedModel := insertInternalChatModelConfig(
t, db, "spawn-explicit-"+uuid.NewString(), true,
)
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, parentModel.ID, "parent-explicit-model",
)
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: subagentType,
Prompt: "delegate work",
ModelConfigID: selectedModel.ID.String(),
})
result := requireSpawnAgentResponse(t, resp)
require.Equal(t, subagentType, result.SubagentType)
childID, err := uuid.Parse(result.ChatID)
require.NoError(t, err)
childChat, err := db.GetChatByID(ctx, childID)
require.NoError(t, err)
require.Equal(t, selectedModel.ID, childChat.LastModelConfigID)
require.False(t, childChat.LastReasoningEffort.Valid)
parentAfter, err := db.GetChatByID(ctx, parentChat.ID)
require.NoError(t, err)
require.Equal(t, parentModel.ID, parentAfter.LastModelConfigID)
})
}
}
type personalOverrideErrorStore struct {
database.Store
}
func (*personalOverrideErrorStore) GetUserChatPersonalModelOverride(
context.Context,
database.GetUserChatPersonalModelOverrideParams,
) (string, error) {
return "", xerrors.New("unexpected personal override lookup")
}
func TestSpawnAgent_ExplicitModelSkipsConfiguredOverrides(t *testing.T) {
t.Parallel()
for _, subagentType := range []string{subagentTypeGeneral, subagentTypeExplore} {
t.Run(subagentType, func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
store := &personalOverrideErrorStore{Store: db}
server := newInternalTestServer(t, store, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, parentModel := seedInternalChatDeps(t, db)
enableInternalChatPersonalModelOverrides(t, db)
selectedModel := insertInternalChatModelConfig(
t, db, "selected-skip-overrides-"+uuid.NewString(), true,
)
parentChat := createInternalParentChat(
ctx, t, server, store, org.ID, user.ID, parentModel.ID, "parent-skip-overrides",
)
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: subagentType,
Prompt: "delegate work",
ModelConfigID: selectedModel.ID.String(),
})
childChat, err := db.GetChatByID(ctx, requireSpawnAgentChildChatID(t, resp))
require.NoError(t, err)
require.Equal(t, selectedModel.ID, childChat.LastModelConfigID)
})
}
}
func TestSpawnAgent_ExplicitModelBeatsConfiguredOverrides(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, parentModel := seedInternalChatDeps(t, db)
deploymentModel := insertInternalChatModelConfig(
t, db, "general-deployment-"+uuid.NewString(), true,
)
personalModel := insertInternalChatModelConfig(
t, db, "general-personal-"+uuid.NewString(), true,
)
selectedModel := insertInternalChatModelConfig(
t, db, "general-selected-"+uuid.NewString(), true,
)
require.NoError(t, db.UpsertChatGeneralModelOverride(
ctx, deploymentModel.ID.String()+":medium",
))
enableInternalChatPersonalModelOverrides(t, db)
upsertInternalUserChatPersonalModelOverride(
t,
db,
user.ID,
codersdk.ChatPersonalModelOverrideContextGeneral,
string(codersdk.ChatPersonalModelOverrideModeModel)+":"+personalModel.ID.String()+":low",
)
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, parentModel.ID, "parent-explicit-beats-overrides",
)
baselineResp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: subagentTypeGeneral,
Prompt: "delegate baseline work",
})
baselineChild, err := db.GetChatByID(ctx, requireSpawnAgentChildChatID(t, baselineResp))
require.NoError(t, err)
require.Equal(t, personalModel.ID, baselineChild.LastModelConfigID)
require.Equal(
t,
database.NullChatReasoningEffort{ChatReasoningEffort: database.ChatReasoningEffortLow, Valid: true},
baselineChild.LastReasoningEffort,
)
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: subagentTypeGeneral,
Prompt: "delegate explicit work",
ModelConfigID: selectedModel.ID.String(),
})
childChat, err := db.GetChatByID(ctx, requireSpawnAgentChildChatID(t, resp))
require.NoError(t, err)
require.Equal(t, selectedModel.ID, childChat.LastModelConfigID)
require.False(t, childChat.LastReasoningEffort.Valid)
}
func TestSpawnAgent_ExplicitReasoningEffort(t *testing.T) {
t.Parallel()
t.Run("EffortOnlyKeepsModelChain", func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, parentModel := seedInternalChatDeps(t, db)
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, parentModel.ID, "parent-effort-only",
)
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: subagentTypeGeneral,
Prompt: "delegate work",
ReasoningEffort: "high",
})
childChat, err := db.GetChatByID(ctx, requireSpawnAgentChildChatID(t, resp))
require.NoError(t, err)
require.Equal(t, parentModel.ID, childChat.LastModelConfigID)
require.Equal(
t,
database.NullChatReasoningEffort{ChatReasoningEffort: database.ChatReasoningEffortHigh, Valid: true},
childChat.LastReasoningEffort,
)
})
t.Run("CombinedModelAndEffort", func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, parentModel := seedInternalChatDeps(t, db)
selectedModel := insertInternalChatModelConfig(
t, db, "combined-"+uuid.NewString(), true,
)
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, parentModel.ID, "parent-combined",
)
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: subagentTypeGeneral,
Prompt: "delegate work",
ModelConfigID: selectedModel.ID.String(),
ReasoningEffort: "low",
})
childChat, err := db.GetChatByID(ctx, requireSpawnAgentChildChatID(t, resp))
require.NoError(t, err)
require.Equal(t, selectedModel.ID, childChat.LastModelConfigID)
require.Equal(
t,
database.NullChatReasoningEffort{ChatReasoningEffort: database.ChatReasoningEffortLow, Valid: true},
childChat.LastReasoningEffort,
)
})
t.Run("BeatsOverrideCarriedEffort", func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, parentModel := seedInternalChatDeps(t, db)
deploymentModel := insertInternalChatModelConfig(
t, db, "general-deployment-"+uuid.NewString(), true,
)
require.NoError(t, db.UpsertChatGeneralModelOverride(
ctx, deploymentModel.ID.String()+":low",
))
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, parentModel.ID, "parent-effort-beats-override",
)
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: subagentTypeGeneral,
Prompt: "delegate work",
ReasoningEffort: "high",
})
childChat, err := db.GetChatByID(ctx, requireSpawnAgentChildChatID(t, resp))
require.NoError(t, err)
require.Equal(t, deploymentModel.ID, childChat.LastModelConfigID)
require.Equal(
t,
database.NullChatReasoningEffort{ChatReasoningEffort: database.ChatReasoningEffortHigh, Valid: true},
childChat.LastReasoningEffort,
)
})
}
func TestSpawnAgent_ModelArgsRejections(t *testing.T) {
t.Parallel()
tests := []struct {
name string
args func(ctx context.Context, t *testing.T, db database.Store, user database.User) spawnAgentArgs
wantError string
}{
{
name: "MalformedModelConfigID",
args: func(context.Context, *testing.T, database.Store, database.User) spawnAgentArgs {
return spawnAgentArgs{ModelConfigID: "not-a-uuid"}
},
wantError: "invalid model_config_id: must be a valid UUID",
},
{
name: "UnknownModelConfigID",
args: func(context.Context, *testing.T, database.Store, database.User) spawnAgentArgs {
return spawnAgentArgs{ModelConfigID: uuid.NewString()}
},
wantError: "model_config_id not found or is disabled",
},
{
name: "DisabledModelConfig",
args: func(_ context.Context, t *testing.T, db database.Store, _ database.User) spawnAgentArgs {
disabled := insertInternalChatModelConfig(
t, db, "reject-disabled-"+uuid.NewString(), false,
)
return spawnAgentArgs{ModelConfigID: disabled.ID.String()}
},
wantError: "model_config_id not found or is disabled",
},
{
name: "DisabledProvider",
args: func(_ context.Context, t *testing.T, db database.Store, user database.User) spawnAgentArgs {
dbgen.ChatProvider(t, db, database.ChatProvider{
Provider: "openai-compat",
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
}, func(p *database.InsertChatProviderParams) {
p.Enabled = false
})
config := insertInternalChatModelConfigForProvider(
t, db, "openai-compat", "gpt-4o-mini", true,
)
return spawnAgentArgs{ModelConfigID: config.ID.String()}
},
wantError: "model_config_id not found or is disabled",
},
{
name: "DeletedProvider",
args: func(_ context.Context, t *testing.T, db database.Store, user database.User) spawnAgentArgs {
provider := dbgen.ChatProvider(t, db, database.ChatProvider{
Provider: "openai-compat",
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
})
config := insertInternalChatModelConfigForProvider(
t, db, "openai-compat", "gpt-4o-mini", true,
)
require.NoError(t, db.DeleteAIProviderByID(
systemRestrictedTestContext(t), provider.ID,
))
return spawnAgentArgs{ModelConfigID: config.ID.String()}
},
wantError: "model_config_id not found or is disabled",
},
{
name: "MissingCredentials",
args: func(_ context.Context, t *testing.T, db database.Store, user database.User) spawnAgentArgs {
insertInternalChatProvider(
t, db, user.ID, "openai-compat", "", false, true, false,
)
config := insertInternalChatModelConfigForProvider(
t, db, "openai-compat", "gpt-4o-mini", true,
)
return spawnAgentArgs{ModelConfigID: config.ID.String()}
},
wantError: `credentials are unavailable for provider "openai-compat"`,
},
{
name: "InvalidReasoningEffort",
args: func(context.Context, *testing.T, database.Store, database.User) spawnAgentArgs {
return spawnAgentArgs{ReasoningEffort: "ultra"}
},
wantError: "invalid reasoning_effort: must be one of",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, model.ID, "parent-rejection",
)
args := tt.args(ctx, t, db, user)
args.Type = subagentTypeGeneral
args.Prompt = "delegate work"
resp := runSpawnAgentTool(ctx, t, server, parentChat, args)
require.True(t, resp.IsError, "expected tool error but got: %s", resp.Content)
require.Contains(t, resp.Content, tt.wantError)
children, err := db.GetChildChatsByParentIDs(ctx, database.GetChildChatsByParentIDsParams{
ParentIds: []uuid.UUID{parentChat.ID},
Archived: sql.NullBool{},
})
require.NoError(t, err)
require.Empty(t, children, "no child chat may be created on rejection")
})
}
}
func TestSpawnAgent_ComputerUseRejectsModelArgs(t *testing.T) {
t.Parallel()
tests := []struct {
name string
muter func(*spawnAgentArgs, database.ChatModelConfig)
}{
{
name: "ModelConfigID",
muter: func(args *spawnAgentArgs, model database.ChatModelConfig) {
args.ModelConfigID = model.ID.String()
},
},
{
name: "ReasoningEffort",
muter: func(args *spawnAgentArgs, _ database.ChatModelConfig) {
args.ReasoningEffort = "high"
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
insertEnabledAnthropicProvider(t, db, user.ID)
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, model.ID, "parent-computer-use-args",
)
args := spawnAgentArgs{
Type: subagentTypeComputerUse,
Prompt: "take a screenshot",
}
tt.muter(&args, model)
resp := runSpawnAgentTool(ctx, t, server, parentChat, args)
require.True(t, resp.IsError, "expected tool error but got: %s", resp.Content)
require.Contains(t, resp.Content, `not supported for type "computer_use"`)
children, err := db.GetChildChatsByParentIDs(ctx, database.GetChildChatsByParentIDsParams{
ParentIds: []uuid.UUID{parentChat.ID},
Archived: sql.NullBool{},
})
require.NoError(t, err)
require.Empty(t, children, "no child chat may be created on rejection")
})
}
}
func TestListSubagentModels(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, defaultModel := seedInternalChatDeps(t, db)
effortModel := insertInternalChatModelConfigWithOptions(
t,
db,
"openai",
"gpt-effort-"+uuid.NewString(),
true,
json.RawMessage(`{"reasoning_effort":{"default":"medium","max":"high"}}`),
)
disabledModel := insertInternalChatModelConfig(
t, db, "list-disabled-"+uuid.NewString(), false,
)
insertInternalChatProvider(t, db, user.ID, "anthropic", "", false, true, false)
noCredsModel := insertInternalChatModelConfigForProvider(
t, db, "anthropic", "claude-sonnet-4-5", true,
)
dbgen.ChatProvider(t, db, database.ChatProvider{
Provider: "openai-compat",
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
}, func(p *database.InsertChatProviderParams) {
p.Enabled = false
})
disabledProviderModel := insertInternalChatModelConfigForProvider(
t, db, "openai-compat", "gpt-4o-mini", true,
)
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, defaultModel.ID, "parent-list-models",
)
resp := runSubagentTool(
ctx,
t,
server,
parentChat,
parentChat.LastModelConfigID,
listSubagentModelsToolName,
struct{}{},
)
require.False(t, resp.IsError, "expected success but got: %s", resp.Content)
type listedModel struct {
ModelConfigID string `json:"model_config_id"`
DisplayName string `json:"display_name"`
Model string `json:"model"`
Provider string `json:"provider"`
ContextLimit int64 `json:"context_limit"`
IsDefault bool `json:"is_default"`
ReasoningEfforts []string `json:"reasoning_efforts"`
}
var result struct {
Models []listedModel `json:"models"`
}
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
byID := make(map[string]listedModel, len(result.Models))
for _, m := range result.Models {
byID[m.ModelConfigID] = m
}
require.Contains(t, byID, defaultModel.ID.String())
require.Contains(t, byID, effortModel.ID.String())
require.NotContains(t, byID, disabledModel.ID.String())
require.NotContains(t, byID, noCredsModel.ID.String())
require.NotContains(t, byID, disabledProviderModel.ID.String())
defaultEntry := byID[defaultModel.ID.String()]
require.Equal(t, defaultModel.DisplayName, defaultEntry.DisplayName)
require.Equal(t, defaultModel.Model, defaultEntry.Model)
require.Equal(t, "openai", defaultEntry.Provider)
require.Equal(t, defaultModel.ContextLimit, defaultEntry.ContextLimit)
require.True(t, defaultEntry.IsDefault)
effortEntry := byID[effortModel.ID.String()]
require.False(t, effortEntry.IsDefault)
require.Equal(
t,
[]string{"none", "minimal", "low", "medium", "high"},
effortEntry.ReasoningEfforts,
)
}
func TestListSubagentModels_NotAvailableForChildChats(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
_, child := createParentChildChats(ctx, t, server, user, org, model)
childChat, err := db.GetChatByID(ctx, child.ID)
require.NoError(t, err)
require.True(t, childChat.ParentChatID.Valid, "child chat must have a parent")
resp := runSubagentTool(
ctx,
t,
server,
childChat,
childChat.LastModelConfigID,
listSubagentModelsToolName,
struct{}{},
)
require.True(t, resp.IsError)
require.Contains(t, resp.Content, "delegated chats cannot create child subagents")
}
func TestSpawnAgent_ExploreUsesConfiguredModelOverride(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
overrideModel := insertInternalChatModelConfig(
t, db, "explore-override-"+uuid.NewString(), true,
)
require.NoError(t, db.UpsertChatExploreModelOverride(ctx, overrideModel.ID.String()))
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, model.ID, "parent-explore-override",
)
resp := runSubagentTool(
ctx,
t,
server,
parentChat,
parentChat.LastModelConfigID,
spawnAgentToolName,
spawnAgentArgs{Type: subagentTypeExplore, Prompt: "investigate the codebase"},
)
result := requireSpawnAgentResponse(t, resp)
require.Equal(t, subagentTypeExplore, result.SubagentType)
childID, err := uuid.Parse(result.ChatID)
require.NoError(t, err)
childChat, err := db.GetChatByID(ctx, childID)
require.NoError(t, err)
require.Equal(t, overrideModel.ID, childChat.LastModelConfigID)
require.True(t, childChat.Mode.Valid)
require.Equal(t, database.ChatModeExplore, childChat.Mode.ChatMode)
require.False(t, childChat.PlanMode.Valid)
}
func TestSpawnAgent_ExploreFallsBackToCurrentTurnModel(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, parentModel := seedInternalChatDeps(t, db)
currentTurnModel := insertInternalChatModelConfig(
t, db, "explore-current-turn-"+uuid.NewString(), true,
)
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, parentModel.ID, "parent-explore-fallback",
)
resp := runSubagentTool(
ctx,
t,
server,
parentChat,
currentTurnModel.ID,
spawnAgentToolName,
spawnAgentArgs{Type: subagentTypeExplore, Prompt: "trace the request flow"},
)
childID := requireSpawnAgentChildChatID(t, resp)
childChat, err := db.GetChatByID(ctx, childID)
require.NoError(t, err)
require.Equal(t, currentTurnModel.ID, childChat.LastModelConfigID)
require.Equal(t, parentModel.ID, parentChat.LastModelConfigID)
}
func TestSpawnAgent_ExploreHonorsPersonalModelOverrides(t *testing.T) {
t.Parallel()
tests := []struct {
name string
enablePersonalOverride bool
personalRaw func(database.ChatModelConfig) string
personalModel func(context.Context, *testing.T, database.Store, uuid.UUID) database.ChatModelConfig
wantModelID func(
database.ChatModelConfig,
database.ChatModelConfig,
database.ChatModelConfig,
database.ChatModelConfig,
) uuid.UUID
}{
{
name: "UnsetUsesDeploymentOverride",
enablePersonalOverride: true,
wantModelID: func(_, _, deploymentModel, _ database.ChatModelConfig) uuid.UUID {
return deploymentModel.ID
},
},
{
name: "DeploymentDefaultUsesDeploymentOverride",
enablePersonalOverride: true,
personalRaw: func(database.ChatModelConfig) string {
return string(codersdk.ChatPersonalModelOverrideModeDeploymentDefault)
},
wantModelID: func(_, _, deploymentModel, _ database.ChatModelConfig) uuid.UUID {
return deploymentModel.ID
},
},
{
name: "ChatDefaultBypassesDeploymentOverride",
enablePersonalOverride: true,
personalRaw: func(database.ChatModelConfig) string {
return string(codersdk.ChatPersonalModelOverrideModeChatDefault)
},
wantModelID: func(_, currentTurnModel, _, _ database.ChatModelConfig) uuid.UUID {
return currentTurnModel.ID
},
},
{
name: "ModelUsesPersonalOverride",
enablePersonalOverride: true,
personalRaw: func(personalModel database.ChatModelConfig) string {
return string(codersdk.ChatPersonalModelOverrideModeModel) + ":" +
personalModel.ID.String()
},
wantModelID: func(_, _, _, personalModel database.ChatModelConfig) uuid.UUID {
return personalModel.ID
},
},
{
name: "AdminFlagOffIgnoresPersonalOverride",
personalRaw: func(database.ChatModelConfig) string {
return string(codersdk.ChatPersonalModelOverrideModeChatDefault)
},
wantModelID: func(_, _, deploymentModel, _ database.ChatModelConfig) uuid.UUID {
return deploymentModel.ID
},
},
{
name: "DisabledPersonalModelFallsBackToDeploymentOverride",
enablePersonalOverride: true,
personalModel: func(
ctx context.Context,
t *testing.T,
db database.Store,
userID uuid.UUID,
) database.ChatModelConfig {
return insertInternalChatModelConfig(
t,
db,
"explore-personal-disabled-"+uuid.NewString(),
false,
)
},
personalRaw: func(personalModel database.ChatModelConfig) string {
return string(codersdk.ChatPersonalModelOverrideModeModel) + ":" +
personalModel.ID.String()
},
wantModelID: func(_, _, deploymentModel, _ database.ChatModelConfig) uuid.UUID {
return deploymentModel.ID
},
},
{
name: "MissingCredentialsFallsBackToDeploymentOverride",
enablePersonalOverride: true,
personalModel: func(
ctx context.Context,
t *testing.T,
db database.Store,
userID uuid.UUID,
) database.ChatModelConfig {
insertInternalChatProvider(
t,
db,
userID,
"openai-compat",
"",
false,
true,
false,
)
return insertInternalChatModelConfigForProvider(
t,
db,
"openai-compat",
"gpt-4o-mini",
true,
)
},
personalRaw: func(personalModel database.ChatModelConfig) string {
return string(codersdk.ChatPersonalModelOverrideModeModel) + ":" +
personalModel.ID.String()
},
wantModelID: func(_, _, deploymentModel, _ database.ChatModelConfig) uuid.UUID {
return deploymentModel.ID
},
},
{
name: "MalformedValueUsesDeploymentOverride",
enablePersonalOverride: true,
personalRaw: func(database.ChatModelConfig) string {
return "not-a-mode"
},
wantModelID: func(_, _, deploymentModel, _ database.ChatModelConfig) uuid.UUID {
return deploymentModel.ID
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, parentModel := seedInternalChatDeps(t, db)
currentTurnModel := insertInternalChatModelConfig(
t,
db,
"explore-current-turn-"+uuid.NewString(),
true,
)
deploymentModel := insertInternalChatModelConfig(
t,
db,
"explore-deployment-"+uuid.NewString(),
true,
)
require.NoError(t, db.UpsertChatExploreModelOverride(ctx, deploymentModel.ID.String()))
personalModel := insertInternalChatModelConfig(
t,
db,
"explore-personal-"+uuid.NewString(),
true,
)
if tt.personalModel != nil {
personalModel = tt.personalModel(ctx, t, db, user.ID)
}
if tt.enablePersonalOverride {
enableInternalChatPersonalModelOverrides(t, db)
}
if tt.personalRaw != nil {
upsertInternalUserChatPersonalModelOverride(
t,
db,
user.ID,
codersdk.ChatPersonalModelOverrideContextExplore,
tt.personalRaw(personalModel),
)
}
parentChat := createInternalParentChat(
ctx,
t,
server,
db,
org.ID,
user.ID,
parentModel.ID,
"parent-explore-personal-override",
)
resp := runSubagentTool(
ctx,
t,
server,
parentChat,
currentTurnModel.ID,
spawnAgentToolName,
spawnAgentArgs{Type: subagentTypeExplore, Prompt: "inspect the codebase"},
)
childID := requireSpawnAgentChildChatID(t, resp)
childChat, err := db.GetChatByID(ctx, childID)
require.NoError(t, err)
require.Equal(
t,
tt.wantModelID(parentModel, currentTurnModel, deploymentModel, personalModel),
childChat.LastModelConfigID,
)
require.True(t, childChat.Mode.Valid)
require.Equal(t, database.ChatModeExplore, childChat.Mode.ChatMode)
require.False(t, childChat.PlanMode.Valid)
})
}
}
func TestCreateChat_ExploreRootStartsWithoutMCPSnapshot(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
root, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "root-explore",
ModelConfigID: model.ID,
ChatMode: database.NullChatMode{
ChatMode: database.ChatModeExplore,
Valid: true,
},
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("inspect the codebase")},
})
require.NoError(t, err)
rootChat, err := db.GetChatByID(ctx, root.ID)
require.NoError(t, err)
require.Empty(t, rootChat.MCPServerIDs)
}
func TestResolveExploreToolSnapshot(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
user, _, _ := seedInternalChatDeps(t, db)
approvedMCP := insertInternalMCPServerConfig(
t, db, user.ID, "approved-"+uuid.NewString(), true,
)
blockedMCP := insertInternalMCPServerConfig(
t, db, user.ID, "blocked-"+uuid.NewString(), false,
)
// Build parent chats in memory rather than via server.CreateChat.
// resolveExploreToolSnapshot only reads ID, MCPServerIDs, PlanMode,
// ParentChatID, and Mode from its parent argument, so persisting
// the chats is unnecessary. Skipping CreateChat avoids waking the
// background acquireLoop, which would otherwise try to dial the
// fake MCP URLs and call OpenAI with the dbgen test API key. Those
// side effects were the root cause of the flake tracked in
// CODAGT-367.
askParent := database.Chat{
ID: uuid.New(),
MCPServerIDs: []uuid.UUID{approvedMCP.ID, blockedMCP.ID},
}
planParent := database.Chat{
ID: uuid.New(),
PlanMode: database.NullChatPlanMode{
ChatPlanMode: database.ChatPlanModePlan,
Valid: true,
},
MCPServerIDs: []uuid.UUID{approvedMCP.ID, blockedMCP.ID},
}
subagentPlanParent := planParent
subagentPlanParent.ID = uuid.New()
subagentPlanParent.ParentChatID = uuid.NullUUID{UUID: uuid.New(), Valid: true}
exploreParent := askParent
exploreParent.ID = uuid.New()
exploreParent.Mode = database.NullChatMode{ChatMode: database.ChatModeExplore, Valid: true}
exploreParent.ParentChatID = uuid.NullUUID{UUID: uuid.New(), Valid: true}
exploreParent.MCPServerIDs = []uuid.UUID{approvedMCP.ID}
tests := []struct {
name string
parent database.Chat
wantMCPServerIDs []uuid.UUID
}{
{
name: "AskModeRootSnapshotsAllExternalTools",
parent: askParent,
wantMCPServerIDs: []uuid.UUID{approvedMCP.ID, blockedMCP.ID},
},
{
name: "PlanModeRootKeepsOnlyApprovedExternalTools",
parent: planParent,
wantMCPServerIDs: []uuid.UUID{approvedMCP.ID},
},
{
name: "PlanModeSubagentKeepsNoExternalTools",
parent: subagentPlanParent,
wantMCPServerIDs: []uuid.UUID{},
},
{
name: "ExploreParentCannotReEscalateSnapshot",
parent: exploreParent,
wantMCPServerIDs: []uuid.UUID{approvedMCP.ID},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := chatdTestContext(t)
gotMCPServerIDs, err := server.resolveExploreToolSnapshot(
ctx,
tt.parent,
)
require.NoError(t, err)
require.ElementsMatch(t, tt.wantMCPServerIDs, gotMCPServerIDs)
})
}
}
func TestCreateChildSubagentChatWithOptions_ExplorePersistsMCPSnapshot(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, model.ID, "parent-explore-snapshot",
)
mcpCfg := insertInternalMCPServerConfig(
t, db, user.ID, "snapshot-"+uuid.NewString(), false,
)
child, err := server.createChildSubagentChatWithOptions(
ctx,
parentChat,
"inspect the codebase",
"explore-snapshot",
childSubagentChatOptions{
chatMode: database.NullChatMode{
ChatMode: database.ChatModeExplore,
Valid: true,
},
inheritedMCPServerIDs: []uuid.UUID{mcpCfg.ID},
},
)
require.NoError(t, err)
childChat, err := db.GetChatByID(ctx, child.ID)
require.NoError(t, err)
require.ElementsMatch(t, []uuid.UUID{mcpCfg.ID}, childChat.MCPServerIDs)
}
func TestSpawnAgent_ExploreSnapshotsTurnStateParentState(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
turnStartConfig := insertInternalMCPServerConfig(
t, db, user.ID, "turn-start-"+uuid.NewString(), false,
)
mutatedConfig := insertInternalMCPServerConfig(
t, db, user.ID, "mutated-"+uuid.NewString(), true,
)
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "parent-turn-state-snapshot",
ModelConfigID: model.ID,
MCPServerIDs: []uuid.UUID{turnStartConfig.ID},
InitialUserContent: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("inspect the codebase"),
},
})
require.NoError(t, err)
turnParent, err := db.GetChatByID(ctx, parent.ID)
require.NoError(t, err)
tools := server.subagentTools(
ctx,
func() database.Chat { return turnParent },
turnParent.LastModelConfigID,
)
tool := findToolByName(tools, spawnAgentToolName)
require.NotNil(t, tool, "spawn_agent tool must be present")
_, err = server.db.UpdateChatPlanModeByID(ctx, database.UpdateChatPlanModeByIDParams{
ID: turnParent.ID,
PlanMode: database.NullChatPlanMode{
ChatPlanMode: database.ChatPlanModePlan,
Valid: true,
},
})
require.NoError(t, err)
_, err = server.db.UpdateChatMCPServerIDs(ctx, database.UpdateChatMCPServerIDsParams{
ID: turnParent.ID,
MCPServerIDs: []uuid.UUID{mutatedConfig.ID},
})
require.NoError(t, err)
reloadedParent, err := db.GetChatByID(ctx, turnParent.ID)
require.NoError(t, err)
require.True(t, reloadedParent.PlanMode.Valid)
require.Equal(t, database.ChatPlanModePlan, reloadedParent.PlanMode.ChatPlanMode)
require.ElementsMatch(t, []uuid.UUID{mutatedConfig.ID}, reloadedParent.MCPServerIDs)
input, err := json.Marshal(spawnAgentArgs{
Type: subagentTypeExplore,
Prompt: "inspect the codebase",
Title: "sub",
})
require.NoError(t, err)
resp, err := tool.Run(ctx, fantasy.ToolCall{
ID: uuid.NewString(),
Name: spawnAgentToolName,
Input: string(input),
})
require.NoError(t, err)
childID := requireSpawnAgentChildChatID(t, resp)
childChat, err := db.GetChatByID(ctx, childID)
require.NoError(t, err)
require.True(t, childChat.Mode.Valid)
require.Equal(t, database.ChatModeExplore, childChat.Mode.ChatMode)
require.ElementsMatch(t, []uuid.UUID{turnStartConfig.ID}, childChat.MCPServerIDs,
"Explore child should keep the turn-start MCP snapshot after parent mutations")
}
func TestSpawnAgent_ExploreFallsBackOnInvalidUUID(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, parentModel := seedInternalChatDeps(t, db)
currentTurnModel := insertInternalChatModelConfig(
t, db, "explore-invalid-override-"+uuid.NewString(), true,
)
require.NoError(t, db.UpsertChatExploreModelOverride(ctx, "not-a-uuid"))
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, parentModel.ID, "parent-explore-invalid-override",
)
resp := runSubagentTool(
ctx,
t,
server,
parentChat,
currentTurnModel.ID,
spawnAgentToolName,
spawnAgentArgs{Type: subagentTypeExplore, Prompt: "inspect the handler flow"},
)
childID := requireSpawnAgentChildChatID(t, resp)
childChat, err := db.GetChatByID(ctx, childID)
require.NoError(t, err)
require.Equal(t, currentTurnModel.ID, childChat.LastModelConfigID)
}
func TestSpawnAgent_ExploreFallsBackWhenOverrideIsUnavailable(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, parentModel := seedInternalChatDeps(t, db)
currentTurnModel := insertInternalChatModelConfig(
t, db, "explore-fallback-current-"+uuid.NewString(), true,
)
disabledModel := insertInternalChatModelConfig(
t, db, "explore-disabled-"+uuid.NewString(), false,
)
require.NoError(t, db.UpsertChatExploreModelOverride(ctx, disabledModel.ID.String()))
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, parentModel.ID, "parent-explore-disabled",
)
resp := runSubagentTool(
ctx,
t,
server,
parentChat,
currentTurnModel.ID,
spawnAgentToolName,
spawnAgentArgs{Type: subagentTypeExplore, Prompt: "inspect the service boundaries"},
)
childID := requireSpawnAgentChildChatID(t, resp)
childChat, err := db.GetChatByID(ctx, childID)
require.NoError(t, err)
require.Equal(t, currentTurnModel.ID, childChat.LastModelConfigID)
}
func TestSpawnAgent_ExploreFallsBackWhenOverrideCredentialsAreUnavailable(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, parentModel := seedInternalChatDeps(t, db)
currentTurnModel := insertInternalChatModelConfig(
t, db, "explore-missing-user-key-current-"+uuid.NewString(), true,
)
overrideProvider := dbgen.ChatProvider(t, db, database.ChatProvider{
Provider: "openai-compat",
DisplayName: "OpenAI Compat",
}, func(p *database.InsertChatProviderParams) {
p.APIKey = ""
p.CentralApiKeyEnabled = false
p.AllowUserApiKey = true
p.AllowCentralApiKeyFallback = false
})
overrideModel := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
AIProviderID: uuid.NullUUID{UUID: overrideProvider.ID, Valid: true},
Model: "gpt-4o-mini",
DisplayName: "Explore Override Missing User Key",
})
require.NoError(t, db.UpsertChatExploreModelOverride(ctx, overrideModel.ID.String()))
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, parentModel.ID, "parent-explore-missing-user-key",
)
resp := runSubagentTool(
ctx,
t,
server,
parentChat,
currentTurnModel.ID,
spawnAgentToolName,
spawnAgentArgs{Type: subagentTypeExplore, Prompt: "inspect provider credential handling"},
)
childID := requireSpawnAgentChildChatID(t, resp)
childChat, err := db.GetChatByID(ctx, childID)
require.NoError(t, err)
require.Equal(t, currentTurnModel.ID, childChat.LastModelConfigID)
}
func TestDefaultSystemPromptPlanningGuidance_SteersSubagentSelection(t *testing.T) {
t.Parallel()
require.Contains(t, defaultSystemPromptPlanningGuidance, `Prefer type="general" for substantial delegated research, analysis, reasoning, review, planning support, or implementation`)
require.Contains(t, defaultSystemPromptPlanningGuidance, `Use type="general" even for read-only work when the task is open-ended, multi-step, parallel, requires synthesis, or may later need edits`)
require.Contains(t, defaultSystemPromptPlanningGuidance, `Use type="explore" only for narrow repository-local read-only code discovery or code tracing`)
require.Contains(t, defaultSystemPromptPlanningGuidance, `Do not use type="explore" for generic research, broad architecture analysis, planning synthesis, external or web research, parallel research, or tasks that may need edits`)
require.NotContains(t, defaultSystemPromptPlanningGuidance, "research the codebase")
require.NotContains(t, defaultSystemPromptPlanningGuidance, "Reserve type=\"general\" for writable delegated work")
}
func TestSpawnAgent_DescriptionListsAllAvailableTypes(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{
Anthropic: "test-anthropic-key",
})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, model.ID, "parent-description-all",
)
tools := server.subagentTools(ctx, func() database.Chat { return parentChat }, parentChat.LastModelConfigID)
tool := findToolByName(tools, spawnAgentToolName)
require.NotNil(t, tool, "spawn_agent tool must be present")
description := tool.Info().Description
require.Contains(t, description, subagentTypeGeneral)
require.Contains(t, description, subagentTypeExplore)
require.Contains(t, description, subagentTypeComputerUse)
}
func TestSpawnAgent_DescriptionSteersGeneralForSubstantialResearch(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, model.ID, "parent-description-selection-guidance",
)
tools := server.subagentTools(ctx, func() database.Chat { return parentChat }, parentChat.LastModelConfigID)
tool := findToolByName(tools, spawnAgentToolName)
require.NotNil(t, tool, "spawn_agent tool must be present")
description := tool.Info().Description
require.Contains(t, description, `Prefer type="general" for substantial delegated research, analysis, reasoning, review, planning support, or implementation`)
require.Contains(t, description, "even when the child should only report findings")
require.Contains(t, description, `When using type="general" for read-only work, explicitly instruct the child not to modify files and to return findings`)
require.Contains(t, description, `Use type="explore" only for narrow repository-local read-only code discovery or code tracing`)
require.Contains(t, description, `Do not use type="explore" for generic research, broad architecture analysis, planning synthesis, external or web research, parallel research, or tasks that may need edits`)
}
func TestSpawnAgent_DescriptionIncludesComputerUseWithMissingProviderKey(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, model.ID, "parent-description-missing-key",
)
tools := server.subagentTools(ctx, func() database.Chat { return parentChat }, parentChat.LastModelConfigID)
tool := findToolByName(tools, spawnAgentToolName)
require.NotNil(t, tool, "spawn_agent tool must be present")
description := tool.Info().Description
require.Contains(t, description, subagentTypeGeneral)
require.Contains(t, description, subagentTypeExplore)
require.Contains(t, description, subagentTypeComputerUse)
}
func TestSpawnAgent_PlanModeDescriptionOmitsComputerUse(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{
Anthropic: "test-anthropic-key",
})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "plan-parent-description",
ModelConfigID: model.ID,
PlanMode: database.NullChatPlanMode{
ChatPlanMode: database.ChatPlanModePlan,
Valid: true,
},
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("plan this change")},
})
require.NoError(t, err)
parentChat, err := db.GetChatByID(ctx, parent.ID)
require.NoError(t, err)
tools := server.subagentTools(ctx, func() database.Chat { return parentChat }, parentChat.LastModelConfigID)
tool := findToolByName(tools, spawnAgentToolName)
require.NotNil(t, tool, "spawn_agent tool must be present")
description := tool.Info().Description
require.Contains(t, description, subagentTypeGeneral)
require.Contains(t, description, subagentTypeExplore)
require.NotContains(t, description, subagentTypeComputerUse)
require.Contains(t, description, `type="general" is for non-mutating substantial investigation and planning support`)
require.Contains(t, description, `type="explore" is for narrow repository-local lookup or tracing`)
require.Contains(t, description, `only type="general" should be used for cloning repositories or non-local investigation`)
require.NotContains(t, description, "Both may use shell commands for exploration, such as cloning repositories")
require.Contains(t, description, "must not implement changes or intentionally modify workspace files")
}
func TestSpawnAgent_PlanModeRejectsComputerUse(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{
Anthropic: "test-anthropic-key",
})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "plan-parent-computer-use-reject",
ModelConfigID: model.ID,
PlanMode: database.NullChatPlanMode{
ChatPlanMode: database.ChatPlanModePlan,
Valid: true,
},
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("plan this change")},
})
require.NoError(t, err)
parentChat, err := db.GetChatByID(ctx, parent.ID)
require.NoError(t, err)
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: subagentTypeComputerUse,
Prompt: "open the browser and click around",
})
require.True(t, resp.IsError)
require.Contains(t, resp.Content, `type "computer_use" is unavailable in plan mode`)
}
func TestPlanningOverlaySubagentGuidance_UsesPlanModeSafeDescriptions(t *testing.T) {
t.Parallel()
guidance := planningOverlaySubagentGuidance()
require.Contains(t, guidance, subagentTypeGeneral)
require.Contains(t, guidance, subagentTypeExplore)
require.Contains(t, guidance, `Use type="general" for substantial investigation, reasoning, and planning support`)
require.Contains(t, guidance, `Use type="explore" only for narrow repository-local lookup or tracing`)
require.Contains(t, guidance, "general (non-mutating substantial investigation, analysis, and planning support)")
require.Contains(t, guidance, "explore (narrow repository-local codebase lookup and code tracing)")
require.NotContains(t, guidance, subagentTypeComputerUse)
require.NotContains(t, guidance, "modify")
require.NotContains(t, guidance, "may inspect or modify workspace files")
}
func TestSpawnAgent_InvalidTypeAndCredentialErrorAreDistinct(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, model.ID, "parent-invalid-type",
)
invalidResp := runSubagentTool(
ctx,
t,
server,
parentChat,
parentChat.LastModelConfigID,
spawnAgentToolName,
spawnAgentArgs{Type: "invalid", Prompt: "delegate work"},
)
require.True(t, invalidResp.IsError)
require.Contains(t, invalidResp.Content, "type must be one of: general, explore, computer_use")
credentialResp := runSubagentTool(
ctx,
t,
server,
parentChat,
parentChat.LastModelConfigID,
spawnAgentToolName,
spawnAgentArgs{Type: subagentTypeComputerUse, Prompt: "open browser"},
)
require.True(t, credentialResp.IsError)
require.Contains(t, credentialResp.Content, "API key")
require.Contains(t, credentialResp.Content, "computer-use")
require.Contains(t, credentialResp.Content, "anthropic")
}
func TestSpawnAgent_ComputerUseAvailabilityUsesConfiguredProvider(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
ctx := chatdTestContext(t)
require.NoError(t, db.UpsertChatComputerUseProvider(
ctx,
string(codersdk.ChatComputerUseProviderOpenAI),
))
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
user, org, model := seedInternalChatDeps(t, db)
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, model.ID, "parent-openai-computer-use",
)
ids := availableSubagentTypeIDs(ctx, server, parentChat)
require.Contains(t, ids, subagentTypeComputerUse)
}
func TestSpawnAgent_ComputerUseRejectsMissingConfiguredProvider(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
ctx := chatdTestContext(t)
require.NoError(t, db.UpsertChatComputerUseProvider(
ctx,
string(codersdk.ChatComputerUseProviderOpenAI),
))
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
user := dbgen.User(t, db, database.User{})
org := dbgen.Organization(t, db, database.Organization{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
UserID: user.ID,
OrganizationID: org.ID,
})
model := insertInternalChatModelConfigForProvider(
t,
db,
string(codersdk.ChatComputerUseProviderOpenAI),
"gpt-4o-mini",
true,
)
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, model.ID, "parent-openai-missing",
)
ids := availableSubagentTypeIDs(ctx, server, parentChat)
require.Contains(t, ids, subagentTypeComputerUse)
beforeChats, err := db.GetChats(ctx, database.GetChatsParams{
OwnedOnly: true,
ViewerID: user.ID,
AfterID: uuid.Nil,
OffsetOpt: 0,
LimitOpt: 100,
})
require.NoError(t, err)
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: subagentTypeComputerUse,
Prompt: "open the browser",
})
require.True(t, resp.IsError)
require.Contains(t, resp.Content, "API key")
require.Contains(t, resp.Content, "computer-use")
require.Contains(t, resp.Content, "openai")
afterChats, err := db.GetChats(ctx, database.GetChatsParams{
OwnedOnly: true,
ViewerID: user.ID,
AfterID: uuid.Nil,
OffsetOpt: 0,
LimitOpt: 100,
})
require.NoError(t, err)
require.Len(t, afterChats, len(beforeChats))
}
func TestSpawnAgent_ComputerUseRejectsInvalidConfiguredProviderWithStableReason(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
ctx := chatdTestContext(t)
require.NoError(t, db.UpsertChatComputerUseProvider(ctx, "bogus"))
logSink := &subagentTestLogSink{}
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).AppendSinks(logSink)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{}, withInternalTestServerLogger(logger))
user, org, model := seedInternalChatDeps(t, db)
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, model.ID, "parent-invalid-computer-use-provider",
)
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: subagentTypeComputerUse,
Prompt: "open the browser",
})
require.True(t, resp.IsError)
require.Contains(t, resp.Content, `type "computer_use" is unavailable because its provider configuration could not be loaded`)
require.NotContains(t, resp.Content, "bogus")
require.NotContains(t, resp.Content, "agents_computer_use_provider")
require.NotEmpty(t, logSink.entriesAtLevelWithMessage(
slog.LevelWarn,
"computer-use provider config is unavailable",
))
}
func TestSpawnAgent_ComputerUseRejectsDesktopDisabled(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
experiments := slices.DeleteFunc(
slices.Clone(codersdk.ExperimentsKnown),
func(e codersdk.Experiment) bool { return e == codersdk.ExperimentChatVirtualDesktop },
)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{
Anthropic: "test-anthropic-key",
}, withInternalTestServerExperiments(experiments))
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, model.ID, "parent-desktop-disabled",
)
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: subagentTypeComputerUse,
Prompt: "open the browser",
})
require.True(t, resp.IsError)
require.Contains(t, resp.Content, `type "computer_use" is unavailable because the chat-virtual-desktop experiment is not enabled`)
}
func TestSpawnAgent_BlankTypeReturnsValidOptions(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{
Anthropic: "test-anthropic-key",
})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, model.ID, "parent-blank-type",
)
tests := []struct {
name string
subagentType string
}{
{name: "empty", subagentType: ""},
{name: "space", subagentType: " "},
{name: "whitespace", subagentType: "\n\t"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := chatdTestContext(t)
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: tt.subagentType,
Prompt: "delegate work",
})
require.True(t, resp.IsError)
require.Contains(t, resp.Content, "type must be one of:")
require.Contains(t, resp.Content, subagentTypeGeneral)
require.Contains(t, resp.Content, subagentTypeExplore)
require.Contains(t, resp.Content, subagentTypeComputerUse)
})
}
}
func TestSpawnAgent_NotAvailableForChildChats(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{
Anthropic: "test-anthropic-key",
})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
_, child := createParentChildChats(ctx, t, server, user, org, model)
childChat, err := db.GetChatByID(ctx, child.ID)
require.NoError(t, err)
require.True(t, childChat.ParentChatID.Valid, "child chat must have a parent")
tools := server.subagentTools(ctx, func() database.Chat { return childChat }, childChat.LastModelConfigID)
tool := findToolByName(tools, spawnAgentToolName)
require.NotNil(t, tool, "spawn_agent tool must be present")
resp, err := tool.Run(ctx, fantasy.ToolCall{
ID: "call-child",
Name: spawnAgentToolName,
Input: `{"type":"general","prompt":"open browser"}`,
})
require.NoError(t, err)
require.True(t, resp.IsError)
require.Contains(t, resp.Content, "delegated chats cannot create child subagents")
}
func TestSpawnAgent_NotAvailableForExploreChats(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
exploreChat, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "root-explore",
ModelConfigID: model.ID,
ChatMode: database.NullChatMode{
ChatMode: database.ChatModeExplore,
Valid: true,
},
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("inspect the codebase")},
})
require.NoError(t, err)
currentChat, err := db.GetChatByID(ctx, exploreChat.ID)
require.NoError(t, err)
tools := server.subagentTools(ctx, func() database.Chat { return currentChat }, currentChat.LastModelConfigID)
tool := findToolByName(tools, spawnAgentToolName)
require.NotNil(t, tool, "spawn_agent tool must be present")
resp, err := tool.Run(ctx, fantasy.ToolCall{
ID: "call-explore",
Name: spawnAgentToolName,
Input: `{"type":"general","prompt":"delegate work"}`,
})
require.NoError(t, err)
require.True(t, resp.IsError)
require.Contains(t, resp.Content, "explore chats cannot create child subagents")
}
func TestSubagentLifecycleToolsIncludePersistedSubagentTypeAcrossVariants(t *testing.T) {
t.Parallel()
tests := []struct {
name string
variant string
}{
{name: "General", variant: subagentTypeGeneral},
{name: "Explore", variant: subagentTypeExplore},
{name: "ComputerUse", variant: subagentTypeComputerUse},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
if tt.variant == subagentTypeComputerUse {
insertEnabledAnthropicProvider(t, db, user.ID)
}
parentChat := createInternalParentChat(
ctx,
t,
server,
db,
org.ID,
user.ID,
model.ID,
"parent-lifecycle-"+tt.variant,
)
spawnResp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: tt.variant,
Prompt: "delegate work",
})
spawnResult := requireSpawnAgentResponse(t, spawnResp)
require.Equal(t, tt.variant, spawnResult.SubagentType)
childID, err := uuid.Parse(spawnResult.ChatID)
require.NoError(t, err)
setChatStatus(ctx, t, db, childID, database.ChatStatusWaiting, "")
insertAssistantMessage(t, db, childID, model.ID, "task complete")
waitResult := requireToolResponseMap(t, runSubagentTool(
ctx,
t,
server,
parentChat,
parentChat.LastModelConfigID,
"wait_agent",
waitAgentArgs{ChatID: childID.String()},
), false)
require.Equal(t, tt.variant, waitResult["type"])
messageResult := requireToolResponseMap(t, runSubagentTool(
ctx,
t,
server,
parentChat,
parentChat.LastModelConfigID,
"message_agent",
messageAgentArgs{ChatID: childID.String(), Message: "follow up"},
), false)
require.Equal(t, tt.variant, messageResult["type"])
setChatStatus(ctx, t, db, childID, database.ChatStatusRunning, "")
interruptResult := requireToolResponseMap(t, runSubagentTool(
ctx,
t,
server,
parentChat,
parentChat.LastModelConfigID,
"interrupt_agent",
interruptAgentArgs{ChatID: childID.String()},
), false)
require.Equal(t, tt.variant, interruptResult["type"])
})
}
}
func TestSubagentLifecycleToolErrorsIncludePersistedSubagentType(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
_, child := createParentChildChats(ctx, t, server, user, org, model)
unrelated, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "unrelated-lifecycle-parent",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("other")},
})
require.NoError(t, err)
unrelatedChat, err := db.GetChatByID(ctx, unrelated.ID)
require.NoError(t, err)
tests := []struct {
name string
toolName string
args any
wantError string
}{
{
name: "WaitAgent",
toolName: "wait_agent",
args: waitAgentArgs{ChatID: child.ID.String()},
wantError: ErrSubagentNotDescendant.Error(),
},
{
name: "MessageAgent",
toolName: "message_agent",
args: messageAgentArgs{ChatID: child.ID.String(), Message: "follow up"},
wantError: ErrSubagentNotDescendant.Error(),
},
{
name: "InterruptAgent",
toolName: "interrupt_agent",
args: interruptAgentArgs{ChatID: child.ID.String()},
wantError: ErrSubagentNotDescendant.Error(),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := chatdTestContext(t)
result := requireToolResponseMap(t, runSubagentTool(
ctx,
t,
server,
unrelatedChat,
unrelatedChat.LastModelConfigID,
tt.toolName,
tt.args,
), true)
require.Equal(t, subagentTypeGeneral, result["type"])
require.Equal(t, tt.wantError, result["error"])
})
}
}
func TestSpawnAgent_ComputerUseUsesComputerUseModelNotParent(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
insertEnabledAnthropicProvider(t, db, user.ID)
workspace, build, agent := seedWorkspaceBinding(t, db, user.ID)
seedProvider, err := db.GetAIProviderByID(ctx, model.AIProviderID.UUID)
require.NoError(t, err)
require.Equal(t, "openai", string(seedProvider.Type), "seed helper must create an OpenAI model")
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
WorkspaceID: uuid.NullUUID{UUID: workspace.ID, Valid: true},
BuildID: uuid.NullUUID{UUID: build.ID, Valid: true},
AgentID: uuid.NullUUID{UUID: agent.ID, Valid: true},
Title: "parent-openai",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
})
require.NoError(t, err)
parentChat, err := db.GetChatByID(ctx, parent.ID)
require.NoError(t, err)
resp := runSubagentTool(
ctx,
t,
server,
parentChat,
parentChat.LastModelConfigID,
spawnAgentToolName,
spawnAgentArgs{Type: subagentTypeComputerUse, Prompt: "take a screenshot"},
)
result := requireSpawnAgentResponse(t, resp)
require.Equal(t, subagentTypeComputerUse, result.SubagentType)
childID, err := uuid.Parse(result.ChatID)
require.NoError(t, err)
childChat, err := db.GetChatByID(ctx, childID)
require.NoError(t, err)
require.Equal(t, parentChat.WorkspaceID, childChat.WorkspaceID)
require.Equal(t, parentChat.BuildID, childChat.BuildID)
require.Equal(t, parentChat.AgentID, childChat.AgentID)
require.True(t, childChat.Mode.Valid)
assert.Equal(t, database.ChatModeComputerUse, childChat.Mode.ChatMode)
computerUseModelProvider, computerUseModelName, ok := chattool.DefaultComputerUseModel(codersdk.ChatComputerUseProviderAnthropic)
require.True(t, ok)
assert.NotEqual(t, string(seedProvider.Type), computerUseModelProvider,
"computer use model provider must differ from parent model provider")
assert.Equal(t, "anthropic", computerUseModelProvider)
assert.NotEmpty(t, computerUseModelName)
}
func TestSpawnAgent_ComputerUseInheritsMCPServerIDs(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
insertEnabledAnthropicProvider(t, db, user.ID)
mcpCfg := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{
DisplayName: "MCP Test",
Slug: "mcp-test",
Url: "https://mcp.example.com",
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
})
parentMCPIDs := []uuid.UUID{mcpCfg.ID}
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "parent-cu-mcp",
ModelConfigID: model.ID,
MCPServerIDs: parentMCPIDs,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
})
require.NoError(t, err)
parentChat, err := db.GetChatByID(ctx, parent.ID)
require.NoError(t, err)
resp := runSubagentTool(
ctx,
t,
server,
parentChat,
parentChat.LastModelConfigID,
spawnAgentToolName,
spawnAgentArgs{Type: subagentTypeComputerUse, Prompt: "check the UI"},
)
childID := requireSpawnAgentChildChatID(t, resp)
childChat, err := db.GetChatByID(ctx, childID)
require.NoError(t, err)
assert.ElementsMatch(t, parentMCPIDs, childChat.MCPServerIDs,
"computer use child chat must inherit MCP server IDs from parent")
}
func TestCreateChildSubagentChat_InheritsMCPServerIDs(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
// Insert two MCP server configs so we can verify both are
// inherited by the child chat.
mcpA := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{
DisplayName: "MCP A",
Slug: "mcp-a",
Url: "https://mcp-a.example.com",
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
})
mcpB := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{
DisplayName: "MCP B",
Slug: "mcp-b",
Url: "https://mcp-b.example.com",
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
})
parentMCPIDs := []uuid.UUID{mcpA.ID, mcpB.ID}
// Create a parent chat with MCP servers.
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "parent-with-mcp",
ModelConfigID: model.ID,
MCPServerIDs: parentMCPIDs,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
})
require.NoError(t, err)
// Refetch the parent to get DB-populated fields.
parentChat, err := db.GetChatByID(ctx, parent.ID)
require.NoError(t, err)
require.ElementsMatch(t, parentMCPIDs, parentChat.MCPServerIDs,
"parent chat must have the MCP server IDs we set")
// Spawn a child subagent chat.
child, err := server.createChildSubagentChatWithOptions(
ctx,
parentChat,
"do some work",
"child-task",
childSubagentChatOptions{},
)
require.NoError(t, err)
// Verify the child inherited the parent's MCP server IDs.
childChat, err := db.GetChatByID(ctx, child.ID)
require.NoError(t, err)
assert.ElementsMatch(t, parentMCPIDs, childChat.MCPServerIDs,
"child chat must inherit MCP server IDs from parent")
}
func TestCreateChildSubagentChat_NoMCPServersStaysEmpty(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
// Create a parent chat without any MCP servers.
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "parent-no-mcp",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
})
require.NoError(t, err)
parentChat, err := db.GetChatByID(ctx, parent.ID)
require.NoError(t, err)
// Spawn a child.
child, err := server.createChildSubagentChatWithOptions(
ctx,
parentChat,
"do some work",
"child-no-mcp",
childSubagentChatOptions{},
)
require.NoError(t, err)
childChat, err := db.GetChatByID(ctx, child.ID)
require.NoError(t, err)
assert.Empty(t, childChat.MCPServerIDs,
"child chat must have empty MCP server IDs when parent has none")
}
func TestIsSubagentDescendant(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
// Build a chain: root -> child -> grandchild.
root, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "root",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("root")},
})
require.NoError(t, err)
child, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
ParentChatID: uuid.NullUUID{
UUID: root.ID,
Valid: true,
},
RootChatID: uuid.NullUUID{
UUID: root.ID,
Valid: true,
},
Title: "child",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("child")},
})
require.NoError(t, err)
grandchild, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
ParentChatID: uuid.NullUUID{
UUID: child.ID,
Valid: true,
},
RootChatID: uuid.NullUUID{
UUID: root.ID,
Valid: true,
},
Title: "grandchild",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("grandchild")},
})
require.NoError(t, err)
// Build a separate, unrelated chain.
unrelated, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "unrelated-root",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("unrelated")},
})
require.NoError(t, err)
unrelatedChild, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
ParentChatID: uuid.NullUUID{
UUID: unrelated.ID,
Valid: true,
},
RootChatID: uuid.NullUUID{
UUID: unrelated.ID,
Valid: true,
},
Title: "unrelated-child",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("unrelated-child")},
})
require.NoError(t, err)
tests := []struct {
name string
ancestor uuid.UUID
target uuid.UUID
want bool
}{
{
name: "SameID",
ancestor: root.ID,
target: root.ID,
want: false,
},
{
name: "DirectChild",
ancestor: root.ID,
target: child.ID,
want: true,
},
{
name: "GrandChild",
ancestor: root.ID,
target: grandchild.ID,
want: true,
},
{
name: "Unrelated",
ancestor: root.ID,
target: unrelatedChild.ID,
want: false,
},
{
name: "RootChat",
ancestor: child.ID,
target: root.ID,
want: false,
},
{
name: "BrokenChain",
ancestor: root.ID,
target: uuid.New(),
want: false,
},
{
name: "NotDescendant",
ancestor: unrelated.ID,
target: child.ID,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := chatdTestContext(t)
got, err := isSubagentDescendant(ctx, db, tt.ancestor, tt.target)
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
// createParentChildChats creates a parent and child chat pair for
// subagent tests. The child starts in pending status.
func createParentChildChats(
ctx context.Context,
t *testing.T,
server *Server,
user database.User,
org database.Organization,
model database.ChatModelConfig,
) (parent database.Chat, child database.Chat) {
t.Helper()
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "parent-" + t.Name(),
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
})
require.NoError(t, err)
child, err = server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
ParentChatID: uuid.NullUUID{
UUID: parent.ID,
Valid: true,
},
RootChatID: uuid.NullUUID{
UUID: parent.ID,
Valid: true,
},
Title: "child-" + t.Name(),
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("do work")},
})
require.NoError(t, err)
return parent, child
}
// setChatStatus transitions a chat to the given status.
func setChatStatus(
ctx context.Context,
t *testing.T,
db database.Store,
chatID uuid.UUID,
status database.ChatStatus,
lastError string,
) {
t.Helper()
params := database.UpdateChatStatusParams{
ID: chatID,
Status: status,
}
if lastError != "" {
encodedLastError, err := json.Marshal(codersdk.ChatError{
Message: lastError,
Kind: codersdk.ChatErrorKindGeneric,
})
require.NoError(t, err)
params.LastError = pqtype.NullRawMessage{RawMessage: encodedLastError, Valid: true}
}
_, err := db.UpdateChatStatus(ctx, params)
require.NoError(t, err)
}
func setChatStatusWithError(
ctx context.Context,
t *testing.T,
db database.Store,
chatID uuid.UUID,
status database.ChatStatus,
chatErr codersdk.ChatError,
) {
t.Helper()
encoded, err := json.Marshal(chatErr)
require.NoError(t, err)
_, err = db.UpdateChatStatus(ctx, database.UpdateChatStatusParams{
ID: chatID,
Status: status,
LastError: pqtype.NullRawMessage{RawMessage: encoded, Valid: true},
})
require.NoError(t, err)
}
// insertAssistantMessage inserts an assistant message with v1 content
// into a chat.
func insertAssistantMessage(
t *testing.T,
db database.Store,
chatID uuid.UUID,
modelID uuid.UUID,
text string,
) {
t.Helper()
parts := []codersdk.ChatMessagePart{codersdk.ChatMessageText(text)}
data, err := json.Marshal(parts)
require.NoError(t, err)
_ = dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: chatID,
CreatedBy: uuid.NullUUID{},
ModelConfigID: uuid.NullUUID{UUID: modelID, Valid: true},
Role: database.ChatMessageRoleAssistant,
Content: pqtype.NullRawMessage{RawMessage: data, Valid: true},
ContentVersion: chatprompt.ContentVersionV1,
})
}
func insertLinkedChatFile(
ctx context.Context,
t *testing.T,
db database.Store,
chatID uuid.UUID,
ownerID uuid.UUID,
organizationID uuid.UUID,
name string,
mediaType string,
data []byte,
) uuid.UUID {
t.Helper()
file, err := db.InsertChatFile(ctx, database.InsertChatFileParams{
OwnerID: ownerID,
OrganizationID: organizationID,
Name: name,
Mimetype: mediaType,
Data: data,
})
require.NoError(t, err)
rejected, err := db.LinkChatFiles(ctx, database.LinkChatFilesParams{
ChatID: chatID,
MaxFileLinks: int32(codersdk.MaxChatFileIDs),
FileIds: []uuid.UUID{file.ID},
})
require.NoError(t, err)
require.Zero(t, rejected)
return file.ID
}
func TestWaitAgentDoesNotRelayComputerUseSubagentAttachments(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
workspace, _, agent := seedWorkspaceBinding(t, db, user.ID)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
parent, child := createComputerUseParentChild(
t, server, user, org, model, workspace, agent,
"parent-relay", "child-relay",
)
insertedFile := insertLinkedChatFile(
ctx,
t,
db,
child.ID,
user.ID,
workspace.OrganizationID,
"screenshot.png",
"image/png",
[]byte("fake-png"),
)
insertAssistantMessage(t, db, child.ID, model.ID, "Shared the screenshot.")
setChatStatus(ctx, t, db, child.ID, database.ChatStatusWaiting, "")
resp, err := invokeWaitAgentTool(ctx, t, server, db, parent.ID, child.ID, 5)
require.NoError(t, err)
require.False(t, resp.IsError, "expected successful response, got: %s", resp.Content)
var result map[string]any
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
require.Equal(t, "Shared the screenshot.", result["report"])
require.Equal(t, string(database.ChatStatusWaiting), result["status"])
assert.NotContains(t, result, "attachment_count")
assert.NotContains(t, result, "attachment_warning")
attachments, err := chattool.AttachmentsFromMetadata(resp.Metadata)
require.NoError(t, err)
assert.Empty(t, attachments)
parts := buildAssistantPartsForPersist(
context.Background(),
testutil.Logger(t),
nil,
[]fantasy.ToolResultContent{{
ToolCallID: "call-1",
ToolName: "wait_agent",
ClientMetadata: resp.Metadata,
}},
chatloop.PersistedStep{},
nil,
)
assert.Empty(t, parts)
parentFiles, err := db.GetChatFileMetadataByChatID(ctx, parent.ID)
require.NoError(t, err)
assert.Empty(t, parentFiles)
childFiles, err := db.GetChatFileMetadataByChatID(ctx, child.ID)
require.NoError(t, err)
require.Len(t, childFiles, 1)
assert.Equal(t, insertedFile, childFiles[0].ID)
assert.Equal(t, "screenshot.png", childFiles[0].Name)
assert.Equal(t, "image/png", childFiles[0].Mimetype)
}
func TestWaitAgentDoesNotRelayRegularSubagentAttachments(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
workspace, _, _ := seedWorkspaceBinding(t, db, user.ID)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
parent, child := createParentChildChats(ctx, t, server, user, org, model)
WaitUntilIdleForTest(server)
insertedFile := insertLinkedChatFile(
ctx,
t,
db,
child.ID,
user.ID,
workspace.OrganizationID,
"notes.txt",
"text/plain",
[]byte("release notes"),
)
insertAssistantMessage(t, db, child.ID, model.ID, "Shared the release notes.")
setChatStatus(ctx, t, db, child.ID, database.ChatStatusWaiting, "")
resp, err := invokeWaitAgentTool(ctx, t, server, db, parent.ID, child.ID, 5)
require.NoError(t, err)
require.False(t, resp.IsError, "expected successful response, got: %s", resp.Content)
var result map[string]any
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
require.Equal(t, "Shared the release notes.", result["report"])
assert.NotContains(t, result, "attachment_count")
assert.NotContains(t, result, "attachment_warning")
attachments, err := chattool.AttachmentsFromMetadata(resp.Metadata)
require.NoError(t, err)
assert.Empty(t, attachments)
parentFiles, err := db.GetChatFileMetadataByChatID(ctx, parent.ID)
require.NoError(t, err)
assert.Empty(t, parentFiles)
childFiles, err := db.GetChatFileMetadataByChatID(ctx, child.ID)
require.NoError(t, err)
require.Len(t, childFiles, 1)
assert.Equal(t, insertedFile, childFiles[0].ID)
assert.Equal(t, "notes.txt", childFiles[0].Name)
assert.Equal(t, "text/plain", childFiles[0].Mimetype)
}
func TestAwaitSubagentCompletion(t *testing.T) {
t.Parallel()
// Shared fixtures for subtests that use a real clock. Each
// subtest creates its own parent+child chats (unique IDs)
// so they don't collide. Mock-clock subtests need their own
// DB and server so the wait loop's timers stay isolated.
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
user, org, model := seedInternalChatDeps(t, db)
t.Run("NotDescendant", func(t *testing.T) {
t.Parallel()
ctx := chatdTestContext(t)
parent, _ := createParentChildChats(ctx, t, server, user, org, model)
unrelated, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "unrelated",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("other")},
})
require.NoError(t, err)
_, _, err = server.awaitSubagentCompletion(
ctx, parent.ID, unrelated.ID, time.Second,
)
require.ErrorIs(t, err, ErrSubagentNotDescendant)
})
t.Run("AlreadyWaiting", func(t *testing.T) {
t.Parallel()
ctx := chatdTestContext(t)
parent, child := createParentChildChats(ctx, t, server, user, org, model)
setChatStatus(ctx, t, db, child.ID, database.ChatStatusWaiting, "")
insertAssistantMessage(t, db, child.ID, model.ID, "task complete")
gotChat, report, err := server.awaitSubagentCompletion(
ctx, parent.ID, child.ID, time.Second,
)
require.NoError(t, err)
assert.Equal(t, child.ID, gotChat.ID)
assert.Equal(t, database.ChatStatusWaiting, gotChat.Status)
assert.Equal(t, "task complete", report)
})
t.Run("AlreadyError", func(t *testing.T) {
t.Parallel()
ctx := chatdTestContext(t)
parent, child := createParentChildChats(ctx, t, server, user, org, model)
setChatStatus(ctx, t, db, child.ID, database.ChatStatusError, "something broke")
insertAssistantMessage(t, db, child.ID, model.ID, "partial work done")
_, _, err := server.awaitSubagentCompletion(
ctx, parent.ID, child.ID, time.Second,
)
require.Error(t, err)
assert.Contains(t, err.Error(), "partial work done")
})
t.Run("AlreadyErrorNoReport", func(t *testing.T) {
t.Parallel()
ctx := chatdTestContext(t)
parent, child := createParentChildChats(ctx, t, server, user, org, model)
setChatStatus(ctx, t, db, child.ID, database.ChatStatusError, "crash")
_, _, err := server.awaitSubagentCompletion(
ctx, parent.ID, child.ID, time.Second,
)
require.Error(t, err)
assert.Contains(t, err.Error(), "agent reached error status")
})
t.Run("CompletesViaPoll", func(t *testing.T) {
t.Parallel()
// Force subscription failure so awaitSubagentCompletion
// falls back to the fast 200ms poll interval.
db, _ := dbtestutil.NewDB(t)
mClock := quartz.NewMock(t)
ps := subscribeFailingPubsub{Pubsub: pubsub.NewInMemory()}
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{}, withInternalTestServerClock(mClock))
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
parent, child := createParentChildChats(ctx, t, server, user, org, model)
setChatStatus(ctx, t, db, parent.ID, database.ChatStatusRunning, "")
setChatStatus(ctx, t, db, child.ID, database.ChatStatusRunning, "")
// Set the trap BEFORE starting the goroutine so we
// deterministically catch the ticker creation.
tickTrap := mClock.Trap().NewTicker("chatd", "subagent_poll")
type awaitResult struct {
chat database.Chat
report string
err error
}
resultCh := make(chan awaitResult, 1)
go func() {
chat, report, err := server.awaitSubagentCompletion(
ctx, parent.ID, child.ID, 5*time.Second,
)
resultCh <- awaitResult{chat, report, err}
}()
// Wait for the poll ticker to be created, confirming
// the function passed its initial check and entered
// the loop. Then release the call.
tickTrap.MustWait(ctx).MustRelease(ctx)
tickTrap.Close()
// Now set the state and advance the clock to the next
// tick so the poll detects the transition.
setChatStatus(ctx, t, db, child.ID, database.ChatStatusWaiting, "")
insertAssistantMessage(t, db, child.ID, model.ID, "poll result")
mClock.Advance(subagentAwaitPollInterval).MustWait(ctx)
result := testutil.RequireReceive(ctx, t, resultCh)
require.NoError(t, result.err)
assert.Equal(t, child.ID, result.chat.ID)
assert.Equal(t, "poll result", result.report)
})
t.Run("CompletesViaPubsub", func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
mClock := quartz.NewMock(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{}, withInternalTestServerClock(mClock))
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
parent, child := createParentChildChats(ctx, t, server, user, org, model)
// signalWake from CreateChat may trigger immediate processing.
// Wait for it to settle, then reset chats to the state we need.
WaitUntilIdleForTest(server)
setChatStatus(ctx, t, db, parent.ID, database.ChatStatusRunning, "")
setChatStatus(ctx, t, db, child.ID, database.ChatStatusRunning, "")
// Trap the fallback poll ticker to know when the
// function has entered the wait setup path. We still
// need an explicit subscription handshake below because
// the ticker can be created before SubscribeWithErr has
// finished registering the listener.
tickTrap := mClock.Trap().NewTicker("chatd", "subagent_poll")
type awaitResult struct {
chat database.Chat
report string
err error
}
resultCh := make(chan awaitResult, 1)
go func() {
chat, report, err := server.awaitSubagentCompletion(
ctx, parent.ID, child.ID, 5*time.Second,
)
resultCh <- awaitResult{chat, report, err}
}()
// Wait for the ticker to be created so the waiter has
// entered its setup path, then subscribe our own probe on
// the same channel. Because MemoryPubsub publishes only to
// listeners already present at Publish time, waiting for
// our probe to receive a message proves the waiter's
// subscription is also registered before we assert on the
// wake-up behavior.
tickTrap.MustWait(ctx).MustRelease(ctx)
tickTrap.Close()
probeCh := make(chan struct{}, 1)
cancelProbe, err := ps.SubscribeWithErr(
coderdpubsub.ChatStateUpdateChannel(child.ID),
func(_ context.Context, _ []byte, _ error) {
select {
case probeCh <- struct{}{}:
default:
}
},
)
require.NoError(t, err)
defer cancelProbe()
// Insert the message before transitioning to Waiting so any
// notification observing the terminal status can also read the
// committed report.
insertAssistantMessage(t, db, child.ID, model.ID, "pubsub result")
setChatStatus(ctx, t, db, child.ID, database.ChatStatusWaiting, "")
require.EventuallyWithT(t, func(c *assert.CollectT) {
chat, report, done, err := server.checkSubagentCompletion(ctx, child.ID)
require.NoError(c, err)
assert.True(c, done)
assert.Equal(c, child.ID, chat.ID)
assert.Equal(c, "pubsub result", report)
}, testutil.WaitMedium, testutil.IntervalFast)
require.NoError(t, ps.Publish(
coderdpubsub.ChatStateUpdateChannel(child.ID),
[]byte("done"),
))
testutil.RequireReceive(ctx, t, probeCh)
result := testutil.RequireReceive(ctx, t, resultCh)
require.NoError(t, result.err)
assert.Equal(t, child.ID, result.chat.ID)
assert.Equal(t, "pubsub result", result.report)
})
t.Run("AlreadyWaitingNoReport", func(t *testing.T) {
t.Parallel()
ctx := chatdTestContext(t)
parent, child := createParentChildChats(ctx, t, server, user, org, model)
// This case should return immediately, so use the shared
// real-clock server instead of a mock clock.
WaitUntilIdleForTest(server)
setChatStatus(ctx, t, db, child.ID, database.ChatStatusWaiting, "")
gotChat, report, err := server.awaitSubagentCompletion(
ctx, parent.ID, child.ID, 5*time.Second,
)
require.NoError(t, err)
assert.Equal(t, child.ID, gotChat.ID)
assert.Empty(t, report)
})
t.Run("Timeout", func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
mClock := quartz.NewMock(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{}, withInternalTestServerClock(mClock))
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
parent, child := createParentChildChats(ctx, t, server, user, org, model)
// Trap the timeout timer to know when the function
// has entered its poll loop.
timerTrap := mClock.Trap().NewTimer("chatd", "subagent_await")
type awaitResult struct {
err error
}
resultCh := make(chan awaitResult, 1)
go func() {
_, _, err := server.awaitSubagentCompletion(
ctx, parent.ID, child.ID, time.Second,
)
resultCh <- awaitResult{err}
}()
// Wait for the timer to be created, release it.
timerTrap.MustWait(ctx).MustRelease(ctx)
timerTrap.Close()
// Advance to the timeout. With pubsub, the fallback
// poll is at 5s, so the 1s timer fires first.
mClock.Advance(time.Second).MustWait(ctx)
result := testutil.RequireReceive(ctx, t, resultCh)
require.Error(t, result.err)
assert.Contains(t, result.err.Error(), "timed out waiting for delegated subagent completion")
})
t.Run("ContextCanceled", func(t *testing.T) {
t.Parallel()
providerCalled := make(chan struct{}, 1)
providerReleased := make(chan struct{})
providerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case providerCalled <- struct{}{}:
default:
}
select {
case <-r.Context().Done():
case <-providerReleased:
}
}))
t.Cleanup(func() {
close(providerReleased)
providerServer.Close()
})
db, ps := dbtestutil.NewDB(t)
providerServerURL, err := url.Parse(providerServer.URL)
require.NoError(t, err)
factory := &aibridgeTestFactory{rt: roundTripFunc(func(req *http.Request) (*http.Response, error) {
cloned := req.Clone(req.Context())
cloned.URL.Scheme = providerServerURL.Scheme
cloned.URL.Host = providerServerURL.Host
cloned.Host = providerServerURL.Host
return http.DefaultTransport.RoundTrip(cloned)
})}
server := newInternalTestServer(
t, db, ps, chatprovider.ProviderAPIKeys{},
withInternalTestServerWorker(),
withInternalTestServerTransportFactory(factory),
)
ctx := chatdTestContext(t)
user, org, _ := seedInternalChatDeps(t, db)
provider := dbgen.ChatProvider(t, db, database.ChatProvider{
Provider: "openai",
DisplayName: "OpenAI",
BaseUrl: providerServer.URL,
})
model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
Model: "gpt-4o-mini",
AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
})
parent, child := createParentChildChats(ctx, t, server, user, org, model)
testutil.RequireReceive(ctx, t, providerCalled)
// Use a short-lived context instead of goroutine + sleep.
shortCtx, cancel := context.WithTimeout(ctx, testutil.IntervalMedium)
defer cancel()
_, _, err = server.awaitSubagentCompletion(
shortCtx, parent.ID, child.ID, 5*time.Second,
)
require.ErrorIs(t, err, context.DeadlineExceeded)
})
t.Run("ZeroTimeoutUsesDefault", func(t *testing.T) {
t.Parallel()
ctx := chatdTestContext(t)
parent, child := createParentChildChats(ctx, t, server, user, org, model)
// Pre-complete the child so it returns immediately.
setChatStatus(ctx, t, db, child.ID, database.ChatStatusWaiting, "")
insertAssistantMessage(t, db, child.ID, model.ID, "zero timeout ok")
gotChat, report, err := server.awaitSubagentCompletion(
ctx, parent.ID, child.ID, 0,
)
require.NoError(t, err)
assert.Equal(t, child.ID, gotChat.ID)
assert.Equal(t, "zero timeout ok", report)
})
}
func TestWaitAgentToolSchema(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
parent, _ := createParentChildChats(ctx, t, server, user, org, model)
tool := findToolByName(server.subagentTools(ctx, func() database.Chat {
return parent
}, parent.LastModelConfigID), "wait_agent")
require.NotNil(t, tool)
timeoutSeconds, ok := tool.Info().Parameters["timeout_seconds"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "integer", timeoutSeconds["type"])
assert.Equal(t, "Defaults to 5 minutes.", timeoutSeconds["description"])
assert.Contains(t, tool.Info().Description, "Returns immediately when the agent finishes")
assert.Contains(t, tool.Info().Description, "A timeout does not stop the agent")
}
func TestWaitAgentTimeoutReturnsInformationalPayload(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
mClock := quartz.NewMock(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{}, withInternalTestServerClock(mClock))
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
parent, child := createParentChildChats(ctx, t, server, user, org, model)
WaitUntilIdleForTest(server)
setChatStatus(ctx, t, db, child.ID, database.ChatStatusRunning, "")
timerTrap := mClock.Trap().NewTimer("chatd", "subagent_await")
type toolResult struct {
resp fantasy.ToolResponse
}
resultCh := make(chan toolResult, 1)
oneSecond := 1
go func() {
resp := runSubagentTool(
ctx,
t,
server,
parent,
parent.LastModelConfigID,
"wait_agent",
waitAgentArgs{ChatID: child.ID.String(), TimeoutSeconds: &oneSecond},
)
resultCh <- toolResult{resp: resp}
}()
// Wait for the timer to be created, then advance past it.
timerTrap.MustWait(ctx).MustRelease(ctx)
timerTrap.Close()
mClock.Advance(time.Second).MustWait(ctx)
result := testutil.RequireReceive(ctx, t, resultCh)
m := requireToolResponseMap(t, result.resp, false)
require.Equal(t, true, m["timed_out"])
require.Equal(t, child.ID.String(), m["chat_id"])
require.Equal(t, string(database.ChatStatusRunning), m["status"])
require.Equal(t, subagentTypeGeneral, m["type"])
}
func TestWaitAgentErrorStatusReturnsStructuredPayload(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
parent, child := createParentChildChats(ctx, t, server, user, org, model)
// An errored, non-archived agent is often recoverable. wait_agent
// must surface a structured payload (status, last_error, report)
// rather than a bare tool error.
WaitUntilIdleForTest(server)
setChatStatus(ctx, t, db, child.ID, database.ChatStatusError, "provider overloaded")
insertAssistantMessage(t, db, child.ID, model.ID, "partial progress")
result := requireToolResponseMap(t, runSubagentTool(
ctx,
t,
server,
parent,
parent.LastModelConfigID,
"wait_agent",
waitAgentArgs{ChatID: child.ID.String()},
), false)
require.Equal(t, string(database.ChatStatusError), result["status"])
require.Equal(t, child.ID.String(), result["chat_id"])
require.Equal(t, "provider overloaded", result["last_error"])
require.Equal(t, "partial progress", result["report"])
require.Equal(t, subagentTypeGeneral, result["type"])
require.Equal(t, string(codersdk.ChatErrorKindGeneric), result["last_error_kind"])
require.Equal(t, false, result["last_error_retryable"])
require.NotContains(t, result, "last_error_detail")
require.NotContains(t, result, "timed_out")
}
func TestSubagentLastError(t *testing.T) {
t.Parallel()
rawJSON := func(s string) pqtype.NullRawMessage {
return pqtype.NullRawMessage{RawMessage: json.RawMessage(s), Valid: true}
}
cases := []struct {
name string
raw pqtype.NullRawMessage
want string
wantDecoded bool
}{
{name: "Invalid", raw: pqtype.NullRawMessage{}, want: ""},
// Unrecognized payloads must not leak raw stored bytes into
// model context; the caller falls back to its status reason.
{name: "NotChatError", raw: rawJSON(`"oops"`), want: ""},
{name: "EmptyObject", raw: rawJSON(`{}`), want: ""},
{
name: "MessageOnly",
raw: rawJSON(`{"message":"provider overloaded"}`),
want: "provider overloaded",
wantDecoded: true,
},
{
name: "DetailReplacesGenericMessage",
raw: rawJSON(`{"message":"The chat request failed unexpectedly.","detail":"reasoning model ` + "`max`" + ` not supported"}`),
want: "reasoning model `max` not supported",
wantDecoded: true,
},
{
name: "DetailOnly",
raw: rawJSON(`{"detail":"reasoning model ` + "`max`" + ` not supported"}`),
want: "reasoning model `max` not supported",
wantDecoded: true,
},
{
name: "DetailAppendedToMeaningfulMessage",
raw: rawJSON(`{"kind":"config","message":"Vercel AI Gateway rejected the model configuration.","detail":"unknown model slug"}`),
want: "Vercel AI Gateway rejected the model configuration. (unknown model slug)",
wantDecoded: true,
},
// Detail passes through exactly as the chat UI renders it,
// including auth details, provider request IDs, and long
// opaque diagnostic tokens.
{
name: "AuthKindDetailPreserved",
raw: rawJSON(`{"kind":"auth","message":"Authentication with Anthropic failed.","detail":"401 invalid x-api-key"}`),
want: "Authentication with Anthropic failed. (401 invalid x-api-key)",
wantDecoded: true,
},
{
name: "RequestIDPreserved",
raw: rawJSON(`{"kind":"generic","message":"The chat request failed unexpectedly.","detail":"upstream error (request id req_0a1b2c3d4e5f6a7b8c9d)"}`),
want: "upstream error (request id req_0a1b2c3d4e5f6a7b8c9d)",
wantDecoded: true,
},
{
name: "ModelSlugPreserved",
raw: rawJSON(`{"kind":"generic","message":"The chat request failed unexpectedly.","detail":"model claude-haiku-4-5-20251001 is not available"}`),
want: "model claude-haiku-4-5-20251001 is not available",
wantDecoded: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
decoded, message := subagentLastError(tc.raw)
require.Equal(t, tc.want, message)
require.Equal(t, tc.wantDecoded, decoded != nil)
})
}
}
func TestWaitAgentErrorStatusSurfacesLastErrorDetail(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
parent, child := createParentChildChats(ctx, t, server, user, org, model)
// A generic fallback message hides the actionable provider detail
// (e.g. an unsupported reasoning effort); wait_agent must surface
// the detail so the parent model can self-correct.
WaitUntilIdleForTest(server)
setChatStatusWithError(ctx, t, db, child.ID, database.ChatStatusError, codersdk.ChatError{
Message: "The chat request failed unexpectedly.",
Detail: "reasoning model `max` not supported",
Kind: codersdk.ChatErrorKindGeneric,
Provider: "vercel",
Retryable: false,
})
insertAssistantMessage(t, db, child.ID, model.ID, "partial progress")
result := requireToolResponseMap(t, runSubagentTool(
ctx,
t,
server,
parent,
parent.LastModelConfigID,
"wait_agent",
waitAgentArgs{ChatID: child.ID.String()},
), false)
require.Equal(t, string(database.ChatStatusError), result["status"])
require.Equal(t, child.ID.String(), result["chat_id"])
require.Equal(t, "reasoning model `max` not supported", result["last_error"])
require.Equal(t, "partial progress", result["report"])
require.Equal(t, string(codersdk.ChatErrorKindGeneric), result["last_error_kind"])
require.Equal(t, false, result["last_error_retryable"])
require.Equal(t, "reasoning model `max` not supported", result["last_error_detail"])
}
func TestWaitAgentTimeoutGapCompletesWithError(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
mClock := quartz.NewMock(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{}, withInternalTestServerClock(mClock))
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
parent, child := createParentChildChats(ctx, t, server, user, org, model)
WaitUntilIdleForTest(server)
setChatStatus(ctx, t, db, child.ID, database.ChatStatusRunning, "")
timerTrap := mClock.Trap().NewTimer("chatd", "subagent_await")
type toolResult struct {
resp fantasy.ToolResponse
}
resultCh := make(chan toolResult, 1)
oneSecond := 1
go func() {
resp := runSubagentTool(
ctx,
t,
server,
parent,
parent.LastModelConfigID,
"wait_agent",
waitAgentArgs{ChatID: child.ID.String(), TimeoutSeconds: &oneSecond},
)
resultCh <- toolResult{resp: resp}
}()
// Wait for the timer to be created, then advance past it.
timerTrap.MustWait(ctx).MustRelease(ctx)
timerTrap.Close()
// Flip the child to error before the timer fires so the
// timeout-gap branch (checkSubagentCompletion after timeout)
// classifies it through handleSubagentDone.
setChatStatus(ctx, t, db, child.ID, database.ChatStatusError, "provider overloaded")
insertAssistantMessage(t, db, child.ID, model.ID, "partial progress")
mClock.Advance(time.Second).MustWait(ctx)
result := testutil.RequireReceive(ctx, t, resultCh)
m := requireToolResponseMap(t, result.resp, false)
require.Equal(t, string(database.ChatStatusError), m["status"])
require.Equal(t, "provider overloaded", m["last_error"])
require.Equal(t, "partial progress", m["report"])
require.Equal(t, child.ID.String(), m["chat_id"])
require.Equal(t, subagentTypeGeneral, m["type"])
require.Equal(t, string(codersdk.ChatErrorKindGeneric), m["last_error_kind"])
require.Equal(t, false, m["last_error_retryable"])
require.NotContains(t, m, "last_error_detail")
require.NotContains(t, m, "timed_out")
}
func TestWaitAgentTimeoutGapSurfacesLastErrorDetail(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
mClock := quartz.NewMock(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{}, withInternalTestServerClock(mClock))
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
parent, child := createParentChildChats(ctx, t, server, user, org, model)
WaitUntilIdleForTest(server)
setChatStatus(ctx, t, db, child.ID, database.ChatStatusRunning, "")
timerTrap := mClock.Trap().NewTimer("chatd", "subagent_await")
type toolResult struct {
resp fantasy.ToolResponse
}
resultCh := make(chan toolResult, 1)
oneSecond := 1
go func() {
resp := runSubagentTool(
ctx,
t,
server,
parent,
parent.LastModelConfigID,
"wait_agent",
waitAgentArgs{ChatID: child.ID.String(), TimeoutSeconds: &oneSecond},
)
resultCh <- toolResult{resp: resp}
}()
timerTrap.MustWait(ctx).MustRelease(ctx)
timerTrap.Close()
// The timeout-gap recheck must surface the same detail-aware
// last_error as the normal poll path.
setChatStatusWithError(ctx, t, db, child.ID, database.ChatStatusError, codersdk.ChatError{
Message: "The chat request failed unexpectedly.",
Detail: "reasoning model `max` not supported",
Kind: codersdk.ChatErrorKindGeneric,
Provider: "vercel",
Retryable: true,
})
insertAssistantMessage(t, db, child.ID, model.ID, "partial progress")
mClock.Advance(time.Second).MustWait(ctx)
result := testutil.RequireReceive(ctx, t, resultCh)
m := requireToolResponseMap(t, result.resp, false)
require.Equal(t, string(database.ChatStatusError), m["status"])
require.Equal(t, "reasoning model `max` not supported", m["last_error"])
require.Equal(t, "partial progress", m["report"])
require.Equal(t, child.ID.String(), m["chat_id"])
require.Equal(t, string(codersdk.ChatErrorKindGeneric), m["last_error_kind"])
require.Equal(t, true, m["last_error_retryable"])
require.Equal(t, "reasoning model `max` not supported", m["last_error_detail"])
require.NotContains(t, m, "timed_out")
}
func TestWaitAgentErrorStatusUnrecognizedLastError(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(t, db)
parent, child := createParentChildChats(ctx, t, server, user, org, model)
WaitUntilIdleForTest(server)
_, err := db.UpdateChatStatus(ctx, database.UpdateChatStatusParams{
ID: child.ID,
Status: database.ChatStatusError,
LastError: pqtype.NullRawMessage{RawMessage: json.RawMessage(`"oops"`), Valid: true},
})
require.NoError(t, err)
result := requireToolResponseMap(t, runSubagentTool(
ctx,
t,
server,
parent,
parent.LastModelConfigID,
"wait_agent",
waitAgentArgs{ChatID: child.ID.String()},
), false)
// Unrecognized payloads fall back to the status reason instead of
// leaking raw stored bytes, and omit the structured fields.
require.Equal(t, string(database.ChatStatusError), result["status"])
require.Equal(t, "agent reached error status", result["last_error"])
require.NotContains(t, result, "last_error_kind")
require.NotContains(t, result, "last_error_retryable")
require.NotContains(t, result, "last_error_detail")
}
func listAgentsChatIDs(t *testing.T, result map[string]any) []string {
t.Helper()
agents, ok := result["agents"].([]any)
require.True(t, ok, "agents must be an array")
ids := make([]string, 0, len(agents))
for _, raw := range agents {
agent, ok := raw.(map[string]any)
require.True(t, ok, "each agent must be an object")
id, ok := agent["chat_id"].(string)
require.True(t, ok, "each agent must have a chat_id")
ids = append(ids, id)
}
return ids
}
func TestListAgents(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
user, org, model := seedInternalChatDeps(t, db)
// Helpers take the running subtest's t and ctx so a failed require
// fires on the correct goroutine.
newParent := func(t *testing.T, ctx context.Context, title string) database.Chat {
t.Helper()
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: title,
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
})
require.NoError(t, err)
return parent
}
newChild := func(t *testing.T, ctx context.Context, parent database.Chat, title string, mode database.NullChatMode) database.Chat {
t.Helper()
child, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
ParentChatID: uuid.NullUUID{UUID: parent.ID, Valid: true},
RootChatID: uuid.NullUUID{UUID: parent.ID, Valid: true},
Title: title,
ModelConfigID: model.ID,
ChatMode: mode,
InitialUserContent: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("do work"),
},
})
require.NoError(t, err)
return child
}
t.Run("Empty", func(t *testing.T) {
t.Parallel()
ctx := chatdTestContext(t)
parent := newParent(t, ctx, "list-agents-empty")
result := requireToolResponseMap(t, runSubagentTool(
ctx, t, server, parent, parent.LastModelConfigID,
"list_agents", listAgentsArgs{},
), false)
require.Equal(t, float64(0), result["total"])
require.Equal(t, float64(0), result["returned"])
require.Equal(t, false, result["has_more"])
require.Empty(t, listAgentsChatIDs(t, result))
})
t.Run("ReturnsChildren", func(t *testing.T) {
t.Parallel()
ctx := chatdTestContext(t)
parent := newParent(t, ctx, "list-agents-children")
generalChild := newChild(t, ctx, parent, "general-child", database.NullChatMode{})
exploreChild := newChild(t, ctx, parent, "explore-child", database.NullChatMode{
ChatMode: database.ChatModeExplore,
Valid: true,
})
result := requireToolResponseMap(t, runSubagentTool(
ctx, t, server, parent, parent.LastModelConfigID,
"list_agents", listAgentsArgs{},
), false)
require.Equal(t, float64(2), result["total"])
require.Equal(t, float64(2), result["returned"])
require.Equal(t, false, result["has_more"])
ids := listAgentsChatIDs(t, result)
require.Contains(t, ids, generalChild.ID.String())
require.Contains(t, ids, exploreChild.ID.String())
agents, ok := result["agents"].([]any)
require.True(t, ok)
typesByID := map[string]string{}
for _, raw := range agents {
agent := raw.(map[string]any)
typesByID[agent["chat_id"].(string)] = agent["type"].(string)
require.NotEmpty(t, agent["created_at"])
require.NotEmpty(t, agent["updated_at"])
}
require.Equal(t, subagentTypeGeneral, typesByID[generalChild.ID.String()])
require.Equal(t, subagentTypeExplore, typesByID[exploreChild.ID.String()])
})
t.Run("Pagination", func(t *testing.T) {
t.Parallel()
ctx := chatdTestContext(t)
parent := newParent(t, ctx, "list-agents-pagination")
newChild(t, ctx, parent, "child-a", database.NullChatMode{})
newChild(t, ctx, parent, "child-b", database.NullChatMode{})
newChild(t, ctx, parent, "child-c", database.NullChatMode{})
limit := 2
first := requireToolResponseMap(t, runSubagentTool(
ctx, t, server, parent, parent.LastModelConfigID,
"list_agents", listAgentsArgs{Limit: &limit},
), false)
require.Equal(t, float64(3), first["total"])
require.Equal(t, float64(2), first["returned"])
require.Equal(t, true, first["has_more"])
firstIDs := listAgentsChatIDs(t, first)
require.Len(t, firstIDs, 2)
offset := 2
second := requireToolResponseMap(t, runSubagentTool(
ctx, t, server, parent, parent.LastModelConfigID,
"list_agents", listAgentsArgs{Limit: &limit, Offset: &offset},
), false)
require.Equal(t, float64(3), second["total"])
require.Equal(t, float64(1), second["returned"])
require.Equal(t, false, second["has_more"])
secondIDs := listAgentsChatIDs(t, second)
require.Len(t, secondIDs, 1)
require.NotContains(t, firstIDs, secondIDs[0])
})
t.Run("OrderByUpdatedAtDesc", func(t *testing.T) {
t.Parallel()
ctx := chatdTestContext(t)
parent := newParent(t, ctx, "list-agents-order")
older := newChild(t, ctx, parent, "older-child", database.NullChatMode{})
newChild(t, ctx, parent, "newer-child", database.NullChatMode{})
// Touch the older child so its updated_at advances past the
// newer one; it must then sort first.
setChatStatus(ctx, t, db, older.ID, database.ChatStatusWaiting, "")
result := requireToolResponseMap(t, runSubagentTool(
ctx, t, server, parent, parent.LastModelConfigID,
"list_agents", listAgentsArgs{},
), false)
ids := listAgentsChatIDs(t, result)
require.Len(t, ids, 2)
require.Equal(t, older.ID.String(), ids[0])
})
t.Run("ExcludesArchived", func(t *testing.T) {
t.Parallel()
ctx := chatdTestContext(t)
parent := newParent(t, ctx, "list-agents-archived")
archivedChild := newChild(t, ctx, parent, "archived-child", database.NullChatMode{})
WaitUntilIdleForTest(server)
// SetArchived is only allowed from a waiting/error state, so
// settle the family into waiting first. Archiving then marks
// the children archived; they must be excluded from
// list_agents by default.
setChatStatus(ctx, t, db, parent.ID, database.ChatStatusWaiting, "")
setChatStatus(ctx, t, db, archivedChild.ID, database.ChatStatusWaiting, "")
require.NoError(t, server.ArchiveChat(ctx, parent))
result := requireToolResponseMap(t, runSubagentTool(
ctx, t, server, parent, parent.LastModelConfigID,
"list_agents", listAgentsArgs{},
), false)
require.Equal(t, float64(0), result["total"])
require.Empty(t, listAgentsChatIDs(t, result))
})
t.Run("DelegatedChatRejected", func(t *testing.T) {
t.Parallel()
ctx := chatdTestContext(t)
parent := newParent(t, ctx, "list-agents-delegated")
child := newChild(t, ctx, parent, "delegated-caller", database.NullChatMode{})
resp := runSubagentTool(
ctx, t, server, child, child.LastModelConfigID,
"list_agents", listAgentsArgs{},
)
require.True(t, resp.IsError, "list_agents on a delegated chat must return an error")
msg := resp.Content
require.Contains(t, msg, "only available on root chats")
})
}