refactor: unify subagent spawn behind spawn_subagent (#24535)

Unify the three subagent spawn tools (`spawn_agent`,
`spawn_explore_agent`, `spawn_computer_use_agent`) behind a single
`spawn_subagent` tool keyed by a `subagent_type` discriminant
(`general`, `explore`, `computer_use`). Mirrors the single-entry-point
pattern already used by `task` in mux while keeping `wait_agent`,
`message_agent`, and `close_agent` as separate lifecycle tools.

A new backend subagent definition catalog
(`coderd/x/chatd/subagent_catalog.go`) is the source of truth for tool
description, prompt guidance, availability rules (plan mode,
desktop/Anthropic gating), and child-chat option building.
`spawn_subagent` advertises only the types available in the current
context and validates `subagent_type` server-side; context inheritance
still flows through the existing `createChildSubagentChatWithOptions`
path. `wait_agent`, `message_agent`, and `close_agent` responses now
include a server-derived `subagent_type` so the UI stops inferring
lifecycle state from tool names.

The frontend gets a shared normalization helper
(`site/src/pages/AgentsPage/components/ChatElements/tools/subagentDescriptor.ts`)
that maps either legacy tool names or new `spawn_subagent` args into a
common descriptor (action, variant, icon, fallback copy). Legacy
transcripts still render identically; `Tool.tsx`, `SubagentTool.tsx`,
`ToolLabel.tsx`, `ToolIcon.tsx`, and `messageParsing.ts` now key off the
descriptor instead of hard-coded names. Existing UI copy is preserved
(`Spawning Explore agent...`, `Using the computer...`, computer-use
monitor icon and Open Desktop affordance).

> This PR was opened by Mux working on Mike's behalf.
This commit is contained in:
Michael Suchacz
2026-04-21 14:01:32 +02:00
committed by GitHub
parent f56adf5731
commit f073323c89
29 changed files with 2235 additions and 712 deletions
+20 -22
View File
@@ -5248,27 +5248,25 @@ func activeToolNamesForTurn(
func allowedExploreToolNames(allTools []fantasy.AgentTool) []string {
builtinExplorePolicy := map[string]bool{
"read_file": true,
"write_file": false,
"edit_files": false,
"execute": true,
"process_output": true,
"process_list": false,
"process_signal": false,
"list_templates": false,
"read_template": false,
"create_workspace": false,
"start_workspace": false,
"propose_plan": false,
"spawn_agent": false,
"spawn_explore_agent": false,
"wait_agent": false,
"message_agent": false,
"close_agent": false,
"spawn_computer_use_agent": false,
"read_skill": true,
"read_skill_file": true,
"ask_user_question": false,
"read_file": true,
"write_file": false,
"edit_files": false,
"execute": true,
"process_output": true,
"process_list": false,
"process_signal": false,
"list_templates": false,
"read_template": false,
"create_workspace": false,
"start_workspace": false,
"propose_plan": false,
"spawn_agent": false,
"wait_agent": false,
"message_agent": false,
"close_agent": false,
"read_skill": true,
"read_skill_file": true,
"ask_user_question": false,
}
toolNames := make([]string, 0, len(allTools))
@@ -5358,7 +5356,7 @@ func buildSystemPrompt(
isPlanModeTurn := behaviorContext.planMode.Valid && behaviorContext.planMode.ChatPlanMode == database.ChatPlanModePlan
if isPlanModeTurn {
if behaviorContext.isRootChat {
prompt = chatprompt.InsertSystem(prompt, PlanningOverlayPrompt)
prompt = chatprompt.InsertSystem(prompt, PlanningOverlayPrompt())
if behaviorContext.planModeInstructions != "" {
prompt = chatprompt.InsertSystem(prompt, behaviorContext.planModeInstructions)
}
+2 -7
View File
@@ -178,11 +178,9 @@ func TestActiveToolNamesForTurn(t *testing.T) {
"start_workspace",
"propose_plan",
"spawn_agent",
"spawn_explore_agent",
"wait_agent",
"message_agent",
"close_agent",
"spawn_computer_use_agent",
"read_skill",
"read_skill_file",
"ask_user_question",
@@ -200,7 +198,6 @@ func TestActiveToolNamesForTurn(t *testing.T) {
"start_workspace",
"propose_plan",
"spawn_agent",
"spawn_explore_agent",
"wait_agent",
"read_skill",
"read_skill_file",
@@ -223,7 +220,6 @@ func TestActiveToolNamesForTurn(t *testing.T) {
"start_workspace",
"propose_plan",
"spawn_agent",
"spawn_explore_agent",
"wait_agent",
"read_skill",
"read_skill_file",
@@ -321,7 +317,6 @@ func TestAllowedExploreToolNames(t *testing.T) {
"process_list",
"process_signal",
"spawn_agent",
"spawn_explore_agent",
"wait_agent",
"read_skill",
"read_skill_file",
@@ -348,7 +343,7 @@ func TestAllowedBehaviorToolNames(t *testing.T) {
return tools
}
allTools := makeTools("read_file", "custom_tool", "spawn_explore_agent")
allTools := makeTools("read_file", "custom_tool", "spawn_agent")
exploreMode := database.NullChatMode{
ChatMode: database.ChatModeExplore,
Valid: true,
@@ -356,7 +351,7 @@ func TestAllowedBehaviorToolNames(t *testing.T) {
t.Run("DefaultModeReturnsAllTools", func(t *testing.T) {
t.Parallel()
require.Equal(t, []string{"read_file", "custom_tool", "spawn_explore_agent"}, allowedBehaviorToolNames(
require.Equal(t, []string{"read_file", "custom_tool", "spawn_agent"}, allowedBehaviorToolNames(
allTools,
database.NullChatMode{},
))
+11 -13
View File
@@ -237,7 +237,7 @@ func TestSubagentChatExcludesWorkspaceProvisioningTools(t *testing.T) {
if callCount.Add(1) == 1 {
// Root chat: model calls spawn_agent.
return chattest.OpenAIStreamingResponse(
chattest.OpenAIToolCallChunk("spawn_agent", `{"prompt":"do the thing","title":"sub"}`),
chattest.OpenAIToolCallChunk("spawn_agent", `{"type":"general","prompt":"do the thing","title":"sub"}`),
)
}
// Subsequent calls (including the subagent): just reply.
@@ -431,7 +431,7 @@ func TestPlanModeSubagentChatExcludesAskUserQuestion(t *testing.T) {
if callCount.Add(1) == 1 {
return chattest.OpenAIStreamingResponse(
chattest.OpenAIToolCallChunk("spawn_agent", `{"prompt":"inspect the codebase","title":"sub"}`),
chattest.OpenAIToolCallChunk("spawn_agent", `{"type":"general","prompt":"inspect the codebase","title":"sub"}`),
)
}
return chattest.OpenAIStreamingResponse(
@@ -587,7 +587,7 @@ func TestExploreSubagentIsReadOnly(t *testing.T) {
if callCount.Add(1) == 1 {
return chattest.OpenAIStreamingResponse(
chattest.OpenAIToolCallChunk("spawn_explore_agent", `{"prompt":"investigate the codebase","title":"sub"}`),
chattest.OpenAIToolCallChunk("spawn_agent", `{"type":"explore","prompt":"investigate the codebase","title":"sub"}`),
)
}
return chattest.OpenAIStreamingResponse(
@@ -631,7 +631,7 @@ func TestExploreSubagentIsReadOnly(t *testing.T) {
sawRoot := false
sawChild := false
for _, tools := range toolsByCall {
if slice.Contains(tools, "spawn_explore_agent") {
if slice.Contains(tools, "spawn_agent") {
sawRoot = true
continue
}
@@ -652,7 +652,7 @@ func TestExploreSubagentIsReadOnly(t *testing.T) {
var rootCalls, childCalls [][]string
var rootRequests, childRequests []recordedOpenAIRequest
for i, tools := range recorded {
if slice.Contains(tools, "spawn_explore_agent") {
if slice.Contains(tools, "spawn_agent") {
rootCalls = append(rootCalls, tools)
rootRequests = append(rootRequests, recordedRequests[i])
continue
@@ -666,13 +666,11 @@ func TestExploreSubagentIsReadOnly(t *testing.T) {
require.NotEmpty(t, rootRequests, "expected at least one root prompt")
require.NotEmpty(t, childRequests, "expected at least one subagent prompt")
require.Contains(t, rootCalls[0], "spawn_agent")
require.Contains(t, rootCalls[0], "spawn_explore_agent")
require.Contains(t, rootCalls[0], "write_file")
require.Contains(t, rootCalls[0], "edit_files")
require.NotContains(t, childCalls[0], "write_file")
require.NotContains(t, childCalls[0], "edit_files")
require.NotContains(t, childCalls[0], "spawn_agent")
require.NotContains(t, childCalls[0], "spawn_explore_agent")
require.NotContains(t, childCalls[0], "wait_agent")
require.Contains(t, childCalls[0], "read_file")
require.Contains(t, childCalls[0], "execute")
@@ -1416,10 +1414,10 @@ func TestPlanTurnPromptContract(t *testing.T) {
if msg.Role != "system" {
continue
}
// The overlay constant includes a placeholder that is replaced at
// The overlay prompt includes a placeholder that is replaced at
// runtime, so strip only the stable body text before checking.
overlayBody := strings.TrimSuffix(
chatd.PlanningOverlayPrompt,
chatd.PlanningOverlayPrompt(),
"{{CODER_CHAT_PLAN_FILE_PATH_BLOCK}}",
)
sanitized := strings.ReplaceAll(msg.Content, overlayBody, "")
@@ -5499,7 +5497,7 @@ func TestComputerUseSubagentToolsAndModel(t *testing.T) {
t.Cleanup(anthropicSrv.Close)
// OpenAI mock for the root chat. The first streaming call
// triggers spawn_computer_use_agent; subsequent calls reply
// triggers spawn_agent; subsequent calls reply
// with text.
var openAICallCount atomic.Int32
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
@@ -5509,8 +5507,8 @@ func TestComputerUseSubagentToolsAndModel(t *testing.T) {
if openAICallCount.Add(1) == 1 {
return chattest.OpenAIStreamingResponse(
chattest.OpenAIToolCallChunk(
"spawn_computer_use_agent",
`{"prompt":"do the desktop thing","title":"cu-sub"}`,
"spawn_agent",
`{"type":"computer_use","prompt":"do the desktop thing","title":"cu-sub"}`,
),
)
}
@@ -5657,7 +5655,7 @@ func TestComputerUseSubagentToolsAndModel(t *testing.T) {
// 5. Verify subagent tools are NOT present.
subagentTools := []string{
"spawn_agent", "spawn_computer_use_agent",
"spawn_agent",
"wait_agent", "message_agent", "close_agent",
}
for _, tool := range subagentTools {
+25 -1
View File
@@ -792,7 +792,12 @@ func toolResultContentToPart(content fantasy.ToolResultContent) codersdk.ChatMes
case fantasy.ToolResultOutputContentError:
isError = true
if output.Error != nil {
result, _ = json.Marshal(map[string]any{"error": output.Error.Error()})
raw := json.RawMessage(strings.TrimSpace(output.Error.Error()))
if isSubagentLifecycleToolName(content.ToolName) && hasErrorField(raw) {
result = raw
} else {
result, _ = json.Marshal(map[string]any{"error": output.Error.Error()})
}
} else {
result = []byte(`{"error":""}`)
}
@@ -819,6 +824,25 @@ func toolResultContentToPart(content fantasy.ToolResultContent) codersdk.ChatMes
return part
}
// Keep in sync with coderd/x/chatd/subagent.go.
func isSubagentLifecycleToolName(name string) bool {
switch name {
case "spawn_agent", "wait_agent", "message_agent", "close_agent":
return true
default:
return false
}
}
func hasErrorField(raw json.RawMessage) bool {
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil {
return false
}
_, ok := payload["error"]
return ok
}
func injectMissingToolResults(prompt []fantasy.Message) []fantasy.Message {
result := make([]fantasy.Message, 0, len(prompt))
for i := 0; i < len(prompt); i++ {
+54 -5
View File
@@ -298,7 +298,7 @@ func TestInjectMissingToolResults_SkipsProviderExecuted(t *testing.T) {
fantasy.ToolCallContent{
ToolCallID: "toolu_local",
ToolName: "spawn_agent",
Input: `{"prompt":"test"}`,
Input: `{"type":"general","prompt":"test"}`,
},
fantasy.ToolCallContent{
ToolCallID: "srvtoolu_websearch",
@@ -310,7 +310,7 @@ func TestInjectMissingToolResults_SkipsProviderExecuted(t *testing.T) {
localResult := mustMarshalToolResult(t,
"toolu_local", "spawn_agent",
json.RawMessage(`{"status":"done"}`),
json.RawMessage(`{"status":"done","type":"general"}`),
false, false, false,
)
@@ -351,12 +351,12 @@ func TestInjectMissingToolResults_SkipsProviderExecuted(t *testing.T) {
func TestInjectMissingToolUses_DropsProviderExecutedOrphans(t *testing.T) {
t.Parallel()
// Step 1: assistant calls spawn_agent x2 + web_search (PE).
// Step 1: assistant calls spawn_agent + legacy spawn_agent + web_search (PE).
step1Assistant := mustMarshalContent(t, []fantasy.Content{
fantasy.ToolCallContent{
ToolCallID: "toolu_A",
ToolName: "spawn_agent",
Input: `{"prompt":"a"}`,
Input: `{"type":"general","prompt":"a"}`,
},
fantasy.ToolCallContent{
ToolCallID: "toolu_B",
@@ -373,7 +373,7 @@ func TestInjectMissingToolUses_DropsProviderExecutedOrphans(t *testing.T) {
resultA := mustMarshalToolResult(t,
"toolu_A", "spawn_agent",
json.RawMessage(`{"status":"done"}`),
json.RawMessage(`{"status":"done","type":"general"}`),
false, false, false,
)
resultB := mustMarshalToolResult(t,
@@ -1151,6 +1151,55 @@ func TestAssistantWriteRoundTrip(t *testing.T) {
require.Equal(t, "ephemeral", cc.Type)
}
func TestStructuredToolErrorWritePreservesJSONObject(t *testing.T) {
t.Parallel()
resultJSON := `{"error":"target chat is not a descendant of current chat","type":"explore"}`
sdkPart := chatprompt.PartFromContent(fantasy.ToolResultContent{
ToolCallID: "call-1",
ToolName: "wait_agent",
Result: fantasy.ToolResultOutputContentError{
Error: xerrors.New(resultJSON),
},
})
require.True(t, sdkPart.IsError)
assert.JSONEq(t, resultJSON, string(sdkPart.Result))
}
func TestStructuredToolErrorWriteWrapsJSONObjectForNonSubagentTool(t *testing.T) {
t.Parallel()
resultJSON := `{"error":"permission denied","detail":"nested payload"}`
sdkPart := chatprompt.PartFromContent(fantasy.ToolResultContent{
ToolCallID: "call-1",
ToolName: "execute",
Result: fantasy.ToolResultOutputContentError{
Error: xerrors.New(resultJSON),
},
})
require.True(t, sdkPart.IsError)
assert.JSONEq(t, `{"error":"{\"error\":\"permission denied\",\"detail\":\"nested payload\"}"}`,
string(sdkPart.Result))
}
func TestStructuredToolErrorWriteWrapsJSONObjectWithoutErrorKey(t *testing.T) {
t.Parallel()
resultJSON := `{"message":"error"}`
sdkPart := chatprompt.PartFromContent(fantasy.ToolResultContent{
ToolCallID: "call-1",
ToolName: "wait_agent",
Result: fantasy.ToolResultOutputContentError{
Error: xerrors.New(resultJSON),
},
})
require.True(t, sdkPart.IsError)
assert.JSONEq(t, `{"error":"{\"message\":\"error\"}"}`, string(sdkPart.Result))
}
// TestMixedFormatConversation verifies ConvertMessagesWithFiles
// handles a realistic post-deploy conversation where legacy and new
// storage formats coexist.
+9 -5
View File
@@ -89,7 +89,7 @@ Propose a plan when:
If no workspace is attached to this chat yet, create and start one first using create_workspace and start_workspace.
Once a workspace is available:
1. Use spawn_explore_agent and wait_agent to research the codebase and gather context as needed. Reserve spawn_agent for writable delegated work.
` + defaultSystemPromptPlanningGuidance + `
2. Use write_file to create a Markdown plan file at the absolute
chat-specific path from the <plan-file-path> block below when it is
available.
@@ -101,22 +101,26 @@ When the <plan-file-path> block below is present, use that exact path.
` + defaultSystemPromptPlanPathBlockPlaceholder + `
</planning>`
// PlanningOverlayPrompt contains plan-mode-only instructions appended
// when the chat is in plan mode.
const PlanningOverlayPrompt = `You are in Plan Mode.
var planningOverlayPrompt = `You are in Plan Mode.
Every response must work toward producing a plan.
The only intentional authored workspace artifact is the plan file at the path specified in the <plan-file-path> block below.
You may use execute and process_output for exploration, including cloning repositories, searching code, and running inspection commands needed to build the plan.
Do not use Plan Mode to implement the requested changes or intentionally modify project files outside the plan file.
If no workspace is attached to this chat yet, create and start one with create_workspace and start_workspace before investigating.
If the plan file already exists, read it first with read_file before replacing or refining it.
Use read_file, execute, process_output, list_templates, read_template, spawn_agent, and approved external MCP tools when available to gather context. Workspace MCP tools are not available in root plan mode, and side-effecting built-in tools such as process_list, process_signal, message_agent, close_agent, and spawn_computer_use_agent remain unavailable. In Plan Mode, spawn_agent delegation is for investigation and planning support, not code writing or implementation.
` + planningOverlaySubagentGuidance() + `
Use write_file to create the plan file and edit_files to refine it.
Use ask_user_question for structured clarification instead of freeform questions.
When the plan is ready, call propose_plan with the plan file path.
After a successful propose_plan call, stop immediately. Do not produce follow-up output.
` + defaultSystemPromptPlanPathBlockPlaceholder
// PlanningOverlayPrompt returns the plan-mode-only instructions appended
// when the chat is in plan mode.
func PlanningOverlayPrompt() string {
return planningOverlayPrompt
}
// Root plan mode may use approved external MCP tools, but delegated
// plan-mode subagents stay on the narrower built-in-only boundary
// because their trust boundary is narrower than the root chat's.
@@ -229,6 +229,7 @@ func TestWaitAgentComputerUseRecording(t *testing.T) {
// Parse the response JSON and check for recording_file_id.
var result map[string]any
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
require.Equal(t, subagentTypeComputerUse, result["type"])
storedFileID, ok := result["recording_file_id"].(string)
require.True(t, ok, "recording_file_id must be present in response")
require.NotEmpty(t, storedFileID)
@@ -312,6 +313,7 @@ func TestWaitAgentComputerUseRecordingWithThumbnail(t *testing.T) {
var result map[string]any
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
require.Equal(t, subagentTypeComputerUse, result["type"])
// Verify recording_file_id is present and valid.
storedFileID, ok := result["recording_file_id"].(string)
@@ -390,6 +392,7 @@ func TestWaitAgentNonComputerUseNoRecording(t *testing.T) {
// Parse the response JSON and verify no recording_file_id.
var result map[string]any
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
require.Equal(t, subagentTypeGeneral, result["type"])
_, hasRecording := result["recording_file_id"]
assert.False(t, hasRecording, "non-computer_use chat should not produce recording_file_id")
}
@@ -445,6 +448,7 @@ func TestWaitAgentRecordingStartFails(t *testing.T) {
// Parse response JSON and assert no recording_file_id.
var result map[string]any
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
require.Equal(t, subagentTypeComputerUse, result["type"])
_, hasRecording := result["recording_file_id"]
assert.False(t, hasRecording, "no recording_file_id when start fails")
}
+124 -189
View File
@@ -48,16 +48,6 @@ Guidelines:
- If an action doesn't produce the expected result, try alternative approaches.
- Report what you accomplished when done.`
type spawnAgentArgs struct {
Prompt string `json:"prompt"`
Title string `json:"title,omitempty"`
}
type spawnComputerUseAgentArgs struct {
Prompt string `json:"prompt"`
Title string `json:"title,omitempty"`
}
type waitAgentArgs struct {
ChatID string `json:"chat_id"`
TimeoutSeconds *int `json:"timeout_seconds,omitempty"`
@@ -164,130 +154,76 @@ func (p *Server) subagentTools(
currentChat func() database.Chat,
currentModelConfigID uuid.UUID,
) []fantasy.AgentTool {
var planMode database.NullChatPlanMode
currentChatSnapshot := database.Chat{}
if currentChat != nil {
planMode = currentChat().PlanMode
currentChatSnapshot = currentChat()
}
spawnAgentDescription := "Spawn a delegated child agent to work on a clearly scoped, " +
"independent task in parallel. Use this when the task is " +
"self-contained and would benefit from a separate agent " +
"(e.g. fixing a specific bug, writing a single module, " +
"running a migration). Do NOT use for simple or quick " +
"operations you can handle directly with execute, " +
"read_file, or write_file. For read-only investigation and " +
"codebase discovery, prefer spawn_explore_agent instead. " +
"Reserve writable subagents for tasks that require " +
"intellectual work such as code analysis, writing new " +
"code, or complex refactoring. Be careful when running " +
"parallel subagents: if two subagents modify the same " +
"files they will conflict with each other, so ensure " +
"parallel subagent tasks are independent. " +
"The child agent receives the same workspace tools but " +
"cannot spawn its own subagents. After spawning, use " +
"wait_agent to collect the result."
if planMode.Valid && planMode.ChatPlanMode == database.ChatPlanModePlan {
spawnAgentDescription += " During plan mode, spawned agents may use shell commands for exploration, such as cloning repositories, searching code, and running inspection commands, but they must not implement changes or intentionally modify workspace files."
}
spawnExploreAgentDescription := "Spawn a read-only delegated child agent for discovery, code reading, and system understanding. Use this when you need investigation, tracing, codebase research, or architecture discovery without intentionally modifying workspace files. The child agent cannot spawn its own subagents and has a restricted toolset focused on reading files and inspection commands. After spawning, use wait_agent to collect the result."
spawnAgentDescription := buildSpawnAgentDescription(
ctx,
p,
currentChatSnapshot,
)
tools := []fantasy.AgentTool{
return []fantasy.AgentTool{
fantasy.NewAgentTool(
"spawn_agent",
spawnAgentToolName,
spawnAgentDescription,
func(ctx context.Context, args spawnAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
if currentChat == nil {
return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil
}
parent := currentChat()
if parent.ParentChatID.Valid {
return fantasy.NewTextErrorResponse("delegated chats cannot create child subagents"), nil
}
parent, err := p.db.GetChatByID(ctx, parent.ID)
parent, err := p.loadSubagentSpawnParentChat(ctx, currentChat)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
childChat, err := p.createChildSubagentChatWithOptions(
definition, err := resolveSubagentDefinition(
ctx,
p,
parent,
args.Prompt,
args.Title,
childSubagentChatOptions{},
args.Type,
)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
return toolJSONResponse(map[string]any{
"chat_id": childChat.ID.String(),
"title": childChat.Title,
"status": string(childChat.Status),
}), nil
},
),
fantasy.NewAgentTool(
"spawn_explore_agent",
spawnExploreAgentDescription,
func(ctx context.Context, args spawnAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
if currentChat == nil {
return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil
}
parent := currentChat()
if parent.ParentChatID.Valid {
return fantasy.NewTextErrorResponse("delegated chats cannot create child subagents"), nil
}
parent, err := p.db.GetChatByID(ctx, parent.ID)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
modelConfigID, err := p.resolveExploreSubagentModelConfigID(
options, err := definition.buildOptions(
ctx,
parent.OwnerID,
p,
parent,
currentModelConfigID,
)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
// Explore subagents operate independently of planning.
// Clear plan mode to prevent the child from inheriting
// parent planning behavior.
clearPlanMode := database.NullChatPlanMode{}
childChat, err := p.createChildSubagentChatWithOptions(
ctx,
parent,
args.Prompt,
args.Title,
childSubagentChatOptions{
chatMode: database.NullChatMode{
ChatMode: database.ChatModeExplore,
Valid: true,
},
modelConfigIDOverride: &modelConfigID,
planModeOverride: &clearPlanMode,
},
)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
return toolJSONResponse(map[string]any{
childChat, err := p.createChildSubagentChatWithOptions(
ctx,
parent,
args.Prompt,
args.Title,
options,
)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
return toolJSONResponse(withSubagentType(map[string]any{
"chat_id": childChat.ID.String(),
"title": childChat.Title,
"status": string(childChat.Status),
}), nil
}, childChat)), nil
},
),
fantasy.NewAgentTool(
"wait_agent",
"Wait until a spawned child agent finishes its task. "+
"Returns the agent's final response and status. "+
"Call this after spawn_agent, spawn_explore_agent, or "+
"spawn_computer_use_agent to collect the result before "+
"continuing your own work.",
"Call this after "+spawnAgentToolName+" to collect the "+
"result before continuing your own work.",
func(ctx context.Context, args waitAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
if currentChat == nil {
return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil
@@ -304,34 +240,41 @@ func (p *Server) subagentTools(
}
parent := currentChat()
// Authorize: the target chat must be a descendant
// of the current (parent) chat.
isDescendant, descErr := isSubagentDescendant(ctx, p.db, parent.ID, targetChatID)
if descErr != nil {
return fantasy.NewTextErrorResponse(
fmt.Sprintf("failed to verify subagent relationship: %v", descErr)), nil
}
if !isDescendant {
return fantasy.NewTextErrorResponse(
"target chat is not a subagent of the current chat"), nil
}
// Check if the target is a computer_use subagent
// and start a desktop recording. Failures are
// best-effort warnings — recording never blocks
// the wait_agent flow.
var recordingID string
var agentConn workspacesdk.AgentConn
targetChatInfo, lookupErr := p.db.GetChatByID(ctx, targetChatID)
if lookupErr != nil && !xerrors.Is(lookupErr, sql.ErrNoRows) {
var targetChatInfo *database.Chat
if chat, lookupErr := p.db.GetChatByID(ctx, targetChatID); lookupErr == nil {
targetChatInfo = &chat
} else if !xerrors.Is(lookupErr, sql.ErrNoRows) {
p.logger.Warn(ctx, "unexpected error looking up chat for recording",
slog.F("chat_id", targetChatID),
slog.Error(lookupErr),
)
}
isComputerUseChat := lookupErr == nil && targetChatInfo.Mode.Valid &&
// Authorize: the target chat must be a descendant
// of the current (parent) chat.
isDescendant, descErr := isSubagentDescendant(ctx, p.db, parent.ID, targetChatID)
if descErr != nil {
return subagentErrorResponse(
xerrors.New(fmt.Sprintf("failed to verify subagent relationship: %v", descErr)),
targetChatInfo,
), nil
}
if !isDescendant {
return subagentErrorResponse(
ErrSubagentNotDescendant,
targetChatInfo,
), nil
}
// Check if the target is a computer_use subagent
// and start a desktop recording. Failures are
// best-effort warnings. Recording never blocks
// the wait_agent flow.
var recordingID string
var agentConn workspacesdk.AgentConn
isComputerUseChat := targetChatInfo != nil &&
targetChatInfo.Mode.Valid &&
targetChatInfo.Mode.ChatMode == database.ChatModeComputerUse &&
targetChatInfo.AgentID.Valid
canRecord := isComputerUseChat && p.agentConnFn != nil
@@ -348,7 +291,7 @@ func (p *Server) subagentTools(
if startErr != nil {
p.logger.Warn(ctx, "failed to start desktop recording",
slog.Error(startErr))
recordingID = "" // Don't try to stop.
recordingID = ""
}
} else {
p.logger.Warn(ctx, "failed to get agent conn for recording",
@@ -360,29 +303,28 @@ func (p *Server) subagentTools(
ctx, parent.ID, targetChatID, timeout,
)
// On timeout/error, leave the recording running on
// the agent so the next wait_agent call continues
// it seamlessly.
// On timeout or error, leave the recording running on
// the agent so the next wait_agent call continues it.
if awaitErr != nil {
return fantasy.NewTextErrorResponse(awaitErr.Error()), nil
return subagentErrorResponse(awaitErr, targetChatInfo), nil
}
// Only stop and store the recording on success.
var recResult recordingResult
if recordingID != "" && agentConn != nil {
// Use a fresh context for cleanup so a canceled
// parent context doesn't prevent recording storage.
// parent context does not prevent recording storage.
stopCtx, stopCancel := context.WithTimeout(context.WithoutCancel(ctx), 90*time.Second)
defer stopCancel()
recResult = p.stopAndStoreRecording(stopCtx, agentConn,
recordingID, parent.ID, parent.OwnerID, parent.WorkspaceID)
}
resp := map[string]any{
"chat_id": targetChatID.String(),
resp := withSubagentType(map[string]any{
"chat_id": targetChat.ID.String(),
"title": targetChat.Title,
"report": report,
"status": string(targetChat.Status),
}
}, targetChat)
if recResult.recordingFileID != "" {
resp["recording_file_id"] = recResult.recordingFileID
}
@@ -410,6 +352,15 @@ func (p *Server) subagentTools(
}
parent := currentChat()
var targetChatInfo *database.Chat
if chat, lookupErr := p.db.GetChatByID(ctx, targetChatID); lookupErr == nil {
targetChatInfo = &chat
} else if !xerrors.Is(lookupErr, sql.ErrNoRows) {
p.logger.Warn(ctx, "unexpected error looking up chat for message",
slog.F("chat_id", targetChatID),
slog.Error(lookupErr),
)
}
busyBehavior := SendMessageBusyBehaviorQueue
if args.Interrupt {
busyBehavior = SendMessageBusyBehaviorInterrupt
@@ -422,15 +373,15 @@ func (p *Server) subagentTools(
busyBehavior,
)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
return subagentErrorResponse(err, targetChatInfo), nil
}
return toolJSONResponse(map[string]any{
"chat_id": targetChatID.String(),
return toolJSONResponse(withSubagentType(map[string]any{
"chat_id": targetChat.ID.String(),
"title": targetChat.Title,
"status": string(targetChat.Status),
"interrupted": args.Interrupt,
}), nil
}, targetChat)), nil
},
),
fantasy.NewAgentTool(
@@ -449,80 +400,58 @@ func (p *Server) subagentTools(
}
parent := currentChat()
var targetChatInfo *database.Chat
if chat, lookupErr := p.db.GetChatByID(ctx, targetChatID); lookupErr == nil {
targetChatInfo = &chat
} else if !xerrors.Is(lookupErr, sql.ErrNoRows) {
p.logger.Warn(ctx, "unexpected error looking up chat for close",
slog.F("chat_id", targetChatID),
slog.Error(lookupErr),
)
}
targetChat, err := p.closeSubagent(
ctx,
parent.ID,
targetChatID,
)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
return subagentErrorResponse(err, targetChatInfo), nil
}
return toolJSONResponse(map[string]any{
"chat_id": targetChatID.String(),
return toolJSONResponse(withSubagentType(map[string]any{
"chat_id": targetChat.ID.String(),
"title": targetChat.Title,
"terminated": true,
"status": string(targetChat.Status),
}), nil
}, targetChat)), nil
},
),
}
}
// Only include the computer use tool when an Anthropic
// provider is configured and desktop is enabled.
if p.isAnthropicConfigured(ctx) && p.isDesktopEnabled(ctx) {
tools = append(tools, fantasy.NewAgentTool(
"spawn_computer_use_agent",
"Spawn a dedicated computer use agent that can see the desktop "+
"(take screenshots) and interact with it (mouse, keyboard, "+
"scroll). The agent runs on a model optimized for computer "+
"use and has the same workspace tools as a standard subagent "+
"plus the native Anthropic computer tool. Use this for tasks "+
"that require visual interaction with a desktop GUI (e.g. "+
"browser automation, GUI testing, visual inspection). After "+
"spawning, use wait_agent to collect the result.",
func(ctx context.Context, args spawnComputerUseAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
if currentChat == nil {
return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil
}
parent := currentChat()
if parent.ParentChatID.Valid {
return fantasy.NewTextErrorResponse("delegated chats cannot create child subagents"), nil
}
parent, err := p.db.GetChatByID(ctx, parent.ID)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
childChat, err := p.createChildSubagentChatWithOptions(
ctx,
parent,
args.Prompt,
args.Title,
childSubagentChatOptions{
chatMode: database.NullChatMode{
ChatMode: database.ChatModeComputerUse,
Valid: true,
},
systemPrompt: computerUseSubagentSystemPrompt + "\n\n" + strings.TrimSpace(args.Prompt),
},
)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
return toolJSONResponse(map[string]any{
"chat_id": childChat.ID.String(),
"title": childChat.Title,
"status": string(childChat.Status),
}), nil
},
))
func (p *Server) loadSubagentSpawnParentChat(
ctx context.Context,
currentChat func() database.Chat,
) (database.Chat, error) {
parent := currentChat()
if err := validateSubagentSpawnParent(parent); err != nil {
return database.Chat{}, err
}
return tools
reloadedParent, err := p.db.GetChatByID(ctx, parent.ID)
if err != nil {
p.logger.Warn(ctx, "failed to load parent chat for spawn_agent",
slog.F("chat_id", parent.ID),
slog.Error(err),
)
return database.Chat{}, xerrors.New("failed to load parent chat")
}
parent = reloadedParent
if err := validateSubagentSpawnParent(parent); err != nil {
return database.Chat{}, err
}
return parent, nil
}
func parseSubagentToolChatID(raw string) (uuid.UUID, error) {
@@ -1148,3 +1077,9 @@ func toolJSONResponse(result map[string]any) fantasy.ToolResponse {
}
return fantasy.NewTextResponse(string(data))
}
func toolJSONErrorResponse(result map[string]any) fantasy.ToolResponse {
resp := toolJSONResponse(result)
resp.IsError = true
return resp
}
+278
View File
@@ -0,0 +1,278 @@
package chatd
import (
"context"
"strings"
"charm.land/fantasy"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database"
)
const (
spawnAgentToolName = "spawn_agent"
subagentTypeGeneral = "general"
subagentTypeExplore = "explore"
subagentTypeComputerUse = "computer_use"
defaultSystemPromptPlanningGuidance = "1. Use " + spawnAgentToolName +
" with type=\"" + subagentTypeExplore +
"\" and wait_agent to research the codebase and gather context as needed. " +
"Reserve type=\"" + subagentTypeGeneral +
"\" for writable delegated work."
)
type spawnAgentArgs struct {
Type string `json:"type"`
Prompt string `json:"prompt"`
Title string `json:"title,omitempty"`
}
type subagentDefinition struct {
id string
description string
unavailableReason func(context.Context, *Server, database.Chat) string
buildOptions func(context.Context, *Server, database.Chat, uuid.UUID, string) (childSubagentChatOptions, error)
}
func allSubagentDefinitions() []subagentDefinition {
return []subagentDefinition{
{
id: subagentTypeGeneral,
description: "delegated work that may inspect or modify workspace files",
buildOptions: func(_ context.Context, _ *Server, _ database.Chat, _ uuid.UUID, _ string) (childSubagentChatOptions, error) {
return childSubagentChatOptions{}, nil
},
},
{
id: subagentTypeExplore,
description: "read-only discovery, code tracing, and system understanding",
buildOptions: func(ctx context.Context, p *Server, parent database.Chat, currentModelConfigID uuid.UUID, _ string) (childSubagentChatOptions, error) {
modelConfigID, err := p.resolveExploreSubagentModelConfigID(
ctx,
parent.OwnerID,
currentModelConfigID,
)
if err != nil {
return childSubagentChatOptions{}, err
}
clearPlanMode := database.NullChatPlanMode{}
return childSubagentChatOptions{
chatMode: database.NullChatMode{
ChatMode: database.ChatModeExplore,
Valid: true,
},
modelConfigIDOverride: &modelConfigID,
planModeOverride: &clearPlanMode,
}, nil
},
},
{
id: subagentTypeComputerUse,
description: "desktop GUI interaction, screenshots, and browser or app automation",
unavailableReason: func(ctx context.Context, p *Server, currentChat database.Chat) string {
if currentChat.PlanMode.Valid && currentChat.PlanMode.ChatPlanMode == database.ChatPlanModePlan {
return `type "computer_use" is unavailable in plan mode`
}
if !p.isAnthropicConfigured(ctx) || !p.isDesktopEnabled(ctx) {
return `type "computer_use" is unavailable because computer use is not configured`
}
return ""
},
buildOptions: func(_ context.Context, _ *Server, _ database.Chat, _ uuid.UUID, prompt string) (childSubagentChatOptions, error) {
return childSubagentChatOptions{
chatMode: database.NullChatMode{
ChatMode: database.ChatModeComputerUse,
Valid: true,
},
systemPrompt: computerUseSubagentSystemPrompt + "\n\n" + strings.TrimSpace(prompt),
}, nil
},
},
}
}
func subagentDefinitionsByID(ids ...string) []subagentDefinition {
defs := make([]subagentDefinition, 0, len(ids))
for _, id := range ids {
if def, ok := lookupSubagentDefinition(id); ok {
defs = append(defs, def)
}
}
return defs
}
func lookupSubagentDefinition(id string) (subagentDefinition, bool) {
for _, def := range allSubagentDefinitions() {
if def.id == id {
return def, true
}
}
return subagentDefinition{}, false
}
func availableSubagentDefinitions(
ctx context.Context,
p *Server,
currentChat database.Chat,
) []subagentDefinition {
defs := allSubagentDefinitions()
available := make([]subagentDefinition, 0, len(defs))
for _, def := range defs {
if def.unavailableReasonText(ctx, p, currentChat) == "" {
available = append(available, def)
}
}
return available
}
func availableSubagentTypeIDs(
ctx context.Context,
p *Server,
currentChat database.Chat,
) []string {
defs := availableSubagentDefinitions(ctx, p, currentChat)
ids := make([]string, 0, len(defs))
for _, def := range defs {
ids = append(ids, def.id)
}
return ids
}
func (d subagentDefinition) unavailableReasonText(
ctx context.Context,
p *Server,
currentChat database.Chat,
) string {
if d.unavailableReason == nil {
return ""
}
return d.unavailableReason(ctx, p, currentChat)
}
func resolveSubagentDefinition(
ctx context.Context,
p *Server,
currentChat database.Chat,
rawSubagentType string,
) (subagentDefinition, error) {
subagentType := strings.TrimSpace(rawSubagentType)
def, ok := lookupSubagentDefinition(subagentType)
if !ok {
return subagentDefinition{}, xerrors.Errorf(
"type must be one of: %s",
strings.Join(availableSubagentTypeIDs(ctx, p, currentChat), ", "),
)
}
if reason := def.unavailableReasonText(ctx, p, currentChat); reason != "" {
return subagentDefinition{}, xerrors.New(reason)
}
return def, nil
}
func validateSubagentSpawnParent(currentChat database.Chat) error {
if currentChat.ParentChatID.Valid {
return xerrors.New("delegated chats cannot create child subagents")
}
if isExploreSubagentMode(currentChat.Mode) {
return xerrors.New("explore chats cannot create child subagents")
}
return nil
}
func subagentTypeFromChat(chat database.Chat) string {
if !chat.Mode.Valid {
return subagentTypeGeneral
}
switch chat.Mode.ChatMode {
case database.ChatModeExplore:
return subagentTypeExplore
case database.ChatModeComputerUse:
return subagentTypeComputerUse
default:
return subagentTypeGeneral
}
}
func withSubagentType(result map[string]any, chat database.Chat) map[string]any {
if result == nil {
result = map[string]any{}
}
result["type"] = subagentTypeFromChat(chat)
return result
}
func subagentErrorResponse(err error, chat *database.Chat) fantasy.ToolResponse {
if chat == nil {
return fantasy.NewTextErrorResponse(err.Error())
}
return toolJSONErrorResponse(withSubagentType(map[string]any{
"error": err.Error(),
}, *chat))
}
func buildSpawnAgentDescription(
ctx context.Context,
p *Server,
currentChat database.Chat,
) string {
availableDefs := availableSubagentDefinitions(ctx, p, currentChat)
description := "Spawn a delegated child subagent to work on a clearly scoped, " +
"independent task in parallel. Use the type field to choose " +
"the right specialist. Available type values: " +
formatSubagentDefinitions(availableDefs) + ". Do not use this for " +
"simple or quick operations you can handle directly with execute, " +
"read_file, or write_file. Reserve writable subagents for tasks that " +
"require intellectual work such as code analysis, writing new code, or " +
"complex refactoring. Be careful when running parallel subagents: if " +
"two subagents modify the same files they will conflict with each " +
"other, so ensure parallel subagent tasks are independent. The child " +
"agent receives the same workspace tools but cannot spawn its own " +
"subagents. After spawning, use wait_agent to collect the result."
if currentChat.PlanMode.Valid && currentChat.PlanMode.ChatPlanMode == database.ChatPlanModePlan {
description += " During plan mode, general and explore subagents may use shell commands for exploration, such as cloning repositories, searching code, and running inspection commands, but they must not implement changes or intentionally modify workspace files."
}
return description
}
func formatSubagentDefinitions(defs []subagentDefinition) string {
return formatSubagentDefinitionsWithDescriptionOverrides(defs, nil)
}
func formatSubagentDefinitionsWithDescriptionOverrides(
defs []subagentDefinition,
descriptionOverrides map[string]string,
) string {
parts := make([]string, 0, len(defs))
for _, def := range defs {
description := def.description
if override, ok := descriptionOverrides[def.id]; ok {
description = override
}
parts = append(parts, def.id+" ("+description+")")
}
return strings.Join(parts, ", ")
}
func planningOverlaySubagentGuidance() string {
planModeDescriptions := map[string]string{
subagentTypeGeneral: "delegated investigation, planning support, and non-mutating exploration",
}
return "Use read_file, execute, process_output, list_templates, read_template, " +
spawnAgentToolName + ", and approved external MCP tools when available to gather context. " +
"Workspace MCP tools are not available in root plan mode, and side-effecting built-in tools such as process_list, process_signal, message_agent, close_agent, and computer-use actions remain unavailable. In Plan Mode, " +
spawnAgentToolName + " delegation is for investigation and planning " +
"support, not code writing or implementation. Allowed type " +
"values in Plan Mode: " +
formatSubagentDefinitionsWithDescriptionOverrides(
subagentDefinitionsByID(
subagentTypeGeneral,
subagentTypeExplore,
),
planModeDescriptions,
) + "."
}
@@ -480,13 +480,13 @@ func TestSpawnComputerUseAgentInheritsContext(t *testing.T) {
parentChat := createParentChatWithInheritedContext(ctx, t, db, server)
tools := server.subagentTools(ctx, func() database.Chat { return parentChat }, parentChat.LastModelConfigID)
tool := findToolByName(tools, "spawn_computer_use_agent")
tool := findToolByName(tools, spawnAgentToolName)
require.NotNil(t, tool)
resp, err := tool.Run(ctx, fantasy.ToolCall{
ID: "call-context",
Name: "spawn_computer_use_agent",
Input: `{"prompt":"inspect bindings"}`,
Name: spawnAgentToolName,
Input: `{"type":"computer_use","prompt":"inspect bindings"}`,
})
require.NoError(t, err)
require.False(t, resp.IsError, "expected success but got: %s", resp.Content)
+516 -210
View File
@@ -308,7 +308,7 @@ func runSubagentTool(
parentChat database.Chat,
currentModelConfigID uuid.UUID,
toolName string,
args spawnAgentArgs,
args any,
) fantasy.ToolResponse {
t.Helper()
@@ -347,11 +347,28 @@ func runSpawnAgentTool(
server,
parentChat,
parentChat.LastModelConfigID,
"spawn_agent",
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)
@@ -367,6 +384,19 @@ func requireSpawnAgentChildChatID(t *testing.T, resp fantasy.ToolResponse) uuid.
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()
@@ -404,7 +434,7 @@ func TestCreateChildSubagentChatCopiesPlanMode(t *testing.T) {
require.Equal(t, planMode, childChat.PlanMode)
}
func TestSpawnAgent_InheritsParentModelWhenOmitted(t *testing.T) {
func TestSpawnAgent_GeneralInheritsParentModelWhenOmitted(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
@@ -417,9 +447,13 @@ func TestSpawnAgent_InheritsParentModelWhenOmitted(t *testing.T) {
)
resp := runSpawnAgentTool(ctx, t, server, parentChat, spawnAgentArgs{
Type: subagentTypeGeneral,
Prompt: "delegate work",
})
childID := requireSpawnAgentChildChatID(t, resp)
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)
@@ -458,7 +492,7 @@ func TestCreateChildSubagentChat_OverrideWorksWhenParentHasNoModel(t *testing.T)
require.Equal(t, overrideModel.ID, childChat.LastModelConfigID)
}
func TestSpawnExploreAgent_UsesConfiguredModelOverride(t *testing.T) {
func TestSpawnAgent_ExploreUsesConfiguredModelOverride(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
@@ -480,10 +514,13 @@ func TestSpawnExploreAgent_UsesConfiguredModelOverride(t *testing.T) {
server,
parentChat,
parentChat.LastModelConfigID,
"spawn_explore_agent",
spawnAgentArgs{Prompt: "investigate the codebase"},
spawnAgentToolName,
spawnAgentArgs{Type: subagentTypeExplore, Prompt: "investigate the codebase"},
)
childID := requireSpawnAgentChildChatID(t, resp)
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)
@@ -493,7 +530,7 @@ func TestSpawnExploreAgent_UsesConfiguredModelOverride(t *testing.T) {
require.False(t, childChat.PlanMode.Valid)
}
func TestSpawnExploreAgent_FallsBackToCurrentTurnModel(t *testing.T) {
func TestSpawnAgent_ExploreFallsBackToCurrentTurnModel(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
@@ -514,8 +551,8 @@ func TestSpawnExploreAgent_FallsBackToCurrentTurnModel(t *testing.T) {
server,
parentChat,
currentTurnModel.ID,
"spawn_explore_agent",
spawnAgentArgs{Prompt: "trace the request flow"},
spawnAgentToolName,
spawnAgentArgs{Type: subagentTypeExplore, Prompt: "trace the request flow"},
)
childID := requireSpawnAgentChildChatID(t, resp)
@@ -525,7 +562,7 @@ func TestSpawnExploreAgent_FallsBackToCurrentTurnModel(t *testing.T) {
require.Equal(t, parentModel.ID, parentChat.LastModelConfigID)
}
func TestSpawnExploreAgent_FallsBackOnInvalidUUID(t *testing.T) {
func TestSpawnAgent_ExploreFallsBackOnInvalidUUID(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
@@ -547,8 +584,8 @@ func TestSpawnExploreAgent_FallsBackOnInvalidUUID(t *testing.T) {
server,
parentChat,
currentTurnModel.ID,
"spawn_explore_agent",
spawnAgentArgs{Prompt: "inspect the handler flow"},
spawnAgentToolName,
spawnAgentArgs{Type: subagentTypeExplore, Prompt: "inspect the handler flow"},
)
childID := requireSpawnAgentChildChatID(t, resp)
@@ -557,7 +594,7 @@ func TestSpawnExploreAgent_FallsBackOnInvalidUUID(t *testing.T) {
require.Equal(t, currentTurnModel.ID, childChat.LastModelConfigID)
}
func TestSpawnExploreAgent_FallsBackWhenOverrideIsUnavailable(t *testing.T) {
func TestSpawnAgent_ExploreFallsBackWhenOverrideIsUnavailable(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
@@ -582,8 +619,8 @@ func TestSpawnExploreAgent_FallsBackWhenOverrideIsUnavailable(t *testing.T) {
server,
parentChat,
currentTurnModel.ID,
"spawn_explore_agent",
spawnAgentArgs{Prompt: "inspect the service boundaries"},
spawnAgentToolName,
spawnAgentArgs{Type: subagentTypeExplore, Prompt: "inspect the service boundaries"},
)
childID := requireSpawnAgentChildChatID(t, resp)
@@ -592,7 +629,7 @@ func TestSpawnExploreAgent_FallsBackWhenOverrideIsUnavailable(t *testing.T) {
require.Equal(t, currentTurnModel.ID, childChat.LastModelConfigID)
}
func TestSpawnExploreAgent_FallsBackWhenOverrideCredentialsAreUnavailable(t *testing.T) {
func TestSpawnAgent_ExploreFallsBackWhenOverrideCredentialsAreUnavailable(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
@@ -639,8 +676,8 @@ func TestSpawnExploreAgent_FallsBackWhenOverrideCredentialsAreUnavailable(t *tes
server,
parentChat,
currentTurnModel.ID,
"spawn_explore_agent",
spawnAgentArgs{Prompt: "inspect provider credential handling"},
spawnAgentToolName,
spawnAgentArgs{Type: subagentTypeExplore, Prompt: "inspect provider credential handling"},
)
childID := requireSpawnAgentChildChatID(t, resp)
@@ -649,103 +686,93 @@ func TestSpawnExploreAgent_FallsBackWhenOverrideCredentialsAreUnavailable(t *tes
require.Equal(t, currentTurnModel.ID, childChat.LastModelConfigID)
}
func TestSpawnComputerUseAgent_NoAnthropicProvider(t *testing.T) {
func TestSpawnAgent_DescriptionListsAllAvailableTypes(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true))
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{
Anthropic: "test-anthropic-key",
})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(ctx, 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_DescriptionOmitsComputerUseWhenUnavailable(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true))
// No Anthropic key in ProviderAPIKeys.
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(ctx, t, db)
// Create a root parent chat.
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "parent-no-anthropic",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
})
require.NoError(t, err)
// Re-fetch so LastModelConfigID is populated from the DB.
parentChat, err := db.GetChatByID(ctx, parent.ID)
require.NoError(t, err)
parentChat := createInternalParentChat(
ctx, t, server, db, org.ID, user.ID, model.ID, "parent-description-unavailable",
)
tools := server.subagentTools(ctx, func() database.Chat { return parentChat }, parentChat.LastModelConfigID)
tool := findToolByName(tools, "spawn_computer_use_agent")
assert.Nil(t, tool, "spawn_computer_use_agent tool must be omitted when Anthropic is not configured")
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)
}
func TestSpawnComputerUseAgent_NotAvailableForChildChats(t *testing.T) {
func TestSpawnAgent_PlanModeDescriptionOmitsComputerUse(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true))
// Provide an Anthropic key so the provider check passes.
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{
Anthropic: "test-anthropic-key",
})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(ctx, t, db)
// Create a root parent chat.
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "root-parent",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
})
require.NoError(t, err)
// Create a child chat under the parent.
child, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
ParentChatID: uuid.NullUUID{
UUID: parent.ID,
Valid: true,
Title: "plan-parent-description",
ModelConfigID: model.ID,
PlanMode: database.NullChatPlanMode{
ChatPlanMode: database.ChatPlanModePlan,
Valid: true,
},
RootChatID: uuid.NullUUID{
UUID: parent.ID,
Valid: true,
},
Title: "child-subagent",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("do something")},
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("plan this change")},
})
require.NoError(t, err)
// Re-fetch the child so ParentChatID is populated.
childChat, err := db.GetChatByID(ctx, child.ID)
require.NoError(t, err)
require.True(t, childChat.ParentChatID.Valid,
"child chat must have a parent")
// Get tools as if the child chat is the current chat.
tools := server.subagentTools(ctx, func() database.Chat { return childChat }, childChat.LastModelConfigID)
tool := findToolByName(tools, "spawn_computer_use_agent")
require.NotNil(t, tool, "spawn_computer_use_agent tool must be present")
resp, err := tool.Run(ctx, fantasy.ToolCall{
ID: "call-2",
Name: "spawn_computer_use_agent",
Input: `{"prompt":"open browser"}`,
})
parentChat, err := db.GetChatByID(ctx, parent.ID)
require.NoError(t, err)
assert.True(t, resp.IsError, "expected an error response")
assert.Contains(t, resp.Content, "delegated chats cannot create child subagents")
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, "must not implement changes or intentionally modify workspace files")
}
func TestSpawnComputerUseAgent_DesktopDisabled(t *testing.T) {
func TestSpawnAgent_PlanModeRejectsComputerUse(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true))
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{
Anthropic: "test-anthropic-key",
})
@@ -753,27 +780,347 @@ func TestSpawnComputerUseAgent_DesktopDisabled(t *testing.T) {
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(ctx, t, db)
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "parent-desktop-disabled",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
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)
tools := server.subagentTools(ctx, func() database.Chat { return parentChat }, parentChat.LastModelConfigID)
tool := findToolByName(tools, "spawn_computer_use_agent")
assert.Nil(t, tool, "spawn_computer_use_agent tool must be omitted when desktop is disabled")
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 TestSpawnComputerUseAgent_UsesComputerUseModelNotParent(t *testing.T) {
func TestPlanningOverlaySubagentGuidance_UsesPlanModeSafeDescriptions(t *testing.T) {
t.Parallel()
guidance := planningOverlaySubagentGuidance()
require.Contains(t, guidance, subagentTypeGeneral)
require.Contains(t, guidance, subagentTypeExplore)
require.NotContains(t, guidance, subagentTypeComputerUse)
require.NotContains(t, guidance, "modify")
require.NotContains(t, guidance, "may inspect or modify workspace files")
}
func TestSpawnAgent_InvalidTypeAndUnavailableTypeAreDistinct(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true))
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(ctx, 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")
unavailableResp := runSubagentTool(
ctx,
t,
server,
parentChat,
parentChat.LastModelConfigID,
spawnAgentToolName,
spawnAgentArgs{Type: subagentTypeComputerUse, Prompt: "open browser"},
)
require.True(t, unavailableResp.IsError)
require.Contains(t, unavailableResp.Content, `type "computer_use" is unavailable because computer use is not configured`)
}
func TestSpawnAgent_BlankTypeReturnsValidOptions(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true))
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{
Anthropic: "test-anthropic-key",
})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(ctx, 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()
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)
require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true))
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{
Anthropic: "test-anthropic-key",
})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(ctx, 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(ctx, 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 {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
if tt.variant == subagentTypeComputerUse {
require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true))
}
providerKeys := chatprovider.ProviderAPIKeys{}
if tt.variant == subagentTypeComputerUse {
providerKeys = chatprovider.ProviderAPIKeys{Anthropic: "test-anthropic-key"}
}
server := newInternalTestServer(t, db, ps, providerKeys)
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(ctx, t, db)
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(ctx, 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, "")
closeResult := requireToolResponseMap(t, runSubagentTool(
ctx,
t,
server,
parentChat,
parentChat.LastModelConfigID,
"close_agent",
closeAgentArgs{ChatID: childID.String()},
), false)
require.Equal(t, tt.variant, closeResult["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(ctx, 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: "CloseAgent",
toolName: "close_agent",
args: closeAgentArgs{ChatID: child.ID.String()},
wantError: ErrSubagentNotDescendant.Error(),
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
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)
require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true))
// Provide an Anthropic key so the tool can proceed.
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{
Anthropic: "test-anthropic-key",
})
@@ -782,25 +1129,14 @@ func TestSpawnComputerUseAgent_UsesComputerUseModelNotParent(t *testing.T) {
user, org, model := seedInternalChatDeps(ctx, t, db)
workspace, build, agent := seedWorkspaceBinding(t, db, user.ID)
// The parent uses an OpenAI model.
require.Equal(t, "openai", model.Provider,
"seed helper must create an OpenAI model")
require.Equal(t, "openai", model.Provider, "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,
},
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")},
@@ -810,25 +1146,18 @@ func TestSpawnComputerUseAgent_UsesComputerUseModelNotParent(t *testing.T) {
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, "spawn_computer_use_agent")
require.NotNil(t, tool)
resp, err := tool.Run(ctx, fantasy.ToolCall{
ID: "call-3",
Name: "spawn_computer_use_agent",
Input: `{"prompt":"take a screenshot"}`,
})
require.NoError(t, err)
require.False(t, resp.IsError, "expected success but got: %s", resp.Content)
// Parse the response to get the child chat ID.
var result map[string]any
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
childIDStr, ok := result["chat_id"].(string)
require.True(t, ok, "response must contain chat_id")
childID, err := uuid.Parse(childIDStr)
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)
@@ -837,23 +1166,73 @@ func TestSpawnComputerUseAgent_UsesComputerUseModelNotParent(t *testing.T) {
require.Equal(t, parentChat.WorkspaceID, childChat.WorkspaceID)
require.Equal(t, parentChat.BuildID, childChat.BuildID)
require.Equal(t, parentChat.AgentID, childChat.AgentID)
// The child must have Mode=computer_use which causes
// runChat to override the model to the predefined computer
// use model instead of using the parent's model config.
require.True(t, childChat.Mode.Valid)
assert.Equal(t, database.ChatModeComputerUse, childChat.Mode.ChatMode)
// The predefined computer use model is Anthropic, which
// differs from the parent's OpenAI model. This confirms
// that the child will not inherit the parent's model at
// runtime.
assert.NotEqual(t, model.Provider, chattool.ComputerUseModelProvider,
"computer use model provider must differ from parent model provider")
assert.Equal(t, "anthropic", chattool.ComputerUseModelProvider)
assert.NotEmpty(t, chattool.ComputerUseModelName)
}
func TestSpawnAgent_ComputerUseInheritsMCPServerIDs(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true))
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{
Anthropic: "test-anthropic-key",
})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(ctx, t, db)
mcpCfg, err := db.InsertMCPServerConfig(ctx, database.InsertMCPServerConfigParams{
DisplayName: "MCP Test",
Slug: "mcp-test",
Url: "https://mcp.example.com",
Transport: "streamable_http",
AuthType: "none",
Availability: "default_off",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
CreatedBy: user.ID,
UpdatedBy: user.ID,
})
require.NoError(t, err)
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()
@@ -930,79 +1309,6 @@ func TestCreateChildSubagentChat_InheritsMCPServerIDs(t *testing.T) {
"child chat must inherit MCP server IDs from parent")
}
func TestSpawnComputerUseAgent_InheritsMCPServerIDs(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true))
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{
Anthropic: "test-anthropic-key",
})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(ctx, t, db)
// Insert an MCP server config.
mcpCfg, err := db.InsertMCPServerConfig(ctx, database.InsertMCPServerConfigParams{
DisplayName: "MCP Test",
Slug: "mcp-test",
Url: "https://mcp.example.com",
Transport: "streamable_http",
AuthType: "none",
Availability: "default_off",
Enabled: true,
ToolAllowList: []string{},
ToolDenyList: []string{},
CreatedBy: user.ID,
UpdatedBy: user.ID,
})
require.NoError(t, err)
parentMCPIDs := []uuid.UUID{mcpCfg.ID}
// Create a parent chat with MCP servers.
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)
// Call spawn_computer_use_agent via the tool.
tools := server.subagentTools(ctx, func() database.Chat { return parentChat }, parentChat.LastModelConfigID)
tool := findToolByName(tools, "spawn_computer_use_agent")
require.NotNil(t, tool)
resp, err := tool.Run(ctx, fantasy.ToolCall{
ID: "call-mcp",
Name: "spawn_computer_use_agent",
Input: `{"prompt":"check the UI"}`,
})
require.NoError(t, err)
require.False(t, resp.IsError, "expected success but got: %s", resp.Content)
// Parse the child chat ID from the response.
var result map[string]any
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
childIDStr, ok := result["chat_id"].(string)
require.True(t, ok)
childID, err := uuid.Parse(childIDStr)
require.NoError(t, err)
// Verify the child inherited MCP server IDs.
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_NoMCPServersStaysEmpty(t *testing.T) {
t.Parallel()
+1 -1
View File
@@ -33,7 +33,7 @@ func TestSpawnComputerUseAgent_CreatesChildWithChatMode(t *testing.T) {
})
require.NoError(t, err)
// Simulate what spawn_computer_use_agent does: set ChatMode
// Simulate what spawn_agent does: set ChatMode
// to computer_use and provide a system prompt.
prompt := "Use the desktop to open Firefox"