diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 95432c40e6887..2d9e417f8cd3d 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -11039,7 +11039,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); it.effect( - "uses task_started as an incremental roster fallback and clears internal work on empty snapshot", + "uses task_started as an incremental roster fallback, keeps its tool call, and clears internal work on empty snapshot", () => Effect.scoped( Effect.gen(function* () { @@ -11097,10 +11097,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { (event) => (event.providerThread.pendingBackgroundTasks?.length ?? 0) > 0, ); assert.isAtLeast(afterStart.length, 1); - assert.equal( - (afterStart.at(-1)?.providerThread.pendingBackgroundTasks ?? [])[0]?.taskId, - WAKE_TASK_ID, - ); + const started = (afterStart.at(-1)?.providerThread.pendingBackgroundTasks ?? [])[0]; + assert.equal(started?.taskId, WAKE_TASK_ID); + assert.equal(started?.nativeItemId, WAKE_TOOL_USE_ID); const command = harness.events.find( (event) => @@ -11133,6 +11132,21 @@ describe("ClaudeAdapterV2 background wake turns", () => { details, ); + // A foreground command that later moves to the background keeps its tool call too. + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: "moved-shell", + tool_use_id: "toolu_moved_shell", + description: "Foreground build", + is_backgrounded: false, + task_type: "local_bash", + uuid: "00000000-0000-4000-8000-000000000204", + session_id: WAKE_NATIVE_SESSION, + }), + ); yield* Queue.offer( harness.sdkMessages, claudeSdkFrame({ @@ -11145,6 +11159,16 @@ describe("ClaudeAdapterV2 background wake turns", () => { description: "sleep 150", parent_task_id: "agent-1", }, + { + task_id: WAKE_TASK_ID, + task_type: "local_bash", + description: WAKE_TASK_DESCRIPTION, + }, + { + task_id: "moved-shell", + task_type: "local_bash", + description: "Foreground build", + }, ], uuid: "00000000-0000-4000-8000-000000000203", session_id: WAKE_NATIVE_SESSION, @@ -11153,7 +11177,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* harness.until(() => providerThreadRosterEvents(harness.events).some( (event) => - event.providerThread.pendingBackgroundTasks?.[0]?.parentTaskId === "agent-1", + event.providerThread.pendingBackgroundTasks?.[0]?.parentTaskId === "agent-1" && + event.providerThread.pendingBackgroundTasks[1]?.nativeItemId === WAKE_TOOL_USE_ID && + event.providerThread.pendingBackgroundTasks[2]?.nativeItemId === + "toolu_moved_shell", ), ); @@ -11435,7 +11462,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { const roster = providerThreadRosterEvents(harness.events).at(-1)?.providerThread .pendingBackgroundTasks; assert.deepEqual(roster, [ - { taskId: longRunning.taskId, kind: "monitor", description: "Monitor 0" }, + { + taskId: longRunning.taskId, + kind: "monitor", + description: "Monitor 0", + nativeItemId: "toolu_monitor_0", + }, ]); }).pipe( Effect.provide( @@ -12795,7 +12827,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { (event) => event.providerThread.id === providerThreadA.id, )?.providerThread.pendingBackgroundTasks; assert.deepEqual(rosterAAfterSettle ?? [], [ - { taskId: taskA, description: "work on A", kind: "command" }, + { + taskId: taskA, + description: "work on A", + kind: "command", + nativeItemId: "toolu-roster-a", + }, ]); assert.isTrue(yield* hasPendingBackgroundWork); assert.isTrue(yield* hasPendingBackgroundWorkForThread(providerThreadA)); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 3e10e09906796..178bf263d5540 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3421,6 +3421,7 @@ function claudePendingBackgroundTask(input: { readonly description: string | undefined; // Undeclared SDK field: the subagent whose own background work this is. readonly parentTaskId: unknown; + readonly toolUseId: string | undefined; }): OrchestrationV2PendingBackgroundTask { return { taskId: input.taskId, @@ -3435,6 +3436,7 @@ function claudePendingBackgroundTask(input: { ...(typeof input.parentTaskId === "string" && input.parentTaskId.trim().length > 0 ? { parentTaskId: input.parentTaskId } : {}), + ...(input.toolUseId === undefined ? {} : { nativeItemId: input.toolUseId }), }; } @@ -3489,6 +3491,7 @@ function claudePendingBackgroundTasksFromRoster( function parseClaudeBackgroundTaskEntry( entry: unknown, monitorTasks: ReadonlyMap, + toolUseIds: ReadonlyMap, ): OrchestrationV2PendingBackgroundTask | null { if (entry === null || typeof entry !== "object") { return null; @@ -3509,6 +3512,7 @@ function parseClaudeBackgroundTaskEntry( startedByMonitor: monitorTasks.has(taskId), description: typeof description === "string" ? description : undefined, parentTaskId: Reflect.get(entry, "parent_task_id"), + toolUseId: toolUseIds.get(taskId), }); } @@ -5677,6 +5681,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const lastKnownOpaqueTasks = yield* Ref.make( new Map(), ); + // Roster entries carry only Claude's task id; the timeline item is keyed by the tool call. + const toolUseIdsByTaskId = yield* Ref.make(new Map()); const claudeMonitors = yield* Ref.make<{ readonly calls: ReadonlySet; readonly tasks: ReadonlyMap< @@ -9602,8 +9608,19 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( (taskId, task) => task.nativeThreadId === input.nativeThreadId && !listedTaskIds.has(taskId), ); + const toolUseIds = new Map(yield* Ref.get(toolUseIdsByTaskId)); + // A live task keeps its tool call after the bounded map evicts it. + const current = rosterForNativeThread( + yield* Ref.get(pendingBackgroundTasksByNativeThread), + input.nativeThreadId, + ); + for (const [taskId, task] of current) { + if (task.nativeItemId !== undefined && !toolUseIds.has(taskId)) { + toolUseIds.set(taskId, task.nativeItemId); + } + } for (const entry of roster) { - const task = parseClaudeBackgroundTaskEntry(entry, monitorTasks); + const task = parseClaudeBackgroundTaskEntry(entry, monitorTasks, toolUseIds); if (task !== null) { nextTasks.push(task); } @@ -9611,7 +9628,22 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* replacePendingBackgroundTasks(input.nativeThreadId, nextTasks); rosterChanged = true; } else if (message.type === "system" && message.subtype === "task_started") { - if (!isClaudeNonSubagentTask(message) || message.is_backgrounded === false) { + if (!isClaudeNonSubagentTask(message)) { + return false; + } + const toolUseId = message.tool_use_id?.trim() || undefined; + if (toolUseId !== undefined) { + // A foreground command can move to the background later, so remember every one. + yield* Ref.update(toolUseIdsByTaskId, (current) => { + const updated = new Map(current).set(message.task_id, toolUseId); + for (const oldest of updated.keys()) { + if (updated.size <= 64) break; + updated.delete(oldest); + } + return updated; + }); + } + if (message.is_backgrounded === false) { return false; } const launch = @@ -9644,11 +9676,18 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( description: typeof message.description === "string" ? message.description : undefined, parentTaskId: Reflect.get(message, "parent_task_id"), + toolUseId, }), }); rosterChanged = true; } else if (message.type === "system" && message.subtype === "task_notification") { yield* endClaudeMonitorTasks((taskId) => taskId === message.task_id); + yield* Ref.update(toolUseIdsByTaskId, (current) => { + if (!current.has(message.task_id)) return current; + const updated = new Map(current); + updated.delete(message.task_id); + return updated; + }); const removed = yield* clearPendingBackgroundTask( input.nativeThreadId, message.task_id, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_monitor_wake/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_monitor_wake/output.ts index c1c3d9a171b0f..ee17f23075a35 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_monitor_wake/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_monitor_wake/output.ts @@ -40,6 +40,7 @@ export function assertClaudeBackgroundMonitorWakeOutput( taskId: MONITOR_TASK_ID, description: "Background monitor test", kind: "monitor", + nativeItemId: "toolu_019b4EUbhEcYP4TS7vZn27N1", }); } assert.deepEqual(projection.providerThreads[0]?.pendingBackgroundTasks ?? [], []); diff --git a/apps/web/src/components/chat/ThreadActivityBanners.tsx b/apps/web/src/components/chat/ThreadActivityBanners.tsx index b12a6d1ae63b4..5e3444f7a13aa 100644 --- a/apps/web/src/components/chat/ThreadActivityBanners.tsx +++ b/apps/web/src/components/chat/ThreadActivityBanners.tsx @@ -182,9 +182,11 @@ export function backgroundWorkNowItem(input: { const presentation = presentPendingBackgroundWork(input.tasks); if (presentation === null) return null; const rows = presentation.items.map((task) => { + const itemKey = + input.tasks.find((pending) => pending.taskId === task.taskId)?.nativeItemId ?? task.taskId; // Same key derivePendingBackgroundWork gives turn-item tasks; subagent items carry progress. const item = input.turnItems.findLast( - (candidate) => (candidate.nativeItemRef?.nativeId || candidate.id) === task.taskId, + (candidate) => (candidate.nativeItemRef?.nativeId || candidate.id) === itemKey, ); const subagent = item?.type === "subagent" ? item : undefined; return { diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index fa94d70d73263..18ef7e9c941c0 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1038,6 +1038,8 @@ const PendingBackgroundTaskFields = { description: Schema.optional(TrimmedNonEmptyString), /** The subagent task that started this work and waits on it. */ parentTaskId: Schema.optional(TrimmedNonEmptyString), + /** Native id of the turn item that started this work, when it is not `taskId`. */ + nativeItemId: Schema.optional(TrimmedNonEmptyString), /** * The agent declared it resumes when this work ends, so the thread holds like it does for a * subagent. Only the agent knows whether a command is a wait or a dev server it left running.