fix(acp): handle claude-acp async_launched subagents end-to-end (#1244)
* 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 <agent@kandev.dev>
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user