From a1143aa5251dbe2a02ec638252d779d4cd85f841 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Tue, 2 Jun 2026 14:30:36 +0100 Subject: [PATCH] fix(acp): handle claude-acp async_launched subagents end-to-end (#1244) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(acp): handle claude-acp async_launched subagents end-to-end Claude Agent SDK's Task tool with run_in_background=true returns session/prompt with stop_reason=end_turn while subagents stay alive in the background, reporting `_meta.claudeCode.toolResponse.status = "async_launched"` instead of a terminal top-level status. kandev previously treated stop_reason as authoritative, cancelled the subagent card on prompt return, and fired an "agent finished without producing any output" notice — even though the dispatch did produce work and the subagent's child events would arrive seconds later. Backend: - `SubagentTaskPayload` gains `IsAsync` / `OutputFile` / `CanReadOutputFile` so the async-launched envelope is preserved. - `convertToolCallResultUpdate` recognizes async_launched as terminal for the Task tool itself (dispatch is the completion). - `cancelActiveToolCalls` preserves subagent_task tool calls through the prompt-return sweep so a late terminal tool_call_update from the SDK still lands correctly (covers the in-turn-complete case). Frontend: - `subagentMetaChips` emits a "background" chip when `is_async` or `status == "async_launched"` so the card is visually distinct. - `computeEmptyTurnNotice` suppresses the notice when any subagent task message exists for the turn (the dispatch IS output). Reproduced via `acpdbg prompt` against `claude-acp` 0.31.1; captured frames in acp-debug/claude-acp-prompt-20260602-110119.jsonl drive the backend test fixtures verbatim. * fix(acp): harden async_launched override against future SDK frames Address PR review feedback (greptile #1244, claude review): - Move the async_launched override inside the mutex lock so it can inspect the stored payload kind before flipping the status. - Drop the `status == ""` precondition. The earlier in_progress synthesis (Title/RawInput/Content/Locations) was implicitly trumping the override; a future SDK frame that combines async_launched with any of those fields would have left the card stuck in_progress forever. The override is now unconditional for async_launched subagent_task frames. - Gate the override on `payload.Kind() == ToolKindSubagentTask`. A hypothetical future claude-acp tool that reuses the `async_launched` literal in its own `_meta.claudeCode.toolResponse.status` would have been incorrectly marked complete by the prior unscoped check. Pinned by two new tests: - TestConvertToolCallResultUpdate_AsyncLaunched_OverridesInProgress - TestConvertToolCallResultUpdate_AsyncLaunched_NonSubagentNotTerminated --------- Co-authored-by: Kandev Agent --- .../adapter/transport/acp/adapter_prompt.go | 14 +- .../transport/acp/adapter_prompt_test.go | 379 ++++++++++++++++++ .../adapter/transport/acp/adapter_tools.go | 20 +- .../server/adapter/transport/acp/subagent.go | 44 ++ .../adapter/transport/acp/subagent_test.go | 50 +++ .../agentctl/types/streams/tool_payload.go | 9 + .../task/chat/messages/subagent-meta.test.ts | 19 + .../task/chat/messages/subagent-meta.ts | 6 + apps/web/components/task/chat/types.ts | 8 +- .../lib/ws/handlers/empty-turn-notice.test.ts | 21 + apps/web/lib/ws/handlers/empty-turn-notice.ts | 17 + 11 files changed, 583 insertions(+), 4 deletions(-) create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt_test.go diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt.go index 0bb4b5ce60..022fe95955 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt.go @@ -333,6 +333,13 @@ func (a *Adapter) Cancel(ctx context.Context) error { // or sweepMonitorsOnReplayEnd) which uses the appropriate status and a // payload snapshot. Without this skip the Monitor would receive two // terminal events with conflicting states. +// +// Subagent (Task) tool calls are also preserved: the Claude Agent SDK can +// return session/prompt with stop_reason while the subagent is still running +// (anthropics/claude-code#47936). Cancelling the card here would mark it +// terminated even though the SDK keeps streaming its real tool_call_update +// when the subagent finishes seconds later. Leaving the entry in +// activeToolCalls lets that authoritative terminal update land naturally. func (a *Adapter) cancelActiveToolCalls(sessionID string) { a.mu.Lock() monitorToolCallIDs := make(map[string]bool) @@ -342,9 +349,12 @@ func (a *Adapter) cancelActiveToolCalls(sessionID string) { toCancel := make(map[string]*streams.NormalizedPayload) preserved := make(map[string]*streams.NormalizedPayload) for tcID, payload := range a.activeToolCalls { - if monitorToolCallIDs[tcID] { + switch { + case monitorToolCallIDs[tcID]: preserved[tcID] = payload - } else { + case payload != nil && payload.Kind() == streams.ToolKindSubagentTask: + preserved[tcID] = payload + default: toCancel[tcID] = payload } } diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt_test.go new file mode 100644 index 0000000000..38a753a605 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt_test.go @@ -0,0 +1,379 @@ +package acp + +import ( + "testing" + + "github.com/coder/acp-go-sdk" + "github.com/kandev/kandev/internal/agentctl/types/streams" +) + +// TestCancelActiveToolCalls_PreservesSubagentTask pins the fix for the +// "main agent finished, subagent keeps working" bug. The Claude Agent SDK +// (anthropics/claude-code#47936) can return session/prompt with stop_reason +// while a Task subagent is still in flight. Cancelling that tool call here +// would mark the subagent card terminated even though a real +// tool_call_update lands seconds later. +func TestCancelActiveToolCalls_PreservesSubagentTask(t *testing.T) { + a := newTestAdapter() + + a.activeToolCalls["shell-1"] = streams.NewShellExec("ls", "", "list files", 0, false) + a.activeToolCalls["subagent-1"] = streams.NewSubagentTask("Investigate", "do it", "general-purpose") + + a.cancelActiveToolCalls("sess-1") + + a.mu.RLock() + _, shellPreserved := a.activeToolCalls["shell-1"] + _, subagentPreserved := a.activeToolCalls["subagent-1"] + a.mu.RUnlock() + + if shellPreserved { + t.Error("non-subagent tool call must be cancelled and removed from activeToolCalls") + } + if !subagentPreserved { + t.Error("subagent_task tool call must be preserved so a later authoritative tool_call_update can land") + } + + var cancelledIDs []string + for _, ev := range drainEvents(a) { + if ev.Type == streams.EventTypeToolUpdate && ev.ToolStatus == toolStatusCancelled { + cancelledIDs = append(cancelledIDs, ev.ToolCallID) + } + } + + for _, id := range cancelledIDs { + if id == "subagent-1" { + t.Error("must not emit cancelled tool_update for subagent_task — leave terminal status to the SDK") + } + } + if len(cancelledIDs) != 1 || cancelledIDs[0] != "shell-1" { + t.Errorf("cancelled IDs = %v, want [shell-1]", cancelledIDs) + } +} + +// TestCancelActiveToolCalls_SubagentLateUpdate_LandsAsComplete drives the full +// race scenario end-to-end through the real adapter methods (no mocks): +// +// 1. ACP delivers the initial subagent tool_call (Claude `_meta.claudeCode.toolName=Agent`). +// 2. session/prompt returns early with stop_reason (anthropics/claude-code#47936) +// so the adapter sweeps in-flight tool calls via cancelActiveToolCalls. +// 3. Seconds later the SDK delivers the real terminal tool_call_update with the +// subagent metrics (status=completed, totalDurationMs, totalTokens, ToolUseCount). +// +// With the fix, the subagent payload stays in activeToolCalls through the cancel +// sweep, so the late tool_call_update finds it and enriches the card with the +// real result. Without the fix the payload would have been deleted and the +// subagent card would terminate as "cancelled" with no metrics. +func TestCancelActiveToolCalls_SubagentLateUpdate_LandsAsComplete(t *testing.T) { + a := newTestAdapter() + + // 1. Initial tool_call for the subagent — Claude tags it via _meta.claudeCode.toolName=Agent. + initial := &acp.SessionUpdateToolCall{ + ToolCallId: "sub-1", + Title: "Agent", + Kind: acp.ToolKind("other"), + Meta: map[string]any{ + "claudeCode": map[string]any{"toolName": "Agent"}, + }, + RawInput: map[string]any{ + "description": "Investigate flaky test", + "prompt": "Find root cause", + "subagent_type": "general-purpose", + }, + } + if ev := a.convertToolCallUpdate("s1", initial); ev == nil { + t.Fatalf("seed: convertToolCallUpdate returned nil") + } + + a.mu.RLock() + stored := a.activeToolCalls["sub-1"] + a.mu.RUnlock() + if stored == nil || stored.Kind() != streams.ToolKindSubagentTask { + t.Fatalf("seed: activeToolCalls['sub-1'] kind = %v, want subagent_task", stored) + } + + // 2. session/prompt returns early — adapter sweeps active tool calls. + _ = drainEvents(a) + a.cancelActiveToolCalls("s1") + + a.mu.RLock() + stillStored := a.activeToolCalls["sub-1"] + a.mu.RUnlock() + if stillStored == nil { + t.Fatal("subagent payload must survive the cancel sweep so a late tool_call_update can enrich it") + } + + // 3. Late terminal tool_call_update arrives with Claude's toolResponse metadata. + completed := acp.ToolCallStatus("completed") + tcu := &acp.SessionToolCallUpdate{ + ToolCallId: "sub-1", + Status: &completed, + Meta: map[string]any{"claudeCode": map[string]any{"toolResponse": map[string]any{ + "agentId": "agent_abc", + "agentType": "general-purpose", + "status": "completed", + "totalDurationMs": float64(12345), + "totalTokens": float64(6789), + "totalToolUseCount": float64(11), + }}}, + } + + ev := a.convertToolCallResultUpdate("s1", tcu) + if ev == nil { + t.Fatal("expected terminal event from late tool_call_update") + } + if ev.ToolStatus != toolStatusComplete { + t.Errorf("ToolStatus = %q, want %q (late update must drive the card to complete, not cancelled)", + ev.ToolStatus, toolStatusComplete) + } + if ev.NormalizedPayload == nil { + t.Fatal("expected NormalizedPayload on terminal event") + } + sa := ev.NormalizedPayload.SubagentTask() + if sa == nil { + t.Fatalf("expected subagent payload, got %v", ev.NormalizedPayload.Kind()) + } + if sa.Description != "Investigate flaky test" { + t.Errorf("Description = %q, want preserved from initial tool_call", sa.Description) + } + if sa.Status != "completed" { + t.Errorf("subagent status = %q, want completed", sa.Status) + } + if sa.DurationMs != 12345 || sa.TotalTokens != 6789 { + t.Errorf("metrics = %d/%d, want 12345/6789", sa.DurationMs, sa.TotalTokens) + } + if sa.ToolUseCount == nil || *sa.ToolUseCount != 11 { + t.Errorf("ToolUseCount = %v, want 11", sa.ToolUseCount) + } +} + +// TestConvertToolCallResultUpdate_AsyncLaunched_TerminalComplete drives the +// real claude-acp async-launched envelope through convertToolCallUpdate + +// convertToolCallResultUpdate end-to-end. The bug: +// +// - Initial tool_call lands (Task, _meta.claudeCode.toolName=Agent). +// - tool_call_update arrives with top-level Status=nil and +// _meta.claudeCode.toolResponse.status="async_launched" (plus isAsync, +// outputFile, canReadOutputFile). +// - Without the fix, status stays empty → orchestrator drops the update → +// card stays "pending" forever in the UI. +// +// Reproduction file: acp-debug/claude-acp-prompt-20260602-110119.jsonl +// (3 subagents stuck async_launched; session/prompt returned end_turn; no +// subsequent terminal updates). +func TestConvertToolCallResultUpdate_AsyncLaunched_TerminalComplete(t *testing.T) { + a := newTestAdapter() + + initial := &acp.SessionUpdateToolCall{ + ToolCallId: "toolu_async_1", + Title: "Task", + Kind: acp.ToolKind("think"), + Meta: map[string]any{"claudeCode": map[string]any{"toolName": "Agent"}}, + } + if ev := a.convertToolCallUpdate("s1", initial); ev == nil { + t.Fatalf("seed: convertToolCallUpdate returned nil") + } + _ = drainEvents(a) + + // Replay the exact frame shape observed in run-2/run-6 (top-level Status=nil). + tcu := &acp.SessionToolCallUpdate{ + ToolCallId: "toolu_async_1", + Status: nil, + Meta: map[string]any{"claudeCode": map[string]any{"toolResponse": map[string]any{ + "agentId": "ab9a9f3ed453c911e", + "description": "Sleep and write file 1", + "isAsync": true, + "outputFile": "/tmp/tasks/ab9a9f3ed453c911e.output", + "canReadOutputFile": true, + "status": "async_launched", + "prompt": "Run this exact bash command: `sleep 120 && echo done`. Report when done.", + }}}, + } + + ev := a.convertToolCallResultUpdate("s1", tcu) + if ev == nil { + t.Fatal("expected terminal event for async_launched") + } + if ev.ToolStatus != toolStatusComplete { + t.Errorf("ToolStatus = %q, want %q (async_launched is terminal for the Task tool)", + ev.ToolStatus, toolStatusComplete) + } + if ev.NormalizedPayload == nil { + t.Fatal("expected NormalizedPayload") + } + sa := ev.NormalizedPayload.SubagentTask() + if sa == nil { + t.Fatalf("expected subagent payload, got kind=%v", ev.NormalizedPayload.Kind()) + } + if sa.Status != "async_launched" { + t.Errorf("payload.Status = %q, want async_launched (preserved verbatim for UI)", sa.Status) + } + if !sa.IsAsync { + t.Error("payload.IsAsync = false, want true") + } + if sa.OutputFile != "/tmp/tasks/ab9a9f3ed453c911e.output" { + t.Errorf("payload.OutputFile = %q", sa.OutputFile) + } + if !sa.CanReadOutputFile { + t.Error("payload.CanReadOutputFile = false, want true") + } + if sa.AgentID != "ab9a9f3ed453c911e" { + t.Errorf("payload.AgentID = %q", sa.AgentID) + } + + // Subsequent cancel sweep must NOT re-cancel the now-terminal subagent. + // Since isTerminal was true, convertToolCallResultUpdate already removed + // the entry from activeToolCalls. + a.mu.RLock() + _, stillTracked := a.activeToolCalls["toolu_async_1"] + a.mu.RUnlock() + if stillTracked { + t.Error("terminal async_launched update should remove the tool call from activeToolCalls") + } +} + +// TestConvertToolCallResultUpdate_AsyncLaunched_NonSubagentNotTerminated guards +// the kind gate: a non-Task tool whose meta happens to carry +// `claudeCode.toolResponse.status == "async_launched"` (hypothetical future +// claude-acp tool reusing the literal) must NOT be marked complete by the +// override. Only subagent_task payloads earn the dispatch-is-terminal treatment. +func TestConvertToolCallResultUpdate_AsyncLaunched_NonSubagentNotTerminated(t *testing.T) { + a := newTestAdapter() + + // Seed a non-Task tool call (a Bash) into activeToolCalls. + initial := &acp.SessionUpdateToolCall{ + ToolCallId: "bash-x", + Title: "Bash", + Kind: acp.ToolKind("execute"), + Meta: map[string]any{"claudeCode": map[string]any{"toolName": "Bash"}}, + RawInput: map[string]any{"command": "ls"}, + } + if ev := a.convertToolCallUpdate("s1", initial); ev == nil { + t.Fatalf("seed: convertToolCallUpdate returned nil") + } + _ = drainEvents(a) + + // Update frame carries async_launched but the underlying payload is shell_exec. + tcu := &acp.SessionToolCallUpdate{ + ToolCallId: "bash-x", + Status: nil, + Meta: map[string]any{"claudeCode": map[string]any{"toolResponse": map[string]any{ + "status": "async_launched", + "isAsync": true, + }}}, + } + ev := a.convertToolCallResultUpdate("s1", tcu) + if ev == nil { + t.Fatal("expected event") + } + if ev.ToolStatus == toolStatusComplete { + t.Errorf("ToolStatus = %q, want non-complete (kind gate must reject non-subagent tools)", ev.ToolStatus) + } + + // Card must remain in activeToolCalls (not deleted as a terminal update). + a.mu.RLock() + _, stillTracked := a.activeToolCalls["bash-x"] + a.mu.RUnlock() + if !stillTracked { + t.Error("non-subagent tool must NOT be removed from activeToolCalls on async_launched envelope") + } +} + +// TestConvertToolCallResultUpdate_AsyncLaunched_OverridesInProgress guards the +// removal of the `status == ""` guard. If a future SDK version adds Title or +// RawInput to the async_launched frame, the earlier in_progress synthesis +// (line ~159) would set status="in_progress" and a gated override would never +// fire — leaving the card stuck. The unconditional override prevents that. +func TestConvertToolCallResultUpdate_AsyncLaunched_OverridesInProgress(t *testing.T) { + a := newTestAdapter() + + initial := &acp.SessionUpdateToolCall{ + ToolCallId: "toolu_async_2", + Title: "Task", + Kind: acp.ToolKind("think"), + Meta: map[string]any{"claudeCode": map[string]any{"toolName": "Agent"}}, + } + if ev := a.convertToolCallUpdate("s1", initial); ev == nil { + t.Fatalf("seed: nil") + } + _ = drainEvents(a) + + // Simulate a hypothetical future SDK frame: async_launched envelope plus a + // Title that triggers the in_progress synthesis. The override must still win. + newTitle := "Task: backgrounded" + tcu := &acp.SessionToolCallUpdate{ + ToolCallId: "toolu_async_2", + Status: nil, + Title: &newTitle, + Meta: map[string]any{"claudeCode": map[string]any{"toolResponse": map[string]any{ + "status": "async_launched", + "isAsync": true, + "outputFile": "/tmp/x.output", + }}}, + } + ev := a.convertToolCallResultUpdate("s1", tcu) + if ev == nil { + t.Fatal("expected event") + } + if ev.ToolStatus != toolStatusComplete { + t.Errorf("ToolStatus = %q, want %q — async_launched override must beat the in_progress synthesis even when Title is set", + ev.ToolStatus, toolStatusComplete) + } +} + +// TestCancelActiveToolCalls_NestedBashCancelledSubagentPreserved verifies the +// realistic claude-acp shape: a subagent (Agent tool) has a child Bash tool_call +// tagged with parentToolUseId. On early prompt return, the child Bash must be +// cancelled (it really is dead from the SDK's perspective), but the parent +// subagent card must be preserved. +func TestCancelActiveToolCalls_NestedBashCancelledSubagentPreserved(t *testing.T) { + a := newTestAdapter() + + parent := &acp.SessionUpdateToolCall{ + ToolCallId: "sub-1", + Title: "Agent", + Kind: acp.ToolKind("other"), + Meta: map[string]any{"claudeCode": map[string]any{"toolName": "Agent"}}, + RawInput: map[string]any{"description": "Do work", "prompt": "p", "subagent_type": "general-purpose"}, + } + child := &acp.SessionUpdateToolCall{ + ToolCallId: "bash-1", + Title: "Bash", + Kind: acp.ToolKind("execute"), + Meta: map[string]any{"claudeCode": map[string]any{ + "toolName": "Bash", + "parentToolUseId": "sub-1", + }}, + RawInput: map[string]any{"command": "sleep 30"}, + } + if ev := a.convertToolCallUpdate("s1", parent); ev == nil { + t.Fatalf("seed parent: nil") + } + if ev := a.convertToolCallUpdate("s1", child); ev == nil { + t.Fatalf("seed child: nil") + } + _ = drainEvents(a) + + a.cancelActiveToolCalls("s1") + + a.mu.RLock() + _, parentKept := a.activeToolCalls["sub-1"] + _, childKept := a.activeToolCalls["bash-1"] + a.mu.RUnlock() + + if !parentKept { + t.Error("parent subagent card must be preserved through cancel sweep") + } + if childKept { + t.Error("nested bash must be cancelled and removed (it really is dead from the SDK's perspective)") + } + + var cancelEvents []AgentEvent + for _, ev := range drainEvents(a) { + if ev.Type == streams.EventTypeToolUpdate && ev.ToolStatus == toolStatusCancelled { + cancelEvents = append(cancelEvents, ev) + } + } + if len(cancelEvents) != 1 || cancelEvents[0].ToolCallID != "bash-1" { + t.Errorf("expected exactly one cancelled event for bash-1, got %d events", len(cancelEvents)) + } +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_tools.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_tools.go index da3d622139..2a5b76fd44 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_tools.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_tools.go @@ -183,11 +183,29 @@ func (a *Adapter) convertToolCallResultUpdate(sessionID string, tcu *acp.Session // the view as ended instead. isTrackedMonitorTerminal := !isMonitorRegistration && isMonitorMeta(tcu.Meta) && a.isTrackedMonitor(sessionID, toolCallID) - isTerminal := status == toolStatusComplete || status == toolStatusError || status == toolStatusCancelled + // Cheap pre-lock inspection of the meta envelope. The override itself runs + // under the lock so it can gate on payload kind (subagent_task only). + isAsyncLaunchedSub := isSubagentAsyncLaunched(tcu.Meta) a.mu.Lock() payload := a.activeToolCalls[toolCallID] + // Recognize claude-acp's async-launched subagent envelope: the Task tool + // successfully dispatched a background subagent. The dispatch IS terminal + // for the Task tool itself — the subagent runs out-of-band and writes its + // result to OutputFile, and no later tool_call_update arrives for it. + // Override unconditionally (not gated on status == "") so a future SDK + // version that adds Title/RawInput/Content to the async_launched frame + // can't leave the card stuck after the earlier in_progress synthesis + // kicked in. Gated on subagent_task payload kind so an unrelated + // claudeCode-namespaced tool that happens to use the same status literal + // can't accidentally terminate. + if isAsyncLaunchedSub && payload != nil && payload.Kind() == streams.ToolKindSubagentTask { + status = toolStatusComplete + } + + isTerminal := status == toolStatusComplete || status == toolStatusError || status == toolStatusCancelled + // Update stored payload with incremental rawInput (e.g. Claude Code sends // command/cwd in a tool_call_update after the initial empty tool_call). // OpenCode may send filePath/locations on the update frame only. diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/subagent.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/subagent.go index 3f8fd18f4b..6bbc1d2a35 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/subagent.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/subagent.go @@ -40,6 +40,38 @@ type SubagentTaskResult struct { // ToolUseCountKnown distinguishes a reported zero from "not reported" — the // agent supplied totalToolUseCount (possibly 0) vs the field being absent. ToolUseCountKnown bool + + // IsAsync / OutputFile / CanReadOutputFile carry the async-launched + // envelope Claude Code emits when the Task tool dispatches a background + // subagent. The dispatch is terminal for the Task tool itself; the + // subagent runs out-of-band and writes its result to OutputFile. + IsAsync bool + OutputFile string + CanReadOutputFile bool +} + +// subagentAsyncLaunchedStatus is the Claude Code marker that the Task tool +// dispatched a background subagent. It appears at +// `_meta.claudeCode.toolResponse.status` with `_meta.claudeCode.toolResponse.isAsync:true`. +const subagentAsyncLaunchedStatus = "async_launched" + +// isSubagentAsyncLaunched reports whether a tool_call_update meta carries the +// claude-acp async-launched envelope. Defensive over untyped maps so it can be +// called on any meta payload. +func isSubagentAsyncLaunched(meta map[string]any) bool { + if meta == nil { + return false + } + cc, ok := meta["claudeCode"].(map[string]any) + if !ok { + return false + } + resp, ok := cc["toolResponse"].(map[string]any) + if !ok { + return false + } + status, _ := resp["status"].(string) + return status == subagentAsyncLaunchedStatus } // recognizeSubagent reports whether a tool call spawns a subagent (Task) and @@ -167,6 +199,9 @@ func claudeSubagentResponse(meta map[string]any, res *SubagentTaskResult) bool { res.ToolUseCount = int(asInt64(count)) res.ToolUseCountKnown = true } + res.IsAsync, _ = resp["isAsync"].(bool) + res.OutputFile, _ = resp["outputFile"].(string) + res.CanReadOutputFile, _ = resp["canReadOutputFile"].(bool) return true } @@ -257,4 +292,13 @@ func applySubagentResult(p *streams.SubagentTaskPayload, res SubagentTaskResult) count := res.ToolUseCount p.ToolUseCount = &count } + if res.IsAsync { + p.IsAsync = true + } + if res.OutputFile != "" { + p.OutputFile = res.OutputFile + } + if res.CanReadOutputFile { + p.CanReadOutputFile = true + } } diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/subagent_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/subagent_test.go index 552a98b507..b262daf62c 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/subagent_test.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/subagent_test.go @@ -180,6 +180,56 @@ func TestExtractSubagentResult_Cursor(t *testing.T) { } } +// TestExtractSubagentResult_ClaudeAsyncLaunched covers the claude-acp +// envelope for `run_in_background: true`: status=async_launched plus the +// isAsync/outputFile/canReadOutputFile fields. +func TestExtractSubagentResult_ClaudeAsyncLaunched(t *testing.T) { + meta := map[string]any{"claudeCode": map[string]any{"toolResponse": map[string]any{ + "agentId": "agent_async_1", + "description": "Run sleep", + "isAsync": true, + "outputFile": "/tmp/tasks/abc.output", + "canReadOutputFile": true, + "status": "async_launched", + }}} + res, ok := extractSubagentResult(meta, nil) + if !ok { + t.Fatal("expected extraction to succeed") + } + if res.Status != "async_launched" { + t.Errorf("Status = %q, want async_launched", res.Status) + } + if !res.IsAsync { + t.Error("IsAsync = false, want true") + } + if res.OutputFile != "/tmp/tasks/abc.output" { + t.Errorf("OutputFile = %q", res.OutputFile) + } + if !res.CanReadOutputFile { + t.Error("CanReadOutputFile = false, want true") + } +} + +func TestIsSubagentAsyncLaunched(t *testing.T) { + for _, tc := range []struct { + name string + meta map[string]any + want bool + }{ + {"async_launched", map[string]any{"claudeCode": map[string]any{"toolResponse": map[string]any{"status": "async_launched"}}}, true}, + {"completed", map[string]any{"claudeCode": map[string]any{"toolResponse": map[string]any{"status": "completed"}}}, false}, + {"nil meta", nil, false}, + {"no claudeCode", map[string]any{"other": 1}, false}, + {"no toolResponse", map[string]any{"claudeCode": map[string]any{"toolName": "Agent"}}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := isSubagentAsyncLaunched(tc.meta); got != tc.want { + t.Errorf("isSubagentAsyncLaunched = %v, want %v", got, tc.want) + } + }) + } +} + func TestExtractSubagentResult_Empty(t *testing.T) { if _, ok := extractSubagentResult(nil, nil); ok { t.Error("expected no result from empty inputs") diff --git a/apps/backend/internal/agentctl/types/streams/tool_payload.go b/apps/backend/internal/agentctl/types/streams/tool_payload.go index 31c9251b12..65887f0cd1 100644 --- a/apps/backend/internal/agentctl/types/streams/tool_payload.go +++ b/apps/backend/internal/agentctl/types/streams/tool_payload.go @@ -257,6 +257,15 @@ type SubagentTaskPayload struct { // subagent) serializes, while agents that don't report it (OpenCode, // Cursor) stay omitted rather than surfacing a misleading "0 tools" chip. ToolUseCount *int `json:"tool_use_count,omitempty"` + + // Async/backgrounded subagent fields. Claude Code's Task tool with + // `run_in_background: true` returns `_meta.claudeCode.toolResponse.status: + // "async_launched"` and includes `isAsync`, `outputFile`, + // `canReadOutputFile`. The dispatch IS terminal for the Task tool — the + // subagent runs in the SDK's background and writes its result to OutputFile. + IsAsync bool `json:"is_async,omitempty"` + OutputFile string `json:"output_file,omitempty"` + CanReadOutputFile bool `json:"can_read_output_file,omitempty"` } // ShowPlanPayload contains normalized data for plan display operations. diff --git a/apps/web/components/task/chat/messages/subagent-meta.test.ts b/apps/web/components/task/chat/messages/subagent-meta.test.ts index 62ecdafe6f..4b5afa0e9c 100644 --- a/apps/web/components/task/chat/messages/subagent-meta.test.ts +++ b/apps/web/components/task/chat/messages/subagent-meta.test.ts @@ -59,4 +59,23 @@ describe("subagentMetaChips", () => { { label: "session", value: "short" }, ]); }); + + // Pins the fix for the "main agent finished, subagent backgrounded" race: + // claude-acp's Task tool with run_in_background=true returns + // is_async=true + status=async_launched. The card must surface a "background" + // chip so users see this isn't a normal completion. + it("adds a background chip when is_async is true", () => { + const payload: SubagentTaskPayload = { + subagent_type: "general-purpose", + is_async: true, + status: "async_launched", + output_file: "/tmp/tasks/abc.output", + }; + expect(subagentMetaChips(payload)).toEqual([{ label: "background", value: "background" }]); + }); + + it("adds a background chip when status is async_launched even without is_async", () => { + const payload: SubagentTaskPayload = { status: "async_launched" }; + expect(subagentMetaChips(payload)).toEqual([{ label: "background", value: "background" }]); + }); }); diff --git a/apps/web/components/task/chat/messages/subagent-meta.ts b/apps/web/components/task/chat/messages/subagent-meta.ts index 41948868ca..e925cc64f5 100644 --- a/apps/web/components/task/chat/messages/subagent-meta.ts +++ b/apps/web/components/task/chat/messages/subagent-meta.ts @@ -38,6 +38,12 @@ export function subagentMetaChips(payload: SubagentTaskPayload | undefined): Sub const chips: SubagentMetaChip[] = []; + // Claude Code's Task with run_in_background=true: the dispatch is terminal, + // but the subagent runs out-of-band and writes its result to output_file. + // Surface a "background" chip so the UI doesn't look like a normal completion. + if (payload.is_async || payload.status === "async_launched") { + chips.push({ label: "background", value: "background" }); + } if (typeof payload.duration_ms === "number" && payload.duration_ms > 0) { chips.push({ label: "duration", value: formatDuration(payload.duration_ms) }); } diff --git a/apps/web/components/task/chat/types.ts b/apps/web/components/task/chat/types.ts index 3f90d7107c..a41dab7ca9 100644 --- a/apps/web/components/task/chat/types.ts +++ b/apps/web/components/task/chat/types.ts @@ -4,13 +4,19 @@ export type SubagentTaskPayload = { description?: string; prompt?: string; subagent_type?: string; - status?: string; // result lifecycle, e.g. "complete" | "error" + status?: string; // result lifecycle, e.g. "complete" | "error" | "async_launched" agent_id?: string; // Claude model?: string; // OpenCode, e.g. "opencode/big-pickle" child_session_id?: string; // OpenCode child session duration_ms?: number; // Claude (totalDurationMs) + Cursor (durationMs) total_tokens?: number; // Claude tool_use_count?: number; // Claude + // Backgrounded subagent fields (Claude Code Task with run_in_background=true): + // the dispatch is terminal for the Task card, but the subagent itself keeps + // running and writes its result to output_file out-of-band. + is_async?: boolean; + output_file?: string; + can_read_output_file?: boolean; }; export type GenericPayload = { diff --git a/apps/web/lib/ws/handlers/empty-turn-notice.test.ts b/apps/web/lib/ws/handlers/empty-turn-notice.test.ts index efd4508d4e..de94163bc6 100644 --- a/apps/web/lib/ws/handlers/empty-turn-notice.test.ts +++ b/apps/web/lib/ws/handlers/empty-turn-notice.test.ts @@ -158,4 +158,25 @@ describe("computeEmptyTurnNotice", () => { ); expect(notice?.content).toContain("`/deploy`"); }); + + // Pins the fix for the "main agent finished, subagent still working" race: + // backend had_output snapshots before the subagent's nested events land, but + // the Task tool_call itself is real output for this turn — suppress the notice. + it("returns null when the turn contains a subagent_task tool call (in any status)", () => { + const subagentMsg: Message = { + id: "tool-1", + session_id: sessionId("sess-1"), + task_id: taskId("task-1"), + turn_id: "turn-1", + author_type: "agent", + content: "Investigate", + type: "tool_call", + metadata: { normalized: { kind: "subagent_task" }, status: "running" }, + created_at: "2026-05-30T00:00:00Z", + }; + const input = baseInput({ + messages: [userMessage("turn-1", "spawn a subagent"), subagentMsg], + }); + expect(computeEmptyTurnNotice(input)).toBeNull(); + }); }); diff --git a/apps/web/lib/ws/handlers/empty-turn-notice.ts b/apps/web/lib/ws/handlers/empty-turn-notice.ts index 3d75958bb9..8099d7e620 100644 --- a/apps/web/lib/ws/handlers/empty-turn-notice.ts +++ b/apps/web/lib/ws/handlers/empty-turn-notice.ts @@ -63,6 +63,21 @@ function findLastUserMessage(messages: Message[], turnId: string): Message | und return undefined; } +// hasSubagentTaskMessage returns true when the turn contains a Task (subagent) +// tool call message. Guards against the empty-turn race when a parent agent +// dispatches a subagent: the Claude Agent SDK may return session/prompt with +// stop_reason while the subagent still runs (anthropics/claude-code#47936), so +// the backend's had_output snapshot can be false even though the turn dispatched +// real work and the subagent's nested events will land seconds later. +function hasSubagentTaskMessage(messages: Message[], turnId: string): boolean { + for (const m of messages) { + if (m.turn_id !== turnId || m.author_type !== "agent") continue; + const meta = m.metadata as { normalized?: { kind?: string } } | undefined; + if (meta?.normalized?.kind === "subagent_task") return true; + } + return false; +} + /** * Decides whether an empty-turn notice should be shown and, if so, returns the * synthetic status Message to inject. Pure: all store reads are passed in. @@ -84,6 +99,8 @@ export function computeEmptyTurnNotice(input: EmptyTurnNoticeInput): Message | n const id = noticeId(input.turnId); if (messages.some((m) => m.id === id)) return null; + if (hasSubagentTaskMessage(messages, input.turnId)) return null; + const userMessage = findLastUserMessage(messages, input.turnId); const command = parseSlashCommand(userMessage?.content); const known = command ? isKnownCommand(command, input.availableCommands) : false;