diff --git a/plugins/provider-claude-code/src/delta-test-harness.ts b/plugins/provider-claude-code/src/delta-test-harness.ts index 6dd35a0c1d..da18b2c216 100644 --- a/plugins/provider-claude-code/src/delta-test-harness.ts +++ b/plugins/provider-claude-code/src/delta-test-harness.ts @@ -49,6 +49,74 @@ export function loadSessionFixture(name: string): Record[] { }); } +/** + * The assistant `tool_use` block that spawns a background task. The CLI + * streams it before the task's `task_started`, and the translator only + * materializes a task whose spawning call it saw open. + */ +export function spawningToolUseMessage(args: { + toolUseId: string; + toolName: string; + input?: Record; + parentToolUseId?: string; +}): Record { + return { + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: args.toolUseId, + name: args.toolName, + input: args.input ?? {}, + }, + ], + }, + parent_tool_use_id: args.parentToolUseId ?? null, + session_id: "sess-1", + }; +} + +/** The spawning `tool_use` for a `task_started` fixture or literal. */ +export function spawningToolUseFor( + taskStarted: Record, +): Record { + const toolUseId = taskStarted.tool_use_id; + if (typeof toolUseId !== "string") { + throw new Error("task_started fixture has no tool_use_id"); + } + const description = + typeof taskStarted.description === "string" ? taskStarted.description : ""; + switch (taskStarted.task_type) { + case "local_workflow": + return spawningToolUseMessage({ + toolUseId, + toolName: "Workflow", + input: { script: taskStarted.prompt ?? "" }, + }); + case "local_bash": + return spawningToolUseMessage({ + toolUseId, + toolName: "Bash", + input: { command: description, run_in_background: true }, + }); + default: + return spawningToolUseMessage({ + toolUseId, + toolName: "Agent", + input: { + description, + prompt: taskStarted.prompt ?? description, + run_in_background: true, + ...(typeof taskStarted.subagent_type === "string" + ? { subagent_type: taskStarted.subagent_type } + : {}), + }, + }); + } +} + const CLAUDE_TEST_ENTROPY = "cl-test"; export const TURN_1 = "cl-test-t1"; export const TURN_2 = "cl-test-t2"; diff --git a/plugins/provider-claude-code/src/delta-translation.test.ts b/plugins/provider-claude-code/src/delta-translation.test.ts index 96eb285c06..c66588c42a 100644 --- a/plugins/provider-claude-code/src/delta-translation.test.ts +++ b/plugins/provider-claude-code/src/delta-translation.test.ts @@ -7,6 +7,7 @@ import { TURN_2, createClaudeDeltaHarness, loadFixture, + spawningToolUseFor, } from "./delta-test-harness.js"; /** @@ -435,6 +436,10 @@ describe("claude turn and checkpoint lifecycle", () => { const harness = createClaudeDeltaHarness(); const context = { threadId: "bb-thread-rate-limited" }; harness.acceptInput("creq_23456789af", context.threadId); + harness.translate( + spawningToolUseFor(loadFixture("task-started-subagent.json")), + context, + ); harness.translate(loadFixture("task-started-subagent.json"), context); harness.translate( { @@ -937,6 +942,10 @@ describe("claude synthetic no-response handling", () => { it("keeps an open turn for synthetic no-response messages while an agent is running", () => { const harness = createClaudeDeltaHarness(); const context = { threadId: "bb-thread-1" }; + harness.translate( + spawningToolUseFor(loadFixture("task-started-subagent.json")), + context, + ); harness.translate(loadFixture("task-started-subagent.json"), context); const events = harness.translate( diff --git a/plugins/provider-claude-code/src/delta-translation.ts b/plugins/provider-claude-code/src/delta-translation.ts index 76dcc4b618..1ef8fb4970 100644 --- a/plugins/provider-claude-code/src/delta-translation.ts +++ b/plugins/provider-claude-code/src/delta-translation.ts @@ -875,6 +875,7 @@ export function createClaudeDeltaTranslator( event, tasks: state.tasksById, turnStartSuppressed: isTurnStartSuppressed(state), + hasForwardedToolUse: (toolUseId) => state.startedTools.has(toolUseId), }); if (taskDeltas !== null) { return withMirror(state, taskDeltas); diff --git a/plugins/provider-claude-code/src/presentation.test.ts b/plugins/provider-claude-code/src/presentation.test.ts index afa312b73a..d1cac3ca0e 100644 --- a/plugins/provider-claude-code/src/presentation.test.ts +++ b/plugins/provider-claude-code/src/presentation.test.ts @@ -7,7 +7,10 @@ */ import type { ThreadEvent } from "@bb/domain"; import { describe, expect, it } from "vitest"; -import { createClaudeDeltaHarness } from "./delta-test-harness.js"; +import { + createClaudeDeltaHarness, + spawningToolUseFor, +} from "./delta-test-harness.js"; import { createClaudeDeltaTranslator } from "./delta-translation.js"; function toolUse( @@ -682,6 +685,7 @@ describe("claude background-task presentation", () => { ["local_bash", {}], ["local_agent", { subagent_type: "Explore" }], ] as const) { + harness.translate(spawningToolUseFor(taskStarted(taskType, extra))); const opened = harness.translate(taskStarted(taskType, extra)); const started = startedItems(opened)[0]; presentations.set( diff --git a/plugins/provider-claude-code/src/task-translation.test.ts b/plugins/provider-claude-code/src/task-translation.test.ts index d166f3eaf2..50f62fd006 100644 --- a/plugins/provider-claude-code/src/task-translation.test.ts +++ b/plugins/provider-claude-code/src/task-translation.test.ts @@ -26,6 +26,8 @@ import { createClaudeDeltaHarness, loadFixture, loadSessionFixture, + spawningToolUseFor, + spawningToolUseMessage, } from "./delta-test-harness.js"; /** The assembler's central progress-throttle default. */ @@ -158,6 +160,10 @@ describe("claude-code background task translation", () => { const harness = createClaudeDeltaHarness(); const context = { threadId: "bb-thread-1" }; + harness.translate( + spawningToolUseFor(loadFixture("task-started-workflow.json")), + context, + ); harness.translate(loadFixture("task-started-workflow.json"), context); advanceClock(PROGRESS_THROTTLE_MS + 1); @@ -198,6 +204,10 @@ describe("claude-code background task translation", () => { const harness = createClaudeDeltaHarness(); const context = { threadId: "bb-thread-1" }; + harness.translate( + spawningToolUseFor(loadFixture("task-started-workflow.json")), + context, + ); harness.translate(loadFixture("task-started-workflow.json"), context); // Within the throttle window: folded but not emitted. @@ -242,6 +252,10 @@ describe("claude-code background task translation", () => { const harness = createClaudeDeltaHarness(); const context = { threadId: "bb-thread-1" }; + harness.translate( + spawningToolUseFor(loadFixture("task-started-workflow.json")), + context, + ); harness.translate(loadFixture("task-started-workflow.json"), context); const killed = harness.translate( { @@ -374,6 +388,10 @@ describe("claude-code background task translation", () => { it("preserves skip_transcript on the item", () => { const harness = createClaudeDeltaHarness(); + harness.translate( + spawningToolUseFor(loadFixture("task-started-workflow.json")), + { threadId: "bb-thread-1" }, + ); const started = harness.translate( { ...loadFixture("task-started-workflow.json"), @@ -389,7 +407,31 @@ describe("claude-code background task translation", () => { const harness = createClaudeDeltaHarness(); const context = { threadId: "bb-thread-1" }; + harness.translate( + spawningToolUseFor(loadFixture("task-started-workflow.json")), + context, + ); harness.translate(loadFixture("task-started-workflow.json"), context); + // The Workflow call returns as soon as the task is registered; only the + // task itself stays open. + harness.translate( + { + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_012BkJCmbBgNqL6SXPKNfPvE", + content: "Workflow started in the background", + is_error: false, + }, + ], + }, + session_id: "sess-1", + }, + context, + ); const events = harness.settleSession("bb-thread-1"); @@ -421,6 +463,10 @@ describe("claude-code background task translation", () => { const harness = createClaudeDeltaHarness(); const context = { threadId: "bb-thread-1" }; + harness.translate( + spawningToolUseFor(loadFixture("task-started-workflow.json")), + context, + ); harness.translate(loadFixture("task-started-workflow.json"), context); // task_updated may report "completed" minutes before task_notification // arrives; a settle inside that window must not flip the workflow to @@ -454,6 +500,10 @@ describe("claude-code background task translation", () => { const harness = createClaudeDeltaHarness(); const context = { threadId: "bb-thread-1" }; + harness.translate( + spawningToolUseFor(loadFixture("task-started-workflow.json")), + context, + ); harness.translate(loadFixture("task-started-workflow.json"), context); const events = harness.settleSession("bb-thread-1"); @@ -471,6 +521,10 @@ describe("claude-code background task translation", () => { const harness = createClaudeDeltaHarness(); const context = { threadId: "bb-thread-1" }; + harness.translate( + spawningToolUseFor(loadFixture("task-started-workflow.json")), + context, + ); harness.translate(loadFixture("task-started-workflow.json"), context); harness.translate(loadFixture("task-notification-workflow.json"), context); @@ -519,6 +573,17 @@ describe("claude-code background task translation", () => { const harness = createClaudeDeltaHarness(); const context = { threadId: "bb-thread-1" }; + harness.translate( + spawningToolUseMessage({ + toolUseId: "toolu_bash_1", + toolName: "Bash", + input: { + command: "for i in 1 2 3 4 5 6; do echo $i; sleep 1; done", + run_in_background: true, + }, + }), + context, + ); const started = harness.translate( { type: "system", @@ -581,8 +646,127 @@ describe("claude-code background task translation", () => { }); }); + it("ignores tasks spawned by an unforwarded child (workflow agent)", () => { + const harness = createClaudeDeltaHarness(); + const context = { threadId: "bb-thread-1" }; + + // The parent starts a workflow and its turn completes; the workflow keeps + // running without holding the turn open. + harness.translate( + spawningToolUseFor(loadFixture("task-started-workflow.json")), + context, + ); + harness.translate(loadFixture("task-started-workflow.json"), context); + const settled = harness.translate( + { type: "result", subtype: "end_turn", session_id: "sess-1" }, + context, + ); + expect(settled).toContainEqual( + expect.objectContaining({ type: "turn/completed" }), + ); + + // A workflow agent backgrounds a shell command. The CLI reports the task + // on the parent session, but the agent's own tool_use never streamed. + const childCommand = harness.translate( + { + type: "system", + subtype: "task_started", + task_id: "b0blaygur", + tool_use_id: "toolu_workflow_child_bash", + description: "Gate runner progress", + task_type: "local_bash", + is_backgrounded: true, + uuid: "u-child-1", + session_id: "s-1", + }, + context, + ); + // Nothing materializes: no background-command row the parent would show + // as its own, and no provider-only turn opens. + expect(childCommand).toEqual([]); + expect(harness.itemId("toolu_workflow_child_bash", "bb-thread-1")).toBe(""); + + // A sub-agent the workflow agent spawned must not hold a turn open either. + const childAgent = harness.translate( + { + type: "system", + subtype: "task_started", + task_id: "a-child-agent", + tool_use_id: "toolu_workflow_child_agent", + description: "Review one file", + task_type: "local_agent", + subagent_type: "general-purpose", + uuid: "u-child-2", + session_id: "s-1", + }, + context, + ); + expect(childAgent).toEqual([]); + + // Later lifecycle events for the untracked task fall through. + const notified = harness.translate( + { + type: "system", + subtype: "task_notification", + task_id: "b0blaygur", + tool_use_id: "toolu_workflow_child_bash", + status: "completed", + output_file: "/tmp/tasks/b0blaygur.output", + summary: + 'Background command "Gate runner progress" completed (exit code 0)', + uuid: "u-child-3", + session_id: "s-1", + }, + context, + ); + expect(notified).toEqual([]); + + // The parent's own next backgrounded command still materializes. + harness.translate( + spawningToolUseMessage({ + toolUseId: "toolu_parent_bash", + toolName: "Bash", + input: { command: "sleep 30", run_in_background: true }, + }), + context, + ); + const parentCommand = harness.translate( + { + type: "system", + subtype: "task_started", + task_id: "parent-bash", + tool_use_id: "toolu_parent_bash", + description: "Sleep", + task_type: "local_bash", + uuid: "u-parent-1", + session_id: "s-1", + }, + context, + ); + expect(collectTaskEvents(parentCommand)).toHaveLength(1); + expect( + backgroundTaskItem(collectTaskEvents(parentCommand)[0]!), + ).toMatchObject({ + taskType: "local_bash", + parentToolCallId: harness.itemId("toolu_parent_bash", "bb-thread-1"), + }); + }); + it("materializes background subagents with legacy task_type local_subagent", () => { const harness = createClaudeDeltaHarness(); + harness.translate( + spawningToolUseMessage({ + toolUseId: "toolu_sub_1", + toolName: "Agent", + input: { + description: "background subagent", + prompt: "background subagent", + subagent_type: "Explore", + run_in_background: true, + }, + }), + { threadId: "bb-thread-1" }, + ); const events = harness.translate( { type: "system", @@ -627,6 +811,10 @@ describe("claude-code background task translation", () => { }, context, ); + harness.translate( + spawningToolUseFor(loadFixture("task-started-subagent.json")), + context, + ); harness.translate(loadFixture("task-started-subagent.json"), context); const intermediateResult = harness.translate( @@ -713,6 +901,7 @@ describe("claude-code background task translation", () => { }, context, ); + harness.translate(spawningToolUseFor(task), context); harness.translate(task, context); const events = harness.translate( @@ -756,6 +945,10 @@ describe("claude-code background task translation", () => { }, context, ); + harness.translate( + spawningToolUseFor({ tool_use_id: `tool-${task.task_id}`, ...task }), + context, + ); harness.translate( { type: "system", @@ -800,6 +993,10 @@ describe("claude-code background task translation", () => { }, context, ); + harness.translate( + spawningToolUseFor(loadFixture("task-started-workflow.json")), + context, + ); const started = harness.translate( loadFixture("task-started-workflow.json"), context, @@ -850,6 +1047,10 @@ describe("claude-code background task translation", () => { }, context, ); + harness.translate( + spawningToolUseFor(loadFixture("task-started-workflow.json")), + context, + ); harness.translate(loadFixture("task-started-workflow.json"), context); harness.translate( { type: "result", subtype: "end_turn", session_id: "sess-1" }, @@ -902,6 +1103,10 @@ describe("claude-code background task translation", () => { }, context, ); + harness.translate( + spawningToolUseFor(loadFixture("task-started-subagent.json")), + context, + ); harness.translate(loadFixture("task-started-subagent.json"), context); const events = harness.translate( diff --git a/plugins/provider-claude-code/src/task-translation.ts b/plugins/provider-claude-code/src/task-translation.ts index 9a9d23f3a8..779efee8ca 100644 --- a/plugins/provider-claude-code/src/task-translation.ts +++ b/plugins/provider-claude-code/src/task-translation.ts @@ -77,6 +77,15 @@ interface TranslateClaudeTaskMessageArgs { * translate — they ride the thread-attached item, not a turn. */ turnStartSuppressed: boolean; + /** + * Whether the bridge saw the `tool_use` block with this id open in the + * forwarded stream. The CLI emits `task_started` for every task in the + * session, including tasks a workflow agent started, but it forwards + * `tool_use` blocks only from the main loop and from `Agent` sub-agents + * (under `parent_tool_use_id`). A task whose spawning call never streamed + * belongs to an unforwarded child, and bb does not materialize it. + */ + hasForwardedToolUse: (toolUseId: string) => boolean; } /** @@ -323,8 +332,8 @@ function isMaterializedTaskType(taskType: string): boolean { * Translates the SDK task event family (task_started / task_progress / * task_updated / task_notification) into deltas. Returns null when the * message is not a task message; returns [] for task messages that are - * intentionally not materialized (monitor/unknown task types and events for - * unknown/settled tasks). + * intentionally not materialized (monitor/unknown task types, tasks an + * unforwarded child spawned, and events for unknown/settled tasks). * * A `task_started` for a materialized type opens the item in the spawning * turn: the returned deltas begin with `turn.open` exactly where the old @@ -345,6 +354,20 @@ export function translateClaudeTaskMessage( // Duplicate started for an open task — nothing new to materialize. return []; } + if ( + existing === undefined && + message.tool_use_id !== undefined && + !args.hasForwardedToolUse(message.tool_use_id) + ) { + // A workflow agent's backgrounded command or sub-agent. Its spawning + // call never streamed, so no row in this thread can own the task. If + // it materialized, the prompt-box card would list it as the parent's + // own command and the `turn.open` would open a provider-only turn that + // stays open until the CLI's next result. The workflow card already + // shows the agent's last tool. Later progress/notification events for + // the untracked task id fall through to the unknown-task branches. + return []; + } const generation = existing ? existing.generation + 1 : 1; if (args.turnStartSuppressed) { return [];