Files
kandev/apps/web/components/task/chat/messages/subagent-meta.ts
T
João Salavisa a1143aa525 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>
2026-06-02 14:30:36 +01:00

65 lines
2.2 KiB
TypeScript

import type { SubagentTaskPayload } from "@/components/task/chat/types";
export type SubagentMetaChip = {
label: string;
value: string;
};
const MAX_ID_LENGTH = 12;
function truncateId(value: string): string {
if (value.length <= MAX_ID_LENGTH) return value;
return `${value.slice(0, MAX_ID_LENGTH)}…`;
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function formatTokens(tokens: number): string {
return `${tokens.toLocaleString("en-US")} tokens`;
}
function formatTools(count: number): string {
return `${count} tool${count === 1 ? "" : "s"}`;
}
/**
* Turns a SubagentTaskPayload into an ordered list of display chips. Different
* agents populate different subsets (Claude: tokens/duration/tool_use_count;
* OpenCode: model/child_session_id; Cursor: duration_ms only),
* so each field is only included when meaningfully present. Duration and tokens
* are skipped when zero; tool_use_count is shown even at zero since "0 tools"
* is meaningful for a completed subagent.
*/
export function subagentMetaChips(payload: SubagentTaskPayload | undefined): SubagentMetaChip[] {
if (!payload) return [];
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) });
}
if (typeof payload.total_tokens === "number" && payload.total_tokens > 0) {
chips.push({ label: "tokens", value: formatTokens(payload.total_tokens) });
}
if (typeof payload.tool_use_count === "number") {
chips.push({ label: "tools", value: formatTools(payload.tool_use_count) });
}
if (payload.model) {
chips.push({ label: "model", value: payload.model });
}
if (payload.child_session_id) {
chips.push({ label: "session", value: truncateId(payload.child_session_id) });
}
return chips;
}