From 4931c145b2ebe7c0931e0694ebb03df700f1ad9c Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Wed, 28 Oct 2026 17:13:24 -0400 Subject: [PATCH 1/4] fix(server): Claude background commands show how long they have run Claude names a background task by its own task id, while the timeline item for the Bash call that started it is keyed by the tool call id. The background work line looks the item up by task id, so it never found it and Claude commands had no elapsed time. The adapter now remembers each task's tool call from task_started, roster updates included, and pending tasks carry it as nativeItemId for the web to match on. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 24 ++++++++++++----- .../Adapters/ClaudeAdapterV2.ts | 27 +++++++++++++++++-- .../components/chat/ThreadActivityBanners.tsx | 4 ++- packages/contracts/src/orchestrationV2.ts | 2 ++ 4 files changed, 47 insertions(+), 10 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index ba4b7444b7b08..6af4cb0119043 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -7586,7 +7586,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); it.effect( - "uses task_started as an incremental roster fallback and clears on empty snapshot", + "uses task_started as an incremental roster fallback, keeps its tool call, and clears on empty snapshot", () => Effect.scoped( Effect.gen(function* () { @@ -7618,10 +7618,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); yield* Queue.offer( harness.sdkMessages, @@ -7635,6 +7634,11 @@ 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, + }, ], uuid: "00000000-0000-4000-8000-000000000203", session_id: WAKE_NATIVE_SESSION, @@ -7643,7 +7647,8 @@ 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, ), ); @@ -8336,7 +8341,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 030fb5785e6d9..dbefcb07d7a22 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2827,6 +2827,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, @@ -2841,6 +2842,7 @@ function claudePendingBackgroundTask(input: { ...(typeof input.parentTaskId === "string" && input.parentTaskId.trim().length > 0 ? { parentTaskId: input.parentTaskId } : {}), + ...(input.toolUseId === undefined ? {} : { nativeItemId: input.toolUseId }), }; } @@ -2884,6 +2886,7 @@ function claudePendingBackgroundTasksFromRoster( function parseClaudeBackgroundTaskEntry( entry: unknown, monitorTasks: ReadonlyMap, + toolUseIds: ReadonlyMap, ): OrchestrationV2PendingBackgroundTask | null { if (entry === null || typeof entry !== "object") { return null; @@ -2904,6 +2907,7 @@ function parseClaudeBackgroundTaskEntry( startedByMonitor: monitorTasks.has(taskId), description: typeof description === "string" ? description : undefined, parentTaskId: Reflect.get(entry, "parent_task_id"), + toolUseId: toolUseIds.get(taskId), }); } @@ -4712,6 +4716,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< @@ -7857,8 +7863,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( (taskId, task) => task.nativeThreadId === input.nativeThreadId && !listedTaskIds.has(taskId), ); + const toolUseIds = yield* Ref.get(toolUseIdsByTaskId); for (const entry of roster) { - const task = parseClaudeBackgroundTaskEntry(entry, monitorTasks); + const task = parseClaudeBackgroundTaskEntry(entry, monitorTasks, toolUseIds); if (task !== null) { nextTasks.push(task); } @@ -7866,7 +7873,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; } yield* upsertPendingBackgroundTask( @@ -7882,6 +7904,7 @@ 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; diff --git a/apps/web/src/components/chat/ThreadActivityBanners.tsx b/apps/web/src/components/chat/ThreadActivityBanners.tsx index 168b674d26c92..ee138171406c6 100644 --- a/apps/web/src/components/chat/ThreadActivityBanners.tsx +++ b/apps/web/src/components/chat/ThreadActivityBanners.tsx @@ -119,9 +119,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 a1dd871205bb9..c7cff3cce2547 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -979,6 +979,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. From ba89c3c2dd9819a8101cabeb5c9888559abd9eb0 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Wed, 28 Oct 2026 17:24:53 -0400 Subject: [PATCH 2/4] fix(server): a long-running Claude command keeps its tool call link The task-to-tool-call map is capped, so a command that outlived 64 newer tasks lost its link and its elapsed time on the next roster update. Roster updates now keep the link a pending task already has. The roster test also covers a foreground command that moves to the background. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 31 +++++++++++++++++-- .../Adapters/ClaudeAdapterV2.ts | 12 ++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 6af4cb0119043..5911f72146f0c 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -7622,6 +7622,21 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(started?.taskId, WAKE_TASK_ID); assert.equal(started?.nativeItemId, WAKE_TOOL_USE_ID); + // 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({ @@ -7639,6 +7654,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { 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, @@ -7648,7 +7668,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { providerThreadRosterEvents(harness.events).some( (event) => event.providerThread.pendingBackgroundTasks?.[0]?.parentTaskId === "agent-1" && - event.providerThread.pendingBackgroundTasks[1]?.nativeItemId === WAKE_TOOL_USE_ID, + event.providerThread.pendingBackgroundTasks[1]?.nativeItemId === WAKE_TOOL_USE_ID && + event.providerThread.pendingBackgroundTasks[2]?.nativeItemId === + "toolu_moved_shell", ), ); @@ -7921,7 +7943,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( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index dbefcb07d7a22..3d124cfc974e6 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -7863,7 +7863,17 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( (taskId, task) => task.nativeThreadId === input.nativeThreadId && !listedTaskIds.has(taskId), ); - const toolUseIds = yield* Ref.get(toolUseIdsByTaskId); + 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, toolUseIds); if (task !== null) { From 34adb2f3598dd2e58d6538ab2033d8fe84ff0359 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Wed, 28 Oct 2026 17:30:28 -0400 Subject: [PATCH 3/4] test(server): Claude monitor replay expects the tool call link The recorded monitor session now carries its Monitor call as nativeItemId once task_started links it. Co-Authored-By: Claude Opus 5.5 --- .../testkit/fixtures/claude_background_monitor_wake/output.ts | 1 + 1 file changed, 1 insertion(+) 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 ?? [], []); From da000cfb0221b2e48ebb831e7f64486457034ae5 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Wed, 28 Oct 2026 17:39:26 -0400 Subject: [PATCH 4/4] fix(server): drop a Claude task's tool call link when the task ends Co-Authored-By: Claude Opus 5.5 --- .../server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 3d124cfc974e6..bed805ce20796 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -7920,6 +7920,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( 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,