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:
João Salavisa
2026-06-02 14:30:36 +01:00
committed by GitHub
parent b0b968cb22
commit a1143aa525
11 changed files with 583 additions and 4 deletions
@@ -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) });
}
+7 -1
View File
@@ -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;