Skip to content
Merged
53 changes: 45 additions & 8 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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* () {
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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({
Expand All @@ -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,
Expand All @@ -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",
),
);

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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));
Expand Down
43 changes: 41 additions & 2 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 }),
};
}

Expand Down Expand Up @@ -3489,6 +3491,7 @@ function claudePendingBackgroundTasksFromRoster(
function parseClaudeBackgroundTaskEntry(
entry: unknown,
monitorTasks: ReadonlyMap<string, unknown>,
toolUseIds: ReadonlyMap<string, string>,
): OrchestrationV2PendingBackgroundTask | null {
if (entry === null || typeof entry !== "object") {
return null;
Expand All @@ -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),
});
}

Expand Down Expand Up @@ -5677,6 +5681,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
const lastKnownOpaqueTasks = yield* Ref.make(
new Map<string, OrchestrationV2PendingBackgroundTask>(),
);
// Roster entries carry only Claude's task id; the timeline item is keyed by the tool call.
const toolUseIdsByTaskId = yield* Ref.make(new Map<string, string>());
const claudeMonitors = yield* Ref.make<{
readonly calls: ReadonlySet<string>;
readonly tasks: ReadonlyMap<
Expand Down Expand Up @@ -9602,16 +9608,42 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
(taskId, task) =>
task.nativeThreadId === input.nativeThreadId && !listedTaskIds.has(taskId),
);
const toolUseIds = new Map(yield* Ref.get(toolUseIdsByTaskId));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
// 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);
}
}
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);
Comment thread
maria-rcks marked this conversation as resolved.
Resolved
}
return updated;
});
}
if (message.is_backgrounded === false) {
return false;
}
const launch =
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? [], []);
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/components/chat/ThreadActivityBanners.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/orchestrationV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading