| ... | ... | @@ -44,6 +44,7 @@ import * as Queue from "effect/Queue"; |
| 44 | 44 | import * as Schema from "effect/Schema"; |
| 45 | 45 | import * as Scope from "effect/Scope"; |
| 46 | 46 | import * as Stream from "effect/Stream"; |
| 47 | import * as TestClock from "effect/testing/TestClock"; |
| 47 | 48 | import { Tool } from "effect/ai"; |
| 48 | 49 | import { formatClaudeResumeCompactionQuestion } from "@t3tools/shared/claudeCompaction"; |
| 49 | 50 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| ... | ... | @@ -2085,6 +2086,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2085 | 2086 | readonly close?: (sdkMessages: Queue.Queue<SDKMessage>) => Effect.Effect<void>; |
| 2086 | 2087 | readonly interrupt?: Effect.Effect<void>; |
| 2087 | 2088 | readonly environment?: NodeJS.ProcessEnv; |
| 2089 | // A CLI process opened after the first streams from its own queue, so the |
| 2090 | // first one can exit (Queue.shutdown) and a later turn can start another. |
| 2091 | readonly freshQueueOnReopen?: boolean; |
| 2088 | 2092 | }) => |
| 2089 | 2093 | Effect.gen(function* () { |
| 2090 | 2094 | const fileSystem = yield* FileSystem.FileSystem; |
| ... | ... | @@ -2093,6 +2097,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2093 | 2097 | prefix: "t3-claude-v2-wake-", |
| 2094 | 2098 | }); |
| 2095 | 2099 | const sdkMessages = yield* Queue.unbounded<SDKMessage>(); |
| 2100 | const processQueues: Array<Queue.Queue<SDKMessage>> = []; |
| 2096 | 2101 | const processedMessages = new WeakMap<SDKMessage, Deferred.Deferred<void>>(); |
| 2097 | 2102 | const offerAndWait = Effect.fnUntraced(function* (message: SDKMessage) { |
| 2098 | 2103 | const processed = yield* Deferred.make<void>(); |
| ... | ... | @@ -2126,8 +2131,24 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2126 | 2131 | queryRunner: { |
| 2127 | 2132 | allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), |
| 2128 | 2133 | open: (input) => |
| 2129 | | Effect.sync(() => { |
| 2134 | Effect.gen(function* () { |
| 2130 | 2135 | openedOptions = input.options; |
| 2136 | if (options?.freshQueueOnReopen === true && processQueues.length > 0) { |
| 2137 | const processMessages = yield* Queue.unbounded<SDKMessage>(); |
| 2138 | processQueues.push(processMessages); |
| 2139 | return { |
| 2140 | messages: Stream.fromQueue(processMessages), |
| 2141 | offer: (message: SDKUserMessage) => |
| 2142 | Effect.sync(() => { |
| 2143 | offeredMessages.push(message); |
| 2144 | }), |
| 2145 | setModel: () => Effect.void, |
| 2146 | setPermissionMode: () => Effect.void, |
| 2147 | interrupt: Effect.void, |
| 2148 | close: Queue.shutdown(processMessages), |
| 2149 | }; |
| 2150 | } |
| 2151 | processQueues.push(sdkMessages); |
| 2131 | 2152 | return { |
| 2132 | 2153 | messages: Stream.fromQueue(sdkMessages).pipe( |
| 2133 | 2154 | Stream.flatMap((message) => |
| ... | ... | @@ -2205,6 +2226,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2205 | 2226 | providerThread, |
| 2206 | 2227 | threadId, |
| 2207 | 2228 | sdkMessages, |
| 2229 | processQueues, |
| 2208 | 2230 | offerAndWait, |
| 2209 | 2231 | offeredMessages, |
| 2210 | 2232 | permissionModeChanges, |
| ... | ... | @@ -6261,150 +6283,174 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 6261 | 6283 | ), |
| 6262 | 6284 | ); |
| 6263 | 6285 | |
| 6264 | | it.effect("releases held frames before the notification that first names the tool use", () => |
| 6286 | it.effect("streams a background subagent's work while the root turn is idle", () => |
| 6265 | 6287 | Effect.scoped( |
| 6266 | 6288 | Effect.gen(function* () { |
| 6267 | | const TASK_ID = "task-late-tool-use"; |
| 6268 | | const TOOL_USE_ID = "toolu-late-tool-use"; |
| 6289 | const TASK_ID = "task-live-idle"; |
| 6290 | const TOOL_USE_ID = "toolu-live-idle"; |
| 6291 | const STRADDLING_BASH = "toolu-live-straddling-bash"; |
| 6292 | const IDLE_BASH = "toolu-live-idle-bash"; |
| 6269 | 6293 | const harness = yield* makeWakeHarness; |
| 6270 | 6294 | const now = yield* DateTime.now; |
| 6295 | const latestToolItem = (nativeId: string) => |
| 6296 | harness.events.findLast( |
| 6297 | (event) => |
| 6298 | event.type === "turn_item.updated" && |
| 6299 | event.turnItem.nativeItemRef?.nativeId === nativeId, |
| 6300 | ); |
| 6271 | 6301 | yield* harness.runtime.startTurn( |
| 6272 | 6302 | makeClaudeTestTurnInput({ |
| 6273 | 6303 | threadId: harness.threadId, |
| 6274 | 6304 | providerThread: harness.providerThread, |
| 6275 | 6305 | now, |
| 6276 | | attemptId: RunAttemptId.make("attempt-claude-late-tool-use"), |
| 6277 | | text: "Run an auditor.", |
| 6306 | attemptId: RunAttemptId.make("attempt-claude-live-idle-1"), |
| 6307 | text: "Audit in the background.", |
| 6278 | 6308 | attachments: [], |
| 6279 | 6309 | }), |
| 6280 | 6310 | ); |
| 6281 | | const frames = [ |
| 6282 | | claudeSdkFrame({ |
| 6283 | | type: "system", |
| 6284 | | subtype: "task_started", |
| 6285 | | task_id: TASK_ID, |
| 6286 | | description: "Audit recent commits", |
| 6287 | | task_type: "local_agent", |
| 6288 | | prompt: "Audit the last five commits.", |
| 6289 | | uuid: "00000000-0000-4000-8000-000000000341", |
| 6290 | | session_id: WAKE_NATIVE_SESSION, |
| 6311 | // The root settles while the subagent's first Bash call still runs. |
| 6312 | for (const frame of [ |
| 6313 | makeSubagentTaskStartedFrame({ |
| 6314 | taskId: TASK_ID, |
| 6315 | toolUseId: TOOL_USE_ID, |
| 6316 | uuid: "00000000-0000-4000-8000-000000000951", |
| 6291 | 6317 | }), |
| 6292 | | // task_started carried no tool_use_id, so these frames are held |
| 6293 | | // until the notification pairs the task with its tool use. The SDK |
| 6294 | | // types tool_use_id as optional; no recorded or logged task_started |
| 6295 | | // has omitted it, so this guards the typed contract only. |
| 6296 | 6318 | ...makeSubagentAssistantFrames({ |
| 6297 | 6319 | parentToolUseId: TOOL_USE_ID, |
| 6298 | | uuid: "00000000-0000-4000-8000-000000000342", |
| 6299 | | text: "Working.", |
| 6320 | uuid: "00000000-0000-4000-8000-000000000952", |
| 6321 | bashToolUseId: STRADDLING_BASH, |
| 6300 | 6322 | }), |
| 6301 | | // The final answer arrives as one snapshot per text block. |
| 6302 | | ...makeSubagentAssistantFrames({ |
| 6323 | makeResultFrame({ |
| 6324 | uuid: "00000000-0000-4000-8000-000000000953", |
| 6325 | result: "Auditing in the background.", |
| 6326 | }), |
| 6327 | ]) { |
| 6328 | yield* Queue.offer(harness.sdkMessages, frame); |
| 6329 | } |
| 6330 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); |
| 6331 | |
| 6332 | for (const frame of [ |
| 6333 | makeSubagentToolResultFrame({ |
| 6303 | 6334 | parentToolUseId: TOOL_USE_ID, |
| 6304 | | uuid: "00000000-0000-4000-8000-000000000344", |
| 6305 | | messageId: "msg_late_final", |
| 6306 | | text: "Part one.", |
| 6335 | uuid: "00000000-0000-4000-8000-000000000954", |
| 6336 | toolUseId: STRADDLING_BASH, |
| 6307 | 6337 | }), |
| 6308 | 6338 | ...makeSubagentAssistantFrames({ |
| 6309 | 6339 | parentToolUseId: TOOL_USE_ID, |
| 6310 | | uuid: "00000000-0000-4000-8000-000000000345", |
| 6311 | | messageId: "msg_late_final", |
| 6312 | | text: "Part two.", |
| 6340 | uuid: "00000000-0000-4000-8000-000000000955", |
| 6341 | text: "Still auditing.", |
| 6342 | bashToolUseId: IDLE_BASH, |
| 6343 | }), |
| 6344 | claudeSdkFrame({ |
| 6345 | type: "system", |
| 6346 | subtype: "task_progress", |
| 6347 | task_id: TASK_ID, |
| 6348 | description: "Reading the last commits", |
| 6349 | uuid: "00000000-0000-4000-8000-000000000956", |
| 6350 | session_id: WAKE_NATIVE_SESSION, |
| 6351 | }), |
| 6352 | ]) { |
| 6353 | yield* Queue.offer(harness.sdkMessages, frame); |
| 6354 | } |
| 6355 | // Before anything wakes the root, the idle work is already projected. |
| 6356 | yield* awaitUntil(() => { |
| 6357 | const event = latestToolItem(IDLE_BASH); |
| 6358 | return event?.type === "turn_item.updated" && event.turnItem.status === "running"; |
| 6359 | }, "idle Bash call"); |
| 6360 | yield* awaitUntil( |
| 6361 | () => |
| 6362 | harness.events.some( |
| 6363 | (event) => |
| 6364 | event.type === "subagent.updated" && |
| 6365 | event.subagent.progress === "Reading the last commits", |
| 6366 | ), |
| 6367 | "idle progress", |
| 6368 | ); |
| 6369 | const routing = subagentRouting(harness.events, [STRADDLING_BASH, IDLE_BASH]); |
| 6370 | assert.deepEqual(routing.assistantTexts(routing.childThreadId), ["Still auditing."]); |
| 6371 | // The call that spans the turn end keeps the type and input its |
| 6372 | // tool_use gave it during the open turn. |
| 6373 | const straddling = latestToolItem(STRADDLING_BASH); |
| 6374 | const straddlingItem = |
| 6375 | straddling?.type === "turn_item.updated" ? straddling.turnItem : undefined; |
| 6376 | assert.equal(straddlingItem?.status, "completed"); |
| 6377 | assert.equal( |
| 6378 | straddlingItem?.type === "command_execution" ? straddlingItem.input : undefined, |
| 6379 | "git log -5", |
| 6380 | ); |
| 6381 | assert.equal(harness.continuationRequests.length, 0); |
| 6382 | |
| 6383 | for (const frame of [ |
| 6384 | makeSubagentToolResultFrame({ |
| 6385 | parentToolUseId: TOOL_USE_ID, |
| 6386 | uuid: "00000000-0000-4000-8000-000000000957", |
| 6387 | toolUseId: IDLE_BASH, |
| 6313 | 6388 | }), |
| 6314 | 6389 | makeSubagentNotificationFrame({ |
| 6315 | 6390 | taskId: TASK_ID, |
| 6316 | 6391 | toolUseId: TOOL_USE_ID, |
| 6317 | | summary: "Part one.\n\nPart two.", |
| 6318 | | uuid: "00000000-0000-4000-8000-000000000346", |
| 6392 | summary: "Still auditing.", |
| 6393 | uuid: "00000000-0000-4000-8000-000000000958", |
| 6319 | 6394 | }), |
| 6395 | ]) { |
| 6396 | yield* Queue.offer(harness.sdkMessages, frame); |
| 6397 | } |
| 6398 | yield* awaitUntil(() => harness.continuationRequests.length === 1, "continuation request"); |
| 6399 | yield* Queue.offer( |
| 6400 | harness.sdkMessages, |
| 6320 | 6401 | makeResultFrame({ |
| 6321 | | uuid: "00000000-0000-4000-8000-000000000347", |
| 6402 | uuid: "00000000-0000-4000-8000-000000000959", |
| 6322 | 6403 | result: "The auditor finished.", |
| 6323 | 6404 | }), |
| 6324 | | ]; |
| 6325 | | for (const frame of frames) { |
| 6326 | | yield* Queue.offer(harness.sdkMessages, frame); |
| 6327 | | } |
| 6328 | | yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); |
| 6329 | | |
| 6330 | | const routing = subagentRouting(harness.events, []); |
| 6331 | | assert.deepEqual(routing.assistantTexts(routing.childThreadId), [ |
| 6332 | | "Working.", |
| 6333 | | "Part one.", |
| 6334 | | "Part two.", |
| 6335 | | ]); |
| 6336 | | assert.deepEqual(routing.assistantTexts(harness.threadId), ["The auditor finished."]); |
| 6337 | | }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), |
| 6338 | | ), |
| 6339 | | ); |
| 6340 | | |
| 6341 | | it.effect("does not resolve a newer API retry when replaying a held subagent frame", () => |
| 6342 | | Effect.scoped( |
| 6343 | | Effect.gen(function* () { |
| 6344 | | const TOOL_USE_ID = "toolu-retry-subagent"; |
| 6345 | | const harness = yield* makeWakeHarness; |
| 6346 | | const now = yield* DateTime.now; |
| 6405 | ); |
| 6347 | 6406 | yield* harness.runtime.startTurn( |
| 6348 | 6407 | makeClaudeTestTurnInput({ |
| 6349 | 6408 | threadId: harness.threadId, |
| 6350 | 6409 | providerThread: harness.providerThread, |
| 6351 | 6410 | now, |
| 6352 | | attemptId: RunAttemptId.make("attempt-claude-retry-subagent"), |
| 6353 | | text: "Run an auditor.", |
| 6411 | attemptId: RunAttemptId.make("attempt-claude-live-idle-2"), |
| 6412 | text: "Background task completed.", |
| 6354 | 6413 | attachments: [], |
| 6414 | providerTurnOrdinal: 2, |
| 6415 | messageCreatedBy: "agent", |
| 6416 | messageCreationSource: "provider", |
| 6355 | 6417 | }), |
| 6356 | 6418 | ); |
| 6357 | | const frames = [ |
| 6358 | | ...makeSubagentAssistantFrames({ |
| 6359 | | parentToolUseId: TOOL_USE_ID, |
| 6360 | | uuid: "00000000-0000-4000-8000-000000000351", |
| 6361 | | text: "Held before registration.", |
| 6362 | | }), |
| 6363 | | claudeSdkFrame({ |
| 6364 | | type: "system", |
| 6365 | | subtype: "api_retry", |
| 6366 | | attempt: 2, |
| 6367 | | max_retries: 10, |
| 6368 | | retry_delay_ms: 1_500, |
| 6369 | | error_status: 529, |
| 6370 | | error: "overloaded", |
| 6371 | | uuid: "00000000-0000-4000-8000-000000000352", |
| 6372 | | session_id: WAKE_NATIVE_SESSION, |
| 6373 | | }), |
| 6374 | | makeSubagentTaskStartedFrame({ |
| 6375 | | taskId: "task-retry-subagent", |
| 6376 | | toolUseId: TOOL_USE_ID, |
| 6377 | | uuid: "00000000-0000-4000-8000-000000000353", |
| 6378 | | }), |
| 6379 | | ]; |
| 6380 | | for (const frame of frames) { |
| 6381 | | yield* Queue.offer(harness.sdkMessages, frame); |
| 6419 | yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); |
| 6420 | |
| 6421 | const finalRouting = subagentRouting(harness.events, [STRADDLING_BASH, IDLE_BASH]); |
| 6422 | assert.deepEqual(finalRouting.assistantTexts(finalRouting.childThreadId), [ |
| 6423 | "Still auditing.", |
| 6424 | ]); |
| 6425 | for (const nativeId of [STRADDLING_BASH, IDLE_BASH]) { |
| 6426 | assert.deepEqual( |
| 6427 | [...(finalRouting.toolThreadIds.get(nativeId) ?? [])], |
| 6428 | [finalRouting.childThreadId], |
| 6429 | ); |
| 6430 | const statuses = harness.events.flatMap((event) => |
| 6431 | event.type === "turn_item.updated" && |
| 6432 | event.turnItem.nativeItemRef?.nativeId === nativeId |
| 6433 | ? [`${event.turnItem.type}:${event.turnItem.status}`] |
| 6434 | : [], |
| 6435 | ); |
| 6436 | assert.deepEqual(statuses, ["command_execution:running", "command_execution:completed"]); |
| 6382 | 6437 | } |
| 6383 | | const childTexts = () => { |
| 6384 | | const routing = subagentRouting(harness.events, []); |
| 6385 | | return routing.childThreadId === undefined |
| 6386 | | ? [] |
| 6387 | | : routing.assistantTexts(routing.childThreadId); |
| 6388 | | }; |
| 6389 | | yield* awaitUntil(() => childTexts().length === 1, "replayed subagent text"); |
| 6390 | | const retryStatuses = harness.events.flatMap((event) => |
| 6391 | | event.type === "turn_item.updated" && |
| 6392 | | event.turnItem.type === "error" && |
| 6393 | | event.turnItem.retry !== undefined |
| 6394 | | ? [event.turnItem.status] |
| 6395 | | : [], |
| 6438 | const finalSubagent = harness.events.findLast((event) => event.type === "subagent.updated"); |
| 6439 | assert.equal( |
| 6440 | finalSubagent?.type === "subagent.updated" && finalSubagent.subagent.status, |
| 6441 | "completed", |
| 6396 | 6442 | ); |
| 6397 | | assert.deepEqual(retryStatuses, ["running"]); |
| 6398 | 6443 | }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), |
| 6399 | 6444 | ), |
| 6400 | 6445 | ); |
| 6401 | 6446 | |
| 6402 | | it.effect("holds subagent frames that precede task_started and shows its result once", () => |
| 6447 | it.effect("keeps a nested subagent's launch in order while the root turn is idle", () => |
| 6403 | 6448 | Effect.scoped( |
| 6404 | 6449 | Effect.gen(function* () { |
| 6405 | | const TASK_ID = "task-early-subagent"; |
| 6406 | | const TOOL_USE_ID = "toolu-early-subagent"; |
| 6407 | | const FINAL_REPORT = "Early auditor done."; |
| 6450 | const OUTER_TASK_ID = "task-idle-outer"; |
| 6451 | const OUTER_TOOL_USE_ID = "toolu-idle-outer"; |
| 6452 | const NESTED_TASK_ID = "task-idle-nested"; |
| 6453 | const NESTED_TOOL_USE_ID = "toolu-idle-nested"; |
| 6408 | 6454 | const harness = yield* makeWakeHarness; |
| 6409 | 6455 | const now = yield* DateTime.now; |
| 6410 | 6456 | yield* harness.runtime.startTurn( |
| ... | ... | @@ -6412,69 +6458,1135 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 6412 | 6458 | threadId: harness.threadId, |
| 6413 | 6459 | providerThread: harness.providerThread, |
| 6414 | 6460 | now, |
| 6415 | | attemptId: RunAttemptId.make("attempt-claude-early-subagent"), |
| 6416 | | text: "Run an auditor.", |
| 6461 | attemptId: RunAttemptId.make("attempt-claude-idle-nested-1"), |
| 6462 | text: "Audit in the background.", |
| 6417 | 6463 | attachments: [], |
| 6418 | 6464 | }), |
| 6419 | 6465 | ); |
| 6420 | | const frames = [ |
| 6421 | | // The SDK can forward child frames before the task_started that |
| 6422 | | // registers their subagent. |
| 6423 | | ...makeSubagentAssistantFrames({ |
| 6424 | | parentToolUseId: TOOL_USE_ID, |
| 6425 | | uuid: "00000000-0000-4000-8000-000000000321", |
| 6426 | | text: "Starting early.", |
| 6427 | | bashToolUseId: "toolu-early-bash", |
| 6466 | for (const frame of [ |
| 6467 | makeSubagentTaskStartedFrame({ |
| 6468 | taskId: OUTER_TASK_ID, |
| 6469 | toolUseId: OUTER_TOOL_USE_ID, |
| 6470 | uuid: "00000000-0000-4000-8000-000000000971", |
| 6428 | 6471 | }), |
| 6429 | | makeSubagentToolResultFrame({ |
| 6430 | | parentToolUseId: TOOL_USE_ID, |
| 6431 | | uuid: "00000000-0000-4000-8000-000000000322", |
| 6432 | | toolUseId: "toolu-early-bash", |
| 6472 | makeResultFrame({ |
| 6473 | uuid: "00000000-0000-4000-8000-000000000972", |
| 6474 | result: "Auditing in the background.", |
| 6433 | 6475 | }), |
| 6434 | | makeSubagentTaskStartedFrame({ |
| 6435 | | taskId: TASK_ID, |
| 6436 | | toolUseId: TOOL_USE_ID, |
| 6437 | | uuid: "00000000-0000-4000-8000-000000000323", |
| 6476 | ]) { |
| 6477 | yield* Queue.offer(harness.sdkMessages, frame); |
| 6478 | } |
| 6479 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); |
| 6480 | |
| 6481 | // While the root is idle, the subagent runs a nested one to completion. |
| 6482 | for (const frame of [ |
| 6483 | claudeSdkFrame({ |
| 6484 | type: "assistant", |
| 6485 | message: { |
| 6486 | model: "claude-sonnet-4-6", |
| 6487 | id: "msg_idle_nested_launch", |
| 6488 | type: "message", |
| 6489 | role: "assistant", |
| 6490 | content: [ |
| 6491 | { |
| 6492 | type: "tool_use", |
| 6493 | id: NESTED_TOOL_USE_ID, |
| 6494 | name: "Agent", |
| 6495 | input: { description: "Nested check", prompt: "Reply with NESTED_OK" }, |
| 6496 | }, |
| 6497 | ], |
| 6498 | }, |
| 6499 | parent_tool_use_id: OUTER_TOOL_USE_ID, |
| 6500 | uuid: "00000000-0000-4000-8000-000000000973", |
| 6501 | session_id: WAKE_NATIVE_SESSION, |
| 6438 | 6502 | }), |
| 6439 | | ...makeSubagentAssistantFrames({ |
| 6440 | | parentToolUseId: TOOL_USE_ID, |
| 6441 | | uuid: "00000000-0000-4000-8000-000000000324", |
| 6442 | | text: "Still working.", |
| 6503 | claudeSdkFrame({ |
| 6504 | type: "system", |
| 6505 | subtype: "task_started", |
| 6506 | task_id: NESTED_TASK_ID, |
| 6507 | tool_use_id: NESTED_TOOL_USE_ID, |
| 6508 | description: "Nested check", |
| 6509 | subagent_type: "general-purpose", |
| 6510 | is_backgrounded: false, |
| 6511 | spawn_depth: 2, |
| 6512 | task_type: "local_agent", |
| 6513 | prompt: "Reply with NESTED_OK", |
| 6514 | uuid: "00000000-0000-4000-8000-000000000974", |
| 6515 | session_id: WAKE_NATIVE_SESSION, |
| 6443 | 6516 | }), |
| 6444 | | // Progress without a tool_use_id replaces the subagent entry while |
| 6445 | | // the per-turn tool-use alias still points at the previous one. |
| 6517 | // The outer subagent's progress and own work don't wait for the |
| 6518 | // nested start. |
| 6446 | 6519 | claudeSdkFrame({ |
| 6447 | 6520 | type: "system", |
| 6448 | 6521 | subtype: "task_progress", |
| 6449 | | task_id: TASK_ID, |
| 6450 | | description: "Checking the diffs", |
| 6451 | | uuid: "00000000-0000-4000-8000-000000000325", |
| 6522 | task_id: OUTER_TASK_ID, |
| 6523 | description: "Waiting on the nested check", |
| 6524 | uuid: "00000000-0000-4000-8000-000000000984", |
| 6452 | 6525 | session_id: WAKE_NATIVE_SESSION, |
| 6453 | 6526 | }), |
| 6454 | 6527 | ...makeSubagentAssistantFrames({ |
| 6455 | | parentToolUseId: TOOL_USE_ID, |
| 6456 | | uuid: "00000000-0000-4000-8000-000000000326", |
| 6457 | | text: FINAL_REPORT, |
| 6528 | parentToolUseId: OUTER_TOOL_USE_ID, |
| 6529 | uuid: "00000000-0000-4000-8000-000000000988", |
| 6530 | messageId: "msg_idle_outer_working", |
| 6531 | text: "OUTER_WORKING", |
| 6532 | }), |
| 6533 | ]) { |
| 6534 | yield* Queue.offer(harness.sdkMessages, frame); |
| 6535 | } |
| 6536 | yield* awaitUntil( |
| 6537 | () => |
| 6538 | harness.events.some( |
| 6539 | (event) => event.type === "message.updated" && event.message.text === "OUTER_WORKING", |
| 6540 | ), |
| 6541 | "outer work while the nested start waits", |
| 6542 | ); |
| 6543 | assert.lengthOf(harness.continuationRequests, 0); |
| 6544 | for (const frame of [ |
| 6545 | ...makeSubagentAssistantFrames({ |
| 6546 | parentToolUseId: NESTED_TOOL_USE_ID, |
| 6547 | uuid: "00000000-0000-4000-8000-000000000975", |
| 6548 | text: "NESTED_OK", |
| 6458 | 6549 | }), |
| 6459 | 6550 | makeSubagentNotificationFrame({ |
| 6460 | | taskId: TASK_ID, |
| 6461 | | toolUseId: TOOL_USE_ID, |
| 6462 | | summary: FINAL_REPORT, |
| 6463 | | uuid: "00000000-0000-4000-8000-000000000327", |
| 6551 | taskId: NESTED_TASK_ID, |
| 6552 | toolUseId: NESTED_TOOL_USE_ID, |
| 6553 | summary: "NESTED_OK", |
| 6554 | uuid: "00000000-0000-4000-8000-000000000976", |
| 6464 | 6555 | }), |
| 6465 | | makeResultFrame({ |
| 6466 | | uuid: "00000000-0000-4000-8000-000000000328", |
| 6467 | | result: "The auditor finished.", |
| 6556 | makeSubagentToolResultFrame({ |
| 6557 | parentToolUseId: OUTER_TOOL_USE_ID, |
| 6558 | uuid: "00000000-0000-4000-8000-000000000977", |
| 6559 | toolUseId: NESTED_TOOL_USE_ID, |
| 6468 | 6560 | }), |
| 6469 | | ]; |
| 6470 | | for (const frame of frames) { |
| 6561 | ...makeSubagentAssistantFrames({ |
| 6562 | parentToolUseId: OUTER_TOOL_USE_ID, |
| 6563 | uuid: "00000000-0000-4000-8000-000000000978", |
| 6564 | text: "OUTER_DONE", |
| 6565 | }), |
| 6566 | makeSubagentNotificationFrame({ |
| 6567 | taskId: OUTER_TASK_ID, |
| 6568 | toolUseId: OUTER_TOOL_USE_ID, |
| 6569 | summary: "OUTER_DONE", |
| 6570 | uuid: "00000000-0000-4000-8000-000000000979", |
| 6571 | }), |
| 6572 | ]) { |
| 6471 | 6573 | yield* Queue.offer(harness.sdkMessages, frame); |
| 6472 | 6574 | } |
| 6473 | | yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); |
| 6474 | | |
| 6475 | | const routing = subagentRouting(harness.events, ["toolu-early-bash"]); |
| 6476 | | assert.isDefined(routing.childThreadId); |
| 6477 | | assert.deepEqual( |
| 6575 | yield* awaitUntil(() => harness.continuationRequests.length === 1, "continuation request"); |
| 6576 | yield* awaitUntil( |
| 6577 | () => |
| 6578 | harness.events.some( |
| 6579 | (event) => |
| 6580 | event.type === "subagent.updated" && |
| 6581 | event.subagent.progress === "Waiting on the nested check", |
| 6582 | ), |
| 6583 | "outer progress", |
| 6584 | ); |
| 6585 | yield* Queue.offer( |
| 6586 | harness.sdkMessages, |
| 6587 | makeResultFrame({ |
| 6588 | uuid: "00000000-0000-4000-8000-000000000980", |
| 6589 | result: "The audit finished.", |
| 6590 | }), |
| 6591 | ); |
| 6592 | yield* harness.runtime.startTurn( |
| 6593 | makeClaudeTestTurnInput({ |
| 6594 | threadId: harness.threadId, |
| 6595 | providerThread: harness.providerThread, |
| 6596 | now, |
| 6597 | attemptId: RunAttemptId.make("attempt-claude-idle-nested-2"), |
| 6598 | text: "Background task completed.", |
| 6599 | attachments: [], |
| 6600 | providerTurnOrdinal: 2, |
| 6601 | messageCreatedBy: "agent", |
| 6602 | messageCreationSource: "provider", |
| 6603 | }), |
| 6604 | ); |
| 6605 | yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); |
| 6606 | |
| 6607 | // The nested Agent call is the nested subagent, never a plain tool row. |
| 6608 | assert.isFalse( |
| 6609 | harness.events.some( |
| 6610 | (event) => |
| 6611 | event.type === "turn_item.updated" && |
| 6612 | event.turnItem.nativeItemRef?.nativeId === NESTED_TOOL_USE_ID, |
| 6613 | ), |
| 6614 | ); |
| 6615 | const finalSubagents = new Map( |
| 6616 | harness.events.flatMap((event) => |
| 6617 | event.type === "subagent.updated" |
| 6618 | ? [[event.subagent.nativeTaskRef?.nativeId, event.subagent] as const] |
| 6619 | : [], |
| 6620 | ), |
| 6621 | ); |
| 6622 | const outer = finalSubagents.get(OUTER_TASK_ID); |
| 6623 | const nested = finalSubagents.get(NESTED_TASK_ID); |
| 6624 | assert.equal(outer?.status, "completed"); |
| 6625 | assert.equal(nested?.status, "completed"); |
| 6626 | assert.equal(nested?.parentNodeId, outer?.id); |
| 6627 | assert.deepEqual( |
| 6628 | subagentRouting(harness.events, []).assistantTexts(outer?.childThreadId ?? undefined), |
| 6629 | ["OUTER_WORKING", "OUTER_DONE"], |
| 6630 | ); |
| 6631 | }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), |
| 6632 | ), |
| 6633 | ); |
| 6634 | |
| 6635 | it.effect("ends an earlier turn's nested subagent while the root turn is idle", () => |
| 6636 | Effect.scoped( |
| 6637 | Effect.gen(function* () { |
| 6638 | const OUTER_TASK_ID = "task-earlier-outer"; |
| 6639 | const OUTER_TOOL_USE_ID = "toolu-earlier-outer"; |
| 6640 | const NESTED_TASK_ID = "task-earlier-nested"; |
| 6641 | const NESTED_TOOL_USE_ID = "toolu-earlier-nested"; |
| 6642 | const harness = yield* makeWakeHarness; |
| 6643 | const now = yield* DateTime.now; |
| 6644 | const userTurn = (attemptId: string, providerTurnOrdinal: number) => |
| 6645 | harness.runtime.startTurn( |
| 6646 | makeClaudeTestTurnInput({ |
| 6647 | threadId: harness.threadId, |
| 6648 | providerThread: harness.providerThread, |
| 6649 | now, |
| 6650 | attemptId: RunAttemptId.make(attemptId), |
| 6651 | text: "Keep auditing.", |
| 6652 | attachments: [], |
| 6653 | providerTurnOrdinal, |
| 6654 | }), |
| 6655 | ); |
| 6656 | // Turn 1 launches the outer subagent, which starts a nested one. |
| 6657 | yield* userTurn("attempt-claude-earlier-nested-1", 1); |
| 6658 | for (const frame of [ |
| 6659 | makeSubagentTaskStartedFrame({ |
| 6660 | taskId: OUTER_TASK_ID, |
| 6661 | toolUseId: OUTER_TOOL_USE_ID, |
| 6662 | uuid: "00000000-0000-4000-8000-000000000a01", |
| 6663 | }), |
| 6664 | claudeSdkFrame({ |
| 6665 | type: "assistant", |
| 6666 | message: { |
| 6667 | model: "claude-sonnet-4-6", |
| 6668 | id: "msg_earlier_nested_launch", |
| 6669 | type: "message", |
| 6670 | role: "assistant", |
| 6671 | content: [ |
| 6672 | { |
| 6673 | type: "tool_use", |
| 6674 | id: NESTED_TOOL_USE_ID, |
| 6675 | name: "Agent", |
| 6676 | input: { description: "Nested check", prompt: "Reply with NESTED_OK" }, |
| 6677 | }, |
| 6678 | ], |
| 6679 | }, |
| 6680 | parent_tool_use_id: OUTER_TOOL_USE_ID, |
| 6681 | uuid: "00000000-0000-4000-8000-000000000a02", |
| 6682 | session_id: WAKE_NATIVE_SESSION, |
| 6683 | }), |
| 6684 | claudeSdkFrame({ |
| 6685 | type: "system", |
| 6686 | subtype: "task_started", |
| 6687 | task_id: NESTED_TASK_ID, |
| 6688 | tool_use_id: NESTED_TOOL_USE_ID, |
| 6689 | description: "Nested check", |
| 6690 | subagent_type: "general-purpose", |
| 6691 | is_backgrounded: false, |
| 6692 | spawn_depth: 2, |
| 6693 | task_type: "local_agent", |
| 6694 | prompt: "Reply with NESTED_OK", |
| 6695 | uuid: "00000000-0000-4000-8000-000000000a03", |
| 6696 | session_id: WAKE_NATIVE_SESSION, |
| 6697 | }), |
| 6698 | makeResultFrame({ |
| 6699 | uuid: "00000000-0000-4000-8000-000000000a04", |
| 6700 | result: "Auditing in the background.", |
| 6701 | }), |
| 6702 | ]) { |
| 6703 | yield* Queue.offer(harness.sdkMessages, frame); |
| 6704 | } |
| 6705 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); |
| 6706 | // A later turn settles, so its context, which never saw the nested |
| 6707 | // launch, is the one the root's idle frames go through. |
| 6708 | yield* userTurn("attempt-claude-earlier-nested-2", 2); |
| 6709 | yield* Queue.offer( |
| 6710 | harness.sdkMessages, |
| 6711 | makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000a05", result: "Still on it." }), |
| 6712 | ); |
| 6713 | yield* awaitUntil(() => harness.terminalEvents().length === 2, "second turn terminal"); |
| 6714 | |
| 6715 | // The nested subagent ends and its Agent call returns while the root |
| 6716 | // is idle. The outer subagent's next message marks when both are handled. |
| 6717 | for (const frame of [ |
| 6718 | makeSubagentNotificationFrame({ |
| 6719 | taskId: NESTED_TASK_ID, |
| 6720 | toolUseId: NESTED_TOOL_USE_ID, |
| 6721 | summary: "NESTED_OK", |
| 6722 | uuid: "00000000-0000-4000-8000-000000000a06", |
| 6723 | }), |
| 6724 | makeSubagentToolResultFrame({ |
| 6725 | parentToolUseId: OUTER_TOOL_USE_ID, |
| 6726 | uuid: "00000000-0000-4000-8000-000000000a07", |
| 6727 | toolUseId: NESTED_TOOL_USE_ID, |
| 6728 | }), |
| 6729 | ...makeSubagentAssistantFrames({ |
| 6730 | parentToolUseId: OUTER_TOOL_USE_ID, |
| 6731 | uuid: "00000000-0000-4000-8000-000000000a08", |
| 6732 | text: "OUTER_AFTER_NESTED", |
| 6733 | }), |
| 6734 | ]) { |
| 6735 | yield* Queue.offer(harness.sdkMessages, frame); |
| 6736 | } |
| 6737 | yield* awaitUntil( |
| 6738 | () => |
| 6739 | harness.events.some( |
| 6740 | (event) => |
| 6741 | event.type === "message.updated" && event.message.text === "OUTER_AFTER_NESTED", |
| 6742 | ), |
| 6743 | "outer work after the nested call returns", |
| 6744 | ); |
| 6745 | |
| 6746 | // The nested Agent call is the nested subagent, never a plain tool row. |
| 6747 | assert.isFalse( |
| 6748 | harness.events.some( |
| 6749 | (event) => |
| 6750 | event.type === "turn_item.updated" && |
| 6751 | event.turnItem.nativeItemRef?.nativeId === NESTED_TOOL_USE_ID, |
| 6752 | ), |
| 6753 | ); |
| 6754 | const nested = harness.events.findLast( |
| 6755 | (event) => |
| 6756 | event.type === "subagent.updated" && |
| 6757 | event.subagent.nativeTaskRef?.nativeId === NESTED_TASK_ID, |
| 6758 | ); |
| 6759 | assert.equal(nested?.type === "subagent.updated" && nested.subagent.status, "completed"); |
| 6760 | assert.lengthOf(harness.continuationRequests, 0); |
| 6761 | }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), |
| 6762 | ), |
| 6763 | ); |
| 6764 | |
| 6765 | it.effect("ends a subagent's open call when the CLI exits while the root turn is idle", () => |
| 6766 | Effect.scoped( |
| 6767 | Effect.gen(function* () { |
| 6768 | const TOOL_USE_ID = "toolu-idle-exit"; |
| 6769 | const BASH = "toolu-idle-exit-bash"; |
| 6770 | const harness = yield* makeWakeHarness; |
| 6771 | const now = yield* DateTime.now; |
| 6772 | const bashStatuses = () => |
| 6773 | harness.events.flatMap((event) => |
| 6774 | event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === BASH |
| 6775 | ? [`${event.turnItem.type}:${event.turnItem.status}`] |
| 6776 | : [], |
| 6777 | ); |
| 6778 | yield* harness.runtime.startTurn( |
| 6779 | makeClaudeTestTurnInput({ |
| 6780 | threadId: harness.threadId, |
| 6781 | providerThread: harness.providerThread, |
| 6782 | now, |
| 6783 | attemptId: RunAttemptId.make("attempt-claude-idle-exit"), |
| 6784 | text: "Audit in the background.", |
| 6785 | attachments: [], |
| 6786 | }), |
| 6787 | ); |
| 6788 | for (const frame of [ |
| 6789 | makeSubagentTaskStartedFrame({ |
| 6790 | taskId: "task-idle-exit", |
| 6791 | toolUseId: TOOL_USE_ID, |
| 6792 | uuid: "00000000-0000-4000-8000-000000000981", |
| 6793 | }), |
| 6794 | ...makeSubagentAssistantFrames({ |
| 6795 | parentToolUseId: TOOL_USE_ID, |
| 6796 | uuid: "00000000-0000-4000-8000-000000000982", |
| 6797 | bashToolUseId: BASH, |
| 6798 | }), |
| 6799 | makeResultFrame({ |
| 6800 | uuid: "00000000-0000-4000-8000-000000000983", |
| 6801 | result: "Auditing in the background.", |
| 6802 | }), |
| 6803 | ]) { |
| 6804 | yield* Queue.offer(harness.sdkMessages, frame); |
| 6805 | } |
| 6806 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); |
| 6807 | assert.deepEqual(bashStatuses(), ["command_execution:running"]); |
| 6808 | |
| 6809 | // The CLI exits before the call returns; its result can never arrive. |
| 6810 | yield* Queue.shutdown(harness.sdkMessages); |
| 6811 | yield* awaitUntil(() => bashStatuses().length === 2, "call ended"); |
| 6812 | assert.deepEqual(bashStatuses(), ["command_execution:running", "command_execution:failed"]); |
| 6813 | }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), |
| 6814 | ), |
| 6815 | ); |
| 6816 | |
| 6817 | it.effect("ends a subagent's open call when Stop's close of the CLI times out", () => |
| 6818 | Effect.scoped( |
| 6819 | Effect.gen(function* () { |
| 6820 | const TOOL_USE_ID = "toolu-idle-close-timeout"; |
| 6821 | const BASH = "toolu-idle-close-timeout-bash"; |
| 6822 | // close() leaves the stream open, so Stop gives up waiting for it. |
| 6823 | const harness = yield* makeWakeHarnessWithOptions(); |
| 6824 | const now = yield* DateTime.now; |
| 6825 | const bashStatuses = () => |
| 6826 | harness.events.flatMap((event) => |
| 6827 | event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === BASH |
| 6828 | ? [`${event.turnItem.type}:${event.turnItem.status}`] |
| 6829 | : [], |
| 6830 | ); |
| 6831 | yield* harness.runtime.startTurn( |
| 6832 | makeClaudeTestTurnInput({ |
| 6833 | threadId: harness.threadId, |
| 6834 | providerThread: harness.providerThread, |
| 6835 | now, |
| 6836 | attemptId: RunAttemptId.make("attempt-claude-idle-close-timeout"), |
| 6837 | text: "Audit in the background.", |
| 6838 | attachments: [], |
| 6839 | }), |
| 6840 | ); |
| 6841 | for (const frame of [ |
| 6842 | makeSubagentTaskStartedFrame({ |
| 6843 | taskId: "task-idle-close-timeout", |
| 6844 | toolUseId: TOOL_USE_ID, |
| 6845 | uuid: "00000000-0000-4000-8000-000000000985", |
| 6846 | }), |
| 6847 | ...makeSubagentAssistantFrames({ |
| 6848 | parentToolUseId: TOOL_USE_ID, |
| 6849 | uuid: "00000000-0000-4000-8000-000000000986", |
| 6850 | bashToolUseId: BASH, |
| 6851 | }), |
| 6852 | makeResultFrame({ |
| 6853 | uuid: "00000000-0000-4000-8000-000000000987", |
| 6854 | result: "Auditing in the background.", |
| 6855 | }), |
| 6856 | ]) { |
| 6857 | yield* Queue.offer(harness.sdkMessages, frame); |
| 6858 | } |
| 6859 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); |
| 6860 | assert.deepEqual(bashStatuses(), ["command_execution:running"]); |
| 6861 | |
| 6862 | const stop = yield* harness.runtime |
| 6863 | .interruptTurn({ |
| 6864 | providerThread: harness.providerThread, |
| 6865 | providerTurnId: harness.terminalEvents()[0]!.providerTurnId, |
| 6866 | requestRuntimeRestart: true, |
| 6867 | }) |
| 6868 | .pipe(Effect.forkScoped); |
| 6869 | yield* TestClock.adjust("10 seconds"); |
| 6870 | yield* Fiber.join(stop); |
| 6871 | assert.deepEqual(bashStatuses(), [ |
| 6872 | "command_execution:running", |
| 6873 | "command_execution:interrupted", |
| 6874 | ]); |
| 6875 | }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), |
| 6876 | ), |
| 6877 | ); |
| 6878 | |
| 6879 | it.effect.each(["before", "after"] as const)( |
| 6880 | "keeps a subagent call's result when the CLI exits before the drain (result %s the nested launch's)", |
| 6881 | (bashResultOrder) => |
| 6882 | Effect.scoped( |
| 6883 | Effect.gen(function* () { |
| 6884 | const OUTER_TASK_ID = "task-exit-buffered-outer"; |
| 6885 | const OUTER_TOOL_USE_ID = "toolu-exit-buffered-outer"; |
| 6886 | const NESTED_TASK_ID = "task-exit-buffered-nested"; |
| 6887 | const NESTED_TOOL_USE_ID = "toolu-exit-buffered-nested"; |
| 6888 | const BASH = "toolu-exit-buffered-bash"; |
| 6889 | const harness = yield* makeWakeHarnessWithOptions({ freshQueueOnReopen: true }); |
| 6890 | const now = yield* DateTime.now; |
| 6891 | const bashItems = () => |
| 6892 | harness.events.flatMap((event) => |
| 6893 | event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === BASH |
| 6894 | ? [ |
| 6895 | `${event.turnItem.type}:${event.turnItem.status}:${JSON.stringify("input" in event.turnItem ? event.turnItem.input : null)}`, |
| 6896 | ] |
| 6897 | : [], |
| 6898 | ); |
| 6899 | yield* harness.runtime.startTurn( |
| 6900 | makeClaudeTestTurnInput({ |
| 6901 | threadId: harness.threadId, |
| 6902 | providerThread: harness.providerThread, |
| 6903 | now, |
| 6904 | attemptId: RunAttemptId.make("attempt-claude-exit-buffered-1"), |
| 6905 | text: "Audit in the background.", |
| 6906 | attachments: [], |
| 6907 | }), |
| 6908 | ); |
| 6909 | for (const frame of [ |
| 6910 | makeSubagentTaskStartedFrame({ |
| 6911 | taskId: OUTER_TASK_ID, |
| 6912 | toolUseId: OUTER_TOOL_USE_ID, |
| 6913 | uuid: "00000000-0000-4000-8000-000000001001", |
| 6914 | }), |
| 6915 | makeResultFrame({ |
| 6916 | uuid: "00000000-0000-4000-8000-000000001002", |
| 6917 | result: "Auditing in the background.", |
| 6918 | }), |
| 6919 | ]) { |
| 6920 | yield* Queue.offer(harness.sdkMessages, frame); |
| 6921 | } |
| 6922 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); |
| 6923 | |
| 6924 | // While the root is idle, the subagent starts Bash; it is projected at once. |
| 6925 | for (const frame of makeSubagentAssistantFrames({ |
| 6926 | parentToolUseId: OUTER_TOOL_USE_ID, |
| 6927 | uuid: "00000000-0000-4000-8000-000000001003", |
| 6928 | bashToolUseId: BASH, |
| 6929 | })) { |
| 6930 | yield* Queue.offer(harness.sdkMessages, frame); |
| 6931 | } |
| 6932 | yield* awaitUntil(() => bashItems().length === 1, "idle Bash call"); |
| 6933 | |
| 6934 | // A nested start buffers, and its launch result waits behind it. Bash's |
| 6935 | // successful result arrives before or after that launch result. |
| 6936 | const bashResultFrame = makeSubagentToolResultFrame({ |
| 6937 | parentToolUseId: OUTER_TOOL_USE_ID, |
| 6938 | uuid: "00000000-0000-4000-8000-000000001006", |
| 6939 | toolUseId: BASH, |
| 6940 | }); |
| 6941 | for (const frame of [ |
| 6942 | claudeSdkFrame({ |
| 6943 | type: "assistant", |
| 6944 | message: { |
| 6945 | model: "claude-sonnet-4-6", |
| 6946 | id: "msg_exit_buffered_nested_launch", |
| 6947 | type: "message", |
| 6948 | role: "assistant", |
| 6949 | content: [ |
| 6950 | { |
| 6951 | type: "tool_use", |
| 6952 | id: NESTED_TOOL_USE_ID, |
| 6953 | name: "Agent", |
| 6954 | input: { description: "Nested check", prompt: "Reply with NESTED_OK" }, |
| 6955 | }, |
| 6956 | ], |
| 6957 | }, |
| 6958 | parent_tool_use_id: OUTER_TOOL_USE_ID, |
| 6959 | uuid: "00000000-0000-4000-8000-000000001004", |
| 6960 | session_id: WAKE_NATIVE_SESSION, |
| 6961 | }), |
| 6962 | claudeSdkFrame({ |
| 6963 | type: "system", |
| 6964 | subtype: "task_started", |
| 6965 | task_id: NESTED_TASK_ID, |
| 6966 | tool_use_id: NESTED_TOOL_USE_ID, |
| 6967 | description: "Nested check", |
| 6968 | subagent_type: "general-purpose", |
| 6969 | is_backgrounded: false, |
| 6970 | spawn_depth: 2, |
| 6971 | task_type: "local_agent", |
| 6972 | prompt: "Reply with NESTED_OK", |
| 6973 | uuid: "00000000-0000-4000-8000-000000001005", |
| 6974 | session_id: WAKE_NATIVE_SESSION, |
| 6975 | }), |
| 6976 | ...(bashResultOrder === "before" ? [bashResultFrame] : []), |
| 6977 | ...makeSubagentAssistantFrames({ |
| 6978 | parentToolUseId: NESTED_TOOL_USE_ID, |
| 6979 | uuid: "00000000-0000-4000-8000-000000001007", |
| 6980 | text: "NESTED_OK", |
| 6981 | }), |
| 6982 | makeSubagentNotificationFrame({ |
| 6983 | taskId: NESTED_TASK_ID, |
| 6984 | toolUseId: NESTED_TOOL_USE_ID, |
| 6985 | summary: "NESTED_OK", |
| 6986 | uuid: "00000000-0000-4000-8000-000000001008", |
| 6987 | }), |
| 6988 | makeSubagentToolResultFrame({ |
| 6989 | parentToolUseId: OUTER_TOOL_USE_ID, |
| 6990 | uuid: "00000000-0000-4000-8000-000000001009", |
| 6991 | toolUseId: NESTED_TOOL_USE_ID, |
| 6992 | }), |
| 6993 | ...(bashResultOrder === "after" ? [bashResultFrame] : []), |
| 6994 | makeSubagentNotificationFrame({ |
| 6995 | taskId: OUTER_TASK_ID, |
| 6996 | toolUseId: OUTER_TOOL_USE_ID, |
| 6997 | summary: "OUTER_DONE", |
| 6998 | uuid: "00000000-0000-4000-8000-000000001010", |
| 6999 | }), |
| 7000 | ]) { |
| 7001 | yield* Queue.offer(harness.sdkMessages, frame); |
| 7002 | } |
| 7003 | yield* awaitUntil( |
| 7004 | () => harness.continuationRequests.length === 1, |
| 7005 | "continuation request", |
| 7006 | ); |
| 7007 | |
| 7008 | // The CLI exits before the continuation run drains the wake buffer. |
| 7009 | yield* Queue.shutdown(harness.sdkMessages); |
| 7010 | let quietYields = 0; |
| 7011 | yield* awaitUntil(() => quietYields++ >= 50, "query exit"); |
| 7012 | yield* harness.runtime.startTurn( |
| 7013 | makeClaudeTestTurnInput({ |
| 7014 | threadId: harness.threadId, |
| 7015 | providerThread: harness.providerThread, |
| 7016 | now, |
| 7017 | attemptId: RunAttemptId.make("attempt-claude-exit-buffered-2"), |
| 7018 | text: "Background task completed.", |
| 7019 | attachments: [], |
| 7020 | providerTurnOrdinal: 2, |
| 7021 | messageCreatedBy: "agent", |
| 7022 | messageCreationSource: "provider", |
| 7023 | }), |
| 7024 | ); |
| 7025 | // The continuation opened a new CLI process: the first one's exit was handled. |
| 7026 | assert.equal(harness.processQueues.length, 2); |
| 7027 | yield* Queue.offer( |
| 7028 | harness.processQueues[1]!, |
| 7029 | makeResultFrame({ |
| 7030 | uuid: "00000000-0000-4000-8000-000000001011", |
| 7031 | result: "The audit finished.", |
| 7032 | }), |
| 7033 | ); |
| 7034 | yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); |
| 7035 | |
| 7036 | const input = JSON.stringify("git log -5"); |
| 7037 | assert.deepEqual(bashItems(), [ |
| 7038 | `command_execution:running:${input}`, |
| 7039 | `command_execution:completed:${input}`, |
| 7040 | ]); |
| 7041 | }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), |
| 7042 | ), |
| 7043 | ); |
| 7044 | |
| 7045 | it.effect( |
| 7046 | "ends a subagent's SendMessage with its result when the CLI exits while the resume waits", |
| 7047 | () => |
| 7048 | Effect.scoped( |
| 7049 | Effect.gen(function* () { |
| 7050 | const SENDER_TASK_ID = "task-exit-resume-sender"; |
| 7051 | const SENDER_TOOL_USE_ID = "toolu-exit-resume-sender"; |
| 7052 | const RESUMED_TASK_ID = "task-exit-resume-target"; |
| 7053 | const RESUMED_TOOL_USE_ID = "toolu-exit-resume-target"; |
| 7054 | const SEND = "toolu-exit-resume-sendmessage"; |
| 7055 | const SEND_INPUT = { to: RESUMED_TASK_ID, message: "Check the last commit again." }; |
| 7056 | const harness = yield* makeWakeHarnessWithOptions({ freshQueueOnReopen: true }); |
| 7057 | const now = yield* DateTime.now; |
| 7058 | const sendItems = () => |
| 7059 | harness.events.flatMap((event) => |
| 7060 | event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === SEND |
| 7061 | ? [ |
| 7062 | `${event.turnItem.type}:${event.turnItem.status}:${JSON.stringify("input" in event.turnItem ? event.turnItem.input : null)}`, |
| 7063 | ] |
| 7064 | : [], |
| 7065 | ); |
| 7066 | yield* harness.runtime.startTurn( |
| 7067 | makeClaudeTestTurnInput({ |
| 7068 | threadId: harness.threadId, |
| 7069 | providerThread: harness.providerThread, |
| 7070 | now, |
| 7071 | attemptId: RunAttemptId.make("attempt-claude-exit-resume-1"), |
| 7072 | text: "Run two auditors in the background.", |
| 7073 | attachments: [], |
| 7074 | }), |
| 7075 | ); |
| 7076 | // The target subagent finishes inside the turn; the sender keeps running. |
| 7077 | for (const frame of [ |
| 7078 | makeSubagentTaskStartedFrame({ |
| 7079 | taskId: SENDER_TASK_ID, |
| 7080 | toolUseId: SENDER_TOOL_USE_ID, |
| 7081 | uuid: "00000000-0000-4000-8000-000000001101", |
| 7082 | }), |
| 7083 | makeSubagentTaskStartedFrame({ |
| 7084 | taskId: RESUMED_TASK_ID, |
| 7085 | toolUseId: RESUMED_TOOL_USE_ID, |
| 7086 | uuid: "00000000-0000-4000-8000-000000001102", |
| 7087 | }), |
| 7088 | makeSubagentNotificationFrame({ |
| 7089 | taskId: RESUMED_TASK_ID, |
| 7090 | toolUseId: RESUMED_TOOL_USE_ID, |
| 7091 | summary: "First answer.", |
| 7092 | uuid: "00000000-0000-4000-8000-000000001103", |
| 7093 | }), |
| 7094 | makeResultFrame({ |
| 7095 | uuid: "00000000-0000-4000-8000-000000001104", |
| 7096 | result: "Auditing in the background.", |
| 7097 | }), |
| 7098 | ]) { |
| 7099 | yield* Queue.offer(harness.sdkMessages, frame); |
| 7100 | } |
| 7101 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); |
| 7102 | |
| 7103 | // While the root is idle, the sender resumes the finished subagent. |
| 7104 | // Its SendMessage call is projected at once. |
| 7105 | yield* Queue.offer( |
| 7106 | harness.sdkMessages, |
| 7107 | claudeSdkFrame({ |
| 7108 | type: "assistant", |
| 7109 | message: { |
| 7110 | model: "claude-sonnet-4-6", |
| 7111 | id: "msg_exit_resume_sendmessage", |
| 7112 | type: "message", |
| 7113 | role: "assistant", |
| 7114 | content: [{ type: "tool_use", id: SEND, name: "SendMessage", input: SEND_INPUT }], |
| 7115 | }, |
| 7116 | parent_tool_use_id: SENDER_TOOL_USE_ID, |
| 7117 | uuid: "00000000-0000-4000-8000-000000001105", |
| 7118 | session_id: WAKE_NATIVE_SESSION, |
| 7119 | }), |
| 7120 | ); |
| 7121 | yield* awaitUntil(() => sendItems().length >= 1, "idle SendMessage call"); |
| 7122 | |
| 7123 | // The resume's task_started buffers. The SendMessage result names that |
| 7124 | // start's tool use, but its call already shows as a row, so it ends it. |
| 7125 | for (const frame of [ |
| 7126 | claudeSdkFrame({ |
| 7127 | type: "system", |
| 7128 | subtype: "task_started", |
| 7129 | task_id: RESUMED_TASK_ID, |
| 7130 | tool_use_id: SEND, |
| 7131 | description: "Audit recent commits", |
| 7132 | is_backgrounded: true, |
| 7133 | task_type: "local_agent", |
| 7134 | prompt: SEND_INPUT.message, |
| 7135 | uuid: "00000000-0000-4000-8000-000000001106", |
| 7136 | session_id: WAKE_NATIVE_SESSION, |
| 7137 | }), |
| 7138 | claudeSdkFrame({ |
| 7139 | type: "user", |
| 7140 | message: { |
| 7141 | role: "user", |
| 7142 | content: [{ type: "tool_result", tool_use_id: SEND, content: "Message sent." }], |
| 7143 | }, |
| 7144 | parent_tool_use_id: SENDER_TOOL_USE_ID, |
| 7145 | uuid: "00000000-0000-4000-8000-000000001107", |
| 7146 | session_id: WAKE_NATIVE_SESSION, |
| 7147 | }), |
| 7148 | makeSubagentNotificationFrame({ |
| 7149 | taskId: RESUMED_TASK_ID, |
| 7150 | toolUseId: SEND, |
| 7151 | summary: "Second answer.", |
| 7152 | uuid: "00000000-0000-4000-8000-000000001108", |
| 7153 | }), |
| 7154 | ]) { |
| 7155 | yield* Queue.offer(harness.sdkMessages, frame); |
| 7156 | } |
| 7157 | yield* awaitUntil( |
| 7158 | () => harness.continuationRequests.length === 1, |
| 7159 | "continuation request", |
| 7160 | ); |
| 7161 | const beforeExit = sendItems(); |
| 7162 | |
| 7163 | // The CLI exits before the continuation run drains the wake buffer. |
| 7164 | yield* Queue.shutdown(harness.sdkMessages); |
| 7165 | let quietYields = 0; |
| 7166 | yield* awaitUntil(() => quietYields++ >= 50, "query exit"); |
| 7167 | yield* harness.runtime.startTurn( |
| 7168 | makeClaudeTestTurnInput({ |
| 7169 | threadId: harness.threadId, |
| 7170 | providerThread: harness.providerThread, |
| 7171 | now, |
| 7172 | attemptId: RunAttemptId.make("attempt-claude-exit-resume-2"), |
| 7173 | text: "Background task completed.", |
| 7174 | attachments: [], |
| 7175 | providerTurnOrdinal: 2, |
| 7176 | messageCreatedBy: "agent", |
| 7177 | messageCreationSource: "provider", |
| 7178 | }), |
| 7179 | ); |
| 7180 | // The continuation opened a new CLI process: the first one's exit was handled. |
| 7181 | assert.equal(harness.processQueues.length, 2); |
| 7182 | yield* Queue.offer( |
| 7183 | harness.processQueues[1]!, |
| 7184 | makeResultFrame({ |
| 7185 | uuid: "00000000-0000-4000-8000-000000001109", |
| 7186 | result: "The audit finished.", |
| 7187 | }), |
| 7188 | ); |
| 7189 | yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); |
| 7190 | |
| 7191 | const input = JSON.stringify(SEND_INPUT); |
| 7192 | assert.deepEqual(beforeExit, [`dynamic_tool:running:${input}`]); |
| 7193 | assert.deepEqual(sendItems(), [ |
| 7194 | `dynamic_tool:running:${input}`, |
| 7195 | `dynamic_tool:completed:${input}`, |
| 7196 | ]); |
| 7197 | }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), |
| 7198 | ), |
| 7199 | ); |
| 7200 | |
| 7201 | it.effect("releases held frames before the notification that first names the tool use", () => |
| 7202 | Effect.scoped( |
| 7203 | Effect.gen(function* () { |
| 7204 | const TASK_ID = "task-late-tool-use"; |
| 7205 | const TOOL_USE_ID = "toolu-late-tool-use"; |
| 7206 | const harness = yield* makeWakeHarness; |
| 7207 | const now = yield* DateTime.now; |
| 7208 | yield* harness.runtime.startTurn( |
| 7209 | makeClaudeTestTurnInput({ |
| 7210 | threadId: harness.threadId, |
| 7211 | providerThread: harness.providerThread, |
| 7212 | now, |
| 7213 | attemptId: RunAttemptId.make("attempt-claude-late-tool-use"), |
| 7214 | text: "Run an auditor.", |
| 7215 | attachments: [], |
| 7216 | }), |
| 7217 | ); |
| 7218 | const frames = [ |
| 7219 | claudeSdkFrame({ |
| 7220 | type: "system", |
| 7221 | subtype: "task_started", |
| 7222 | task_id: TASK_ID, |
| 7223 | description: "Audit recent commits", |
| 7224 | task_type: "local_agent", |
| 7225 | prompt: "Audit the last five commits.", |
| 7226 | uuid: "00000000-0000-4000-8000-000000000341", |
| 7227 | session_id: WAKE_NATIVE_SESSION, |
| 7228 | }), |
| 7229 | // task_started carried no tool_use_id, so these frames are held |
| 7230 | // until the notification pairs the task with its tool use. The SDK |
| 7231 | // types tool_use_id as optional; no recorded or logged task_started |
| 7232 | // has omitted it, so this guards the typed contract only. |
| 7233 | ...makeSubagentAssistantFrames({ |
| 7234 | parentToolUseId: TOOL_USE_ID, |
| 7235 | uuid: "00000000-0000-4000-8000-000000000342", |
| 7236 | text: "Working.", |
| 7237 | }), |
| 7238 | // The final answer arrives as one snapshot per text block. |
| 7239 | ...makeSubagentAssistantFrames({ |
| 7240 | parentToolUseId: TOOL_USE_ID, |
| 7241 | uuid: "00000000-0000-4000-8000-000000000344", |
| 7242 | messageId: "msg_late_final", |
| 7243 | text: "Part one.", |
| 7244 | }), |
| 7245 | ...makeSubagentAssistantFrames({ |
| 7246 | parentToolUseId: TOOL_USE_ID, |
| 7247 | uuid: "00000000-0000-4000-8000-000000000345", |
| 7248 | messageId: "msg_late_final", |
| 7249 | text: "Part two.", |
| 7250 | }), |
| 7251 | makeSubagentNotificationFrame({ |
| 7252 | taskId: TASK_ID, |
| 7253 | toolUseId: TOOL_USE_ID, |
| 7254 | summary: "Part one.\n\nPart two.", |
| 7255 | uuid: "00000000-0000-4000-8000-000000000346", |
| 7256 | }), |
| 7257 | makeResultFrame({ |
| 7258 | uuid: "00000000-0000-4000-8000-000000000347", |
| 7259 | result: "The auditor finished.", |
| 7260 | }), |
| 7261 | ]; |
| 7262 | for (const frame of frames) { |
| 7263 | yield* Queue.offer(harness.sdkMessages, frame); |
| 7264 | } |
| 7265 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); |
| 7266 | |
| 7267 | const routing = subagentRouting(harness.events, []); |
| 7268 | assert.deepEqual(routing.assistantTexts(routing.childThreadId), [ |
| 7269 | "Working.", |
| 7270 | "Part one.", |
| 7271 | "Part two.", |
| 7272 | ]); |
| 7273 | assert.deepEqual(routing.assistantTexts(harness.threadId), ["The auditor finished."]); |
| 7274 | }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), |
| 7275 | ), |
| 7276 | ); |
| 7277 | |
| 7278 | it.effect.each([ |
| 7279 | ["", "none"], |
| 7280 | [" behind another subagent's start without one", "before"], |
| 7281 | [" when another subagent's start without one arrives between its frames", "between"], |
| 7282 | ] as const)( |
| 7283 | "keeps a subagent's frames in order when a late progress names its tool use while the root is idle%s", |
| 7284 | ([, otherStart]) => |
| 7285 | Effect.scoped( |
| 7286 | Effect.gen(function* () { |
| 7287 | const TASK_ID = "task-late-owner-idle"; |
| 7288 | const TOOL_USE_ID = "toolu-late-owner-idle"; |
| 7289 | const BASH = "toolu-late-owner-idle-bash"; |
| 7290 | const harness = yield* makeWakeHarness; |
| 7291 | const now = yield* DateTime.now; |
| 7292 | const bashStatuses = () => |
| 7293 | harness.events.flatMap((event) => |
| 7294 | event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === BASH |
| 7295 | ? [`${event.turnItem.type}:${event.turnItem.status}`] |
| 7296 | : [], |
| 7297 | ); |
| 7298 | yield* harness.runtime.startTurn( |
| 7299 | makeClaudeTestTurnInput({ |
| 7300 | threadId: harness.threadId, |
| 7301 | providerThread: harness.providerThread, |
| 7302 | now, |
| 7303 | attemptId: RunAttemptId.make("attempt-claude-late-owner-idle-1"), |
| 7304 | text: "Audit in the background.", |
| 7305 | attachments: [], |
| 7306 | }), |
| 7307 | ); |
| 7308 | // task_started without tool_use_id (optional in the SDK type), then |
| 7309 | // the root settles. |
| 7310 | for (const frame of [ |
| 7311 | claudeSdkFrame({ |
| 7312 | type: "system", |
| 7313 | subtype: "task_started", |
| 7314 | task_id: TASK_ID, |
| 7315 | description: "Audit recent commits", |
| 7316 | subagent_type: "general-purpose", |
| 7317 | is_backgrounded: true, |
| 7318 | task_type: "local_agent", |
| 7319 | prompt: "Audit the last five commits.", |
| 7320 | uuid: "00000000-0000-4000-8000-000000000a01", |
| 7321 | session_id: WAKE_NATIVE_SESSION, |
| 7322 | }), |
| 7323 | makeResultFrame({ |
| 7324 | uuid: "00000000-0000-4000-8000-000000000a02", |
| 7325 | result: "Auditing in the background.", |
| 7326 | }), |
| 7327 | ]) { |
| 7328 | yield* Queue.offer(harness.sdkMessages, frame); |
| 7329 | } |
| 7330 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); |
| 7331 | |
| 7332 | // Another subagent starts while the root is idle, also without a |
| 7333 | // tool_use_id, so its start waits in the wake buffer. |
| 7334 | const otherStartFrame = claudeSdkFrame({ |
| 7335 | type: "system", |
| 7336 | subtype: "task_started", |
| 7337 | task_id: "task-late-owner-other", |
| 7338 | description: "Check the changelog", |
| 7339 | subagent_type: "general-purpose", |
| 7340 | is_backgrounded: true, |
| 7341 | task_type: "local_agent", |
| 7342 | prompt: "Check the changelog.", |
| 7343 | uuid: "00000000-0000-4000-8000-000000000a09", |
| 7344 | session_id: WAKE_NATIVE_SESSION, |
| 7345 | }); |
| 7346 | const progressFrame = claudeSdkFrame({ |
| 7347 | type: "system", |
| 7348 | subtype: "task_progress", |
| 7349 | task_id: TASK_ID, |
| 7350 | tool_use_id: TOOL_USE_ID, |
| 7351 | description: "Reading the last commits", |
| 7352 | usage: { total_tokens: 1, tool_uses: 1, duration_ms: 1 }, |
| 7353 | uuid: "00000000-0000-4000-8000-000000000a04", |
| 7354 | session_id: WAKE_NATIVE_SESSION, |
| 7355 | }); |
| 7356 | const bashResultFrame = makeSubagentToolResultFrame({ |
| 7357 | parentToolUseId: TOOL_USE_ID, |
| 7358 | uuid: "00000000-0000-4000-8000-000000000a05", |
| 7359 | toolUseId: BASH, |
| 7360 | }); |
| 7361 | // Root idle: the child's Bash call arrives before anything names the |
| 7362 | // owner's tool use; progress names it before or after the call returns. |
| 7363 | for (const frame of [ |
| 7364 | ...(otherStart === "before" ? [otherStartFrame] : []), |
| 7365 | ...makeSubagentAssistantFrames({ |
| 7366 | parentToolUseId: TOOL_USE_ID, |
| 7367 | uuid: "00000000-0000-4000-8000-000000000a03", |
| 7368 | text: "First.", |
| 7369 | bashToolUseId: BASH, |
| 7370 | }), |
| 7371 | ...(otherStart === "between" |
| 7372 | ? [otherStartFrame, bashResultFrame, progressFrame] |
| 7373 | : [progressFrame, bashResultFrame]), |
| 7374 | ...makeSubagentAssistantFrames({ |
| 7375 | parentToolUseId: TOOL_USE_ID, |
| 7376 | uuid: "00000000-0000-4000-8000-000000000a06", |
| 7377 | text: "Second.", |
| 7378 | }), |
| 7379 | makeSubagentNotificationFrame({ |
| 7380 | taskId: TASK_ID, |
| 7381 | toolUseId: TOOL_USE_ID, |
| 7382 | summary: "Second.", |
| 7383 | uuid: "00000000-0000-4000-8000-000000000a07", |
| 7384 | }), |
| 7385 | ]) { |
| 7386 | yield* Queue.offer(harness.sdkMessages, frame); |
| 7387 | } |
| 7388 | yield* awaitUntil( |
| 7389 | () => harness.continuationRequests.length === 1, |
| 7390 | "continuation request", |
| 7391 | ); |
| 7392 | yield* Queue.offer( |
| 7393 | harness.sdkMessages, |
| 7394 | makeResultFrame({ |
| 7395 | uuid: "00000000-0000-4000-8000-000000000a08", |
| 7396 | result: "The auditor finished.", |
| 7397 | }), |
| 7398 | ); |
| 7399 | yield* harness.runtime.startTurn( |
| 7400 | makeClaudeTestTurnInput({ |
| 7401 | threadId: harness.threadId, |
| 7402 | providerThread: harness.providerThread, |
| 7403 | now, |
| 7404 | attemptId: RunAttemptId.make("attempt-claude-late-owner-idle-2"), |
| 7405 | text: "Background task completed.", |
| 7406 | attachments: [], |
| 7407 | providerTurnOrdinal: 2, |
| 7408 | messageCreatedBy: "agent", |
| 7409 | messageCreationSource: "provider", |
| 7410 | }), |
| 7411 | ); |
| 7412 | yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); |
| 7413 | |
| 7414 | const routing = subagentRouting(harness.events, [BASH]); |
| 7415 | const childTexts = routing.assistantTexts(routing.childThreadId); |
| 7416 | const statuses = bashStatuses(); |
| 7417 | // Observed order of child texts and Bash item updates, for diagnosis. |
| 7418 | const trace = harness.events.flatMap((event) => |
| 7419 | event.type === "message.updated" && |
| 7420 | event.message.role === "assistant" && |
| 7421 | event.message.threadId === routing.childThreadId |
| 7422 | ? [`text:${event.message.text}#${event.message.id}`] |
| 7423 | : event.type === "turn_item.updated" && |
| 7424 | event.turnItem.nativeItemRef?.nativeId === BASH |
| 7425 | ? [`bash:${event.turnItem.status}`] |
| 7426 | : [], |
| 7427 | ); |
| 7428 | assert.deepEqual(childTexts, ["First.", "Second."], `trace: ${JSON.stringify(trace)}`); |
| 7429 | assert.deepEqual( |
| 7430 | [...(routing.toolThreadIds.get(BASH) ?? [])], |
| 7431 | [routing.childThreadId], |
| 7432 | `trace: ${JSON.stringify(trace)}`, |
| 7433 | ); |
| 7434 | assert.deepEqual( |
| 7435 | statuses, |
| 7436 | ["command_execution:running", "command_execution:completed"], |
| 7437 | `trace: ${JSON.stringify(trace)}`, |
| 7438 | ); |
| 7439 | // Progress that waited behind the subagent's frames still reaches its |
| 7440 | // card, and asks for no continuation of its own. |
| 7441 | assert.isTrue( |
| 7442 | harness.events.some( |
| 7443 | (event) => |
| 7444 | event.type === "subagent.updated" && |
| 7445 | event.subagent.progress === "Reading the last commits", |
| 7446 | ), |
| 7447 | ); |
| 7448 | assert.lengthOf(harness.continuationRequests, 1); |
| 7449 | }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), |
| 7450 | ), |
| 7451 | ); |
| 7452 | |
| 7453 | it.effect("does not resolve a newer API retry when replaying a held subagent frame", () => |
| 7454 | Effect.scoped( |
| 7455 | Effect.gen(function* () { |
| 7456 | const TOOL_USE_ID = "toolu-retry-subagent"; |
| 7457 | const harness = yield* makeWakeHarness; |
| 7458 | const now = yield* DateTime.now; |
| 7459 | yield* harness.runtime.startTurn( |
| 7460 | makeClaudeTestTurnInput({ |
| 7461 | threadId: harness.threadId, |
| 7462 | providerThread: harness.providerThread, |
| 7463 | now, |
| 7464 | attemptId: RunAttemptId.make("attempt-claude-retry-subagent"), |
| 7465 | text: "Run an auditor.", |
| 7466 | attachments: [], |
| 7467 | }), |
| 7468 | ); |
| 7469 | const frames = [ |
| 7470 | ...makeSubagentAssistantFrames({ |
| 7471 | parentToolUseId: TOOL_USE_ID, |
| 7472 | uuid: "00000000-0000-4000-8000-000000000351", |
| 7473 | text: "Held before registration.", |
| 7474 | }), |
| 7475 | claudeSdkFrame({ |
| 7476 | type: "system", |
| 7477 | subtype: "api_retry", |
| 7478 | attempt: 2, |
| 7479 | max_retries: 10, |
| 7480 | retry_delay_ms: 1_500, |
| 7481 | error_status: 529, |
| 7482 | error: "overloaded", |
| 7483 | uuid: "00000000-0000-4000-8000-000000000352", |
| 7484 | session_id: WAKE_NATIVE_SESSION, |
| 7485 | }), |
| 7486 | makeSubagentTaskStartedFrame({ |
| 7487 | taskId: "task-retry-subagent", |
| 7488 | toolUseId: TOOL_USE_ID, |
| 7489 | uuid: "00000000-0000-4000-8000-000000000353", |
| 7490 | }), |
| 7491 | ]; |
| 7492 | for (const frame of frames) { |
| 7493 | yield* Queue.offer(harness.sdkMessages, frame); |
| 7494 | } |
| 7495 | const childTexts = () => { |
| 7496 | const routing = subagentRouting(harness.events, []); |
| 7497 | return routing.childThreadId === undefined |
| 7498 | ? [] |
| 7499 | : routing.assistantTexts(routing.childThreadId); |
| 7500 | }; |
| 7501 | yield* awaitUntil(() => childTexts().length === 1, "replayed subagent text"); |
| 7502 | const retryStatuses = harness.events.flatMap((event) => |
| 7503 | event.type === "turn_item.updated" && |
| 7504 | event.turnItem.type === "error" && |
| 7505 | event.turnItem.retry !== undefined |
| 7506 | ? [event.turnItem.status] |
| 7507 | : [], |
| 7508 | ); |
| 7509 | assert.deepEqual(retryStatuses, ["running"]); |
| 7510 | }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), |
| 7511 | ), |
| 7512 | ); |
| 7513 | |
| 7514 | it.effect("holds subagent frames that precede task_started and shows its result once", () => |
| 7515 | Effect.scoped( |
| 7516 | Effect.gen(function* () { |
| 7517 | const TASK_ID = "task-early-subagent"; |
| 7518 | const TOOL_USE_ID = "toolu-early-subagent"; |
| 7519 | const FINAL_REPORT = "Early auditor done."; |
| 7520 | const harness = yield* makeWakeHarness; |
| 7521 | const now = yield* DateTime.now; |
| 7522 | yield* harness.runtime.startTurn( |
| 7523 | makeClaudeTestTurnInput({ |
| 7524 | threadId: harness.threadId, |
| 7525 | providerThread: harness.providerThread, |
| 7526 | now, |
| 7527 | attemptId: RunAttemptId.make("attempt-claude-early-subagent"), |
| 7528 | text: "Run an auditor.", |
| 7529 | attachments: [], |
| 7530 | }), |
| 7531 | ); |
| 7532 | const frames = [ |
| 7533 | // The SDK can forward child frames before the task_started that |
| 7534 | // registers their subagent. |
| 7535 | ...makeSubagentAssistantFrames({ |
| 7536 | parentToolUseId: TOOL_USE_ID, |
| 7537 | uuid: "00000000-0000-4000-8000-000000000321", |
| 7538 | text: "Starting early.", |
| 7539 | bashToolUseId: "toolu-early-bash", |
| 7540 | }), |
| 7541 | makeSubagentToolResultFrame({ |
| 7542 | parentToolUseId: TOOL_USE_ID, |
| 7543 | uuid: "00000000-0000-4000-8000-000000000322", |
| 7544 | toolUseId: "toolu-early-bash", |
| 7545 | }), |
| 7546 | makeSubagentTaskStartedFrame({ |
| 7547 | taskId: TASK_ID, |
| 7548 | toolUseId: TOOL_USE_ID, |
| 7549 | uuid: "00000000-0000-4000-8000-000000000323", |
| 7550 | }), |
| 7551 | ...makeSubagentAssistantFrames({ |
| 7552 | parentToolUseId: TOOL_USE_ID, |
| 7553 | uuid: "00000000-0000-4000-8000-000000000324", |
| 7554 | text: "Still working.", |
| 7555 | }), |
| 7556 | // Progress without a tool_use_id replaces the subagent entry while |
| 7557 | // the per-turn tool-use alias still points at the previous one. |
| 7558 | claudeSdkFrame({ |
| 7559 | type: "system", |
| 7560 | subtype: "task_progress", |
| 7561 | task_id: TASK_ID, |
| 7562 | description: "Checking the diffs", |
| 7563 | uuid: "00000000-0000-4000-8000-000000000325", |
| 7564 | session_id: WAKE_NATIVE_SESSION, |
| 7565 | }), |
| 7566 | ...makeSubagentAssistantFrames({ |
| 7567 | parentToolUseId: TOOL_USE_ID, |
| 7568 | uuid: "00000000-0000-4000-8000-000000000326", |
| 7569 | text: FINAL_REPORT, |
| 7570 | }), |
| 7571 | makeSubagentNotificationFrame({ |
| 7572 | taskId: TASK_ID, |
| 7573 | toolUseId: TOOL_USE_ID, |
| 7574 | summary: FINAL_REPORT, |
| 7575 | uuid: "00000000-0000-4000-8000-000000000327", |
| 7576 | }), |
| 7577 | makeResultFrame({ |
| 7578 | uuid: "00000000-0000-4000-8000-000000000328", |
| 7579 | result: "The auditor finished.", |
| 7580 | }), |
| 7581 | ]; |
| 7582 | for (const frame of frames) { |
| 7583 | yield* Queue.offer(harness.sdkMessages, frame); |
| 7584 | } |
| 7585 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); |
| 7586 | |
| 7587 | const routing = subagentRouting(harness.events, ["toolu-early-bash"]); |
| 7588 | assert.isDefined(routing.childThreadId); |
| 7589 | assert.deepEqual( |
| 6478 | 7590 | [...(routing.toolThreadIds.get("toolu-early-bash") ?? [])], |
| 6479 | 7591 | [routing.childThreadId], |
| 6480 | 7592 | ); |
| ... | ... | @@ -6949,6 +8061,23 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 6949 | 8061 | tool_use_result: resumeAck, |
| 6950 | 8062 | }), |
| 6951 | 8063 | ); |
| 8064 | // The resumed run's own work follows its task_started into the wake |
| 8065 | // buffer: nothing ingests the child thread until the drain. |
| 8066 | for (const frame of [ |
| 8067 | ...makeSubagentAssistantFrames({ |
| 8068 | parentToolUseId: SUBAGENT_TOOL_USE_ID, |
| 8069 | uuid: "00000000-0000-4000-8000-000000000961", |
| 8070 | text: "RESUMED_WORK", |
| 8071 | bashToolUseId: "toolu-resume-postsettle-bash", |
| 8072 | }), |
| 8073 | makeSubagentToolResultFrame({ |
| 8074 | parentToolUseId: SUBAGENT_TOOL_USE_ID, |
| 8075 | uuid: "00000000-0000-4000-8000-000000000962", |
| 8076 | toolUseId: "toolu-resume-postsettle-bash", |
| 8077 | }), |
| 8078 | ]) { |
| 8079 | yield* Queue.offer(harness.sdkMessages, frame); |
| 8080 | } |
| 6952 | 8081 | yield* Queue.offer(harness.sdkMessages, secondNotification); |
| 6953 | 8082 | yield* awaitUntil( |
| 6954 | 8083 | () => harness.continuationRequests.length === 2, |
| ... | ... | @@ -7010,6 +8139,19 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 7010 | 8139 | assert.equal(finalSubagent?.result, SECOND_SUMMARY); |
| 7011 | 8140 | // The completion keeps the resuming run's attribution. |
| 7012 | 8141 | assert.equal(finalSubagent?.runId, "run-attempt-claude-wake-9c"); |
| 8142 | // Its work is projected once, by the drain that re-opens it. |
| 8143 | const reopenIndex = harness.events.findIndex( |
| 8144 | (event) => |
| 8145 | event.type === "subagent.updated" && |
| 8146 | event.subagent.status === "running" && |
| 8147 | event.subagent.runId === "run-attempt-claude-wake-9c", |
| 8148 | ); |
| 8149 | assert.isAtLeast(reopenIndex, 0); |
| 8150 | const resumedWorkIndexes = harness.events.flatMap((event, index) => |
| 8151 | event.type === "message.updated" && event.message.text === "RESUMED_WORK" ? [index] : [], |
| 8152 | ); |
| 8153 | assert.lengthOf(resumedWorkIndexes, 1); |
| 8154 | assert.isAbove(resumedWorkIndexes[0] ?? -1, reopenIndex); |
| 7013 | 8155 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 7014 | 8156 | }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), |
| 7015 | 8157 | ), |