diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index 4704e7489af..e7caddc597b 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -66,6 +66,7 @@ import { type ProviderContinuationRequest } from "../ProviderContinuationRequest import { makeProviderFailure } from "../ProviderFailure.ts"; import { acpSelectionTransition } from "../ProviderSelectionTransition.ts"; import { + hasSubagentPromptText, makeSubagentChildThread, makeSubagentConversationArtifacts, subagentThreadTitle, @@ -2199,36 +2200,38 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV creationSource: "provider", }), }); - const promptNativeItemId = `${nativeTaskId}:prompt`; - const promptArtifacts = makeSubagentConversationArtifacts({ - messageId: idAllocator.derive.messageFromProviderItem({ + if (hasSubagentPromptText(update.prompt)) { + const promptNativeItemId = `${nativeTaskId}:prompt`; + const promptArtifacts = makeSubagentConversationArtifacts({ + messageId: idAllocator.derive.messageFromProviderItem({ + driver, + nativeItemId: promptNativeItemId, + }), + turnItemId: idAllocator.derive.turnItemFromProviderItem({ + driver, + nativeItemId: promptNativeItemId, + }), + threadId: childThreadId, + rootNodeId: childRootNodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: { driver, nativeId: promptNativeItemId, strength: "weak" }, + role: "user", + text: update.prompt, + ordinal: 100, + now, + }); + yield* emitProviderEvent({ + type: "message.updated", driver, - nativeItemId: promptNativeItemId, - }), - turnItemId: idAllocator.derive.turnItemFromProviderItem({ + message: promptArtifacts.message, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", driver, - nativeItemId: promptNativeItemId, - }), - threadId: childThreadId, - rootNodeId: childRootNodeId, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: { driver, nativeId: promptNativeItemId, strength: "weak" }, - role: "user", - text: update.prompt, - ordinal: 100, - now, - }); - yield* emitProviderEvent({ - type: "message.updated", - driver, - message: promptArtifacts.message, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver, - turnItem: promptArtifacts.turnItem, - }); + turnItem: promptArtifacts.turnItem, + }); + } } if (update.childSessionId !== null && subagent.childSessionId === null) { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index e73fba0298b..b43d2dcac01 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -23,18 +23,22 @@ import { ThreadId, } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { Tool } from "effect/unstable/ai"; import { attachmentRelativePath } from "../../attachmentStore.ts"; @@ -1148,7 +1152,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { uuid: "00000000-0000-4000-8000-000000000101", session_id: WAKE_NATIVE_SESSION, }); - const makeAssistantTextFrame = (input: { readonly uuid: string; readonly text: string }) => + const makeAssistantTextFrame = (input: { + readonly uuid: string; + readonly text: string; + readonly parentToolUseId?: string | null; + }) => claudeSdkFrame({ type: "assistant", message: { @@ -1166,7 +1174,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { cache_read_input_tokens: 0, }, }, - parent_tool_use_id: null, + parent_tool_use_id: input.parentToolUseId ?? null, uuid: input.uuid, session_id: WAKE_NATIVE_SESSION, }); @@ -1179,10 +1187,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { readonly isError?: boolean; readonly errors?: ReadonlyArray; readonly apiErrorStatus?: number; + readonly terminalReason?: string; }) => claudeSdkFrame({ type: "result", subtype: input.subtype ?? "success", + ...(input.terminalReason === undefined ? {} : { terminal_reason: input.terminalReason }), duration_ms: 10, duration_api_ms: 10, is_error: input.isError ?? false, @@ -1256,6 +1266,8 @@ describe("ClaudeAdapterV2 background wake turns", () => { const makeWakeHarnessWithOptions = (options?: { readonly close?: (sdkMessages: Queue.Queue) => Effect.Effect; readonly interrupt?: Effect.Effect; + readonly sessionScope?: Scope.Scope; + readonly runtimePolicy?: typeof CLAUDE_TEST_RUNTIME_POLICY; }) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1265,6 +1277,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); const sdkMessages = yield* Queue.unbounded(); const offeredMessages: Array = []; + const openedQueries: Array = []; const continuationRequests: Array = []; const adapter = makeClaudeAdapterV2({ instanceId: CLAUDE_DEFAULT_INSTANCE_ID, @@ -1281,32 +1294,39 @@ describe("ClaudeAdapterV2 background wake turns", () => { }, queryRunner: { allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), - open: () => - Effect.succeed({ - messages: Stream.fromQueue(sdkMessages), - offer: (message) => - Effect.sync(() => { - offeredMessages.push(message); - }), - setModel: () => Effect.void, - interrupt: options?.interrupt ?? Effect.void, - close: options?.close?.(sdkMessages) ?? Effect.void, + open: (input) => + Effect.sync(() => { + openedQueries.push(input); + return { + messages: Stream.fromQueue(sdkMessages), + offer: (message: SDKUserMessage) => + Effect.sync(() => { + offeredMessages.push(message); + }), + setModel: () => Effect.void, + interrupt: options?.interrupt ?? Effect.void, + close: options?.close?.(sdkMessages) ?? Effect.void, + }; }), forkSession: () => Effect.die("unused forkSession"), assertComplete: Effect.void, }, }); const threadId = ThreadId.make("thread-claude-wake"); - const runtime = yield* adapter.openSession({ + const runtimePolicy = options?.runtimePolicy ?? CLAUDE_TEST_RUNTIME_POLICY; + const openSession = adapter.openSession({ threadId, providerSessionId: ProviderSessionId.make("provider-session-claude-wake"), modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + runtimePolicy, }); + const runtime = yield* options?.sessionScope === undefined + ? openSession + : openSession.pipe(Effect.provideService(Scope.Scope, options.sessionScope)); const providerThread = yield* runtime.ensureThread({ threadId, modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + runtimePolicy, }); const events: Array = []; yield* runtime.events.pipe( @@ -1332,6 +1352,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { threadId, sdkMessages, offeredMessages, + openedQueries, continuationRequests, events, terminalEvents, @@ -2016,6 +2037,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(harness.continuationRequests[0]?.providerThreadId, harness.providerThread.id); assert.equal(harness.continuationRequests[0]?.driver, CLAUDE_PROVIDER); assert.equal(harness.continuationRequests[0]?.detail, WAKE_SUMMARY); + assert.equal(harness.continuationRequests[0]?.messageText, "Background task completed."); yield* Queue.offer(harness.sdkMessages, wakeResult); let settleYields = 0; @@ -2455,6 +2477,8 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ); yield* awaitUntil(() => harness.continuationRequests.length === 1, "continuation request"); + let settleYields = 0; + yield* awaitUntil(() => settleYields++ >= 50, "post-settlement child output buffered"); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ @@ -2593,6 +2617,289 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("clears native child state when an interrupt close times out", () => + Effect.scoped( + Effect.gen(function* () { + const closeStarted = yield* Deferred.make(); + const harness = yield* makeWakeHarnessWithOptions({ + close: () => Deferred.succeed(closeStarted, undefined), + }); + const idAllocator = yield* IdAllocatorV2; + const now = yield* DateTime.now; + const attemptId = RunAttemptId.make("attempt-claude-interrupt-timeout-cleanup"); + const taskId = "task-interrupt-timeout-subagent"; + const providerTurnId = idAllocator.derive.providerTurn({ + driver: CLAUDE_PROVIDER, + nativeTurnId: `turn:${attemptId}`, + }); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId, + text: "Start native background work, then stop.", + attachments: [], + }), + ); + yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: taskId, + tool_use_id: "toolu-interrupt-timeout-subagent", + description: "Native child that cannot survive query invalidation", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Wait for the parent interrupt.", + uuid: "00000000-0000-4000-8000-000000000119", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* awaitUntil( + () => harness.events.some((event) => event.type === "subagent.updated"), + "subagent registration before interrupt timeout", + ); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + + const interruptFiber = yield* harness.runtime + .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) + .pipe(Effect.forkScoped); + yield* Deferred.await(closeStarted); + yield* Effect.yieldNow; + yield* TestClock.adjust("10 seconds"); + yield* Fiber.join(interruptFiber); + yield* awaitUntil( + () => harness.terminalEvents().length === 1, + "interrupt timeout terminal", + ); + yield* awaitUntil( + () => + harness.events.some( + (event) => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === taskId && + event.subagent.status === "failed", + ), + "interrupted subagent terminal", + ); + + assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); + const terminalSubagent = harness.events.findLast( + (event) => + event.type === "subagent.updated" && event.subagent.nativeTaskRef?.nativeId === taskId, + ); + assert.equal(terminalSubagent?.type, "subagent.updated"); + assert.equal( + terminalSubagent?.type === "subagent.updated" ? terminalSubagent.subagent.status : null, + "failed", + ); + assert.equal( + terminalSubagent?.type === "subagent.updated" ? terminalSubagent.subagent.result : null, + "Background task ended when its Claude session closed.", + ); + assert.lengthOf( + harness.events.filter( + (event) => + event.type === "node.updated" && + event.node.nativeItemRef?.nativeId === taskId && + event.node.status === "failed", + ), + 2, + ); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + + it.effect("finalizes running native children when the Claude session closes", () => + Effect.scoped( + Effect.gen(function* () { + const sessionScope = yield* Scope.make(); + const harness = yield* makeWakeHarnessWithOptions({ sessionScope }); + const now = yield* DateTime.now; + const taskId = "task-session-close-subagent"; + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-session-close-cleanup"), + text: "Start native background work, then close the session.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: taskId, + tool_use_id: "toolu-session-close-subagent", + description: "Native child invalidated by session close", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Wait for the session to close.", + uuid: "00000000-0000-4000-8000-000000000706", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* awaitUntil( + () => + harness.events.some( + (event) => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === taskId && + event.subagent.status === "running", + ), + "running subagent before session close", + ); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + + yield* Scope.close(sessionScope, Exit.void); + yield* awaitUntil( + () => + harness.events.some( + (event) => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === taskId && + event.subagent.status === "failed", + ), + "failed subagent after session close", + ); + + const terminalSubagent = harness.events.findLast( + (event) => + event.type === "subagent.updated" && event.subagent.nativeTaskRef?.nativeId === taskId, + ); + assert.equal(terminalSubagent?.type, "subagent.updated"); + assert.equal( + terminalSubagent?.type === "subagent.updated" ? terminalSubagent.subagent.result : null, + "Background task ended when its Claude session closed.", + ); + assert.lengthOf( + harness.events.filter( + (event) => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === taskId && + event.subagent.status === "failed", + ), + 1, + ); + assert.lengthOf( + harness.events.filter( + (event) => + event.type === "node.updated" && + event.node.nativeItemRef?.nativeId === taskId && + event.node.status === "failed", + ), + 2, + ); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + + it.effect("preserves an offered continuation when its buffered wake survives replacement", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-stale-continuation-1"), + text: "Run the build in the background.", + attachments: [], + }), + ); + yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + yield* Queue.offer(harness.sdkMessages, wakeNotification); + yield* Queue.offer(harness.sdkMessages, wakeResult); + yield* awaitUntil(() => harness.continuationRequests.length === 1, "continuation offer"); + const offeredContinuation = harness.continuationRequests[0]; + assert.isDefined(offeredContinuation?.dispatchIfCurrent); + + const replacementInput = makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-stale-continuation-2"), + text: "Replace the query policy.", + attachments: [], + providerTurnOrdinal: 2, + }); + yield* harness.runtime.startTurn({ + ...replacementInput, + runtimePolicy: ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: "/workspace", + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }), + }); + + let dispatched = false; + const guardedDispatch = yield* offeredContinuation!.dispatchIfCurrent!( + Effect.sync(() => { + dispatched = true; + }), + ); + assert.isTrue(Option.isSome(guardedDispatch)); + assert.isTrue(dispatched); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + + it.effect("clears buffered work when the continuation worker drops an offer", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-dropped-continuation"), + text: "Run the build in the background.", + attachments: [], + }), + ); + yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + yield* Queue.offer(harness.sdkMessages, wakeNotification); + yield* Queue.offer(harness.sdkMessages, wakeResult); + yield* awaitUntil(() => harness.continuationRequests.length === 1, "continuation offer"); + const offeredContinuation = harness.continuationRequests[0]; + assert.isDefined(offeredContinuation?.clearIfCurrent); + assert.isDefined(offeredContinuation?.dispatchIfCurrent); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + + yield* offeredContinuation!.clearIfCurrent!(); + + assert.isFalse(yield* harness.hasPendingBackgroundWork); + assert.isTrue(Option.isNone(yield* offeredContinuation!.dispatchIfCurrent!(Effect.void))); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + it.effect("fails a positive task-notification error result", () => Effect.scoped( Effect.gen(function* () { @@ -2821,7 +3128,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { (event): event is Extract => event.type === "subagent.updated", ); - yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, @@ -2851,10 +3157,22 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.isTrue(yield* harness.hasPendingBackgroundWork); assert.lengthOf(harness.continuationRequests, 0); + yield* Queue.offer( + harness.sdkMessages, + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000206", + text: "The background subagent has finished.", + }), + ); + let bufferedYields = 0; + yield* awaitUntil(() => bufferedYields++ >= 50, "subagent output to buffer"); + assert.lengthOf(harness.continuationRequests, 0); + yield* Queue.offer(harness.sdkMessages, subagentNotification); yield* awaitUntil(() => harness.continuationRequests.length === 1, "continuation request"); assert.equal(harness.continuationRequests[0]?.threadId, harness.threadId); assert.equal(harness.continuationRequests[0]?.detail, SUBAGENT_SUMMARY); + assert.equal(harness.continuationRequests[0]?.messageText, "Background task completed."); yield* Queue.offer( harness.sdkMessages, @@ -2863,6 +3181,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { result: "The subagent finished with SUB_SETTLE_DONE.", }), ); + const continuationRuntimePolicy = ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "approval-required", + interactionMode: "default", + cwd: "/workspace", + }); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, @@ -2874,10 +3197,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { providerTurnOrdinal: 2, messageCreatedBy: "agent", messageCreationSource: "provider", + runtimePolicy: continuationRuntimePolicy, }), ); yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); assert.equal(harness.terminalEvents()[1]?.status, "completed"); + assert.lengthOf(harness.openedQueries, 1); // The replayed notification hydrates the original subagent node with // its terminal status and result, and keeps the original run @@ -2900,13 +3225,16 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("extracts text from direct content-block subagent results", () => + it.effect("keeps Agent launch results off the root before aliases register", () => Effect.scoped( Effect.gen(function* () { - const SUBAGENT_TASK_ID = "task-direct-content-blocks"; - const SUBAGENT_TOOL_USE_ID = "toolu-direct-content-blocks"; const harness = yield* makeWakeHarness; const now = yield* DateTime.now; + const ackTaskId = "task-prealias-ack"; + const ackToolUseId = "toolu-prealias-ack"; + const resultTaskId = "task-prealias-result"; + const resultToolUseId = "toolu-prealias-result"; + const terminalText = "Terminal child result before alias registration."; const subagentEvents = () => harness.events.filter( (event): event is Extract => @@ -2918,28 +3246,37 @@ describe("ClaudeAdapterV2 background wake turns", () => { threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-claude-direct-content-blocks"), - text: "Delegate this task.", + attemptId: RunAttemptId.make("attempt-claude-prealias-launch-results"), + text: "Delegate two tasks.", attachments: [], }), ); yield* Queue.offer( harness.sdkMessages, claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: SUBAGENT_TASK_ID, - tool_use_id: SUBAGENT_TOOL_USE_ID, - description: "Delegated task", - subagent_type: "general-purpose", - task_type: "local_agent", - prompt: "Return the result.", + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: ackToolUseId, + name: "Agent", + input: { prompt: "Keep running.", run_in_background: true }, + }, + { + type: "tool_use", + id: resultToolUseId, + name: "Agent", + input: { prompt: "Return a terminal result.", run_in_background: false }, + }, + ], + }, + parent_tool_use_id: null, uuid: "00000000-0000-4000-8000-000000000206", session_id: WAKE_NATIVE_SESSION, }), ); - yield* awaitUntil(() => subagentEvents().length === 1, "subagent node created"); - yield* Queue.offer( harness.sdkMessages, claudeSdkFrame({ @@ -2949,112 +3286,225 @@ describe("ClaudeAdapterV2 background wake turns", () => { content: [ { type: "tool_result", - tool_use_id: SUBAGENT_TOOL_USE_ID, - content: [ - { type: "text", text: "First line." }, - { type: "text", text: "Second line." }, - ], + tool_use_id: ackToolUseId, + content: [{ type: "text", text: "Async agent launched successfully." }], }, ], }, parent_tool_use_id: null, uuid: "00000000-0000-4000-8000-000000000207", session_id: WAKE_NATIVE_SESSION, + tool_use_result: { + isAsync: true, + status: "async_launched", + agentId: ackTaskId, + }, }), ); - yield* awaitUntil( - () => subagentEvents().at(-1)?.subagent.status === "completed", - "subagent terminal", - ); - - assert.equal(subagentEvents().at(-1)?.subagent.result, "First line.\nSecond line."); - yield* Queue.offer( harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000208", - result: "Delegation completed.", - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: resultToolUseId, + content: [{ type: "text", text: terminalText }], + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000208", + session_id: WAKE_NATIVE_SESSION, + tool_use_result: { + agentId: resultTaskId, + content: [{ type: "text", text: terminalText }], + }, + }), + ); + for (const [taskId, uuid] of [ + [ackTaskId, "00000000-0000-4000-8000-000000000209"], + [resultTaskId, "00000000-0000-4000-8000-000000000210"], + ] as const) { + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: taskId, + description: taskId, + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: taskId, + uuid, + session_id: WAKE_NATIVE_SESSION, + }), + ); + } + yield* awaitUntil( + () => + subagentEvents().some( + (event) => + event.subagent.nativeTaskRef?.nativeId === resultTaskId && + event.subagent.status === "completed", + ), + "pre-alias terminal result routed", + ); + + assert.equal( + subagentEvents().findLast((event) => event.subagent.nativeTaskRef?.nativeId === ackTaskId) + ?.subagent.status, + "running", + ); + assert.equal( + subagentEvents().findLast( + (event) => event.subagent.nativeTaskRef?.nativeId === resultTaskId, + )?.subagent.result, + terminalText, + ); + assert.isFalse( + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.threadId === harness.threadId && + (event.turnItem.nativeItemRef?.nativeId === ackToolUseId || + event.turnItem.nativeItemRef?.nativeId === resultToolUseId), + ), + ); + + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000211", + result: "Delegation launched.", + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), ), ); - it.effect("releases the idle pin when a post-settle subagent stops without completing", () => + it.effect("preserves Agent launch identity across a post-settlement result race", () => Effect.scoped( Effect.gen(function* () { - const SUBAGENT_TASK_ID = "task-wake-subagent-stopped"; - const subagentTaskStarted = claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: SUBAGENT_TASK_ID, - tool_use_id: "toolu-wake-subagent-stopped", - description: "Long-running research task", - subagent_type: "general-purpose", - task_type: "local_agent", - prompt: "Investigate the flaky test.", - uuid: "00000000-0000-4000-8000-000000000301", - session_id: WAKE_NATIVE_SESSION, - }); - const subagentStoppedNotification = claudeSdkFrame({ - type: "system", - subtype: "task_notification", - task_id: SUBAGENT_TASK_ID, - tool_use_id: "toolu-wake-subagent-stopped", - status: "stopped", - output_file: "/tmp/task-wake-subagent-stopped.output", - summary: "Agent was stopped before finishing.", - uuid: "00000000-0000-4000-8000-000000000302", - session_id: WAKE_NATIVE_SESSION, - }); - const harness = yield* makeWakeHarness; const now = yield* DateTime.now; - const subagentEvents = () => - harness.events.filter( - (event): event is Extract => - event.type === "subagent.updated", - ); + const taskId = "task-postsettle-prealias-result"; + const toolUseId = "toolu-postsettle-prealias-result"; + const siblingTaskId = "task-postsettle-prealias-sibling"; + const siblingToolUseId = "toolu-postsettle-prealias-sibling"; + const terminalText = "Distinct terminal result after the root settled."; yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-claude-wake-7a"), - text: "Spawn a background subagent and stop.", + attemptId: RunAttemptId.make("attempt-claude-postsettle-prealias-result-1"), + text: "Delegate a task and settle.", attachments: [], }), ); - yield* Queue.offer(harness.sdkMessages, subagentTaskStarted); - yield* awaitUntil(() => subagentEvents().length >= 1, "subagent node created"); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: toolUseId, + name: "Agent", + input: { prompt: "Finish after settle.", run_in_background: true }, + }, + { + type: "tool_use", + id: siblingToolUseId, + name: "Agent", + input: { prompt: "Remain running.", run_in_background: true }, + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000215", + session_id: WAKE_NATIVE_SESSION, + }), + ); + for (const [nativeTaskId, uuid] of [ + [taskId, "00000000-0000-4000-8000-000000000218"], + [siblingTaskId, "00000000-0000-4000-8000-000000000221"], + ] as const) { + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: nativeTaskId, + description: "Post-settlement alias race", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: nativeTaskId, + uuid, + session_id: WAKE_NATIVE_SESSION, + }), + ); + } yield* Queue.offer( harness.sdkMessages, makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000303", - result: "Spawned the subagent in the background.", + uuid: "00000000-0000-4000-8000-000000000216", + result: "Root settled before the task alias arrived.", }), ); yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); - assert.isTrue(yield* harness.hasPendingBackgroundWork); - - yield* Queue.offer(harness.sdkMessages, subagentStoppedNotification); - yield* awaitUntil(() => harness.continuationRequests.length === 1, "continuation request"); yield* Queue.offer( harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000304", - result: "The subagent was stopped.", + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: toolUseId, + content: [{ type: "text", text: terminalText }], + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000217", + session_id: WAKE_NATIVE_SESSION, + tool_use_result: { + agentId: taskId, + content: [{ type: "text", text: terminalText }], + }, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_notification", + task_id: taskId, + status: "completed", + output_file: "", + summary: "Notification fallback after the terminal result.", + uuid: "00000000-0000-4000-8000-000000000219", + session_id: WAKE_NATIVE_SESSION, }), ); + yield* awaitUntil(() => harness.continuationRequests.length === 1, "continuation offer"); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-claude-wake-7b"), + attemptId: RunAttemptId.make("attempt-claude-postsettle-prealias-result-2"), text: "Background task completed.", attachments: [], providerTurnOrdinal: 2, @@ -3062,150 +3512,214 @@ describe("ClaudeAdapterV2 background wake turns", () => { messageCreationSource: "provider", }), ); + yield* awaitUntil( + () => + harness.events.some( + (event) => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === taskId, + ), + "post-settlement subagent replay", + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000220", + result: "Post-settlement task completed.", + origin: { kind: "task-notification" }, + }), + ); yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); - assert.equal(subagentEvents().at(-1)?.subagent.status, "cancelled"); - assert.isFalse(yield* harness.hasPendingBackgroundWork); + const childThreadId = harness.events.find( + (event): event is Extract => + event.type === "subagent.updated" && event.subagent.nativeTaskRef?.nativeId === taskId, + )?.subagent.childThreadId; + assert.ok(childThreadId); + assert.equal( + harness.events.filter( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "assistant_message" && + event.turnItem.threadId === childThreadId && + event.turnItem.text === terminalText, + ).length, + 1, + ); + assert.isFalse( + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.threadId === harness.threadId && + event.turnItem.nativeItemRef?.nativeId === toolUseId, + ), + ); }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), ), ); - it.effect("re-opens a resumed subagent and hydrates its second result", () => + it.effect("keeps root tool results containing agentId on the root", () => Effect.scoped( Effect.gen(function* () { - const SUBAGENT_TASK_ID = "task-resume-subagent"; - const SUBAGENT_TOOL_USE_ID = "toolu-resume-subagent"; - const RESUME_TOOL_USE_ID = "toolu-resume-sendmessage"; - const FIRST_SUMMARY = "Timer armed. Waiting for it to complete."; - const SECOND_SUMMARY = "RESUME_DONE"; - const subagentTaskStarted = claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: SUBAGENT_TASK_ID, - tool_use_id: SUBAGENT_TOOL_USE_ID, - description: "Sleep then echo done token", - subagent_type: "general-purpose", - task_type: "local_agent", - prompt: "Run the shell command, then return exactly RESUME_DONE.", - uuid: "00000000-0000-4000-8000-000000000401", - session_id: WAKE_NATIVE_SESSION, - }); - const firstNotification = claudeSdkFrame({ - type: "system", - subtype: "task_notification", - task_id: SUBAGENT_TASK_ID, - tool_use_id: SUBAGENT_TOOL_USE_ID, - status: "completed", - output_file: "/tmp/task-resume-subagent.output", - summary: FIRST_SUMMARY, - uuid: "00000000-0000-4000-8000-000000000402", - session_id: WAKE_NATIVE_SESSION, - }); - // SendMessage to a completed subagent resumes it: the CLI re-emits - // task_started with the same task id but the SendMessage call's - // tool_use_id, not the original Agent launch's. - const resumeTaskStarted = claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: SUBAGENT_TASK_ID, - tool_use_id: RESUME_TOOL_USE_ID, - description: "Sleep then echo done token", - subagent_type: "general-purpose", - task_type: "local_agent", - prompt: "Run the shell command, then return exactly RESUME_DONE.", - uuid: "00000000-0000-4000-8000-000000000405", - session_id: WAKE_NATIVE_SESSION, - }); - const secondNotification = claudeSdkFrame({ - type: "system", - subtype: "task_notification", - task_id: SUBAGENT_TASK_ID, - tool_use_id: SUBAGENT_TOOL_USE_ID, - status: "completed", - output_file: "/tmp/task-resume-subagent.output", - summary: SECOND_SUMMARY, - uuid: "00000000-0000-4000-8000-000000000407", - session_id: WAKE_NATIVE_SESSION, - }); - const harness = yield* makeWakeHarness; const now = yield* DateTime.now; - const subagentEvents = () => - harness.events.filter( - (event): event is Extract => - event.type === "subagent.updated", - ); + const toolUseId = "toolu-root-agent-id-output"; yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-claude-wake-8a"), - text: "Spawn a background subagent and stop.", + attemptId: RunAttemptId.make("attempt-claude-root-agent-id-output"), + text: "Run a root tool whose output includes an agentId field.", attachments: [], }), ); - yield* Queue.offer(harness.sdkMessages, subagentTaskStarted); - yield* awaitUntil(() => subagentEvents().length >= 1, "subagent node created"); yield* Queue.offer( harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000403", - result: "Spawned the subagent in the background.", + claudeSdkFrame({ + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: toolUseId, + name: "mcp__example__lookup", + input: { query: "external agent" }, + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000226", + session_id: WAKE_NATIVE_SESSION, }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); - - yield* Queue.offer(harness.sdkMessages, firstNotification); yield* awaitUntil( - () => harness.continuationRequests.length === 1, - "first continuation request", + () => + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.nativeItemRef?.nativeId === toolUseId && + event.turnItem.threadId === harness.threadId && + event.turnItem.status === "running", + ), + "root tool started", ); - assert.equal(harness.continuationRequests[0]?.detail, FIRST_SUMMARY); yield* Queue.offer( harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000404", - result: "The subagent finished early.", + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: toolUseId, + content: [{ type: "text", text: "External lookup complete." }], + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000227", + session_id: WAKE_NATIVE_SESSION, + tool_use_result: { + agentId: "external-service-agent", + result: "External lookup complete.", + }, }), ); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-8b"), - text: "Background task completed.", - attachments: [], - providerTurnOrdinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", + yield* awaitUntil( + () => + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.nativeItemRef?.nativeId === toolUseId && + event.turnItem.threadId === harness.threadId && + event.turnItem.status === "completed", + ), + "root tool completed", + ); + assert.isFalse(harness.events.some((event) => event.type === "subagent.updated")); + + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000228", + result: "Root tool result handled.", }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); - assert.equal(subagentEvents().at(-1)?.subagent.status, "completed"); - assert.equal(subagentEvents().at(-1)?.subagent.result, FIRST_SUMMARY); - assert.isFalse(yield* harness.hasPendingBackgroundWork); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + + it.effect("forgets declined Agent approvals before missing-alias inference", () => + Effect.scoped( + Effect.gen(function* () { + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "approval-required", + interactionMode: "default", + cwd: "/workspace", + }); + const harness = yield* makeWakeHarnessWithOptions({ runtimePolicy }); + const now = yield* DateTime.now; + const declinedToolUseId = "toolu-declined-agent-launch"; + const launchToolUseId = "toolu-approved-agent-launch"; + const taskId = "task-approved-agent-launch"; + const childText = "Approved child output after a declined launch."; - // A user turn nudges the completed subagent via SendMessage; the - // resume task_started re-opens the row across turn contexts (the new - // turn's maps are empty, so this exercises the session registry). yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-claude-wake-8c"), - text: "Nudge the subagent to finish.", + attemptId: RunAttemptId.make("attempt-claude-declined-agent-launch"), + text: "Decline one Agent launch, then approve another.", attachments: [], - providerTurnOrdinal: 3, + runtimePolicy, }), ); - yield* awaitUntil(() => harness.offeredMessages.length === 2, "nudge prompt offered"); - // The resume rides on a SendMessage tool call: the CLI re-emits - // task_started with the SendMessage tool_use_id, and that tool call's - // result is a delivery ACK which must not terminalize the subagent. + yield* awaitUntil(() => harness.openedQueries.length === 1, "Claude query opened"); + const canUseTool = harness.openedQueries[0]?.options.canUseTool; + assert.ok(canUseTool); + if (canUseTool === undefined) { + return; + } + + const permissionFiber = yield* Effect.promise(() => + canUseTool( + "Agent", + { prompt: "This launch will be declined.", run_in_background: true }, + { + requestId: "request-declined-agent-launch", + signal: new AbortController().signal, + toolUseID: declinedToolUseId, + }, + ), + ).pipe(Effect.forkScoped); + yield* awaitUntil( + () => harness.events.some((event) => event.type === "runtime_request.updated"), + "Agent approval request", + ); + const requestEvent = harness.events.find( + (event): event is Extract => + event.type === "runtime_request.updated", + ); + assert.ok(requestEvent); + if (requestEvent === undefined) { + return; + } + yield* harness.runtime.respondToRuntimeRequest({ + requestId: requestEvent.runtimeRequest.id, + decision: "decline", + }); + const declinedResult = yield* Fiber.join(permissionFiber); + assert.equal(declinedResult?.behavior, "deny"); + yield* Queue.offer( harness.sdkMessages, claudeSdkFrame({ @@ -3215,249 +3729,563 @@ describe("ClaudeAdapterV2 background wake turns", () => { content: [ { type: "tool_use", - id: RESUME_TOOL_USE_ID, - name: "SendMessage", - input: { agent_id: SUBAGENT_TASK_ID, message: "Continue and return the token." }, + id: launchToolUseId, + name: "Agent", + input: { prompt: "This launch is valid.", run_in_background: true }, }, ], }, parent_tool_use_id: null, - uuid: "00000000-0000-4000-8000-000000000411", + uuid: "00000000-0000-4000-8000-000000000235", session_id: WAKE_NATIVE_SESSION, }), ); - yield* Queue.offer(harness.sdkMessages, resumeTaskStarted); yield* Queue.offer( harness.sdkMessages, claudeSdkFrame({ - type: "user", - message: { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: RESUME_TOOL_USE_ID, - content: [ - { - type: "text", - text: '{"success":true,"message":"Message sent to agent; it will resume."}', - }, - ], - }, - ], - }, - parent_tool_use_id: null, - uuid: "00000000-0000-4000-8000-000000000412", + type: "system", + subtype: "task_started", + task_id: taskId, + description: "Approved Agent launch", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "This launch is valid.", + uuid: "00000000-0000-4000-8000-000000000236", session_id: WAKE_NATIVE_SESSION, }), ); yield* awaitUntil( - () => subagentEvents().at(-1)?.subagent.status === "running", - "subagent re-opened", + () => + harness.events.some( + (event) => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === taskId, + ), + "approved subagent registered", ); - const reopened = subagentEvents().at(-1)?.subagent; - assert.isNull(reopened?.result); - // The reopen re-attributes the subagent to the resuming run: - // RunExecutionService routes parent-thread events by runId, and the - // launch run's ingestion fiber stops once its child subagents - // terminalize, so only the resuming run's fiber can persist the - // resumed lifecycle. - assert.equal(reopened?.runId, "run-attempt-claude-wake-8c"); - assert.notEqual(reopened?.runId, subagentEvents()[0]?.subagent.runId); + const childThreadId = harness.events.findLast( + (event): event is Extract => + event.type === "subagent.updated" && event.subagent.nativeTaskRef?.nativeId === taskId, + )?.subagent.childThreadId; + assert.ok(childThreadId); + yield* Queue.offer( harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000406", - result: "Nudged the subagent.", + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000237", + text: childText, + parentToolUseId: launchToolUseId, }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 3, "nudge turn terminal"); - // The re-opened subagent pins idle release again. - assert.isTrue(yield* harness.hasPendingBackgroundWork); - - // The resumed run's notification is wake evidence again and carries - // its summary as the continuation detail. - yield* Queue.offer(harness.sdkMessages, secondNotification); yield* awaitUntil( - () => harness.continuationRequests.length === 2, - "second continuation request", + () => + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "assistant_message" && + event.turnItem.threadId === childThreadId && + event.turnItem.text === childText, + ), + "approved child output routed", ); - assert.equal(harness.continuationRequests[1]?.detail, SECOND_SUMMARY); + assert.isFalse( + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "assistant_message" && + event.turnItem.threadId === harness.threadId && + event.turnItem.text === childText, + ), + ); + yield* Queue.offer( harness.sdkMessages, makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000408", - result: "The subagent finished with RESUME_DONE.", + uuid: "00000000-0000-4000-8000-000000000238", + result: "Approval test complete.", }), ); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + + it.effect("routes subagent permission artifacts by Claude agentID", () => + Effect.scoped( + Effect.gen(function* () { + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "approval-required", + interactionMode: "default", + cwd: "/workspace", + }); + const harness = yield* makeWakeHarnessWithOptions({ + runtimePolicy, + }); + const now = yield* DateTime.now; + const taskId = "task-subagent-permission"; + const launchToolUseId = "toolu-subagent-permission-launch"; + const siblingTaskId = "task-subagent-permission-sibling"; + const siblingToolUseId = "toolu-subagent-permission-sibling"; + const childToolUseId = "toolu-subagent-permission-child"; + assert.isTrue(ClaudeProviderCapabilitiesV2.approvals.approvalsCanOriginateFromSubagents); + yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-claude-wake-8d"), - text: "Background task completed.", + attemptId: RunAttemptId.make("attempt-claude-subagent-permission"), + text: "Delegate a task that needs permission.", attachments: [], - providerTurnOrdinal: 4, - messageCreatedBy: "agent", - messageCreationSource: "provider", + runtimePolicy, }), ); - yield* awaitUntil( - () => harness.terminalEvents().length === 4, - "second continuation terminal", + yield* awaitUntil(() => harness.openedQueries.length === 1, "Claude query opened"); + const canUseTool = harness.openedQueries[0]?.options.canUseTool; + assert.ok(canUseTool); + if (canUseTool === undefined) { + return; + } + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: launchToolUseId, + name: "Agent", + input: { prompt: "Run a child command.", run_in_background: true }, + }, + { + type: "tool_use", + id: siblingToolUseId, + name: "Agent", + input: { prompt: "Remain idle.", run_in_background: true }, + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000212", + session_id: WAKE_NATIVE_SESSION, + }), ); - - const finalSubagent = subagentEvents().at(-1)?.subagent; - assert.equal(finalSubagent?.status, "completed"); - assert.equal(finalSubagent?.result, SECOND_SUMMARY); - // The completion keeps the resuming run's attribution. - assert.equal(finalSubagent?.runId, "run-attempt-claude-wake-8c"); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - - // Only task_started may re-open a terminal subagent: a late - // task_progress must not flip the row back to running or re-pin idle. - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-8e"), - text: "Anything new?", - attachments: [], - providerTurnOrdinal: 5, + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: taskId, + description: "Child permission task", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Run a child command.", + uuid: "00000000-0000-4000-8000-000000000213", + session_id: WAKE_NATIVE_SESSION, }), ); - yield* awaitUntil(() => harness.offeredMessages.length === 3, "final prompt offered"); yield* Queue.offer( harness.sdkMessages, claudeSdkFrame({ type: "system", - subtype: "task_progress", - task_id: SUBAGENT_TASK_ID, - tool_use_id: SUBAGENT_TOOL_USE_ID, - description: "Stale progress line", - uuid: "00000000-0000-4000-8000-000000000409", + subtype: "task_started", + task_id: siblingTaskId, + description: "Sibling permission task", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Remain idle.", + uuid: "00000000-0000-4000-8000-000000000223", session_id: WAKE_NATIVE_SESSION, }), ); + yield* awaitUntil( + () => harness.events.some((event) => event.type === "subagent.updated"), + "subagent registered", + ); + const childThreadId = harness.events.find( + (event): event is Extract => + event.type === "subagent.updated" && event.subagent.nativeTaskRef?.nativeId === taskId, + )?.subagent.childThreadId; + assert.ok(childThreadId); + + const controller = new AbortController(); + const permissionFiber = yield* Effect.promise(() => + canUseTool( + "Bash", + { command: "pwd" }, + { + agentID: taskId, + requestId: "request-subagent-permission", + signal: controller.signal, + toolUseID: childToolUseId, + }, + ), + ).pipe(Effect.forkScoped); + yield* awaitUntil( + () => harness.events.some((event) => event.type === "runtime_request.updated"), + "child permission request", + ); + const requestEvent = harness.events.find( + (event): event is Extract => + event.type === "runtime_request.updated", + ); + assert.ok(requestEvent); + if (requestEvent === undefined) { + return; + } + const approvalNode = harness.events.find( + (event): event is Extract => + event.type === "node.updated" && event.node.kind === "approval_request", + )?.node; + const approvalItem = harness.events.find( + (event): event is Extract => + event.type === "turn_item.updated" && event.turnItem.type === "approval_request", + )?.turnItem; + const childToolNode = harness.events.find( + (event): event is Extract => + event.type === "node.updated" && + event.node.kind !== "approval_request" && + event.node.nativeItemRef?.nativeId === childToolUseId, + )?.node; + const childToolItem = harness.events.find( + (event): event is Extract => + event.type === "turn_item.updated" && + event.turnItem.type !== "approval_request" && + event.turnItem.nativeItemRef?.nativeId === childToolUseId, + )?.turnItem; + assert.equal(approvalNode?.threadId, childThreadId); + assert.isNull(approvalNode?.runId); + assert.equal(approvalItem?.threadId, childThreadId); + assert.isNull(approvalItem?.runId); + assert.equal(childToolNode?.threadId, childThreadId); + assert.isNull(childToolNode?.runId); + assert.equal(approvalNode?.rootNodeId, childToolNode?.rootNodeId); + assert.equal(approvalNode?.parentNodeId, childToolNode?.id); + assert.equal(childToolNode?.parentNodeId, childToolNode?.rootNodeId); + assert.isAbove(approvalItem?.ordinal ?? 0, childToolItem?.ordinal ?? 0); + + const concurrentRootText = "Root narration while child permission is pending."; yield* Queue.offer( harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000410", - result: "Nothing new.", + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000229", + text: concurrentRootText, + parentToolUseId: null, }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 5, "final turn terminal"); - assert.equal(subagentEvents().at(-1)?.subagent.status, "completed"); - assert.equal(subagentEvents().at(-1)?.subagent.result, SECOND_SUMMARY); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), - ), - ); + yield* awaitUntil( + () => + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "assistant_message" && + event.turnItem.threadId === harness.threadId && + event.turnItem.text === concurrentRootText, + ), + "SDK messages continue while permission is pending", + ); - it.effect("re-opens a resumed subagent whose task_started races past settle", () => - Effect.scoped( - Effect.gen(function* () { - const SUBAGENT_TASK_ID = "task-resume-postsettle"; - const SUBAGENT_TOOL_USE_ID = "toolu-resume-postsettle"; - const RESUME_TOOL_USE_ID = "toolu-resume-postsettle-sendmessage"; - const FIRST_SUMMARY = "Answered early."; - const SECOND_SUMMARY = "RESUME_SETTLE_DONE"; - const subagentTaskStarted = claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: SUBAGENT_TASK_ID, - tool_use_id: SUBAGENT_TOOL_USE_ID, - description: "Sleep then echo done token", - subagent_type: "general-purpose", - task_type: "local_agent", - prompt: "Run the shell command, then return exactly RESUME_SETTLE_DONE.", - uuid: "00000000-0000-4000-8000-000000000501", - session_id: WAKE_NATIVE_SESSION, - }); - const firstNotification = claudeSdkFrame({ - type: "system", - subtype: "task_notification", - task_id: SUBAGENT_TASK_ID, - tool_use_id: SUBAGENT_TOOL_USE_ID, - status: "completed", - output_file: "/tmp/task-resume-postsettle.output", - summary: FIRST_SUMMARY, - uuid: "00000000-0000-4000-8000-000000000502", - session_id: WAKE_NATIVE_SESSION, - }); - const resumeTaskStarted = claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: SUBAGENT_TASK_ID, - tool_use_id: RESUME_TOOL_USE_ID, - description: "Sleep then echo done token", - subagent_type: "general-purpose", - task_type: "local_agent", - prompt: "Run the shell command, then return exactly RESUME_SETTLE_DONE.", - uuid: "00000000-0000-4000-8000-000000000505", - session_id: WAKE_NATIVE_SESSION, - }); - const secondNotification = claudeSdkFrame({ - type: "system", - subtype: "task_notification", - task_id: SUBAGENT_TASK_ID, - tool_use_id: SUBAGENT_TOOL_USE_ID, - status: "completed", - output_file: "/tmp/task-resume-postsettle.output", - summary: SECOND_SUMMARY, - uuid: "00000000-0000-4000-8000-000000000506", - session_id: WAKE_NATIVE_SESSION, + yield* harness.runtime.respondToRuntimeRequest({ + requestId: requestEvent.runtimeRequest.id, + decision: "accept", }); + const permissionResult = yield* Fiber.join(permissionFiber); + assert.equal(permissionResult?.behavior, "allow"); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: childToolUseId, + content: "Child command completed.", + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000222", + session_id: WAKE_NATIVE_SESSION, + tool_use_result: { + agentId: taskId, + stdout: "Child command completed.", + }, + }), + ); + yield* awaitUntil( + () => + harness.events.some( + (event) => + event.type === "node.updated" && + event.node.nativeItemRef?.nativeId === childToolUseId && + event.node.status === "completed", + ), + "child permission tool completed", + ); + assert.isFalse( + harness.events.some( + (event) => + event.type === "node.updated" && + event.node.nativeItemRef?.nativeId === childToolUseId && + event.node.threadId === harness.threadId, + ), + ); + + const eventsBeforeUnknownRequest = harness.events.length; + const unknownResult = yield* Effect.promise(() => + canUseTool( + "Bash", + { command: "pwd" }, + { + agentID: "task-unknown-permission", + requestId: "request-unknown-permission", + signal: new AbortController().signal, + toolUseID: "toolu-unknown-permission", + }, + ), + ); + assert.equal(unknownResult?.behavior, "deny"); + assert.lengthOf(harness.events, eventsBeforeUnknownRequest); + + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000214", + result: "Delegation paused for permission.", + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + it.effect("routes interleaved post-settlement subagent output through durable aliases", () => + Effect.scoped( + Effect.gen(function* () { const harness = yield* makeWakeHarness; const now = yield* DateTime.now; + const firstTaskId = "task-postsettle-first"; + const firstToolUseId = "toolu-postsettle-first"; + const secondTaskId = "task-postsettle-second"; + const secondToolUseId = "toolu-postsettle-second"; const subagentEvents = () => harness.events.filter( (event): event is Extract => event.type === "subagent.updated", ); + const assistantItems = () => + harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "assistant_message" + ? [event.turnItem] + : [], + ); + const toolItems = () => + harness.events.filter( + (event): event is Extract => + event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool", + ); + const taskStarted = (taskId: string, toolUseId: string, uuid: string) => + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: taskId, + tool_use_id: toolUseId, + description: `Run ${taskId}`, + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: `Complete ${taskId}.`, + uuid, + session_id: WAKE_NATIVE_SESSION, + }); + const childToolUse = (parentToolUseId: string, toolUseId: string, uuid: string) => + claudeSdkFrame({ + type: "assistant", + message: { + role: "assistant", + content: [{ type: "tool_use", id: toolUseId, name: "ChildTool", input: {} }], + }, + parent_tool_use_id: parentToolUseId, + uuid, + session_id: WAKE_NATIVE_SESSION, + }); + const childToolResult = (parentToolUseId: string, toolUseId: string, uuid: string) => + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: toolUseId, content: "done" }], + }, + parent_tool_use_id: parentToolUseId, + uuid, + session_id: WAKE_NATIVE_SESSION, + }); + const taskNotification = (input: { + readonly taskId: string; + readonly summary: string; + readonly uuid: string; + readonly toolUseId?: string; + }) => + claudeSdkFrame({ + type: "system", + subtype: "task_notification", + task_id: input.taskId, + ...(input.toolUseId === undefined ? {} : { tool_use_id: input.toolUseId }), + status: "completed", + output_file: "", + summary: input.summary, + uuid: input.uuid, + session_id: WAKE_NATIVE_SESSION, + }); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-claude-wake-9a"), - text: "Spawn a background subagent and stop.", + attemptId: RunAttemptId.make("attempt-claude-postsettle-1"), + text: "Launch two background subagents.", attachments: [], }), ); - yield* Queue.offer(harness.sdkMessages, subagentTaskStarted); - yield* awaitUntil(() => subagentEvents().length >= 1, "subagent node created"); + yield* Queue.offer( + harness.sdkMessages, + taskStarted(firstTaskId, firstToolUseId, "00000000-0000-4000-8000-000000000601"), + ); + yield* Queue.offer( + harness.sdkMessages, + taskStarted(secondTaskId, secondToolUseId, "00000000-0000-4000-8000-000000000602"), + ); + yield* awaitUntil(() => subagentEvents().length >= 2, "two subagent nodes"); + const firstChildThreadId = subagentEvents().find( + (event) => event.subagent.nativeTaskRef?.nativeId === firstTaskId, + )?.subagent.childThreadId; + const secondChildThreadId = subagentEvents().find( + (event) => event.subagent.nativeTaskRef?.nativeId === secondTaskId, + )?.subagent.childThreadId; + assert.ok(firstChildThreadId); + assert.ok(secondChildThreadId); + yield* Queue.offer( + harness.sdkMessages, + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000603", + text: "Root launch complete.", + }), + ); yield* Queue.offer( harness.sdkMessages, makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000503", - result: "Spawned the subagent in the background.", + uuid: "00000000-0000-4000-8000-000000000604", + result: "Root launch complete.", }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); - yield* Queue.offer(harness.sdkMessages, firstNotification); - yield* awaitUntil( - () => harness.continuationRequests.length === 1, - "first continuation request", + const firstNarration = "First child narration."; + const firstFinal = "First child SDK final."; + const firstSummary = "First child distinct terminal summary."; + const secondNarration = "Second child narration."; + const secondFinal = "Second child final."; + yield* Queue.offer( + harness.sdkMessages, + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000605", + text: firstNarration, + parentToolUseId: firstToolUseId, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000606", + text: secondNarration, + parentToolUseId: secondToolUseId, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + childToolUse( + firstToolUseId, + "toolu-postsettle-first-read", + "00000000-0000-4000-8000-000000000607", + ), + ); + yield* Queue.offer( + harness.sdkMessages, + childToolUse( + secondToolUseId, + "toolu-postsettle-second-read", + "00000000-0000-4000-8000-000000000608", + ), + ); + yield* Queue.offer( + harness.sdkMessages, + childToolResult( + firstToolUseId, + "toolu-postsettle-first-read", + "00000000-0000-4000-8000-000000000609", + ), + ); + yield* Queue.offer( + harness.sdkMessages, + childToolResult( + secondToolUseId, + "toolu-postsettle-second-read", + "00000000-0000-4000-8000-000000000610", + ), + ); + yield* Queue.offer( + harness.sdkMessages, + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000611", + text: firstFinal, + parentToolUseId: firstToolUseId, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000612", + text: secondFinal, + parentToolUseId: secondToolUseId, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + taskNotification({ + taskId: firstTaskId, + summary: firstSummary, + uuid: "00000000-0000-4000-8000-000000000613", + }), + ); + yield* Queue.offer( + harness.sdkMessages, + taskNotification({ + taskId: secondTaskId, + toolUseId: secondToolUseId, + summary: secondFinal, + uuid: "00000000-0000-4000-8000-000000000614", + }), ); yield* Queue.offer( harness.sdkMessages, makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000504", - result: "The subagent answered early.", + uuid: "00000000-0000-4000-8000-000000000615", + result: "Root processed both completions.", + origin: { kind: "task-notification" }, }), ); + yield* awaitUntil(() => harness.continuationRequests.length === 1, "continuation request"); + yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-claude-wake-9b"), + attemptId: RunAttemptId.make("attempt-claude-postsettle-2"), text: "Background task completed.", attachments: [], providerTurnOrdinal: 2, @@ -3466,38 +4294,124 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ); yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); - assert.equal(subagentEvents().at(-1)?.subagent.status, "completed"); - assert.equal(subagentEvents().at(-1)?.subagent.result, FIRST_SUMMARY); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - // The resume task_started races past settle: no turn is active, so it - // must re-open the session registry entry (pinning idle again) and - // buffer for replay. Its notification then counts as wake evidence - // and carries the new summary as the continuation detail. The resume - // rides on a SendMessage tool call whose frames race past settle too; - // on drain replay the SendMessage tool_result is a delivery ACK and - // must not terminalize the re-opened subagent. + const firstChildAssistantTexts = assistantItems() + .filter((item) => item.threadId === firstChildThreadId) + .map((item) => item.text); + const secondChildAssistantTexts = assistantItems() + .filter((item) => item.threadId === secondChildThreadId) + .map((item) => item.text); + assert.deepEqual(firstChildAssistantTexts, [firstNarration, firstFinal, firstSummary]); + assert.deepEqual(secondChildAssistantTexts, [secondNarration, secondFinal]); + assert.deepEqual( + toolItems().map((event) => event.turnItem.threadId), + [firstChildThreadId, secondChildThreadId, firstChildThreadId, secondChildThreadId], + ); + const rootAssistantTexts = assistantItems() + .filter((item) => item.threadId === harness.threadId) + .map((item) => item.text); + assert.deepEqual(rootAssistantTexts, [ + "Root launch complete.", + "Root processed both completions.", + ]); + for (const childText of [ + firstNarration, + firstFinal, + firstSummary, + secondNarration, + secondFinal, + ]) { + assert.notInclude(rootAssistantTexts, childText); + } + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + + it.effect("buffers a short subagent registration race without leaking to the root", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const taskId = "task-registration-race"; + const toolUseId = "toolu-registration-race"; + const childText = "Child text that arrived before registration."; + const assistantItems = () => + harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "assistant_message" + ? [event.turnItem] + : [], + ); + const subagentEvents = () => + harness.events.filter( + (event): event is Extract => + event.type === "subagent.updated", + ); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-registration-race"), + text: "Delegate after the child begins responding.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000621", + text: childText, + parentToolUseId: toolUseId, + }), + ); yield* Queue.offer( harness.sdkMessages, claudeSdkFrame({ - type: "assistant", - message: { - role: "assistant", - content: [ - { - type: "tool_use", - id: RESUME_TOOL_USE_ID, - name: "SendMessage", - input: { agent_id: SUBAGENT_TASK_ID, message: "Continue and return the token." }, - }, - ], - }, - parent_tool_use_id: null, - uuid: "00000000-0000-4000-8000-000000000508", + type: "system", + subtype: "task_started", + task_id: taskId, + description: "Registration race", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Finish after registration.", + uuid: "00000000-0000-4000-8000-000000000622", session_id: WAKE_NATIVE_SESSION, }), ); - yield* Queue.offer(harness.sdkMessages, resumeTaskStarted); + yield* awaitUntil(() => subagentEvents().length === 1, "subagent registered without alias"); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: taskId, + tool_use_id: toolUseId, + description: "Registration race", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Finish after registration.", + uuid: "00000000-0000-4000-8000-000000000624", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* awaitUntil( + () => assistantItems().some((item) => item.text === childText), + "buffered child text", + ); + const childThreadId = subagentEvents().at(-1)?.subagent.childThreadId; + assert.ok(childThreadId); + assert.equal( + assistantItems().find((item) => item.text === childText)?.threadId, + childThreadId, + ); + assert.isFalse( + assistantItems().some( + (item) => item.threadId === harness.threadId && item.text === childText, + ), + ); + + const terminalText = "Terminal result after the late launch alias."; yield* Queue.offer( harness.sdkMessages, claudeSdkFrame({ @@ -3507,934 +4421,2440 @@ describe("ClaudeAdapterV2 background wake turns", () => { content: [ { type: "tool_result", - tool_use_id: RESUME_TOOL_USE_ID, - content: [ - { - type: "text", - text: '{"success":true,"message":"Message sent to agent; it will resume."}', - }, - ], + tool_use_id: toolUseId, + content: [{ type: "text", text: terminalText }], }, ], }, parent_tool_use_id: null, - uuid: "00000000-0000-4000-8000-000000000509", + uuid: "00000000-0000-4000-8000-000000000625", session_id: WAKE_NATIVE_SESSION, + tool_use_result: { + agentId: taskId, + content: [{ type: "text", text: terminalText }], + }, }), ); - yield* Queue.offer(harness.sdkMessages, secondNotification); yield* awaitUntil( - () => harness.continuationRequests.length === 2, - "second continuation request", + () => + subagentEvents().some( + (event) => + event.subagent.status === "completed" && event.subagent.result === terminalText, + ), + "late launch alias terminal result", ); - assert.equal(harness.continuationRequests[1]?.detail, SECOND_SUMMARY); - assert.isTrue(yield* harness.hasPendingBackgroundWork); yield* Queue.offer( harness.sdkMessages, makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000507", - result: "The subagent finished with RESUME_SETTLE_DONE.", + uuid: "00000000-0000-4000-8000-000000000623", + result: "Root race handling complete.", }), ); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "race turn terminal"); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + + it.effect("waits for explicit aliases when sibling task starts are ambiguous", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const firstTaskId = "task-ambiguous-first"; + const firstToolUseId = "toolu-ambiguous-first"; + const firstChildText = "First ambiguous child text."; + const secondTaskId = "task-ambiguous-second"; + const secondToolUseId = "toolu-ambiguous-second"; + const secondChildText = "Second ambiguous child text."; + const subagentEvents = () => + harness.events.filter( + (event): event is Extract => + event.type === "subagent.updated", + ); + const assistantItems = () => + harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "assistant_message" + ? [event.turnItem] + : [], + ); + yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-claude-wake-9c"), - text: "Background task completed.", + attemptId: RunAttemptId.make("attempt-claude-ambiguous-launch-alias"), + text: "Delegate two tasks.", attachments: [], - providerTurnOrdinal: 3, - messageCreatedBy: "agent", - messageCreationSource: "provider", }), ); - yield* awaitUntil( - () => harness.terminalEvents().length === 3, - "resume continuation terminal", + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: firstToolUseId, + name: "Agent", + input: { prompt: "Return the first child result.", run_in_background: true }, + }, + { + type: "tool_use", + id: secondToolUseId, + name: "Agent", + input: { prompt: "Return the second child result.", run_in_background: true }, + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000639", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000640", + text: secondChildText, + parentToolUseId: secondToolUseId, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000641", + text: firstChildText, + parentToolUseId: firstToolUseId, + }), + ); + for (const [taskId, description, uuid] of [ + [secondTaskId, "Second task starts first", "00000000-0000-4000-8000-000000000642"], + [firstTaskId, "First task starts second", "00000000-0000-4000-8000-000000000643"], + ] as const) { + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: taskId, + description, + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: description, + uuid, + session_id: WAKE_NATIVE_SESSION, + }), + ); + } + yield* awaitUntil(() => subagentEvents().length === 2, "ambiguous sibling registrations"); + assert.notIncludeMembers( + assistantItems().map((item) => item.text), + [firstChildText, secondChildText], ); - // The drained replay re-opens the row (running, stale result cleared) - // before the second notification terminalizes it again. - const statuses = subagentEvents().map((event) => event.subagent.status); - const firstCompleted = statuses.indexOf("completed"); - const reopenedIndex = statuses.lastIndexOf("running"); - assert.isAbove(reopenedIndex, firstCompleted); - assert.isNull(subagentEvents()[reopenedIndex]?.subagent.result); - // The drain-replayed reopen re-attributes the subagent to the - // continuation run performing the replay, so that run's ingestion - // fiber routes the resumed lifecycle and lingers past settle until - // the resumed task completes. - assert.equal(subagentEvents()[reopenedIndex]?.subagent.runId, "run-attempt-claude-wake-9c"); - // The execution node re-opens too, even though the registry entry was - // already pre-opened by the wake buffer before the drain replay. - const nodeStatuses = harness.events - .filter( - (event): event is Extract => - event.type === "node.updated" && - event.node.kind === "subagent" && - event.node.nativeItemRef?.nativeId === SUBAGENT_TASK_ID, - ) - .map((event) => event.node.status); - assert.isAbove(nodeStatuses.lastIndexOf("running"), nodeStatuses.indexOf("completed")); - const finalSubagent = subagentEvents().at(-1)?.subagent; - assert.equal(finalSubagent?.status, "completed"); - assert.equal(finalSubagent?.result, SECOND_SUMMARY); - // The completion keeps the resuming run's attribution. - assert.equal(finalSubagent?.runId, "run-attempt-claude-wake-9c"); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), - ), - ); - - it.effect( - "orders nonempty level, empty level, notification, and continuation drain without subagent projection", - () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - const nonemptyRoster = claudeSdkFrame({ - type: "system", - subtype: "background_tasks_changed", - tasks: [ - { - task_id: WAKE_TASK_ID, - description: "npm run build", - task_type: "local_bash", - }, - ], - uuid: "00000000-0000-4000-8000-000000000600", - session_id: WAKE_NATIVE_SESSION, - }); - const emptyRoster = claudeSdkFrame({ + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ type: "system", - subtype: "background_tasks_changed", - tasks: [], - uuid: "00000000-0000-4000-8000-000000000601", + subtype: "task_progress", + task_id: firstTaskId, + tool_use_id: firstToolUseId, + description: "Explicit progress alias available", + uuid: "00000000-0000-4000-8000-000000000644", session_id: WAKE_NATIVE_SESSION, - }); - const duplicateNotification = claudeSdkFrame({ + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ type: "system", subtype: "task_notification", - task_id: WAKE_TASK_ID, + task_id: secondTaskId, + tool_use_id: secondToolUseId, status: "completed", - output_file: "/tmp/task-wake-build-dup.log", - summary: "duplicate should not re-buffer", - uuid: "00000000-0000-4000-8000-000000000605", + output_file: "", + summary: "Second child notification fallback.", + uuid: "00000000-0000-4000-8000-000000000645", session_id: WAKE_NATIVE_SESSION, - }); - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-level-before-edge-a"), - text: "Run the build in the background.", - attachments: [], - }), - ); - // 1) Nonempty authoritative level admits local_bash to Waiting + - // wake eligibility. - yield* Queue.offer(harness.sdkMessages, nonemptyRoster); - yield* awaitUntil( - () => - providerThreadRosterEvents(harness.events).some( - (event) => (event.providerThread.pendingBackgroundTasks?.length ?? 0) > 0, - ), - "nonempty level populated Waiting roster", - ); - assert.deepEqual( - providerThreadRosterEvents(harness.events).at(-1)?.providerThread - .pendingBackgroundTasks ?? [], - [ - { - taskId: WAKE_TASK_ID, - description: "npm run build", - taskType: "local_bash", - }, - ], - ); - yield* Queue.offer(harness.sdkMessages, turnOneResult); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); - assert.isTrue(yield* harness.hasPendingBackgroundWork); - - // 2) Empty level clears Waiting but keeps wake eligibility so the - // later notification can still offer exactly one continuation. - yield* Queue.offer(harness.sdkMessages, emptyRoster); - yield* awaitUntil( - () => - providerThreadRosterEvents(harness.events).some( - (event) => - event.providerThread.status === "idle" && - (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0, - ), - "empty level cleared Waiting roster", - ); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - assert.lengthOf(harness.continuationRequests, 0); + }), + ); + yield* awaitUntil( + () => + assistantItems().some((item) => item.text === firstChildText) && + assistantItems().some((item) => item.text === secondChildText), + "ambiguous sibling buffers drained", + ); - // 3) First idle notification buffers and consumes eligibility. The - // following native assistant frame proves Claude began a wake turn. - yield* Queue.offer(harness.sdkMessages, wakeNotification); - yield* Queue.offer(harness.sdkMessages, wakeAssistant); - yield* awaitUntil( - () => harness.continuationRequests.length === 1, - "continuation after level-before-edge", - ); - assert.equal(harness.continuationRequests[0]?.detail, WAKE_SUMMARY); + const firstChildThreadId = subagentEvents().find( + (event) => event.subagent.nativeTaskRef?.nativeId === firstTaskId, + )?.subagent.childThreadId; + const secondChildThreadId = subagentEvents().find( + (event) => event.subagent.nativeTaskRef?.nativeId === secondTaskId, + )?.subagent.childThreadId; + assert.ok(firstChildThreadId); + assert.ok(secondChildThreadId); + assert.equal( + assistantItems().find((item) => item.text === firstChildText)?.threadId, + firstChildThreadId, + ); + assert.equal( + assistantItems().find((item) => item.text === secondChildText)?.threadId, + secondChildThreadId, + ); + assert.isFalse( + assistantItems().some( + (item) => + item.threadId === harness.threadId && + (item.text === firstChildText || item.text === secondChildText), + ), + ); - // A duplicate notification must not re-buffer or re-offer. - yield* Queue.offer(harness.sdkMessages, duplicateNotification); - let settleYields = 0; - yield* awaitUntil(() => settleYields++ >= 50, "duplicate notification settle"); - assert.lengthOf(harness.continuationRequests, 1); - - // 4) Continuation drain classifies the buffered notification as - // local_bash (replay tombstone) and never fabricates a subagent. - yield* Queue.offer(harness.sdkMessages, wakeResult); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-level-before-edge-b"), - text: "Background task completed.", - attachments: [], - providerTurnOrdinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); - assert.equal(harness.terminalEvents()[1]?.status, "completed"); - assert.lengthOf(harness.continuationRequests, 1); - assert.isTrue( - harness.events.some( - (event) => - event.type === "message.updated" && event.message.text === WAKE_ASSISTANT_TEXT, - ), - ); - assert.isFalse( - harness.events.some( - (event) => - event.type === "subagent.updated" || - (event.type === "node.updated" && event.node.kind === "subagent"), - ), - ); - assert.isFalse( - harness.events.some( + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: firstToolUseId, + content: "First child terminal result.", + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000646", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* awaitUntil( + () => + subagentEvents().some( (event) => - event.type !== "provider_thread.updated" && - JSON.stringify(event).includes(WAKE_TASK_ID), + event.subagent.nativeTaskRef?.nativeId === firstTaskId && + event.subagent.status === "completed", ), - ); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), - ), - ); - - it.effect("resets Waiting roster and wake eligibility when the CLI process is replaced", () => - Effect.scoped( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocatorV2; - const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-v2-process-reset-", - }); - const processQueues: Array> = []; - const events: Array = []; - const continuationRequests: Array = []; - const adapter = makeClaudeAdapterV2({ - instanceId: CLAUDE_DEFAULT_INSTANCE_ID, - settings: DEFAULT_CLAUDE_SETTINGS, - environment: {}, - attachmentsDir, - fileSystem, - idAllocator, - continuationRequests: { - offer: (request) => - Effect.sync(() => { - continuationRequests.push(request); - }), - }, - queryRunner: { - allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), - open: () => - Effect.gen(function* () { - const sdkMessages = yield* Queue.unbounded(); - processQueues.push(sdkMessages); - return { - messages: Stream.fromQueue(sdkMessages), - offer: () => Effect.void, - setModel: () => Effect.void, - interrupt: Effect.void, - // End this process stream so openQuery can replace it. - close: Queue.shutdown(sdkMessages), - }; - }), - forkSession: () => Effect.die("unused forkSession"), - assertComplete: Effect.void, - }, - }); - const threadId = ThreadId.make("thread-claude-process-reset"); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-claude-process-reset"), - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - yield* runtime.events.pipe( - Stream.runForEach((event) => - Effect.sync(() => { - events.push(event); - }), - ), - Effect.forkScoped, + "late launch alias terminal result", ); - if (runtime.hasPendingBackgroundWork === undefined) { - return yield* Effect.die("Claude adapter runtime must expose hasPendingBackgroundWork."); - } - const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const now = yield* DateTime.now; - - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-process-reset-a"), - text: "Run the build in the background.", - attachments: [], - }), + assert.equal( + subagentEvents().findLast( + (event) => event.subagent.nativeTaskRef?.nativeId === firstTaskId, + )?.subagent.result, + "First child terminal result.", ); - assert.equal(processQueues.length, 1); - const firstProcess = processQueues[0]!; - yield* Queue.offer(firstProcess, wakeTaskStarted); - yield* Queue.offer(firstProcess, turnOneResult); - yield* awaitUntil( - () => events.some((event) => event.type === "turn.terminal"), - "first turn terminal", + assert.isFalse( + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.threadId === harness.threadId && + event.turnItem.nativeItemRef?.nativeId === firstToolUseId, + ), ); - assert.isTrue(yield* hasPendingBackgroundWork); - const alternateModel = { - ...CLAUDE_TEST_MODEL_SELECTION, - model: "claude-haiku-4-5-20251001", - } satisfies ModelSelection; - // ProviderTurnStartService marks the thread active before startTurn; - // the process-reset clear must preserve that status. - const activeProviderThread = { - ...providerThread, - status: "active" as const, - } satisfies OrchestrationV2ProviderThread; - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread: activeProviderThread, - now, - attemptId: RunAttemptId.make("attempt-claude-process-reset-b"), - text: "Continue after process restart.", - attachments: [], - providerTurnOrdinal: 2, - modelSelection: alternateModel, + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: secondToolUseId, + content: "Second child terminal result.", + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000647", + session_id: WAKE_NATIVE_SESSION, }), ); - assert.equal(processQueues.length, 2); - - // Process-scoped level resets to empty on CLI (re)start while the - // starting turn's provider thread remains active (not idle). yield* awaitUntil( () => - providerThreadRosterEvents(events).some( + subagentEvents().some( (event) => - event.providerThread.status === "active" && - (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0 && - // Prefer the post-replace clear over the initial empty thread. - event.providerThread.updatedAt !== undefined, + event.subagent.nativeTaskRef?.nativeId === secondTaskId && + event.subagent.result === "Second child terminal result.", ), - "roster cleared on process replace while remaining active", - ); - // After replace, the in-memory Waiting probe must be false even if a - // late empty-level event was already present before background work. - assert.isFalse(yield* hasPendingBackgroundWork); - const emptyActiveRosterEvents = providerThreadRosterEvents(events).filter( - (event) => - event.providerThread.status === "active" && - (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0, + "notification alias terminal result", ); - assert.isAtLeast(emptyActiveRosterEvents.length, 1); - assert.deepEqual( - emptyActiveRosterEvents.at(-1)?.providerThread.pendingBackgroundTasks ?? [], - [], + assert.isFalse( + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.threadId === harness.threadId && + event.turnItem.nativeItemRef?.nativeId === secondToolUseId, + ), ); - assert.equal(emptyActiveRosterEvents.at(-1)?.providerThread.status, "active"); - - // A late notification from the previous process must not wake after - // eligibility was reset with the process. Offer on the new process - // stream (the old queue is shut down). - const secondProcess = processQueues[1]!; - yield* Queue.offer(secondProcess, wakeNotification); - let settleYields = 0; - yield* awaitUntil(() => settleYields++ >= 50, "stale notification settle"); - assert.lengthOf(continuationRequests, 0); yield* Queue.offer( - secondProcess, + harness.sdkMessages, makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000602", - result: "Process restart turn finished.", + uuid: "00000000-0000-4000-8000-000000000648", + result: "Root delegation complete.", }), ); - yield* awaitUntil( - () => events.filter((event) => event.type === "turn.terminal").length === 2, - "second turn terminal", - ); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), ), ); - it.effect("admits only local_bash from a mixed background_tasks_changed snapshot", () => + it.effect("disables implicit launch aliases after pending launch overflow", () => Effect.scoped( Effect.gen(function* () { const harness = yield* makeWakeHarness; const now = yield* DateTime.now; - const SUBAGENT_TASK_ID = "task-mixed-snapshot-subagent"; - const SUBAGENT_TOOL_USE_ID = "toolu-mixed-snapshot-subagent"; - const mixedSnapshot = claudeSdkFrame({ - type: "system", - subtype: "background_tasks_changed", - tasks: [ - { - task_id: WAKE_TASK_ID, - description: "npm run build", - task_type: "local_bash", - }, - { - task_id: SUBAGENT_TASK_ID, - description: "Agent review", - task_type: "local_agent", - }, - { - task_id: "task-mixed-foreground-agent", - description: "Backgrounded foreground agent", - task_type: "local_agent", - }, - ], - uuid: "00000000-0000-4000-8000-000000000603", - session_id: WAKE_NATIVE_SESSION, - }); - const subagentTaskStarted = claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: SUBAGENT_TASK_ID, - tool_use_id: SUBAGENT_TOOL_USE_ID, - description: "Agent review", - subagent_type: "general-purpose", - task_type: "local_agent", - prompt: "Review the change.", - uuid: "00000000-0000-4000-8000-000000000604", - session_id: WAKE_NATIVE_SESSION, - }); + const launches = Array.from({ length: 33 }, (_, index) => ({ + taskId: `task-launch-overflow-${index + 1}`, + toolUseId: `toolu-launch-overflow-${index + 1}`, + })); + const finalLaunch = launches.at(-1)!; + const childText = "Only the final explicit overflow alias may receive this text."; + const subagentEvents = () => + harness.events.filter( + (event): event is Extract => + event.type === "subagent.updated", + ); + const assistantItems = () => + harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "assistant_message" + ? [event.turnItem] + : [], + ); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-claude-mixed-snapshot"), - text: "Background a bash task and a subagent.", + attemptId: RunAttemptId.make("attempt-claude-launch-overflow"), + text: "Delegate more tasks than implicit alias recovery can retain.", attachments: [], }), ); - yield* Queue.offer(harness.sdkMessages, subagentTaskStarted); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "assistant", + message: { + role: "assistant", + content: launches.map((launch) => ({ + type: "tool_use" as const, + id: launch.toolUseId, + name: "Agent", + input: { prompt: launch.taskId, run_in_background: true }, + })), + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000720", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000721", + text: childText, + parentToolUseId: finalLaunch.toolUseId, + }), + ); + for (const [index, launch] of launches.slice(1, -1).entries()) { + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: launch.taskId, + tool_use_id: launch.toolUseId, + description: launch.taskId, + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: launch.taskId, + uuid: `00000000-0000-4000-8000-${String(722 + index).padStart(12, "0")}`, + session_id: WAKE_NATIVE_SESSION, + }), + ); + } + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: launches[0]!.taskId, + description: "Evicted launch starts without an alias", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: launches[0]!.taskId, + uuid: "00000000-0000-4000-8000-000000000753", + session_id: WAKE_NATIVE_SESSION, + }), + ); yield* awaitUntil( () => - harness.events.some( - (event) => - event.type === "subagent.updated" && - event.subagent.nativeTaskRef?.nativeId === SUBAGENT_TASK_ID, + subagentEvents().some( + (event) => event.subagent.nativeTaskRef?.nativeId === launches[0]!.taskId, ), - "subagent projected normally", + "alias-free overflow task registration", + ); + assert.notInclude( + assistantItems().map((item) => item.text), + childText, + ); + + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: finalLaunch.taskId, + tool_use_id: finalLaunch.toolUseId, + description: "Final launch receives its explicit alias", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: finalLaunch.taskId, + uuid: "00000000-0000-4000-8000-000000000754", + session_id: WAKE_NATIVE_SESSION, + }), ); - yield* Queue.offer(harness.sdkMessages, mixedSnapshot); yield* awaitUntil( - () => - providerThreadRosterEvents(harness.events).some( - (event) => (event.providerThread.pendingBackgroundTasks?.length ?? 0) > 0, - ), - "roster after mixed snapshot", + () => assistantItems().some((item) => item.text === childText), + "explicit overflow alias buffer drain", ); - const roster = providerThreadRosterEvents(harness.events).at(-1)?.providerThread - .pendingBackgroundTasks; - assert.deepEqual(roster ?? [], [ - { - taskId: WAKE_TASK_ID, - description: "npm run build", - taskType: "local_bash", - }, - ]); - // Subagent lifecycle stays on the subagent path, not the Waiting roster. - assert.isTrue( - harness.events.some( - (event) => - event.type === "subagent.updated" && - event.subagent.nativeTaskRef?.nativeId === SUBAGENT_TASK_ID && - event.subagent.status === "running", + const evictedChildThreadId = subagentEvents().find( + (event) => event.subagent.nativeTaskRef?.nativeId === launches[0]!.taskId, + )?.subagent.childThreadId; + const finalChildThreadId = subagentEvents().find( + (event) => event.subagent.nativeTaskRef?.nativeId === finalLaunch.taskId, + )?.subagent.childThreadId; + assert.ok(evictedChildThreadId); + assert.ok(finalChildThreadId); + assert.equal( + assistantItems().find((item) => item.text === childText)?.threadId, + finalChildThreadId, + ); + assert.notEqual(finalChildThreadId, evictedChildThreadId); + assert.isFalse( + assistantItems().some( + (item) => item.threadId === harness.threadId && item.text === childText, ), ); - assert.isFalse((roster ?? []).some((task) => task.taskId === SUBAGENT_TASK_ID)); - yield* Queue.offer(harness.sdkMessages, turnOneResult); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); - assert.isTrue(yield* harness.hasPendingBackgroundWork); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000755", + result: "Overflow routing complete.", + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "overflow turn terminal"); }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), ), ); - it.effect( - "preserves buffered local_bash notification classification across model/policy query replacement", - () => - Effect.scoped( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocatorV2; - const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-v2-buffer-replace-", - }); - const processQueues: Array> = []; - const events: Array = []; - const continuationRequests: Array = []; - const adapter = makeClaudeAdapterV2({ - instanceId: CLAUDE_DEFAULT_INSTANCE_ID, - settings: DEFAULT_CLAUDE_SETTINGS, - environment: {}, - attachmentsDir, - fileSystem, - idAllocator, - continuationRequests: { - offer: (request) => - Effect.sync(() => { - continuationRequests.push(request); - }), - }, - queryRunner: { - allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), - open: () => - Effect.gen(function* () { - const sdkMessages = yield* Queue.unbounded(); - processQueues.push(sdkMessages); - return { - messages: Stream.fromQueue(sdkMessages), - offer: () => Effect.void, - setModel: () => Effect.void, - interrupt: Effect.void, - close: Queue.shutdown(sdkMessages), - }; - }), - forkSession: () => Effect.die("unused forkSession"), - assertComplete: Effect.void, - }, - }); - const threadId = ThreadId.make("thread-claude-buffer-replace"); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-claude-buffer-replace"), - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - yield* runtime.events.pipe( - Stream.runForEach((event) => - Effect.sync(() => { - events.push(event); - }), - ), - Effect.forkScoped, - ); - if (runtime.hasPendingBackgroundWork === undefined) { - return yield* Effect.die( - "Claude adapter runtime must expose hasPendingBackgroundWork.", - ); - } - const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const now = yield* DateTime.now; - - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-buffer-replace-a"), - text: "Run the build in the background.", - attachments: [], - }), - ); - assert.equal(processQueues.length, 1); - const firstProcess = processQueues[0]!; - yield* Queue.offer(firstProcess, wakeTaskStarted); - yield* Queue.offer(firstProcess, turnOneResult); - yield* awaitUntil( - () => events.some((event) => event.type === "turn.terminal"), - "first turn terminal", + it.effect("extracts text from direct content-block subagent results", () => + Effect.scoped( + Effect.gen(function* () { + const SUBAGENT_TASK_ID = "task-direct-content-blocks"; + const SUBAGENT_TOOL_USE_ID = "toolu-direct-content-blocks"; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const subagentEvents = () => + harness.events.filter( + (event): event is Extract => + event.type === "subagent.updated", ); - assert.isTrue(yield* hasPendingBackgroundWork); - // Idle completion notification buffers before any continuation runs. - yield* Queue.offer(firstProcess, wakeNotification); - let quietYields = 0; - yield* awaitUntil(() => quietYields++ >= 50, "notification-only quiet window"); - assert.lengthOf(continuationRequests, 0); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-direct-content-blocks"), + text: "Delegate this task.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: SUBAGENT_TASK_ID, + tool_use_id: SUBAGENT_TOOL_USE_ID, + description: "Delegated task", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Return the result.", + uuid: "00000000-0000-4000-8000-000000000206", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* awaitUntil(() => subagentEvents().length === 1, "subagent node created"); - // User turn changes model, replacing the query while the wake buffer - // stays queued for the later provider continuation. - const alternateModel = { - ...CLAUDE_TEST_MODEL_SELECTION, - model: "claude-haiku-4-5-20251001", - } satisfies ModelSelection; - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread: { ...providerThread, status: "active" }, - now, - attemptId: RunAttemptId.make("attempt-claude-buffer-replace-user"), - text: "Switch model while background work completes.", - attachments: [], - providerTurnOrdinal: 2, - modelSelection: alternateModel, - }), + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: SUBAGENT_TOOL_USE_ID, + content: [ + { type: "text", text: "First line." }, + { type: "text", text: "Second line." }, + ], + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000207", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* awaitUntil( + () => subagentEvents().at(-1)?.subagent.status === "completed", + "subagent terminal", + ); + + assert.equal(subagentEvents().at(-1)?.subagent.result, "First line.\nSecond line."); + + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000208", + result: "Delegation completed.", + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + + it.effect("gives batched sibling subagent results distinct native identities", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const firstTaskId = "task-batched-result-first"; + const firstToolUseId = "toolu-batched-result-first"; + const secondTaskId = "task-batched-result-second"; + const secondToolUseId = "toolu-batched-result-second"; + const subagentEvents = () => + harness.events.filter( + (event): event is Extract => + event.type === "subagent.updated", ); - assert.equal(processQueues.length, 2); - const secondProcess = processQueues[1]!; + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-batched-sibling-results"), + text: "Delegate two tasks.", + attachments: [], + }), + ); + for (const [taskId, toolUseId, uuid] of [ + [firstTaskId, firstToolUseId, "00000000-0000-4000-8000-000000000631"], + [secondTaskId, secondToolUseId, "00000000-0000-4000-8000-000000000632"], + ] as const) { yield* Queue.offer( - secondProcess, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000701", - result: "User turn finished after model switch.", + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: taskId, + tool_use_id: toolUseId, + description: `Run ${taskId}`, + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: `Complete ${taskId}.`, + uuid, + session_id: WAKE_NATIVE_SESSION, }), ); - yield* awaitUntil( - () => events.filter((event) => event.type === "turn.terminal").length === 2, - "user turn terminal after replace", - ); - // The terminal notification remains buffered for classification, but - // notification-only traffic no longer pins pending work. - assert.isFalse(yield* hasPendingBackgroundWork); - assert.lengthOf(continuationRequests, 0); + } + yield* awaitUntil(() => subagentEvents().length >= 2, "two sibling subagents"); + const firstChildThreadId = subagentEvents().find( + (event) => event.subagent.nativeTaskRef?.nativeId === firstTaskId, + )?.subagent.childThreadId; + const secondChildThreadId = subagentEvents().find( + (event) => event.subagent.nativeTaskRef?.nativeId === secondTaskId, + )?.subagent.childThreadId; + assert.ok(firstChildThreadId); + assert.ok(secondChildThreadId); - // Continuation drains the buffered local_bash notification with no - // fabricated subagent/node and attributes the wake result text. - yield* Queue.offer(secondProcess, wakeResult); - yield* awaitUntil(() => continuationRequests.length === 1, "continuation after result"); - assert.equal(continuationRequests[0]?.detail, WAKE_SUMMARY); - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread: { ...providerThread, status: "active" }, - now, - attemptId: RunAttemptId.make("attempt-claude-buffer-replace-cont"), - text: "Background task completed.", - attachments: [], - providerTurnOrdinal: 3, - modelSelection: alternateModel, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - yield* awaitUntil( - () => events.filter((event) => event.type === "turn.terminal").length === 3, - "continuation terminal after buffered drain", - ); - assert.isTrue( - events.some( - (event) => - event.type === "message.updated" && event.message.text === WAKE_RESULT_TEXT, - ), - ); - assert.isFalse( - events.some( - (event) => - event.type === "subagent.updated" || - (event.type === "node.updated" && event.node.kind === "subagent"), - ), - ); - // Must not re-project the opaque task id as anything but roster history. - assert.isFalse( - events.some( - (event) => - event.type !== "provider_thread.updated" && - JSON.stringify(event).includes(WAKE_TASK_ID), - ), - ); - assert.isFalse(yield* hasPendingBackgroundWork); - }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), - ), + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: firstToolUseId, + content: "First sibling result.", + }, + { + type: "tool_result", + tool_use_id: secondToolUseId, + content: "Second sibling result.", + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000633", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* awaitUntil( + () => + subagentEvents().filter((event) => event.subagent.status === "completed").length >= 2, + "two sibling results", + ); + + const childMessages = harness.events.filter( + (event): event is Extract => + event.type === "message.updated" && + (event.message.threadId === firstChildThreadId || + event.message.threadId === secondChildThreadId) && + (event.message.text === "First sibling result." || + event.message.text === "Second sibling result."), + ); + assert.deepEqual( + childMessages.map((event) => [event.message.threadId, event.message.text]), + [ + [firstChildThreadId, "First sibling result."], + [secondChildThreadId, "Second sibling result."], + ], + ); + assert.notEqual(childMessages[0]?.message.id, childMessages[1]?.message.id); + + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000634", + result: "Both delegated tasks completed.", + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), ); - it.effect( - "does not opaque-misclassify a buffered subagent notification across model/policy query replacement", - () => - Effect.scoped( - Effect.gen(function* () { - const SUBAGENT_TASK_ID = "task-buffer-replace-subagent"; - const SUBAGENT_TOOL_USE_ID = "toolu-buffer-replace-subagent"; - const SUBAGENT_SUMMARY = "SUB_BUFFER_REPLACE_DONE"; - const subagentTaskStarted = claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: SUBAGENT_TASK_ID, - tool_use_id: SUBAGENT_TOOL_USE_ID, - description: "Background research", - subagent_type: "general-purpose", - task_type: "local_agent", - prompt: "Research then return SUB_BUFFER_REPLACE_DONE.", - uuid: "00000000-0000-4000-8000-000000000801", - session_id: WAKE_NATIVE_SESSION, - }); - const subagentNotification = claudeSdkFrame({ - type: "system", - subtype: "task_notification", - task_id: SUBAGENT_TASK_ID, - tool_use_id: SUBAGENT_TOOL_USE_ID, - status: "completed", - output_file: "/tmp/task-buffer-replace-subagent.output", - summary: SUBAGENT_SUMMARY, - uuid: "00000000-0000-4000-8000-000000000802", + it.effect("buffers batched pre-alias Agent results by their own lineage", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const firstTaskId = "task-batched-prealias-first"; + const firstToolUseId = "toolu-batched-prealias-first"; + const secondTaskId = "task-batched-prealias-second"; + const secondToolUseId = "toolu-batched-prealias-second"; + const subagentEvents = () => + harness.events.filter( + (event): event is Extract => + event.type === "subagent.updated", + ); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-batched-prealias-results"), + text: "Delegate two tasks before their aliases arrive.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: firstToolUseId, + name: "Agent", + input: { prompt: "Return the first result.", run_in_background: false }, + }, + { + type: "tool_use", + id: secondToolUseId, + name: "Agent", + input: { prompt: "Return the second result.", run_in_background: false }, + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000701", session_id: WAKE_NATIVE_SESSION, - }); - const subagentAsyncAck = claudeSdkFrame({ + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ type: "user", message: { role: "user", content: [ { type: "tool_result", - tool_use_id: SUBAGENT_TOOL_USE_ID, - content: [{ type: "text", text: "Async agent launched successfully." }], + tool_use_id: firstToolUseId, + content: "First pre-alias result.", + }, + { + type: "tool_result", + tool_use_id: secondToolUseId, + content: "Second pre-alias result.", }, ], }, parent_tool_use_id: null, - uuid: "00000000-0000-4000-8000-000000000803", + uuid: "00000000-0000-4000-8000-000000000702", session_id: WAKE_NATIVE_SESSION, tool_use_result: { - isAsync: true, - status: "async_launched", - agentId: SUBAGENT_TASK_ID, - prompt: "Research then return SUB_BUFFER_REPLACE_DONE.", + agentId: "task-shared-batched-output", + content: [{ type: "text", text: "Shared structured result." }], }, - }); - - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocatorV2; - const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-v2-subagent-buffer-replace-", - }); - const processQueues: Array> = []; - const events: Array = []; - const continuationRequests: Array = []; - const adapter = makeClaudeAdapterV2({ - instanceId: CLAUDE_DEFAULT_INSTANCE_ID, - settings: DEFAULT_CLAUDE_SETTINGS, - environment: {}, - attachmentsDir, - fileSystem, - idAllocator, - continuationRequests: { - offer: (request) => - Effect.sync(() => { - continuationRequests.push(request); - }), - }, - queryRunner: { - allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), - open: () => - Effect.gen(function* () { - const sdkMessages = yield* Queue.unbounded(); - processQueues.push(sdkMessages); - return { - messages: Stream.fromQueue(sdkMessages), - offer: () => Effect.void, - setModel: () => Effect.void, - interrupt: Effect.void, - close: Queue.shutdown(sdkMessages), - }; - }), - forkSession: () => Effect.die("unused forkSession"), - assertComplete: Effect.void, - }, - }); - const threadId = ThreadId.make("thread-claude-subagent-buffer-replace"); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-claude-subagent-buffer-replace", - ), - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - yield* runtime.events.pipe( - Stream.runForEach((event) => - Effect.sync(() => { - events.push(event); - }), + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: secondTaskId, + tool_use_id: secondToolUseId, + description: "Second task starts first", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Return the second result.", + uuid: "00000000-0000-4000-8000-000000000703", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* awaitUntil( + () => + subagentEvents().some( + (event) => + event.subagent.nativeTaskRef?.nativeId === secondTaskId && + event.subagent.status === "completed", ), - Effect.forkScoped, - ); - if (runtime.hasPendingBackgroundWork === undefined) { - return yield* Effect.die( - "Claude adapter runtime must expose hasPendingBackgroundWork.", - ); - } - const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const subagentEvents = () => - events.filter( - (event): event is Extract => - event.type === "subagent.updated", - ); - const now = yield* DateTime.now; - - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-subagent-buffer-replace-a"), - text: "Spawn a background subagent and stop.", - attachments: [], - }), - ); - assert.equal(processQueues.length, 1); - const firstProcess = processQueues[0]!; - yield* Queue.offer(firstProcess, subagentTaskStarted); - yield* awaitUntil(() => subagentEvents().length >= 1, "subagent node created"); - assert.equal(subagentEvents()[0]?.subagent.status, "running"); - yield* Queue.offer(firstProcess, subagentAsyncAck); - yield* Queue.offer( - firstProcess, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000804", - result: "Spawned the subagent in the background.", - }), - ); - yield* awaitUntil( - () => events.some((event) => event.type === "turn.terminal"), - "first turn terminal", - ); - assert.isTrue(yield* hasPendingBackgroundWork); + "second batched pre-alias result", + ); + assert.equal( + subagentEvents().findLast( + (event) => event.subagent.nativeTaskRef?.nativeId === secondTaskId, + )?.subagent.result, + "Second pre-alias result.", + ); + assert.isFalse( + subagentEvents().some((event) => event.subagent.nativeTaskRef?.nativeId === firstTaskId), + ); - // Session-registered subagent completion buffers; no opaque tombstone. - yield* Queue.offer(firstProcess, subagentNotification); - yield* awaitUntil(() => continuationRequests.length === 1, "continuation after notify"); - assert.equal(continuationRequests[0]?.detail, SUBAGENT_SUMMARY); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: firstTaskId, + tool_use_id: firstToolUseId, + description: "First task starts second", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Return the first result.", + uuid: "00000000-0000-4000-8000-000000000704", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* awaitUntil( + () => + subagentEvents().some( + (event) => + event.subagent.nativeTaskRef?.nativeId === firstTaskId && + event.subagent.status === "completed", + ), + "first batched pre-alias result", + ); + assert.equal( + subagentEvents().findLast( + (event) => event.subagent.nativeTaskRef?.nativeId === firstTaskId, + )?.subagent.result, + "First pre-alias result.", + ); + assert.isFalse( + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.threadId === harness.threadId && + (event.turnItem.nativeItemRef?.nativeId === firstToolUseId || + event.turnItem.nativeItemRef?.nativeId === secondToolUseId), + ), + ); - // Model-changing user turn replaces the query while continuation stays - // queued. Process reset must not invent opaque classification for the - // buffered subagent notification. - const alternateModel = { - ...CLAUDE_TEST_MODEL_SELECTION, - model: "claude-haiku-4-5-20251001", - } satisfies ModelSelection; - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread: { ...providerThread, status: "active" }, - now, - attemptId: RunAttemptId.make("attempt-claude-subagent-buffer-replace-user"), - text: "Switch model while the subagent completes.", - attachments: [], - providerTurnOrdinal: 2, - modelSelection: alternateModel, - }), - ); - assert.equal(processQueues.length, 2); - const secondProcess = processQueues[1]!; - yield* Queue.offer( - secondProcess, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000805", - result: "User turn finished after model switch.", - }), - ); - yield* awaitUntil( - () => events.filter((event) => event.type === "turn.terminal").length === 2, - "user turn terminal after replace", - ); - assert.isTrue(yield* hasPendingBackgroundWork); - assert.lengthOf(continuationRequests, 1); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000705", + result: "Both pre-alias tasks completed.", + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); - yield* Queue.offer( - secondProcess, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000806", - result: "The subagent finished with SUB_BUFFER_REPLACE_DONE.", - }), - ); - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread: { ...providerThread, status: "active" }, - now, - attemptId: RunAttemptId.make("attempt-claude-subagent-buffer-replace-cont"), - text: "Background task completed.", - attachments: [], - providerTurnOrdinal: 3, - modelSelection: alternateModel, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - yield* awaitUntil( - () => events.filter((event) => event.type === "turn.terminal").length === 3, - "continuation terminal after buffered subagent drain", - ); + it.effect("releases the idle pin when a post-settle subagent stops without completing", () => + Effect.scoped( + Effect.gen(function* () { + const SUBAGENT_TASK_ID = "task-wake-subagent-stopped"; + const subagentTaskStarted = claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: SUBAGENT_TASK_ID, + tool_use_id: "toolu-wake-subagent-stopped", + description: "Long-running research task", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Investigate the flaky test.", + uuid: "00000000-0000-4000-8000-000000000301", + session_id: WAKE_NATIVE_SESSION, + }); + const subagentStoppedNotification = claudeSdkFrame({ + type: "system", + subtype: "task_notification", + task_id: SUBAGENT_TASK_ID, + tool_use_id: "toolu-wake-subagent-stopped", + status: "stopped", + output_file: "/tmp/task-wake-subagent-stopped.output", + summary: "Agent was stopped before finishing.", + uuid: "00000000-0000-4000-8000-000000000302", + session_id: WAKE_NATIVE_SESSION, + }); - const finalSubagent = subagentEvents().at(-1)?.subagent; - assert.equal(finalSubagent?.status, "completed"); - assert.equal(finalSubagent?.result, SUBAGENT_SUMMARY); - assert.equal(finalSubagent?.runId, subagentEvents()[0]?.subagent.runId); - const subagentNodeEvents = events.filter( - (event): event is Extract => - event.type === "node.updated" && - event.node.kind === "subagent" && - event.node.nativeItemRef?.nativeId === SUBAGENT_TASK_ID, + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const subagentEvents = () => + harness.events.filter( + (event): event is Extract => + event.type === "subagent.updated", ); - assert.equal(subagentNodeEvents.at(-1)?.node.status, "completed"); - assert.isFalse(yield* hasPendingBackgroundWork); - }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), - ), - ); - it.effect( - "clears process-scoped roster when same-native-thread replacement open fails after close", - () => + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-7a"), + text: "Spawn a background subagent and stop.", + attachments: [], + }), + ); + yield* Queue.offer(harness.sdkMessages, subagentTaskStarted); + yield* awaitUntil(() => subagentEvents().length >= 1, "subagent node created"); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000303", + result: "Spawned the subagent in the background.", + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + + yield* Queue.offer(harness.sdkMessages, subagentStoppedNotification); + yield* awaitUntil(() => harness.continuationRequests.length === 1, "continuation request"); + + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000304", + result: "The subagent was stopped.", + }), + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-7b"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); + + assert.equal(subagentEvents().at(-1)?.subagent.status, "cancelled"); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + + it.effect("re-opens a resumed subagent and hydrates its second result", () => + Effect.scoped( + Effect.gen(function* () { + const SUBAGENT_TASK_ID = "task-resume-subagent"; + const SUBAGENT_TOOL_USE_ID = "toolu-resume-subagent"; + const RESUME_TOOL_USE_ID = "toolu-resume-sendmessage"; + const FIRST_SUMMARY = "Timer armed. Waiting for it to complete."; + const SECOND_SUMMARY = "RESUME_DONE"; + const subagentTaskStarted = claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: SUBAGENT_TASK_ID, + tool_use_id: SUBAGENT_TOOL_USE_ID, + description: "Sleep then echo done token", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Run the shell command, then return exactly RESUME_DONE.", + uuid: "00000000-0000-4000-8000-000000000401", + session_id: WAKE_NATIVE_SESSION, + }); + const firstNotification = claudeSdkFrame({ + type: "system", + subtype: "task_notification", + task_id: SUBAGENT_TASK_ID, + tool_use_id: SUBAGENT_TOOL_USE_ID, + status: "completed", + output_file: "/tmp/task-resume-subagent.output", + summary: FIRST_SUMMARY, + uuid: "00000000-0000-4000-8000-000000000402", + session_id: WAKE_NATIVE_SESSION, + }); + // SendMessage to a completed subagent resumes it: the CLI re-emits + // task_started with the same task id but the SendMessage call's + // tool_use_id, not the original Agent launch's. + const resumeTaskStarted = claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: SUBAGENT_TASK_ID, + tool_use_id: RESUME_TOOL_USE_ID, + description: "Sleep then echo done token", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Run the shell command, then return exactly RESUME_DONE.", + uuid: "00000000-0000-4000-8000-000000000405", + session_id: WAKE_NATIVE_SESSION, + }); + const secondNotification = claudeSdkFrame({ + type: "system", + subtype: "task_notification", + task_id: SUBAGENT_TASK_ID, + tool_use_id: SUBAGENT_TOOL_USE_ID, + status: "completed", + output_file: "/tmp/task-resume-subagent.output", + summary: SECOND_SUMMARY, + uuid: "00000000-0000-4000-8000-000000000407", + session_id: WAKE_NATIVE_SESSION, + }); + + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const subagentEvents = () => + harness.events.filter( + (event): event is Extract => + event.type === "subagent.updated", + ); + const assistantItems = () => + harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "assistant_message" + ? [event.turnItem] + : [], + ); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-8a"), + text: "Spawn a background subagent and stop.", + attachments: [], + }), + ); + yield* Queue.offer(harness.sdkMessages, subagentTaskStarted); + yield* awaitUntil(() => subagentEvents().length >= 1, "subagent node created"); + const childThreadId = subagentEvents()[0]!.subagent.childThreadId; + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000403", + result: "Spawned the subagent in the background.", + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + + yield* Queue.offer(harness.sdkMessages, firstNotification); + yield* awaitUntil( + () => harness.continuationRequests.length === 1, + "first continuation request", + ); + assert.equal(harness.continuationRequests[0]?.detail, FIRST_SUMMARY); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000404", + result: "The subagent finished early.", + }), + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-8b"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); + assert.equal(subagentEvents().at(-1)?.subagent.status, "completed"); + assert.equal(subagentEvents().at(-1)?.subagent.result, FIRST_SUMMARY); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + + // A user turn nudges the completed subagent via SendMessage; the + // resume task_started re-opens the row across turn contexts (the new + // turn's maps are empty, so this exercises the session registry). + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-8c"), + text: "Nudge the subagent to finish.", + attachments: [], + providerTurnOrdinal: 3, + }), + ); + yield* awaitUntil(() => harness.offeredMessages.length === 2, "nudge prompt offered"); + // The resume rides on a SendMessage tool call: the CLI re-emits + // task_started with the SendMessage tool_use_id, and that tool call's + // result is a delivery ACK which must not terminalize the subagent. + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: RESUME_TOOL_USE_ID, + name: "SendMessage", + input: { agent_id: SUBAGENT_TASK_ID, message: "Continue and return the token." }, + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000411", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer(harness.sdkMessages, resumeTaskStarted); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: RESUME_TOOL_USE_ID, + content: [ + { + type: "text", + text: '{"success":true,"message":"Message sent to agent; it will resume."}', + }, + ], + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000412", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* awaitUntil( + () => subagentEvents().at(-1)?.subagent.status === "running", + "subagent re-opened", + ); + const reopened = subagentEvents().at(-1)?.subagent; + assert.isNull(reopened?.result); + // The reopen re-attributes the subagent to the resuming run: + // RunExecutionService routes parent-thread events by runId, and the + // launch run's ingestion fiber stops once its child subagents + // terminalize, so only the resuming run's fiber can persist the + // resumed lifecycle. + assert.equal(reopened?.runId, "run-attempt-claude-wake-8c"); + assert.notEqual(reopened?.runId, subagentEvents()[0]?.subagent.runId); + // A duplicate terminal frame from the completed generation must not + // close the resumed generation before its new output arrives. + yield* Queue.offer(harness.sdkMessages, firstNotification); + yield* Queue.offer( + harness.sdkMessages, + makeAssistantTextFrame({ + parentToolUseId: SUBAGENT_TOOL_USE_ID, + text: "Original launch alias still routes.", + uuid: "00000000-0000-4000-8000-000000000413", + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeAssistantTextFrame({ + parentToolUseId: RESUME_TOOL_USE_ID, + text: SECOND_SUMMARY, + uuid: "00000000-0000-4000-8000-000000000414", + }), + ); + yield* awaitUntil( + () => + assistantItems().some( + (item) => item.threadId === childThreadId && item.text === SECOND_SUMMARY, + ), + "resumed child text routed through both aliases", + ); + assert.equal(subagentEvents().at(-1)?.subagent.status, "running"); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000406", + result: "Nudged the subagent.", + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 3, "nudge turn terminal"); + // The re-opened subagent pins idle release again. + assert.isTrue(yield* harness.hasPendingBackgroundWork); + + // The resumed run's notification is wake evidence again and carries + // its summary as the continuation detail. + yield* Queue.offer(harness.sdkMessages, secondNotification); + yield* awaitUntil( + () => harness.continuationRequests.length === 2, + "second continuation request", + ); + assert.equal(harness.continuationRequests[1]?.detail, SECOND_SUMMARY); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000408", + result: "The subagent finished with RESUME_DONE.", + }), + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-8d"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 4, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* awaitUntil( + () => harness.terminalEvents().length === 4, + "second continuation terminal", + ); + + const finalSubagent = subagentEvents().at(-1)?.subagent; + assert.equal(finalSubagent?.status, "completed"); + assert.equal(finalSubagent?.result, SECOND_SUMMARY); + // The completion keeps the resuming run's attribution. + assert.equal(finalSubagent?.runId, "run-attempt-claude-wake-8c"); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + const childTexts = assistantItems() + .filter((item) => item.threadId === childThreadId) + .map((item) => item.text); + assert.equal( + childTexts.filter((text) => text === "Original launch alias still routes.").length, + 1, + ); + assert.equal(childTexts.filter((text) => text === SECOND_SUMMARY).length, 1); + const rootTexts = assistantItems() + .filter((item) => item.threadId === harness.threadId) + .map((item) => item.text); + assert.notInclude(rootTexts, "Original launch alias still routes."); + assert.notInclude(rootTexts, SECOND_SUMMARY); + + // Only task_started may re-open a terminal subagent: a late + // task_progress must not flip the row back to running or re-pin idle. + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-8e"), + text: "Anything new?", + attachments: [], + providerTurnOrdinal: 5, + }), + ); + yield* awaitUntil(() => harness.offeredMessages.length === 3, "final prompt offered"); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_progress", + task_id: SUBAGENT_TASK_ID, + tool_use_id: SUBAGENT_TOOL_USE_ID, + description: "Stale progress line", + uuid: "00000000-0000-4000-8000-000000000409", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000410", + result: "Nothing new.", + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 5, "final turn terminal"); + assert.equal(subagentEvents().at(-1)?.subagent.status, "completed"); + assert.equal(subagentEvents().at(-1)?.subagent.result, SECOND_SUMMARY); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + + it.effect("re-opens a resumed subagent whose task_started races past settle", () => + Effect.scoped( + Effect.gen(function* () { + const SUBAGENT_TASK_ID = "task-resume-postsettle"; + const SUBAGENT_TOOL_USE_ID = "toolu-resume-postsettle"; + const RESUME_TOOL_USE_ID = "toolu-resume-postsettle-sendmessage"; + const FIRST_SUMMARY = "Answered early."; + const SECOND_SUMMARY = "RESUME_SETTLE_DONE"; + const subagentTaskStarted = claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: SUBAGENT_TASK_ID, + tool_use_id: SUBAGENT_TOOL_USE_ID, + description: "Sleep then echo done token", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Run the shell command, then return exactly RESUME_SETTLE_DONE.", + uuid: "00000000-0000-4000-8000-000000000501", + session_id: WAKE_NATIVE_SESSION, + }); + const firstNotification = claudeSdkFrame({ + type: "system", + subtype: "task_notification", + task_id: SUBAGENT_TASK_ID, + tool_use_id: SUBAGENT_TOOL_USE_ID, + status: "completed", + output_file: "/tmp/task-resume-postsettle.output", + summary: FIRST_SUMMARY, + uuid: "00000000-0000-4000-8000-000000000502", + session_id: WAKE_NATIVE_SESSION, + }); + const resumeTaskStarted = claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: SUBAGENT_TASK_ID, + tool_use_id: RESUME_TOOL_USE_ID, + description: "Sleep then echo done token", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Run the shell command, then return exactly RESUME_SETTLE_DONE.", + uuid: "00000000-0000-4000-8000-000000000505", + session_id: WAKE_NATIVE_SESSION, + }); + const secondNotification = claudeSdkFrame({ + type: "system", + subtype: "task_notification", + task_id: SUBAGENT_TASK_ID, + tool_use_id: SUBAGENT_TOOL_USE_ID, + status: "completed", + output_file: "/tmp/task-resume-postsettle.output", + summary: SECOND_SUMMARY, + uuid: "00000000-0000-4000-8000-000000000506", + session_id: WAKE_NATIVE_SESSION, + }); + + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const subagentEvents = () => + harness.events.filter( + (event): event is Extract => + event.type === "subagent.updated", + ); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-9a"), + text: "Spawn a background subagent and stop.", + attachments: [], + }), + ); + yield* Queue.offer(harness.sdkMessages, subagentTaskStarted); + yield* awaitUntil(() => subagentEvents().length >= 1, "subagent node created"); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000503", + result: "Spawned the subagent in the background.", + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + + yield* Queue.offer(harness.sdkMessages, firstNotification); + yield* awaitUntil( + () => harness.continuationRequests.length === 1, + "first continuation request", + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000504", + result: "The subagent answered early.", + }), + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-9b"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); + assert.equal(subagentEvents().at(-1)?.subagent.status, "completed"); + assert.equal(subagentEvents().at(-1)?.subagent.result, FIRST_SUMMARY); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + + // The resume task_started races past settle: no turn is active, so it + // must re-open the session registry entry (pinning idle again) and + // buffer for replay. Its notification then counts as wake evidence + // and carries the new summary as the continuation detail. The resume + // rides on a SendMessage tool call whose frames race past settle too; + // on drain replay the SendMessage tool_result is a delivery ACK and + // must not terminalize the re-opened subagent. + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: RESUME_TOOL_USE_ID, + name: "SendMessage", + input: { agent_id: SUBAGENT_TASK_ID, message: "Continue and return the token." }, + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000508", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer(harness.sdkMessages, resumeTaskStarted); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: RESUME_TOOL_USE_ID, + content: [ + { + type: "text", + text: '{"success":true,"message":"Message sent to agent; it will resume."}', + }, + ], + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000509", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer(harness.sdkMessages, secondNotification); + yield* awaitUntil( + () => harness.continuationRequests.length === 2, + "second continuation request", + ); + assert.equal(harness.continuationRequests[1]?.detail, SECOND_SUMMARY); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000507", + result: "The subagent finished with RESUME_SETTLE_DONE.", + }), + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-9c"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 3, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* awaitUntil( + () => harness.terminalEvents().length === 3, + "resume continuation terminal", + ); + + // The drained replay re-opens the row (running, stale result cleared) + // before the second notification terminalizes it again. + const statuses = subagentEvents().map((event) => event.subagent.status); + const firstCompleted = statuses.indexOf("completed"); + const reopenedIndex = statuses.lastIndexOf("running"); + assert.isAbove(reopenedIndex, firstCompleted); + assert.isNull(subagentEvents()[reopenedIndex]?.subagent.result); + // The drain-replayed reopen re-attributes the subagent to the + // continuation run performing the replay, so that run's ingestion + // fiber routes the resumed lifecycle and lingers past settle until + // the resumed task completes. + assert.equal(subagentEvents()[reopenedIndex]?.subagent.runId, "run-attempt-claude-wake-9c"); + // The execution node re-opens too, even though the registry entry was + // already pre-opened by the wake buffer before the drain replay. + const nodeStatuses = harness.events + .filter( + (event): event is Extract => + event.type === "node.updated" && + event.node.kind === "subagent" && + event.node.nativeItemRef?.nativeId === SUBAGENT_TASK_ID, + ) + .map((event) => event.node.status); + assert.isAbove(nodeStatuses.lastIndexOf("running"), nodeStatuses.indexOf("completed")); + const finalSubagent = subagentEvents().at(-1)?.subagent; + assert.equal(finalSubagent?.status, "completed"); + assert.equal(finalSubagent?.result, SECOND_SUMMARY); + // The completion keeps the resuming run's attribution. + assert.equal(finalSubagent?.runId, "run-attempt-claude-wake-9c"); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + + it.effect( + "orders nonempty level, empty level, notification, and continuation drain without subagent projection", + () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const nonemptyRoster = claudeSdkFrame({ + type: "system", + subtype: "background_tasks_changed", + tasks: [ + { + task_id: WAKE_TASK_ID, + description: "npm run build", + task_type: "local_bash", + }, + ], + uuid: "00000000-0000-4000-8000-000000000600", + session_id: WAKE_NATIVE_SESSION, + }); + const emptyRoster = claudeSdkFrame({ + type: "system", + subtype: "background_tasks_changed", + tasks: [], + uuid: "00000000-0000-4000-8000-000000000601", + session_id: WAKE_NATIVE_SESSION, + }); + const duplicateNotification = claudeSdkFrame({ + type: "system", + subtype: "task_notification", + task_id: WAKE_TASK_ID, + status: "completed", + output_file: "/tmp/task-wake-build-dup.log", + summary: "duplicate should not re-buffer", + uuid: "00000000-0000-4000-8000-000000000605", + session_id: WAKE_NATIVE_SESSION, + }); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-level-before-edge-a"), + text: "Run the build in the background.", + attachments: [], + }), + ); + // 1) Nonempty authoritative level admits local_bash to Waiting + + // wake eligibility. + yield* Queue.offer(harness.sdkMessages, nonemptyRoster); + yield* awaitUntil( + () => + providerThreadRosterEvents(harness.events).some( + (event) => (event.providerThread.pendingBackgroundTasks?.length ?? 0) > 0, + ), + "nonempty level populated Waiting roster", + ); + assert.deepEqual( + providerThreadRosterEvents(harness.events).at(-1)?.providerThread + .pendingBackgroundTasks ?? [], + [ + { + taskId: WAKE_TASK_ID, + description: "npm run build", + taskType: "local_bash", + }, + ], + ); + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + + // 2) Empty level clears Waiting but keeps wake eligibility so the + // later notification can still offer exactly one continuation. + yield* Queue.offer(harness.sdkMessages, emptyRoster); + yield* awaitUntil( + () => + providerThreadRosterEvents(harness.events).some( + (event) => + event.providerThread.status === "idle" && + (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0, + ), + "empty level cleared Waiting roster", + ); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + assert.lengthOf(harness.continuationRequests, 0); + + // 3) First idle notification buffers and consumes eligibility. The + // following native assistant frame proves Claude began a wake turn. + yield* Queue.offer(harness.sdkMessages, wakeNotification); + yield* Queue.offer(harness.sdkMessages, wakeAssistant); + yield* awaitUntil( + () => harness.continuationRequests.length === 1, + "continuation after level-before-edge", + ); + assert.equal(harness.continuationRequests[0]?.detail, WAKE_SUMMARY); + + // A duplicate notification must not re-buffer or re-offer. + yield* Queue.offer(harness.sdkMessages, duplicateNotification); + let settleYields = 0; + yield* awaitUntil(() => settleYields++ >= 50, "duplicate notification settle"); + assert.lengthOf(harness.continuationRequests, 1); + + // 4) Continuation drain classifies the buffered notification as + // local_bash (replay tombstone) and never fabricates a subagent. + yield* Queue.offer(harness.sdkMessages, wakeResult); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-level-before-edge-b"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); + assert.equal(harness.terminalEvents()[1]?.status, "completed"); + assert.lengthOf(harness.continuationRequests, 1); + assert.isTrue( + harness.events.some( + (event) => + event.type === "message.updated" && event.message.text === WAKE_ASSISTANT_TEXT, + ), + ); + assert.isFalse( + harness.events.some( + (event) => + event.type === "subagent.updated" || + (event.type === "node.updated" && event.node.kind === "subagent"), + ), + ); + assert.isFalse( + harness.events.some( + (event) => + event.type !== "provider_thread.updated" && + JSON.stringify(event).includes(WAKE_TASK_ID), + ), + ); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + + it.effect("resets Waiting roster and wake eligibility when the CLI process is replaced", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocatorV2; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-v2-process-reset-", + }); + const processQueues: Array> = []; + const events: Array = []; + const continuationRequests: Array = []; + const adapter = makeClaudeAdapterV2({ + instanceId: CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir, + fileSystem, + idAllocator, + continuationRequests: { + offer: (request) => + Effect.sync(() => { + continuationRequests.push(request); + }), + }, + queryRunner: { + allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), + open: () => + Effect.gen(function* () { + const sdkMessages = yield* Queue.unbounded(); + processQueues.push(sdkMessages); + return { + messages: Stream.fromQueue(sdkMessages), + offer: () => Effect.void, + setModel: () => Effect.void, + interrupt: Effect.void, + // End this process stream so openQuery can replace it. + close: Queue.shutdown(sdkMessages), + }; + }), + forkSession: () => Effect.die("unused forkSession"), + assertComplete: Effect.void, + }, + }); + const threadId = ThreadId.make("thread-claude-process-reset"); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-claude-process-reset"), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + yield* runtime.events.pipe( + Stream.runForEach((event) => + Effect.sync(() => { + events.push(event); + }), + ), + Effect.forkScoped, + ); + if (runtime.hasPendingBackgroundWork === undefined) { + return yield* Effect.die("Claude adapter runtime must expose hasPendingBackgroundWork."); + } + const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; + const now = yield* DateTime.now; + + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-process-reset-a"), + text: "Run the build in the background.", + attachments: [], + }), + ); + assert.equal(processQueues.length, 1); + const firstProcess = processQueues[0]!; + yield* Queue.offer(firstProcess, wakeTaskStarted); + yield* Queue.offer(firstProcess, turnOneResult); + yield* awaitUntil( + () => events.some((event) => event.type === "turn.terminal"), + "first turn terminal", + ); + assert.isTrue(yield* hasPendingBackgroundWork); + + const alternateModel = { + ...CLAUDE_TEST_MODEL_SELECTION, + model: "claude-haiku-4-5-20251001", + } satisfies ModelSelection; + // ProviderTurnStartService marks the thread active before startTurn; + // the process-reset clear must preserve that status. + const activeProviderThread = { + ...providerThread, + status: "active" as const, + } satisfies OrchestrationV2ProviderThread; + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread: activeProviderThread, + now, + attemptId: RunAttemptId.make("attempt-claude-process-reset-b"), + text: "Continue after process restart.", + attachments: [], + providerTurnOrdinal: 2, + modelSelection: alternateModel, + }), + ); + assert.equal(processQueues.length, 2); + + // Process-scoped level resets to empty on CLI (re)start while the + // starting turn's provider thread remains active (not idle). + yield* awaitUntil( + () => + providerThreadRosterEvents(events).some( + (event) => + event.providerThread.status === "active" && + (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0 && + // Prefer the post-replace clear over the initial empty thread. + event.providerThread.updatedAt !== undefined, + ), + "roster cleared on process replace while remaining active", + ); + // After replace, the in-memory Waiting probe must be false even if a + // late empty-level event was already present before background work. + assert.isFalse(yield* hasPendingBackgroundWork); + const emptyActiveRosterEvents = providerThreadRosterEvents(events).filter( + (event) => + event.providerThread.status === "active" && + (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0, + ); + assert.isAtLeast(emptyActiveRosterEvents.length, 1); + assert.deepEqual( + emptyActiveRosterEvents.at(-1)?.providerThread.pendingBackgroundTasks ?? [], + [], + ); + assert.equal(emptyActiveRosterEvents.at(-1)?.providerThread.status, "active"); + + // A late notification from the previous process must not wake after + // eligibility was reset with the process. Offer on the new process + // stream (the old queue is shut down). + const secondProcess = processQueues[1]!; + yield* Queue.offer(secondProcess, wakeNotification); + let settleYields = 0; + yield* awaitUntil(() => settleYields++ >= 50, "stale notification settle"); + assert.lengthOf(continuationRequests, 0); + + yield* Queue.offer( + secondProcess, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000602", + result: "Process restart turn finished.", + }), + ); + yield* awaitUntil( + () => events.filter((event) => event.type === "turn.terminal").length === 2, + "second turn terminal", + ); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + + it.effect("admits only local_bash from a mixed background_tasks_changed snapshot", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const SUBAGENT_TASK_ID = "task-mixed-snapshot-subagent"; + const SUBAGENT_TOOL_USE_ID = "toolu-mixed-snapshot-subagent"; + const mixedSnapshot = claudeSdkFrame({ + type: "system", + subtype: "background_tasks_changed", + tasks: [ + { + task_id: WAKE_TASK_ID, + description: "npm run build", + task_type: "local_bash", + }, + { + task_id: SUBAGENT_TASK_ID, + description: "Agent review", + task_type: "local_agent", + }, + { + task_id: "task-mixed-foreground-agent", + description: "Backgrounded foreground agent", + task_type: "local_agent", + }, + ], + uuid: "00000000-0000-4000-8000-000000000603", + session_id: WAKE_NATIVE_SESSION, + }); + const subagentTaskStarted = claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: SUBAGENT_TASK_ID, + tool_use_id: SUBAGENT_TOOL_USE_ID, + description: "Agent review", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Review the change.", + uuid: "00000000-0000-4000-8000-000000000604", + session_id: WAKE_NATIVE_SESSION, + }); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-mixed-snapshot"), + text: "Background a bash task and a subagent.", + attachments: [], + }), + ); + yield* Queue.offer(harness.sdkMessages, subagentTaskStarted); + yield* awaitUntil( + () => + harness.events.some( + (event) => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === SUBAGENT_TASK_ID, + ), + "subagent projected normally", + ); + yield* Queue.offer(harness.sdkMessages, mixedSnapshot); + yield* awaitUntil( + () => + providerThreadRosterEvents(harness.events).some( + (event) => (event.providerThread.pendingBackgroundTasks?.length ?? 0) > 0, + ), + "roster after mixed snapshot", + ); + + const roster = providerThreadRosterEvents(harness.events).at(-1)?.providerThread + .pendingBackgroundTasks; + assert.deepEqual(roster ?? [], [ + { + taskId: WAKE_TASK_ID, + description: "npm run build", + taskType: "local_bash", + }, + ]); + // Subagent lifecycle stays on the subagent path, not the Waiting roster. + assert.isTrue( + harness.events.some( + (event) => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === SUBAGENT_TASK_ID && + event.subagent.status === "running", + ), + ); + assert.isFalse((roster ?? []).some((task) => task.taskId === SUBAGENT_TASK_ID)); + + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + + it.effect( + "preserves buffered local_bash notification classification across model/policy query replacement", + () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocatorV2; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-v2-buffer-replace-", + }); + const processQueues: Array> = []; + const events: Array = []; + const continuationRequests: Array = []; + const adapter = makeClaudeAdapterV2({ + instanceId: CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir, + fileSystem, + idAllocator, + continuationRequests: { + offer: (request) => + Effect.sync(() => { + continuationRequests.push(request); + }), + }, + queryRunner: { + allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), + open: () => + Effect.gen(function* () { + const sdkMessages = yield* Queue.unbounded(); + processQueues.push(sdkMessages); + return { + messages: Stream.fromQueue(sdkMessages), + offer: () => Effect.void, + setModel: () => Effect.void, + interrupt: Effect.void, + close: Queue.shutdown(sdkMessages), + }; + }), + forkSession: () => Effect.die("unused forkSession"), + assertComplete: Effect.void, + }, + }); + const threadId = ThreadId.make("thread-claude-buffer-replace"); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-claude-buffer-replace"), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + yield* runtime.events.pipe( + Stream.runForEach((event) => + Effect.sync(() => { + events.push(event); + }), + ), + Effect.forkScoped, + ); + if (runtime.hasPendingBackgroundWork === undefined) { + return yield* Effect.die( + "Claude adapter runtime must expose hasPendingBackgroundWork.", + ); + } + const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; + const now = yield* DateTime.now; + + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-buffer-replace-a"), + text: "Run the build in the background.", + attachments: [], + }), + ); + assert.equal(processQueues.length, 1); + const firstProcess = processQueues[0]!; + yield* Queue.offer(firstProcess, wakeTaskStarted); + yield* Queue.offer(firstProcess, turnOneResult); + yield* awaitUntil( + () => events.some((event) => event.type === "turn.terminal"), + "first turn terminal", + ); + assert.isTrue(yield* hasPendingBackgroundWork); + + // Idle completion notification buffers before any continuation runs. + yield* Queue.offer(firstProcess, wakeNotification); + let quietYields = 0; + yield* awaitUntil(() => quietYields++ >= 50, "notification-only quiet window"); + assert.lengthOf(continuationRequests, 0); + + // User turn changes model, replacing the query while the wake buffer + // stays queued for the later provider continuation. + const alternateModel = { + ...CLAUDE_TEST_MODEL_SELECTION, + model: "claude-haiku-4-5-20251001", + } satisfies ModelSelection; + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread: { ...providerThread, status: "active" }, + now, + attemptId: RunAttemptId.make("attempt-claude-buffer-replace-user"), + text: "Switch model while background work completes.", + attachments: [], + providerTurnOrdinal: 2, + modelSelection: alternateModel, + }), + ); + assert.equal(processQueues.length, 2); + const secondProcess = processQueues[1]!; + yield* Queue.offer( + secondProcess, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000701", + result: "User turn finished after model switch.", + }), + ); + yield* awaitUntil( + () => events.filter((event) => event.type === "turn.terminal").length === 2, + "user turn terminal after replace", + ); + // The terminal notification remains buffered for classification, but + // notification-only traffic no longer pins pending work. + assert.isFalse(yield* hasPendingBackgroundWork); + assert.lengthOf(continuationRequests, 0); + + // Continuation drains the buffered local_bash notification with no + // fabricated subagent/node and attributes the wake result text. + yield* Queue.offer(secondProcess, wakeResult); + yield* awaitUntil(() => continuationRequests.length === 1, "continuation after result"); + assert.equal(continuationRequests[0]?.detail, WAKE_SUMMARY); + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread: { ...providerThread, status: "active" }, + now, + attemptId: RunAttemptId.make("attempt-claude-buffer-replace-cont"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 3, + modelSelection: alternateModel, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* awaitUntil( + () => events.filter((event) => event.type === "turn.terminal").length === 3, + "continuation terminal after buffered drain", + ); + assert.isTrue( + events.some( + (event) => + event.type === "message.updated" && event.message.text === WAKE_RESULT_TEXT, + ), + ); + assert.isFalse( + events.some( + (event) => + event.type === "subagent.updated" || + (event.type === "node.updated" && event.node.kind === "subagent"), + ), + ); + // Must not re-project the opaque task id as anything but roster history. + assert.isFalse( + events.some( + (event) => + event.type !== "provider_thread.updated" && + JSON.stringify(event).includes(WAKE_TASK_ID), + ), + ); + assert.isFalse(yield* hasPendingBackgroundWork); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + + it.effect( + "does not opaque-misclassify a buffered subagent notification across model/policy query replacement", + () => + Effect.scoped( + Effect.gen(function* () { + const SUBAGENT_TASK_ID = "task-buffer-replace-subagent"; + const SUBAGENT_TOOL_USE_ID = "toolu-buffer-replace-subagent"; + const SUBAGENT_SUMMARY = "SUB_BUFFER_REPLACE_DONE"; + const subagentTaskStarted = claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: SUBAGENT_TASK_ID, + tool_use_id: SUBAGENT_TOOL_USE_ID, + description: "Background research", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Research then return SUB_BUFFER_REPLACE_DONE.", + uuid: "00000000-0000-4000-8000-000000000801", + session_id: WAKE_NATIVE_SESSION, + }); + const subagentNotification = claudeSdkFrame({ + type: "system", + subtype: "task_notification", + task_id: SUBAGENT_TASK_ID, + tool_use_id: SUBAGENT_TOOL_USE_ID, + status: "completed", + output_file: "/tmp/task-buffer-replace-subagent.output", + summary: SUBAGENT_SUMMARY, + uuid: "00000000-0000-4000-8000-000000000802", + session_id: WAKE_NATIVE_SESSION, + }); + const subagentAsyncAck = claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: SUBAGENT_TOOL_USE_ID, + content: [{ type: "text", text: "Async agent launched successfully." }], + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000803", + session_id: WAKE_NATIVE_SESSION, + tool_use_result: { + isAsync: true, + status: "async_launched", + agentId: SUBAGENT_TASK_ID, + prompt: "Research then return SUB_BUFFER_REPLACE_DONE.", + }, + }); + + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocatorV2; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-v2-subagent-buffer-replace-", + }); + const processQueues: Array> = []; + const events: Array = []; + const continuationRequests: Array = []; + const adapter = makeClaudeAdapterV2({ + instanceId: CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir, + fileSystem, + idAllocator, + continuationRequests: { + offer: (request) => + Effect.sync(() => { + continuationRequests.push(request); + }), + }, + queryRunner: { + allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), + open: () => + Effect.gen(function* () { + const sdkMessages = yield* Queue.unbounded(); + processQueues.push(sdkMessages); + return { + messages: Stream.fromQueue(sdkMessages), + offer: () => Effect.void, + setModel: () => Effect.void, + interrupt: Effect.void, + close: Queue.shutdown(sdkMessages), + }; + }), + forkSession: () => Effect.die("unused forkSession"), + assertComplete: Effect.void, + }, + }); + const threadId = ThreadId.make("thread-claude-subagent-buffer-replace"); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make( + "provider-session-claude-subagent-buffer-replace", + ), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + yield* runtime.events.pipe( + Stream.runForEach((event) => + Effect.sync(() => { + events.push(event); + }), + ), + Effect.forkScoped, + ); + if (runtime.hasPendingBackgroundWork === undefined) { + return yield* Effect.die( + "Claude adapter runtime must expose hasPendingBackgroundWork.", + ); + } + const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; + const subagentEvents = () => + events.filter( + (event): event is Extract => + event.type === "subagent.updated", + ); + const now = yield* DateTime.now; + + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-subagent-buffer-replace-a"), + text: "Spawn a background subagent and stop.", + attachments: [], + }), + ); + assert.equal(processQueues.length, 1); + const firstProcess = processQueues[0]!; + yield* Queue.offer(firstProcess, subagentTaskStarted); + yield* awaitUntil(() => subagentEvents().length >= 1, "subagent node created"); + assert.equal(subagentEvents()[0]?.subagent.status, "running"); + yield* Queue.offer(firstProcess, subagentAsyncAck); + yield* Queue.offer( + firstProcess, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000804", + result: "Spawned the subagent in the background.", + }), + ); + yield* awaitUntil( + () => events.some((event) => event.type === "turn.terminal"), + "first turn terminal", + ); + assert.isTrue(yield* hasPendingBackgroundWork); + + // Session-registered subagent completion buffers; no opaque tombstone. + yield* Queue.offer(firstProcess, subagentNotification); + yield* awaitUntil(() => continuationRequests.length === 1, "continuation after notify"); + assert.equal(continuationRequests[0]?.detail, SUBAGENT_SUMMARY); + + // Model-changing user turn replaces the query while continuation stays + // queued. Process reset must not invent opaque classification for the + // buffered subagent notification. + const alternateModel = { + ...CLAUDE_TEST_MODEL_SELECTION, + model: "claude-haiku-4-5-20251001", + } satisfies ModelSelection; + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread: { ...providerThread, status: "active" }, + now, + attemptId: RunAttemptId.make("attempt-claude-subagent-buffer-replace-user"), + text: "Switch model while the subagent completes.", + attachments: [], + providerTurnOrdinal: 2, + modelSelection: alternateModel, + }), + ); + assert.equal(processQueues.length, 2); + const secondProcess = processQueues[1]!; + yield* Queue.offer( + secondProcess, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000805", + result: "User turn finished after model switch.", + }), + ); + yield* awaitUntil( + () => events.filter((event) => event.type === "turn.terminal").length === 2, + "user turn terminal after replace", + ); + assert.isTrue(yield* hasPendingBackgroundWork); + assert.lengthOf(continuationRequests, 1); + + yield* Queue.offer( + secondProcess, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000806", + result: "The subagent finished with SUB_BUFFER_REPLACE_DONE.", + }), + ); + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread: { ...providerThread, status: "active" }, + now, + attemptId: RunAttemptId.make("attempt-claude-subagent-buffer-replace-cont"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 3, + modelSelection: alternateModel, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* awaitUntil( + () => events.filter((event) => event.type === "turn.terminal").length === 3, + "continuation terminal after buffered subagent drain", + ); + + const finalSubagent = subagentEvents().at(-1)?.subagent; + assert.equal(finalSubagent?.status, "completed"); + assert.equal(finalSubagent?.result, SUBAGENT_SUMMARY); + assert.equal(finalSubagent?.runId, subagentEvents()[0]?.subagent.runId); + const subagentNodeEvents = events.filter( + (event): event is Extract => + event.type === "node.updated" && + event.node.kind === "subagent" && + event.node.nativeItemRef?.nativeId === SUBAGENT_TASK_ID, + ); + assert.equal(subagentNodeEvents.at(-1)?.node.status, "completed"); + assert.isFalse(yield* hasPendingBackgroundWork); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + + it.effect( + "clears process-scoped roster when same-native-thread replacement open fails after close", + () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocatorV2; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-v2-replace-open-fail-", + }); + let openCount = 0; + const processQueues: Array> = []; + const events: Array = []; + const adapter = makeClaudeAdapterV2({ + instanceId: CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir, + fileSystem, + idAllocator, + continuationRequests: { + offer: () => Effect.void, + }, + queryRunner: { + allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), + open: () => { + openCount += 1; + if (openCount === 2) { + return Effect.fail( + new ClaudeAgentSdkQueryRunnerError({ + method: "open", + cause: "forced replacement open failure", + }), + ); + } + return Effect.gen(function* () { + const sdkMessages = yield* Queue.unbounded(); + processQueues.push(sdkMessages); + return { + messages: Stream.fromQueue(sdkMessages), + offer: () => Effect.void, + setModel: () => Effect.void, + interrupt: Effect.void, + close: Queue.shutdown(sdkMessages), + }; + }); + }, + forkSession: () => Effect.die("unused forkSession"), + assertComplete: Effect.void, + }, + }); + const threadId = ThreadId.make("thread-claude-replace-open-fail"); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-claude-replace-open-fail"), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + yield* runtime.events.pipe( + Stream.runForEach((event) => + Effect.sync(() => { + events.push(event); + }), + ), + Effect.forkScoped, + ); + if (runtime.hasPendingBackgroundWork === undefined) { + return yield* Effect.die( + "Claude adapter runtime must expose hasPendingBackgroundWork.", + ); + } + const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; + const now = yield* DateTime.now; + + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-replace-open-fail-a"), + text: "Run the build in the background.", + attachments: [], + }), + ); + assert.equal(processQueues.length, 1); + yield* Queue.offer(processQueues[0]!, wakeTaskStarted); + yield* Queue.offer(processQueues[0]!, turnOneResult); + yield* awaitUntil( + () => events.some((event) => event.type === "turn.terminal"), + "first turn terminal", + ); + assert.isTrue(yield* hasPendingBackgroundWork); + + const alternateModel = { + ...CLAUDE_TEST_MODEL_SELECTION, + model: "claude-haiku-4-5-20251001", + } satisfies ModelSelection; + const failedStart = yield* runtime + .startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread: { ...providerThread, status: "active" }, + now, + attemptId: RunAttemptId.make("attempt-claude-replace-open-fail-b"), + text: "Replace process but fail open.", + attachments: [], + providerTurnOrdinal: 2, + modelSelection: alternateModel, + }), + ) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(failedStart)); + // Old process was closed before the failed open: roster must not stick. + yield* awaitUntil( + () => + providerThreadRosterEvents(events).some( + (event) => + event.providerThread.status === "idle" && + (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0, + ), + "roster cleared after failed same-thread replacement open", + ); + assert.isFalse(yield* hasPendingBackgroundWork); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + + it.effect( + "preserves buffered wake and continuation state when same-native-thread replacement open fails", + () => Effect.scoped( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocatorV2; const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-v2-replace-open-fail-", + prefix: "t3-claude-v2-replace-open-fail-wake-", }); let openCount = 0; const processQueues: Array> = []; const events: Array = []; + const continuationRequests: Array = []; const adapter = makeClaudeAdapterV2({ instanceId: CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, @@ -4443,384 +6863,1137 @@ describe("ClaudeAdapterV2 background wake turns", () => { fileSystem, idAllocator, continuationRequests: { - offer: () => Effect.void, + offer: (request) => + Effect.sync(() => { + continuationRequests.push(request); + }), + }, + queryRunner: { + allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), + open: () => { + openCount += 1; + if (openCount === 2) { + return Effect.fail( + new ClaudeAgentSdkQueryRunnerError({ + method: "open", + cause: "forced replacement open failure", + }), + ); + } + return Effect.gen(function* () { + const sdkMessages = yield* Queue.unbounded(); + processQueues.push(sdkMessages); + return { + messages: Stream.fromQueue(sdkMessages), + offer: () => Effect.void, + setModel: () => Effect.void, + interrupt: Effect.void, + close: Queue.shutdown(sdkMessages), + }; + }); + }, + forkSession: () => Effect.die("unused forkSession"), + assertComplete: Effect.void, + }, + }); + const threadId = ThreadId.make("thread-claude-replace-open-fail-wake"); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make( + "provider-session-claude-replace-open-fail-wake", + ), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + yield* runtime.events.pipe( + Stream.runForEach((event) => + Effect.sync(() => { + events.push(event); + }), + ), + Effect.forkScoped, + ); + if (runtime.hasPendingBackgroundWork === undefined) { + return yield* Effect.die( + "Claude adapter runtime must expose hasPendingBackgroundWork.", + ); + } + const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; + const now = yield* DateTime.now; + + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-replace-open-fail-wake-a"), + text: "Run the build in the background.", + attachments: [], + }), + ); + yield* Queue.offer(processQueues[0]!, wakeTaskStarted); + yield* Queue.offer(processQueues[0]!, turnOneResult); + yield* awaitUntil( + () => events.filter((event) => event.type === "turn.terminal").length === 1, + "first turn terminal", + ); + yield* Queue.offer(processQueues[0]!, wakeNotification); + yield* Queue.offer(processQueues[0]!, wakeAssistant); + yield* awaitUntil(() => continuationRequests.length === 1, "first continuation request"); + assert.isTrue(yield* hasPendingBackgroundWork); + + const alternateModel = { + ...CLAUDE_TEST_MODEL_SELECTION, + model: "claude-haiku-4-5-20251001", + } satisfies ModelSelection; + const failedStart = yield* runtime + .startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread: { ...providerThread, status: "active" }, + now, + attemptId: RunAttemptId.make("attempt-claude-replace-open-fail-wake-b"), + text: "Replace process but fail open.", + attachments: [], + providerTurnOrdinal: 2, + modelSelection: alternateModel, + }), + ) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(failedStart)); + assert.isTrue(yield* hasPendingBackgroundWork); + assert.lengthOf(continuationRequests, 1); + + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread: { ...providerThread, status: "active" }, + now, + attemptId: RunAttemptId.make("attempt-claude-replace-open-fail-wake-c"), + text: "Retry after the failed replacement.", + attachments: [], + providerTurnOrdinal: 2, + modelSelection: alternateModel, + }), + ); + const retryProcess = processQueues[1]!; + const retryTaskId = "task-wake-build-after-retry"; + yield* Queue.offer( + retryProcess, + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: retryTaskId, + description: "npm run build after retry", + task_type: "local_bash", + uuid: "00000000-0000-4000-8000-000000000901", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + retryProcess, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000902", + result: "Kicked off the retry build in the background.", + }), + ); + yield* awaitUntil( + () => events.filter((event) => event.type === "turn.terminal").length === 2, + "retry turn terminal", + ); + + // The already-requested continuation survives the failed process + // replacement and drains the wake output after the user retry. + yield* Queue.offer(retryProcess, wakeResult); + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread: { ...providerThread, status: "active" }, + now, + attemptId: RunAttemptId.make("attempt-claude-replace-open-fail-wake-d"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 3, + modelSelection: alternateModel, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* awaitUntil( + () => events.filter((event) => event.type === "turn.terminal").length === 3, + "preserved continuation terminal", + ); + assert.lengthOf(continuationRequests, 1); + + yield* Queue.offer( + retryProcess, + claudeSdkFrame({ + type: "system", + subtype: "task_notification", + task_id: retryTaskId, + status: "completed", + output_file: "/tmp/task-wake-build-after-retry.log", + summary: "Retry build completed successfully", + uuid: "00000000-0000-4000-8000-000000000903", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + retryProcess, + claudeSdkFrame({ + type: "assistant", + message: { + role: "assistant", + content: [{ type: "text", text: "The retry build has finished." }], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000904", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* awaitUntil( + () => continuationRequests.length === 2, + "continuation request after retry", + ); + assert.equal(continuationRequests[1]?.detail, "Retry build completed successfully"); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + + it.effect("does not invent process reset state on a first-ever failed open", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocatorV2; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-v2-first-open-fail-", + }); + const events: Array = []; + const adapter = makeClaudeAdapterV2({ + instanceId: CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir, + fileSystem, + idAllocator, + continuationRequests: { + offer: () => Effect.void, + }, + queryRunner: { + allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), + open: () => + Effect.fail( + new ClaudeAgentSdkQueryRunnerError({ + method: "open", + cause: "forced first open failure", + }), + ), + forkSession: () => Effect.die("unused forkSession"), + assertComplete: Effect.void, + }, + }); + const threadId = ThreadId.make("thread-claude-first-open-fail"); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-claude-first-open-fail"), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + yield* runtime.events.pipe( + Stream.runForEach((event) => + Effect.sync(() => { + events.push(event); + }), + ), + Effect.forkScoped, + ); + const now = yield* DateTime.now; + const failedStart = yield* runtime + .startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-first-open-fail"), + text: "First open fails.", + attachments: [], + }), + ) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(failedStart)); + // No live process ever existed: do not emit a fabricated empty roster. + assert.lengthOf(providerThreadRosterEvents(events), 0); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + it.effect("releases pending work after a failed wake dispatch", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const siblingTaskId = "task-failed-wake-sibling"; + const failedToolUseId = "toolu-failed-wake-child-tool"; + const failedToolItems = () => + harness.events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === failedToolUseId + ? [event.turnItem] + : [], + ); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-failed-wake-dispatch"), + text: "Run the build in the background.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: WAKE_TASK_ID, + tool_use_id: "toolu-failed-wake-child", + description: "Background subagent", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Finish after the parent settles.", + uuid: "00000000-0000-4000-8000-000000000631", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: failedToolUseId, + name: "Bash", + input: { command: "printf unfinished" }, + }, + ], + }, + parent_tool_use_id: "toolu-failed-wake-child", + uuid: "00000000-0000-4000-8000-000000000630", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* awaitUntil( + () => failedToolItems().at(-1)?.status === "running", + "failed-wake child tool started", + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: siblingTaskId, + tool_use_id: "toolu-failed-wake-sibling", + description: "Still-running sibling", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Finish after the first sibling's wake fails.", + uuid: "00000000-0000-4000-8000-000000000632", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + yield* Queue.offer(harness.sdkMessages, wakeNotification); + yield* awaitUntil(() => harness.continuationRequests.length === 1, "continuation offer"); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + + const request = harness.continuationRequests[0]; + assert.isDefined(request?.dispatchIfCurrent); + let dispatchAttempts = 0; + const dispatch = Effect.sync(() => { + dispatchAttempts += 1; + }).pipe(Effect.andThen(Effect.fail("dispatch failure"))); + const exit = yield* Effect.exit(request!.dispatchIfCurrent!(dispatch)); + assert.isTrue(Exit.isFailure(exit)); + assert.equal(dispatchAttempts, 1); + yield* request!.failIfCurrent!(Cause.fail("dispatch failure")); + yield* awaitUntil( + () => + harness.events.findLast((event) => event.type === "subagent.updated")?.subagent + .status === "failed", + "failed subagent projection", + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_notification", + task_id: siblingTaskId, + status: "completed", + output_file: "/tmp/task-failed-wake-sibling.output", + summary: "The sibling completed after the failed wake.", + uuid: "00000000-0000-4000-8000-000000000633", + session_id: WAKE_NATIVE_SESSION, + }), + ); + + const failedSubagent = harness.events.findLast( + (event) => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === WAKE_TASK_ID, + ); + assert.equal(failedSubagent?.type, "subagent.updated"); + assert.equal( + failedSubagent?.type === "subagent.updated" ? failedSubagent.subagent.status : null, + "failed", + ); + assert.equal( + failedSubagent?.type === "subagent.updated" ? failedSubagent.subagent.result : null, + "Background task completion could not be delivered.", + ); + const failedSubagentId = + failedSubagent?.type === "subagent.updated" ? failedSubagent.subagent.id : null; + assert.isTrue( + harness.events.some( + (event) => + event.type === "node.updated" && + event.node.kind === "subagent" && + event.node.nativeItemRef?.nativeId === WAKE_TASK_ID && + event.node.status === "failed", + ), + ); + assert.equal(failedToolItems().at(-1)?.status, "failed"); + assert.equal(failedToolItems().filter((item) => item.status === "failed").length, 1); + assert.isTrue( + harness.events.some( + (event) => + event.type === "node.updated" && + event.node.kind === "root_turn" && + event.node.nativeItemRef?.nativeId === WAKE_TASK_ID && + event.node.status === "failed", + ), + ); + assert.isTrue( + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "subagent" && + event.turnItem.subagentId === failedSubagentId && + event.turnItem.status === "failed" && + event.turnItem.result === "Background task completion could not be delivered.", + ), + ); + assert.isTrue( + harness.events.some( + (event) => + event.type === "message.updated" && + event.message.text === "Background task completion could not be delivered.", + ), + ); + const siblingSubagent = harness.events.findLast( + (event) => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === siblingTaskId, + ); + assert.equal(siblingSubagent?.type, "subagent.updated"); + assert.equal( + siblingSubagent?.type === "subagent.updated" ? siblingSubagent.subagent.status : null, + "running", + ); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + + yield* awaitUntil( + () => harness.continuationRequests.length === 2, + "sibling continuation offer", + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu-failed-wake-child", + content: [{ type: "text", text: "Late failed-child launch result." }], + }, + ], + }, + parent_tool_use_id: null, + tool_use_result: { + agentId: WAKE_TASK_ID, + content: [{ type: "text", text: "Late failed-child launch result." }], + }, + uuid: "00000000-0000-4000-8000-000000000634", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Effect.yieldNow; + assert.isFalse( + harness.events.some( + (event) => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === WAKE_TASK_ID && + event.subagent.status === "completed", + ), + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-failed-wake-sibling"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu-failed-wake-child", + content: [{ type: "text", text: "Late active failed-child launch result." }], + }, + ], }, - queryRunner: { - allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), - open: () => { - openCount += 1; - if (openCount === 2) { - return Effect.fail( - new ClaudeAgentSdkQueryRunnerError({ - method: "open", - cause: "forced replacement open failure", - }), - ); - } - return Effect.gen(function* () { - const sdkMessages = yield* Queue.unbounded(); - processQueues.push(sdkMessages); - return { - messages: Stream.fromQueue(sdkMessages), - offer: () => Effect.void, - setModel: () => Effect.void, - interrupt: Effect.void, - close: Queue.shutdown(sdkMessages), - }; - }); - }, - forkSession: () => Effect.die("unused forkSession"), - assertComplete: Effect.void, + parent_tool_use_id: null, + tool_use_result: { + agentId: WAKE_TASK_ID, + content: [{ type: "text", text: "Late active failed-child launch result." }], }, - }); - const threadId = ThreadId.make("thread-claude-replace-open-fail"); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-claude-replace-open-fail"), - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - yield* runtime.events.pipe( - Stream.runForEach((event) => - Effect.sync(() => { - events.push(event); - }), - ), - Effect.forkScoped, + uuid: "00000000-0000-4000-8000-000000000636", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000635", + result: "The sibling completed after the failed wake.", + origin: { kind: "task-notification" }, + }), + ); + yield* awaitUntil( + () => harness.terminalEvents().length === 2, + "sibling continuation terminal", + ); + const completedSibling = harness.events.findLast( + (event): event is Extract => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === siblingTaskId, + ); + assert.equal(completedSibling?.subagent.status, "completed"); + assert.isFalse( + harness.events.some( + (event) => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === siblingTaskId && + event.subagent.status === "failed", + ), + ); + assert.isFalse( + harness.events.some( + (event) => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === WAKE_TASK_ID && + event.subagent.status === "completed", + ), + ); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + + it.effect("retains late subagent alias registration across settle", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const taskId = "task-postsettle-registration-race"; + const toolUseId = "toolu-postsettle-registration-race"; + const childText = "Child narration before the post-settle alias."; + const subagentEvents = () => + harness.events.filter( + (event): event is Extract => + event.type === "subagent.updated", + ); + const assistantItems = () => + harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "assistant_message" + ? [event.turnItem] + : [], ); - if (runtime.hasPendingBackgroundWork === undefined) { - return yield* Effect.die( - "Claude adapter runtime must expose hasPendingBackgroundWork.", - ); - } - const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const now = yield* DateTime.now; - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-replace-open-fail-a"), - text: "Run the build in the background.", - attachments: [], - }), + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-postsettle-registration-race-1"), + text: "Delegate before the alias is available.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: taskId, + description: "Post-settle registration race", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Finish after the parent settles.", + uuid: "00000000-0000-4000-8000-000000000625", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* awaitUntil(() => subagentEvents().length === 1, "subagent registration"); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000626", + result: "Root settled before alias registration.", + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + + yield* Queue.offer( + harness.sdkMessages, + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000627", + text: childText, + parentToolUseId: toolUseId, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_progress", + task_id: taskId, + tool_use_id: toolUseId, + description: " ", + uuid: "00000000-0000-4000-8000-000000000628", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_notification", + task_id: taskId, + status: "completed", + output_file: "/tmp/task-postsettle-registration-race.output", + summary: "Post-settle registration completed.", + uuid: "00000000-0000-4000-8000-000000000629", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000630", + result: "Post-settle registration completed.", + origin: { kind: "task-notification" }, + }), + ); + yield* awaitUntil(() => harness.continuationRequests.length === 1, "continuation offer"); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-postsettle-registration-race-2"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); + + const childThreadId = subagentEvents()[0]?.subagent.childThreadId; + assert.ok(childThreadId); + assert.equal( + assistantItems().filter( + (item) => item.threadId === childThreadId && item.text === childText, + ).length, + 1, + ); + assert.isFalse( + assistantItems().some( + (item) => item.threadId === harness.threadId && item.text === childText, + ), + ); + const completedProviderTurn = harness.events.findLast( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "completed", + ); + assert.equal(completedProviderTurn?.type, "provider_turn.updated"); + assert.isNotNull( + completedProviderTurn?.type === "provider_turn.updated" + ? completedProviderTurn.providerTurn.nativeTurnRef + : null, + ); + if ( + completedProviderTurn?.type === "provider_turn.updated" && + completedProviderTurn.providerTurn.nativeTurnRef !== null + ) { + assert.notEqual( + completedProviderTurn.providerTurn.nativeTurnRef.nativeId, + "00000000-0000-4000-8000-000000000627", ); - assert.equal(processQueues.length, 1); - yield* Queue.offer(processQueues[0]!, wakeTaskStarted); - yield* Queue.offer(processQueues[0]!, turnOneResult); - yield* awaitUntil( - () => events.some((event) => event.type === "turn.terminal"), - "first turn terminal", + } + assert.isFalse(yield* harness.hasPendingBackgroundWork); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + + it.effect("recovers the Agent launch alias when task_started omits tool_use_id", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const taskId = "task-optional-launch-alias"; + const toolUseId = "toolu-optional-launch-alias"; + const childText = "Child text buffered before an alias-free task_started frame."; + const subagentEvents = () => + harness.events.filter( + (event): event is Extract => + event.type === "subagent.updated", ); - assert.isTrue(yield* hasPendingBackgroundWork); + const assistantItems = () => + harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "assistant_message" + ? [event.turnItem] + : [], + ); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-optional-launch-alias"), + text: "Delegate this task.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: "task-before-pending-launch", + description: "Task start before any pending launch", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Start before the Agent tool-use frame.", + uuid: "00000000-0000-4000-8000-000000000634", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: toolUseId, + name: "Agent", + input: { prompt: "Return the child result.", run_in_background: true }, + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000635", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000636", + text: childText, + parentToolUseId: toolUseId, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: taskId, + description: "Optional launch alias", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Return the child result.", + uuid: "00000000-0000-4000-8000-000000000637", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* awaitUntil( + () => assistantItems().some((item) => item.text === childText), + "child text routed through recovered launch alias", + ); - const alternateModel = { - ...CLAUDE_TEST_MODEL_SELECTION, - model: "claude-haiku-4-5-20251001", - } satisfies ModelSelection; - const failedStart = yield* runtime - .startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread: { ...providerThread, status: "active" }, - now, - attemptId: RunAttemptId.make("attempt-claude-replace-open-fail-b"), - text: "Replace process but fail open.", - attachments: [], - providerTurnOrdinal: 2, - modelSelection: alternateModel, - }), - ) - .pipe(Effect.exit); - assert.isTrue(Exit.isFailure(failedStart)); - // Old process was closed before the failed open: roster must not stick. - yield* awaitUntil( - () => - providerThreadRosterEvents(events).some( - (event) => - event.providerThread.status === "idle" && - (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0, - ), - "roster cleared after failed same-thread replacement open", - ); - assert.isFalse(yield* hasPendingBackgroundWork); - }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), - ), + const childThreadId = subagentEvents().at(-1)?.subagent.childThreadId; + assert.ok(childThreadId); + assert.equal( + assistantItems().find((item) => item.text === childText)?.threadId, + childThreadId, + ); + assert.isFalse( + assistantItems().some( + (item) => item.threadId === harness.threadId && item.text === childText, + ), + ); + + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000638", + result: "Root delegation complete.", + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), ); - it.effect( - "clears buffered wake and continuation state when same-native-thread replacement open fails", - () => - Effect.scoped( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocatorV2; - const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-v2-replace-open-fail-wake-", - }); - let openCount = 0; - const processQueues: Array> = []; - const events: Array = []; - const continuationRequests: Array = []; - const adapter = makeClaudeAdapterV2({ - instanceId: CLAUDE_DEFAULT_INSTANCE_ID, - settings: DEFAULT_CLAUDE_SETTINGS, - environment: {}, - attachmentsDir, - fileSystem, - idAllocator, - continuationRequests: { - offer: (request) => - Effect.sync(() => { - continuationRequests.push(request); - }), - }, - queryRunner: { - allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), - open: () => { - openCount += 1; - if (openCount === 2) { - return Effect.fail( - new ClaudeAgentSdkQueryRunnerError({ - method: "open", - cause: "forced replacement open failure", - }), - ); - } - return Effect.gen(function* () { - const sdkMessages = yield* Queue.unbounded(); - processQueues.push(sdkMessages); - return { - messages: Stream.fromQueue(sdkMessages), - offer: () => Effect.void, - setModel: () => Effect.void, - interrupt: Effect.void, - close: Queue.shutdown(sdkMessages), - }; - }); - }, - forkSession: () => Effect.die("unused forkSession"), - assertComplete: Effect.void, - }, - }); - const threadId = ThreadId.make("thread-claude-replace-open-fail-wake"); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-claude-replace-open-fail-wake", - ), - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - yield* runtime.events.pipe( - Stream.runForEach((event) => - Effect.sync(() => { - events.push(event); - }), - ), - Effect.forkScoped, + it.effect("does not terminalize a resumed subagent from a cross-turn delivery ack", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const taskId = "task-cross-turn-resume-ack"; + const launchToolUseId = "toolu-cross-turn-resume-launch"; + const resumeToolUseId = "toolu-cross-turn-resume-message"; + const subagentEvents = () => + harness.events.filter( + (event): event is Extract => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === taskId, ); - if (runtime.hasPendingBackgroundWork === undefined) { - return yield* Effect.die( - "Claude adapter runtime must expose hasPendingBackgroundWork.", - ); - } - const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const now = yield* DateTime.now; - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-replace-open-fail-wake-a"), - text: "Run the build in the background.", - attachments: [], - }), - ); - yield* Queue.offer(processQueues[0]!, wakeTaskStarted); - yield* Queue.offer(processQueues[0]!, turnOneResult); - yield* awaitUntil( - () => events.filter((event) => event.type === "turn.terminal").length === 1, - "first turn terminal", - ); - yield* Queue.offer(processQueues[0]!, wakeNotification); - yield* Queue.offer(processQueues[0]!, wakeAssistant); - yield* awaitUntil(() => continuationRequests.length === 1, "first continuation request"); - assert.isTrue(yield* hasPendingBackgroundWork); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-cross-turn-resume-ack-1"), + text: "Launch and complete the subagent.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: taskId, + tool_use_id: launchToolUseId, + description: "Cross-turn resume ack", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Complete, then resume.", + uuid: "00000000-0000-4000-8000-000000000639", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_notification", + task_id: taskId, + tool_use_id: launchToolUseId, + status: "completed", + output_file: "/tmp/task-cross-turn-resume-ack.output", + summary: "Initial generation completed.", + uuid: "00000000-0000-4000-8000-000000000640", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000641", + result: "Initial generation complete.", + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + assert.equal(subagentEvents().at(-1)?.subagent.status, "completed"); - const alternateModel = { - ...CLAUDE_TEST_MODEL_SELECTION, - model: "claude-haiku-4-5-20251001", - } satisfies ModelSelection; - const failedStart = yield* runtime - .startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread: { ...providerThread, status: "active" }, - now, - attemptId: RunAttemptId.make("attempt-claude-replace-open-fail-wake-b"), - text: "Replace process but fail open.", - attachments: [], - providerTurnOrdinal: 2, - modelSelection: alternateModel, - }), - ) - .pipe(Effect.exit); - assert.isTrue(Exit.isFailure(failedStart)); - assert.isFalse(yield* hasPendingBackgroundWork); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-cross-turn-resume-ack-2"), + text: "Resume the subagent.", + attachments: [], + providerTurnOrdinal: 2, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: resumeToolUseId, + name: "SendMessage", + input: { agent_id: taskId, message: "Resume." }, + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000642", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: taskId, + tool_use_id: resumeToolUseId, + description: "Cross-turn resume ack", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Complete, then resume.", + uuid: "00000000-0000-4000-8000-000000000643", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000644", + result: "Resume sent before its acknowledgement arrived.", + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 2, "resume turn terminal"); + assert.equal(subagentEvents().at(-1)?.subagent.status, "running"); - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread: { ...providerThread, status: "active" }, - now, - attemptId: RunAttemptId.make("attempt-claude-replace-open-fail-wake-c"), - text: "Retry after the failed replacement.", - attachments: [], - providerTurnOrdinal: 2, - modelSelection: alternateModel, - }), - ); - const retryProcess = processQueues[1]!; - const retryTaskId = "task-wake-build-after-retry"; - yield* Queue.offer( - retryProcess, - claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: retryTaskId, - description: "npm run build after retry", - task_type: "local_bash", - uuid: "00000000-0000-4000-8000-000000000901", - session_id: WAKE_NATIVE_SESSION, - }), - ); - yield* Queue.offer( - retryProcess, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000902", - result: "Kicked off the retry build in the background.", - }), - ); - yield* awaitUntil( - () => events.filter((event) => event.type === "turn.terminal").length === 2, - "retry turn terminal", - ); - yield* Queue.offer( - retryProcess, - claudeSdkFrame({ - type: "system", - subtype: "task_notification", - task_id: retryTaskId, - status: "completed", - output_file: "/tmp/task-wake-build-after-retry.log", - summary: "Retry build completed successfully", - uuid: "00000000-0000-4000-8000-000000000903", - session_id: WAKE_NATIVE_SESSION, - }), - ); - yield* Queue.offer( - retryProcess, - claudeSdkFrame({ - type: "assistant", - message: { - role: "assistant", - content: [{ type: "text", text: "The retry build has finished." }], - }, - parent_tool_use_id: null, - uuid: "00000000-0000-4000-8000-000000000904", - session_id: WAKE_NATIVE_SESSION, - }), - ); - yield* awaitUntil( - () => continuationRequests.length === 2, - "continuation request after retry", - ); - assert.equal(continuationRequests[1]?.detail, "Retry build completed successfully"); - }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), - ), + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-cross-turn-resume-ack-3"), + text: "Observe the delayed acknowledgement.", + attachments: [], + providerTurnOrdinal: 3, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: resumeToolUseId, + content: '{"success":true,"message":"Message delivered."}', + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000645", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* awaitUntil( + () => + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === resumeToolUseId && + event.turnItem.status === "completed", + ), + "delivery acknowledgement projected as a tool result", + ); + assert.equal(subagentEvents().at(-1)?.subagent.status, "running"); + assert.isNull(subagentEvents().at(-1)?.subagent.result); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000646", + result: "Delayed acknowledgement observed.", + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 3, "ack turn terminal"); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), ); - it.effect("does not invent process reset state on a first-ever failed open", () => + it.effect("keeps a subagent tool ordinal stable across turn re-derivation", () => Effect.scoped( Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocatorV2; - const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-v2-first-open-fail-", - }); - const events: Array = []; - const adapter = makeClaudeAdapterV2({ - instanceId: CLAUDE_DEFAULT_INSTANCE_ID, - settings: DEFAULT_CLAUDE_SETTINGS, - environment: {}, - attachmentsDir, - fileSystem, - idAllocator, - continuationRequests: { - offer: () => Effect.void, - }, - queryRunner: { - allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), - open: () => - Effect.fail( - new ClaudeAgentSdkQueryRunnerError({ - method: "open", - cause: "forced first open failure", - }), - ), - forkSession: () => Effect.die("unused forkSession"), - assertComplete: Effect.void, - }, - }); - const threadId = ThreadId.make("thread-claude-first-open-fail"); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-claude-first-open-fail"), - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - yield* runtime.events.pipe( - Stream.runForEach((event) => - Effect.sync(() => { - events.push(event); - }), - ), - Effect.forkScoped, - ); + const harness = yield* makeWakeHarness; const now = yield* DateTime.now; - const failedStart = yield* runtime - .startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-first-open-fail"), - text: "First open fails.", - attachments: [], - }), - ) - .pipe(Effect.exit); - assert.isTrue(Exit.isFailure(failedStart)); - // No live process ever existed: do not emit a fabricated empty roster. - assert.lengthOf(providerThreadRosterEvents(events), 0); + const taskId = "task-cross-turn-tool-ordinal"; + const launchToolUseId = "toolu-cross-turn-tool-launch"; + const childToolUseId = "toolu-cross-turn-child-tool"; + const childToolItems = () => + harness.events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === childToolUseId + ? [event.turnItem] + : [], + ); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-cross-turn-tool-ordinal-1"), + text: "Launch a child tool call.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: taskId, + tool_use_id: launchToolUseId, + description: "Cross-turn child tool", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Run one tool across the turn boundary.", + uuid: "00000000-0000-4000-8000-000000000647", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: childToolUseId, + name: "Bash", + input: { command: "printf done" }, + }, + ], + }, + parent_tool_use_id: launchToolUseId, + uuid: "00000000-0000-4000-8000-000000000648", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* awaitUntil( + () => childToolItems().at(-1)?.status === "running", + "child tool started", + ); + const startedItem = childToolItems().at(-1); + assert.isDefined(startedItem); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000649", + result: "Root turn settled while the child tool ran.", + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + assert.equal(childToolItems().at(-1)?.status, "running"); + assert.isFalse(childToolItems().some((item) => item.status === "failed")); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-cross-turn-tool-ordinal-2"), + text: "Receive the child tool result.", + attachments: [], + providerTurnOrdinal: 2, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: childToolUseId, + content: "done", + }, + ], + }, + parent_tool_use_id: launchToolUseId, + uuid: "00000000-0000-4000-8000-000000000650", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* awaitUntil( + () => childToolItems().at(-1)?.status === "completed", + "child tool completed", + ); + const completedItem = childToolItems().at(-1); + assert.isDefined(completedItem); + assert.equal(completedItem?.id, startedItem?.id); + assert.equal(completedItem?.ordinal, startedItem?.ordinal); + assert.equal(completedItem?.threadId, startedItem?.threadId); + const childToolStatuses = childToolItems().map((item) => item.status); + assert.equal(childToolStatuses[0], "running"); + assert.equal(childToolStatuses.at(-1), "completed"); + assert.equal(childToolStatuses.filter((status) => status === "completed").length, 1); + assert.isFalse(childToolStatuses.includes("failed")); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000651", + result: "Child tool result received.", + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 2, "second turn terminal"); }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), ), ); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 416fda8b032..9822a343381 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -59,6 +59,7 @@ import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -105,9 +106,11 @@ import { } from "../ProviderAdapterDriver.ts"; import { type ProviderContinuationRequest, + PROVIDER_CONTINUATION_MESSAGE_TEXT, ProviderContinuationRequests, } from "../ProviderContinuationRequests.ts"; import { + hasSubagentPromptText, makeSubagentChildThread, makeSubagentConversationArtifacts, subagentThreadTitle, @@ -168,7 +171,7 @@ export const ClaudeProviderCapabilitiesV2 = { supportsApplyPatchApproval: false, approvalsHaveNativeRequestIds: true, approvalCallbacksAreLiveOnly: true, - approvalsCanOriginateFromSubagents: false, + approvalsCanOriginateFromSubagents: true, }, planning: { emitsPlanUpdated: false, @@ -1298,6 +1301,7 @@ const CLAUDE_KNOWN_TOOL_CLASSIFICATIONS: Record< notebookedit: { itemType: "file_change", requestKind: "file-change" }, read: { itemType: "dynamic_tool", requestKind: "file-read" }, task: { itemType: "dynamic_tool", requestKind: "command" }, + taskoutput: { itemType: "dynamic_tool", requestKind: "command" }, todowrite: { itemType: "dynamic_tool", requestKind: "command" }, toolsearch: { itemType: "dynamic_tool", requestKind: "command" }, webfetch: { itemType: "web_search", requestKind: "command" }, @@ -1557,6 +1561,19 @@ function isClaudeSubagentAsyncLaunchAck(output: ClaudeNativeToolOutput): boolean return claudeSubagentResultText(output).startsWith("Async agent launched successfully."); } +function claudeSubagentTaskIdFromToolOutput(output: ClaudeNativeToolOutput): string | undefined { + const value = claudeNativeToolOutputValue(output); + if ( + typeof value !== "object" || + value === null || + !("agentId" in value) || + typeof value.agentId !== "string" + ) { + return undefined; + } + return value.agentId; +} + function webSearchPatternsFromClaudeTool(input: { readonly toolInput: ClaudeNativeToolInput; readonly output: ClaudeNativeToolOutput; @@ -1718,47 +1735,52 @@ function claudeToolUseBlocksFromAssistantMessage( return message.message.content.filter(isClaudeToolUseContentBlock); } -function claudeToolResultBlocksFromAssistantMessage( - message: SDKMessage, -): ReadonlyArray { - if (message.type !== "assistant") { - return []; - } - return message.message.content.filter(isClaudeAssistantToolResultContentBlock); -} - -function claudeToolResultBlocksFromUserMessage( - message: SDKMessage, -): ReadonlyArray { - if (message.type !== "user" || typeof message.message.content === "string") { - return []; - } - return message.message.content.filter(isClaudeUserToolResultContentBlock); -} - function claudeToolResultEntriesFromMessage(message: SDKMessage): ReadonlyArray<{ + readonly message: SDKMessage; readonly toolResult: ClaudeToolResultContentBlock; readonly output: ClaudeNativeToolOutput; }> { - const assistantResults = claudeToolResultBlocksFromAssistantMessage(message).map( - (toolResult) => ({ toolResult, output: claudeNativeToolOutputFromToolResult(toolResult) }), - ); - const userResults = claudeToolResultBlocksFromUserMessage(message); - const structuredOutput = - message.type === "user" && userResults.length === 1 ? message.tool_use_result : undefined; - return [ - ...assistantResults, - ...userResults.map((toolResult) => ({ - toolResult, - output: - structuredOutput === undefined - ? claudeNativeToolOutputFromToolResult(toolResult) - : claudeNativeToolOutputFromStructuredResult({ - structuredOutput, - fallbackValue: outputFromClaudeToolResult(toolResult), - }), - })), - ]; + if (message.type === "assistant") { + return message.message.content + .filter(isClaudeAssistantToolResultContentBlock) + .map((toolResult) => ({ + message: { + ...message, + message: { + ...message.message, + content: [toolResult], + }, + }, + toolResult, + output: claudeNativeToolOutputFromToolResult(toolResult), + })); + } + if (message.type !== "user" || typeof message.message.content === "string") { + return []; + } + const userResults = message.message.content.filter(isClaudeUserToolResultContentBlock); + const structuredOutput = userResults.length === 1 ? message.tool_use_result : undefined; + const scopedMessage = + userResults.length === 1 + ? message + : (({ tool_use_result: _structuredOutput, ...rest }) => rest)(message); + return userResults.map((toolResult) => ({ + message: { + ...scopedMessage, + message: { + ...message.message, + content: [toolResult], + }, + }, + toolResult, + output: + structuredOutput === undefined + ? claudeNativeToolOutputFromToolResult(toolResult) + : claudeNativeToolOutputFromStructuredResult({ + structuredOutput, + fallbackValue: outputFromClaudeToolResult(toolResult), + }), + })); } function parentToolUseIdFromSdkMessage(message: SDKMessage): string | null { @@ -1960,6 +1982,7 @@ function buildAssistantArtifacts(input: { interface ActiveClaudeTurnContext { readonly input: ProviderAdapterV2TurnInput; + readonly nativeThreadId: string; readonly nativeTurnId: string; nativeMessageCursor: string | null; readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; @@ -1971,10 +1994,9 @@ interface ActiveClaudeTurnContext { emittedNativeItemIds: Set; }; readonly toolCalls: Map; + readonly pendingSubagentLaunchToolUseIds: Array; + subagentLaunchAliasInferenceDisabled: boolean; readonly ignoredTaskIds: Set; - readonly subagentsByTaskId: Map; - readonly subagentsByToolUseId: Map; - readonly subagentNodesByTaskId: Map; } interface ActiveClaudeProviderRetry { @@ -1986,14 +2008,147 @@ interface ActiveClaudeProviderRetry { interface ActiveClaudeSubagent { task: OrchestrationV2Subagent; + readonly nativeThreadId: string | null; readonly childThreadId: ThreadId; readonly childRootNodeId: OrchestrationV2ExecutionNode["id"]; readonly turnItemId: OrchestrationV2TurnItem["id"]; readonly turnItemOrdinal: number; + readonly providerThreadId: OrchestrationV2ProviderThread["id"]; + readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; + readonly launchToolUseId: string | null; nextChildItemOrdinal: number; + readonly childItemOrdinalsByNativeItemId: Map; + readonly activeToolCallsByNativeItemId: Map; progressItemOrdinal: number | null; progressStartedAt: DateTime.Utc | null; resultItemOrdinal: number | null; + readonly emittedTextNativeItemIds: Set; + emittedTerminalResultNativeItemIds: Set; + generation: number; + streamedTexts: ReadonlyArray; + terminalResultTexts: ReadonlyArray; +} + +interface ClaudeNativeThreadSubagentState { + readonly agentLaunchToolUseIds: Set; + readonly subagentsByTaskId: Map; + readonly taskIdByToolUseId: Map; + readonly pendingMessagesByToolUseId: Map>; + readonly pendingResumeTaskIds: Set; +} + +interface ClaudeSessionSubagentState { + readonly byNativeThreadId: Map; +} + +const MAX_PENDING_CLAUDE_SUBAGENT_LINEAGES = 32; +const MAX_CLAUDE_SUBAGENT_LAUNCH_TOOL_USE_IDS = 128; +const MAX_PENDING_CLAUDE_SUBAGENT_LAUNCH_TOOL_USE_IDS = 32; +const MAX_PENDING_CLAUDE_SUBAGENT_MESSAGES_PER_LINEAGE = 32; +const MAX_CLAUDE_SUBAGENT_TEXT_NATIVE_ITEM_IDS = 128; +const MAX_CLAUDE_SUBAGENT_STREAMED_TEXTS = 16; +const MAX_CLAUDE_SUBAGENT_TERMINAL_NATIVE_ITEM_IDS = 32; +const MAX_CLAUDE_SUBAGENT_TERMINAL_TEXTS = 16; +const CLAUDE_SUBAGENT_WAKE_DELIVERY_FAILURE = "Background task completion could not be delivered."; +const CLAUDE_SUBAGENT_SESSION_ENDED_FAILURE = + "Background task ended when its Claude session closed."; + +function emptyClaudeNativeThreadSubagentState(): ClaudeNativeThreadSubagentState { + return { + agentLaunchToolUseIds: new Set(), + subagentsByTaskId: new Map(), + taskIdByToolUseId: new Map(), + pendingMessagesByToolUseId: new Map(), + pendingResumeTaskIds: new Set(), + }; +} + +function rememberPendingSubagentLaunchToolUseId( + context: ActiveClaudeTurnContext, + toolUseId: string, +): void { + if (context.pendingSubagentLaunchToolUseIds.includes(toolUseId)) { + return; + } + if ( + context.pendingSubagentLaunchToolUseIds.length >= + MAX_PENDING_CLAUDE_SUBAGENT_LAUNCH_TOOL_USE_IDS + ) { + context.pendingSubagentLaunchToolUseIds.shift(); + context.subagentLaunchAliasInferenceDisabled = true; + } + context.pendingSubagentLaunchToolUseIds.push(toolUseId); +} + +function forgetPendingSubagentLaunchToolUseId( + context: ActiveClaudeTurnContext, + toolUseId: string, +): void { + const index = context.pendingSubagentLaunchToolUseIds.indexOf(toolUseId); + if (index >= 0) { + context.pendingSubagentLaunchToolUseIds.splice(index, 1); + } +} + +function takePendingSubagentLaunchToolUseId( + context: ActiveClaudeTurnContext, + explicitToolUseId: string | undefined, +): string | undefined { + if (explicitToolUseId !== undefined) { + const index = context.pendingSubagentLaunchToolUseIds.indexOf(explicitToolUseId); + if (index >= 0) { + context.pendingSubagentLaunchToolUseIds.splice(index, 1); + } + return explicitToolUseId; + } + + if (context.subagentLaunchAliasInferenceDisabled) { + context.subagentLaunchAliasInferenceDisabled = true; + context.pendingSubagentLaunchToolUseIds.splice(0); + return undefined; + } + + if (context.pendingSubagentLaunchToolUseIds.length === 0) { + return undefined; + } + + if (context.pendingSubagentLaunchToolUseIds.length > 1) { + context.subagentLaunchAliasInferenceDisabled = true; + context.pendingSubagentLaunchToolUseIds.splice(0); + return undefined; + } + + return context.pendingSubagentLaunchToolUseIds.shift(); +} + +function normalizeClaudeTerminalText(text: string): string { + return text.replaceAll("\r\n", "\n").trim(); +} + +function addToBoundedSet(current: ReadonlySet, value: A, maximumSize: number): Set { + const updated = new Set(current); + if (updated.has(value)) { + return updated; + } + if (updated.size >= maximumSize) { + const oldest = updated.values().next().value; + if (oldest !== undefined) { + updated.delete(oldest); + } + } + updated.add(value); + return updated; +} + +function addToBoundedTextList( + current: ReadonlyArray, + value: string, + maximumSize: number, +): ReadonlyArray { + if (current.includes(value)) { + return current; + } + return [...current.slice(-(maximumSize - 1)), value]; } interface ClaudeLiveQueryContext { @@ -2015,6 +2170,7 @@ interface ActiveClaudeToolCall { readonly parentNodeId: OrchestrationV2ExecutionNode["id"]; readonly ordinal: number; readonly startedAt: DateTime.Utc; + readonly subagentTaskId: string | null; } interface PendingClaudeRuntimeRequest { @@ -2117,14 +2273,22 @@ export function makeClaudeAdapterV2( // (Agent with run_in_background) can complete after the root turn // ended, and its task_notification must both count as wake evidence // and hydrate the original subagent node instead of being dropped. - const sessionSubagentsByTaskId = yield* Ref.make(new Map()); + const sessionSubagents = yield* Ref.make({ + byNativeThreadId: new Map(), + }); const wakeBuffers = yield* Ref.make( new Map< string, { readonly messages: ReadonlyArray; readonly detail: string | null } >(), ); - const requestedContinuations = yield* Ref.make(new Set()); + const continuationStateByNativeThread = yield* Ref.make( + new Map(), + ); + const failedWakeDrainByNativeThread = yield* Ref.make( + new Map>(), + ); + const sdkMessagePermit = yield* Semaphore.make(1); const runtimeContext = yield* Effect.context(); const runFork = Effect.runForkWith(runtimeContext); const runPromise = Effect.runPromiseWith(runtimeContext); @@ -2132,6 +2296,176 @@ export function makeClaudeAdapterV2( const emitProviderEvent = (event: ProviderAdapterV2Event) => Queue.offer(events, event).pipe(Effect.asVoid); + const resolveSessionSubagent = Effect.fnUntraced(function* (resolveInput: { + readonly nativeThreadId: string; + readonly taskId?: string; + readonly toolUseId?: string; + }) { + const state = yield* Ref.get(sessionSubagents); + const nativeThreadState = state.byNativeThreadId.get(resolveInput.nativeThreadId); + if (nativeThreadState === undefined) { + return undefined; + } + const directSubagent = + resolveInput.taskId === undefined + ? undefined + : nativeThreadState.subagentsByTaskId.get(resolveInput.taskId); + if (resolveInput.taskId !== undefined && directSubagent !== undefined) { + return { taskId: resolveInput.taskId, subagent: directSubagent }; + } + const taskId = + resolveInput.toolUseId === undefined + ? undefined + : nativeThreadState.taskIdByToolUseId.get(resolveInput.toolUseId); + if (taskId === undefined) { + return undefined; + } + const subagent = nativeThreadState.subagentsByTaskId.get(taskId); + return subagent === undefined ? undefined : { taskId, subagent }; + }); + + const rememberSessionSubagentLaunchToolUseId = Effect.fnUntraced(function* (input: { + readonly nativeThreadId: string; + readonly toolUseId: string; + }) { + yield* Ref.update(sessionSubagents, (state) => { + const existing = + state.byNativeThreadId.get(input.nativeThreadId) ?? + emptyClaudeNativeThreadSubagentState(); + const agentLaunchToolUseIds = addToBoundedSet( + existing.agentLaunchToolUseIds, + input.toolUseId, + MAX_CLAUDE_SUBAGENT_LAUNCH_TOOL_USE_IDS, + ); + const byNativeThreadId = new Map(state.byNativeThreadId); + byNativeThreadId.set(input.nativeThreadId, { + ...existing, + agentLaunchToolUseIds, + }); + return { byNativeThreadId }; + }); + }); + + const isSessionSubagentLaunchToolUseId = Effect.fnUntraced(function* (input: { + readonly nativeThreadId: string; + readonly toolUseId: string; + }) { + const state = yield* Ref.get(sessionSubagents); + return ( + state.byNativeThreadId + .get(input.nativeThreadId) + ?.agentLaunchToolUseIds.has(input.toolUseId) ?? false + ); + }); + + const registerSessionSubagentToolUseAlias = Effect.fnUntraced(function* (input: { + readonly nativeThreadId: string; + readonly taskId: string; + readonly toolUseId: string; + }) { + yield* Ref.update(sessionSubagents, (state) => { + const nativeThreadState = state.byNativeThreadId.get(input.nativeThreadId); + const subagent = nativeThreadState?.subagentsByTaskId.get(input.taskId); + if (nativeThreadState === undefined || subagent === undefined) { + return state; + } + const remembersLaunchToolUseId = + subagent.generation === 0 && subagent.launchToolUseId === null; + if ( + !remembersLaunchToolUseId && + nativeThreadState.taskIdByToolUseId.get(input.toolUseId) === input.taskId + ) { + return state; + } + const subagentsByTaskId = new Map(nativeThreadState.subagentsByTaskId); + if (remembersLaunchToolUseId) { + subagentsByTaskId.set(input.taskId, { + ...subagent, + launchToolUseId: input.toolUseId, + }); + } + const taskIdByToolUseId = new Map(nativeThreadState.taskIdByToolUseId); + taskIdByToolUseId.set(input.toolUseId, input.taskId); + const byNativeThreadId = new Map(state.byNativeThreadId); + byNativeThreadId.set(input.nativeThreadId, { + ...nativeThreadState, + subagentsByTaskId, + taskIdByToolUseId, + }); + return { byNativeThreadId }; + }); + }); + + const countSessionSubagents = Effect.fnUntraced(function* (nativeThreadId: string) { + const state = yield* Ref.get(sessionSubagents); + return state.byNativeThreadId.get(nativeThreadId)?.subagentsByTaskId.size ?? 0; + }); + + const bufferPendingSubagentMessage = Effect.fnUntraced(function* (bufferInput: { + readonly nativeThreadId: string; + readonly parentToolUseId: string; + readonly taskId?: string; + readonly message: SDKMessage; + }) { + return yield* Ref.modify(sessionSubagents, (state) => { + const existing = + state.byNativeThreadId.get(bufferInput.nativeThreadId) ?? + emptyClaudeNativeThreadSubagentState(); + const lineageId = bufferInput.taskId ?? bufferInput.parentToolUseId; + const pending = existing.pendingMessagesByToolUseId.get(lineageId); + if ( + pending === undefined && + existing.pendingMessagesByToolUseId.size >= MAX_PENDING_CLAUDE_SUBAGENT_LINEAGES + ) { + return [false, state] as const; + } + if ( + pending !== undefined && + pending.length >= MAX_PENDING_CLAUDE_SUBAGENT_MESSAGES_PER_LINEAGE + ) { + return [false, state] as const; + } + const pendingMessagesByToolUseId = new Map(existing.pendingMessagesByToolUseId); + pendingMessagesByToolUseId.set(lineageId, [...(pending ?? []), bufferInput.message]); + const byNativeThreadId = new Map(state.byNativeThreadId); + byNativeThreadId.set(bufferInput.nativeThreadId, { + ...existing, + pendingMessagesByToolUseId, + }); + return [true, { byNativeThreadId }] as const; + }); + }); + + const takePendingSubagentMessages = Effect.fnUntraced(function* (takeInput: { + readonly nativeThreadId: string; + readonly taskId: string; + readonly toolUseId?: string; + }) { + return yield* Ref.modify(sessionSubagents, (state) => { + const existing = state.byNativeThreadId.get(takeInput.nativeThreadId); + const lineageIds = new Set([ + takeInput.taskId, + ...(takeInput.toolUseId === undefined ? [] : [takeInput.toolUseId]), + ]); + const pending = [...lineageIds].flatMap( + (lineageId) => existing?.pendingMessagesByToolUseId.get(lineageId) ?? [], + ); + if (existing === undefined || pending.length === 0) { + return [pending, state] as const; + } + const pendingMessagesByToolUseId = new Map(existing.pendingMessagesByToolUseId); + for (const lineageId of lineageIds) { + pendingMessagesByToolUseId.delete(lineageId); + } + const byNativeThreadId = new Map(state.byNativeThreadId); + byNativeThreadId.set(takeInput.nativeThreadId, { + ...existing, + pendingMessagesByToolUseId, + }); + return [pending, { byNativeThreadId }] as const; + }); + }); + const rememberProviderThread = (providerThread: OrchestrationV2ProviderThread) => Effect.gen(function* () { const nativeThreadId = providerThread.nativeThreadRef?.nativeId; @@ -2395,11 +2729,20 @@ export function makeClaudeAdapterV2( updated.delete(nativeThreadId); return updated; }); - yield* Ref.update(requestedContinuations, (current) => { + yield* Ref.update(continuationStateByNativeThread, (current) => { + const existing = current.get(nativeThreadId); + const updated = new Map(current); + updated.set(nativeThreadId, { + generation: (existing?.generation ?? 0) + 1, + requested: false, + }); + return updated; + }); + yield* Ref.update(failedWakeDrainByNativeThread, (current) => { if (!current.has(nativeThreadId)) { return current; } - const updated = new Set(current); + const updated = new Map(current); updated.delete(nativeThreadId); return updated; }); @@ -2530,7 +2873,7 @@ export function makeClaudeAdapterV2( }); const buildToolCallArtifacts = (input: { - readonly context: ActiveClaudeTurnContext; + readonly context?: ActiveClaudeTurnContext; readonly nativeItemId: string; readonly toolName: string; readonly classification: ClaudeToolClassification; @@ -2571,8 +2914,9 @@ export function makeClaudeAdapterV2( kind: "tool_call", status: input.status, countsForRun: false, - providerThreadId: input.runId === null ? null : input.context.input.providerThread.id, - providerTurnId: input.runId === null ? null : input.context.providerTurnId, + providerThreadId: + input.runId === null ? null : (input.context?.input.providerThread.id ?? null), + providerTurnId: input.runId === null ? null : (input.context?.providerTurnId ?? null), nativeItemRef, runtimeRequestId: null, checkpointScopeId: null, @@ -2584,8 +2928,9 @@ export function makeClaudeAdapterV2( threadId: input.threadId, runId: input.runId, nodeId, - providerThreadId: input.runId === null ? null : input.context.input.providerThread.id, - providerTurnId: input.runId === null ? null : input.context.providerTurnId, + providerThreadId: + input.runId === null ? null : (input.context?.input.providerThread.id ?? null), + providerTurnId: input.runId === null ? null : (input.context?.providerTurnId ?? null), nativeItemRef, parentItemId: null, ordinal: input.ordinal, @@ -2677,6 +3022,7 @@ export function makeClaudeAdapterV2( readonly title?: string; readonly progress?: string; readonly result?: string; + readonly resultNativeItemId?: string; readonly status: Extract< OrchestrationV2ExecutionNode["status"], "running" | "completed" | "failed" | "cancelled" @@ -2685,163 +3031,199 @@ export function makeClaudeAdapterV2( }) { // The session registry lets a wake-replay turn (fresh context maps) // hydrate a subagent that was created by an earlier, settled turn. - const existingSubagent = - input.context.subagentsByTaskId.get(input.taskId) ?? - (input.toolUseId === undefined - ? undefined - : input.context.subagentsByToolUseId.get(input.toolUseId)) ?? - (yield* Ref.get(sessionSubagentsByTaskId)).get(input.taskId); + const resolvedSubagent = yield* resolveSessionSubagent({ + nativeThreadId: input.context.nativeThreadId, + taskId: input.taskId, + ...(input.toolUseId === undefined ? {} : { toolUseId: input.toolUseId }), + }); + const existingSubagent = resolvedSubagent?.subagent; if (existingSubagent === undefined && input.status !== "running") { return; } - // Status is monotone with one exception: task_started for a known - // task id is an authoritative CLI lifecycle event (SendMessage to a - // completed subagent resumes it and re-emits task_started with the - // same id), so it may re-open a terminal entry. A late or - // out-of-order task_progress must not. - const isReopen = - input.reopen === true && - existingSubagent !== undefined && - existingSubagent.task.status !== "running" && - input.status === "running"; - if ( - existingSubagent !== undefined && - existingSubagent.task.status !== "running" && - input.status === "running" && - !isReopen - ) { - return; - } - const lifecycleChanged = - existingSubagent === undefined || - existingSubagent.task.status !== input.status || - // A drain-replayed resume task_started finds the registry entry - // already pre-opened to running by bufferWakeMessage while the - // projection node still holds the old terminal status; re-emit - // the node lifecycle for authoritative task_started updates. - (input.reopen === true && input.status === "running"); - + const observedGeneration = existingSubagent?.generation; const now = yield* DateTime.now; const nativeItemId = `task:${input.taskId}`; - const nodeId = - existingSubagent?.task.id ?? - idAllocator.derive.nodeFromProviderItem({ - driver: CLAUDE_PROVIDER, - nativeItemId, - }); - const childRootNodeId = - existingSubagent?.childRootNodeId ?? - idAllocator.derive.nodeFromProviderItem({ - driver: CLAUDE_PROVIDER, - nativeItemId: `${nativeItemId}:thread-root`, - }); - const childThreadId = - existingSubagent?.childThreadId ?? - idAllocator.derive.threadFromProviderThread({ - driver: CLAUDE_PROVIDER, - nativeThreadId: `${input.context.input.providerThread.id}:${input.taskId}`, - }); - if (existingSubagent === undefined) { - input.context.subagentNodesByTaskId.set(input.taskId, nodeId); - } - const turnItemOrdinal = + const derivedNodeId = idAllocator.derive.nodeFromProviderItem({ + driver: CLAUDE_PROVIDER, + nativeItemId, + }); + const derivedChildRootNodeId = idAllocator.derive.nodeFromProviderItem({ + driver: CLAUDE_PROVIDER, + nativeItemId: `${nativeItemId}:thread-root`, + }); + const derivedChildThreadId = idAllocator.derive.threadFromProviderThread({ + driver: CLAUDE_PROVIDER, + nativeThreadId: `${input.context.input.providerThread.id}:${input.taskId}`, + }); + const derivedTurnItemOrdinal = existingSubagent?.turnItemOrdinal ?? (yield* resolveItemOrdinal(input.context, `${nativeItemId}:subagent`)); - // A resumed subagent's previous final answer and progress no longer - // represent its outcome; the next task_progress/task_notification - // carry the new ones. - const priorTask = - existingSubagent === undefined - ? undefined - : isReopen - ? (({ progress: _staleProgress, ...rest }) => ({ ...rest, result: null }))( - existingSubagent.task, - ) - : existingSubagent.task; - const task = { - ...(priorTask ?? { - id: nodeId, - threadId: input.context.input.threadId, - runId: input.context.input.runId, - parentNodeId: input.context.input.rootNodeId, - origin: "provider_native" as const, - createdBy: "agent" as const, - driver: CLAUDE_PROVIDER, - providerInstanceId: input.context.input.modelSelection.instanceId, - providerThreadId: null, - childThreadId, - nativeTaskRef: { - driver: CLAUDE_PROVIDER, - nativeId: input.taskId, - strength: "strong" as const, - }, - prompt: input.prompt ?? "", - title: input.title ?? null, - model: input.context.input.modelSelection.model, - result: null, - startedAt: now, - }), - status: input.status, - // A reopen replayed under a continuation run re-attributes the - // subagent to that run. RunExecutionService routes parent-thread - // events by runId, and only the resuming run's ingestion fiber is - // guaranteed alive (the launch run's fiber stops once its child - // subagents terminalize); attribution also enrolls the subagent - // in the resuming run's active-child tracking so its fiber - // outlives settle until the resumed task completes. - ...(input.reopen === true && - input.status === "running" && - existingSubagent !== undefined - ? { runId: input.context.input.runId } - : {}), - ...(input.prompt === undefined ? {} : { prompt: input.prompt }), - ...(input.title === undefined ? {} : { title: input.title }), - ...(input.progress === undefined ? {} : { progress: input.progress }), - ...(input.result === undefined ? {} : { result: input.result }), - completedAt: input.status === "running" ? null : now, - updatedAt: now, - } satisfies OrchestrationV2Subagent; - const subagent = { - task, - childThreadId, - childRootNodeId, - turnItemId: - existingSubagent?.turnItemId ?? - idAllocator.derive.turnItemFromProviderItem({ - driver: CLAUDE_PROVIDER, - nativeItemId: `${nativeItemId}:subagent`, - }), - turnItemOrdinal, - nextChildItemOrdinal: existingSubagent?.nextChildItemOrdinal ?? 100, - progressItemOrdinal: existingSubagent?.progressItemOrdinal ?? null, - progressStartedAt: existingSubagent?.progressStartedAt ?? null, - resultItemOrdinal: existingSubagent?.resultItemOrdinal ?? null, - } satisfies ActiveClaudeSubagent; - input.context.subagentsByTaskId.set(input.taskId, subagent); - if (input.toolUseId !== undefined) { - input.context.subagentsByToolUseId.set(input.toolUseId, subagent); - } - // The same terminal protection, applied atomically: a concurrent - // fiber (live stream vs continuation drain) may have terminalized - // the registry entry after this update's lookup read it. A resume - // re-open (task_started) bypasses it only when the registered entry - // is still the terminal generation the lookup resolved; if a - // concurrent fiber installed a newer terminal entry meanwhile, the - // re-open must not clobber its result. - yield* Ref.update(sessionSubagentsByTaskId, (current) => { - const registered = current.get(input.taskId); + const installation = yield* Ref.modify(sessionSubagents, (state) => { + const nativeThreadState = + state.byNativeThreadId.get(input.context.nativeThreadId) ?? + emptyClaudeNativeThreadSubagentState(); + const registered = nativeThreadState.subagentsByTaskId.get(input.taskId); + if ( + existingSubagent !== undefined && + (registered === undefined || registered.generation !== observedGeneration) + ) { + return [undefined, state] as const; + } + if (registered === undefined && input.status !== "running") { + return [undefined, state] as const; + } + const terminalNativeItemId = + input.status === "running" ? undefined : input.resultNativeItemId; + if ( + terminalNativeItemId !== undefined && + registered?.emittedTerminalResultNativeItemIds.has(terminalNativeItemId) === true + ) { + return [undefined, state] as const; + } + const isReopen = + input.reopen === true && + registered !== undefined && + registered.task.status !== "running" && + input.status === "running"; if ( registered !== undefined && registered.task.status !== "running" && input.status === "running" && - !(isReopen && registered === existingSubagent) + !isReopen ) { - return current; + return [undefined, state] as const; + } + const isNew = registered === undefined; + const lifecycleChanged = + isNew || + registered.task.status !== input.status || + (input.reopen === true && input.status === "running"); + const childThreadId = registered?.childThreadId ?? derivedChildThreadId; + const childRootNodeId = registered?.childRootNodeId ?? derivedChildRootNodeId; + const priorTask = + registered === undefined + ? undefined + : isReopen + ? (({ progress: _staleProgress, ...rest }) => ({ ...rest, result: null }))( + registered.task, + ) + : registered.task; + const task = { + ...(priorTask ?? { + id: derivedNodeId, + threadId: input.context.input.threadId, + runId: input.context.input.runId, + parentNodeId: input.context.input.rootNodeId, + origin: "provider_native" as const, + createdBy: "agent" as const, + driver: CLAUDE_PROVIDER, + providerInstanceId: input.context.input.modelSelection.instanceId, + providerThreadId: null, + childThreadId, + nativeTaskRef: { + driver: CLAUDE_PROVIDER, + nativeId: input.taskId, + strength: "strong" as const, + }, + prompt: input.prompt ?? "", + title: input.title ?? null, + model: input.context.input.modelSelection.model, + result: null, + startedAt: now, + }), + status: input.status, + ...(isReopen ? { runId: input.context.input.runId } : {}), + ...(input.prompt === undefined ? {} : { prompt: input.prompt }), + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.progress === undefined ? {} : { progress: input.progress }), + ...(input.result === undefined ? {} : { result: input.result }), + completedAt: input.status === "running" ? null : now, + updatedAt: now, + } satisfies OrchestrationV2Subagent; + // A task_progress frame can register a subagent before any frame + // carries its prompt, so the opening message waits for the first + // prompt that actually has text instead of being tied to isNew. + const emitPromptMessage = + hasSubagentPromptText(task.prompt) && !hasSubagentPromptText(priorTask?.prompt); + const subagent = { + ...(registered ?? { + childThreadId, + childRootNodeId, + nativeThreadId: input.context.nativeThreadId, + turnItemId: idAllocator.derive.turnItemFromProviderItem({ + driver: CLAUDE_PROVIDER, + nativeItemId: `${nativeItemId}:subagent`, + }), + turnItemOrdinal: derivedTurnItemOrdinal, + providerThreadId: input.context.input.providerThread.id, + providerTurnId: input.context.providerTurnId, + launchToolUseId: input.toolUseId ?? null, + nextChildItemOrdinal: 100, + childItemOrdinalsByNativeItemId: new Map(), + activeToolCallsByNativeItemId: new Map(), + progressItemOrdinal: null, + progressStartedAt: null, + resultItemOrdinal: null, + emittedTextNativeItemIds: new Set(), + emittedTerminalResultNativeItemIds: new Set(), + generation: 0, + streamedTexts: [], + terminalResultTexts: [], + }), + task, + providerThreadId: input.context.input.providerThread.id, + providerTurnId: input.context.providerTurnId, + ...(isReopen + ? { activeToolCallsByNativeItemId: new Map() } + : {}), + ...(terminalNativeItemId === undefined + ? {} + : { + emittedTerminalResultNativeItemIds: addToBoundedSet( + registered?.emittedTerminalResultNativeItemIds ?? new Set(), + terminalNativeItemId, + MAX_CLAUDE_SUBAGENT_TERMINAL_NATIVE_ITEM_IDS, + ), + }), + ...(isReopen + ? { + resultItemOrdinal: null, + generation: (registered?.generation ?? 0) + 1, + streamedTexts: [], + terminalResultTexts: [], + } + : {}), + } satisfies ActiveClaudeSubagent; + const subagentsByTaskId = new Map(nativeThreadState.subagentsByTaskId); + subagentsByTaskId.set(input.taskId, subagent); + const taskIdByToolUseId = new Map(nativeThreadState.taskIdByToolUseId); + if (input.toolUseId !== undefined) { + taskIdByToolUseId.set(input.toolUseId, input.taskId); } - return new Map(current).set(input.taskId, subagent); + const pendingResumeTaskIds = new Set(nativeThreadState.pendingResumeTaskIds); + pendingResumeTaskIds.delete(input.taskId); + const byNativeThreadId = new Map(state.byNativeThreadId); + byNativeThreadId.set(input.context.nativeThreadId, { + ...nativeThreadState, + subagentsByTaskId, + taskIdByToolUseId, + pendingResumeTaskIds, + }); + return [ + { subagent, isNew, lifecycleChanged, terminalNativeItemId, emitPromptMessage }, + { byNativeThreadId }, + ] as const; }); + if (installation === undefined) { + return; + } + const { subagent, isNew, lifecycleChanged, emitPromptMessage } = installation; + const task = subagent.task; + const nodeId = task.id; + const childRootNodeId = subagent.childRootNodeId; + const childThreadId = subagent.childThreadId; - if (existingSubagent === undefined) { + if (isNew) { const childThread = makeSubagentChildThread({ parentThread: input.context.input.appThread, childThreadId, @@ -2853,7 +3235,7 @@ export function makeClaudeAdapterV2( parentTitle: input.context.input.appThread.title, prompt: task.prompt, title: task.title, - ordinal: input.context.subagentsByTaskId.size, + ordinal: yield* countSessionSubagents(input.context.nativeThreadId), }), now, createdBy: "agent", @@ -2917,7 +3299,7 @@ export function makeClaudeAdapterV2( }, }); } - if (existingSubagent === undefined) { + if (emitPromptMessage) { const promptNativeItemId = `${nativeItemId}:prompt`; const promptArtifacts = makeSubagentConversationArtifacts({ messageId: idAllocator.derive.messageFromProviderItem({ @@ -2989,17 +3371,46 @@ export function makeClaudeAdapterV2( }); const progress = task.progress?.trim(); - if ( + const progressEmission = progress !== undefined && progress.length > 0 && (input.progress !== undefined || (lifecycleChanged && input.status !== "running")) - ) { + ? yield* Ref.modify(sessionSubagents, (state) => { + const nativeThreadState = state.byNativeThreadId.get( + input.context.nativeThreadId, + ); + const registered = nativeThreadState?.subagentsByTaskId.get(input.taskId); + if ( + nativeThreadState === undefined || + registered === undefined || + registered.generation !== subagent.generation + ) { + return [undefined, state] as const; + } + const ordinal = + registered.progressItemOrdinal ?? registered.nextChildItemOrdinal + 1; + const startedAt = registered.progressStartedAt ?? now; + const updatedSubagent = { + ...registered, + nextChildItemOrdinal: + registered.progressItemOrdinal === null + ? ordinal + : registered.nextChildItemOrdinal, + progressItemOrdinal: ordinal, + progressStartedAt: startedAt, + }; + const subagentsByTaskId = new Map(nativeThreadState.subagentsByTaskId); + subagentsByTaskId.set(input.taskId, updatedSubagent); + const byNativeThreadId = new Map(state.byNativeThreadId); + byNativeThreadId.set(input.context.nativeThreadId, { + ...nativeThreadState, + subagentsByTaskId, + }); + return [{ ordinal, startedAt }, { byNativeThreadId }] as const; + }) + : undefined; + if (progressEmission !== undefined && progress !== undefined) { const progressNativeItemId = `${nativeItemId}:progress`; - const progressItemOrdinal = - subagent.progressItemOrdinal ?? ++subagent.nextChildItemOrdinal; - const progressStartedAt = subagent.progressStartedAt ?? now; - subagent.progressItemOrdinal = progressItemOrdinal; - subagent.progressStartedAt = progressStartedAt; yield* emitProviderEvent({ type: "turn_item.updated", driver: CLAUDE_PROVIDER, @@ -3019,10 +3430,10 @@ export function makeClaudeAdapterV2( strength: "strong", }, parentItemId: null, - ordinal: progressItemOrdinal, + ordinal: progressEmission.ordinal, status: input.status, title: "Subagent progress", - startedAt: progressStartedAt, + startedAt: progressEmission.startedAt, completedAt: input.status === "running" ? null : now, updatedAt: now, type: "reasoning", @@ -3032,35 +3443,93 @@ export function makeClaudeAdapterV2( }); } - if ( - input.result !== undefined && - input.result.trim().length > 0 && + const resultText = input.result; + const normalizedResult = + resultText === undefined ? null : normalizeClaudeTerminalText(resultText); + const resultEmission = + resultText !== undefined && + normalizedResult !== null && + normalizedResult.length > 0 && input.status !== "running" - ) { - const resultNativeItemId = `${nativeItemId}:result`; - const resultItemOrdinal = subagent.resultItemOrdinal ?? ++subagent.nextChildItemOrdinal; - subagent.resultItemOrdinal = resultItemOrdinal; + ? yield* Ref.modify(sessionSubagents, (state) => { + const nativeThreadState = state.byNativeThreadId.get( + input.context.nativeThreadId, + ); + const registered = nativeThreadState?.subagentsByTaskId.get(input.taskId); + if ( + nativeThreadState === undefined || + registered === undefined || + registered.generation !== subagent.generation + ) { + return [undefined, state] as const; + } + const resultNativeItemId = + input.resultNativeItemId ?? + `${nativeItemId}:result:${registered.generation}:${registered.nextChildItemOrdinal + 1}`; + if ( + (installation.terminalNativeItemId !== resultNativeItemId && + registered.emittedTerminalResultNativeItemIds.has(resultNativeItemId)) || + registered.terminalResultTexts.includes(normalizedResult) || + registered.streamedTexts.includes(normalizedResult) + ) { + return [undefined, state] as const; + } + const ordinal = registered.nextChildItemOrdinal + 1; + const updatedSubagent = { + ...registered, + nextChildItemOrdinal: ordinal, + resultItemOrdinal: ordinal, + emittedTerminalResultNativeItemIds: addToBoundedSet( + registered.emittedTerminalResultNativeItemIds, + resultNativeItemId, + MAX_CLAUDE_SUBAGENT_TERMINAL_NATIVE_ITEM_IDS, + ), + terminalResultTexts: addToBoundedTextList( + registered.terminalResultTexts, + normalizedResult, + MAX_CLAUDE_SUBAGENT_TERMINAL_TEXTS, + ), + }; + const subagentsByTaskId = new Map(nativeThreadState.subagentsByTaskId); + subagentsByTaskId.set(input.taskId, updatedSubagent); + const byNativeThreadId = new Map(state.byNativeThreadId); + byNativeThreadId.set(input.context.nativeThreadId, { + ...nativeThreadState, + subagentsByTaskId, + }); + return [ + { + subagent: updatedSubagent, + nativeItemId: resultNativeItemId, + ordinal, + text: resultText, + }, + { byNativeThreadId }, + ] as const; + }) + : undefined; + if (resultEmission !== undefined) { const resultArtifacts = makeSubagentConversationArtifacts({ messageId: idAllocator.derive.messageFromProviderItem({ driver: CLAUDE_PROVIDER, - nativeItemId: resultNativeItemId, + nativeItemId: resultEmission.nativeItemId, }), turnItemId: idAllocator.derive.turnItemFromProviderItem({ driver: CLAUDE_PROVIDER, - nativeItemId: resultNativeItemId, + nativeItemId: resultEmission.nativeItemId, }), - threadId: childThreadId, - rootNodeId: childRootNodeId, + threadId: resultEmission.subagent.childThreadId, + rootNodeId: resultEmission.subagent.childRootNodeId, providerThreadId: null, providerTurnId: null, nativeItemRef: { driver: CLAUDE_PROVIDER, - nativeId: resultNativeItemId, + nativeId: resultEmission.nativeItemId, strength: "strong", }, role: "assistant", - text: input.result, - ordinal: resultItemOrdinal, + text: resultEmission.text, + ordinal: resultEmission.ordinal, now, }); yield* emitProviderEvent({ @@ -3076,44 +3545,463 @@ export function makeClaudeAdapterV2( } }); - const ensureToolCallStarted = Effect.fnUntraced(function* (input: { - readonly context: ActiveClaudeTurnContext; - readonly nativeItemId: string; - readonly toolName: string; - readonly toolInput: ClaudeNativeToolInput; - readonly parentToolUseId: string | null; + const failClaudeSubagent = Effect.fnUntraced(function* (failInput: { + readonly failureNativeItemId: string; + readonly failureText: string; + readonly nativeThreadId: string; + readonly taskId: string; }) { - const existing = input.context.toolCalls.get(input.nativeItemId); - if (existing !== undefined) { - return existing; + const now = yield* DateTime.now; + const failed = yield* Ref.modify(sessionSubagents, (state) => { + const nativeThreadState = state.byNativeThreadId.get(failInput.nativeThreadId); + const subagent = nativeThreadState?.subagentsByTaskId.get(failInput.taskId); + if ( + nativeThreadState === undefined || + subagent === undefined || + subagent.task.status !== "running" + ) { + return [undefined, state] as const; + } + const ordinal = subagent.resultItemOrdinal ?? subagent.nextChildItemOrdinal + 1; + const task = { + ...subagent.task, + status: "failed" as const, + result: failInput.failureText, + completedAt: now, + updatedAt: now, + } satisfies OrchestrationV2Subagent; + const updatedSubagent = { + ...subagent, + task, + activeToolCallsByNativeItemId: new Map(), + nextChildItemOrdinal: + subagent.resultItemOrdinal === null ? ordinal : subagent.nextChildItemOrdinal, + resultItemOrdinal: ordinal, + emittedTerminalResultNativeItemIds: addToBoundedSet( + subagent.emittedTerminalResultNativeItemIds, + failInput.failureNativeItemId, + MAX_CLAUDE_SUBAGENT_TERMINAL_NATIVE_ITEM_IDS, + ), + terminalResultTexts: addToBoundedTextList( + subagent.terminalResultTexts, + normalizeClaudeTerminalText(failInput.failureText), + MAX_CLAUDE_SUBAGENT_TERMINAL_TEXTS, + ), + } satisfies ActiveClaudeSubagent; + const subagentsByTaskId = new Map(nativeThreadState.subagentsByTaskId); + subagentsByTaskId.set(failInput.taskId, updatedSubagent); + const pendingResumeTaskIds = new Set(nativeThreadState.pendingResumeTaskIds); + pendingResumeTaskIds.delete(failInput.taskId); + const byNativeThreadId = new Map(state.byNativeThreadId); + byNativeThreadId.set(failInput.nativeThreadId, { + ...nativeThreadState, + subagentsByTaskId, + pendingResumeTaskIds, + }); + return [ + { + subagent: updatedSubagent, + ordinal, + activeToolCalls: [...subagent.activeToolCallsByNativeItemId.values()], + }, + { byNativeThreadId }, + ] as const; + }); + if (failed === undefined) { + return; } - const startedAt = yield* DateTime.now; - const classification = classifyClaudeNativeTool(input.toolName); - const subagent = - input.parentToolUseId === null - ? undefined - : input.context.subagentsByToolUseId.get(input.parentToolUseId); - const threadId = subagent?.childThreadId ?? input.context.input.threadId; - const runId = subagent === undefined ? input.context.input.runId : null; - const rootNodeId = subagent?.childRootNodeId ?? input.context.input.rootNodeId; - const parentNodeId = rootNodeId; - const ordinal = - subagent === undefined - ? yield* resolveItemOrdinal(input.context, input.nativeItemId) - : ++subagent.nextChildItemOrdinal; - const toolCall: ActiveClaudeToolCall = { - nativeItemId: input.nativeItemId, - toolName: input.toolName, - classification, - input: input.toolInput, - threadId, + const { subagent, ordinal, activeToolCalls } = failed; + const task = subagent.task; + yield* Effect.all( + [ + emitProviderEvent({ + type: "node.updated", + driver: CLAUDE_PROVIDER, + node: { + id: task.id, + threadId: task.threadId, + runId: task.runId, + parentNodeId: task.parentNodeId, + rootNodeId: task.parentNodeId, + kind: "subagent", + status: "failed", + countsForRun: false, + providerThreadId: subagent.providerThreadId, + providerTurnId: subagent.providerTurnId, + nativeItemRef: task.nativeTaskRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: task.startedAt, + completedAt: now, + }, + }), + emitProviderEvent({ + type: "node.updated", + driver: CLAUDE_PROVIDER, + node: { + id: subagent.childRootNodeId, + threadId: subagent.childThreadId, + runId: null, + parentNodeId: null, + rootNodeId: subagent.childRootNodeId, + kind: "root_turn", + status: "failed", + countsForRun: false, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: task.nativeTaskRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: task.startedAt, + completedAt: now, + }, + }), + emitProviderEvent({ + type: "subagent.updated", + driver: CLAUDE_PROVIDER, + subagent: task, + }), + emitProviderEvent({ + type: "turn_item.updated", + driver: CLAUDE_PROVIDER, + turnItem: { + id: subagent.turnItemId, + threadId: task.threadId, + runId: task.runId, + nodeId: task.id, + providerThreadId: subagent.providerThreadId, + providerTurnId: subagent.providerTurnId, + nativeItemRef: task.nativeTaskRef, + parentItemId: null, + ordinal: subagent.turnItemOrdinal, + status: "failed", + title: task.title, + startedAt: task.startedAt, + completedAt: now, + updatedAt: now, + type: "subagent", + subagentId: task.id, + origin: task.origin, + driver: task.driver, + providerInstanceId: task.providerInstanceId, + childThreadId: task.childThreadId, + prompt: task.prompt, + ...(task.progress === undefined ? {} : { progress: task.progress }), + result: task.result, + }, + }), + ], + { concurrency: 1 }, + ); + for (const toolCall of activeToolCalls) { + yield* emitToolCallArtifacts( + buildToolCallArtifacts({ + nativeItemId: toolCall.nativeItemId, + toolName: toolCall.toolName, + classification: toolCall.classification, + toolInput: toolCall.input, + threadId: toolCall.threadId, + runId: toolCall.runId, + rootNodeId: toolCall.rootNodeId, + parentNodeId: toolCall.parentNodeId, + ordinal: toolCall.ordinal, + output: NO_CLAUDE_NATIVE_TOOL_OUTPUT, + status: "failed", + startedAt: toolCall.startedAt, + updatedAt: now, + }), + ); + } + const resultArtifacts = makeSubagentConversationArtifacts({ + messageId: idAllocator.derive.messageFromProviderItem({ + driver: CLAUDE_PROVIDER, + nativeItemId: failInput.failureNativeItemId, + }), + turnItemId: idAllocator.derive.turnItemFromProviderItem({ + driver: CLAUDE_PROVIDER, + nativeItemId: failInput.failureNativeItemId, + }), + threadId: subagent.childThreadId, + rootNodeId: subagent.childRootNodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: { + driver: CLAUDE_PROVIDER, + nativeId: failInput.failureNativeItemId, + strength: "strong", + }, + role: "assistant", + text: failInput.failureText, + ordinal, + now, + }); + yield* emitProviderEvent({ + type: "message.updated", + driver: CLAUDE_PROVIDER, + message: resultArtifacts.message, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CLAUDE_PROVIDER, + turnItem: resultArtifacts.turnItem, + }); + }); + + const failClaudeSubagentWakeDelivery = (failInput: { + readonly nativeThreadId: string; + readonly taskId: string; + }) => + failClaudeSubagent({ + ...failInput, + failureNativeItemId: `task:${failInput.taskId}:wake-delivery-failed`, + failureText: CLAUDE_SUBAGENT_WAKE_DELIVERY_FAILURE, + }); + + const clearNativeThreadSubagentsUnlocked = Effect.fnUntraced(function* ( + nativeThreadId: string, + ) { + const runningTaskIds = [ + ...((yield* Ref.get(sessionSubagents)).byNativeThreadId + .get(nativeThreadId) + ?.subagentsByTaskId.entries() ?? []), + ].flatMap(([taskId, subagent]) => (subagent.task.status === "running" ? [taskId] : [])); + yield* Effect.forEach( + runningTaskIds, + (taskId) => + failClaudeSubagent({ + failureNativeItemId: `task:${taskId}:session-ended`, + failureText: CLAUDE_SUBAGENT_SESSION_ENDED_FAILURE, + nativeThreadId, + taskId, + }), + { concurrency: 1 }, + ); + yield* Ref.update(sessionSubagents, (state) => { + if (!state.byNativeThreadId.has(nativeThreadId)) { + return state; + } + const byNativeThreadId = new Map(state.byNativeThreadId); + byNativeThreadId.delete(nativeThreadId); + return { byNativeThreadId }; + }); + }); + + const clearNativeThreadStateUnlocked = Effect.fnUntraced(function* ( + nativeThreadId: string, + ) { + yield* clearNativeThreadSubagentsUnlocked(nativeThreadId); + yield* Ref.update(wakeBuffers, (current) => { + const updated = new Map(current); + updated.delete(nativeThreadId); + return updated; + }); + yield* Ref.update(continuationStateByNativeThread, (current) => { + const existing = current.get(nativeThreadId); + const updated = new Map(current); + updated.set(nativeThreadId, { + generation: (existing?.generation ?? 0) + 1, + requested: false, + }); + return updated; + }); + yield* Ref.update(failedWakeDrainByNativeThread, (current) => { + const updated = new Map(current); + updated.delete(nativeThreadId); + return updated; + }); + yield* Ref.update(pendingBackgroundTasksByNativeThread, (current) => { + if (!current.has(nativeThreadId)) { + return current; + } + const updated = new Map(current); + updated.delete(nativeThreadId); + return updated; + }); + yield* Ref.update(wakeEligibleBackgroundTasksByNativeThread, (current) => { + if (!current.has(nativeThreadId)) { + return current; + } + const updated = new Map(current); + updated.delete(nativeThreadId); + return updated; + }); + yield* Ref.update(opaqueBackgroundTaskReplayTombstonesByNativeThread, (current) => { + if (!current.has(nativeThreadId)) { + return current; + } + const updated = new Map(current); + updated.delete(nativeThreadId); + return updated; + }); + yield* Ref.update(lastProviderThreadByNativeThread, (current) => { + if (!current.has(nativeThreadId)) { + return current; + } + const updated = new Map(current); + updated.delete(nativeThreadId); + return updated; + }); + }); + + const clearNativeThreadState = (nativeThreadId: string) => + sdkMessagePermit.withPermit(clearNativeThreadStateUnlocked(nativeThreadId)); + + const clearNativeThreadSubagents = (nativeThreadId: string) => + sdkMessagePermit.withPermit(clearNativeThreadSubagentsUnlocked(nativeThreadId)); + + const reserveSubagentChildOrdinal = Effect.fnUntraced(function* (reserveInput: { + readonly nativeThreadId: string; + readonly nativeItemId: string; + readonly taskId?: string; + readonly toolUseId?: string; + }) { + return yield* Ref.modify(sessionSubagents, (state) => { + const nativeThreadState = state.byNativeThreadId.get(reserveInput.nativeThreadId); + const taskId = + reserveInput.taskId ?? + (reserveInput.toolUseId === undefined + ? undefined + : nativeThreadState?.taskIdByToolUseId.get(reserveInput.toolUseId)); + const subagent = + taskId === undefined ? undefined : nativeThreadState?.subagentsByTaskId.get(taskId); + if (nativeThreadState === undefined || taskId === undefined || subagent === undefined) { + return [undefined, state] as const; + } + const existingOrdinal = subagent.childItemOrdinalsByNativeItemId.get( + reserveInput.nativeItemId, + ); + const ordinal = existingOrdinal ?? subagent.nextChildItemOrdinal + 1; + const childItemOrdinalsByNativeItemId = new Map( + subagent.childItemOrdinalsByNativeItemId, + ); + childItemOrdinalsByNativeItemId.set(reserveInput.nativeItemId, ordinal); + const updatedSubagent = { + ...subagent, + nextChildItemOrdinal: + existingOrdinal === undefined ? ordinal : subagent.nextChildItemOrdinal, + childItemOrdinalsByNativeItemId, + }; + const subagentsByTaskId = new Map(nativeThreadState.subagentsByTaskId); + subagentsByTaskId.set(taskId, updatedSubagent); + const byNativeThreadId = new Map(state.byNativeThreadId); + byNativeThreadId.set(reserveInput.nativeThreadId, { + ...nativeThreadState, + subagentsByTaskId, + }); + return [{ subagent: updatedSubagent, taskId, ordinal }, { byNativeThreadId }] as const; + }); + }); + + const forgetActiveSubagentToolCall = Effect.fnUntraced(function* (forgetInput: { + readonly nativeThreadId: string; + readonly taskId: string; + readonly nativeItemId: string; + }) { + yield* Ref.update(sessionSubagents, (state) => { + const nativeThreadState = state.byNativeThreadId.get(forgetInput.nativeThreadId); + const subagent = nativeThreadState?.subagentsByTaskId.get(forgetInput.taskId); + if (nativeThreadState === undefined || subagent === undefined) return state; + const activeToolCallsByNativeItemId = new Map(subagent.activeToolCallsByNativeItemId); + activeToolCallsByNativeItemId.delete(forgetInput.nativeItemId); + const subagentsByTaskId = new Map(nativeThreadState.subagentsByTaskId); + subagentsByTaskId.set(forgetInput.taskId, { + ...subagent, + activeToolCallsByNativeItemId, + }); + const byNativeThreadId = new Map(state.byNativeThreadId); + byNativeThreadId.set(forgetInput.nativeThreadId, { + ...nativeThreadState, + subagentsByTaskId, + }); + return { byNativeThreadId }; + }); + }); + + const ensureToolCallStarted = Effect.fnUntraced(function* (input: { + readonly context: ActiveClaudeTurnContext; + readonly nativeItemId: string; + readonly toolName: string; + readonly toolInput: ClaudeNativeToolInput; + readonly parentToolUseId: string | null; + readonly subagentTaskId?: string; + }) { + const existing = input.context.toolCalls.get(input.nativeItemId); + if (existing !== undefined) { + return existing; + } + const startedAt = yield* DateTime.now; + const classification = classifyClaudeNativeTool(input.toolName); + const subagentRoute = + input.subagentTaskId !== undefined + ? yield* reserveSubagentChildOrdinal({ + nativeThreadId: input.context.nativeThreadId, + nativeItemId: input.nativeItemId, + taskId: input.subagentTaskId, + }) + : input.parentToolUseId === null + ? undefined + : yield* reserveSubagentChildOrdinal({ + nativeThreadId: input.context.nativeThreadId, + nativeItemId: input.nativeItemId, + toolUseId: input.parentToolUseId, + }); + if ( + (input.parentToolUseId !== null || input.subagentTaskId !== undefined) && + subagentRoute === undefined + ) { + yield* Effect.logWarning("orchestration-v2.claude-subagent-tool-unrouted", { + providerTurnId: input.context.providerTurnId, + parentToolUseId: input.parentToolUseId, + ...(input.subagentTaskId === undefined ? {} : { taskId: input.subagentTaskId }), + nativeItemId: input.nativeItemId, + }); + return null; + } + const subagent = subagentRoute?.subagent; + const threadId = subagent?.childThreadId ?? input.context.input.threadId; + const runId = subagent === undefined ? input.context.input.runId : null; + const rootNodeId = subagent?.childRootNodeId ?? input.context.input.rootNodeId; + const parentNodeId = rootNodeId; + const ordinal = + subagentRoute?.ordinal ?? + (yield* resolveItemOrdinal(input.context, input.nativeItemId)); + const toolCall: ActiveClaudeToolCall = { + nativeItemId: input.nativeItemId, + toolName: input.toolName, + classification, + input: input.toolInput, + threadId, runId, rootNodeId, parentNodeId, ordinal, startedAt, + subagentTaskId: subagentRoute?.taskId ?? null, }; input.context.toolCalls.set(input.nativeItemId, toolCall); + const subagentTaskId = toolCall.subagentTaskId; + if (subagentTaskId !== null) { + yield* Ref.update(sessionSubagents, (state) => { + const nativeThreadState = state.byNativeThreadId.get(input.context.nativeThreadId); + const registered = nativeThreadState?.subagentsByTaskId.get(subagentTaskId); + if (nativeThreadState === undefined || registered === undefined) return state; + const activeToolCallsByNativeItemId = new Map( + registered.activeToolCallsByNativeItemId, + ); + activeToolCallsByNativeItemId.set(toolCall.nativeItemId, toolCall); + const subagentsByTaskId = new Map(nativeThreadState.subagentsByTaskId); + subagentsByTaskId.set(subagentTaskId, { + ...registered, + activeToolCallsByNativeItemId, + }); + const byNativeThreadId = new Map(state.byNativeThreadId); + byNativeThreadId.set(input.context.nativeThreadId, { + ...nativeThreadState, + subagentsByTaskId, + }); + return { byNativeThreadId }; + }); + } yield* emitToolCallArtifacts( buildToolCallArtifacts({ context: input.context, @@ -3139,6 +4027,8 @@ export function makeClaudeAdapterV2( readonly context: ActiveClaudeTurnContext; readonly nativeItemId: string; readonly nativeRequestId: string; + readonly parentToolUseId: string | null; + readonly subagentTaskId?: string; readonly requestKind: ProviderRequestKind; readonly prompt?: string; }) { @@ -3156,24 +4046,51 @@ export function makeClaudeAdapterV2( detail: `Provider thread ${input.context.input.providerThread.id} is missing a provider session id.`, }); } - const ordinal = yield* resolveItemOrdinal( - input.context, - `${input.nativeItemId}:approval:${input.nativeRequestId}`, - ); + const approvalNativeItemId = `${input.nativeItemId}:approval:${input.nativeRequestId}`; + const subagentRoute = + input.subagentTaskId !== undefined + ? yield* reserveSubagentChildOrdinal({ + nativeThreadId: input.context.nativeThreadId, + nativeItemId: approvalNativeItemId, + taskId: input.subagentTaskId, + }) + : input.parentToolUseId === null + ? undefined + : yield* reserveSubagentChildOrdinal({ + nativeThreadId: input.context.nativeThreadId, + nativeItemId: approvalNativeItemId, + toolUseId: input.parentToolUseId, + }); + if ( + (input.parentToolUseId !== null || input.subagentTaskId !== undefined) && + subagentRoute === undefined + ) { + return yield* new ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: `Claude subagent lineage ${input.parentToolUseId} is unavailable for approval ${input.nativeRequestId}.`, + }); + } + const ordinal = + subagentRoute?.ordinal ?? + (yield* resolveItemOrdinal(input.context, approvalNativeItemId)); const nativeItemRef = { driver: CLAUDE_PROVIDER, nativeId: input.nativeRequestId, strength: "strong" as const, }; + const subagent = subagentRoute?.subagent; + const threadId = subagent?.childThreadId ?? input.context.input.threadId; + const runId = subagent === undefined ? input.context.input.runId : null; + const rootNodeId = subagent?.childRootNodeId ?? input.context.input.rootNodeId; const node: OrchestrationV2ExecutionNode = { id: nodeId, - threadId: input.context.input.threadId, - runId: input.context.input.runId, + threadId, + runId, parentNodeId: idAllocator.derive.nodeFromProviderItem({ driver: CLAUDE_PROVIDER, nativeItemId: input.nativeItemId, }), - rootNodeId: input.context.input.rootNodeId, + rootNodeId, kind: "approval_request", status: "waiting", countsForRun: false, @@ -3205,8 +4122,8 @@ export function makeClaudeAdapterV2( }; const turnItem: OrchestrationV2TurnItem = { id: idAllocator.derive.approvalTurnItem({ requestId }), - threadId: input.context.input.threadId, - runId: input.context.input.runId, + threadId, + runId, nodeId, providerThreadId: input.context.input.providerThread.id, providerTurnId: input.context.providerTurnId, @@ -3236,7 +4153,23 @@ export function makeClaudeAdapterV2( readonly failure?: OrchestrationV2ProviderFailure; readonly threadDisposition?: "reusable" | "broken"; }) { + // Claim the turn before emitting anything. The handoff bound runs on + // its own fiber, so without this a frame handler and the bound can + // both finalize the same turn and emit two terminals for it. + const claimed = yield* Ref.modify(activeTurn, (current) => { + if (current?.providerTurnId !== input.context.providerTurnId) { + return [false, current] as const; + } + return [true, null] as const; + }); + if (!claimed) { + return; + } + for (const toolCall of input.context.toolCalls.values()) { + if (toolCall.runId === null) { + continue; + } const artifacts = buildToolCallArtifacts({ context: input.context, nativeItemId: toolCall.nativeItemId, @@ -3425,14 +4358,16 @@ export function makeClaudeAdapterV2( ], { concurrency: 1 }, ); - yield* Ref.update(activeTurn, (current) => - current?.providerTurnId === input.context.providerTurnId ? null : current, - ); yield* Ref.update(interruptedTurns, (current) => { const next = new Set(current); next.delete(input.context.providerTurnId); return next; }); + yield* Ref.update(steeredTurns, (current) => { + const next = new Set(current); + next.delete(input.context.providerTurnId); + return next; + }); }); const emitAssistantTextArtifacts = Effect.fnUntraced(function* (input: { @@ -3478,6 +4413,87 @@ export function makeClaudeAdapterV2( ); }); + const emitSubagentAssistantTextArtifacts = Effect.fnUntraced(function* (input: { + readonly context: ActiveClaudeTurnContext; + readonly taskId: string; + readonly nativeItemId: string; + readonly text: string; + }) { + const emission = yield* Ref.modify(sessionSubagents, (state) => { + const nativeThreadState = state.byNativeThreadId.get(input.context.nativeThreadId); + const subagent = nativeThreadState?.subagentsByTaskId.get(input.taskId); + if ( + nativeThreadState === undefined || + subagent === undefined || + subagent.emittedTextNativeItemIds.has(input.nativeItemId) || + subagent.terminalResultTexts.includes(normalizeClaudeTerminalText(input.text)) + ) { + return [undefined, state] as const; + } + const emittedTextNativeItemIds = addToBoundedSet( + subagent.emittedTextNativeItemIds, + input.nativeItemId, + MAX_CLAUDE_SUBAGENT_TEXT_NATIVE_ITEM_IDS, + ); + const ordinal = subagent.nextChildItemOrdinal + 1; + const updatedSubagent = { + ...subagent, + nextChildItemOrdinal: ordinal, + emittedTextNativeItemIds, + streamedTexts: addToBoundedTextList( + subagent.streamedTexts, + normalizeClaudeTerminalText(input.text), + MAX_CLAUDE_SUBAGENT_STREAMED_TEXTS, + ), + }; + const subagentsByTaskId = new Map(nativeThreadState.subagentsByTaskId); + subagentsByTaskId.set(input.taskId, updatedSubagent); + const byNativeThreadId = new Map(state.byNativeThreadId); + byNativeThreadId.set(input.context.nativeThreadId, { + ...nativeThreadState, + subagentsByTaskId, + }); + return [{ subagent: updatedSubagent, ordinal }, { byNativeThreadId }] as const; + }); + if (emission === undefined) { + return; + } + const now = yield* DateTime.now; + const artifacts = makeSubagentConversationArtifacts({ + messageId: idAllocator.derive.messageFromProviderItem({ + driver: CLAUDE_PROVIDER, + nativeItemId: input.nativeItemId, + }), + turnItemId: idAllocator.derive.turnItemFromProviderItem({ + driver: CLAUDE_PROVIDER, + nativeItemId: input.nativeItemId, + }), + threadId: emission.subagent.childThreadId, + rootNodeId: emission.subagent.childRootNodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: { + driver: CLAUDE_PROVIDER, + nativeId: input.nativeItemId, + strength: "strong", + }, + role: "assistant", + text: input.text, + ordinal: emission.ordinal, + now, + }); + yield* emitProviderEvent({ + type: "message.updated", + driver: CLAUDE_PROVIDER, + message: artifacts.message, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CLAUDE_PROVIDER, + turnItem: artifacts.turnItem, + }); + }); + const finalizeActiveTurnAfterQueryExit = Effect.fnUntraced(function* ( cause?: Cause.Cause, ) { @@ -3505,21 +4521,229 @@ export function makeClaudeAdapterV2( next.delete(context.providerTurnId); return next; }); - if (cause !== undefined) { - yield* Effect.logWarning("orchestration-v2.claude-query-stream-failed", { - providerSessionId: input.providerSessionId, - providerThreadId: context.input.providerThread.id, - providerTurnId: context.providerTurnId, - cause, - }); - } + if (cause !== undefined) { + yield* Effect.logWarning("orchestration-v2.claude-query-stream-failed", { + providerSessionId: input.providerSessionId, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + cause, + }); + } + }); + + const offerBufferedContinuation = Effect.fnUntraced(function* (nativeThreadId: string) { + const route = (yield* Ref.get(lastTurnRouteByNativeThread)).get(nativeThreadId); + if (route === undefined) { + yield* Effect.logWarning("orchestration-v2.claude-wake-turn-unroutable", { + providerSessionId: input.providerSessionId, + nativeThreadId, + }); + return; + } + const continuationGeneration = yield* Ref.modify( + continuationStateByNativeThread, + (current) => { + const existing = current.get(nativeThreadId); + if (existing?.requested === true) { + return [undefined, current] as const; + } + const generation = (existing?.generation ?? 0) + 1; + const updated = new Map(current); + updated.set(nativeThreadId, { generation, requested: true }); + return [generation, updated] as const; + }, + ); + if (continuationGeneration === undefined) { + return; + } + const offeredWakeBuffer = (yield* Ref.get(wakeBuffers)).get(nativeThreadId); + const offeredMessages = offeredWakeBuffer?.messages ?? []; + const failedSubagentTaskIds = new Set(); + const completedBackgroundTaskIds = new Set(); + const registeredSubagents = (yield* Ref.get(sessionSubagents)).byNativeThreadId.get( + nativeThreadId, + )?.subagentsByTaskId; + for (const offeredMessage of offeredMessages) { + if ( + offeredMessage.type === "system" && + offeredMessage.subtype === "task_notification" && + registeredSubagents?.has(offeredMessage.task_id) === true + ) { + failedSubagentTaskIds.add(offeredMessage.task_id); + } else if ( + offeredMessage.type === "system" && + offeredMessage.subtype === "task_notification" + ) { + completedBackgroundTaskIds.add(offeredMessage.task_id); + } + } + const clearContinuationIfCurrent = () => + sdkMessagePermit.withPermit( + Effect.gen(function* () { + const current = (yield* Ref.get(continuationStateByNativeThread)).get( + nativeThreadId, + ); + if (current?.generation !== continuationGeneration) { + return; + } + yield* clearNativeThreadStateUnlocked(nativeThreadId); + }), + ); + const failContinuationIfCurrent: NonNullable< + ProviderContinuationRequest["failIfCurrent"] + > = () => + sdkMessagePermit.withPermit( + Effect.gen(function* () { + const current = (yield* Ref.get(continuationStateByNativeThread)).get( + nativeThreadId, + ); + if (current?.generation !== continuationGeneration || current.requested !== true) { + return; + } + const bufferedWake = (yield* Ref.get(wakeBuffers)).get(nativeThreadId); + const bufferedMessages = bufferedWake?.messages ?? offeredMessages; + const appendedMessages = bufferedMessages.slice(offeredMessages.length); + for (const taskId of failedSubagentTaskIds) { + yield* failClaudeSubagentWakeDelivery({ nativeThreadId, taskId }); + } + yield* Ref.update(wakeBuffers, (buffers) => { + const updated = new Map(buffers); + updated.delete(nativeThreadId); + return updated; + }); + if (!offeredMessages.some((offeredMessage) => offeredMessage.type === "result")) { + const failedWakeTaskIds = new Set([ + ...failedSubagentTaskIds, + ...completedBackgroundTaskIds, + ]); + yield* Ref.update(failedWakeDrainByNativeThread, (current) => { + const updated = new Map(current); + updated.set(nativeThreadId, failedWakeTaskIds); + return updated; + }); + } + yield* Ref.update(continuationStateByNativeThread, (continuations) => { + const existing = continuations.get(nativeThreadId); + const updated = new Map(continuations); + updated.set(nativeThreadId, { + generation: (existing?.generation ?? continuationGeneration) + 1, + requested: false, + }); + return updated; + }); + for (const appendedMessage of appendedMessages) { + yield* bufferWakeMessage({ + nativeThreadId, + message: appendedMessage, + }); + } + }), + ); + const dispatchContinuationIfCurrent: NonNullable< + ProviderContinuationRequest["dispatchIfCurrent"] + > = (effect) => + sdkMessagePermit.withPermit( + Effect.gen(function* () { + const current = (yield* Ref.get(continuationStateByNativeThread)).get( + nativeThreadId, + ); + if (current?.generation !== continuationGeneration || current.requested !== true) { + return Option.none(); + } + return Option.some(yield* effect); + }), + ); + const detail = (yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.detail ?? null; + yield* Effect.logInfo("orchestration-v2.claude-wake-turn-detected", { + providerSessionId: input.providerSessionId, + threadId: route.threadId, + providerThreadId: route.providerThreadId, + }); + yield* continuationRequests.offer({ + threadId: route.threadId, + providerThreadId: route.providerThreadId, + driver: CLAUDE_PROVIDER, + detail, + messageText: PROVIDER_CONTINUATION_MESSAGE_TEXT, + clearIfCurrent: clearContinuationIfCurrent, + dispatchIfCurrent: dispatchContinuationIfCurrent, + failIfCurrent: failContinuationIfCurrent, + }); }); - const bufferWakeMessage = Effect.fnUntraced(function* (wakeInput: { + const bufferWakeMessage: (wakeInput: { readonly nativeThreadId: string; readonly message: SDKMessage; - }) { + }) => Effect.Effect = Effect.fnUntraced(function* (wakeInput) { const message = wakeInput.message; + const failedWakeTaskIds = (yield* Ref.get(failedWakeDrainByNativeThread)).get( + wakeInput.nativeThreadId, + ); + if (failedWakeTaskIds !== undefined) { + if (message.type === "result") { + return; + } + const messageTaskId = + message.type === "system" && + (message.subtype === "task_started" || + message.subtype === "task_progress" || + message.subtype === "task_notification") + ? message.task_id + : undefined; + const attributedParentToolUseId = parentToolUseIdFromSdkMessage(message); + const attributedSubagent = + attributedParentToolUseId === null + ? undefined + : yield* resolveSessionSubagent({ + nativeThreadId: wakeInput.nativeThreadId, + toolUseId: attributedParentToolUseId, + }); + const resultTaskId = claudeToolResultEntriesFromMessage(message) + .map(({ output }) => claudeSubagentTaskIdFromToolOutput(output)) + .find((taskId) => taskId !== undefined); + const attributedTaskId = messageTaskId ?? attributedSubagent?.taskId ?? resultTaskId; + const nativeThreadSubagents = (yield* Ref.get(sessionSubagents)).byNativeThreadId.get( + wakeInput.nativeThreadId, + ); + const registered = + attributedTaskId === undefined + ? undefined + : nativeThreadSubagents?.subagentsByTaskId.get(attributedTaskId); + const isPendingBackgroundTask = + attributedTaskId !== undefined && + (yield* isKnownOpaqueBackgroundTaskOnNativeThread( + wakeInput.nativeThreadId, + attributedTaskId, + )); + const isKnownSubagentTask = + registered?.task.status === "running" || + (attributedTaskId !== undefined && + nativeThreadSubagents?.pendingResumeTaskIds.has(attributedTaskId) === true) || + (registered !== undefined && + message.type === "system" && + message.subtype === "task_started"); + const isKnownSuccessorTask = + attributedTaskId !== undefined && + !failedWakeTaskIds.has(attributedTaskId) && + (isKnownSubagentTask || isPendingBackgroundTask); + if (!isKnownSuccessorTask) { + if ( + attributedTaskId !== undefined && + failedWakeTaskIds.has(attributedTaskId) && + message.type === "system" && + message.subtype === "task_notification" + ) { + if (registered?.task.status === "running") { + yield* failClaudeSubagentWakeDelivery({ + nativeThreadId: wakeInput.nativeThreadId, + taskId: attributedTaskId, + }); + } + yield* clearPendingBackgroundTask(wakeInput.nativeThreadId, attributedTaskId); + } + return; + } + } const isNotification = message.type === "system" && message.subtype === "task_notification"; // Only notifications for tracked tasks count as wake evidence: a @@ -3534,11 +4758,22 @@ export function makeClaudeAdapterV2( wakeInput.nativeThreadId, message.task_id, )); + const nativeThreadSubagentState = (yield* Ref.get(sessionSubagents)).byNativeThreadId.get( + wakeInput.nativeThreadId, + ); const isPendingSubagentNotification = isNotification && !isPendingTaskNotification && - (yield* Ref.get(sessionSubagentsByTaskId)).get(message.task_id)?.task.status === - "running"; + (nativeThreadSubagentState?.subagentsByTaskId.get(message.task_id)?.task.status === + "running" || + nativeThreadSubagentState?.pendingResumeTaskIds.has(message.task_id) === true); + const isKnownSubagentProgress = + message.type === "system" && + message.subtype === "task_progress" && + message.tool_use_id !== undefined && + (nativeThreadSubagentState?.subagentsByTaskId.get(message.task_id)?.task.status === + "running" || + nativeThreadSubagentState?.pendingResumeTaskIds.has(message.task_id) === true); // A task_started for a session-registered subagent that races past // settle is a resume (SendMessage to a completed subagent re-emits // task_started with the same task id). Re-open the registry entry @@ -3549,35 +4784,34 @@ export function makeClaudeAdapterV2( const isKnownSubagentTaskStarted = message.type === "system" && message.subtype === "task_started" && - (yield* Ref.get(sessionSubagentsByTaskId)).has(message.task_id); + (yield* resolveSessionSubagent({ + nativeThreadId: wakeInput.nativeThreadId, + taskId: message.task_id, + })) !== undefined; 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, - task: { - ...priorTask, - status: "running", - result: null, - completedAt: null, - updatedAt: now, - }, + yield* Ref.update(sessionSubagents, (state) => { + const nativeThreadState = + state.byNativeThreadId.get(wakeInput.nativeThreadId) ?? + emptyClaudeNativeThreadSubagentState(); + const pendingResumeTaskIds = new Set(nativeThreadState.pendingResumeTaskIds); + pendingResumeTaskIds.add(message.task_id); + const byNativeThreadId = new Map(state.byNativeThreadId); + byNativeThreadId.set(wakeInput.nativeThreadId, { + ...nativeThreadState, + pendingResumeTaskIds, }); + return { byNativeThreadId }; }); } const isWakeEvidence = isPendingTaskNotification || isPendingSubagentNotification || isKnownSubagentTaskStarted || + isKnownSubagentProgress || message.type === "assistant" || message.type === "user" || message.type === "result"; @@ -3626,38 +4860,7 @@ export function makeClaudeAdapterV2( ) { return; } - const route = (yield* Ref.get(lastTurnRouteByNativeThread)).get(wakeInput.nativeThreadId); - if (route === undefined) { - yield* Effect.logWarning("orchestration-v2.claude-wake-turn-unroutable", { - providerSessionId: input.providerSessionId, - nativeThreadId: wakeInput.nativeThreadId, - }); - return; - } - const shouldOffer = yield* Ref.modify(requestedContinuations, (current) => { - if (current.has(wakeInput.nativeThreadId)) { - return [false, current] as const; - } - const updated = new Set(current); - updated.add(wakeInput.nativeThreadId); - return [true, updated] as const; - }); - if (!shouldOffer) { - return; - } - const detail = - (yield* Ref.get(wakeBuffers)).get(wakeInput.nativeThreadId)?.detail ?? null; - yield* Effect.logInfo("orchestration-v2.claude-wake-turn-detected", { - providerSessionId: input.providerSessionId, - threadId: route.threadId, - providerThreadId: route.providerThreadId, - }); - yield* continuationRequests.offer({ - threadId: route.threadId, - providerThreadId: route.providerThreadId, - driver: CLAUDE_PROVIDER, - detail, - }); + yield* offerBufferedContinuation(wakeInput.nativeThreadId); }); const applyBackgroundTaskRosterMessage = Effect.fnUntraced(function* (input: { @@ -3744,10 +4947,10 @@ export function makeClaudeAdapterV2( return true; }); - const handleSdkMessage = Effect.fnUntraced(function* (input: { + const handleSdkMessageUnlocked = Effect.fnUntraced(function* (input: { readonly query: ClaudeAgentSdkQuerySession; readonly message: SDKMessage; - }) { + }): Effect.fn.Return { const liveQuery = yield* Ref.get(queryContext); if (liveQuery?.query !== input.query) { return; @@ -3776,7 +4979,55 @@ export function makeClaudeAdapterV2( return; } - if (message.type === "assistant") { + const failedWakeTaskIds = (yield* Ref.get(failedWakeDrainByNativeThread)).get( + context.nativeThreadId, + ); + const attributedParentToolUseId = parentToolUseIdFromSdkMessage(message); + const messageTaskId = + message.type === "system" && + (message.subtype === "task_started" || + message.subtype === "task_progress" || + message.subtype === "task_notification") + ? message.task_id + : undefined; + const attributedSubagent = + attributedParentToolUseId === null + ? undefined + : yield* resolveSessionSubagent({ + nativeThreadId: context.nativeThreadId, + toolUseId: attributedParentToolUseId, + }); + const resultTaskId = claudeToolResultEntriesFromMessage(message) + .map(({ output }) => claudeSubagentTaskIdFromToolOutput(output)) + .find((taskId) => taskId !== undefined); + const attributedTaskId = messageTaskId ?? attributedSubagent?.taskId ?? resultTaskId; + if (attributedTaskId !== undefined && failedWakeTaskIds?.has(attributedTaskId) === true) { + return; + } + + if ( + attributedParentToolUseId !== null && + (yield* resolveSessionSubagent({ + nativeThreadId: context.nativeThreadId, + toolUseId: attributedParentToolUseId, + })) === undefined + ) { + const buffered = yield* bufferPendingSubagentMessage({ + nativeThreadId: context.nativeThreadId, + parentToolUseId: attributedParentToolUseId, + message, + }); + if (!buffered) { + yield* Effect.logWarning("orchestration-v2.claude-subagent-lineage-buffer-full", { + providerTurnId: context.providerTurnId, + parentToolUseId: attributedParentToolUseId, + messageType: message.type, + }); + } + return; + } + + if (message.type === "assistant" && message.parent_tool_use_id === null) { context.nativeMessageCursor = message.uuid; } @@ -3834,6 +5085,7 @@ export function makeClaudeAdapterV2( return; } + let taskStartedToolUseId: string | undefined; if (message.type === "system" && message.subtype === "task_started") { if (isClaudeNonSubagentTask(message)) { context.ignoredTaskIds.add(message.task_id); @@ -3843,15 +5095,26 @@ export function makeClaudeAdapterV2( activeContext: context, }); } else { + taskStartedToolUseId = takePendingSubagentLaunchToolUseId( + context, + message.tool_use_id, + ); yield* updateClaudeSubagentNode({ context, taskId: message.task_id, - ...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }), + ...(taskStartedToolUseId === undefined ? {} : { toolUseId: taskStartedToolUseId }), ...(message.prompt === undefined ? {} : { prompt: message.prompt }), title: message.description, status: "running", reopen: true, }); + if (taskStartedToolUseId !== undefined) { + yield* registerSessionSubagentToolUseAlias({ + nativeThreadId: context.nativeThreadId, + taskId: message.task_id, + toolUseId: taskStartedToolUseId, + }); + } } } @@ -3861,11 +5124,16 @@ export function makeClaudeAdapterV2( liveQuery.nativeThreadId, message.task_id, ); - if ( - progress.length > 0 && - !context.ignoredTaskIds.has(message.task_id) && - !isBackgroundTask - ) { + const isSubagentTask = + !context.ignoredTaskIds.has(message.task_id) && !isBackgroundTask; + if (isSubagentTask && message.tool_use_id !== undefined) { + yield* registerSessionSubagentToolUseAlias({ + nativeThreadId: liveQuery.nativeThreadId, + taskId: message.task_id, + toolUseId: message.tool_use_id, + }); + } + if (progress.length > 0 && isSubagentTask) { yield* updateClaudeSubagentNode({ context, taskId: message.task_id, @@ -3891,11 +5159,19 @@ export function makeClaudeAdapterV2( activeContext: context, }); if (!wasBackgroundTask && !context.ignoredTaskIds.has(message.task_id)) { + if (message.tool_use_id !== undefined) { + yield* registerSessionSubagentToolUseAlias({ + nativeThreadId: context.nativeThreadId, + taskId: message.task_id, + toolUseId: message.tool_use_id, + }); + } yield* updateClaudeSubagentNode({ context, taskId: message.task_id, ...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }), result: message.summary, + resultNativeItemId: message.uuid, status: message.status === "completed" ? "completed" @@ -3914,8 +5190,30 @@ export function makeClaudeAdapterV2( } } + if ( + message.type === "system" && + (message.subtype === "task_started" || + message.subtype === "task_progress" || + message.subtype === "task_notification") + ) { + const toolUseId = message.tool_use_id ?? taskStartedToolUseId; + const pendingMessages = yield* takePendingSubagentMessages({ + nativeThreadId: context.nativeThreadId, + taskId: message.task_id, + ...(toolUseId === undefined ? {} : { toolUseId }), + }); + for (const pendingMessage of pendingMessages) { + yield* handleSdkMessageUnlocked({ query: input.query, message: pendingMessage }); + } + } + for (const toolUse of claudeToolUseBlocksFromAssistantMessage(message)) { if (toolUse.name === "Agent") { + rememberPendingSubagentLaunchToolUseId(context, toolUse.id); + yield* rememberSessionSubagentLaunchToolUseId({ + nativeThreadId: context.nativeThreadId, + toolUseId: toolUse.id, + }); continue; } yield* ensureToolCallStarted({ @@ -3927,14 +5225,78 @@ export function makeClaudeAdapterV2( }); } - for (const { toolResult, output } of claudeToolResultEntriesFromMessage(message)) { - const subagent = context.subagentsByToolUseId.get(toolResult.tool_use_id); + for (const { + message: toolResultMessage, + toolResult, + output, + } of claudeToolResultEntriesFromMessage(message)) { + const existingToolCall = context.toolCalls.get(toolResult.tool_use_id); + let resolvedSubagent = yield* resolveSessionSubagent({ + nativeThreadId: context.nativeThreadId, + toolUseId: toolResult.tool_use_id, + }); + const outputTaskId = + existingToolCall?.subagentTaskId === null + ? undefined + : claudeSubagentTaskIdFromToolOutput(output); + const isKnownAgentLaunch = yield* isSessionSubagentLaunchToolUseId({ + nativeThreadId: context.nativeThreadId, + toolUseId: toolResult.tool_use_id, + }); + if (resolvedSubagent === undefined && outputTaskId !== undefined) { + const resolvedByTaskId = yield* resolveSessionSubagent({ + nativeThreadId: context.nativeThreadId, + taskId: outputTaskId, + }); + if (resolvedByTaskId !== undefined && isKnownAgentLaunch) { + yield* registerSessionSubagentToolUseAlias({ + nativeThreadId: context.nativeThreadId, + taskId: outputTaskId, + toolUseId: toolResult.tool_use_id, + }); + resolvedSubagent = yield* resolveSessionSubagent({ + nativeThreadId: context.nativeThreadId, + toolUseId: toolResult.tool_use_id, + }); + } else { + resolvedSubagent = resolvedByTaskId; + } + } + const isUnresolvedAgentLaunch = + resolvedSubagent === undefined && (outputTaskId !== undefined || isKnownAgentLaunch); + if (isUnresolvedAgentLaunch) { + if (isClaudeSubagentAsyncLaunchAck(output)) { + continue; + } + const buffered = yield* bufferPendingSubagentMessage({ + nativeThreadId: context.nativeThreadId, + parentToolUseId: toolResult.tool_use_id, + ...(outputTaskId === undefined ? {} : { taskId: outputTaskId }), + message: toolResultMessage, + }); + if (!buffered) { + yield* Effect.logWarning( + "orchestration-v2.claude-subagent-launch-result-buffer-full", + { + providerTurnId: context.providerTurnId, + toolUseId: toolResult.tool_use_id, + ...(outputTaskId === undefined ? {} : { taskId: outputTaskId }), + }, + ); + } + continue; + } + const subagent = resolvedSubagent?.subagent; // A resume task_started reuses the resuming tool call's // tool_use_id (e.g. SendMessage), whose tool_result only // acknowledges delivery. Only the Agent launch's tool_result may // terminalize the subagent, and Agent tool_uses never enter // toolCalls (they project as subagent rows instead). - if (subagent !== undefined && !context.toolCalls.has(toolResult.tool_use_id)) { + if ( + subagent !== undefined && + subagent.launchToolUseId === toolResult.tool_use_id && + !context.toolCalls.has(toolResult.tool_use_id) + ) { // A background Agent launch resolves its tool_use immediately // with an async-launch ACK while the task keeps running; only // the eventual task_notification terminalizes the subagent. @@ -3942,25 +5304,36 @@ export function makeClaudeAdapterV2( continue; } const result = claudeSubagentResultText(output); + const resultNativeItemId = + message.uuid === undefined + ? undefined + : `${message.uuid}:tool-result:${toolResult.tool_use_id}`; yield* updateClaudeSubagentNode({ context, taskId: subagent.task.nativeTaskRef?.nativeId ?? String(subagent.task.id), toolUseId: toolResult.tool_use_id, ...(result.length === 0 ? {} : { result }), + ...(resultNativeItemId === undefined ? {} : { resultNativeItemId }), status: isClaudeToolResultError(toolResult) ? "failed" : "completed", }); continue; } - const parentToolUseId = parentToolUseIdFromSdkMessage(message); + const parentToolUseId = + parentToolUseIdFromSdkMessage(message) ?? + (outputTaskId === undefined ? null : (subagent?.launchToolUseId ?? null)); const toolCall = - context.toolCalls.get(toolResult.tool_use_id) ?? + existingToolCall ?? (yield* ensureToolCallStarted({ context, nativeItemId: toolResult.tool_use_id, toolName: toolNameFromClaudeToolResult(toolResult), toolInput: EMPTY_CLAUDE_NATIVE_TOOL_INPUT, parentToolUseId, + ...(outputTaskId === undefined ? {} : { subagentTaskId: outputTaskId }), })); + if (toolCall === null) { + continue; + } const completedAt = yield* DateTime.now; const artifacts = buildToolCallArtifacts({ context, @@ -3979,11 +5352,39 @@ export function makeClaudeAdapterV2( updatedAt: completedAt, }); yield* emitToolCallArtifacts(artifacts); + if (toolCall.subagentTaskId !== null) { + yield* forgetActiveSubagentToolCall({ + nativeThreadId: context.nativeThreadId, + taskId: toolCall.subagentTaskId, + nativeItemId: toolCall.nativeItemId, + }); + } context.toolCalls.delete(toolCall.nativeItemId); } const assistantText = assistantTextFromSdkMessage(message); if (assistantText !== null && assistantText.text.length > 0) { + if (attributedParentToolUseId !== null) { + const resolved = yield* resolveSessionSubagent({ + nativeThreadId: context.nativeThreadId, + toolUseId: attributedParentToolUseId, + }); + if (resolved !== undefined) { + yield* emitSubagentAssistantTextArtifacts({ + context, + taskId: resolved.taskId, + nativeItemId: assistantText.nativeItemId, + text: assistantText.text, + }); + } else { + yield* Effect.logWarning("orchestration-v2.claude-subagent-text-unrouted", { + providerTurnId: context.providerTurnId, + parentToolUseId: attributedParentToolUseId, + nativeItemId: assistantText.nativeItemId, + }); + } + return; + } yield* emitAssistantTextArtifacts({ context, nativeItemId: assistantText.nativeItemId, @@ -4082,76 +5483,139 @@ export function makeClaudeAdapterV2( }); } }); + const handleSdkMessage = (input: { + readonly query: ClaudeAgentSdkQuerySession; + readonly message: SDKMessage; + }) => sdkMessagePermit.withPermit(handleSdkMessageUnlocked(input)); const canUseToolEffect = Effect.fn("ClaudeAdapterV2.canUseTool")(function* ( toolName: Parameters[0], toolInput: Parameters[1], callbackOptions: Parameters[2], ) { - const context = yield* Ref.get(activeTurn); - if (context === null) { - return { - behavior: "deny", - message: "Claude V2 adapter has no active turn for this tool request.", - toolUseID: callbackOptions.toolUseID, - } satisfies PermissionResult; - } + const setup = yield* sdkMessagePermit.withPermit( + Effect.gen(function* () { + const context = yield* Ref.get(activeTurn); + if (context === null) { + return { + type: "denied" as const, + result: { + behavior: "deny", + message: "Claude V2 adapter has no active turn for this tool request.", + toolUseID: callbackOptions.toolUseID, + } satisfies PermissionResult, + }; + } - const nativeRequestId = callbackOptions.toolUseID; - const nativeToolInput = claudeNativeToolInputFromRecord(toolInput); - if (toolName !== "Agent") { - yield* ensureToolCallStarted({ - context, - nativeItemId: nativeRequestId, - toolName, - toolInput: nativeToolInput, - parentToolUseId: null, - }); - } + const nativeRequestId = callbackOptions.toolUseID; + const nativeToolInput = claudeNativeToolInputFromRecord(toolInput); + const parentSubagent = + callbackOptions.agentID === undefined + ? undefined + : yield* resolveSessionSubagent({ + nativeThreadId: context.nativeThreadId, + taskId: callbackOptions.agentID, + }); + if (callbackOptions.agentID !== undefined && parentSubagent === undefined) { + yield* Effect.logWarning("orchestration-v2.claude-subagent-approval-unrouted", { + providerTurnId: context.providerTurnId, + taskId: callbackOptions.agentID, + nativeRequestId, + toolName, + }); + return { + type: "denied" as const, + result: { + behavior: "deny", + message: "Claude V2 adapter could not resolve this subagent tool request.", + toolUseID: callbackOptions.toolUseID, + } satisfies PermissionResult, + }; + } + const parentToolUseId = parentSubagent?.subagent.launchToolUseId ?? null; + if (toolName !== "Agent") { + const toolCall = yield* ensureToolCallStarted({ + context, + nativeItemId: nativeRequestId, + toolName, + toolInput: nativeToolInput, + parentToolUseId, + ...(callbackOptions.agentID === undefined + ? {} + : { subagentTaskId: callbackOptions.agentID }), + }); + if (toolCall === null) { + return { + type: "denied" as const, + result: { + behavior: "deny", + message: "Claude V2 adapter could not route this subagent tool request.", + toolUseID: callbackOptions.toolUseID, + } satisfies PermissionResult, + }; + } + } else { + rememberPendingSubagentLaunchToolUseId(context, nativeRequestId); + yield* rememberSessionSubagentLaunchToolUseId({ + nativeThreadId: context.nativeThreadId, + toolUseId: nativeRequestId, + }); + } - const requestKind = providerRequestKindFromClaudeTool(toolName); - const prompt = - callbackOptions.title ?? - callbackOptions.description ?? - callbackOptions.decisionReason ?? - summarizeClaudeToolRequest(toolName, nativeToolInput); - const artifacts = yield* buildApprovalRequestArtifacts({ - context, - nativeItemId: nativeRequestId, - nativeRequestId, - requestKind, - prompt, - }); - const decision = yield* Deferred.make(); - yield* Ref.update(pendingRuntimeRequests, (current) => { - const updated = new Map(current); - updated.set(String(artifacts.request.id), { - requestId: artifacts.request.id, - requestKind, - decision, - }); - return updated; - }); - yield* Effect.all( - [ - emitProviderEvent({ - type: "node.updated", - driver: CLAUDE_PROVIDER, - node: artifacts.node, - }), - emitProviderEvent({ - type: "runtime_request.updated", - driver: CLAUDE_PROVIDER, - runtimeRequest: artifacts.request, - }), - emitProviderEvent({ - type: "turn_item.updated", - driver: CLAUDE_PROVIDER, - turnItem: artifacts.turnItem, - }), - ], - { concurrency: 1 }, + const requestKind = providerRequestKindFromClaudeTool(toolName); + const prompt = + callbackOptions.title ?? + callbackOptions.description ?? + callbackOptions.decisionReason ?? + summarizeClaudeToolRequest(toolName, nativeToolInput); + const artifacts = yield* buildApprovalRequestArtifacts({ + context, + nativeItemId: nativeRequestId, + nativeRequestId, + parentToolUseId, + ...(callbackOptions.agentID === undefined + ? {} + : { subagentTaskId: callbackOptions.agentID }), + requestKind, + prompt, + }); + const decision = yield* Deferred.make(); + yield* Ref.update(pendingRuntimeRequests, (current) => { + const updated = new Map(current); + updated.set(String(artifacts.request.id), { + requestId: artifacts.request.id, + requestKind, + decision, + }); + return updated; + }); + yield* Effect.all( + [ + emitProviderEvent({ + type: "node.updated", + driver: CLAUDE_PROVIDER, + node: artifacts.node, + }), + emitProviderEvent({ + type: "runtime_request.updated", + driver: CLAUDE_PROVIDER, + runtimeRequest: artifacts.request, + }), + emitProviderEvent({ + type: "turn_item.updated", + driver: CLAUDE_PROVIDER, + turnItem: artifacts.turnItem, + }), + ], + { concurrency: 1 }, + ); + return { type: "pending" as const, artifacts, context, decision, nativeRequestId }; + }), ); + if (setup.type === "denied") { + return setup.result; + } + const { artifacts, context, decision, nativeRequestId } = setup; const abort = () => { runFork(Deferred.succeed(decision, "cancel")); @@ -4168,6 +5632,18 @@ export function makeClaudeAdapterV2( ); callbackOptions.signal.removeEventListener("abort", abort); + if ( + toolName === "Agent" && + resolvedDecision !== "accept" && + resolvedDecision !== "acceptForSession" + ) { + yield* sdkMessagePermit.withPermit( + Effect.sync(() => { + forgetPendingSubagentLaunchToolUseId(context, nativeRequestId); + }), + ); + } + return permissionResultFromDecision({ decision: resolvedDecision, toolInput, @@ -4214,7 +5690,14 @@ export function makeClaudeAdapterV2( // the replacement open succeeds or fails below. const closedExistingNativeThreadId = existing !== null ? existing.nativeThreadId : null; if (existing !== null) { + yield* Ref.update(queryContext, (current) => + current?.query === existing.query ? null : current, + ); yield* existing.query.close.pipe(Effect.ignore); + const hasBufferedWake = (yield* Ref.get(wakeBuffers)).has(existing.nativeThreadId); + if (existing.nativeThreadId !== nativeThreadId || !hasBufferedWake) { + yield* clearNativeThreadSubagents(existing.nativeThreadId); + } if (existing.nativeThreadId !== nativeThreadId) { yield* clearWakeStateForNativeThread(existing.nativeThreadId); yield* resetBackgroundTaskStateForNativeThreadProcess(existing.nativeThreadId, { @@ -4263,7 +5746,6 @@ export function makeClaudeAdapterV2( // native-session reset events. closedExistingNativeThreadId === nativeThreadId ? Effect.gen(function* () { - yield* clearWakeStateForNativeThread(nativeThreadId); yield* resetBackgroundTaskStateForNativeThreadProcess(nativeThreadId, { status: "idle", }); @@ -4313,6 +5795,7 @@ export function makeClaudeAdapterV2( yield* finalizeActiveTurnAfterQueryExit( exit._tag === "Failure" ? exit.cause : undefined, ); + yield* clearNativeThreadState(nativeThreadId); } }), ), @@ -4350,6 +5833,7 @@ export function makeClaudeAdapterV2( yield* rememberProviderThread(turnInput.providerThread); const context: ActiveClaudeTurnContext = { input: turnInput, + nativeThreadId, nativeTurnId, nativeMessageCursor: null, providerTurnId, @@ -4361,10 +5845,9 @@ export function makeClaudeAdapterV2( emittedNativeItemIds: new Set(), }, toolCalls: new Map(), + pendingSubagentLaunchToolUseIds: [], + subagentLaunchAliasInferenceDisabled: false, ignoredTaskIds: new Set(), - subagentsByTaskId: new Map(), - subagentsByToolUseId: new Map(), - subagentNodesByTaskId: new Map(), }; // Continuation turns attach to the wake output the CLI already // produced instead of prompting it again: drain the buffered wake @@ -4382,77 +5865,95 @@ export function makeClaudeAdapterV2( attachmentsDir, fileSystem, }); - const querySession = yield* openQuery(turnInput, nativeThreadId); - yield* Ref.set(activeTurn, context); - yield* emitProviderEvent({ - type: "provider_turn.updated", - driver: CLAUDE_PROVIDER, - providerTurn: providerTurnPayload({ - context, - status: "running", - completedAt: null, + const existingQuery = isContinuationTurn ? yield* Ref.get(queryContext) : null; + const querySession = + existingQuery?.nativeThreadId === nativeThreadId + ? existingQuery + : yield* openQuery(turnInput, nativeThreadId); + yield* sdkMessagePermit.withPermit( + Effect.gen(function* () { + yield* Ref.set(activeTurn, context); + yield* emitProviderEvent({ + type: "provider_turn.updated", + driver: CLAUDE_PROVIDER, + providerTurn: providerTurnPayload({ + context, + status: "running", + completedAt: null, + }), + }); + if (userMessage !== null) { + // A user turn that races a wake leaves the buffer alone: the + // continuation run the worker queued behind this run drains it + // afterwards with correct attribution. + yield* querySession.query.offer(userMessage); + return; + } + const drained = yield* Ref.modify(wakeBuffers, (current) => { + const entry = current.get(nativeThreadId); + if (entry === undefined) { + return [[] as ReadonlyArray, current] as const; + } + const updated = new Map(current); + updated.delete(nativeThreadId); + return [entry.messages, updated] as const; + }); + yield* Ref.update(continuationStateByNativeThread, (current) => { + const existing = current.get(nativeThreadId); + if (existing === undefined || existing.requested === false) { + return current; + } + const updated = new Map(current); + updated.set(nativeThreadId, { ...existing, requested: false }); + return updated; + }); + if (drained.length === 0) { + // Spurious continuation (buffer already lost with a recycled + // session, or a duplicate request): settle immediately instead + // of leaving a run waiting on a prompt that was never sent. + const completedAt = yield* DateTime.now; + yield* finalizeActiveTurn({ context, status: "completed", completedAt }); + return; + } + // Replay any result message last: a result finalizes the turn, and + // replaying it before the rest would drop them back into the wake + // buffer and request another continuation. + const resultMessages = drained.filter((entry) => entry.type === "result"); + const opaqueReplayTombstones = taskIdSetForNativeThread( + yield* Ref.get(opaqueBackgroundTaskReplayTombstonesByNativeThread), + nativeThreadId, + ); + const hasOpaqueTaskNotification = drained.some( + (entry) => + entry.type === "system" && + entry.subtype === "task_notification" && + opaqueReplayTombstones.has(entry.task_id), + ); + for (const entry of drained) { + if (entry.type !== "result") { + yield* handleSdkMessageUnlocked({ + query: querySession.query, + message: entry, + }); + } + } + const lastResult = resultMessages.at(-1); + if (lastResult !== undefined) { + yield* handleSdkMessageUnlocked({ + query: querySession.query, + message: lastResult, + }); + return; + } + const hasNativeWakeFrame = drained.some( + (entry) => entry.type === "user" || entry.type === "assistant", + ); + if (hasOpaqueTaskNotification && !hasNativeWakeFrame) { + const completedAt = yield* DateTime.now; + yield* finalizeActiveTurn({ context, status: "completed", completedAt }); + } }), - }); - if (userMessage !== null) { - // A user turn that races a wake leaves the buffer alone: the - // continuation run the worker queued behind this run drains it - // afterwards with correct attribution. - yield* querySession.query.offer(userMessage); - return; - } - const drained = yield* Ref.modify(wakeBuffers, (current) => { - const entry = current.get(nativeThreadId); - if (entry === undefined) { - return [[] as ReadonlyArray, current] as const; - } - const updated = new Map(current); - updated.delete(nativeThreadId); - return [entry.messages, updated] as const; - }); - yield* Ref.update(requestedContinuations, (current) => { - const updated = new Set(current); - updated.delete(nativeThreadId); - return updated; - }); - if (drained.length === 0) { - // Spurious continuation (buffer already lost with a recycled - // session, or a duplicate request): settle immediately instead - // of leaving a run waiting on a prompt that was never sent. - const completedAt = yield* DateTime.now; - yield* finalizeActiveTurn({ context, status: "completed", completedAt }); - return; - } - // Replay any result message last: a result finalizes the turn, and - // replaying it before the rest would drop them back into the wake - // buffer and request another continuation. - const resultMessages = drained.filter((entry) => entry.type === "result"); - const opaqueReplayTombstones = taskIdSetForNativeThread( - yield* Ref.get(opaqueBackgroundTaskReplayTombstonesByNativeThread), - nativeThreadId, - ); - const hasOpaqueTaskNotification = drained.some( - (entry) => - entry.type === "system" && - entry.subtype === "task_notification" && - opaqueReplayTombstones.has(entry.task_id), - ); - for (const entry of drained) { - if (entry.type !== "result") { - yield* handleSdkMessage({ query: querySession.query, message: entry }); - } - } - const lastResult = resultMessages.at(-1); - if (lastResult !== undefined) { - yield* handleSdkMessage({ query: querySession.query, message: lastResult }); - return; - } - const hasNativeWakeFrame = drained.some( - (entry) => entry.type === "user" || entry.type === "assistant", ); - if (hasOpaqueTaskNotification && !hasNativeWakeFrame) { - const completedAt = yield* DateTime.now; - yield* finalizeActiveTurn({ context, status: "completed", completedAt }); - } }, (effect, turnInput) => effect.pipe( @@ -4508,6 +6009,7 @@ export function makeClaudeAdapterV2( yield* Ref.update(queryContext, (current) => current?.query === existing.query ? null : current, ); + yield* clearNativeThreadState(existing.nativeThreadId); yield* finalizeActiveTurn({ context: currentTurn, status: "interrupted", @@ -4590,18 +6092,39 @@ export function makeClaudeAdapterV2( }), ), ); + yield* sdkMessagePermit.withPermit( + Effect.gen(function* () { + const nativeThreadIds = [ + ...(yield* Ref.get(sessionSubagents)).byNativeThreadId.keys(), + ]; + yield* Effect.forEach(nativeThreadIds, clearNativeThreadStateUnlocked, { + concurrency: 1, + }); + yield* Ref.set(sessionSubagents, { byNativeThreadId: new Map() }); + yield* Ref.set(wakeBuffers, new Map()); + yield* Ref.set(continuationStateByNativeThread, new Map()); + yield* Ref.set(failedWakeDrainByNativeThread, new Map()); + yield* Ref.set(pendingBackgroundTasksByNativeThread, new Map()); + yield* Ref.set(wakeEligibleBackgroundTasksByNativeThread, new Map()); + yield* Ref.set(opaqueBackgroundTaskReplayTombstonesByNativeThread, new Map()); + yield* Ref.set(lastProviderThreadByNativeThread, new Map()); + }), + ); }); const closeLiveQueryForNativeThread = Effect.fnUntraced(function* (nativeThreadId: string) { const existing = yield* Ref.get(queryContext); if (existing === null || existing.nativeThreadId !== nativeThreadId) { + yield* clearNativeThreadState(nativeThreadId); return; } + yield* Ref.set(queryContext, null); yield* existing.query.close.pipe(Effect.ignore); const closed = yield* Deferred.await(existing.closed).pipe( Effect.timeoutOption("10 seconds"), ); + yield* clearNativeThreadState(nativeThreadId); if (Option.isSome(closed)) { return; } @@ -4610,9 +6133,6 @@ export function makeClaudeAdapterV2( providerSessionId: input.providerSessionId, nativeThreadId, }); - yield* Ref.update(queryContext, (current) => - current?.query === existing.query ? null : current, - ); yield* Deferred.succeed(existing.closed, undefined); }); yield* Effect.addFinalizer(() => closeSession()); @@ -4630,10 +6150,17 @@ export function makeClaudeAdapterV2( return true; } } - for (const subagent of (yield* Ref.get(sessionSubagentsByTaskId)).values()) { - if (subagent.task.status === "running") { + for (const nativeThreadState of (yield* Ref.get( + sessionSubagents, + )).byNativeThreadId.values()) { + if (nativeThreadState.pendingResumeTaskIds.size > 0) { return true; } + for (const subagent of nativeThreadState.subagentsByTaskId.values()) { + if (subagent.task.status === "running") { + return true; + } + } } const buffers = yield* Ref.get(wakeBuffers); for (const entry of buffers.values()) { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 65abca30d9d..0b0cbd7e11e 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -96,6 +96,7 @@ import { type ProviderAdapterV2TurnInput, } from "../ProviderAdapter.ts"; import { + hasSubagentPromptText, makeSubagentChildThread, makeSubagentConversationArtifacts, subagentThreadTitle, @@ -2122,7 +2123,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi driver: CODEX_PROVIDER, providerThread, }); - if (input.emitInitialPrompt && input.prompt.length > 0) { + if (input.emitInitialPrompt && hasSubagentPromptText(input.prompt)) { const promptNativeItemId = `${input.nativeItemId}:prompt`; const promptArtifacts = makeSubagentConversationArtifacts({ messageId: idAllocator.derive.messageFromProviderItem({ diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts index b60b5591227..e19102a1464 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts @@ -75,6 +75,7 @@ import { type ProviderAdapterDriverCreateInput, } from "../ProviderAdapterDriver.ts"; import { + hasSubagentPromptText, makeSubagentChildThread, makeSubagentConversationArtifacts, subagentThreadTitle, @@ -1547,40 +1548,42 @@ export function makeCursorAdapterV2( creationSource: "provider", }), }); - const promptNativeId = `${nativeItemId}:prompt`; - const promptArtifacts = makeSubagentConversationArtifacts({ - messageId: idAllocator.derive.messageFromProviderItem({ - driver: CURSOR_PROVIDER, - nativeItemId: promptNativeId, - }), - turnItemId: idAllocator.derive.turnItemFromProviderItem({ + if (hasSubagentPromptText(args.prompt)) { + const promptNativeId = `${nativeItemId}:prompt`; + const promptArtifacts = makeSubagentConversationArtifacts({ + messageId: idAllocator.derive.messageFromProviderItem({ + driver: CURSOR_PROVIDER, + nativeItemId: promptNativeId, + }), + turnItemId: idAllocator.derive.turnItemFromProviderItem({ + driver: CURSOR_PROVIDER, + nativeItemId: promptNativeId, + }), + threadId: childThreadId, + rootNodeId: childRootNodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: { + driver: CURSOR_PROVIDER, + nativeId: promptNativeId, + strength: "weak", + }, + role: "user", + text: args.prompt, + ordinal: 100, + now, + }); + yield* emitProviderEvent({ + type: "message.updated", driver: CURSOR_PROVIDER, - nativeItemId: promptNativeId, - }), - threadId: childThreadId, - rootNodeId: childRootNodeId, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: { + message: promptArtifacts.message, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", driver: CURSOR_PROVIDER, - nativeId: promptNativeId, - strength: "weak", - }, - role: "user", - text: args.prompt, - ordinal: 100, - now, - }); - yield* emitProviderEvent({ - type: "message.updated", - driver: CURSOR_PROVIDER, - message: promptArtifacts.message, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: CURSOR_PROVIDER, - turnItem: promptArtifacts.turnItem, - }); + turnItem: promptArtifacts.turnItem, + }); + } } yield* emitProviderEvent({ diff --git a/apps/server/src/orchestration-v2/ProviderContinuationRequests.ts b/apps/server/src/orchestration-v2/ProviderContinuationRequests.ts index 0e892ee666b..a4f0ae2e3f3 100644 --- a/apps/server/src/orchestration-v2/ProviderContinuationRequests.ts +++ b/apps/server/src/orchestration-v2/ProviderContinuationRequests.ts @@ -6,16 +6,21 @@ import { ThreadId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; +import type * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; +export const PROVIDER_CONTINUATION_MESSAGE_TEXT = "Background task completed."; + export interface ProviderContinuationRequest { readonly threadId: ThreadId; readonly providerThreadId: ProviderThreadId; readonly driver: ProviderDriverKind; readonly detail: string | null; + /** Overrides the queued message text without discarding provider-specific detail. */ + readonly messageText?: string; /** * Durable ownership for an app-owned delegated-task completion delivery. * The continuation worker re-reads the cohort before dispatching so a later @@ -44,6 +49,8 @@ export interface ProviderContinuationRequest { ) => Effect.Effect, E, R>; /** Clears a pending offer that the continuation worker intentionally drops. */ readonly clearIfCurrent?: () => Effect.Effect; + /** Releases adapter state when continuation preparation or dispatch fails. */ + readonly failIfCurrent?: (cause: Cause.Cause) => Effect.Effect; } /** diff --git a/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts b/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts index 03d21828950..819d87e2997 100644 --- a/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts @@ -111,10 +111,22 @@ describe("ProviderContinuationService", () => { const dispatched = yield* Queue.unbounded(); yield* Effect.gen(function* () { const requests = yield* ProviderContinuationRequests; - yield* requests.offer(request()); - const command = (yield* Queue.take(dispatched)) as { readonly creationSource: string }; + yield* requests.offer({ + ...request(undefined, "Raw child task summary."), + messageText: "Background task completed.", + }); + const command = (yield* Queue.take(dispatched)) as { + readonly createdBy: string; + readonly creationSource: string; + readonly dispatchMode: { readonly type: string }; + readonly text: string; + }; // ClaudeAdapterV2 keys on this to attach buffered CLI output. + assert.equal(command.createdBy, "agent"); assert.equal(command.creationSource, "provider"); + assert.deepEqual(command.dispatchMode, { type: "queue_after_active" }); + assert.equal(command.text, "Background task completed."); + assert.notEqual(command.text, "Raw child task summary."); }).pipe( Effect.provide( testLayer({ dispatched, getThreadProjection: () => Effect.succeed(projection) }), @@ -124,6 +136,30 @@ describe("ProviderContinuationService", () => { }); }); + it.effect( + "preserves adapter-buffered provider detail without an explicit message override", + () => { + return Effect.gen(function* () { + const dispatched = yield* Queue.unbounded(); + yield* Effect.gen(function* () { + const requests = yield* ProviderContinuationRequests; + yield* requests.offer(request(undefined, "Background command completed: sleep 20")); + const command = (yield* Queue.take(dispatched)) as { + readonly creationSource: string; + readonly text: string; + }; + assert.equal(command.creationSource, "provider"); + assert.equal(command.text, "Background command completed: sleep 20"); + }).pipe( + Effect.provide( + testLayer({ dispatched, getThreadProjection: () => Effect.succeed(projection) }), + ), + Effect.scoped, + ); + }); + }, + ); + it.effect("delivers a message_text wake as a real prompt, not a buffered wake", () => { return Effect.gen(function* () { const dispatched = yield* Queue.unbounded(); @@ -732,4 +768,61 @@ describe("ProviderContinuationService", () => { ); }); }); + + it.effect("releases an adapter wake when projection lookup fails", () => { + return Effect.gen(function* () { + const dispatched = yield* Queue.unbounded(); + const released = yield* Ref.make(false); + yield* Effect.gen(function* () { + const requests = yield* ProviderContinuationRequests; + yield* requests.offer({ + ...request(), + failIfCurrent: () => Ref.set(released, true), + }); + for (let attempt = 0; attempt < 20 && !(yield* Ref.get(released)); attempt += 1) { + yield* Effect.yieldNow; + } + assert.isTrue(yield* Ref.get(released)); + assert.isTrue(Option.isNone(yield* Queue.poll(dispatched))); + }).pipe( + Effect.provide( + testLayer({ + dispatched, + getThreadProjection: () => Effect.die("projection unavailable"), + }), + ), + Effect.scoped, + ); + }); + }); + + it.effect("releases a guarded adapter wake when dispatch fails", () => { + return Effect.gen(function* () { + const attempts = yield* Ref.make(0); + const released = yield* Ref.make(false); + const threads = Layer.mock(ThreadManagementService)({ + getThreadProjection: () => Effect.succeed(projection), + dispatch: () => + Ref.update(attempts, (count) => count + 1).pipe( + Effect.andThen(Effect.fail(new Error("simulated adapter dispatch failure") as never)), + ), + }); + const worker = workerLive.pipe( + Layer.provide(Layer.mergeAll(idAllocatorLayer, continuationRequestsLayer, threads)), + ); + + yield* Effect.gen(function* () { + const requests = yield* ProviderContinuationRequests; + yield* requests.offer({ + ...request((effect) => effect.pipe(Effect.map(Option.some))), + failIfCurrent: () => Ref.set(released, true), + }); + for (let attempt = 0; attempt < 20 && !(yield* Ref.get(released)); attempt += 1) { + yield* Effect.yieldNow; + } + assert.isTrue(yield* Ref.get(released)); + assert.equal(yield* Ref.get(attempts), 1); + }).pipe(Effect.provide(Layer.merge(continuationRequestsLayer, worker)), Effect.scoped); + }); + }); }); diff --git a/apps/server/src/orchestration-v2/ProviderContinuationService.ts b/apps/server/src/orchestration-v2/ProviderContinuationService.ts index 2ea80a34db3..ff2a543339f 100644 --- a/apps/server/src/orchestration-v2/ProviderContinuationService.ts +++ b/apps/server/src/orchestration-v2/ProviderContinuationService.ts @@ -6,12 +6,11 @@ import * as Ref from "effect/Ref"; import { IdAllocatorV2 } from "./IdAllocator.ts"; import { type ProviderContinuationRequest, + PROVIDER_CONTINUATION_MESSAGE_TEXT, ProviderContinuationRequests, } from "./ProviderContinuationRequests.ts"; import { ThreadManagementService } from "./ThreadManagementService.ts"; -const CONTINUATION_MESSAGE_TEXT = "Background task completed."; - function delegatedCompletionText(taskIds: ReadonlyArray): string { const taskList = taskIds.join(", "); return taskIds.length === 1 @@ -141,12 +140,13 @@ export const workerLive = Layer.effectDiscard( ordinal: projection.messages.length + 1, }); const commandId = CommandId.make(`provider-continuation:${messageId}`); + const text = request.messageText ?? request.detail ?? PROVIDER_CONTINUATION_MESSAGE_TEXT; const dispatch = threads.dispatch({ type: "message.dispatch", commandId, threadId: request.threadId, messageId, - text: request.detail ?? CONTINUATION_MESSAGE_TEXT, + text, attachments: [], dispatchMode: { type: "queue_after_active" }, createdBy: "agent", @@ -196,8 +196,20 @@ export const workerLive = Layer.effectDiscard( ), Effect.forkScoped, ); + return; + } + if (request.failIfCurrent !== undefined) { + yield* request.failIfCurrent(cause); } - }), + }).pipe( + Effect.catchCause((cleanupCause) => + Effect.logWarning("orchestration-v2.provider-continuation.failure-cleanup-failed", { + threadId: request.threadId, + providerThreadId: request.providerThreadId, + cause: cleanupCause, + }), + ), + ), ), ), ), diff --git a/apps/server/src/orchestration-v2/SubagentProjection.test.ts b/apps/server/src/orchestration-v2/SubagentProjection.test.ts index a1ff328d5ed..2dd5506e1bb 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.test.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.test.ts @@ -10,7 +10,7 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; -import { makeSubagentChildThread } from "./SubagentProjection.ts"; +import { hasSubagentPromptText, makeSubagentChildThread } from "./SubagentProjection.ts"; const parentThreadId = ThreadId.make("thread:subagent-snoozed-parent"); const childThreadId = ThreadId.make("thread:subagent-awake-child"); @@ -100,3 +100,13 @@ it("keeps a subagent child awake when its parent thread is snoozed", () => { nodeId: parentNodeId, }); }); + +it("only opens a subagent child thread for a prompt that has text", () => { + assert.isTrue(hasSubagentPromptText("Read tsconfig.json")); + assert.isTrue(hasSubagentPromptText(" leading and trailing ")); + assert.isFalse(hasSubagentPromptText("")); + assert.isFalse(hasSubagentPromptText(" ")); + assert.isFalse(hasSubagentPromptText("\n\t ")); + assert.isFalse(hasSubagentPromptText(null)); + assert.isFalse(hasSubagentPromptText(undefined)); +}); diff --git a/apps/server/src/orchestration-v2/SubagentProjection.ts b/apps/server/src/orchestration-v2/SubagentProjection.ts index 79c0881d59e..7824b7d913e 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.ts @@ -23,6 +23,15 @@ function trimmed(value: string | null | undefined): string | undefined { return result && result.length > 0 ? result : undefined; } +/** + * Guards the child thread's opening user message. A provider can register a + * subagent before it knows the prompt, and an agent can launch one with a blank + * prompt; either way an empty bubble under "Sent by another agent" is noise. + */ +export function hasSubagentPromptText(prompt: string | null | undefined): boolean { + return trimmed(prompt) !== undefined; +} + export function subagentThreadTitle(input: { readonly parentTitle: string; readonly title?: string | null; diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_subagent_empty_prompt/claude_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/claude_subagent_empty_prompt/claude_transcript.ndjson new file mode 100644 index 00000000000..c34a4a549a4 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_subagent_empty_prompt/claude_transcript.ndjson @@ -0,0 +1,21 @@ +{"type":"transcript_start","provider":"claudeAgent","protocol":"claude-agent-sdk.query","version":"0.2.111","scenario":"claude_subagent_empty_prompt","metadata":{"prompts":["Launch two subagents with the Agent tool. Give the first one a blank prompt. Let the second report progress before its prompt is known."],"model":"claude-sonnet-4-6","nativeSessionId":"3b7c1f2a-4d5e-4a6b-8c9d-0e1f2a3b4c5d","queryMode":"streaming","tools":"claude_code","permissionMode":"bypassPermissions","derivedFrom":"subagent/claude_transcript.ndjson","derivation":"Recorded Agent-tool session with a whitespace-only launch prompt and a task_progress frame that registers the second subagent before its task_started prompt arrives."}} +{"type":"expect_outbound","label":"query.open","frame":{"type":"query.open","options":{"model":"claude-sonnet-4-6","tools":{"type":"preset","preset":"claude_code"},"permissionMode":"bypassPermissions","allowDangerouslySkipPermissions":true,"sessionId":"3b7c1f2a-4d5e-4a6b-8c9d-0e1f2a3b4c5d"}}} +{"type":"expect_outbound","label":"prompt.offer:1","frame":{"type":"prompt.offer","message":{"type":"user","message":{"role":"user","content":"Launch two subagents with the Agent tool. Give the first one a blank prompt. Let the second report progress before its prompt is known."},"parent_tool_use_id":null}}} +{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"init","agents":[],"apiKeySource":"none","claude_code_version":"2.1.111","cwd":"/tmp/claude-replay-claude_subagent_empty_prompt","tools":[],"mcp_servers":[],"model":"claude-sonnet-4-6","permissionMode":"bypassPermissions","slash_commands":[],"output_style":"default","skills":[],"plugins":[],"fast_mode_state":"off","uuid":"00000000-0000-4000-8000-000000000001","session_id":"3b7c1f2a-4d5e-4a6b-8c9d-0e1f2a3b4c5d"}} +{"type":"emit_inbound","label":"assistant","frame":{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01EmptyPromptFixture","type":"message","role":"assistant","content":[{"type":"text","text":"Launching both subagents now."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":1024,"cache_read_input_tokens":4096,"cache_creation":{"ephemeral_5m_input_tokens":1024,"ephemeral_1h_input_tokens":0},"output_tokens":64,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"3b7c1f2a-4d5e-4a6b-8c9d-0e1f2a3b4c5d","uuid":"00000000-0000-4000-8000-000000000002"}} +{"type":"emit_inbound","label":"assistant","frame":{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01EmptyPromptFixture","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01BlankPromptFixtureAA","name":"Agent","input":{"description":"Blank prompt subagent","prompt":" "},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":1024,"cache_read_input_tokens":4096,"cache_creation":{"ephemeral_5m_input_tokens":1024,"ephemeral_1h_input_tokens":0},"output_tokens":64,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"3b7c1f2a-4d5e-4a6b-8c9d-0e1f2a3b4c5d","uuid":"00000000-0000-4000-8000-000000000003"}} +{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"task_started","task_id":"b71f0c9d4e2a35168","tool_use_id":"toolu_01BlankPromptFixtureAA","description":"Blank prompt subagent","task_type":"local_agent","prompt":" ","uuid":"00000000-0000-4000-8000-000000000004","session_id":"3b7c1f2a-4d5e-4a6b-8c9d-0e1f2a3b4c5d"}} +{"type":"emit_inbound","label":"user","frame":{"type":"user","message":{"role":"user","content":[{"type":"text","text":" "}]},"parent_tool_use_id":"toolu_01BlankPromptFixtureAA","session_id":"3b7c1f2a-4d5e-4a6b-8c9d-0e1f2a3b4c5d","uuid":"00000000-0000-4000-8000-000000000005","timestamp":"2026-08-05T21:35:28.401Z"}} +{"type":"emit_inbound","label":"assistant","frame":{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01EmptyPromptFixture","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01LatePromptFixtureBB","name":"Agent","input":{"description":"Read tsconfig.json","prompt":"Read the file `tsconfig.json` in the current working directory and return its full contents."},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":1024,"cache_read_input_tokens":4096,"cache_creation":{"ephemeral_5m_input_tokens":1024,"ephemeral_1h_input_tokens":0},"output_tokens":64,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"3b7c1f2a-4d5e-4a6b-8c9d-0e1f2a3b4c5d","uuid":"00000000-0000-4000-8000-000000000006"}} +{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"task_progress","task_id":"c82a1d0e5f3b46279","tool_use_id":"toolu_01LatePromptFixtureBB","description":"Reading tsconfig.json","usage":{"total_tokens":8707,"tool_uses":1,"duration_ms":2053},"last_tool_name":"Read","uuid":"00000000-0000-4000-8000-000000000007","session_id":"3b7c1f2a-4d5e-4a6b-8c9d-0e1f2a3b4c5d"}} +{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"task_started","task_id":"c82a1d0e5f3b46279","tool_use_id":"toolu_01LatePromptFixtureBB","description":"Read tsconfig.json","task_type":"local_agent","prompt":"Read the file `tsconfig.json` in the current working directory and return its full contents.","uuid":"00000000-0000-4000-8000-000000000008","session_id":"3b7c1f2a-4d5e-4a6b-8c9d-0e1f2a3b4c5d"}} +{"type":"emit_inbound","label":"user","frame":{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Read the file `tsconfig.json` in the current working directory and return its full contents."}]},"parent_tool_use_id":"toolu_01LatePromptFixtureBB","session_id":"3b7c1f2a-4d5e-4a6b-8c9d-0e1f2a3b4c5d","uuid":"00000000-0000-4000-8000-000000000009","timestamp":"2026-08-05T21:35:28.401Z"}} +{"type":"emit_inbound","label":"assistant","frame":{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01EmptyPromptFixtureBlankChild","type":"message","role":"assistant","content":[{"type":"text","text":"I was launched without a prompt, so there was nothing to do."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":1024,"cache_read_input_tokens":4096,"cache_creation":{"ephemeral_5m_input_tokens":1024,"ephemeral_1h_input_tokens":0},"output_tokens":64,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_01BlankPromptFixtureAA","session_id":"3b7c1f2a-4d5e-4a6b-8c9d-0e1f2a3b4c5d","uuid":"00000000-0000-4000-8000-000000000010"}} +{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"task_notification","task_id":"b71f0c9d4e2a35168","tool_use_id":"toolu_01BlankPromptFixtureAA","status":"completed","output_file":"","summary":"Blank prompt subagent finished","usage":{"total_tokens":10750,"tool_uses":0,"duration_ms":4211},"uuid":"00000000-0000-4000-8000-000000000011","session_id":"3b7c1f2a-4d5e-4a6b-8c9d-0e1f2a3b4c5d"}} +{"type":"emit_inbound","label":"user","frame":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01BlankPromptFixtureAA","type":"tool_result","content":[{"type":"text","text":"I was launched without a prompt, so there was nothing to do."},{"type":"text","text":"agentId: b71f0c9d4e2a35168 (use SendMessage with to: 'b71f0c9d4e2a35168' to continue this agent)"}]}]},"parent_tool_use_id":null,"session_id":"3b7c1f2a-4d5e-4a6b-8c9d-0e1f2a3b4c5d","uuid":"00000000-0000-4000-8000-000000000012","timestamp":"2026-08-05T21:35:28.401Z","tool_use_result":{"status":"completed","prompt":" ","agentId":"b71f0c9d4e2a35168","agentType":"general-purpose","content":[{"type":"text","text":"I was launched without a prompt, so there was nothing to do."}],"totalDurationMs":4211,"totalTokens":9001,"totalToolUseCount":1}}} +{"type":"emit_inbound","label":"assistant","frame":{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01EmptyPromptFixtureLateChild","type":"message","role":"assistant","content":[{"type":"text","text":"Here are the full contents of `tsconfig.json`:\n\n```json\n{\"compilerOptions\":{\"module\":\"ESNext\",\"strict\":true,\"target\":\"ES2022\"}}\n```"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":1024,"cache_read_input_tokens":4096,"cache_creation":{"ephemeral_5m_input_tokens":1024,"ephemeral_1h_input_tokens":0},"output_tokens":64,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_01LatePromptFixtureBB","session_id":"3b7c1f2a-4d5e-4a6b-8c9d-0e1f2a3b4c5d","uuid":"00000000-0000-4000-8000-000000000013"}} +{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"task_notification","task_id":"c82a1d0e5f3b46279","tool_use_id":"toolu_01LatePromptFixtureBB","status":"completed","output_file":"","summary":"Read tsconfig.json","usage":{"total_tokens":10755,"tool_uses":1,"duration_ms":5183},"uuid":"00000000-0000-4000-8000-000000000014","session_id":"3b7c1f2a-4d5e-4a6b-8c9d-0e1f2a3b4c5d"}} +{"type":"emit_inbound","label":"user","frame":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01LatePromptFixtureBB","type":"tool_result","content":[{"type":"text","text":"Here are the full contents of `tsconfig.json`:\n\n```json\n{\"compilerOptions\":{\"module\":\"ESNext\",\"strict\":true,\"target\":\"ES2022\"}}\n```"},{"type":"text","text":"agentId: c82a1d0e5f3b46279 (use SendMessage with to: 'c82a1d0e5f3b46279' to continue this agent)"}]}]},"parent_tool_use_id":null,"session_id":"3b7c1f2a-4d5e-4a6b-8c9d-0e1f2a3b4c5d","uuid":"00000000-0000-4000-8000-000000000015","timestamp":"2026-08-05T21:35:28.401Z","tool_use_result":{"status":"completed","prompt":"Read the file `tsconfig.json` in the current working directory and return its full contents.","agentId":"c82a1d0e5f3b46279","agentType":"general-purpose","content":[{"type":"text","text":"Here are the full contents of `tsconfig.json`:\n\n```json\n{\"compilerOptions\":{\"module\":\"ESNext\",\"strict\":true,\"target\":\"ES2022\"}}\n```"}],"totalDurationMs":4211,"totalTokens":9001,"totalToolUseCount":1}}} +{"type":"emit_inbound","label":"assistant","frame":{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01EmptyPromptFixtureRootFinal","type":"message","role":"assistant","content":[{"type":"text","text":"Both subagents finished. The blank one had nothing to do; the other read tsconfig.json."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":1024,"cache_read_input_tokens":4096,"cache_creation":{"ephemeral_5m_input_tokens":1024,"ephemeral_1h_input_tokens":0},"output_tokens":64,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"3b7c1f2a-4d5e-4a6b-8c9d-0e1f2a3b4c5d","uuid":"00000000-0000-4000-8000-000000000016"}} +{"type":"emit_inbound","label":"result","frame":{"type":"result","subtype":"success","is_error":false,"api_error_status":null,"duration_ms":16909,"duration_api_ms":18827,"num_turns":3,"result":"Both subagents finished. The blank one had nothing to do; the other read tsconfig.json.","stop_reason":"end_turn","session_id":"3b7c1f2a-4d5e-4a6b-8c9d-0e1f2a3b4c5d","total_cost_usd":0.0421,"usage":{"input_tokens":212,"cache_creation_input_tokens":2832,"cache_read_input_tokens":21261,"output_tokens":419,"service_tier":"standard","inference_geo":""},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","uuid":"00000000-0000-4000-8000-000000000017"}} +{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_subagent_empty_prompt/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_subagent_empty_prompt/input.ts new file mode 100644 index 00000000000..77512201862 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_subagent_empty_prompt/input.ts @@ -0,0 +1,19 @@ +import type { OrchestratorFixtureInput } from "../shared.ts"; + +export const CLAUDE_SUBAGENT_EMPTY_PROMPT_ROOT_PROMPT = [ + "Launch two subagents with the Agent tool. Give the first one a blank prompt.", + "Let the second report progress before its prompt is known.", +].join(" "); + +/** The whitespace-only prompt the first Agent launch carries. */ +export const CLAUDE_SUBAGENT_EMPTY_PROMPT_BLANK_PROMPT = " "; + +/** Arrives on task_started only after task_progress already registered the child. */ +export const CLAUDE_SUBAGENT_EMPTY_PROMPT_LATE_PROMPT = + "Read the file `tsconfig.json` in the current working directory and return its full contents."; + +export function claudeSubagentEmptyPromptInput(): OrchestratorFixtureInput { + return { + steps: [{ type: "message", text: CLAUDE_SUBAGENT_EMPTY_PROMPT_ROOT_PROMPT }], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_subagent_empty_prompt/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_subagent_empty_prompt/output.ts new file mode 100644 index 00000000000..3b4a056b846 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_subagent_empty_prompt/output.ts @@ -0,0 +1,90 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertBaseProjection, + assertSemanticProjectionIntegrity, + assertUserMessagesInclude, + projectionFor, +} from "../shared.ts"; +import { + CLAUDE_SUBAGENT_EMPTY_PROMPT_BLANK_PROMPT, + CLAUDE_SUBAGENT_EMPTY_PROMPT_LATE_PROMPT, + CLAUDE_SUBAGENT_EMPTY_PROMPT_ROOT_PROMPT, +} from "./input.ts"; + +export function assertClaudeSubagentEmptyPromptOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + assertBaseProjection({ + result, + transcript, + runCount: 1, + runStatuses: ["completed"], + }); + + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + assertUserMessagesInclude(projection, [CLAUDE_SUBAGENT_EMPTY_PROMPT_ROOT_PROMPT]); + assert.lengthOf(projection.subagents, 2); + + for (const [threadId, childProjection] of result.projections) { + if (childProjection.thread.lineage.parentThreadId === null) { + continue; + } + assert.isFalse( + childProjection.messages.some( + (message) => message.role === "user" && message.text.trim().length === 0, + ), + `child thread ${threadId} must not open with a blank user message`, + ); + assert.isFalse( + childProjection.turnItems.some( + (item) => item.type === "user_message" && item.text.trim().length === 0, + ), + `child thread ${threadId} must not open with a blank user turn item`, + ); + } + + const blankSubagent = projection.subagents.find( + (subagent) => subagent.prompt === CLAUDE_SUBAGENT_EMPTY_PROMPT_BLANK_PROMPT, + ); + assert.isDefined(blankSubagent, "the whitespace-only launch must still register a subagent"); + assert.equal(blankSubagent.status, "completed"); + assert.isNotNull(blankSubagent.childThreadId); + if (blankSubagent.childThreadId === null) { + throw new Error(`Subagent ${blankSubagent.id} is missing its child thread`); + } + const blankChild = result.projections.get(blankSubagent.childThreadId); + assert.isDefined(blankChild); + assert.lengthOf( + blankChild.turnItems.filter((item) => item.type === "user_message"), + 0, + "a whitespace-only prompt must not project an opening user message", + ); + assert.isTrue( + blankChild.turnItems.some((item) => item.type === "assistant_message"), + "the child thread still receives its own assistant output", + ); + + const lateSubagent = projection.subagents.find( + (subagent) => subagent.prompt === CLAUDE_SUBAGENT_EMPTY_PROMPT_LATE_PROMPT, + ); + assert.isDefined( + lateSubagent, + "task_started must still fill in the prompt task_progress registered without", + ); + assert.equal(lateSubagent.status, "completed"); + if (lateSubagent.childThreadId === null) { + throw new Error(`Subagent ${lateSubagent.id} is missing its child thread`); + } + const lateChild = result.projections.get(lateSubagent.childThreadId); + assert.isDefined(lateChild); + assert.deepEqual( + lateChild.turnItems.filter((item) => item.type === "user_message").map((item) => item.text), + [CLAUDE_SUBAGENT_EMPTY_PROMPT_LATE_PROMPT], + "the opening message is emitted exactly once, when the prompt first has text", + ); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts index b7799362446..8214cd81e31 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts @@ -8,6 +8,8 @@ import { claudeLocalBashTaskInput } from "./claude_local_bash_task/input.ts"; import { assertClaudeLocalBashTaskOutput } from "./claude_local_bash_task/output.ts"; import { claudeResultIsErrorInput } from "./claude_result_is_error/input.ts"; import { assertClaudeResultIsErrorOutput } from "./claude_result_is_error/output.ts"; +import { claudeSubagentEmptyPromptInput } from "./claude_subagent_empty_prompt/input.ts"; +import { assertClaudeSubagentEmptyPromptOutput } from "./claude_subagent_empty_prompt/output.ts"; import { grokSubagentLineageInput } from "./grok_subagent_lineage/input.ts"; import { assertGrokSubagentLineageOutput } from "./grok_subagent_lineage/output.ts"; import { assertClaudeMessageSteeringOutput } from "./message_steering/claude_output.ts"; @@ -145,6 +147,21 @@ export const ORCHESTRATOR_REPLAY_FIXTURES: ReadonlyArray { ]); }); + it("hides provider-buffered automatic continuations from the visible queue", () => { + const state = deriveThreadQueueWorkflowState({ + thread: { id: "thread", activeProviderThreadId: null }, + runs: [ + { + id: "provider-automatic", + status: "queued", + userMessageId: "message-provider-automatic", + ordinal: 1, + queuePosition: 1, + }, + { + id: "user-provider-message", + status: "queued", + userMessageId: "message-user-provider", + ordinal: 2, + queuePosition: 2, + }, + { + id: "provider-detail", + status: "queued", + userMessageId: "message-provider-detail", + ordinal: 3, + queuePosition: 3, + }, + { + id: "visible", + status: "queued", + userMessageId: "message-visible", + ordinal: 4, + queuePosition: 4, + }, + ], + messages: [ + { + id: "message-provider-automatic", + text: "Background task completed.", + createdBy: "agent", + creationSource: "provider", + }, + { + id: "message-user-provider", + text: "User-created provider message", + createdBy: "user", + creationSource: "provider", + }, + { + id: "message-provider-detail", + text: "Background command completed: sleep 20", + createdBy: "agent", + creationSource: "provider", + }, + { id: "message-visible", text: "Visible queued message" }, + ], + providerTurns: [], + providerThreads: [], + providerSessions: [], + } as never); + + expect(state.queuedRuns.map(({ run, text }) => [run.id, text])).toEqual([ + ["user-provider-message", "User-created provider message"], + ["provider-detail", "Background command completed: sleep 20"], + ["visible", "Visible queued message"], + ]); + }); + it("removes only the promoted head from the visible queue", () => { const state = deriveThreadQueueWorkflowState({ thread: { id: "thread", activeProviderThreadId: "provider-thread" }, diff --git a/packages/client-runtime/src/state/threadWorkflows.ts b/packages/client-runtime/src/state/threadWorkflows.ts index 265be810e3c..563c2e103b6 100644 --- a/packages/client-runtime/src/state/threadWorkflows.ts +++ b/packages/client-runtime/src/state/threadWorkflows.ts @@ -16,6 +16,7 @@ const MERGE_BACK_BLOCKING_RUN_STATUSES = new Set([ "starting", "running", ]); +const PROVIDER_BUFFERED_CONTINUATION_TEXT = "Background task completed."; export interface QueuedThreadRun { readonly run: Run; @@ -92,7 +93,13 @@ export function deriveThreadQueueWorkflowState(projection: Projection): ThreadQu ); const automaticCompletionMessageIds = new Set( projection.messages - .filter((message) => message.delegatedCompletion !== undefined) + .filter( + (message) => + message.delegatedCompletion !== undefined || + (message.createdBy === "agent" && + message.creationSource === "provider" && + message.text === PROVIDER_BUFFERED_CONTINUATION_TEXT), + ) .map((message) => message.id), ); const queuedRuns = copySorted(