diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 1add2822a01e9..a3d9e37e20191 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -13847,6 +13847,208 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + // Claude reports a background subagent `completed` when its turn ends while + // its own background shell still runs. When that shell finishes, Claude + // resumes the subagent with a new task_started for the same task and launch + // tool_use_id while the root is idle. Frame order mirrors a live capture. + it.effect("reopens a completed background subagent that resumes while the root is idle", () => + Effect.scoped( + Effect.gen(function* () { + const TASK_ID = "ae3f32459af762a5f"; + const LAUNCH = "toolu_01LcidmwEK1Y6GC6LpX3kvxX"; + const OWN_SHELL = "toolu_01QAahgijM2KRBBSkWxnui4g"; + const RESUMED_BASH = "toolu_01CkA1juicPNCtQrqKL8h6Rz"; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + let sequence = 0; + const system = (frame: object) => + claudeSdkFrame({ + type: "system", + ...frame, + uuid: `00000000-0000-4000-8000-0000000162${String(++sequence).padStart(2, "0")}`, + session_id: WAKE_NATIVE_SESSION, + }); + const agentEntry = (runId: string) => ({ + task_id: TASK_ID, + run_id: runId, + task_type: "local_agent", + subagent_type: "general-purpose", + description: "Audit recent commits", + }); + const shellEntry = (taskId: string) => ({ + task_id: taskId, + run_id: `run-${taskId}`, + task_type: "local_bash", + description: "Sleep", + parent_task_id: TASK_ID, + }); + const roster = (tasks: ReadonlyArray) => + system({ subtype: "background_tasks_changed", tasks }); + const shellStarted = (taskId: string, toolUseId: string) => + system({ + subtype: "task_started", + task_id: taskId, + tool_use_id: toolUseId, + task_type: "local_bash", + description: "Sleep", + }); + const startTurn = (ordinal: number, continuation: boolean) => + harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make(`attempt-claude-self-resume-${ordinal}`), + text: continuation ? "Background task completed." : "Launch a background agent.", + attachments: [], + providerTurnOrdinal: ordinal, + ...(continuation + ? { + messageCreatedBy: "agent" as const, + messageCreationSource: "provider" as const, + } + : {}), + }), + ); + const subagentUpdates = () => + harness.events.flatMap((event) => + event.type === "subagent.updated" ? [event.subagent] : [], + ); + + yield* startTurn(1, false); + for (const frame of [ + roster([agentEntry("run-1")]), + makeSubagentTaskStartedFrame({ + taskId: TASK_ID, + toolUseId: LAUNCH, + uuid: "00000000-0000-4000-8000-000000016101", + }), + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000016102", result: "LAUNCHED" }), + ]) { + yield* harness.offerAndWait(frame); + } + yield* Queue.take(harness.terminalReceipts); + + // The subagent starts its own background shell, ends its turn, and + // Claude reports it completed; the parent wakes, answers, goes idle. + for (const frame of [ + ...makeSubagentAssistantFrames({ + parentToolUseId: LAUNCH, + uuid: "00000000-0000-4000-8000-000000016103", + bashToolUseId: OWN_SHELL, + }), + roster([agentEntry("run-1"), shellEntry("b455x9pq5")]), + shellStarted("b455x9pq5", OWN_SHELL), + makeSubagentToolResultFrame({ + parentToolUseId: LAUNCH, + uuid: "00000000-0000-4000-8000-000000016104", + toolUseId: OWN_SHELL, + }), + makeSubagentNotificationFrame({ + taskId: TASK_ID, + toolUseId: LAUNCH, + summary: "WAITING", + uuid: "00000000-0000-4000-8000-000000016105", + }), + roster([shellEntry("b455x9pq5")]), + wakeTurnInit, + ]) { + yield* harness.offerAndWait(frame); + } + yield* Queue.take(harness.continuationReceipts); + yield* startTurn(2, true); + yield* harness.offerAndWait( + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000016106", result: "Noted." }), + ); + yield* Queue.take(harness.terminalReceipts); + + // Its shell finishes and Claude resumes it under a new native run. + for (const frame of [ + system({ + subtype: "task_notification", + task_id: "b455x9pq5", + tool_use_id: OWN_SHELL, + status: "completed", + summary: "Background command completed (exit code 0)", + output_file: "/tmp/b455x9pq5.output", + }), + roster([]), + roster([agentEntry("run-2")]), + makeSubagentTaskStartedFrame({ + taskId: TASK_ID, + toolUseId: LAUNCH, + uuid: "00000000-0000-4000-8000-000000016107", + }), + ]) { + yield* harness.offerAndWait(frame); + } + // Events reach the app only through a run, and the launching run ended + // with the interim completion. + assert.equal(harness.continuationRequests.length, 2); + + yield* Queue.take(harness.continuationReceipts); + yield* startTurn(3, true); + // Claude does not wake the root for this, so the root must not stay busy. + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); + yield* startTurn(4, false); + yield* harness.offerAndWait( + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000016110", result: "PONG" }), + ); + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); + for (const frame of [ + ...makeSubagentAssistantFrames({ + parentToolUseId: LAUNCH, + uuid: "00000000-0000-4000-8000-000000016108", + bashToolUseId: RESUMED_BASH, + }), + shellStarted("beyhxkdpc", RESUMED_BASH), + roster([agentEntry("run-2"), shellEntry("beyhxkdpc")]), + ]) { + yield* harness.offerAndWait(frame); + } + // Events are ordered: once this roster is recorded, so is every earlier event. + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "provider_thread.updated" && + event.providerThread.pendingBackgroundTasks?.some( + (task) => task.taskId === "beyhxkdpc", + ) === true, + ), + ); + const resumed = subagentUpdates().at(-1); + assert.equal(resumed?.status, "running"); + assert.isNull(resumed?.completedAt); + assert.equal(resumed?.runId, "run-attempt-claude-self-resume-3"); + const routing = subagentRouting(harness.events, [RESUMED_BASH]); + assert.deepEqual( + [...(routing.toolThreadIds.get(RESUMED_BASH) ?? [])], + [routing.childThreadId], + ); + + yield* harness.offerAndWait( + makeSubagentNotificationFrame({ + taskId: TASK_ID, + toolUseId: LAUNCH, + summary: "DONE", + uuid: "00000000-0000-4000-8000-000000016109", + }), + ); + yield* harness.until(() => subagentUpdates().at(-1)?.result === "DONE"); + assert.equal(subagentUpdates().at(-1)?.status, "completed"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ), + ); + it.effect("keeps a nested subagent's launch in order while the root turn is idle", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 7b63b7efc0b65..835daca557a0d 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -8518,31 +8518,41 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( message.type === "system" && message.subtype === "task_started" && (yield* Ref.get(sessionSubagentsByTaskId)).has(message.task_id); + // A completed subagent that Claude resumes on its own (under its launch + // call, unlike SendMessage) has no live run left to show it while the + // root is idle; only a continuation does. + let isResumedSubagentTaskStarted = false; if ( isKnownSubagentTaskStarted && message.type === "system" && message.subtype === "task_started" ) { const now = yield* DateTime.now; - yield* Ref.update(sessionSubagentsByTaskId, (current) => { - const registered = current.get(message.task_id); - if (registered === undefined || registered.task.status === "running") { - return current; - } - const { progress: _staleProgress, ...priorTask } = registered.task; - return new Map(current).set(message.task_id, { - ...registered, - confirmedOutcome: undefined, - task: { - ...priorTask, - status: "running", - result: null, - startedAt: now, - completedAt: null, - updatedAt: now, - }, - }); - }); + isResumedSubagentTaskStarted = yield* Ref.modify( + sessionSubagentsByTaskId, + (current) => { + const registered = current.get(message.task_id); + if (registered === undefined || registered.task.status === "running") { + return [false, current] as const; + } + const { progress: _staleProgress, ...priorTask } = registered.task; + return [ + registered.runToolUseId === message.tool_use_id, + new Map(current).set(message.task_id, { + ...registered, + confirmedOutcome: undefined, + task: { + ...priorTask, + status: "running", + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + }, + }), + ] as const; + }, + ); } const isWakeTurnStart = isClaudeTurnStartMessage(message); const isWakeEvidence = @@ -8622,6 +8632,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( parentToolUseIdFromSdkMessage(message) === null; if ( !isPendingSubagentNotification && + !isResumedSubagentTaskStarted && !isNativeOpaqueWakeFrame && !isWakeTurnStart && message.type !== "result"