1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
2// @effect-diagnostics nodeBuiltinImport:off -- workflow fixtures use files and the fake Claude CLI uses Node streams
3import * as NodeEvents from "node:events";
4import * as NodeFS from "node:fs";
5import * as NodeOS from "node:os";
6import * as NodePath from "node:path";
7import * as NodeStream from "node:stream";
8
9import type {
10 Query as ClaudeQuery,
11 SDKMessage,
12 SDKResultMessage,
13 SDKUserMessage,
14 SpawnedProcess,
15 SpawnOptions,
16} from "@anthropic-ai/claude-agent-sdk";
17import type {
18 AskUserQuestionInput,
19 TaskCreateInput,
20 TaskCreateOutput,
21 TaskListOutput,
22 TaskUpdateInput,
23 TaskUpdateOutput,
24} from "@anthropic-ai/claude-agent-sdk/sdk-tools";
25import * as NodeServices from "@effect/platform-node/NodeServices";
26import {
27 ChatAttachmentId,
28 ChatFileAttachment,
29 ChatImageAttachment,
30 ClaudeSettings,
31 EnvironmentId,
32 MessageId,
33 type ModelSelection,
34 NodeId,
35 type OrchestrationV2AppThread,
36 type OrchestrationV2ProviderThread,
37 ProjectId,
38 ProviderInstanceId,
39 type ProviderInstanceEnvironment,
40 ProviderDriverKind,
41 type ProviderReplayTranscript,
42 type ProviderApprovalDecision,
43 ProviderSessionId,
44 ProviderTurnId,
45 RunAttemptId,
46 RunId,
47 ThreadId,
48} from "@t3tools/contracts";
49import { assert, describe, it } from "@effect/vitest";
50import * as Clock from "effect/Clock";
51import * as Context from "effect/Context";
52import * as DateTime from "effect/DateTime";
53import * as Deferred from "effect/Deferred";
54import * as Crypto from "effect/Crypto";
55import * as Effect from "effect/Effect";
56import * as Exit from "effect/Exit";
57import * as FileSystem from "effect/FileSystem";
58import * as Fiber from "effect/Fiber";
59import * as Layer from "effect/Layer";
60import * as Option from "effect/Option";
61import * as Path from "effect/Path";
62import * as Queue from "effect/Queue";
63import * as Schema from "effect/Schema";
64import * as Scheduler from "effect/Scheduler";
65import * as Scope from "effect/Scope";
66import * as Stream from "effect/Stream";
67import * as TestClock from "effect/testing/TestClock";
68import { Tool } from "effect/ai";
69import { formatClaudeResumeCompactionQuestion } from "@t3tools/shared/claudeCompaction";
70import * as HostProcess from "@t3tools/shared/HostProcess";
71import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
72import { SpawnExecutableResolution } from "@t3tools/shared/shell";
73
74import { attachmentRelativePath } from "../../attachmentStore.ts";
75import * as ServerConfig from "../../config.ts";
76import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession";
77import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
78import { PreviewControlsToolkit } from "../../mcp/toolkits/previewControls/tools.ts";
79import { HtmlToolkit } from "../../mcp/toolkits/html/tools.ts";
80import { EnvironmentToolkit } from "../../mcp/toolkits/environment/tools.ts";
81import { ProjectToolkit } from "../../mcp/toolkits/project/tools.ts";
82import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts";
83import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts";
84import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts";
85import { ClaudeExecutableFileCheck } from "../../provider/Drivers/ClaudeExecutable.ts";
86import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
87import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
88import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests";
89import { makeProviderFailure } from "@t3tools/provider-core/server/failure";
90import * as ClaudeAdapterV2 from "./ClaudeAdapterV2.ts";
91import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
92import { ClaudeOrchestratorReplayHarness } from "./ClaudeAdapterV2.testkit.ts";
93import { provideDeterministicTestRuntime } from "../testkit/DeterministicRuntime.ts";
94import { runOrchestratorV2Scenario } from "../testkit/OrchestratorScenario.ts";
95import { layerProviderReplay } from "../testkit/ProviderReplayHarness.ts";
96import {
97 assertBaseProjection,
98 assertSemanticProjectionIntegrity,
99 CLAUDE_MODEL_SELECTION,
100 materializeFixtureInput,
101 projectionFor,
102} from "../testkit/fixtures/shared.ts";
103
104const DEFAULT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({});
105const AUTO_COMPACT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({
106 autoCompactWindow: "300000",
107});
108const CUSTOM_MODEL_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({
109 customModels: [
110 // A bare custom slug that shadows the built-in "opus" alias.
111 "opus",
112 {
113 slug: "claude-custom-tuned",
114 name: "Tuned",
115 capabilities: {
116 optionDescriptors: [
117 {
118 id: "effort",
119 label: "Reasoning",
120 type: "select",
121 options: [
122 { id: "gentle", label: "Gentle", isDefault: true },
123 { id: "brutal", label: "Brutal" },
124 ],
125 },
126 ],
127 },
128 },
129 ],
130});
131const CLAUDE_TEST_MODEL_SELECTION = {
132 instanceId: ProviderInstanceId.make(ClaudeAdapterV2.CLAUDE_PROVIDER),
133 model: "claude-sonnet-4-6",
134 options: [{ id: "effort", value: "ultrathink" }],
135} satisfies ModelSelection;
136// Thinking is a launch flag, so switching to this selection needs a new process.
137const CLAUDE_TEST_RESTART_MODEL_SELECTION = {
138 ...CLAUDE_TEST_MODEL_SELECTION,
139 model: "claude-haiku-4-5-20251001",
140 options: [{ id: "thinking", value: false }],
141} satisfies ModelSelection;
142const CLAUDE_TEST_RUNTIME_POLICY = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
143 runtimeMode: "full-access",
144 interactionMode: "default",
145 cwd: "/workspace",
146});
147
148function makeClaudeTestAppThread(input: {
149 readonly threadId: ThreadId;
150 readonly providerThread: OrchestrationV2ProviderThread;
151 readonly now: DateTime.Utc;
152}): OrchestrationV2AppThread {
153 return {
154 createdBy: "user",
155 creationSource: "web",
156 id: input.threadId,
157 projectId: ProjectId.make(`project-${input.threadId}`),
158 title: "Claude attachment test",
159 providerInstanceId: ProviderInstanceId.make(ClaudeAdapterV2.CLAUDE_PROVIDER),
160 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
161 runtimeMode: "full-access",
162 interactionMode: "default",
163 branch: null,
164 worktreePath: null,
165 activeProviderThreadId: input.providerThread.id,
166 lineage: {
167 parentThreadId: null,
168 relationshipToParent: null,
169 rootThreadId: input.threadId,
170 },
171 forkedFrom: null,
172 createdAt: input.now,
173 updatedAt: input.now,
174 archivedAt: null,
175 settledOverride: null,
176 settledAt: null,
177 lastVisitedAt: null,
178 deletedAt: null,
179 };
180}
181
182function makeClaudeTestTurnInput(input: {
183 readonly threadId: ThreadId;
184 readonly providerThread: OrchestrationV2ProviderThread;
185 readonly now: DateTime.Utc;
186 readonly attemptId: RunAttemptId;
187 readonly text: string;
188 readonly attachments: ProviderAdapter.ProviderAdapterV2TurnInput["message"]["attachments"];
189 readonly providerTurnOrdinal?: number;
190 readonly nativeThreadHasTurns?: boolean;
191 readonly messageCreatedBy?: ProviderAdapter.ProviderAdapterV2TurnInput["message"]["createdBy"];
192 readonly messageCreationSource?: ProviderAdapter.ProviderAdapterV2TurnInput["message"]["creationSource"];
193 readonly modelSelection?: ModelSelection;
194 readonly runtimePolicy?: ProviderAdapter.ProviderAdapterV2RuntimePolicy;
195}): ProviderAdapter.ProviderAdapterV2TurnInput {
196 return {
197 appThread: makeClaudeTestAppThread(input),
198 threadId: input.threadId,
199 runId: RunId.make(`run-${input.attemptId}`),
200 runOrdinal: 1,
201 providerTurnOrdinal: input.providerTurnOrdinal ?? 1,
202 ...(input.nativeThreadHasTurns === undefined
203 ? {}
204 : { nativeThreadHasTurns: input.nativeThreadHasTurns }),
205 attemptId: input.attemptId,
206 rootNodeId: NodeId.make(`node-${input.attemptId}`),
207 providerThread: input.providerThread,
208 message: {
209 createdBy: input.messageCreatedBy ?? "user",
210 creationSource: input.messageCreationSource ?? "web",
211 messageId: MessageId.make(`message-${input.attemptId}`),
212 text: input.text,
213 attachments: input.attachments,
214 },
215 modelSelection: input.modelSelection ?? CLAUDE_TEST_MODEL_SELECTION,
216 runtimePolicy: input.runtimePolicy ?? CLAUDE_TEST_RUNTIME_POLICY,
217 };
218}
219
220describe("ClaudeAdapterV2 runtime query policy", () => {
221 it.each([false, true])("requests thinking summaries with resume=%s", (resume) => {
222 const options = ClaudeAdapterV2.makeClaudeQueryOptions({
223 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
224 nativeThreadId: "thinking-thread",
225 resume,
226 cwd: "/workspace",
227 });
228 assert.deepEqual(options.thinking, { type: "adaptive", display: "summarized" });
229 assert.equal(options.extraArgs?.["thinking-display"], "summarized");
230 assert.include(options.settings, { showThinkingSummaries: true });
231 });
232
233 it("preserves an explicit omitted thinking display", () => {
234 const options = ClaudeAdapterV2.makeClaudeQueryOptions({
235 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
236 nativeThreadId: "thinking-thread",
237 resume: false,
238 cwd: "/workspace",
239 settings: { ...DEFAULT_CLAUDE_SETTINGS, launchArgs: "--thinking-display omitted" },
240 });
241 assert.isUndefined(options.thinking);
242 assert.equal(options.extraArgs?.["thinking-display"], "omitted");
243 assert.notInclude(options.settings ?? {}, { showThinkingSummaries: true });
244 });
245
246 it("does not enable thinking when the model option disables it", () => {
247 const options = ClaudeAdapterV2.makeClaudeQueryOptions({
248 modelSelection: {
249 ...CLAUDE_TEST_MODEL_SELECTION,
250 model: "claude-haiku-4-5",
251 options: [{ id: "thinking", value: false }],
252 },
253 nativeThreadId: "thinking-thread",
254 resume: false,
255 cwd: "/workspace",
256 });
257 assert.isUndefined(options.thinking);
258 assert.isUndefined(options.extraArgs?.["thinking-display"]);
259 assert.include(options.settings, { alwaysThinkingEnabled: false });
260 });
261
262 it.each([
263 ["--permission-mode acceptEdits", "acceptEdits"],
264 ["--dangerously-skip-permissions", "bypassPermissions"],
265 ["--dangerously-skip-permissions --permission-mode plan", "plan"],
266 ])("folds %s into the SDK permission mode", (launchArgs, expected) => {
267 const options = ClaudeAdapterV2.makeClaudeQueryOptions({
268 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
269 nativeThreadId: "native-permission-override",
270 resume: false,
271 cwd: "/workspace",
272 permissionMode: "default",
273 settings: { ...AUTO_COMPACT_CLAUDE_SETTINGS, launchArgs },
274 });
275 assert.equal(options.permissionMode, expected);
276 assert.isUndefined(options.extraArgs?.["permission-mode"]);
277 assert.isUndefined(options.extraArgs?.["dangerously-skip-permissions"]);
278 });
279
280 it("passes automatic compaction and resume-dialog controls to the SDK", () => {
281 const onUserDialog = async () => ({
282 behavior: "completed" as const,
283 result: "continue" as const,
284 });
285 const options = ClaudeAdapterV2.makeClaudeQueryOptions({
286 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
287 nativeThreadId: "native-thread-auto-compact",
288 resume: true,
289 cwd: "/workspace",
290 settings: AUTO_COMPACT_CLAUDE_SETTINGS,
291 onUserDialog,
292 supportedDialogKinds: ["resume_return"],
293 });
294
295 assert.equal((options.settings as { autoCompactWindow?: number }).autoCompactWindow, 300_000);
296 assert.equal(options.onUserDialog, onUserDialog);
297 assert.deepEqual(options.supportedDialogKinds, ["resume_return"]);
298 });
299
300 it("projects AskUserQuestion input with question text as the answer key", () => {
301 assert.deepEqual(
302 ClaudeAdapterV2.claudeUserInputQuestions({
303 questions: [
304 {
305 header: "Approach",
306 question: "Which approach?",
307 options: [{ label: "Simple", description: "Use fewer moving parts" }],
308 multiSelect: true,
309 },
310 ],
311 }),
312 [
313 {
314 id: "Which approach?",
315 header: "Approach",
316 question: "Which approach?",
317 options: [{ label: "Simple", description: "Use fewer moving parts" }],
318 multiSelect: true,
319 },
320 ],
321 );
322 const sdkAnswers: NonNullable<AskUserQuestionInput["answers"]> =
323 ClaudeAdapterV2.claudeSdkUserInputAnswers({
324 "Which approach?": ["Simple", "Safe"],
325 "Deploy now?": "Yes",
326 });
327 assert.deepEqual(sdkAnswers, {
328 "Which approach?": "Simple, Safe",
329 "Deploy now?": "Yes",
330 });
331 assert.isTrue(
332 ClaudeAdapterV2.ClaudeProviderCapabilitiesV2.planning.supportsStructuredQuestions,
333 );
334 });
335
336 it("normalizes Claude todo and proposed-plan tool input", () => {
337 assert.deepEqual(
338 ClaudeAdapterV2.claudeTodoSteps({
339 todos: [
340 { content: "Inspect", status: "completed" },
341 { content: "Implement", status: "in_progress" },
342 ],
343 }),
344 [
345 { id: "todo-0", text: "Inspect", status: "completed" },
346 { id: "todo-1", text: "Implement", status: "running" },
347 ],
348 );
349 assert.equal(
350 ClaudeAdapterV2.claudeProposedPlan({ plan: " # Plan\nShip it " }),
351 "# Plan\nShip it",
352 );
353 assert.isTrue(ClaudeAdapterV2.ClaudeProviderCapabilitiesV2.planning.emitsTodoList);
354 assert.isTrue(ClaudeAdapterV2.ClaudeProviderCapabilitiesV2.planning.emitsProposedPlan);
355 });
356
357 it("maps canonical read-only never policy to Claude dontAsk with read-only tools", () => {
358 const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy(
359 ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
360 runtimeMode: "full-access",
361 interactionMode: "default",
362 cwd: "/workspace",
363 approvalPolicy: "never",
364 sandboxPolicy: {
365 type: "readOnly",
366 access: { type: "fullAccess" },
367 networkAccess: false,
368 },
369 }),
370 );
371
372 assert.deepEqual(queryPolicy, {
373 permissionMode: "dontAsk",
374 tools: ClaudeAdapterV2.CLAUDE_READ_ONLY_ALLOWED_TOOLS,
375 allowedTools: ClaudeAdapterV2.CLAUDE_READ_ONLY_ALLOWED_TOOLS,
376 installPermissionCallback: false,
377 });
378 });
379
380 it("maps canonical read-only on-request policy to Claude default with callbacks", () => {
381 const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy(
382 ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
383 runtimeMode: "full-access",
384 interactionMode: "default",
385 cwd: "/workspace",
386 approvalPolicy: "on-request",
387 sandboxPolicy: {
388 type: "readOnly",
389 access: { type: "fullAccess" },
390 networkAccess: false,
391 },
392 }),
393 );
394
395 assert.deepEqual(queryPolicy, {
396 permissionMode: "default",
397 tools: ClaudeAdapterV2.CLAUDE_READ_ONLY_ALLOWED_TOOLS,
398 allowedTools: ClaudeAdapterV2.CLAUDE_READ_ONLY_ALLOWED_TOOLS,
399 installPermissionCallback: true,
400 });
401 });
402
403 it("does not auto-allow reads for canonical restricted read-only never policy", () => {
404 const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy(
405 ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
406 runtimeMode: "full-access",
407 interactionMode: "default",
408 cwd: "/workspace",
409 approvalPolicy: "never",
410 sandboxPolicy: {
411 type: "readOnly",
412 access: {
413 type: "restricted",
414 includePlatformDefaults: false,
415 readableRoots: [],
416 },
417 networkAccess: false,
418 },
419 }),
420 );
421
422 assert.deepEqual(queryPolicy, {
423 permissionMode: "dontAsk",
424 tools: ClaudeAdapterV2.CLAUDE_READ_ONLY_ALLOWED_TOOLS,
425 installPermissionCallback: false,
426 });
427 });
428
429 it("maps default full-access policy to Claude bypass permissions", () => {
430 const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy(
431 ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
432 runtimeMode: "full-access",
433 interactionMode: "default",
434 cwd: "/workspace",
435 }),
436 );
437
438 assert.deepEqual(queryPolicy, {
439 permissionMode: "bypassPermissions",
440 allowDangerouslySkipPermissions: true,
441 installPermissionCallback: false,
442 });
443 });
444
445 it("maps Auto runtime mode to Claude's AI-reviewed permission mode", () => {
446 const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy(
447 ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
448 runtimeMode: "auto",
449 interactionMode: "default",
450 cwd: "/workspace",
451 }),
452 );
453
454 assert.deepEqual(queryPolicy, {
455 permissionMode: "auto",
456 installPermissionCallback: false,
457 });
458 });
459
460 it("keeps approval-required mode interactive with danger-full-access sandboxing", () => {
461 const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy(
462 ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
463 runtimeMode: "approval-required",
464 interactionMode: "default",
465 cwd: "/workspace",
466 sandboxPolicy: {
467 type: "dangerFullAccess",
468 },
469 }),
470 );
471
472 assert.deepEqual(queryPolicy, {
473 permissionMode: "default",
474 installPermissionCallback: true,
475 });
476 });
477
478 it("installs the permission callback for approval-required plan mode", () => {
479 const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy(
480 ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
481 runtimeMode: "approval-required",
482 interactionMode: "plan",
483 cwd: "/workspace",
484 }),
485 );
486
487 assert.deepEqual(queryPolicy, {
488 permissionMode: "plan",
489 installPermissionCallback: true,
490 });
491 });
492
493 it("honors never approvals for approval-required workspace-write policy", () => {
494 const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy(
495 ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
496 runtimeMode: "approval-required",
497 interactionMode: "default",
498 cwd: "/workspace",
499 approvalPolicy: "never",
500 sandboxPolicy: {
501 type: "workspaceWrite",
502 },
503 }),
504 );
505
506 assert.deepEqual(queryPolicy, {
507 permissionMode: "dontAsk",
508 installPermissionCallback: false,
509 });
510 });
511
512 it("honors never approvals for externally sandboxed policy", () => {
513 const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy(
514 ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
515 runtimeMode: "approval-required",
516 interactionMode: "default",
517 cwd: "/workspace",
518 approvalPolicy: "never",
519 sandboxPolicy: {
520 type: "externalSandbox",
521 },
522 }),
523 );
524
525 assert.deepEqual(queryPolicy, {
526 permissionMode: "bypassPermissions",
527 allowDangerouslySkipPermissions: true,
528 installPermissionCallback: false,
529 });
530 });
531});
532
533describe("ClaudeAdapterV2 MCP query overrides", () => {
534 const T3_MCP_SERVERS = {
535 "t3-code": {
536 type: "http",
537 url: "http://127.0.0.1:43123/mcp",
538 headers: {
539 Authorization: "Bearer secret-claude-token",
540 },
541 timeout: ClaudeAdapterV2.CLAUDE_T3_MCP_TOOL_TIMEOUT_MS,
542 },
543 } as const;
544
545 const mcpSessionFor = (
546 threadId: ThreadId,
547 authorizationHeader = "Bearer secret-claude-token",
548 ): McpProviderSession.McpProviderSessionConfig => ({
549 environmentId: EnvironmentId.make(`environment-${threadId}`),
550 threadId,
551 providerSessionId: `mcp-session-${threadId}`,
552 providerInstanceId: ProviderInstanceId.make("claudeAgent"),
553 endpoint: "http://127.0.0.1:43123/mcp",
554 authorizationHeader,
555 browserToolsAvailable: true,
556 });
557
558 it("leaves an absent allowlist absent when no MCP session exists", () => {
559 const overrides = ClaudeAdapterV2.claudeMcpQueryOverrides({
560 mcpSession: undefined,
561 readOnlySandbox: false,
562 });
563
564 assert.deepEqual(overrides, {});
565 });
566
567 it("preserves an explicit allowlist when no MCP session exists", () => {
568 const overrides = ClaudeAdapterV2.claudeMcpQueryOverrides({
569 mcpSession: undefined,
570 readOnlySandbox: false,
571 allowedTools: ["Read"],
572 });
573
574 assert.deepEqual(overrides, { allowedTools: ["Read"] });
575 });
576
577 it("pre-approves all t3-code tools when attaching an MCP session without an allowlist", () => {
578 const threadId = ThreadId.make("thread-claude-mcp-no-allowlist");
579 const mcpSession = mcpSessionFor(threadId);
580 const overrides = ClaudeAdapterV2.claudeMcpQueryOverrides({
581 mcpSession,
582 readOnlySandbox: false,
583 });
584
585 assert.deepEqual(overrides, {
586 allowedTools: [ClaudeAdapterV2.CLAUDE_T3_MCP_TOOL_WILDCARD],
587 mcpServers: T3_MCP_SERVERS,
588 });
589 });
590
591 it("extends an explicit allowlist with the t3-code wildcard", () => {
592 const threadId = ThreadId.make("thread-claude-mcp-with-allowlist");
593 const mcpSession = mcpSessionFor(threadId);
594 const overrides = ClaudeAdapterV2.claudeMcpQueryOverrides({
595 mcpSession,
596 readOnlySandbox: false,
597 allowedTools: ["Read", "mcp__t3-code__*"],
598 });
599
600 assert.deepEqual(overrides, {
601 allowedTools: ["Read", "mcp__t3-code__*"],
602 mcpServers: T3_MCP_SERVERS,
603 });
604 });
605
606 it("pre-approves only read-only t3-code tools in a read-only sandbox", () => {
607 const threadId = ThreadId.make("thread-claude-mcp-read-only");
608 const mcpSession = mcpSessionFor(threadId);
609 const overrides = ClaudeAdapterV2.claudeMcpQueryOverrides({
610 mcpSession,
611 readOnlySandbox: true,
612 allowedTools: [...ClaudeAdapterV2.CLAUDE_READ_ONLY_ALLOWED_TOOLS],
613 });
614
615 assert.deepEqual(overrides, {
616 allowedTools: [
617 ...ClaudeAdapterV2.CLAUDE_READ_ONLY_ALLOWED_TOOLS,
618 ...ClaudeAdapterV2.CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS,
619 ],
620 mcpServers: T3_MCP_SERVERS,
621 });
622 assert.isFalse(overrides.allowedTools?.includes(ClaudeAdapterV2.CLAUDE_T3_MCP_TOOL_WILDCARD));
623 });
624
625 it("pre-approves only read-only t3-code tools in a read-only sandbox without an allowlist", () => {
626 const threadId = ThreadId.make("thread-claude-mcp-read-only-no-allowlist");
627 const mcpSession = mcpSessionFor(threadId);
628 const overrides = ClaudeAdapterV2.claudeMcpQueryOverrides({
629 mcpSession,
630 readOnlySandbox: true,
631 });
632
633 assert.deepEqual(overrides.allowedTools, [
634 ...ClaudeAdapterV2.CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS,
635 ]);
636 });
637
638 it("keys live-query reuse on the MCP-derived pre-approvals", () => {
639 const threadId = ThreadId.make("thread-claude-mcp-query-key");
640 const mcpSession = mcpSessionFor(threadId);
641 const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy(
642 ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
643 runtimeMode: "full-access",
644 interactionMode: "default",
645 cwd: "/workspace",
646 approvalPolicy: "on-request",
647 sandboxPolicy: {
648 type: "readOnly",
649 access: { type: "fullAccess" },
650 networkAccess: false,
651 },
652 }),
653 );
654
655 const readOnlyKey = ClaudeAdapterV2.claudeEffectiveQueryPolicyKey(
656 queryPolicy,
657 ClaudeAdapterV2.claudeMcpQueryOverrides({ mcpSession, readOnlySandbox: true }),
658 );
659 const fullAccessKey = ClaudeAdapterV2.claudeEffectiveQueryPolicyKey(
660 queryPolicy,
661 ClaudeAdapterV2.claudeMcpQueryOverrides({ mcpSession, readOnlySandbox: false }),
662 );
663 const detachedKey = ClaudeAdapterV2.claudeEffectiveQueryPolicyKey(queryPolicy, {});
664
665 assert.notEqual(readOnlyKey, fullAccessKey);
666 assert.notEqual(fullAccessKey, detachedKey);
667 });
668
669 it("invalidates live-query reuse when MCP credentials rotate", () => {
670 const threadId = ThreadId.make("thread-claude-mcp-credential-rotation");
671 const mcpSession = mcpSessionFor(threadId);
672 const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy(
673 ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
674 runtimeMode: "approval-required",
675 interactionMode: "default",
676 cwd: "/workspace",
677 }),
678 );
679 const initialKey = ClaudeAdapterV2.claudeEffectiveQueryPolicyKey(
680 queryPolicy,
681 ClaudeAdapterV2.claudeMcpQueryOverrides({ mcpSession, readOnlySandbox: false }),
682 );
683
684 const rotatedKey = ClaudeAdapterV2.claudeEffectiveQueryPolicyKey(
685 queryPolicy,
686 ClaudeAdapterV2.claudeMcpQueryOverrides({
687 mcpSession: mcpSessionFor(threadId, "Bearer rotated-claude-token"),
688 readOnlySandbox: false,
689 }),
690 );
691 assert.notEqual(rotatedKey, initialKey);
692 });
693
694 it("matches the read-only allowlist to the orchestrator toolkit annotations", () => {
695 const readOnlyToolNames = [
696 ...Object.values(OrchestratorToolkit.tools),
697 ...Object.values(ThreadToolkit.tools),
698 ...Object.values(WorktreeToolkit.tools),
699 ...Object.values(ProjectToolkit.tools),
700 ...Object.values(EnvironmentToolkit.tools),
701 ...Object.values(PreviewControlsToolkit.tools),
702 ...Object.values(HtmlToolkit.tools),
703 ]
704 .filter((tool) => Context.get(tool.annotations, Tool.Readonly))
705 .map((tool) => `mcp__t3-code__${tool.name}`)
706 .sort();
707
708 assert.deepEqual(
709 [...ClaudeAdapterV2.CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS].sort(),
710 readOnlyToolNames,
711 );
712 });
713});
714
715describe("ClaudeAdapterV2 native protocol logging", () => {
716 it("injects thread-scoped MCP configuration without logging the credential", () => {
717 const threadId = ThreadId.make("thread-claude-mcp");
718 const mcpSession = {
719 environmentId: EnvironmentId.make("environment-claude-mcp"),
720 threadId,
721 providerSessionId: "mcp-session-claude",
722 providerInstanceId: ProviderInstanceId.make("claudeAgent"),
723 endpoint: "http://127.0.0.1:43123/mcp",
724 authorizationHeader: "Bearer secret-claude-token",
725 browserToolsAvailable: true,
726 };
727
728 const overrides = ClaudeAdapterV2.claudeMcpQueryOverrides({
729 mcpSession,
730 readOnlySandbox: false,
731 allowedTools: ["Read"],
732 });
733 assert.deepEqual(overrides, {
734 allowedTools: ["Read", "mcp__t3-code__*"],
735 mcpServers: {
736 "t3-code": {
737 type: "http",
738 url: "http://127.0.0.1:43123/mcp",
739 headers: {
740 Authorization: "Bearer secret-claude-token",
741 },
742 timeout: ClaudeAdapterV2.CLAUDE_T3_MCP_TOOL_TIMEOUT_MS,
743 },
744 },
745 });
746
747 const options = ClaudeAdapterV2.makeClaudeQueryOptions({
748 modelSelection: {
749 instanceId: ProviderInstanceId.make("claudeAgent"),
750 model: "claude-sonnet-4-6",
751 },
752 nativeThreadId: "native-thread-claude-mcp",
753 resume: false,
754 cwd: "/workspace",
755 allowedTools: overrides.allowedTools ?? [],
756 mcpServers: overrides.mcpServers ?? {},
757 environment: {},
758 });
759 assert.isObject(options.systemPrompt);
760 const systemPrompt = options.systemPrompt as {
761 readonly type: string;
762 readonly preset: string;
763 readonly append?: string;
764 };
765 assert.equal(systemPrompt.type, "preset");
766 assert.equal(systemPrompt.preset, "claude_code");
767 assert.include(systemPrompt.append ?? "", "Use `delegate_task`");
768 const logged = ClaudeAdapterV2.loggedClaudeQueryOptions(options);
769 assert.equal(logged.hasMcpServers, true);
770 assert.notInclude(JSON.stringify(logged), "secret-claude-token");
771 });
772
773 it.effect("writes Claude Agent SDK protocol frames to the native provider log", () =>
774 Effect.gen(function* () {
775 const writes: Array<{
776 readonly event: unknown;
777 readonly threadId: ThreadId | null;
778 }> = [];
779 const logger: ProviderEventLoggers.EventNdjsonLogger = {
780 filePath: "/tmp/events.log",
781 write: (event, threadId) =>
782 Effect.sync(() => {
783 writes.push({ event, threadId });
784 }),
785 close: () => Effect.void,
786 };
787 const threadId = ThreadId.make("thread-1");
788 const providerSessionId = ProviderSessionId.make("provider-session-1");
789 const protocolLogger = ClaudeAdapterV2.makeClaudeAgentSdkProtocolLogger({
790 nativeEventLogger: logger,
791 threadId,
792 providerSessionId,
793 });
794
795 assert.notEqual(protocolLogger, undefined);
796 if (protocolLogger === undefined) {
797 return;
798 }
799
800 yield* protocolLogger({
801 direction: "incoming",
802 stage: "decoded",
803 payload: {
804 type: "stream_event",
805 uuid: "00000000-0000-0000-0000-000000000001",
806 session_id: "native-thread",
807 parent_tool_use_id: null,
808 event: {
809 type: "content_block_delta",
810 index: 0,
811 delta: {
812 type: "text_delta",
813 get text(): string {
814 throw new Error("streaming text must not be inspected");
815 },
816 },
817 },
818 },
819 });
820 yield* protocolLogger({
821 direction: "outgoing",
822 stage: "decoded",
823 payload: {
824 type: "query.interrupt",
825 },
826 });
827
828 assert.equal(writes.length, 1);
829 assert.equal(writes[0]?.threadId, threadId);
830 assert.deepEqual(writes[0]?.event, {
831 provider: "claudeAgent",
832 protocol: ClaudeAdapterV2.CLAUDE_AGENT_SDK_QUERY_PROTOCOL,
833 kind: "protocol",
834 providerSessionId,
835 event: {
836 direction: "outgoing",
837 stage: "decoded",
838 payload: {
839 type: "query.interrupt",
840 },
841 },
842 });
843 }),
844 );
845
846 it("logs query options without leaking environment values or callback functions", () => {
847 const options: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions = {
848 model: "claude-sonnet-4-6",
849 tools: {
850 type: "preset",
851 preset: "claude_code",
852 },
853 permissionMode: "default",
854 sessionId: "native-thread-1",
855 cwd: "/workspace",
856 env: {
857 ANTHROPIC_API_KEY: "secret",
858 },
859 extraArgs: {
860 "append-system-prompt": "secret launch prompt",
861 },
862 canUseTool: (_toolName, input, callbackOptions) =>
863 Promise.resolve({
864 behavior: "allow",
865 updatedInput: input,
866 toolUseID: callbackOptions.toolUseID,
867 decisionClassification: "user_temporary",
868 }),
869 };
870
871 assert.deepEqual(ClaudeAdapterV2.loggedClaudeQueryOptions(options), {
872 model: "claude-sonnet-4-6",
873 tools: {
874 type: "preset",
875 preset: "claude_code",
876 },
877 permissionMode: "default",
878 sessionId: "native-thread-1",
879 cwd: "/workspace",
880 hasCanUseTool: true,
881 hasEnvironment: true,
882 hasExtraArgs: true,
883 });
884 assert.notInclude(
885 JSON.stringify(ClaudeAdapterV2.loggedClaudeQueryOptions(options)),
886 "secret launch prompt",
887 );
888 });
889});
890
891describe("ClaudeAdapterV2 context usage", () => {
892 it("projects assistant usage against the selected context window", () => {
893 const usage = ClaudeAdapterV2.claudeProviderTurnTokenUsage(
894 {
895 input_tokens: 42_000,
896 cache_creation_input_tokens: 2_000,
897 cache_read_input_tokens: 5_000,
898 output_tokens: 1_000,
899 },
900 CLAUDE_TEST_MODEL_SELECTION,
901 "2026-08-29T00:00:00.000Z",
902 );
903
904 assert.deepEqual(usage, {
905 usedTokens: 50_000,
906 maxTokens: 200_000,
907 inputTokens: 49_000,
908 cachedInputTokens: 5_000,
909 outputTokens: 1_000,
910 reasoningOutputTokens: 0,
911 updatedAt: "2026-08-29T00:00:00.000Z",
912 });
913 });
914
915 it("reports the prompt cache TTL the response wrote", () => {
916 const ttlFor = (cache_creation: {
917 ephemeral_1h_input_tokens: number;
918 ephemeral_5m_input_tokens: number;
919 }) =>
920 ClaudeAdapterV2.claudeProviderTurnTokenUsage(
921 { input_tokens: 1, output_tokens: 1, cache_creation },
922 CLAUDE_TEST_MODEL_SELECTION,
923 "2026-08-29T00:00:00.000Z",
924 ).promptCacheTtlMs;
925
926 assert.strictEqual(
927 ttlFor({ ephemeral_1h_input_tokens: 2_000, ephemeral_5m_input_tokens: 300 }),
928 60 * 60_000,
929 );
930 assert.strictEqual(
931 ttlFor({ ephemeral_1h_input_tokens: 0, ephemeral_5m_input_tokens: 300 }),
932 5 * 60_000,
933 );
934 assert.strictEqual(
935 ttlFor({ ephemeral_1h_input_tokens: 0, ephemeral_5m_input_tokens: 0 }),
936 undefined,
937 );
938 });
939});
940
941describe("ClaudeAdapterV2 session permissions", () => {
942 it("keeps explicit user refusals classified as user_reject", () => {
943 const result = ClaudeAdapterV2.permissionResultFromDecision({
944 toolName: "Bash",
945 decision: "decline",
946 toolInput: { command: "make" },
947 toolUseID: "denied-build",
948 });
949 assert.equal(result.behavior, "deny");
950 if (result.behavior !== "deny") return;
951 assert.equal(result.decisionClassification, "user_reject");
952 assert.equal(result.message, "User declined tool execution.");
953 assert.equal(result.interrupt, undefined);
954 });
955
956 it("forces suggested permission updates to session scope", () => {
957 const result = ClaudeAdapterV2.permissionResultFromDecision({
958 toolName: "Bash",
959 decision: "acceptForSession",
960 toolInput: { command: "git status" },
961 toolUseID: "tool-1",
962 suggestions: [
963 {
964 type: "addRules",
965 rules: [{ toolName: "Bash", ruleContent: "git status" }],
966 behavior: "allow",
967 destination: "localSettings",
968 },
969 ],
970 });
971
972 assert.equal(result.behavior, "allow");
973 if (result.behavior !== "allow") {
974 return;
975 }
976 assert.deepEqual(result.updatedPermissions, [
977 {
978 type: "addRules",
979 rules: [{ toolName: "Bash", ruleContent: "git status" }],
980 behavior: "allow",
981 destination: "session",
982 },
983 ]);
984 });
985
986 it("adds a whole-tool session rule when Claude offers no suggestion", () => {
987 const result = ClaudeAdapterV2.permissionResultFromDecision({
988 toolName: "mcp__t3__custom_tool",
989 decision: "acceptForSession",
990 toolInput: {},
991 toolUseID: "tool-2",
992 });
993
994 assert.equal(result.behavior, "allow");
995 if (result.behavior !== "allow") {
996 return;
997 }
998 assert.deepEqual(result.updatedPermissions, [
999 {
1000 type: "addRules",
1001 rules: [{ toolName: "mcp__t3__custom_tool" }],
1002 behavior: "allow",
1003 destination: "session",
1004 },
1005 ]);
1006 });
1007});
1008
1009describe("ClaudeAdapterV2 Auto-accept edits", () => {
1010 it.effect("asks before a command instead of allowing it", () =>
1011 Effect.scoped(
1012 Effect.gen(function* () {
1013 const fileSystem = yield* FileSystem.FileSystem;
1014 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1015 const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({
1016 prefix: "t3-claude-accept-edits-",
1017 });
1018 let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined;
1019 const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({
1020 instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
1021 settings: DEFAULT_CLAUDE_SETTINGS,
1022 environment: {},
1023 attachmentsDir,
1024 fileSystem,
1025 path: yield* Path.Path,
1026 crypto: yield* Crypto.Crypto,
1027 idAllocator,
1028 queryRunner: {
1029 allocateSessionId: Effect.succeed("native-thread-claude-accept-edits"),
1030 open: (input) =>
1031 Effect.sync(() => {
1032 openedOptions = input.options;
1033 return {
1034 messages: Stream.never,
1035 offer: () => Effect.void,
1036 setModel: () => Effect.void,
1037 applyFlagSettings: () => Effect.void,
1038 setPermissionMode: () => Effect.void,
1039 stopTask: () => Effect.void,
1040 interrupt: Effect.void,
1041 close: Effect.void,
1042 };
1043 }),
1044 forkSession: () => Effect.die("unused"),
1045 subagentLaunchToolUseId: () => Effect.succeed(null),
1046 assertComplete: Effect.void,
1047 },
1048 });
1049 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
1050 runtimeMode: "auto-accept-edits",
1051 interactionMode: "default",
1052 cwd: "/workspace",
1053 });
1054 const threadId = ThreadId.make("thread-claude-accept-edits");
1055 const runtime = yield* adapter.openSession({
1056 threadId,
1057 providerSessionId: ProviderSessionId.make("provider-session-claude-accept-edits"),
1058 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
1059 runtimePolicy,
1060 });
1061 const providerThread = yield* runtime.ensureThread({
1062 threadId,
1063 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
1064 runtimePolicy,
1065 });
1066 const now = yield* DateTime.now;
1067 yield* runtime.startTurn(
1068 makeClaudeTestTurnInput({
1069 threadId,
1070 providerThread,
1071 now,
1072 attemptId: RunAttemptId.make("attempt-claude-accept-edits"),
1073 text: "Run node.",
1074 attachments: [],
1075 runtimePolicy,
1076 }),
1077 );
1078 assert.equal(openedOptions?.permissionMode, "acceptEdits");
1079 const canUseTool = openedOptions?.canUseTool;
1080 assert.isFunction(canUseTool);
1081
1082 const requestEvent = yield* runtime.events.pipe(
1083 Stream.filter((event) => event.type === "runtime_request.updated"),
1084 Stream.runHead,
1085 Effect.forkScoped,
1086 );
1087 const command = { command: "node -e 'console.log(42)'" };
1088 const decision = yield* Effect.promise(() =>
1089 canUseTool!("Bash", command, {
1090 signal: new AbortController().signal,
1091 toolUseID: "tool-bash-accept-edits",
1092 requestId: "request-bash-accept-edits",
1093 }),
1094 ).pipe(Effect.forkScoped);
1095 // Without a callback that asks, the command is allowed before any
1096 // request is raised.
1097 const first = yield* Effect.raceFirst(
1098 Fiber.join(requestEvent).pipe(
1099 Effect.map((event) => ({ type: "request", event }) as const),
1100 ),
1101 Fiber.join(decision).pipe(
1102 Effect.map((result) => ({ type: "decision", result }) as const),
1103 ),
1104 );
1105 assert.equal(first.type, "request", "the command ran without asking");
1106 if (first.type !== "request") return;
1107 const event = first.event;
1108 if (Option.isNone(event) || event.value.type !== "runtime_request.updated") return;
1109 assert.equal(event.value.runtimeRequest.kind, "command");
1110
1111 yield* runtime.respondToRuntimeRequest({
1112 requestId: event.value.runtimeRequest.id,
1113 decision: "accept",
1114 });
1115 assert.equal((yield* Fiber.join(decision))?.behavior, "allow");
1116 }).pipe(
1117 Effect.provide(
1118 Layer.mergeAll(
1119 IdAllocator.layer,
1120 McpProviderSessions.layer,
1121 ScriptProgress.layerUnavailable,
1122 NodeServices.layer,
1123 ),
1124 ),
1125 ),
1126 ),
1127 );
1128});
1129
1130describe("ClaudeAdapterV2 approval cancellation", () => {
1131 it.effect("observes an approval signal that was already aborted", () =>
1132 Effect.gen(function* () {
1133 const decision = yield* Deferred.make<ProviderApprovalDecision>();
1134 const controller = new AbortController();
1135 controller.abort();
1136
1137 const result = yield* ClaudeAdapterV2.awaitClaudeApprovalDecision(
1138 decision,
1139 controller.signal,
1140 );
1141
1142 assert.equal(result, "cancel");
1143 }),
1144 );
1145
1146 it.effect("removes the cancellation listener after approval resolves", () =>
1147 Effect.gen(function* () {
1148 const decision = yield* Deferred.make<ProviderApprovalDecision>();
1149 const controller = new AbortController();
1150 let removes = 0;
1151 const removeEventListener = controller.signal.removeEventListener.bind(controller.signal);
1152 controller.signal.removeEventListener = (...args: Parameters<typeof removeEventListener>) => {
1153 removes += 1;
1154 return removeEventListener(...args);
1155 };
1156 const fiber = yield* Effect.forkChild(
1157 ClaudeAdapterV2.awaitClaudeApprovalDecision(decision, controller.signal),
1158 );
1159 yield* Effect.yieldNow;
1160 yield* Deferred.succeed(decision, "accept");
1161 const result = yield* Fiber.join(fiber);
1162
1163 assert.equal(result, "accept");
1164 assert.equal(removes, 1);
1165 }),
1166 );
1167});
1168
1169// Opens a session with the given configured binary path and environment, runs
1170// one turn, and returns the executable paths the SDK was asked to spawn and the
1171// CLAUDE_CODE_ENABLE_TODO_TOOLS value it was given.
1172const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(function* (
1173 binaryPath: string,
1174 environment: ProviderInstanceEnvironment = [],
1175) {
1176 const executablePaths: Array<string | undefined> = [];
1177 const taskToolEnvironment: Array<string | undefined> = [];
1178 const adapter = yield* ClaudeAdapterV2.createClaudeAdapterV2(
1179 {
1180 instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
1181 displayName: undefined,
1182 environment,
1183 enabled: true,
1184 config: { ...DEFAULT_CLAUDE_SETTINGS, binaryPath },
1185 },
1186 {},
1187 ).pipe(
1188 Effect.provide(
1189 ServerConfig.layerTest(process.cwd(), {
1190 prefix: "t3-claude-binary-path-",
1191 }),
1192 ),
1193 Effect.provideService(ClaudeAdapterV2.ClaudeAgentSdkQueryRunner, {
1194 allocateSessionId: Effect.succeed("native-thread-claude-binary-path"),
1195 open: (input) =>
1196 Effect.sync(() => {
1197 executablePaths.push(input.options.pathToClaudeCodeExecutable);
1198 taskToolEnvironment.push(input.options.env?.CLAUDE_CODE_ENABLE_TODO_TOOLS);
1199 return {
1200 messages: Stream.never,
1201 offer: () => Effect.void,
1202 setModel: () => Effect.void,
1203 applyFlagSettings: () => Effect.void,
1204 setPermissionMode: () => Effect.void,
1205 stopTask: () => Effect.void,
1206 interrupt: Effect.void,
1207 close: Effect.void,
1208 };
1209 }),
1210 forkSession: () => Effect.die("unused"),
1211 subagentLaunchToolUseId: () => Effect.succeed(null),
1212 assertComplete: Effect.void,
1213 }),
1214 );
1215 const threadId = ThreadId.make("thread-claude-binary-path");
1216 const runtime = yield* adapter.openSession({
1217 threadId,
1218 providerSessionId: ProviderSessionId.make("provider-session-claude-binary-path"),
1219 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
1220 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
1221 });
1222 const providerThread = yield* runtime.ensureThread({
1223 threadId,
1224 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
1225 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
1226 });
1227 yield* runtime.startTurn(
1228 makeClaudeTestTurnInput({
1229 threadId,
1230 providerThread,
1231 now: yield* DateTime.now,
1232 attemptId: RunAttemptId.make("attempt-claude-binary-path"),
1233 text: "hello",
1234 attachments: [],
1235 }),
1236 );
1237 return { executablePaths, taskToolEnvironment };
1238});
1239
1240describe("ClaudeAdapterV2 executable path", () => {
1241 it.effect.each([
1242 { source: "the default", environment: [], hostEnvironment: {}, expected: undefined },
1243 {
1244 source: "an instance opt-in",
1245 environment: [{ name: "CLAUDE_CODE_ENABLE_TODO_TOOLS", value: "1", sensitive: false }],
1246 hostEnvironment: {},
1247 expected: "1",
1248 },
1249 {
1250 source: "a host opt-in",
1251 environment: [],
1252 hostEnvironment: { CLAUDE_CODE_ENABLE_TODO_TOOLS: "1" },
1253 expected: "1",
1254 },
1255 ])("expands the executable path and respects $source for task tools", (testCase) =>
1256 Effect.scoped(
1257 Effect.gen(function* () {
1258 const path = yield* Path.Path;
1259 const { executablePaths, taskToolEnvironment } = yield* captureSdkExecutablePaths(
1260 "~/bin/claude",
1261 testCase.environment,
1262 );
1263
1264 assert.deepEqual(executablePaths, [path.join(NodeOS.homedir(), "bin", "claude")]);
1265 assert.deepEqual(taskToolEnvironment, [testCase.expected]);
1266 }),
1267 ).pipe(
1268 Effect.provideService(HostProcess.Environment, testCase.hostEnvironment),
1269 Effect.provide(
1270 Layer.mergeAll(
1271 IdAllocator.layer,
1272 McpProviderSessions.layer,
1273 ScriptProgress.layerUnavailable,
1274 NodeServices.layer,
1275 ),
1276 ),
1277 ),
1278 );
1279
1280 it.effect("follows a bare claude on Windows to the npm package executable", () =>
1281 Effect.scoped(
1282 Effect.gen(function* () {
1283 const npmDir = "C:\\Users\\dev\\AppData\\Roaming\\npm";
1284 const packageExe = `${npmDir}\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe`;
1285 const { executablePaths } = yield* captureSdkExecutablePaths("claude").pipe(
1286 Effect.provideService(HostProcess.Platform, "win32"),
1287 Effect.provideService(SpawnExecutableResolution, () => `${npmDir}\\claude.cmd`),
1288 Effect.provideService(ClaudeExecutableFileCheck, (filePath) => filePath === packageExe),
1289 );
1290
1291 assert.deepEqual(executablePaths, [packageExe]);
1292 }),
1293 ).pipe(
1294 Effect.provide(
1295 Layer.mergeAll(
1296 IdAllocator.layer,
1297 McpProviderSessions.layer,
1298 ScriptProgress.layerUnavailable,
1299 NodeServices.layer,
1300 ),
1301 ),
1302 ),
1303 );
1304});
1305
1306describe("ClaudeAdapterV2 resume compaction", () => {
1307 it.effect("resolves and cancels the SDK resume dialog through structured runtime input", () =>
1308 Effect.scoped(
1309 Effect.gen(function* () {
1310 const fileSystem = yield* FileSystem.FileSystem;
1311 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1312 const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({
1313 prefix: "t3-claude-resume-",
1314 });
1315 let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined;
1316 const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({
1317 instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
1318 settings: DEFAULT_CLAUDE_SETTINGS,
1319 environment: {},
1320 attachmentsDir,
1321 fileSystem,
1322 path: yield* Path.Path,
1323 crypto: yield* Crypto.Crypto,
1324 idAllocator,
1325 queryRunner: {
1326 allocateSessionId: Effect.succeed("native-thread-claude-resume"),
1327 open: (input) =>
1328 Effect.sync(() => {
1329 openedOptions = input.options;
1330 return {
1331 messages: Stream.never,
1332 offer: () => Effect.void,
1333 setModel: () => Effect.void,
1334 applyFlagSettings: () => Effect.void,
1335 setPermissionMode: () => Effect.void,
1336 stopTask: () => Effect.void,
1337 interrupt: Effect.void,
1338 close: Effect.void,
1339 };
1340 }),
1341 forkSession: () => Effect.die("unused"),
1342 subagentLaunchToolUseId: () => Effect.succeed(null),
1343 assertComplete: Effect.void,
1344 },
1345 });
1346 const threadId = ThreadId.make("thread-claude-resume");
1347 const runtime = yield* adapter.openSession({
1348 threadId,
1349 providerSessionId: ProviderSessionId.make("provider-session-claude-resume"),
1350 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
1351 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
1352 });
1353 const providerThread = yield* runtime.ensureThread({
1354 threadId,
1355 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
1356 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
1357 });
1358 const now = yield* DateTime.now;
1359 yield* runtime.startTurn(
1360 makeClaudeTestTurnInput({
1361 threadId,
1362 providerThread,
1363 now,
1364 attemptId: RunAttemptId.make("attempt-claude-resume"),
1365 text: "continue",
1366 attachments: [],
1367 }),
1368 );
1369 const callback = openedOptions?.onUserDialog;
1370 assert.isFunction(callback);
1371 const canUseTool = openedOptions?.canUseTool;
1372 assert.isFunction(canUseTool);
1373 const ordinaryToolResult = yield* Effect.promise(() =>
1374 canUseTool!(
1375 "Bash",
1376 { command: "pwd" },
1377 {
1378 signal: new AbortController().signal,
1379 toolUseID: "tool-bash-full-access",
1380 requestId: "request-bash-full-access",
1381 },
1382 ),
1383 );
1384 assert.deepEqual(ordinaryToolResult, {
1385 behavior: "allow",
1386 updatedInput: { command: "pwd" },
1387 toolUseID: "tool-bash-full-access",
1388 });
1389 const longQuestion = `Choose a deployment target: ${"region ".repeat(80)}`;
1390 const questionRequestEvent = yield* runtime.events.pipe(
1391 Stream.filter((event) => event.type === "runtime_request.updated"),
1392 Stream.runHead,
1393 Effect.forkScoped,
1394 );
1395 const questionResult = yield* Effect.promise(() =>
1396 canUseTool!(
1397 "AskUserQuestion",
1398 {
1399 questions: [
1400 {
1401 header: "Target",
1402 question: longQuestion,
1403 options: [
1404 { label: "Production", description: "Deploy to production." },
1405 { label: "Staging", description: "Deploy to staging." },
1406 ],
1407 multiSelect: true,
1408 },
1409 ],
1410 },
1411 {
1412 signal: new AbortController().signal,
1413 toolUseID: "tool-question-full-access",
1414 requestId: "request-question-full-access",
1415 },
1416 ),
1417 ).pipe(Effect.forkScoped);
1418 const questionEvent = yield* Fiber.join(questionRequestEvent);
1419 assert.isTrue(Option.isSome(questionEvent));
1420 if (
1421 Option.isNone(questionEvent) ||
1422 questionEvent.value.type !== "runtime_request.updated"
1423 ) {
1424 return;
1425 }
1426 yield* runtime.respondToRuntimeRequest({
1427 requestId: questionEvent.value.runtimeRequest.id,
1428 answers: { [longQuestion]: ["Production", "Staging"] },
1429 });
1430 assert.deepEqual(yield* Fiber.join(questionResult), {
1431 behavior: "allow",
1432 updatedInput: {
1433 questions: [
1434 {
1435 header: "Target",
1436 question: longQuestion,
1437 options: [
1438 { label: "Production", description: "Deploy to production." },
1439 { label: "Staging", description: "Deploy to staging." },
1440 ],
1441 multiSelect: true,
1442 },
1443 ],
1444 answers: { [longQuestion]: "Production, Staging" },
1445 },
1446 toolUseID: "tool-question-full-access",
1447 });
1448 const longPlan = `# Deployment plan\n\n${"Validate every region before promotion.\n".repeat(120)}`;
1449 const proposedPlanEvent = yield* runtime.events.pipe(
1450 Stream.filter((event) => event.type === "plan.updated"),
1451 Stream.runHead,
1452 Effect.forkScoped,
1453 );
1454 const exitPlanResult = yield* Effect.promise(() =>
1455 canUseTool!(
1456 "ExitPlanMode",
1457 { plan: longPlan },
1458 {
1459 signal: new AbortController().signal,
1460 toolUseID: "tool-exit-plan-full-access",
1461 requestId: "request-exit-plan-full-access",
1462 },
1463 ),
1464 );
1465 assert.deepEqual(exitPlanResult, {
1466 behavior: "deny",
1467 message:
1468 "The client captured your proposed plan. Stop here and wait for the user's feedback or implementation request in a later turn.",
1469 toolUseID: "tool-exit-plan-full-access",
1470 });
1471 const planEvent = yield* Fiber.join(proposedPlanEvent);
1472 assert.isTrue(Option.isSome(planEvent));
1473 if (Option.isSome(planEvent) && planEvent.value.type === "plan.updated") {
1474 assert.equal(planEvent.value.plan.kind, "proposed_plan");
1475 if (planEvent.value.plan.kind === "proposed_plan") {
1476 assert.equal(planEvent.value.plan.markdown, longPlan.trim());
1477 }
1478 }
1479 const requestEvent = yield* runtime.events
1480 .pipe(
1481 Stream.filter((event) => event.type === "runtime_request.updated"),
1482 Stream.runHead,
1483 )
1484 .pipe(Effect.forkScoped);
1485 const controller = new AbortController();
1486 const dialog = yield* Effect.promise(() =>
1487 callback!(
1488 {
1489 dialogKind: "resume_return",
1490 payload: { sessionAgeMinutes: 90, estimatedTokens: 120000 },
1491 },
1492 { signal: controller.signal, requestId: "dialog-resume-1" },
1493 ),
1494 ).pipe(Effect.forkScoped);
1495 const event = yield* Fiber.join(requestEvent);
1496 assert.isTrue(Option.isSome(event));
1497 if (Option.isNone(event) || event.value.type !== "runtime_request.updated") return;
1498 const question = formatClaudeResumeCompactionQuestion({
1499 ageMinutes: 90,
1500 estimatedTokens: 120000,
1501 });
1502 yield* runtime.respondToRuntimeRequest({
1503 requestId: event.value.runtimeRequest.id,
1504 answers: { [question]: "Compact and continue" },
1505 });
1506 assert.deepEqual(yield* Fiber.join(dialog), { behavior: "completed", result: "compact" });
1507
1508 const cancelledController = new AbortController();
1509 cancelledController.abort();
1510 assert.deepEqual(
1511 yield* Effect.promise(() =>
1512 callback!(
1513 {
1514 dialogKind: "resume_return",
1515 payload: { sessionAgeMinutes: 90, estimatedTokens: 120000 },
1516 },
1517 { signal: cancelledController.signal, requestId: "dialog-resume-2" },
1518 ),
1519 ),
1520 { behavior: "cancelled" },
1521 );
1522 }).pipe(
1523 Effect.provide(
1524 Layer.mergeAll(
1525 IdAllocator.layer,
1526 McpProviderSessions.layer,
1527 ScriptProgress.layerUnavailable,
1528 NodeServices.layer,
1529 ),
1530 ),
1531 ),
1532 ),
1533 );
1534});
1535
1536describe("ClaudeAdapterV2 attachments", () => {
1537 it.effect("forwards images and references generic files on sends and steering", () =>
1538 Effect.scoped(
1539 Effect.gen(function* () {
1540 const fileSystem = yield* FileSystem.FileSystem;
1541 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1542 const path = yield* Path.Path;
1543 const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({
1544 prefix: "t3-claude-v2-attachments-",
1545 });
1546 const offeredMessages: Array<SDKUserMessage> = [];
1547 const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({
1548 instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
1549 settings: DEFAULT_CLAUDE_SETTINGS,
1550 environment: {},
1551 attachmentsDir,
1552 fileSystem,
1553 path: yield* Path.Path,
1554 crypto: yield* Crypto.Crypto,
1555 idAllocator,
1556 queryRunner: {
1557 allocateSessionId: Effect.succeed("native-thread-claude-attachments"),
1558 open: () =>
1559 Effect.succeed({
1560 messages: Stream.never,
1561 offer: (message) =>
1562 Effect.sync(() => {
1563 offeredMessages.push(message);
1564 }),
1565 setModel: () => Effect.void,
1566 applyFlagSettings: () => Effect.void,
1567 setPermissionMode: () => Effect.void,
1568 stopTask: () => Effect.void,
1569 interrupt: Effect.void,
1570 close: Effect.void,
1571 }),
1572 forkSession: () => Effect.die("unused forkSession"),
1573 subagentLaunchToolUseId: () => Effect.succeed(null),
1574 assertComplete: Effect.void,
1575 },
1576 });
1577 const threadId = ThreadId.make("thread-claude-attachments");
1578 const providerSessionId = ProviderSessionId.make("provider-session-claude-attachments");
1579 const runtime = yield* adapter.openSession({
1580 threadId,
1581 providerSessionId,
1582 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
1583 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
1584 });
1585 const providerThread = yield* runtime.ensureThread({
1586 threadId,
1587 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
1588 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
1589 });
1590 const attachment = ChatImageAttachment.make({
1591 type: "image",
1592 id: ChatAttachmentId.make(
1593 "thread-claude-attachments-12345678-1234-1234-1234-123456789abc",
1594 ),
1595 name: "diagram.png",
1596 mimeType: "image/png",
1597 sizeBytes: 4,
1598 });
1599 const document = ChatFileAttachment.make({
1600 type: "file",
1601 id: ChatAttachmentId.make(
1602 "thread-claude-attachments-abcdefab-1234-1234-1234-123456789abc",
1603 ),
1604 name: "requirements.pdf",
1605 mimeType: "application/pdf",
1606 sizeBytes: 4,
1607 });
1608 yield* fileSystem.writeFile(
1609 path.join(attachmentsDir, attachmentRelativePath(attachment)!),
1610 Uint8Array.from([1, 2, 3, 4]),
1611 );
1612 yield* fileSystem.writeFile(
1613 path.join(attachmentsDir, attachmentRelativePath(document)!),
1614 Uint8Array.from([5, 6, 7, 8]),
1615 );
1616 const attemptId = RunAttemptId.make("attempt-claude-attachments");
1617 const now = yield* DateTime.now;
1618
1619 yield* runtime.startTurn(
1620 makeClaudeTestTurnInput({
1621 threadId,
1622 providerThread,
1623 now,
1624 attemptId,
1625 text: "What's in this image?",
1626 attachments: [attachment, document],
1627 }),
1628 );
1629
1630 const expectedImageBlock = {
1631 type: "image",
1632 source: {
1633 type: "base64",
1634 media_type: "image/png",
1635 data: "AQIDBA==",
1636 },
1637 } as const;
1638 const expectedAttachmentPath = path.join(
1639 attachmentsDir,
1640 attachmentRelativePath(attachment)!,
1641 );
1642 const expectedDocumentPath = path.join(attachmentsDir, attachmentRelativePath(document)!);
1643 assert.deepEqual(offeredMessages[0]?.message.content, [
1644 expectedImageBlock,
1645 {
1646 type: "text",
1647 text: `Ultrathink:\nWhat's in this image?\n\n[Attached image "diagram.png" is saved at: ${expectedAttachmentPath}]\n\n[Attached file "requirements.pdf" is saved at: ${expectedDocumentPath}]`,
1648 },
1649 ]);
1650
1651 const providerTurnId = idAllocator.derive.providerTurn({
1652 driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
1653 nativeTurnId: `turn:${attemptId}`,
1654 });
1655 yield* runtime.steerTurn({
1656 threadId,
1657 runId: RunId.make("run-claude-attachments"),
1658 providerThread,
1659 providerTurnId,
1660 message: {
1661 createdBy: "user",
1662 creationSource: "web",
1663 messageId: MessageId.make("message-claude-attachments-steer"),
1664 text: "Focus on the diagram labels.",
1665 attachments: [attachment, document],
1666 },
1667 });
1668
1669 assert.equal(offeredMessages[1]?.priority, "now");
1670 assert.deepEqual(offeredMessages[1]?.message.content, [
1671 expectedImageBlock,
1672 {
1673 type: "text",
1674 text: `Ultrathink:\nFocus on the diagram labels.\n\n[Attached image "diagram.png" is saved at: ${expectedAttachmentPath}]\n\n[Attached file "requirements.pdf" is saved at: ${expectedDocumentPath}]`,
1675 },
1676 ]);
1677 }).pipe(
1678 Effect.provide(
1679 Layer.mergeAll(
1680 IdAllocator.layer,
1681 McpProviderSessions.layer,
1682 ScriptProgress.layerUnavailable,
1683 NodeServices.layer,
1684 ),
1685 ),
1686 ),
1687 ),
1688 );
1689
1690 it.effect("rejects unsupported image types before opening a provider query", () =>
1691 Effect.scoped(
1692 Effect.gen(function* () {
1693 const fileSystem = yield* FileSystem.FileSystem;
1694 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1695 const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({
1696 prefix: "t3-claude-v2-unsupported-attachment-",
1697 });
1698 let openCount = 0;
1699 const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({
1700 instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
1701 settings: DEFAULT_CLAUDE_SETTINGS,
1702 environment: {},
1703 attachmentsDir,
1704 fileSystem,
1705 path: yield* Path.Path,
1706 crypto: yield* Crypto.Crypto,
1707 idAllocator,
1708 queryRunner: {
1709 allocateSessionId: Effect.succeed("native-thread-claude-unsupported-attachment"),
1710 open: () =>
1711 Effect.sync(() => {
1712 openCount += 1;
1713 return {
1714 messages: Stream.never,
1715 offer: () => Effect.void,
1716 setModel: () => Effect.void,
1717 applyFlagSettings: () => Effect.void,
1718 setPermissionMode: () => Effect.void,
1719 stopTask: () => Effect.void,
1720 interrupt: Effect.void,
1721 close: Effect.void,
1722 };
1723 }),
1724 forkSession: () => Effect.die("unused forkSession"),
1725 subagentLaunchToolUseId: () => Effect.succeed(null),
1726 assertComplete: Effect.void,
1727 },
1728 });
1729 const threadId = ThreadId.make("thread-claude-unsupported-attachment");
1730 const runtime = yield* adapter.openSession({
1731 threadId,
1732 providerSessionId: ProviderSessionId.make(
1733 "provider-session-claude-unsupported-attachment",
1734 ),
1735 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
1736 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
1737 });
1738 const providerThread = yield* runtime.ensureThread({
1739 threadId,
1740 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
1741 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
1742 });
1743 const attachment = ChatImageAttachment.make({
1744 type: "image",
1745 id: ChatAttachmentId.make(
1746 "thread-claude-unsupported-12345678-1234-1234-1234-123456789abc",
1747 ),
1748 name: "diagram.svg",
1749 mimeType: "image/svg+xml",
1750 sizeBytes: 4,
1751 });
1752 const now = yield* DateTime.now;
1753
1754 const error = yield* runtime
1755 .startTurn(
1756 makeClaudeTestTurnInput({
1757 threadId,
1758 providerThread,
1759 now,
1760 attemptId: RunAttemptId.make("attempt-claude-unsupported-attachment"),
1761 text: "Inspect this image.",
1762 attachments: [attachment],
1763 }),
1764 )
1765 .pipe(Effect.flip);
1766
1767 assert.equal(error._tag, "ProviderAdapterTurnStartError");
1768 assert.include(String(error.cause), "Unsupported Claude image attachment type");
1769 assert.equal(openCount, 0);
1770 }).pipe(
1771 Effect.provide(
1772 Layer.mergeAll(
1773 IdAllocator.layer,
1774 McpProviderSessions.layer,
1775 ScriptProgress.layerUnavailable,
1776 NodeServices.layer,
1777 ),
1778 ),
1779 ),
1780 ),
1781 );
1782});
1783
1784describe("ClaudeAdapterV2 native fork", () => {
1785 it.effect("forks at the source assistant cursor and resumes the forked session", () =>
1786 Effect.scoped(
1787 Effect.gen(function* () {
1788 const fileSystem = yield* FileSystem.FileSystem;
1789 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1790 const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({
1791 prefix: "t3-claude-v2-fork-attachments-",
1792 });
1793 const openedQueries: Array<ClaudeAdapterV2.ClaudeAgentSdkQueryOpenInput> = [];
1794 const forkCalls: Array<{
1795 readonly sessionId: string;
1796 readonly options: unknown;
1797 readonly threadId: ThreadId;
1798 readonly providerSessionId: ProviderSessionId;
1799 }> = [];
1800 const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({
1801 instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
1802 settings: DEFAULT_CLAUDE_SETTINGS,
1803 environment: {},
1804 attachmentsDir,
1805 fileSystem,
1806 path: yield* Path.Path,
1807 crypto: yield* Crypto.Crypto,
1808 idAllocator,
1809 queryRunner: {
1810 allocateSessionId: Effect.succeed("source-native-session"),
1811 open: (input) =>
1812 Effect.sync(() => {
1813 openedQueries.push(input);
1814 return {
1815 messages: Stream.empty,
1816 offer: () => Effect.void,
1817 setModel: () => Effect.void,
1818 applyFlagSettings: () => Effect.void,
1819 setPermissionMode: () => Effect.void,
1820 stopTask: () => Effect.void,
1821 interrupt: Effect.void,
1822 close: Effect.void,
1823 };
1824 }),
1825 forkSession: (input) =>
1826 Effect.sync(() => {
1827 forkCalls.push(input);
1828 return { sessionId: "forked-native-session" };
1829 }),
1830 subagentLaunchToolUseId: () => Effect.succeed(null),
1831 assertComplete: Effect.void,
1832 },
1833 });
1834 const providerSessionId = ProviderSessionId.make("provider-session-claude-fork");
1835 const sourceThreadId = ThreadId.make("thread-claude-fork-source");
1836 const targetThreadId = ThreadId.make("thread-claude-fork-target");
1837 const runtime = yield* adapter.openSession({
1838 threadId: sourceThreadId,
1839 providerSessionId,
1840 modelSelection: {
1841 instanceId: ProviderInstanceId.make(ClaudeAdapterV2.CLAUDE_PROVIDER),
1842 model: "claude-sonnet-4-6",
1843 },
1844 runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
1845 runtimeMode: "full-access",
1846 interactionMode: "default",
1847 cwd: "/workspace",
1848 }),
1849 });
1850 const sourceProviderThread = yield* runtime.ensureThread({
1851 threadId: sourceThreadId,
1852 modelSelection: {
1853 instanceId: ProviderInstanceId.make(ClaudeAdapterV2.CLAUDE_PROVIDER),
1854 model: "claude-sonnet-4-6",
1855 },
1856 runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
1857 runtimeMode: "full-access",
1858 interactionMode: "default",
1859 cwd: "/workspace",
1860 }),
1861 });
1862 const now = yield* DateTime.now;
1863 const providerTurnId = ProviderTurnId.make("provider-turn-claude-source");
1864 const forkedProviderThread = yield* runtime.forkThread({
1865 sourceProviderThread,
1866 sourceProviderTurns: [
1867 {
1868 id: providerTurnId,
1869 providerThreadId: sourceProviderThread.id,
1870 nodeId: NodeId.make("node-claude-source"),
1871 runAttemptId: RunAttemptId.make("run-attempt-claude-source"),
1872 nativeTurnRef: {
1873 driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
1874 nativeId: "assistant-message-cursor",
1875 strength: "weak",
1876 },
1877 ordinal: 1,
1878 status: "completed",
1879 startedAt: now,
1880 completedAt: now,
1881 },
1882 ],
1883 providerTurnId,
1884 targetThreadId,
1885 });
1886
1887 assert.deepEqual(forkCalls, [
1888 {
1889 sessionId: "source-native-session",
1890 options: {
1891 dir: "/workspace",
1892 upToMessageId: "assistant-message-cursor",
1893 },
1894 threadId: targetThreadId,
1895 providerSessionId,
1896 },
1897 ]);
1898 assert.equal(forkedProviderThread.nativeThreadRef?.nativeId, "forked-native-session");
1899 assert.equal(forkedProviderThread.forkedFrom?.providerThreadId, sourceProviderThread.id);
1900 assert.equal(forkedProviderThread.forkedFrom?.providerTurnId, providerTurnId);
1901
1902 yield* runtime.startTurn({
1903 appThread: {
1904 createdBy: "user",
1905 creationSource: "web",
1906 id: targetThreadId,
1907 projectId: ProjectId.make("project-claude-fork-target"),
1908 title: "Claude fork target",
1909 providerInstanceId: ProviderInstanceId.make(ClaudeAdapterV2.CLAUDE_PROVIDER),
1910 modelSelection: {
1911 instanceId: ProviderInstanceId.make(ClaudeAdapterV2.CLAUDE_PROVIDER),
1912 model: "claude-sonnet-4-6",
1913 },
1914 runtimeMode: "full-access",
1915 interactionMode: "default",
1916 branch: null,
1917 worktreePath: null,
1918 activeProviderThreadId: forkedProviderThread.id,
1919 lineage: {
1920 parentThreadId: sourceThreadId,
1921 relationshipToParent: "fork",
1922 rootThreadId: sourceThreadId,
1923 },
1924 forkedFrom: null,
1925 createdAt: now,
1926 updatedAt: now,
1927 archivedAt: null,
1928 settledOverride: null,
1929 settledAt: null,
1930 lastVisitedAt: null,
1931 deletedAt: null,
1932 },
1933 threadId: targetThreadId,
1934 runId: RunId.make("run-claude-fork-target"),
1935 runOrdinal: 1,
1936 providerTurnOrdinal: 1,
1937 attemptId: RunAttemptId.make("run-attempt-claude-fork-target"),
1938 rootNodeId: NodeId.make("node-claude-fork-target-root"),
1939 providerThread: forkedProviderThread,
1940 message: {
1941 createdBy: "user",
1942 creationSource: "web",
1943 messageId: MessageId.make("message-claude-fork-target"),
1944 text: "Respond with fork ok",
1945 attachments: [],
1946 },
1947 modelSelection: {
1948 instanceId: ProviderInstanceId.make(ClaudeAdapterV2.CLAUDE_PROVIDER),
1949 model: "claude-sonnet-4-6",
1950 },
1951 runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
1952 runtimeMode: "full-access",
1953 interactionMode: "default",
1954 cwd: "/workspace",
1955 }),
1956 });
1957
1958 assert.equal(openedQueries[0]?.options.resume, "forked-native-session");
1959 assert.equal(openedQueries[0]?.options.sessionId, undefined);
1960 }).pipe(
1961 Effect.provide(
1962 Layer.mergeAll(
1963 IdAllocator.layer,
1964 McpProviderSessions.layer,
1965 ScriptProgress.layerUnavailable,
1966 NodeServices.layer,
1967 ),
1968 ),
1969 ),
1970 ),
1971 );
1972});
1973
1974// Stands in for the Claude CLI behind the SDK's spawn hook: records how it was
1975// started and answers stdin control requests, except mcp_set_servers if asked.
1976function makeFakeClaudeCli(answerSetServers = true) {
1977 const spawns: Array<SpawnOptions> = [];
1978 const controlRequests: Array<object> = [];
1979 const stdins: Array<NodeStream.PassThrough> = [];
1980 const spawn = (options: SpawnOptions): SpawnedProcess => {
1981 spawns.push(options);
1982 const stdin = new NodeStream.PassThrough();
1983 const stdout = new NodeStream.PassThrough();
1984 stdins.push(stdin);
1985 const child = Object.assign(new NodeEvents.EventEmitter(), {
1986 stdin,
1987 stdout,
1988 killed: false,
1989 exitCode: null as number | null,
1990 kill: () => {
1991 stdout.end();
1992 child.emit("exit", null, "SIGTERM");
1993 return true;
1994 },
1995 });
1996 let pending = "";
1997 stdin.on("data", (chunk: Buffer) => {
1998 pending += chunk.toString("utf8");
1999 for (let end = pending.indexOf("\n"); end >= 0; end = pending.indexOf("\n")) {
2000 const frame = JSON.parse(pending.slice(0, end));
2001 pending = pending.slice(end + 1);
2002 if (frame.type !== "control_request") continue;
2003 controlRequests.push(frame.request);
2004 const setServers = frame.request.subtype === "mcp_set_servers";
2005 if (setServers && !answerSetServers) continue;
2006 const response = {
2007 subtype: "success",
2008 request_id: frame.request_id,
2009 response: setServers ? { added: [], removed: [], errors: {} } : {},
2010 };
2011 stdout.write(`${JSON.stringify({ type: "control_response", response })}\n`);
2012 }
2013 });
2014 stdin.on("end", () => {
2015 stdout.end();
2016 child.emit("exit", 0, null);
2017 });
2018 return child;
2019 };
2020 // The SDK closes the CLI by ending its stdin.
2021 const closed = () => stdins.length > 0 && stdins.every((stdin) => stdin.writableEnded);
2022 return { spawn, spawns, controlRequests, closed };
2023}
2024
2025describe("ClaudeAdapterV2 MCP credential channel", () => {
2026 const t3McpServer = {
2027 type: "http" as const,
2028 url: "http://127.0.0.1:43123/mcp",
2029 headers: { Authorization: "Bearer dummy-mcp-credential" },
2030 };
2031 const openWith = (cli: ReturnType<typeof makeFakeClaudeCli>) =>
2032 Effect.gen(function* () {
2033 const runner = yield* ClaudeAdapterV2.ClaudeAgentSdkQueryRunner;
2034 return yield* runner.open({
2035 threadId: ThreadId.make("thread-claude-mcp-channel"),
2036 providerSessionId: ProviderSessionId.make("provider-session-claude-mcp-channel"),
2037 options: {
2038 ...ClaudeAdapterV2.makeClaudeQueryOptions({
2039 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
2040 nativeThreadId: "native-thread-claude-mcp-channel",
2041 resume: false,
2042 cwd: null,
2043 environment: { PATH: "/usr/bin" },
2044 mcpServers: { "t3-code": t3McpServer },
2045 }),
2046 pathToClaudeCodeExecutable: "/opt/claude/cli.js",
2047 spawnClaudeCodeProcess: cli.spawn,
2048 },
2049 });
2050 }).pipe(
2051 Effect.provide(ClaudeAdapterV2.layerQueryRunner),
2052 Effect.provideService(
2053 ProviderEventLoggers.ProviderEventLoggers,
2054 ProviderEventLoggers.NoOpProviderEventLoggers,
2055 ),
2056 Effect.provide(NodeServices.layer),
2057 );
2058
2059 it.effect("sends MCP servers over stdin, not argv or the environment", () =>
2060 Effect.gen(function* () {
2061 const cli = makeFakeClaudeCli();
2062 const session = yield* openWith(cli);
2063 yield* session.close;
2064
2065 assert.deepInclude(cli.controlRequests, {
2066 subtype: "mcp_set_servers",
2067 servers: { "t3-code": t3McpServer },
2068 });
2069 const spawned = JSON.stringify(cli.spawns);
2070 assert.notInclude(spawned, "--mcp-config");
2071 assert.notInclude(spawned, "dummy-mcp-credential");
2072 }),
2073 );
2074
2075 it.effect("closes the CLI when it never answers the MCP registration", () =>
2076 Effect.gen(function* () {
2077 const cli = makeFakeClaudeCli(false);
2078 const opening = yield* openWith(cli).pipe(Effect.result, Effect.forkChild);
2079 yield* TestClock.adjust("90 seconds");
2080 const opened = yield* Fiber.join(opening);
2081
2082 assert.equal(opened._tag, "Failure");
2083 assert.isTrue(cli.closed());
2084 }),
2085 );
2086});
2087
2088describe("ClaudeAdapterV2 native session identity", () => {
2089 const openTurnWithOrdinal = (providerTurnOrdinal: number, nativeThreadHasTurns?: boolean) =>
2090 Effect.scoped(
2091 Effect.gen(function* () {
2092 const fileSystem = yield* FileSystem.FileSystem;
2093 const idAllocator = yield* IdAllocator.IdAllocatorV2;
2094 const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({
2095 prefix: "t3-claude-v2-session-identity-",
2096 });
2097 const openedQueries: Array<ClaudeAdapterV2.ClaudeAgentSdkQueryOpenInput> = [];
2098 const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({
2099 instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
2100 settings: DEFAULT_CLAUDE_SETTINGS,
2101 environment: {},
2102 attachmentsDir,
2103 fileSystem,
2104 path: yield* Path.Path,
2105 crypto: yield* Crypto.Crypto,
2106 idAllocator,
2107 queryRunner: {
2108 allocateSessionId: Effect.succeed("native-session-identity"),
2109 open: (input) =>
2110 Effect.sync(() => {
2111 openedQueries.push(input);
2112 return {
2113 messages: Stream.empty,
2114 offer: () => Effect.void,
2115 setModel: () => Effect.void,
2116 applyFlagSettings: () => Effect.void,
2117 setPermissionMode: () => Effect.void,
2118 stopTask: () => Effect.void,
2119 interrupt: Effect.void,
2120 close: Effect.void,
2121 };
2122 }),
2123 forkSession: () => Effect.die("unused forkSession"),
2124 subagentLaunchToolUseId: () => Effect.succeed(null),
2125 assertComplete: Effect.void,
2126 },
2127 });
2128 const threadId = ThreadId.make("thread-claude-session-identity");
2129 const providerSessionId = ProviderSessionId.make("provider-session-claude-identity");
2130 const runtime = yield* adapter.openSession({
2131 threadId,
2132 providerSessionId,
2133 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
2134 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
2135 });
2136 const providerThread = yield* runtime.ensureThread({
2137 threadId,
2138 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
2139 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
2140 });
2141 const now = yield* DateTime.now;
2142 yield* runtime.startTurn(
2143 makeClaudeTestTurnInput({
2144 threadId,
2145 providerThread,
2146 now,
2147 attemptId: RunAttemptId.make("run-attempt-claude-session-identity"),
2148 text: "Respond with identity ok",
2149 attachments: [],
2150 providerTurnOrdinal,
2151 ...(nativeThreadHasTurns === undefined ? {} : { nativeThreadHasTurns }),
2152 }),
2153 );
2154 return openedQueries;
2155 }).pipe(
2156 Effect.provide(
2157 Layer.mergeAll(
2158 IdAllocator.layer,
2159 McpProviderSessions.layer,
2160 ScriptProgress.layerUnavailable,
2161 NodeServices.layer,
2162 ),
2163 ),
2164 ),
2165 );
2166
2167 it.effect("creates the native session on the first provider turn", () =>
2168 Effect.gen(function* () {
2169 const openedQueries = yield* openTurnWithOrdinal(1);
2170 assert.equal(openedQueries.length, 1);
2171 assert.equal(openedQueries[0]?.options.sessionId, "native-session-identity");
2172 assert.equal(openedQueries[0]?.options.resume, undefined);
2173 }),
2174 );
2175
2176 it.effect(
2177 "resumes the native session on a fresh session instance when prior provider turns exist",
2178 () =>
2179 Effect.gen(function* () {
2180 const openedQueries = yield* openTurnWithOrdinal(2);
2181 assert.equal(openedQueries.length, 1);
2182 assert.equal(openedQueries[0]?.options.resume, "native-session-identity");
2183 assert.equal(openedQueries[0]?.options.sessionId, undefined);
2184 }),
2185 );
2186
2187 it.effect("creates a fresh native session despite earlier provider-thread turns", () =>
2188 Effect.gen(function* () {
2189 const openedQueries = yield* openTurnWithOrdinal(4, false);
2190 assert.equal(openedQueries[0]?.options.sessionId, "native-session-identity");
2191 assert.equal(openedQueries[0]?.options.resume, undefined);
2192 }),
2193 );
2194});
2195
2196const encodeJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
2197
2198describe("ClaudeAdapterV2 background wake turns", () => {
2199 const WAKE_NATIVE_SESSION = "native-thread-claude-wake";
2200 // Background Bash ids and texts follow the claude_background_task_wake
2201 // recording, so the frames below have the shapes the CLI really sends.
2202 const WAKE_TASK_ID = "bdqirlcyw";
2203 const WAKE_TOOL_USE_ID = "toolu_01Rs6JNNf5SqHRxpq5DeJHrW";
2204 const WAKE_TASK_DESCRIPTION = "Background sleep test";
2205 const WAKE_SUMMARY = 'Background command "Background sleep test" completed (exit code 0)';
2206 const WAKE_ASSISTANT_TEXT = "WAKE_DONE";
2207 const WAKE_RESULT_TEXT = "WAKE_DONE";
2208
2209 function claudeSdkFrame(frame: unknown): SDKMessage {
2210 if (
2211 typeof frame !== "object" ||
2212 frame === null ||
2213 typeof Reflect.get(frame, "type") !== "string"
2214 ) {
2215 throw new Error("Frame is not a Claude Agent SDK message.");
2216 }
2217 return frame as SDKMessage;
2218 }
2219
2220 const wakeTaskStarted = claudeSdkFrame({
2221 type: "system",
2222 subtype: "task_started",
2223 task_id: WAKE_TASK_ID,
2224 tool_use_id: WAKE_TOOL_USE_ID,
2225 description: WAKE_TASK_DESCRIPTION,
2226 is_backgrounded: true,
2227 task_type: "local_bash",
2228 uuid: "00000000-0000-4000-8000-000000000101",
2229 session_id: WAKE_NATIVE_SESSION,
2230 });
2231 const makeAssistantTextFrame = (input: { readonly uuid: string; readonly text: string }) =>
2232 claudeSdkFrame({
2233 type: "assistant",
2234 message: {
2235 model: "claude-sonnet-4-6",
2236 id: `msg_${input.uuid}`,
2237 type: "message",
2238 role: "assistant",
2239 content: [{ type: "text", text: input.text }],
2240 stop_reason: null,
2241 stop_sequence: null,
2242 usage: {
2243 input_tokens: 1,
2244 output_tokens: 1,
2245 cache_creation_input_tokens: 0,
2246 cache_read_input_tokens: 0,
2247 },
2248 },
2249 parent_tool_use_id: null,
2250 uuid: input.uuid,
2251 session_id: WAKE_NATIVE_SESSION,
2252 });
2253 const makeAssistantErrorFrame = (input: {
2254 readonly uuid: string;
2255 readonly error: "authentication_failed" | "rate_limit" | "server_error" | undefined;
2256 readonly parentToolUseId?: string | null;
2257 }) =>
2258 claudeSdkFrame({
2259 type: "assistant",
2260 message: {
2261 model: "claude-sonnet-4-6",
2262 id: `msg_${input.uuid}`,
2263 type: "message",
2264 role: "assistant",
2265 content: [{ type: "text", text: "Claude could not complete this request." }],
2266 stop_reason: null,
2267 stop_sequence: null,
2268 usage: {
2269 input_tokens: 1,
2270 output_tokens: 1,
2271 cache_creation_input_tokens: 0,
2272 cache_read_input_tokens: 0,
2273 },
2274 },
2275 parent_tool_use_id: input.parentToolUseId ?? null,
2276 ...(input.error === undefined ? {} : { error: input.error }),
2277 uuid: input.uuid,
2278 session_id: WAKE_NATIVE_SESSION,
2279 });
2280 const makeResultFrame = (input: {
2281 readonly uuid: string;
2282 readonly result: string;
2283 readonly numTurns?: number;
2284 readonly origin?: { readonly kind: "task-notification" };
2285 readonly subtype?: string;
2286 readonly isError?: boolean;
2287 readonly errors?: ReadonlyArray<string>;
2288 readonly apiErrorStatus?: number;
2289 // null omits the field, as the CLI does on a zero-turn result.
2290 readonly terminalReason?: SDKResultMessage["terminal_reason"] | null;
2291 readonly usage?: {
2292 readonly input_tokens: number;
2293 readonly output_tokens: number;
2294 readonly cache_creation_input_tokens: number;
2295 readonly cache_read_input_tokens: number;
2296 };
2297 }) =>
2298 claudeSdkFrame({
2299 type: "result",
2300 subtype: input.subtype ?? "success",
2301 duration_ms: 10,
2302 duration_api_ms: 10,
2303 is_error: input.isError ?? false,
2304 num_turns: input.numTurns ?? 1,
2305 result: input.result,
2306 stop_reason: "end_turn",
2307 total_cost_usd: 0,
2308 usage: input.usage ?? {
2309 input_tokens: 1,
2310 output_tokens: 1,
2311 cache_creation_input_tokens: 0,
2312 cache_read_input_tokens: 0,
2313 },
2314 modelUsage: {},
2315 permission_denials: [],
2316 uuid: input.uuid,
2317 session_id: WAKE_NATIVE_SESSION,
2318 ...(input.origin === undefined ? {} : { origin: input.origin }),
2319 ...(input.errors === undefined ? {} : { errors: input.errors }),
2320 ...(input.apiErrorStatus === undefined ? {} : { api_error_status: input.apiErrorStatus }),
2321 ...(input.terminalReason === null
2322 ? {}
2323 : { terminal_reason: input.terminalReason ?? "completed" }),
2324 });
2325 const turnOneResult = makeResultFrame({
2326 uuid: "00000000-0000-4000-8000-000000000102",
2327 result: "Kicked off the build in the background.",
2328 });
2329 const wakeNotification = claudeSdkFrame({
2330 type: "system",
2331 subtype: "task_notification",
2332 task_id: WAKE_TASK_ID,
2333 tool_use_id: WAKE_TOOL_USE_ID,
2334 status: "completed",
2335 output_file: `/tmp/claude-replay/tasks/${WAKE_TASK_ID}.output`,
2336 summary: WAKE_SUMMARY,
2337 uuid: "00000000-0000-4000-8000-000000000103",
2338 session_id: WAKE_NATIVE_SESSION,
2339 });
2340 const wakeAssistant = makeAssistantTextFrame({
2341 uuid: "00000000-0000-4000-8000-000000000107",
2342 text: WAKE_ASSISTANT_TEXT,
2343 });
2344 // The CLI opens the wake turn with `init`, seconds before its first output.
2345 const wakeTurnInit = claudeSdkFrame({
2346 type: "system",
2347 subtype: "init",
2348 uuid: "00000000-0000-4000-8000-000000000110",
2349 session_id: WAKE_NATIVE_SESSION,
2350 });
2351 const wakeResult = makeResultFrame({
2352 uuid: "00000000-0000-4000-8000-000000000104",
2353 result: WAKE_RESULT_TEXT,
2354 origin: { kind: "task-notification" },
2355 });
2356 const STALE_TASK_NOTIFICATION_RESULT_TEXT =
2357 "Stale task-notification origin text that must not appear.";
2358 // Shape seen live after interrupt recovery: zero turns, no terminal_reason.
2359 const staleTaskNotificationResult = makeResultFrame({
2360 uuid: "00000000-0000-4000-8000-000000000106",
2361 result: STALE_TASK_NOTIFICATION_RESULT_TEXT,
2362 numTurns: 0,
2363 origin: { kind: "task-notification" },
2364 terminalReason: null,
2365 });
2366
2367 const awaitUntil = (predicate: () => boolean, label: string): Effect.Effect<void> =>
2368 Effect.gen(function* () {
2369 for (let attempt = 0; attempt < 5000; attempt++) {
2370 if (predicate()) {
2371 return;
2372 }
2373 yield* Effect.yieldNow;
2374 }
2375 return yield* Effect.die(`Timed out waiting for ${label}.`);
2376 });
2377
2378 const makeWakeHarnessWithOptions = (options?: {
2379 readonly close?: (sdkMessages: Queue.Queue<SDKMessage>) => Effect.Effect<void>;
2380 readonly interrupt?: Effect.Effect<void>;
2381 readonly stopTask?: (
2382 taskId: string,
2383 ) => Effect.Effect<void, ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError>;
2384 readonly environment?: NodeJS.ProcessEnv;
2385 readonly subagentLaunchToolUseId?: string;
2386 readonly nativeSessionId?: string;
2387 readonly threadId?: string;
2388 readonly settings?: ClaudeSettings;
2389 // A CLI process opened after the first streams from its own queue, so the
2390 // first one can exit (Queue.shutdown) and a later turn can start another.
2391 readonly freshQueueOnReopen?: boolean;
2392 }) =>
2393 Effect.gen(function* () {
2394 const fileSystem = yield* FileSystem.FileSystem;
2395 const idAllocator = yield* IdAllocator.IdAllocatorV2;
2396 const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({
2397 prefix: "t3-claude-v2-wake-",
2398 });
2399 const sdkMessages = yield* Queue.unbounded<SDKMessage>();
2400 const processQueues: Array<Queue.Queue<SDKMessage>> = [];
2401 const processedMessages = new WeakMap<SDKMessage, Deferred.Deferred<void>>();
2402 const offerAndWait = Effect.fnUntraced(function* (
2403 message: SDKMessage,
2404 offered?: Deferred.Deferred<void>,
2405 ) {
2406 const processed = yield* Deferred.make<void>();
2407 processedMessages.set(message, processed);
2408 yield* Queue.offer(sdkMessages, message);
2409 if (offered !== undefined) yield* Deferred.succeed(offered, undefined);
2410 yield* Deferred.await(processed);
2411 });
2412 const offeredMessages: Array<SDKUserMessage> = [];
2413 const permissionModeChanges: Array<string> = [];
2414 // setModel and applyFlagSettings calls on the first process, in order.
2415 const liveSelectionChanges: Array<unknown> = [];
2416 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
2417 [];
2418 const continuationReceipts =
2419 yield* Queue.unbounded<ProviderContinuationRequests.ProviderContinuationRequest>();
2420 const terminalReceipts =
2421 yield* Queue.unbounded<
2422 Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "turn.terminal" }>
2423 >();
2424 const subagentReceipts =
2425 yield* Queue.unbounded<
2426 Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "subagent.updated" }>
2427 >();
2428 const systemNoticeReceipts =
2429 yield* Queue.unbounded<
2430 Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "turn_item.updated" }>
2431 >();
2432 let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined;
2433 const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({
2434 instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
2435 settings: options?.settings ?? DEFAULT_CLAUDE_SETTINGS,
2436 environment: options?.environment ?? {},
2437 attachmentsDir,
2438 fileSystem,
2439 path: yield* Path.Path,
2440 crypto: yield* Crypto.Crypto,
2441 idAllocator,
2442 continuationRequests: {
2443 offer: (request) =>
2444 Effect.sync(() => {
2445 continuationRequests.push(request);
2446 }).pipe(Effect.andThen(Queue.offer(continuationReceipts, request))),
2447 },
2448 queryRunner: {
2449 allocateSessionId: Effect.succeed(options?.nativeSessionId ?? WAKE_NATIVE_SESSION),
2450 open: (input) =>
2451 Effect.gen(function* () {
2452 openedOptions = input.options;
2453 if (options?.freshQueueOnReopen === true && processQueues.length > 0) {
2454 const processMessages = yield* Queue.unbounded<SDKMessage>();
2455 processQueues.push(processMessages);
2456 return {
2457 messages: Stream.fromQueue(processMessages),
2458 offer: (message: SDKUserMessage) =>
2459 Effect.sync(() => {
2460 offeredMessages.push(message);
2461 }),
2462 setModel: () => Effect.void,
2463 applyFlagSettings: () => Effect.void,
2464 setPermissionMode: () => Effect.void,
2465 stopTask: options?.stopTask ?? (() => Effect.void),
2466 interrupt: Effect.void,
2467 close: Queue.shutdown(processMessages),
2468 };
2469 }
2470 processQueues.push(sdkMessages);
2471 return {
2472 messages: Stream.fromQueue(sdkMessages).pipe(
2473 Stream.flatMap((message) =>
2474 Stream.make(message).pipe(
2475 // The next pull happens after runForEach finishes handling this frame.
2476 Stream.concat(
2477 Stream.fromEffect(
2478 Effect.suspend(() => {
2479 const processed = processedMessages.get(message);
2480 return processed === undefined
2481 ? Effect.void
2482 : Deferred.succeed(processed, undefined);
2483 }),
2484 ).pipe(Stream.drain),
2485 ),
2486 ),
2487 ),
2488 ),
2489 offer: (message) =>
2490 Effect.sync(() => {
2491 offeredMessages.push(message);
2492 }),
2493 setModel: (model) =>
2494 Effect.sync(() => {
2495 liveSelectionChanges.push({ model });
2496 }),
2497 applyFlagSettings: (settings) =>
2498 Effect.sync(() => {
2499 liveSelectionChanges.push(settings);
2500 }),
2501 setPermissionMode: (mode) =>
2502 Effect.sync(() => {
2503 permissionModeChanges.push(mode);
2504 }),
2505 stopTask: options?.stopTask ?? (() => Effect.void),
2506 interrupt: options?.interrupt ?? Effect.void,
2507 close: options?.close?.(sdkMessages) ?? Effect.void,
2508 };
2509 }),
2510 forkSession: () => Effect.die("unused forkSession"),
2511 subagentLaunchToolUseId: () => Effect.succeed(options?.subagentLaunchToolUseId ?? null),
2512 assertComplete: Effect.void,
2513 },
2514 });
2515 const threadId = ThreadId.make(options?.threadId ?? "thread-claude-wake");
2516 const runtime = yield* adapter.openSession({
2517 threadId,
2518 providerSessionId: ProviderSessionId.make("provider-session-claude-wake"),
2519 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
2520 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
2521 });
2522 const providerThread = yield* runtime.ensureThread({
2523 threadId,
2524 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
2525 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
2526 });
2527 const events: Array<ProviderAdapter.ProviderAdapterV2Event> = [];
2528 yield* runtime.events.pipe(
2529 Stream.runForEach((event) =>
2530 Effect.gen(function* () {
2531 events.push(event);
2532 if (event.type === "subagent.updated") {
2533 yield* Queue.offer(subagentReceipts, event);
2534 }
2535 if (event.type === "turn.terminal") {
2536 yield* Queue.offer(terminalReceipts, event);
2537 }
2538 if (event.type === "turn_item.updated" && event.turnItem.type === "system_notice") {
2539 yield* Queue.offer(systemNoticeReceipts, event);
2540 }
2541 }),
2542 ),
2543 Effect.forkScoped,
2544 );
2545 if (runtime.hasPendingBackgroundWork === undefined) {
2546 throw new Error("Claude adapter runtime must expose hasPendingBackgroundWork.");
2547 }
2548 const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork;
2549 const terminalEvents = () =>
2550 events.filter(
2551 (
2552 event,
2553 ): event is Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "turn.terminal" }> =>
2554 event.type === "turn.terminal",
2555 );
2556 return {
2557 runtime,
2558 providerThread,
2559 threadId,
2560 sdkMessages,
2561 processQueues,
2562 offerAndWait,
2563 offeredMessages,
2564 // The uuid Claude echoes on the turn answering the nth offered prompt.
2565 promptUuid: (index: number) => {
2566 const uuid = offeredMessages[index]?.uuid;
2567 if (uuid === undefined) throw new Error(`No prompt offered at index ${index}.`);
2568 return uuid;
2569 },
2570 permissionModeChanges,
2571 liveSelectionChanges,
2572 continuationRequests,
2573 continuationReceipts,
2574 events,
2575 terminalReceipts,
2576 subagentReceipts,
2577 systemNoticeReceipts,
2578 getOpenedOptions: () => openedOptions,
2579 terminalEvents,
2580 hasPendingBackgroundWork,
2581 };
2582 });
2583 const makeWakeHarness = makeWakeHarnessWithOptions();
2584
2585 it.effect("keeps progress environments isolated across concurrent Claude queries", () =>
2586 Effect.gen(function* () {
2587 const environment = { NODE_OPTIONS: "--trace-warnings", USER_SETTING: "keep" };
2588 const harnesses = yield* Effect.all(
2589 ["one", "two"].map((name) =>
2590 makeWakeHarnessWithOptions({
2591 threadId: `claude-progress-${name}`,
2592 nativeSessionId: `native-progress-${name}`,
2593 environment,
2594 }),
2595 ),
2596 { concurrency: "unbounded" },
2597 );
2598 const now = yield* DateTime.now;
2599 yield* Effect.all(
2600 harnesses.map((harness, index) =>
2601 harness.runtime.startTurn(
2602 makeClaudeTestTurnInput({
2603 threadId: harness.threadId,
2604 providerThread: harness.providerThread,
2605 now,
2606 attemptId: RunAttemptId.make(`progress-query-${index}`),
2607 text: "Run a script",
2608 attachments: [],
2609 }),
2610 ),
2611 ),
2612 { concurrency: "unbounded" },
2613 );
2614 const options = harnesses.map((harness) => harness.getOpenedOptions()?.env);
2615 assert.equal(options[0]?.T3_SCRIPT_PROGRESS_TOKEN, "claude-progress-one");
2616 assert.equal(options[1]?.T3_SCRIPT_PROGRESS_TOKEN, "claude-progress-two");
2617 for (const option of options) {
2618 assert.equal(
2619 option?.NODE_OPTIONS,
2620 "--import=file:///private/progress.mjs --trace-warnings",
2621 );
2622 assert.equal(option?.USER_SETTING, "keep");
2623 }
2624 assert.deepEqual(environment, { NODE_OPTIONS: "--trace-warnings", USER_SETTING: "keep" });
2625 assert.notStrictEqual(options[0], options[1]);
2626 }).pipe(
2627 Effect.scoped,
2628 Effect.provideService(ScriptProgress.ScriptProgress, {
2629 environmentForThread: (threadId) =>
2630 Effect.succeed({
2631 NODE_OPTIONS: "--import=file:///private/progress.mjs",
2632 T3_SCRIPT_PROGRESS_ENDPOINT: "http://127.0.0.1:1234/progress",
2633 T3_SCRIPT_PROGRESS_TOKEN: threadId,
2634 }),
2635 subscribe: () => Stream.empty,
2636 }),
2637 Effect.provide(
2638 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
2639 ),
2640 ),
2641 );
2642
2643 it.effect.each([
2644 { isError: false, title: "Check weather" },
2645 { isError: true, title: "Check weather" },
2646 { isError: false, title: undefined },
2647 ])("keeps late MCP display metadata with %j", ({ isError, title }) =>
2648 Effect.gen(function* () {
2649 const harness = yield* makeWakeHarness;
2650 yield* harness.runtime.startTurn(
2651 makeClaudeTestTurnInput({
2652 threadId: harness.threadId,
2653 providerThread: harness.providerThread,
2654 now: yield* DateTime.now,
2655 attemptId: RunAttemptId.make("mcp-presentation"),
2656 text: "Check the weather",
2657 attachments: [],
2658 }),
2659 );
2660 const toolName = "mcp__weather__get_weather";
2661 const id = "weather-call";
2662 yield* Effect.promise(() =>
2663 harness.getOpenedOptions()!.canUseTool!(
2664 toolName,
2665 { city: "Berlin" },
2666 {
2667 signal: new AbortController().signal,
2668 toolUseID: id,
2669 requestId: "weather-request",
2670 },
2671 ),
2672 );
2673 yield* harness.offerAndWait(
2674 claudeSdkFrame({
2675 type: "assistant",
2676 uuid: "weather-assistant",
2677 session_id: WAKE_NATIVE_SESSION,
2678 parent_tool_use_id: null,
2679 message: {
2680 id: "weather-message",
2681 type: "message",
2682 role: "assistant",
2683 model: "claude-sonnet-4-6",
2684 content: [{ type: "tool_use", id, name: toolName, input: { city: "Berlin" } }],
2685 },
2686 tool_use_meta: [
2687 {
2688 id,
2689 display_name: title,
2690 server_display_name: "Weather",
2691 icon_url: "https://example.com/weather.png",
2692 },
2693 ],
2694 }),
2695 );
2696 yield* harness.offerAndWait(
2697 claudeSdkFrame({
2698 type: "user",
2699 uuid: "weather-result",
2700 session_id: WAKE_NATIVE_SESSION,
2701 parent_tool_use_id: null,
2702 message: {
2703 role: "user",
2704 content: [
2705 {
2706 type: "tool_result",
2707 tool_use_id: id,
2708 content: "Weather result",
2709 is_error: isError,
2710 },
2711 ],
2712 },
2713 }),
2714 );
2715 yield* Queue.offer(
2716 harness.sdkMessages,
2717 makeResultFrame({ uuid: "weather-terminal", result: "Weather checked" }),
2718 );
2719 yield* Queue.take(harness.terminalReceipts);
2720 const items = harness.events.flatMap((event) =>
2721 event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool"
2722 ? [event.turnItem]
2723 : [],
2724 );
2725 assert.deepEqual(
2726 items.map((item) => item.status),
2727 ["running", "running", isError ? "failed" : "completed"],
2728 );
2729 assert.isNull(items[0]?.title);
2730 for (const item of items.slice(1)) {
2731 assert.equal(item.title, title ?? "get weather");
2732 assert.deepEqual(item.toolIcon, {
2733 _tag: "themed-logo",
2734 logoUrl: "https://example.com/weather.png",
2735 });
2736 assert.deepEqual(item.toolSource, {
2737 key: "mcp:weather",
2738 name: "Weather",
2739 kind: "integration",
2740 icon: { _tag: "themed-logo", logoUrl: "https://example.com/weather.png" },
2741 });
2742 assert.deepEqual(item.input, { city: "Berlin" });
2743 }
2744 assert.equal(new Set(items.map((item) => item.id)).size, 1);
2745 }).pipe(
2746 Effect.scoped,
2747 Effect.provide(
2748 Layer.mergeAll(
2749 IdAllocator.layer,
2750 McpProviderSessions.layer,
2751 ScriptProgress.layerUnavailable,
2752 NodeServices.layer,
2753 ),
2754 ),
2755 ),
2756 );
2757
2758 it.effect(
2759 "applies model, effort and Fast changes to a process running a background shell, but not thinking or a cleared effort",
2760 () =>
2761 Effect.scoped(
2762 Effect.gen(function* () {
2763 const harness = yield* makeWakeHarness;
2764 const now = yield* DateTime.now;
2765 const normal = {
2766 instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
2767 model: "claude-opus-5-5",
2768 } satisfies ModelSelection;
2769 const turn = (ordinal: number, modelSelection: ModelSelection) =>
2770 makeClaudeTestTurnInput({
2771 threadId: harness.threadId,
2772 providerThread: harness.providerThread,
2773 now,
2774 attemptId: RunAttemptId.make(`attempt-normal-background:${ordinal}`),
2775 text: `Request ${ordinal}`,
2776 attachments: [],
2777 providerTurnOrdinal: ordinal,
2778 modelSelection,
2779 });
2780 const settle = Effect.gen(function* () {
2781 yield* harness.offerAndWait(turnOneResult);
2782 yield* Queue.take(harness.terminalReceipts);
2783 });
2784 yield* harness.runtime.startTurn(turn(1, normal));
2785 const originalOptions = harness.getOpenedOptions();
2786 yield* harness.offerAndWait(wakeTaskStarted);
2787 yield* settle;
2788 assert.isTrue(yield* harness.hasPendingBackgroundWork);
2789
2790 // Omitted and explicit Normal are the same selection.
2791 yield* harness.runtime.startTurn(
2792 turn(2, { ...normal, options: [{ id: "fastMode", value: false }] }),
2793 );
2794 yield* settle;
2795 assert.deepEqual(harness.liveSelectionChanges, []);
2796
2797 yield* harness.runtime.startTurn(
2798 turn(3, {
2799 ...normal,
2800 options: [
2801 { id: "effort", value: "ultracode" },
2802 { id: "fastMode", value: true },
2803 ],
2804 }),
2805 );
2806 yield* settle;
2807 yield* harness.runtime.startTurn(turn(4, { ...normal, model: "claude-sonnet-4-6" }));
2808 yield* settle;
2809 assert.deepEqual(harness.liveSelectionChanges, [
2810 { model: "claude-opus-5-5" },
2811 { effortLevel: "xhigh", fastMode: true, ultracode: true },
2812 { model: "claude-sonnet-4-6" },
2813 // Sonnet's default effort; it has no Fast mode.
2814 { effortLevel: "high", fastMode: null, ultracode: null },
2815 ]);
2816 assert.strictEqual(harness.getOpenedOptions(), originalOptions);
2817 assert.lengthOf(harness.offeredMessages, 4);
2818
2819 // Thinking is a launch flag. Haiku 4.5 has no effort, and only a new
2820 // process reads the user's configured effort again.
2821 for (const [ordinal, selection] of [
2822 [5, CLAUDE_TEST_RESTART_MODEL_SELECTION],
2823 [6, { ...normal, model: "claude-haiku-4-5-20251001" }],
2824 ] as const) {
2825 const refused = yield* harness.runtime
2826 .startTurn(turn(ordinal, selection))
2827 .pipe(Effect.result);
2828 assert.equal(refused._tag, "Failure");
2829 if (refused._tag === "Failure") {
2830 assert.instanceOf(
2831 refused.failure.cause,
2832 ClaudeAdapterV2.ClaudeBackgroundWorkBlocksQueryReplacementError,
2833 );
2834 }
2835 }
2836 assert.strictEqual(harness.getOpenedOptions(), originalOptions);
2837 assert.lengthOf(harness.offeredMessages, 4);
2838 assert.lengthOf(harness.liveSelectionChanges, 4);
2839 assert.isTrue(yield* harness.hasPendingBackgroundWork);
2840 }).pipe(
2841 Effect.provide(
2842 Layer.mergeAll(
2843 IdAllocator.layer,
2844 McpProviderSessions.layer,
2845 ScriptProgress.layerUnavailable,
2846 NodeServices.layer,
2847 ),
2848 ),
2849 ),
2850 ),
2851 );
2852
2853 it.effect.each(["completed", "interrupted"] as const)(
2854 "projects Claude thinking blocks when %s",
2855 (status) =>
2856 Effect.gen(function* () {
2857 const harness = yield* makeWakeHarness;
2858 yield* harness.runtime.startTurn(
2859 makeClaudeTestTurnInput({
2860 threadId: harness.threadId,
2861 providerThread: harness.providerThread,
2862 now: yield* DateTime.now,
2863 attemptId: RunAttemptId.make("reasoning-attempt"),
2864 text: "Check the plan",
2865 attachments: [],
2866 }),
2867 );
2868 const stream = (event: unknown, parent: string | null = null) =>
2869 claudeSdkFrame({
2870 type: "stream_event",
2871 event,
2872 parent_tool_use_id: parent,
2873 session_id: WAKE_NATIVE_SESSION,
2874 uuid: "stream-frame",
2875 });
2876 const snapshot = (id: string, uuid: string, thinking: string) =>
2877 claudeSdkFrame({
2878 type: "assistant",
2879 uuid,
2880 session_id: WAKE_NATIVE_SESSION,
2881 parent_tool_use_id: null,
2882 message: {
2883 id,
2884 model: "claude-sonnet-4-6",
2885 content: [{ type: "thinking", thinking, signature: "secret-signature" }],
2886 },
2887 });
2888 const frames = [
2889 stream({ type: "message_start", message: { id: "thought-message" } }),
2890 stream({
2891 type: "content_block_start",
2892 index: 2,
2893 content_block: { type: "thinking", thinking: "" },
2894 }),
2895 stream({
2896 type: "content_block_delta",
2897 index: 2,
2898 delta: { type: "thinking_delta", thinking: "First " },
2899 }),
2900 stream({
2901 type: "content_block_delta",
2902 index: 2,
2903 delta: { type: "thinking_delta", thinking: "thought" },
2904 }),
2905 stream({
2906 type: "content_block_delta",
2907 index: 2,
2908 delta: { type: "signature_delta", signature: "secret-signature" },
2909 }),
2910 stream({ type: "content_block_stop", index: 2 }),
2911 snapshot("thought-message", "first-snapshot", "Authoritative first thought"),
2912 snapshot("thought-message", "first-snapshot", "Authoritative first thought"),
2913 stream({
2914 type: "content_block_start",
2915 index: 4,
2916 content_block: { type: "thinking", thinking: "Second thought" },
2917 }),
2918 stream({ type: "content_block_stop", index: 4 }),
2919 snapshot("thought-message", "second-snapshot", ""),
2920 snapshot("completion-only", "third-snapshot", "Completion only"),
2921 snapshot("redacted", "empty-snapshot", ""),
2922 stream({ type: "message_start", message: { id: "child-message" } }, "child-tool"),
2923 stream(
2924 {
2925 type: "content_block_start",
2926 index: 0,
2927 content_block: { type: "thinking", thinking: "Child thought" },
2928 },
2929 "child-tool",
2930 ),
2931 stream({ type: "message_start", message: { id: "partial-message" } }),
2932 stream({
2933 type: "content_block_start",
2934 index: 0,
2935 content_block: { type: "thinking", thinking: "Partial thought" },
2936 }),
2937 makeResultFrame({
2938 uuid: "reasoning-result",
2939 result: "",
2940 ...(status === "interrupted" ? { terminalReason: "aborted_streaming" as const } : {}),
2941 }),
2942 ];
2943 for (const frame of frames) yield* Queue.offer(harness.sdkMessages, frame);
2944 const terminal = yield* Queue.take(harness.terminalReceipts);
2945 assert.equal(terminal.status, status);
2946 const latest = new Map(
2947 harness.events.flatMap((event) =>
2948 event.type === "turn_item.updated" && event.turnItem.type === "reasoning"
2949 ? [[event.turnItem.id, event.turnItem] as const]
2950 : [],
2951 ),
2952 );
2953 assert.deepEqual(
2954 [...latest.values()].map((item) => item.text),
2955 ["Authoritative first thought", "Second thought", "Completion only", "Partial thought"],
2956 );
2957 assert.equal(new Set([...latest.values()].map((item) => item.ordinal)).size, 4);
2958 for (const item of latest.values()) {
2959 assert.equal(item.streaming, false);
2960 assert.isNotNull(item.completedAt);
2961 }
2962 assert.isFalse(
2963 harness.events.some(
2964 (event) => event.type === "message.updated" && event.message.role === "assistant",
2965 ),
2966 );
2967 for (const item of latest.values()) assert.notInclude(item.text, "secret-signature");
2968 }).pipe(
2969 Effect.scoped,
2970 Effect.provide(
2971 Layer.mergeAll(
2972 IdAllocator.layer,
2973 McpProviderSessions.layer,
2974 ScriptProgress.layerUnavailable,
2975 NodeServices.layer,
2976 ),
2977 ),
2978 ),
2979 );
2980
2981 // Summarized display can't mark a progress update, so only the second of two
2982 // thinking blocks before a tool call becomes text; a lone block is reasoning.
2983 const plan = "Planning the check";
2984 const update = "Found it: the memory file is the cause.";
2985 it.effect.each([
2986 {
2987 model: "claude-opus-5-5",
2988 texts: [plan, update],
2989 expected: ["reasoning", "assistant_message"],
2990 },
2991 {
2992 model: "claude-fable-5-1",
2993 texts: [plan, update],
2994 expected: ["reasoning", "assistant_message"],
2995 },
2996 { model: "claude-opus-5", texts: [plan, update], expected: ["reasoning", "reasoning"] },
2997 { model: "claude-sonnet-4-6", texts: [plan, update], expected: ["reasoning", "reasoning"] },
2998 { model: "claude-opus-5-5", texts: [plan], expected: ["reasoning"] },
2999 { model: "claude-opus-5-5", texts: [plan, " \n "], expected: ["reasoning", "reasoning"] },
3000 { model: "claude-opus-5-5", texts: [" \n ", update], expected: ["reasoning", "reasoning"] },
3001 ] as const)(
3002 "shows $model thinking $texts before a tool call as $expected",
3003 ({ model, texts, expected }) =>
3004 Effect.gen(function* () {
3005 const harness = yield* makeWakeHarness;
3006 yield* harness.runtime.startTurn(
3007 makeClaudeTestTurnInput({
3008 threadId: harness.threadId,
3009 providerThread: harness.providerThread,
3010 now: yield* DateTime.now,
3011 attemptId: RunAttemptId.make("thinking-before-tool-attempt"),
3012 text: "Check the memory file",
3013 attachments: [],
3014 }),
3015 );
3016 const snapshot = (uuid: string, content: ReadonlyArray<unknown>) =>
3017 claudeSdkFrame({
3018 type: "assistant",
3019 uuid,
3020 session_id: WAKE_NATIVE_SESSION,
3021 parent_tool_use_id: null,
3022 message: { id: "reply-message", model, content },
3023 });
3024 const frames = [
3025 ...texts.map((thinking, index) =>
3026 snapshot(`thinking-snapshot-${index}`, [
3027 { type: "thinking", thinking, signature: "sig" },
3028 ]),
3029 ),
3030 snapshot("tool-snapshot", [
3031 { type: "tool_use", id: "tool-1", name: "Bash", input: { command: "ls" } },
3032 ]),
3033 makeResultFrame({ uuid: "thinking-before-tool-result", result: "" }),
3034 ];
3035 for (const frame of frames) yield* Queue.offer(harness.sdkMessages, frame);
3036 yield* Queue.take(harness.terminalReceipts);
3037 const latest = new Map(
3038 harness.events.flatMap((event) =>
3039 event.type === "turn_item.updated" &&
3040 (event.turnItem.type === "reasoning" || event.turnItem.type === "assistant_message")
3041 ? [[event.turnItem.id, event.turnItem] as const]
3042 : [],
3043 ),
3044 );
3045 const items = [...latest.values()];
3046 assert.deepEqual(
3047 items.map((item) => item.type),
3048 [...expected],
3049 );
3050 assert.deepEqual(
3051 items.map((item) => item.text),
3052 [...texts],
3053 );
3054 }).pipe(
3055 Effect.scoped,
3056 Effect.provide(
3057 Layer.mergeAll(
3058 IdAllocator.layer,
3059 McpProviderSessions.layer,
3060 ScriptProgress.layerUnavailable,
3061 NodeServices.layer,
3062 ),
3063 ),
3064 ),
3065 );
3066
3067 it.effect.each(["cancelled", "denied", "permission_denied", undefined])(
3068 "preserves native tool non-execution metadata %s without inferring a denial from text",
3069 (kind) =>
3070 Effect.scoped(
3071 Effect.gen(function* () {
3072 const harness = yield* makeWakeHarness;
3073 yield* harness.runtime.startTurn(
3074 makeClaudeTestTurnInput({
3075 threadId: harness.threadId,
3076 providerThread: harness.providerThread,
3077 now: yield* DateTime.now,
3078 attemptId: RunAttemptId.make("attempt-tool-non-execution"),
3079 text: "Run the tool.",
3080 attachments: [],
3081 }),
3082 );
3083 // Same error text can describe a cancellation or a real refusal.
3084 // Each result must use its own metadata, even in a multi-result frame.
3085 yield* harness.offerAndWait(
3086 claudeSdkFrame({
3087 type: "user",
3088 uuid: "tool-non-execution",
3089 session_id: WAKE_NATIVE_SESSION,
3090 parent_tool_use_id: null,
3091 message: {
3092 role: "user",
3093 content: [
3094 {
3095 type: "tool_result",
3096 tool_use_id: "tool-error",
3097 is_error: true,
3098 content: "STOP and wait for the user.",
3099 },
3100 { type: "tool_result", tool_use_id: "tool-ok", is_error: false, content: "OK" },
3101 ],
3102 },
3103 ...(kind === undefined
3104 ? {}
3105 : {
3106 tool_result_meta: [
3107 { id: "tool-error", non_execution_kind: kind },
3108 { id: "tool-ok", non_execution_kind: null },
3109 ],
3110 }),
3111 }),
3112 );
3113 yield* Queue.offer(
3114 harness.sdkMessages,
3115 makeResultFrame({ uuid: "result-non-execution", result: "Done" }),
3116 );
3117 yield* Queue.take(harness.terminalReceipts);
3118 const items = harness.events.flatMap((event) =>
3119 event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool"
3120 ? [event.turnItem]
3121 : [],
3122 );
3123 const failed = items.findLast((item) => item.nativeItemRef?.nativeId === "tool-error")!;
3124 assert.equal(failed.status, kind === "cancelled" ? "cancelled" : "failed");
3125 assert.equal(failed.toolNonExecutionKind, kind);
3126 const ok = items.findLast((item) => item.nativeItemRef?.nativeId === "tool-ok")!;
3127 assert.equal(ok.status, "completed");
3128 assert.equal(ok.toolNonExecutionKind, undefined);
3129 const node = harness.events.findLast(
3130 (event) =>
3131 event.type === "node.updated" && event.node.nativeItemRef?.nativeId === "tool-error",
3132 );
3133 assert.equal(node?.type === "node.updated" ? node.node.status : undefined, failed.status);
3134 }).pipe(
3135 Effect.provide(
3136 Layer.mergeAll(
3137 IdAllocator.layer,
3138 McpProviderSessions.layer,
3139 ScriptProgress.layerUnavailable,
3140 NodeServices.layer,
3141 ),
3142 ),
3143 ),
3144 ),
3145 );
3146
3147 it.effect.each(
3148 (["aborted_tools", "aborted_streaming"] as const).flatMap((terminalReason) =>
3149 [true, false].map((steered) => ({ terminalReason, steered })),
3150 ),
3151 )("handles $terminalReason with active steering=$steered", ({ terminalReason, steered }) =>
3152 Effect.scoped(
3153 Effect.gen(function* () {
3154 const harness = yield* makeWakeHarness;
3155 const idAllocator = yield* IdAllocator.IdAllocatorV2;
3156 const attemptId = RunAttemptId.make("attempt-steering-abort");
3157 const input = makeClaudeTestTurnInput({
3158 threadId: harness.threadId,
3159 providerThread: harness.providerThread,
3160 now: yield* DateTime.now,
3161 attemptId,
3162 text: "Audit the settings pages.",
3163 attachments: [],
3164 });
3165 yield* harness.runtime.startTurn(input);
3166 if (steered) {
3167 yield* harness.runtime.steerTurn({
3168 threadId: harness.threadId,
3169 runId: input.runId,
3170 providerThread: harness.providerThread,
3171 providerTurnId: idAllocator.derive.providerTurn({
3172 driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
3173 nativeTurnId: `turn:${attemptId}`,
3174 }),
3175 message: {
3176 createdBy: "user",
3177 creationSource: "web",
3178 messageId: MessageId.make("message-steering-abort"),
3179 text: "Include the hierarchy mock.",
3180 attachments: [],
3181 },
3182 });
3183 assert.equal(harness.offeredMessages[1]?.priority, "now");
3184 }
3185 yield* Queue.offer(
3186 harness.sdkMessages,
3187 makeResultFrame({
3188 uuid: "00000000-0000-4000-8000-000000000901",
3189 result: "",
3190 terminalReason,
3191 usage: {
3192 input_tokens: 600,
3193 output_tokens: 40,
3194 cache_creation_input_tokens: 500,
3195 cache_read_input_tokens: 2000,
3196 },
3197 }),
3198 );
3199 if (steered) {
3200 yield* Queue.offer(harness.sdkMessages, wakeAssistant);
3201 yield* Queue.offer(
3202 harness.sdkMessages,
3203 makeResultFrame({
3204 uuid: "00000000-0000-4000-8000-000000000902",
3205 result: "Audit finished after the steer.",
3206 usage: {
3207 input_tokens: 3,
3208 output_tokens: 6,
3209 cache_creation_input_tokens: 2347,
3210 cache_read_input_tokens: 9455,
3211 },
3212 }),
3213 );
3214 }
3215 const terminal = yield* Queue.take(harness.terminalReceipts);
3216 assert.equal(terminal.status, steered ? "completed" : "interrupted");
3217 if (steered) {
3218 assert.isTrue(
3219 harness.events.some(
3220 (event) =>
3221 event.type === "turn_item.updated" &&
3222 event.turnItem.type === "assistant_message" &&
3223 event.turnItem.text === WAKE_ASSISTANT_TEXT,
3224 ),
3225 );
3226 // Each result's usage covers only its own segment, so the steered
3227 // turn bills both segments.
3228 const completed = harness.events.findLast(
3229 (event) => event.type === "provider_turn.updated",
3230 );
3231 assert.deepEqual(
3232 completed?.type === "provider_turn.updated"
3233 ? completed.providerTurn.turnTokenUsage
3234 : undefined,
3235 {
3236 usageScope: "main_agent",
3237 usageStatus: "complete",
3238 hasSubagents: false,
3239 inputTokens: 603 + 2847 + 11455,
3240 outputTokens: 46,
3241 cachedInputTokens: 11455,
3242 cacheCreationTokens: 2847,
3243 },
3244 );
3245 }
3246 assert.lengthOf(harness.terminalEvents(), 1);
3247 }).pipe(
3248 Effect.provide(
3249 Layer.mergeAll(
3250 IdAllocator.layer,
3251 McpProviderSessions.layer,
3252 ScriptProgress.layerUnavailable,
3253 NodeServices.layer,
3254 ),
3255 ),
3256 ),
3257 ),
3258 );
3259
3260 it.effect("announces usage-limit pauses once per window and again on a new turn", () =>
3261 Effect.gen(function* () {
3262 const harness = yield* makeWakeHarness;
3263 const now = yield* DateTime.now;
3264 const resetsAt = Math.floor(DateTime.toEpochMillis(now) / 1000) + 7_200;
3265 const limit = (rateLimitType: "five_hour" | "seven_day" = "five_hour") =>
3266 claudeSdkFrame({
3267 type: "rate_limit_event",
3268 rate_limit_info: { status: "rejected", rateLimitType, resetsAt },
3269 uuid: "00000000-0000-4000-8000-000000000601",
3270 session_id: WAKE_NATIVE_SESSION,
3271 });
3272 const start = (ordinal: number) =>
3273 harness.runtime.startTurn(
3274 makeClaudeTestTurnInput({
3275 threadId: harness.threadId,
3276 providerThread: harness.providerThread,
3277 now,
3278 attemptId: RunAttemptId.make(`attempt-claude-limit-${ordinal}`),
3279 providerTurnOrdinal: ordinal,
3280 text: "Continue.",
3281 attachments: [],
3282 }),
3283 );
3284 yield* start(1);
3285 yield* Queue.offer(harness.sdkMessages, limit());
3286 const pause = (yield* Queue.take(harness.systemNoticeReceipts)).turnItem;
3287 assert.equal(pause.type, "system_notice");
3288 if (pause.type !== "system_notice") return;
3289 assert.equal(
3290 pause.message,
3291 "Claude usage limit reached. This turn is paused until the 5-hour limit resets in 2h.",
3292 );
3293 assert.lengthOf(harness.terminalEvents(), 0);
3294 yield* Queue.offerAll(harness.sdkMessages, [
3295 limit(),
3296 limit("seven_day"),
3297 limit(),
3298 makeResultFrame({
3299 uuid: "00000000-0000-4000-8000-000000000602",
3300 result: "Recovered.",
3301 }),
3302 ]);
3303 yield* Queue.take(harness.terminalReceipts);
3304 const notices = () =>
3305 harness.events.flatMap((event) =>
3306 event.type === "turn_item.updated" && event.turnItem.type === "system_notice"
3307 ? [event.turnItem]
3308 : [],
3309 );
3310 assert.lengthOf(notices(), 2);
3311 yield* start(2);
3312 yield* Queue.offerAll(harness.sdkMessages, [
3313 limit(),
3314 makeResultFrame({
3315 uuid: "00000000-0000-4000-8000-000000000603",
3316 result: "Done.",
3317 }),
3318 ]);
3319 yield* Queue.take(harness.terminalReceipts);
3320 assert.lengthOf(notices(), 3);
3321 assert.notEqual(notices()[0]?.id, notices()[2]?.id);
3322 }).pipe(
3323 Effect.provide(
3324 Layer.mergeAll(
3325 IdAllocator.layer,
3326 McpProviderSessions.layer,
3327 ScriptProgress.layerUnavailable,
3328 NodeServices.layer,
3329 ),
3330 ),
3331 ),
3332 );
3333
3334 it.effect("keeps usage warnings and provisioned overage silent", () =>
3335 Effect.gen(function* () {
3336 const harness = yield* makeWakeHarness;
3337 const now = yield* DateTime.now;
3338 yield* harness.runtime.startTurn(
3339 makeClaudeTestTurnInput({
3340 threadId: harness.threadId,
3341 providerThread: harness.providerThread,
3342 now,
3343 attemptId: RunAttemptId.make("attempt-claude-overage"),
3344 text: "Continue.",
3345 attachments: [],
3346 }),
3347 );
3348 for (const rate_limit_info of [
3349 { status: "allowed_warning" },
3350 { status: "rejected", overageStatus: "allowed" },
3351 { status: "rejected", overageStatus: "allowed_warning" },
3352 { status: "rejected", isUsingOverage: true },
3353 { status: "rejected", overageInUse: true },
3354 ]) {
3355 yield* Queue.offer(
3356 harness.sdkMessages,
3357 claudeSdkFrame({
3358 type: "rate_limit_event",
3359 rate_limit_info,
3360 session_id: WAKE_NATIVE_SESSION,
3361 uuid: "00000000-0000-4000-8000-000000000604",
3362 }),
3363 );
3364 }
3365 yield* Queue.offer(
3366 harness.sdkMessages,
3367 makeResultFrame({
3368 uuid: "00000000-0000-4000-8000-000000000605",
3369 result: "Done.",
3370 }),
3371 );
3372 yield* Queue.take(harness.terminalReceipts);
3373 assert.isFalse(
3374 harness.events.some(
3375 (event) => event.type === "turn_item.updated" && event.turnItem.type === "system_notice",
3376 ),
3377 );
3378 }).pipe(
3379 Effect.provide(
3380 Layer.mergeAll(
3381 IdAllocator.layer,
3382 McpProviderSessions.layer,
3383 ScriptProgress.layerUnavailable,
3384 NodeServices.layer,
3385 ),
3386 ),
3387 ),
3388 );
3389
3390 it.effect("names an expired Claude login instead of the terminal API error", () =>
3391 Effect.gen(function* () {
3392 const configDir = "/synthetic/Claude config";
3393 // The adapter resolves the cwd on the host, which adds a drive letter on Windows.
3394 const cwd = NodePath.resolve("/synthetic/project");
3395 const harness = yield* makeWakeHarnessWithOptions({
3396 environment: { CLAUDE_CONFIG_DIR: configDir },
3397 });
3398 const now = yield* DateTime.now;
3399 yield* harness.runtime.startTurn(
3400 makeClaudeTestTurnInput({
3401 threadId: harness.threadId,
3402 providerThread: harness.providerThread,
3403 now,
3404 attemptId: RunAttemptId.make("attempt-claude-auth-failure"),
3405 text: "Continue.",
3406 attachments: [],
3407 runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
3408 runtimeMode: "full-access",
3409 interactionMode: "default",
3410 cwd,
3411 }),
3412 }),
3413 );
3414 yield* Queue.offerAll(harness.sdkMessages, [
3415 makeAssistantErrorFrame({
3416 uuid: "00000000-0000-4000-8000-000000000606",
3417 error: "authentication_failed",
3418 }),
3419 makeResultFrame({
3420 uuid: "00000000-0000-4000-8000-000000000607",
3421 result: "API Error",
3422 terminalReason: "api_error",
3423 }),
3424 ]);
3425
3426 const terminal = yield* Queue.take(harness.terminalReceipts);
3427 assert.equal(terminal.status, "failed");
3428 if (terminal.status !== "failed") return;
3429 assert.include(terminal.failure.message, "run `claude auth login`");
3430 assert.include(terminal.failure.message, configDir);
3431 // The message quotes its paths, which escapes Windows separators.
3432 assert.include(terminal.failure.message, JSON.stringify(cwd));
3433 assert.notInclude(terminal.failure.message, "repeated API errors");
3434 }).pipe(
3435 Effect.provide(
3436 Layer.mergeAll(
3437 IdAllocator.layer,
3438 McpProviderSessions.layer,
3439 ScriptProgress.layerUnavailable,
3440 NodeServices.layer,
3441 ),
3442 ),
3443 ),
3444 );
3445
3446 it.effect("shows a 403 permission error instead of the login hint", () =>
3447 Effect.gen(function* () {
3448 const harness = yield* makeWakeHarness;
3449 const now = yield* DateTime.now;
3450 yield* harness.runtime.startTurn(
3451 makeClaudeTestTurnInput({
3452 threadId: harness.threadId,
3453 providerThread: harness.providerThread,
3454 now,
3455 attemptId: RunAttemptId.make("attempt-claude-permission-denied"),
3456 text: "Continue.",
3457 attachments: [],
3458 }),
3459 );
3460 // Claude CLI 2.1.288 labels a 403 authentication_failed too.
3461 const cliText =
3462 "Failed to authenticate. API Error: 403 Your organization does not have access to this model.";
3463 yield* Queue.offerAll(harness.sdkMessages, [
3464 makeAssistantErrorFrame({
3465 uuid: "00000000-0000-4000-8000-000000000608",
3466 error: "authentication_failed",
3467 }),
3468 makeResultFrame({
3469 uuid: "00000000-0000-4000-8000-000000000609",
3470 result: cliText,
3471 isError: true,
3472 apiErrorStatus: 403,
3473 terminalReason: "api_error",
3474 }),
3475 ]);
3476
3477 const terminal = yield* Queue.take(harness.terminalReceipts);
3478 assert.equal(terminal.status, "failed");
3479 if (terminal.status !== "failed") return;
3480 assert.equal(terminal.failure.message, cliText);
3481 assert.equal(terminal.failure.code, "api_error_403");
3482 }).pipe(
3483 Effect.provide(
3484 Layer.mergeAll(
3485 IdAllocator.layer,
3486 McpProviderSessions.layer,
3487 ScriptProgress.layerUnavailable,
3488 NodeServices.layer,
3489 ),
3490 ),
3491 ),
3492 );
3493
3494 it.effect.each([
3495 { recovered: false, expected: "Claude usage limit reached" },
3496 { recovered: true, expected: "Claude gave up after repeated API errors" },
3497 ])("tracks whether a rejected usage window recovered ($recovered)", ({ recovered, expected }) =>
3498 Effect.gen(function* () {
3499 const harness = yield* makeWakeHarness;
3500 const now = yield* DateTime.now;
3501 yield* harness.runtime.startTurn(
3502 makeClaudeTestTurnInput({
3503 threadId: harness.threadId,
3504 providerThread: harness.providerThread,
3505 now,
3506 attemptId: RunAttemptId.make(`attempt-claude-window-${recovered}`),
3507 text: "Continue.",
3508 attachments: [],
3509 }),
3510 );
3511 yield* Queue.offer(
3512 harness.sdkMessages,
3513 claudeSdkFrame({
3514 type: "rate_limit_event",
3515 rate_limit_info: { status: "rejected", rateLimitType: "five_hour" },
3516 uuid: "00000000-0000-4000-8000-000000000608",
3517 session_id: WAKE_NATIVE_SESSION,
3518 }),
3519 );
3520 if (recovered) {
3521 yield* Queue.offer(
3522 harness.sdkMessages,
3523 claudeSdkFrame({
3524 type: "rate_limit_event",
3525 rate_limit_info: { status: "allowed", rateLimitType: "five_hour" },
3526 uuid: "00000000-0000-4000-8000-000000000609",
3527 session_id: WAKE_NATIVE_SESSION,
3528 }),
3529 );
3530 }
3531 yield* Queue.offer(
3532 harness.sdkMessages,
3533 makeResultFrame({
3534 uuid: "00000000-0000-4000-8000-000000000610",
3535 result: "API Error",
3536 terminalReason: "api_error",
3537 }),
3538 );
3539
3540 const terminal = yield* Queue.take(harness.terminalReceipts);
3541 assert.equal(terminal.status, "failed");
3542 if (terminal.status !== "failed") return;
3543 assert.include(terminal.failure.message, expected);
3544 assert.equal(terminal.failure.class, recovered ? "provider_error" : "usage_limit");
3545 }).pipe(
3546 Effect.provide(
3547 Layer.mergeAll(
3548 IdAllocator.layer,
3549 McpProviderSessions.layer,
3550 ScriptProgress.layerUnavailable,
3551 NodeServices.layer,
3552 ),
3553 ),
3554 ),
3555 );
3556
3557 it.effect.each([
3558 { name: "parent limit", parentErrors: ["rate_limit"], expectedLimit: true },
3559 {
3560 name: "parent limit then nested response",
3561 parentErrors: ["rate_limit", "nested-ok"],
3562 expectedLimit: true,
3563 },
3564 { name: "nested limit", parentErrors: ["nested-limit"], expectedLimit: false },
3565 {
3566 name: "parent limit then parent response",
3567 parentErrors: ["rate_limit", "ok"],
3568 expectedLimit: false,
3569 },
3570 ])("classifies retried terminal API failures after $name", ({ parentErrors, expectedLimit }) =>
3571 Effect.gen(function* () {
3572 const harness = yield* makeWakeHarness;
3573 const now = yield* DateTime.now;
3574 yield* harness.runtime.startTurn(
3575 makeClaudeTestTurnInput({
3576 threadId: harness.threadId,
3577 providerThread: harness.providerThread,
3578 now,
3579 attemptId: RunAttemptId.make(`attempt-claude-retry-${parentErrors.join("-")}`),
3580 text: "Continue.",
3581 attachments: [],
3582 }),
3583 );
3584 for (const [index, evidence] of parentErrors.entries()) {
3585 yield* Queue.offer(
3586 harness.sdkMessages,
3587 makeAssistantErrorFrame({
3588 uuid: `00000000-0000-4000-8000-00000000062${index}`,
3589 error: evidence.includes("limit") ? "rate_limit" : undefined,
3590 parentToolUseId: evidence.startsWith("nested") ? "nested-tool" : null,
3591 }),
3592 );
3593 }
3594 yield* Queue.offer(
3595 harness.sdkMessages,
3596 makeResultFrame({
3597 uuid: "00000000-0000-4000-8000-000000000629",
3598 result: "API Error",
3599 isError: true,
3600 terminalReason: "api_error",
3601 }),
3602 );
3603
3604 const terminal = yield* Queue.take(harness.terminalReceipts);
3605 assert.equal(terminal.status, "failed");
3606 if (terminal.status !== "failed") return;
3607 assert.equal(terminal.failure.class, expectedLimit ? "usage_limit" : "provider_error");
3608 assert.equal(
3609 terminal.failure.message,
3610 expectedLimit
3611 ? "Claude usage limit reached. Send the message again once the limit resets."
3612 : "Claude gave up after repeated API errors.",
3613 );
3614 }).pipe(
3615 Effect.provide(
3616 Layer.mergeAll(
3617 IdAllocator.layer,
3618 McpProviderSessions.layer,
3619 ScriptProgress.layerUnavailable,
3620 NodeServices.layer,
3621 ),
3622 ),
3623 ),
3624 );
3625
3626 it.effect.each([
3627 { status: 503, cooldown: "4m 6s", seconds: 246 },
3628 { status: 503, cooldown: "1h 2m 3s", seconds: 3723 },
3629 { status: 503, cooldown: "7s", seconds: 7 },
3630 { status: 503, cooldown: "0s", seconds: 0 },
3631 { status: 503, cooldown: "unknown", seconds: 0 },
3632 { status: 503, cooldown: "999999999999999999999999h", seconds: 0 },
3633 { status: 503, cooldown: "4m 6s", seconds: 0, errorType: "overloaded_error" },
3634 { status: 401, cooldown: "4m 6s", seconds: 0 },
3635 ])("recovers wrapped gateway limits with status $status and cooldown $cooldown", (input) =>
3636 Effect.gen(function* () {
3637 const harness = yield* makeWakeHarness;
3638 const now = yield* DateTime.now;
3639 yield* harness.runtime.startTurn(
3640 makeClaudeTestTurnInput({
3641 threadId: harness.threadId,
3642 providerThread: harness.providerThread,
3643 now,
3644 attemptId: RunAttemptId.make("attempt-gateway-limit"),
3645 text: "Continue.",
3646 attachments: [],
3647 }),
3648 );
3649 yield* Queue.offerAll(harness.sdkMessages, [
3650 makeAssistantErrorFrame({
3651 uuid: "00000000-0000-4000-8000-000000000652",
3652 error: "server_error",
3653 }),
3654 makeResultFrame({
3655 uuid: "00000000-0000-4000-8000-000000000653",
3656 result: `API Error: ${input.status} [claude/claude-opus-5-5] [429]: {"type":"error","error":{"type":"${input.errorType ?? "rate_limit_error"}","message":"Please try again later."}} (reset after ${input.cooldown}).`,
3657 terminalReason: "api_error",
3658 isError: true,
3659 apiErrorStatus: input.status,
3660 }),
3661 ]);
3662 const terminal = yield* Queue.take(harness.terminalReceipts);
3663 assert.equal(terminal.status, "failed");
3664 if (terminal.status !== "failed") return;
3665 assert.equal(terminal.failure.class, input.seconds > 0 ? "usage_limit" : "provider_error");
3666 if (input.seconds > 0) {
3667 assert.equal(terminal.failure.retryable, true);
3668 assert.equal(
3669 terminal.failure.resetAt,
3670 DateTime.formatIso(
3671 DateTime.makeUnsafe(DateTime.toEpochMillis(now) + input.seconds * 1_000),
3672 ),
3673 );
3674 } else {
3675 assert.isUndefined(terminal.failure.resetAt);
3676 }
3677 }).pipe(
3678 Effect.provide(
3679 Layer.mergeAll(
3680 IdAllocator.layer,
3681 McpProviderSessions.layer,
3682 ScriptProgress.layerUnavailable,
3683 NodeServices.layer,
3684 ),
3685 ),
3686 ),
3687 );
3688
3689 it.effect.each([429, 401, 503, 529])(
3690 "classifies the current Claude API status %s after rate-limit evidence",
3691 (apiErrorStatus) =>
3692 Effect.gen(function* () {
3693 const harness = yield* makeWakeHarness;
3694 yield* harness.runtime.startTurn(
3695 makeClaudeTestTurnInput({
3696 threadId: harness.threadId,
3697 providerThread: harness.providerThread,
3698 now: yield* DateTime.now,
3699 attemptId: RunAttemptId.make(`attempt-status-${apiErrorStatus}`),
3700 text: "Continue.",
3701 attachments: [],
3702 }),
3703 );
3704 yield* Queue.offer(
3705 harness.sdkMessages,
3706 makeAssistantErrorFrame({
3707 uuid: "00000000-0000-4000-8000-000000000650",
3708 error: "rate_limit",
3709 }),
3710 );
3711 yield* Queue.offer(
3712 harness.sdkMessages,
3713 makeResultFrame({
3714 uuid: "00000000-0000-4000-8000-000000000651",
3715 result: "API Error",
3716 terminalReason: "api_error",
3717 isError: true,
3718 apiErrorStatus,
3719 }),
3720 );
3721 const terminal = yield* Queue.take(harness.terminalReceipts);
3722 assert.equal(terminal.status, "failed");
3723 if (terminal.status !== "failed") return;
3724 assert.equal(
3725 terminal.failure.class,
3726 apiErrorStatus === 429 ? "usage_limit" : "provider_error",
3727 );
3728 if (apiErrorStatus !== 429)
3729 assert.notInclude(terminal.failure.message.toLowerCase(), "usage limit");
3730 }).pipe(
3731 Effect.provide(
3732 Layer.mergeAll(
3733 IdAllocator.layer,
3734 McpProviderSessions.layer,
3735 ScriptProgress.layerUnavailable,
3736 NodeServices.layer,
3737 ),
3738 ),
3739 ),
3740 );
3741
3742 it.effect("keeps the measured context when a usage limit ends the turn", () =>
3743 Effect.gen(function* () {
3744 const harness = yield* makeWakeHarness;
3745 yield* harness.runtime.startTurn(
3746 makeClaudeTestTurnInput({
3747 threadId: harness.threadId,
3748 providerThread: harness.providerThread,
3749 now: yield* DateTime.now,
3750 attemptId: RunAttemptId.make("attempt-limit-context"),
3751 text: "Continue.",
3752 attachments: [],
3753 }),
3754 );
3755 yield* Queue.offer(
3756 harness.sdkMessages,
3757 makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000660", text: "Working." }),
3758 );
3759 // The CLI answers a 429 with a locally generated frame that reports no usage.
3760 yield* Queue.offer(
3761 harness.sdkMessages,
3762 claudeSdkFrame({
3763 type: "assistant",
3764 message: {
3765 model: "<synthetic>",
3766 id: "00000000-0000-4000-8000-000000000661",
3767 type: "message",
3768 role: "assistant",
3769 content: [{ type: "text", text: "You've hit your session limit" }],
3770 stop_reason: "stop_sequence",
3771 stop_sequence: "",
3772 usage: {
3773 input_tokens: 0,
3774 output_tokens: 0,
3775 cache_creation_input_tokens: 0,
3776 cache_read_input_tokens: 0,
3777 },
3778 },
3779 parent_tool_use_id: null,
3780 error: "rate_limit",
3781 uuid: "00000000-0000-4000-8000-000000000661",
3782 session_id: WAKE_NATIVE_SESSION,
3783 }),
3784 );
3785 yield* Queue.offer(
3786 harness.sdkMessages,
3787 makeResultFrame({
3788 uuid: "00000000-0000-4000-8000-000000000662",
3789 result: "You've hit your session limit",
3790 terminalReason: "api_error",
3791 isError: true,
3792 apiErrorStatus: 429,
3793 }),
3794 );
3795 const terminal = yield* Queue.take(harness.terminalReceipts);
3796 assert.equal(terminal.status, "failed");
3797 const reportedContext = harness.events.flatMap((event) =>
3798 event.type === "provider_turn.updated" && event.providerTurn.tokenUsage
3799 ? [event.providerTurn.tokenUsage.usedTokens]
3800 : [],
3801 );
3802 assert.deepEqual(reportedContext, [2]);
3803 }).pipe(
3804 Effect.provide(
3805 Layer.mergeAll(
3806 IdAllocator.layer,
3807 McpProviderSessions.layer,
3808 ScriptProgress.layerUnavailable,
3809 NodeServices.layer,
3810 ),
3811 ),
3812 ),
3813 );
3814
3815 it.effect("surfaces a Claude safety model fallback without failing the turn", () =>
3816 Effect.gen(function* () {
3817 const harness = yield* makeWakeHarness;
3818 const now = yield* DateTime.now;
3819 yield* harness.runtime.startTurn(
3820 makeClaudeTestTurnInput({
3821 threadId: harness.threadId,
3822 providerThread: harness.providerThread,
3823 now,
3824 attemptId: RunAttemptId.make("claude-safety-fallback-attempt"),
3825 text: "Continue the audit",
3826 attachments: [],
3827 }),
3828 );
3829 const notice = "Safeguards flagged this message. Switched to Opus 4.8.";
3830 const uuid = "00000000-0000-4000-8000-000000000301";
3831 yield* Queue.offer(
3832 harness.sdkMessages,
3833 claudeSdkFrame({
3834 type: "system",
3835 subtype: "model_refusal_fallback",
3836 trigger: "refusal",
3837 direction: "retry",
3838 original_model: "claude-fable-5",
3839 fallback_model: "claude-opus-4-8",
3840 request_id: "request-safety-fallback",
3841 api_refusal_category: "cyber",
3842 api_refusal_explanation: null,
3843 content: notice,
3844 session_id: WAKE_NATIVE_SESSION,
3845 uuid,
3846 }),
3847 );
3848 yield* Queue.offer(
3849 harness.sdkMessages,
3850 makeResultFrame({
3851 uuid: "00000000-0000-4000-8000-000000000302",
3852 result: "Audit complete.",
3853 }),
3854 );
3855 yield* Queue.take(harness.terminalReceipts);
3856 const notices = harness.events.flatMap((event) =>
3857 event.type === "turn_item.updated" && event.turnItem.type === "system_notice"
3858 ? [event.turnItem]
3859 : [],
3860 );
3861 assert.lengthOf(notices, 1);
3862 assert.equal(notices[0]?.message, notice);
3863 assert.equal(notices[0]?.status, "completed");
3864 assert.equal(notices[0]?.nativeItemRef?.nativeId, uuid);
3865 assert.equal(harness.terminalEvents()[0]?.status, "completed");
3866 assert.isFalse(
3867 harness.events.some(
3868 (event) => event.type === "turn_item.updated" && event.turnItem.type === "error",
3869 ),
3870 );
3871 }).pipe(
3872 Effect.scoped,
3873 Effect.provide(
3874 Layer.mergeAll(
3875 NodeServices.layer,
3876 IdAllocator.layer,
3877 Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable),
3878 ),
3879 ),
3880 ),
3881 );
3882
3883 it.effect(
3884 "runs native compaction and keeps its context watermark separate from billed usage",
3885 () =>
3886 Effect.gen(function* () {
3887 const harness = yield* makeWakeHarness;
3888 const now = yield* DateTime.now;
3889 const compact = harness.runtime.compactThread;
3890 assert.isDefined(compact);
3891 if (compact === undefined) return;
3892 yield* compact(
3893 makeClaudeTestTurnInput({
3894 threadId: harness.threadId,
3895 providerThread: harness.providerThread,
3896 now,
3897 attemptId: RunAttemptId.make("claude-native-compact-attempt"),
3898 text: " /COMPACT ",
3899 attachments: [],
3900 }),
3901 );
3902 yield* Queue.offer(
3903 harness.sdkMessages,
3904 claudeSdkFrame({
3905 type: "system",
3906 subtype: "compact_boundary",
3907 compact_metadata: { trigger: "manual", pre_tokens: 1500, post_tokens: 400 },
3908 uuid: "00000000-0000-4000-8000-000000000201",
3909 session_id: WAKE_NATIVE_SESSION,
3910 }),
3911 );
3912 yield* Queue.offer(
3913 harness.sdkMessages,
3914 makeResultFrame({
3915 uuid: "00000000-0000-4000-8000-000000000202",
3916 result: "Compacted conversation.",
3917 }),
3918 );
3919 yield* Queue.take(harness.terminalReceipts);
3920
3921 assert.deepEqual(harness.offeredMessages[0]?.message.content, "/compact");
3922 const compaction = harness.events.find(
3923 (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction",
3924 );
3925 assert.isDefined(compaction);
3926 if (compaction?.type === "turn_item.updated" && compaction.turnItem.type === "compaction") {
3927 assert.equal(compaction.turnItem.beforeTokenCount, 1500);
3928 assert.equal(compaction.turnItem.afterTokenCount, 400);
3929 assert.equal(compaction.turnItem.status, "completed");
3930 }
3931 const watermark = harness.events.find(
3932 (event) =>
3933 event.type === "provider_turn.updated" &&
3934 event.providerTurn.tokenUsage?.usedTokens === 400,
3935 );
3936 assert.isDefined(watermark);
3937 const completed = harness.events.findLast(
3938 (event) => event.type === "provider_turn.updated",
3939 );
3940 assert.equal(completed?.type, "provider_turn.updated");
3941 if (completed?.type === "provider_turn.updated") {
3942 assert.deepEqual(completed.providerTurn.turnTokenUsage, {
3943 usageScope: "main_agent",
3944 usageStatus: "complete",
3945 hasSubagents: false,
3946 inputTokens: 1,
3947 outputTokens: 1,
3948 cachedInputTokens: 0,
3949 cacheCreationTokens: 0,
3950 });
3951 }
3952 }).pipe(
3953 Effect.scoped,
3954 Effect.provide(
3955 Layer.mergeAll(
3956 NodeServices.layer,
3957 IdAllocator.layer,
3958 Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable),
3959 ),
3960 ),
3961 ),
3962 );
3963
3964 it.effect("titles Claude reads, searches, and skills on tool completion", () =>
3965 Effect.gen(function* () {
3966 const harness = yield* makeWakeHarness;
3967 const now = yield* DateTime.now;
3968 yield* harness.runtime.startTurn(
3969 makeClaudeTestTurnInput({
3970 threadId: harness.threadId,
3971 providerThread: harness.providerThread,
3972 now,
3973 attemptId: RunAttemptId.make("attempt-read-images"),
3974 text: "Read the files",
3975 attachments: [],
3976 }),
3977 );
3978 const tools = [
3979 { id: "image", name: "Read", input: { file_path: " /workspace/reference.png " } },
3980 { id: "text", name: "Read", input: { file_path: "/workspace/README.md" } },
3981 { id: "search", name: "Grep", input: { pattern: "TODO", path: "/workspace/src" } },
3982 { id: "skill", name: "Skill", input: { skill: "full-send" } },
3983 {
3984 id: "write",
3985 name: "Write",
3986 input: { file_path: "/workspace/output.png", content: "text" },
3987 },
3988 ];
3989 yield* Queue.offer(
3990 harness.sdkMessages,
3991 claudeSdkFrame({
3992 type: "assistant",
3993 uuid: "00000000-0000-4000-8000-000000000601",
3994 session_id: WAKE_NATIVE_SESSION,
3995 parent_tool_use_id: null,
3996 message: {
3997 id: "msg_image_reads",
3998 model: "claude-sonnet-4-6",
3999 type: "message",
4000 role: "assistant",
4001 content: tools.map((tool) => ({ type: "tool_use", ...tool })),
4002 stop_reason: "tool_use",
4003 stop_sequence: null,
4004 usage: {
4005 input_tokens: 1,
4006 output_tokens: 1,
4007 cache_creation_input_tokens: 0,
4008 cache_read_input_tokens: 0,
4009 },
4010 },
4011 }),
4012 );
4013 yield* Queue.offer(
4014 harness.sdkMessages,
4015 claudeSdkFrame({
4016 type: "user",
4017 uuid: "00000000-0000-4000-8000-000000000602",
4018 session_id: WAKE_NATIVE_SESSION,
4019 parent_tool_use_id: null,
4020 message: {
4021 role: "user",
4022 content: tools.map((tool) => ({
4023 type: "tool_result",
4024 tool_use_id: tool.id,
4025 content: "ok",
4026 })),
4027 },
4028 }),
4029 );
4030 yield* Queue.offer(
4031 harness.sdkMessages,
4032 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000603", result: "Read files" }),
4033 );
4034 yield* Queue.take(harness.terminalReceipts);
4035 const items = harness.events.flatMap((event) =>
4036 event.type === "turn_item.updated" && event.turnItem.status === "completed"
4037 ? [event.turnItem]
4038 : [],
4039 );
4040 const image = items.find((item) => item.nativeItemRef?.nativeId === "image");
4041 assert.equal(image?.type, "dynamic_tool");
4042 if (image?.type === "dynamic_tool")
4043 assert.equal(image.viewedImagePath, "/workspace/reference.png");
4044 assert.equal(image?.title, "Read /workspace/reference.png");
4045 assert.equal(
4046 items.find((item) => item.nativeItemRef?.nativeId === "text")?.title,
4047 "Read /workspace/README.md",
4048 );
4049 assert.equal(
4050 items.find((item) => item.nativeItemRef?.nativeId === "search")?.title,
4051 "Searched TODO in src",
4052 );
4053 assert.equal(
4054 items.find((item) => item.nativeItemRef?.nativeId === "skill")?.title,
4055 "Skill: full-send",
4056 );
4057 for (const item of items.filter((item) => item.nativeItemRef?.nativeId !== "image"))
4058 assert.notProperty(item, "viewedImagePath");
4059 }).pipe(
4060 Effect.scoped,
4061 Effect.provide(
4062 Layer.mergeAll(
4063 NodeServices.layer,
4064 IdAllocator.layer,
4065 Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable),
4066 ),
4067 ),
4068 ),
4069 );
4070
4071 it.effect("preserves typed Claude plans and todos through generic tool completion", () =>
4072 Effect.scoped(
4073 Effect.gen(function* () {
4074 const harness = yield* makeWakeHarness;
4075 const now = yield* DateTime.now;
4076 const assistantTools = (
4077 uuid: string,
4078 tools: ReadonlyArray<Record<string, unknown>>,
4079 parentToolUseId: string | null = null,
4080 ) =>
4081 claudeSdkFrame({
4082 type: "assistant",
4083 message: {
4084 model: "claude-sonnet-4-6",
4085 id: `msg_${uuid}`,
4086 type: "message",
4087 role: "assistant",
4088 content: tools.map((tool) => ({ type: "tool_use", ...tool })),
4089 stop_reason: "tool_use",
4090 stop_sequence: null,
4091 usage: {
4092 input_tokens: 1,
4093 output_tokens: 1,
4094 cache_creation_input_tokens: 0,
4095 cache_read_input_tokens: 0,
4096 },
4097 },
4098 parent_tool_use_id: parentToolUseId,
4099 uuid,
4100 session_id: WAKE_NATIVE_SESSION,
4101 });
4102 const toolResults = (uuid: string, toolUseIds: ReadonlyArray<string>) =>
4103 claudeSdkFrame({
4104 type: "user",
4105 message: {
4106 role: "user",
4107 content: toolUseIds.map((toolUseId) => ({
4108 type: "tool_result",
4109 tool_use_id: toolUseId,
4110 content: "ok",
4111 })),
4112 },
4113 parent_tool_use_id: null,
4114 uuid,
4115 session_id: WAKE_NATIVE_SESSION,
4116 });
4117
4118 yield* harness.runtime.startTurn(
4119 makeClaudeTestTurnInput({
4120 threadId: harness.threadId,
4121 providerThread: harness.providerThread,
4122 now,
4123 attemptId: RunAttemptId.make("attempt-claude-plan-lifecycle-1"),
4124 text: "Plan the work.",
4125 attachments: [],
4126 }),
4127 );
4128 yield* Queue.offer(
4129 harness.sdkMessages,
4130 assistantTools("00000000-0000-4000-8000-000000000501", [
4131 {
4132 id: "tool-todo-1",
4133 name: "TodoWrite",
4134 input: { todos: [{ content: "Inspect", status: "in_progress" }] },
4135 },
4136 ]),
4137 );
4138 yield* Queue.offer(
4139 harness.sdkMessages,
4140 assistantTools("00000000-0000-4000-8000-000000000501", [
4141 {
4142 id: "tool-todo-1",
4143 name: "TodoWrite",
4144 input: { todos: [{ content: "Inspect", status: "in_progress" }] },
4145 },
4146 ]),
4147 );
4148 yield* Queue.offer(
4149 harness.sdkMessages,
4150 toolResults("00000000-0000-4000-8000-000000000502", ["tool-todo-1"]),
4151 );
4152 // Claude entered plan mode on its own (EnterPlanMode).
4153 yield* Queue.offer(
4154 harness.sdkMessages,
4155 claudeSdkFrame({
4156 type: "system",
4157 subtype: "status",
4158 status: null,
4159 permissionMode: "plan",
4160 uuid: "00000000-0000-4000-8000-000000000508",
4161 session_id: WAKE_NATIVE_SESSION,
4162 }),
4163 );
4164 yield* Queue.offer(
4165 harness.sdkMessages,
4166 makeResultFrame({
4167 uuid: "00000000-0000-4000-8000-000000000503",
4168 result: "Todo recorded.",
4169 }),
4170 );
4171 yield* Queue.take(harness.terminalReceipts);
4172
4173 yield* harness.runtime.startTurn(
4174 makeClaudeTestTurnInput({
4175 threadId: harness.threadId,
4176 providerThread: harness.providerThread,
4177 now,
4178 attemptId: RunAttemptId.make("attempt-claude-plan-lifecycle-2"),
4179 providerTurnOrdinal: 2,
4180 text: "Finish the plan.",
4181 attachments: [],
4182 }),
4183 );
4184 const canUseTool = harness.getOpenedOptions()?.canUseTool;
4185 assert.isFunction(canUseTool);
4186 const planMarkdown = "# Ready to implement\n\n1. Ship it.";
4187 yield* Effect.promise(() =>
4188 canUseTool!(
4189 "ExitPlanMode",
4190 { plan: planMarkdown },
4191 {
4192 signal: new AbortController().signal,
4193 toolUseID: "tool-exit-plan-1",
4194 requestId: "request-exit-plan-1",
4195 },
4196 ),
4197 );
4198 yield* Queue.offer(
4199 harness.sdkMessages,
4200 assistantTools("00000000-0000-4000-8000-000000000504", [
4201 {
4202 id: "tool-todo-2",
4203 name: "TodoWrite",
4204 input: { todos: [{ content: "Inspect", status: "completed" }] },
4205 },
4206 { id: "tool-exit-plan-1", name: "ExitPlanMode", input: { plan: planMarkdown } },
4207 ]),
4208 );
4209 yield* Queue.offer(
4210 harness.sdkMessages,
4211 assistantTools(
4212 "00000000-0000-4000-8000-000000000507",
4213 [
4214 {
4215 id: "tool-subagent-todo",
4216 name: "TodoWrite",
4217 input: { todos: [{ content: "Child-only work", status: "in_progress" }] },
4218 },
4219 ],
4220 "tool-parent-agent",
4221 ),
4222 );
4223 yield* Queue.offer(
4224 harness.sdkMessages,
4225 toolResults("00000000-0000-4000-8000-000000000505", ["tool-todo-2", "tool-exit-plan-1"]),
4226 );
4227 yield* Queue.offer(
4228 harness.sdkMessages,
4229 makeResultFrame({
4230 uuid: "00000000-0000-4000-8000-000000000506",
4231 result: "Plan captured.",
4232 }),
4233 );
4234 yield* Queue.take(harness.terminalReceipts);
4235
4236 const items = new Map(
4237 harness.events.flatMap((event) =>
4238 event.type === "turn_item.updated" ? [[String(event.turnItem.id), event.turnItem]] : [],
4239 ),
4240 );
4241 const plans = new Map(
4242 harness.events.flatMap((event) =>
4243 event.type === "plan.updated" ? [[String(event.plan.id), event.plan]] : [],
4244 ),
4245 );
4246 const todoItems = [...items.values()].filter((item) => item.type === "todo_list");
4247 const proposedItems = [...items.values()].filter((item) => item.type === "proposed_plan");
4248 assert.lengthOf(todoItems, 2);
4249 assert.lengthOf(proposedItems, 1);
4250 assert.equal(
4251 proposedItems[0]?.type === "proposed_plan" && proposedItems[0].markdown,
4252 planMarkdown,
4253 );
4254 assert.isTrue(
4255 [...items.values()].some(
4256 (item) =>
4257 item.type === "dynamic_tool" && item.nativeItemRef?.nativeId === "tool-todo-2",
4258 ),
4259 );
4260 assert.isTrue(
4261 [...items.values()].some(
4262 (item) =>
4263 item.type === "dynamic_tool" && item.nativeItemRef?.nativeId === "tool-exit-plan-1",
4264 ),
4265 );
4266 assert.deepEqual(
4267 [...plans.values()]
4268 .filter((plan) => plan.kind === "todo_list")
4269 .map((plan) => plan.status),
4270 ["superseded", "completed"],
4271 );
4272 const proposedPlan = [...plans.values()].find((plan) => plan.kind === "proposed_plan");
4273 assert.equal(proposedPlan?.status, "active");
4274 // The second prompt reuses the live process, which is still in the
4275 // plan mode Claude entered, so it is put back in the thread's mode.
4276 assert.deepEqual(harness.permissionModeChanges, ["bypassPermissions"]);
4277 }).pipe(
4278 Effect.provide(
4279 Layer.mergeAll(
4280 IdAllocator.layer,
4281 McpProviderSessions.layer,
4282 ScriptProgress.layerUnavailable,
4283 NodeServices.layer,
4284 ),
4285 ),
4286 ),
4287 ),
4288 );
4289
4290 type TaskToolCall =
4291 | { name: "TaskCreate"; input: TaskCreateInput; output: TaskCreateOutput }
4292 | { name: "TaskUpdate"; input: TaskUpdateInput; output: TaskUpdateOutput }
4293 | { name: "TaskList"; input: Record<string, never>; output: TaskListOutput };
4294
4295 function taskToolFrames(
4296 tool: TaskToolCall,
4297 nextUuid: () => string,
4298 options?: { parentToolUseId?: string; isError?: boolean; extraToolResult?: boolean },
4299 ): ReadonlyArray<SDKMessage> {
4300 const uuid = nextUuid();
4301 const toolUseId = `tool-${uuid}`;
4302 const parentToolUseId = options?.parentToolUseId ?? null;
4303 return [
4304 claudeSdkFrame({
4305 type: "assistant",
4306 message: {
4307 id: `msg_${uuid}`,
4308 model: "claude-sonnet-4-6",
4309 type: "message",
4310 role: "assistant",
4311 content: [{ type: "tool_use", id: toolUseId, name: tool.name, input: tool.input }],
4312 stop_reason: "tool_use",
4313 stop_sequence: null,
4314 usage: {
4315 input_tokens: 1,
4316 output_tokens: 1,
4317 cache_creation_input_tokens: 0,
4318 cache_read_input_tokens: 0,
4319 },
4320 },
4321 parent_tool_use_id: parentToolUseId,
4322 uuid,
4323 session_id: WAKE_NATIVE_SESSION,
4324 }),
4325 claudeSdkFrame({
4326 type: "user",
4327 message: {
4328 role: "user",
4329 content: [
4330 {
4331 type: "tool_result",
4332 tool_use_id: toolUseId,
4333 content: "Task tool finished.",
4334 is_error: options?.isError ?? false,
4335 },
4336 ...(options?.extraToolResult
4337 ? [{ type: "tool_result", tool_use_id: "unrelated-tool", content: "ok" }]
4338 : []),
4339 ],
4340 },
4341 tool_use_result: tool.output,
4342 parent_tool_use_id: parentToolUseId,
4343 uuid: nextUuid(),
4344 session_id: WAKE_NATIVE_SESSION,
4345 }),
4346 ];
4347 }
4348
4349 const makeTaskToolHarness = Effect.gen(function* () {
4350 const harness = yield* makeWakeHarness;
4351 let sequence = 600;
4352 let turn = 0;
4353 const nextUuid = () => `00000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}`;
4354 const startTurn = Effect.fnUntraced(function* () {
4355 turn++;
4356 yield* harness.runtime.startTurn(
4357 makeClaudeTestTurnInput({
4358 threadId: harness.threadId,
4359 providerThread: harness.providerThread,
4360 now: yield* DateTime.now,
4361 attemptId: RunAttemptId.make(`attempt-claude-task-tools-${turn}`),
4362 providerTurnOrdinal: turn,
4363 text: "Update the task list.",
4364 attachments: [],
4365 }),
4366 );
4367 });
4368 const call = Effect.fnUntraced(function* (
4369 tool: TaskToolCall,
4370 options?: { parentToolUseId?: string; isError?: boolean; extraToolResult?: boolean },
4371 ) {
4372 for (const frame of taskToolFrames(tool, nextUuid, options)) {
4373 yield* harness.offerAndWait(frame);
4374 }
4375 });
4376 const finishTurn = Effect.fnUntraced(function* () {
4377 yield* harness.offerAndWait(
4378 makeResultFrame({ uuid: nextUuid(), result: "Task list updated." }),
4379 );
4380 yield* Queue.take(harness.terminalReceipts);
4381 });
4382 const plans = () =>
4383 harness.events.flatMap((event) =>
4384 event.type === "plan.updated" && event.plan.kind === "todo_list" ? [event.plan] : [],
4385 );
4386 return { startTurn, call, finishTurn, plans };
4387 });
4388
4389 const createTask = (id: string, subject: string): TaskToolCall => ({
4390 name: "TaskCreate",
4391 input: { subject: `Requested ${subject}`, description: `Work on ${subject}.` },
4392 output: { task: { id, subject } },
4393 });
4394 const updateTask = (input: TaskUpdateInput, success = true): TaskToolCall => ({
4395 name: "TaskUpdate",
4396 input,
4397 output: { success, taskId: input.taskId, updatedFields: ["status"] },
4398 });
4399
4400 it.effect("persists a todo_list from inline Claude task-tool frames", () =>
4401 Effect.gen(function* () {
4402 let sequence = 700;
4403 const nextUuid = () => `00000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}`;
4404 const prompt = "Track a synthetic task.";
4405 const transcript = yield* ClaudeOrchestratorReplayHarness.decodeTranscript({
4406 provider: "claudeAgent",
4407 protocol: "claude-agent-sdk.query",
4408 version: "1",
4409 scenario: "claude_task_tools_inline",
4410 metadata: { nativeSessionId: WAKE_NATIVE_SESSION },
4411 entries: [
4412 {
4413 type: "expect_outbound",
4414 frame: {
4415 type: "query.open",
4416 options: {
4417 model: "claude-sonnet-4-6",
4418 tools: { type: "preset", preset: "claude_code" },
4419 permissionMode: "bypassPermissions",
4420 allowDangerouslySkipPermissions: true,
4421 settings: { showThinkingSummaries: true },
4422 sessionId: WAKE_NATIVE_SESSION,
4423 },
4424 },
4425 },
4426 {
4427 type: "expect_outbound",
4428 frame: {
4429 type: "prompt.offer",
4430 message: {
4431 type: "user",
4432 message: { role: "user", content: prompt },
4433 parent_tool_use_id: null,
4434 },
4435 },
4436 },
4437 ...[
4438 ...taskToolFrames(createTask("1", "Inspect"), nextUuid),
4439 ...taskToolFrames(updateTask({ taskId: "1", status: "in_progress" }), nextUuid),
4440 makeResultFrame({ uuid: nextUuid(), result: "Task recorded." }),
4441 ].map((frame) => ({ type: "emit_inbound" as const, frame })),
4442 ],
4443 } satisfies ProviderReplayTranscript);
4444 const materialized = yield* materializeFixtureInput({
4445 scenario: transcript.scenario,
4446 fixtureInput: { steps: [{ type: "message", text: prompt }] },
4447 driver: ProviderDriverKind.make("claudeAgent"),
4448 modelSelection: CLAUDE_MODEL_SELECTION,
4449 }).pipe(Effect.provide(IdAllocator.layer), provideDeterministicTestRuntime);
4450 const scenario = { name: transcript.scenario, transcript, ...materialized };
4451 yield* Effect.gen(function* () {
4452 const result = yield* runOrchestratorV2Scenario(scenario);
4453 assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["completed"] });
4454 const projection = projectionFor(result, transcript.scenario);
4455 assertSemanticProjectionIntegrity(projection);
4456 const plans = projection.plans.filter((plan) => plan.kind === "todo_list");
4457 assert.lengthOf(plans, 1);
4458 assert.deepEqual(
4459 plans[0]?.steps.map(({ id, text, status }) => ({ id, text, status })),
4460 [{ id: "task-1", text: "Inspect", status: "running" }],
4461 );
4462 assert.lengthOf(
4463 projection.turnItems.filter((item) => item.type === "todo_list"),
4464 1,
4465 );
4466 }).pipe(
4467 Effect.provide(layerProviderReplay(scenario, ClaudeOrchestratorReplayHarness)),
4468 provideDeterministicTestRuntime,
4469 Effect.scoped,
4470 );
4471 }),
4472 );
4473
4474 it.effect(
4475 "projects TaskCreate and TaskUpdate into one plan per turn and supersedes it later",
4476 () =>
4477 Effect.gen(function* () {
4478 const harness = yield* makeTaskToolHarness;
4479 yield* harness.startTurn();
4480 yield* harness.call(createTask("1", "Inspect"));
4481 yield* harness.call(createTask("2", "Implement"));
4482 yield* harness.call(updateTask({ taskId: "1", status: "in_progress" }));
4483 yield* harness.finishTurn();
4484
4485 const firstTurnPlans = harness.plans();
4486 assert.lengthOf(firstTurnPlans, 3);
4487 assert.equal(new Set(firstTurnPlans.map((plan) => plan.id)).size, 1);
4488 assert.deepEqual(firstTurnPlans.at(-1)?.steps, [
4489 { id: "task-1", text: "Inspect", status: "running" },
4490 { id: "task-2", text: "Implement", status: "pending" },
4491 ]);
4492 const firstPlan = firstTurnPlans.at(-1)!;
4493
4494 yield* harness.startTurn();
4495 yield* harness.call(updateTask({ taskId: "1", status: "completed", subject: "Inspected" }));
4496 yield* harness.finishTurn();
4497
4498 const laterPlans = harness.plans().slice(firstTurnPlans.length);
4499 assert.lengthOf(laterPlans, 2);
4500 assert.equal(laterPlans[0]?.id, firstPlan.id);
4501 assert.equal(laterPlans[0]?.status, "superseded");
4502 const newPlan = laterPlans[1]!;
4503 assert.notEqual(newPlan.id, firstPlan.id);
4504 assert.notEqual(newPlan.runId, firstPlan.runId);
4505 assert.deepEqual(newPlan.steps, [
4506 { id: "task-1", text: "Inspected", status: "completed" },
4507 { id: "task-2", text: "Implement", status: "pending" },
4508 ]);
4509 }).pipe(
4510 Effect.scoped,
4511 Effect.provide(
4512 Layer.mergeAll(
4513 IdAllocator.layer,
4514 McpProviderSessions.layer,
4515 ScriptProgress.layerUnavailable,
4516 NodeServices.layer,
4517 ),
4518 ),
4519 ),
4520 );
4521
4522 it.effect("removes deleted tasks without resurrecting them on later updates or turns", () =>
4523 Effect.gen(function* () {
4524 const harness = yield* makeTaskToolHarness;
4525 yield* harness.startTurn();
4526 yield* harness.call(createTask("1", "Discard"));
4527 yield* harness.call(createTask("2", "Keep"));
4528 yield* harness.call(updateTask({ taskId: "1", status: "deleted" }));
4529 yield* harness.call(updateTask({ taskId: "1", status: "pending" }));
4530 yield* harness.finishTurn();
4531 assert.lengthOf(harness.plans(), 3);
4532 assert.deepEqual(harness.plans().at(-1)?.steps, [
4533 { id: "task-2", text: "Keep", status: "pending" },
4534 ]);
4535
4536 yield* harness.startTurn();
4537 yield* harness.call(updateTask({ taskId: "2", status: "in_progress" }));
4538 yield* harness.finishTurn();
4539 assert.deepEqual(harness.plans().at(-1)?.steps, [
4540 { id: "task-2", text: "Keep", status: "running" },
4541 ]);
4542 for (const plan of harness.plans().slice(2)) {
4543 assert.isFalse(plan.steps.some((step) => step.id === "task-1"));
4544 }
4545 }).pipe(
4546 Effect.scoped,
4547 Effect.provide(
4548 Layer.mergeAll(
4549 IdAllocator.layer,
4550 McpProviderSessions.layer,
4551 ScriptProgress.layerUnavailable,
4552 NodeServices.layer,
4553 ),
4554 ),
4555 ),
4556 );
4557
4558 it.effect.each(["success false", "error result", "unknown id"] as const)(
4559 "does not emit a plan update for TaskUpdate with %s",
4560 (reason) =>
4561 Effect.gen(function* () {
4562 const harness = yield* makeTaskToolHarness;
4563 yield* harness.startTurn();
4564 yield* harness.call(createTask("1", "Inspect"));
4565 yield* harness.call(
4566 updateTask(
4567 { taskId: reason === "unknown id" ? "missing" : "1", status: "completed" },
4568 reason !== "success false",
4569 ),
4570 { isError: reason === "error result" },
4571 );
4572 yield* harness.finishTurn();
4573 assert.lengthOf(harness.plans(), 1);
4574 assert.deepEqual(harness.plans()[0]?.steps, [
4575 { id: "task-1", text: "Inspect", status: "pending" },
4576 ]);
4577 }).pipe(
4578 Effect.scoped,
4579 Effect.provide(
4580 Layer.mergeAll(
4581 IdAllocator.layer,
4582 McpProviderSessions.layer,
4583 ScriptProgress.layerUnavailable,
4584 NodeServices.layer,
4585 ),
4586 ),
4587 ),
4588 );
4589
4590 it.effect("replaces task state with TaskList, including an empty list", () =>
4591 Effect.gen(function* () {
4592 const harness = yield* makeTaskToolHarness;
4593 yield* harness.startTurn();
4594 yield* harness.call(createTask("1", "Stale"));
4595 yield* harness.call({
4596 name: "TaskList",
4597 input: {},
4598 output: {
4599 tasks: [
4600 { id: "2", subject: "Queued", status: "pending", blockedBy: [] },
4601 { id: "3", subject: "Working", status: "in_progress", blockedBy: [] },
4602 { id: "4", subject: "Done", status: "completed", blockedBy: [] },
4603 ],
4604 },
4605 });
4606 yield* harness.call(updateTask({ taskId: "1", status: "in_progress" }));
4607 yield* harness.call(updateTask({ taskId: "2", status: "in_progress" }));
4608 yield* harness.call({ name: "TaskList", input: {}, output: { tasks: [] } });
4609 yield* harness.call(updateTask({ taskId: "2", status: "pending" }));
4610 yield* harness.finishTurn();
4611 const plans = harness.plans();
4612 assert.lengthOf(plans, 4);
4613 assert.deepEqual(plans[1]?.steps, [
4614 { id: "task-2", text: "Queued", status: "pending" },
4615 { id: "task-3", text: "Working", status: "running" },
4616 { id: "task-4", text: "Done", status: "completed" },
4617 ]);
4618 assert.deepEqual(plans[2]?.steps, [
4619 { id: "task-2", text: "Queued", status: "running" },
4620 { id: "task-3", text: "Working", status: "running" },
4621 { id: "task-4", text: "Done", status: "completed" },
4622 ]);
4623 assert.deepEqual(plans[3]?.steps, []);
4624 }).pipe(
4625 Effect.scoped,
4626 Effect.provide(
4627 Layer.mergeAll(
4628 IdAllocator.layer,
4629 McpProviderSessions.layer,
4630 ScriptProgress.layerUnavailable,
4631 NodeServices.layer,
4632 ),
4633 ),
4634 ),
4635 );
4636
4637 it.effect("keeps subagent TaskCreate, TaskUpdate and TaskList out of the main plan", () =>
4638 Effect.gen(function* () {
4639 const harness = yield* makeTaskToolHarness;
4640 yield* harness.startTurn();
4641 yield* harness.call(createTask("1", "Main task"));
4642 const child = { parentToolUseId: "tool-parent-agent" };
4643 yield* harness.call(createTask("2", "Child task"), child);
4644 yield* harness.call(updateTask({ taskId: "1", status: "completed" }), child);
4645 yield* harness.call({ name: "TaskList", input: {}, output: { tasks: [] } }, child);
4646 yield* harness.call(updateTask({ taskId: "1", status: "in_progress" }));
4647 yield* harness.finishTurn();
4648 assert.lengthOf(harness.plans(), 2);
4649 assert.deepEqual(harness.plans().at(-1)?.steps, [
4650 { id: "task-1", text: "Main task", status: "running" },
4651 ]);
4652 }).pipe(
4653 Effect.scoped,
4654 Effect.provide(
4655 Layer.mergeAll(
4656 IdAllocator.layer,
4657 McpProviderSessions.layer,
4658 ScriptProgress.layerUnavailable,
4659 NodeServices.layer,
4660 ),
4661 ),
4662 ),
4663 );
4664
4665 it.effect("does not associate structured task output with multiple tool results", () =>
4666 Effect.gen(function* () {
4667 const harness = yield* makeTaskToolHarness;
4668 yield* harness.startTurn();
4669 yield* harness.call(createTask("1", "Ambiguous"), { extraToolResult: true });
4670 yield* harness.finishTurn();
4671 assert.deepEqual(harness.plans(), []);
4672 }).pipe(
4673 Effect.scoped,
4674 Effect.provide(
4675 Layer.mergeAll(
4676 IdAllocator.layer,
4677 McpProviderSessions.layer,
4678 ScriptProgress.layerUnavailable,
4679 NodeServices.layer,
4680 ),
4681 ),
4682 ),
4683 );
4684
4685 it.effect("resolves API retries on resumed assistant activity", () =>
4686 Effect.scoped(
4687 Effect.gen(function* () {
4688 const harness = yield* makeWakeHarness;
4689 const now = yield* DateTime.now;
4690 yield* harness.runtime.startTurn(
4691 makeClaudeTestTurnInput({
4692 threadId: harness.threadId,
4693 providerThread: harness.providerThread,
4694 now,
4695 attemptId: RunAttemptId.make("attempt-claude-api-retry"),
4696 text: "Open github.com.",
4697 attachments: [],
4698 }),
4699 );
4700
4701 yield* Queue.offer(
4702 harness.sdkMessages,
4703 claudeSdkFrame({
4704 type: "system",
4705 subtype: "api_retry",
4706 attempt: 2,
4707 max_retries: 10,
4708 retry_delay_ms: 1_500,
4709 error_status: 529,
4710 error: "overloaded",
4711 uuid: "00000000-0000-4000-8000-000000000201",
4712 session_id: WAKE_NATIVE_SESSION,
4713 }),
4714 );
4715 const retryItems = () =>
4716 harness.events.flatMap((event) =>
4717 event.type === "turn_item.updated" &&
4718 event.turnItem.type === "error" &&
4719 event.turnItem.retry !== undefined
4720 ? [event.turnItem]
4721 : [],
4722 );
4723 yield* awaitUntil(() => retryItems().length === 1, "Claude retry item");
4724 const runningRetry = retryItems()[0];
4725 assert.equal(runningRetry?.status, "running");
4726 assert.equal(runningRetry?.failure.code, "api_error_529");
4727 assert.deepEqual(runningRetry?.retry, {
4728 attempt: 2,
4729 maxAttempts: 10,
4730 retryDelayMs: 1_500,
4731 });
4732
4733 yield* Queue.offer(
4734 harness.sdkMessages,
4735 makeAssistantTextFrame({
4736 uuid: "00000000-0000-4000-8000-000000000202",
4737 text: "Opening GitHub.",
4738 }),
4739 );
4740 yield* awaitUntil(() => retryItems().length === 2, "resolved Claude retry item");
4741 assert.lengthOf(harness.terminalEvents(), 0);
4742 const recoveredRetry = retryItems()[1];
4743 assert.equal(recoveredRetry?.id, runningRetry?.id);
4744 assert.equal(recoveredRetry?.status, "completed");
4745 assert.equal(recoveredRetry?.title, "Provider recovered");
4746
4747 yield* Queue.offer(
4748 harness.sdkMessages,
4749 makeResultFrame({
4750 uuid: "00000000-0000-4000-8000-000000000203",
4751 result: "Opened GitHub.",
4752 }),
4753 );
4754 yield* awaitUntil(() => harness.terminalEvents().length === 1, "recovered Claude turn");
4755 }).pipe(
4756 Effect.provide(
4757 Layer.mergeAll(
4758 IdAllocator.layer,
4759 McpProviderSessions.layer,
4760 ScriptProgress.layerUnavailable,
4761 NodeServices.layer,
4762 ),
4763 ),
4764 ),
4765 ),
4766 );
4767
4768 it.effect("carries exhausted retry progress into the terminal provider error", () =>
4769 Effect.scoped(
4770 Effect.gen(function* () {
4771 const harness = yield* makeWakeHarness;
4772 const now = yield* DateTime.now;
4773 yield* harness.runtime.startTurn(
4774 makeClaudeTestTurnInput({
4775 threadId: harness.threadId,
4776 providerThread: harness.providerThread,
4777 now,
4778 attemptId: RunAttemptId.make("attempt-claude-api-retry-exhausted"),
4779 text: "Open github.com.",
4780 attachments: [],
4781 }),
4782 );
4783 yield* Queue.offer(
4784 harness.sdkMessages,
4785 claudeSdkFrame({
4786 type: "system",
4787 subtype: "api_retry",
4788 attempt: 10,
4789 max_retries: 10,
4790 retry_delay_ms: 38_010,
4791 error_status: 529,
4792 error: "overloaded",
4793 uuid: "00000000-0000-4000-8000-000000000203",
4794 session_id: WAKE_NATIVE_SESSION,
4795 }),
4796 );
4797 yield* Queue.offer(
4798 harness.sdkMessages,
4799 makeResultFrame({
4800 uuid: "00000000-0000-4000-8000-000000000204",
4801 result: "Claude is temporarily overloaded.",
4802 isError: true,
4803 apiErrorStatus: 529,
4804 }),
4805 );
4806 yield* awaitUntil(() => harness.terminalEvents().length === 1, "failed Claude turn");
4807
4808 const terminal = harness.terminalEvents()[0];
4809 assert.equal(terminal?.status, "failed");
4810 if (terminal?.status !== "failed") return;
4811 assert.deepEqual(terminal.retry, {
4812 attempt: 10,
4813 maxAttempts: 10,
4814 retryDelayMs: 38_010,
4815 });
4816 assert.isDefined(terminal.retryStartedAt);
4817 assert.equal(terminal.failure.code, "api_error_529");
4818 }).pipe(
4819 Effect.provide(
4820 Layer.mergeAll(
4821 IdAllocator.layer,
4822 McpProviderSessions.layer,
4823 ScriptProgress.layerUnavailable,
4824 NodeServices.layer,
4825 ),
4826 ),
4827 ),
4828 ),
4829 );
4830
4831 it.effect.each([
4832 "api_error",
4833 "malformed_tool_use_exhausted",
4834 "budget_exhausted",
4835 "structured_output_retry_exhausted",
4836 "tool_deferred_unavailable",
4837 "turn_setup_failed",
4838 "blocking_limit",
4839 "rapid_refill_breaker",
4840 "prompt_too_long",
4841 "image_error",
4842 "model_error",
4843 "overloaded_status",
4844 ] as const)("fails a success-shaped Claude result with %s", (terminalReason) =>
4845 Effect.scoped(
4846 Effect.gen(function* () {
4847 const harness = yield* makeWakeHarness;
4848 const now = yield* DateTime.now;
4849 yield* harness.runtime.startTurn(
4850 makeClaudeTestTurnInput({
4851 threadId: harness.threadId,
4852 providerThread: harness.providerThread,
4853 now,
4854 attemptId: RunAttemptId.make("attempt-structured-terminal-failure"),
4855 text: "Complete the task.",
4856 attachments: [],
4857 }),
4858 );
4859 yield* Queue.offer(
4860 harness.sdkMessages,
4861 makeResultFrame({
4862 uuid: "00000000-0000-4000-8000-000000000205",
4863 result: "Provider failure details.",
4864 isError: false,
4865 ...(terminalReason === "overloaded_status"
4866 ? { apiErrorStatus: 529 }
4867 : { terminalReason }),
4868 }),
4869 );
4870 const terminal = yield* Queue.take(harness.terminalReceipts);
4871 assert.equal(terminal.status, "failed");
4872 if (terminal.status !== "failed") return;
4873 assert.isNotEmpty(terminal.failure.message);
4874 assert.equal(
4875 terminal.failure.class,
4876 terminalReason === "blocking_limit" ? "usage_limit" : "provider_error",
4877 );
4878 assert.isFalse(
4879 harness.events.some(
4880 (event) =>
4881 event.type === "message.updated" &&
4882 event.message.text === "Provider failure details.",
4883 ),
4884 );
4885 assert.equal(
4886 terminal.failure.code,
4887 terminalReason === "overloaded_status" ? "api_error_529" : terminalReason,
4888 );
4889 }).pipe(
4890 Effect.provide(
4891 Layer.mergeAll(
4892 IdAllocator.layer,
4893 McpProviderSessions.layer,
4894 ScriptProgress.layerUnavailable,
4895 NodeServices.layer,
4896 ),
4897 ),
4898 ),
4899 ),
4900 );
4901
4902 const providerThreadRosterEvents = (
4903 events: ReadonlyArray<ProviderAdapter.ProviderAdapterV2Event>,
4904 ) =>
4905 events.filter(
4906 (
4907 event,
4908 ): event is Extract<
4909 ProviderAdapter.ProviderAdapterV2Event,
4910 { type: "provider_thread.updated" }
4911 > => event.type === "provider_thread.updated",
4912 );
4913
4914 it.effect(
4915 "uses task_started as an incremental roster fallback and clears on empty snapshot",
4916 () =>
4917 Effect.scoped(
4918 Effect.gen(function* () {
4919 const harness = yield* makeWakeHarness;
4920 const now = yield* DateTime.now;
4921 const emptyRoster = claudeSdkFrame({
4922 type: "system",
4923 subtype: "background_tasks_changed",
4924 tasks: [],
4925 uuid: "00000000-0000-4000-8000-000000000202",
4926 session_id: WAKE_NATIVE_SESSION,
4927 });
4928
4929 yield* harness.runtime.startTurn(
4930 makeClaudeTestTurnInput({
4931 threadId: harness.threadId,
4932 providerThread: harness.providerThread,
4933 now,
4934 attemptId: RunAttemptId.make("attempt-claude-roster-fallback"),
4935 text: "Run the build in the background.",
4936 attachments: [],
4937 }),
4938 );
4939 yield* Queue.offer(harness.sdkMessages, wakeTaskStarted);
4940 yield* Queue.offer(harness.sdkMessages, turnOneResult);
4941 yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal");
4942
4943 const afterStart = providerThreadRosterEvents(harness.events).filter(
4944 (event) => (event.providerThread.pendingBackgroundTasks?.length ?? 0) > 0,
4945 );
4946 assert.isAtLeast(afterStart.length, 1);
4947 assert.equal(
4948 (afterStart.at(-1)?.providerThread.pendingBackgroundTasks ?? [])[0]?.taskId,
4949 WAKE_TASK_ID,
4950 );
4951
4952 yield* Queue.offer(harness.sdkMessages, emptyRoster);
4953 yield* awaitUntil(
4954 () =>
4955 providerThreadRosterEvents(harness.events).some(
4956 (event) =>
4957 event.providerThread.status === "idle" &&
4958 (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0,
4959 ),
4960 "empty roster clear",
4961 );
4962 assert.isFalse(yield* harness.hasPendingBackgroundWork);
4963 }).pipe(
4964 Effect.provide(
4965 Layer.mergeAll(
4966 IdAllocator.layer,
4967 McpProviderSessions.layer,
4968 ScriptProgress.layerUnavailable,
4969 NodeServices.layer,
4970 ),
4971 ),
4972 ),
4973 ),
4974 );
4975
4976 // Frame shapes follow the claude_background_monitor_wake recording: Claude
4977 // runs a Monitor as a local_bash task, linked to its call by tool_use_id.
4978 it.effect("keeps a running Claude monitor typed after many newer monitors end", () =>
4979 Effect.scoped(
4980 Effect.gen(function* () {
4981 const harness = yield* makeWakeHarness;
4982 const now = yield* DateTime.now;
4983 let frameNumber = 0;
4984 const nextUuid = () => `00000000-0000-4000-8000-${String(++frameNumber).padStart(12, "0")}`;
4985 const monitorFrames = (index: number) => {
4986 const taskId = `monitor-task-${index}`;
4987 const toolUseId = `toolu_monitor_${index}`;
4988 const description = `Monitor ${index}`;
4989 return {
4990 taskId,
4991 start: [
4992 claudeSdkFrame({
4993 type: "assistant",
4994 message: {
4995 model: "claude-sonnet-4-6",
4996 id: `msg_monitor_${index}`,
4997 type: "message",
4998 role: "assistant",
4999 content: [
5000 {
5001 type: "tool_use",
5002 id: toolUseId,
5003 name: "Monitor",
5004 input: { description, command: "sleep 8 && echo MONITOR_DONE" },
5005 },
5006 ],
5007 stop_reason: null,
5008 stop_sequence: null,
5009 usage: { input_tokens: 1, output_tokens: 1 },
5010 },
5011 parent_tool_use_id: null,
5012 uuid: nextUuid(),
5013 session_id: WAKE_NATIVE_SESSION,
5014 }),
5015 claudeSdkFrame({
5016 type: "system",
5017 subtype: "task_started",
5018 task_id: taskId,
5019 tool_use_id: toolUseId,
5020 description,
5021 is_backgrounded: true,
5022 task_type: "local_bash",
5023 uuid: nextUuid(),
5024 session_id: WAKE_NATIVE_SESSION,
5025 }),
5026 claudeSdkFrame({
5027 type: "user",
5028 message: {
5029 role: "user",
5030 content: [
5031 {
5032 type: "tool_result",
5033 tool_use_id: toolUseId,
5034 content: `Monitor started (task ${taskId}).`,
5035 },
5036 ],
5037 },
5038 parent_tool_use_id: null,
5039 uuid: nextUuid(),
5040 session_id: WAKE_NATIVE_SESSION,
5041 tool_use_result: { taskId, persistent: false },
5042 }),
5043 ],
5044 end: claudeSdkFrame({
5045 type: "system",
5046 subtype: "task_notification",
5047 task_id: taskId,
5048 tool_use_id: toolUseId,
5049 status: "completed",
5050 output_file: `/tmp/claude-replay/tasks/${taskId}.output`,
5051 summary: `Monitor "${description}" stream ended`,
5052 uuid: nextUuid(),
5053 session_id: WAKE_NATIVE_SESSION,
5054 }),
5055 };
5056 };
5057
5058 yield* harness.runtime.startTurn(
5059 makeClaudeTestTurnInput({
5060 threadId: harness.threadId,
5061 providerThread: harness.providerThread,
5062 now,
5063 attemptId: RunAttemptId.make("attempt-claude-many-monitors"),
5064 text: "Watch the deploy, then re-arm short watches.",
5065 attachments: [],
5066 }),
5067 );
5068 const longRunning = monitorFrames(0);
5069 for (const frame of longRunning.start) {
5070 yield* Queue.offer(harness.sdkMessages, frame);
5071 }
5072 // More newer monitors start and end than any fixed id cap would hold.
5073 for (let index = 1; index <= 65; index++) {
5074 const monitor = monitorFrames(index);
5075 for (const frame of monitor.start) {
5076 yield* Queue.offer(harness.sdkMessages, frame);
5077 }
5078 yield* Queue.offer(harness.sdkMessages, monitor.end);
5079 }
5080 yield* Queue.offer(
5081 harness.sdkMessages,
5082 claudeSdkFrame({
5083 type: "system",
5084 subtype: "background_tasks_changed",
5085 tasks: [
5086 {
5087 task_id: longRunning.taskId,
5088 task_type: "local_bash",
5089 description: "Monitor 0",
5090 },
5091 ],
5092 uuid: nextUuid(),
5093 session_id: WAKE_NATIVE_SESSION,
5094 }),
5095 );
5096 yield* Queue.offer(
5097 harness.sdkMessages,
5098 makeResultFrame({ uuid: nextUuid(), result: "Watching." }),
5099 );
5100 yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal");
5101
5102 const roster = providerThreadRosterEvents(harness.events).at(-1)?.providerThread
5103 .pendingBackgroundTasks;
5104 assert.deepEqual(roster, [
5105 { taskId: longRunning.taskId, kind: "monitor", description: "Monitor 0" },
5106 ]);
5107 }).pipe(
5108 Effect.provide(
5109 Layer.mergeAll(
5110 IdAllocator.layer,
5111 McpProviderSessions.layer,
5112 ScriptProgress.layerUnavailable,
5113 NodeServices.layer,
5114 ),
5115 ),
5116 ),
5117 ),
5118 );
5119
5120 it.effect("stops background work after the turn settled", () =>
5121 Effect.scoped(
5122 Effect.gen(function* () {
5123 let closes = 0;
5124 const harness = yield* makeWakeHarnessWithOptions({
5125 close: (sdkMessages) =>
5126 Effect.sync(() => {
5127 closes++;
5128 }).pipe(Effect.andThen(Queue.shutdown(sdkMessages))),
5129 });
5130 const now = yield* DateTime.now;
5131 const attemptId = RunAttemptId.make("attempt-claude-settled-stop");
5132 yield* harness.runtime.startTurn(
5133 makeClaudeTestTurnInput({
5134 threadId: harness.threadId,
5135 providerThread: harness.providerThread,
5136 now,
5137 attemptId,
5138 text: "Run the build in the background.",
5139 attachments: [],
5140 }),
5141 );
5142 yield* Queue.offer(harness.sdkMessages, wakeTaskStarted);
5143 yield* Queue.offer(harness.sdkMessages, turnOneResult);
5144 yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal");
5145 const settledThread = providerThreadRosterEvents(harness.events).at(-1)?.providerThread;
5146 assert.equal(settledThread?.pendingBackgroundTasks?.[0]?.taskId, WAKE_TASK_ID);
5147 assert.isTrue(yield* harness.hasPendingBackgroundWork);
5148
5149 // The Waiting strip's Stop reaches the adapter as an interrupt of the
5150 // settled turn with requestRuntimeRestart.
5151 yield* harness.runtime.interruptTurn({
5152 providerThread: settledThread ?? harness.providerThread,
5153 providerTurnId: harness.terminalEvents()[0]!.providerTurnId,
5154 requestRuntimeRestart: true,
5155 });
5156
5157 assert.equal(closes, 1, "Stop must close the CLI process that owns the task");
5158 yield* awaitUntil(
5159 () =>
5160 (providerThreadRosterEvents(harness.events).at(-1)?.providerThread
5161 .pendingBackgroundTasks?.length ?? 0) === 0,
5162 "roster clear after Stop",
5163 );
5164 assert.isFalse(yield* harness.hasPendingBackgroundWork);
5165 assert.lengthOf(harness.continuationRequests, 0);
5166 }).pipe(
5167 Effect.provide(
5168 Layer.mergeAll(
5169 IdAllocator.layer,
5170 McpProviderSessions.layer,
5171 ScriptProgress.layerUnavailable,
5172 NodeServices.layer,
5173 ),
5174 ),
5175 ),
5176 ),
5177 );
5178
5179 // The CLI process exits on its own after the turn settled (idle, crash),
5180 // leaving its background task on the roster. Stop must succeed so the
5181 // orchestrator goes on to settle what the thread still shows.
5182 it.effect("a settled Stop with no CLI process left succeeds and clears the roster", () =>
5183 Effect.scoped(
5184 Effect.gen(function* () {
5185 const harness = yield* makeWakeHarnessWithOptions();
5186 const now = yield* DateTime.now;
5187 yield* harness.runtime.startTurn(
5188 makeClaudeTestTurnInput({
5189 threadId: harness.threadId,
5190 providerThread: harness.providerThread,
5191 now,
5192 attemptId: RunAttemptId.make("attempt-claude-settled-stop-no-process"),
5193 text: "Run the build in the background.",
5194 attachments: [],
5195 }),
5196 );
5197 yield* Queue.offer(harness.sdkMessages, wakeTaskStarted);
5198 yield* Queue.offer(harness.sdkMessages, turnOneResult);
5199 yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal");
5200 const settledThread = providerThreadRosterEvents(harness.events).at(-1)?.providerThread;
5201 assert.equal(settledThread?.pendingBackgroundTasks?.[0]?.taskId, WAKE_TASK_ID);
5202
5203 yield* Queue.shutdown(harness.sdkMessages);
5204 let quietYields = 0;
5205 yield* awaitUntil(() => quietYields++ >= 50, "query exit");
5206
5207 yield* harness.runtime.interruptTurn({
5208 providerThread: settledThread ?? harness.providerThread,
5209 providerTurnId: harness.terminalEvents()[0]!.providerTurnId,
5210 requestRuntimeRestart: true,
5211 });
5212 yield* awaitUntil(
5213 () =>
5214 (providerThreadRosterEvents(harness.events).at(-1)?.providerThread
5215 .pendingBackgroundTasks?.length ?? 0) === 0,
5216 "roster clear after Stop",
5217 );
5218 assert.isFalse(yield* harness.hasPendingBackgroundWork);
5219 assert.lengthOf(harness.continuationRequests, 0);
5220 }).pipe(
5221 Effect.provide(
5222 Layer.mergeAll(
5223 IdAllocator.layer,
5224 McpProviderSessions.layer,
5225 ScriptProgress.layerUnavailable,
5226 NodeServices.layer,
5227 ),
5228 ),
5229 ),
5230 ),
5231 );
5232
5233 it.effect("a settled Stop leaves a turn that replaced the closing process alone", () =>
5234 Effect.scoped(
5235 Effect.gen(function* () {
5236 const fileSystem = yield* FileSystem.FileSystem;
5237 const idAllocator = yield* IdAllocator.IdAllocatorV2;
5238 const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({
5239 prefix: "t3-claude-v2-settled-stop-replaced-",
5240 });
5241 const processQueues: Array<Queue.Queue<SDKMessage>> = [];
5242 const firstCloseRequested = yield* Deferred.make<void>();
5243 const events: Array<ProviderAdapter.ProviderAdapterV2Event> = [];
5244 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
5245 [];
5246 const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({
5247 instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
5248 settings: DEFAULT_CLAUDE_SETTINGS,
5249 environment: {},
5250 attachmentsDir,
5251 fileSystem,
5252 path: yield* Path.Path,
5253 crypto: yield* Crypto.Crypto,
5254 idAllocator,
5255 continuationRequests: {
5256 offer: (request) =>
5257 Effect.sync(() => {
5258 continuationRequests.push(request);
5259 }),
5260 },
5261 queryRunner: {
5262 allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION),
5263 open: () =>
5264 Effect.gen(function* () {
5265 const sdkMessages = yield* Queue.unbounded<SDKMessage>();
5266 const isFirstProcess = processQueues.length === 0;
5267 processQueues.push(sdkMessages);
5268 return {
5269 messages: Stream.fromQueue(sdkMessages),
5270 offer: () => Effect.void,
5271 setModel: () => Effect.void,
5272 applyFlagSettings: () => Effect.void,
5273 setPermissionMode: () => Effect.void,
5274 stopTask: () => Effect.void,
5275 interrupt: Effect.void,
5276 // The first CLI process keeps streaming until the test ends
5277 // it, so Stop stays parked waiting for it to exit.
5278 close: isFirstProcess
5279 ? Deferred.succeed(firstCloseRequested, undefined).pipe(Effect.asVoid)
5280 : Queue.shutdown(sdkMessages),
5281 };
5282 }),
5283 forkSession: () => Effect.die("unused forkSession"),
5284 subagentLaunchToolUseId: () => Effect.succeed(null),
5285 assertComplete: Effect.void,
5286 },
5287 });
5288 const threadId = ThreadId.make("thread-claude-settled-stop-replaced");
5289 const runtime = yield* adapter.openSession({
5290 threadId,
5291 providerSessionId: ProviderSessionId.make("provider-session-claude-settled-stop"),
5292 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
5293 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
5294 });
5295 const providerThread = yield* runtime.ensureThread({
5296 threadId,
5297 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
5298 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
5299 });
5300 yield* runtime.events.pipe(
5301 Stream.runForEach((event) =>
5302 Effect.sync(() => {
5303 events.push(event);
5304 }),
5305 ),
5306 Effect.forkScoped,
5307 );
5308 if (runtime.hasPendingBackgroundWork === undefined) {
5309 return yield* Effect.die("Claude adapter runtime must expose hasPendingBackgroundWork.");
5310 }
5311 const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork;
5312 const terminals = () => events.filter((event) => event.type === "turn.terminal");
5313 const now = yield* DateTime.now;
5314
5315 yield* runtime.startTurn(
5316 makeClaudeTestTurnInput({
5317 threadId,
5318 providerThread,
5319 now,
5320 attemptId: RunAttemptId.make("attempt-claude-settled-stop-replaced-a"),
5321 text: "Run the build in the background.",
5322 attachments: [],
5323 }),
5324 );
5325 yield* Queue.offer(processQueues[0]!, wakeTaskStarted);
5326 yield* Queue.offer(processQueues[0]!, turnOneResult);
5327 yield* awaitUntil(() => terminals().length === 1, "first turn terminal");
5328 const settledThread = providerThreadRosterEvents(events).at(-1)?.providerThread;
5329 const settledTurn = terminals()[0];
5330 assert.equal(settledThread?.pendingBackgroundTasks?.[0]?.taskId, WAKE_TASK_ID);
5331
5332 const stop = yield* runtime
5333 .interruptTurn({
5334 providerThread: settledThread ?? providerThread,
5335 providerTurnId: settledTurn!.providerTurnId,
5336 requestRuntimeRestart: true,
5337 })
5338 .pipe(Effect.forkScoped);
5339 yield* Deferred.await(firstCloseRequested);
5340
5341 // While Stop waits for the old CLI to exit, a new turn on another
5342 // model replaces the process and starts its own background task.
5343 yield* runtime.startTurn(
5344 makeClaudeTestTurnInput({
5345 threadId,
5346 providerThread: { ...providerThread, status: "active" },
5347 now,
5348 attemptId: RunAttemptId.make("attempt-claude-settled-stop-replaced-b"),
5349 text: "Start another background build.",
5350 attachments: [],
5351 providerTurnOrdinal: 2,
5352 modelSelection: {
5353 ...CLAUDE_TEST_MODEL_SELECTION,
5354 model: "claude-haiku-4-5-20251001",
5355 },
5356 }),
5357 );
5358 assert.lengthOf(processQueues, 2);
5359 const replacementTaskId = "replacement-task";
5360 yield* Queue.offer(
5361 processQueues[1]!,
5362 claudeSdkFrame({
5363 ...wakeTaskStarted,
5364 task_id: replacementTaskId,
5365 uuid: "00000000-0000-4000-8000-000000000905",
5366 }),
5367 );
5368 yield* awaitUntil(
5369 () =>
5370 providerThreadRosterEvents(events).at(-1)?.providerThread.pendingBackgroundTasks?.[0]
5371 ?.taskId === replacementTaskId,
5372 "replacement roster",
5373 );
5374
5375 yield* Queue.shutdown(processQueues[0]!);
5376 yield* Fiber.join(stop);
5377 assert.isTrue(yield* hasPendingBackgroundWork);
5378 let quietYields = 0;
5379 yield* awaitUntil(() => quietYields++ >= 50, "late roster events");
5380 const latestThread = providerThreadRosterEvents(events).at(-1)?.providerThread;
5381 assert.equal(latestThread?.status, "active");
5382 assert.deepEqual(
5383 latestThread?.pendingBackgroundTasks?.map((task) => task.taskId),
5384 [replacementTaskId],
5385 );
5386
5387 // The replacement's background task still wakes Claude once its
5388 // turn settles.
5389 yield* Queue.offer(
5390 processQueues[1]!,
5391 makeResultFrame({
5392 uuid: "00000000-0000-4000-8000-000000000906",
5393 result: "Started another build.",
5394 }),
5395 );
5396 yield* awaitUntil(() => terminals().length === 2, "replacement turn terminal");
5397 yield* Queue.offer(
5398 processQueues[1]!,
5399 claudeSdkFrame({
5400 ...wakeNotification,
5401 task_id: replacementTaskId,
5402 uuid: "00000000-0000-4000-8000-000000000907",
5403 }),
5404 );
5405 yield* Queue.offer(
5406 processQueues[1]!,
5407 makeAssistantTextFrame({
5408 uuid: "00000000-0000-4000-8000-000000000908",
5409 text: WAKE_ASSISTANT_TEXT,
5410 }),
5411 );
5412 yield* awaitUntil(() => continuationRequests.length === 1, "replacement wake");
5413 }).pipe(
5414 Effect.provide(
5415 Layer.mergeAll(
5416 IdAllocator.layer,
5417 McpProviderSessions.layer,
5418 ScriptProgress.layerUnavailable,
5419 NodeServices.layer,
5420 ),
5421 ),
5422 ),
5423 ),
5424 );
5425
5426 it.effect("clears the roster when a turn fails", () =>
5427 Effect.scoped(
5428 Effect.gen(function* () {
5429 const harness = yield* makeWakeHarness;
5430 const now = yield* DateTime.now;
5431 const failedResult = claudeSdkFrame({
5432 type: "result",
5433 subtype: "error_during_execution",
5434 duration_ms: 10,
5435 duration_api_ms: 10,
5436 is_error: true,
5437 num_turns: 1,
5438 result: "boom",
5439 stop_reason: "end_turn",
5440 total_cost_usd: 0,
5441 usage: {
5442 input_tokens: 1,
5443 output_tokens: 1,
5444 cache_creation_input_tokens: 0,
5445 cache_read_input_tokens: 0,
5446 },
5447 modelUsage: {},
5448 permission_denials: [],
5449 errors: ["boom"],
5450 terminal_reason: "model_error",
5451 uuid: "00000000-0000-4000-8000-000000000203",
5452 session_id: WAKE_NATIVE_SESSION,
5453 });
5454
5455 yield* harness.runtime.startTurn(
5456 makeClaudeTestTurnInput({
5457 threadId: harness.threadId,
5458 providerThread: harness.providerThread,
5459 now,
5460 attemptId: RunAttemptId.make("attempt-claude-roster-fail"),
5461 text: "Run the build in the background.",
5462 attachments: [],
5463 }),
5464 );
5465 yield* Queue.offer(harness.sdkMessages, wakeTaskStarted);
5466 yield* awaitUntil(
5467 () =>
5468 providerThreadRosterEvents(harness.events).some(
5469 (event) => (event.providerThread.pendingBackgroundTasks?.length ?? 0) > 0,
5470 ),
5471 "roster after task_started",
5472 );
5473 yield* Queue.offer(harness.sdkMessages, failedResult);
5474 yield* awaitUntil(() => harness.terminalEvents().length === 1, "failed terminal");
5475 assert.equal(harness.terminalEvents()[0]?.status, "failed");
5476
5477 const afterFailure = providerThreadRosterEvents(harness.events).at(-1);
5478 assert.deepEqual(afterFailure?.providerThread.pendingBackgroundTasks ?? [], []);
5479 assert.isFalse(yield* harness.hasPendingBackgroundWork);
5480 }).pipe(
5481 Effect.provide(
5482 Layer.mergeAll(
5483 IdAllocator.layer,
5484 McpProviderSessions.layer,
5485 ScriptProgress.layerUnavailable,
5486 NodeServices.layer,
5487 ),
5488 ),
5489 ),
5490 ),
5491 );
5492
5493 it.effect(
5494 "clears the replaced sibling native thread roster when openQuery switches processes",
5495 () =>
5496 Effect.scoped(
5497 Effect.gen(function* () {
5498 const fileSystem = yield* FileSystem.FileSystem;
5499 const idAllocator = yield* IdAllocator.IdAllocatorV2;
5500 const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({
5501 prefix: "t3-claude-v2-sibling-replace-",
5502 });
5503 const nativeIds = ["native-thread-roster-a", "native-thread-roster-b"] as const;
5504 let allocateIndex = 0;
5505 // Real two-process model: each openQuery owns its own message queue.
5506 // A shared queue would mask sibling process death on replacement.
5507 const processQueues: Array<{
5508 readonly nativeThreadId: string;
5509 readonly queue: Queue.Queue<SDKMessage>;
5510 }> = [];
5511 const events: Array<ProviderAdapter.ProviderAdapterV2Event> = [];
5512 const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({
5513 instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
5514 settings: DEFAULT_CLAUDE_SETTINGS,
5515 environment: {},
5516 attachmentsDir,
5517 fileSystem,
5518 path: yield* Path.Path,
5519 crypto: yield* Crypto.Crypto,
5520 idAllocator,
5521 continuationRequests: {
5522 offer: () => Effect.void,
5523 },
5524 queryRunner: {
5525 allocateSessionId: Effect.sync(() => {
5526 const next =
5527 nativeIds[allocateIndex] ?? `native-thread-roster-extra-${allocateIndex}`;
5528 allocateIndex += 1;
5529 return next;
5530 }),
5531 open: (openInput) =>
5532 Effect.gen(function* () {
5533 const nativeThreadId = openInput.options.sessionId ?? openInput.options.resume;
5534 if (typeof nativeThreadId !== "string" || nativeThreadId.length === 0) {
5535 return yield* Effect.die("openQuery must supply a native session id");
5536 }
5537 const queue = yield* Queue.unbounded<SDKMessage>();
5538 processQueues.push({ nativeThreadId, queue });
5539 return {
5540 messages: Stream.fromQueue(queue),
5541 offer: () => Effect.void,
5542 setModel: () => Effect.void,
5543 applyFlagSettings: () => Effect.void,
5544 setPermissionMode: () => Effect.void,
5545 stopTask: () => Effect.void,
5546 interrupt: Effect.void,
5547 close: Queue.shutdown(queue),
5548 };
5549 }),
5550 forkSession: () => Effect.die("unused forkSession"),
5551 subagentLaunchToolUseId: () => Effect.succeed(null),
5552 assertComplete: Effect.void,
5553 },
5554 });
5555 const appThreadA = ThreadId.make("thread-claude-roster-a");
5556 const appThreadB = ThreadId.make("thread-claude-roster-b");
5557 const runtime = yield* adapter.openSession({
5558 threadId: appThreadA,
5559 providerSessionId: ProviderSessionId.make("provider-session-claude-sibling-replace"),
5560 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
5561 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
5562 });
5563 const providerThreadA = yield* runtime.ensureThread({
5564 threadId: appThreadA,
5565 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
5566 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
5567 });
5568 const providerThreadB = yield* runtime.ensureThread({
5569 threadId: appThreadB,
5570 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
5571 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
5572 });
5573 assert.notEqual(
5574 providerThreadA.nativeThreadRef?.nativeId,
5575 providerThreadB.nativeThreadRef?.nativeId,
5576 );
5577 yield* runtime.events.pipe(
5578 Stream.runForEach((event) =>
5579 Effect.sync(() => {
5580 events.push(event);
5581 }),
5582 ),
5583 Effect.forkScoped,
5584 );
5585 if (runtime.hasPendingBackgroundWork === undefined) {
5586 return yield* Effect.die(
5587 "Claude adapter runtime must expose hasPendingBackgroundWork.",
5588 );
5589 }
5590 if (runtime.hasPendingBackgroundWorkForThread === undefined) {
5591 return yield* Effect.die(
5592 "Claude adapter runtime must expose hasPendingBackgroundWorkForThread.",
5593 );
5594 }
5595 const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork;
5596 const hasPendingBackgroundWorkForThread = runtime.hasPendingBackgroundWorkForThread;
5597 const now = yield* DateTime.now;
5598 const taskA = "task-roster-a";
5599 const taskB = "task-roster-b";
5600
5601 yield* runtime.startTurn(
5602 makeClaudeTestTurnInput({
5603 threadId: appThreadA,
5604 providerThread: providerThreadA,
5605 now,
5606 attemptId: RunAttemptId.make("attempt-roster-iso-a"),
5607 text: "Background work on A.",
5608 attachments: [],
5609 }),
5610 );
5611 assert.equal(processQueues.length, 1);
5612 const processA = processQueues[0]!;
5613 yield* Queue.offer(
5614 processA.queue,
5615 claudeSdkFrame({
5616 type: "system",
5617 subtype: "task_started",
5618 task_id: taskA,
5619 tool_use_id: "toolu-roster-a",
5620 description: "work on A",
5621 is_backgrounded: true,
5622 task_type: "local_bash",
5623 uuid: "00000000-0000-4000-8000-000000000301",
5624 session_id: nativeIds[0],
5625 }),
5626 );
5627 yield* Queue.offer(
5628 processA.queue,
5629 makeResultFrame({
5630 uuid: "00000000-0000-4000-8000-000000000302",
5631 result: "A settled with background work.",
5632 }),
5633 );
5634 yield* awaitUntil(
5635 () =>
5636 events.some(
5637 (event) =>
5638 event.type === "turn.terminal" &&
5639 event.providerThreadId === providerThreadA.id &&
5640 event.status === "completed",
5641 ),
5642 "thread A terminal",
5643 );
5644 const rosterAAfterSettle = providerThreadRosterEvents(events).findLast(
5645 (event) => event.providerThread.id === providerThreadA.id,
5646 )?.providerThread.pendingBackgroundTasks;
5647 assert.deepEqual(rosterAAfterSettle ?? [], [
5648 { taskId: taskA, description: "work on A", kind: "command" },
5649 ]);
5650 assert.isTrue(yield* hasPendingBackgroundWork);
5651 assert.isTrue(yield* hasPendingBackgroundWorkForThread(providerThreadA));
5652 assert.isFalse(yield* hasPendingBackgroundWorkForThread(providerThreadB));
5653
5654 // Starting B closes A's only live query. A can never emit a roster
5655 // clear from a dead process, so openQuery must idle-clear A.
5656 yield* runtime.startTurn(
5657 makeClaudeTestTurnInput({
5658 threadId: appThreadB,
5659 providerThread: { ...providerThreadB, status: "active" },
5660 now,
5661 attemptId: RunAttemptId.make("attempt-roster-iso-b"),
5662 text: "Background work on B.",
5663 attachments: [],
5664 }),
5665 );
5666 assert.equal(processQueues.length, 2);
5667 yield* awaitUntil(
5668 () =>
5669 providerThreadRosterEvents(events).some(
5670 (event) =>
5671 event.providerThread.id === providerThreadA.id &&
5672 event.providerThread.status === "idle" &&
5673 (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0,
5674 ),
5675 "sibling A roster cleared idle on process replacement",
5676 );
5677 assert.isFalse(yield* hasPendingBackgroundWorkForThread(providerThreadA));
5678
5679 const processB = processQueues[1]!;
5680 yield* Queue.offer(
5681 processB.queue,
5682 claudeSdkFrame({
5683 type: "system",
5684 subtype: "task_started",
5685 task_id: taskB,
5686 tool_use_id: "toolu-roster-b",
5687 description: "work on B",
5688 is_backgrounded: true,
5689 task_type: "local_bash",
5690 uuid: "00000000-0000-4000-8000-000000000303",
5691 session_id: nativeIds[1],
5692 }),
5693 );
5694 yield* awaitUntil(
5695 () =>
5696 providerThreadRosterEvents(events).some(
5697 (event) =>
5698 event.providerThread.id === providerThreadB.id &&
5699 (event.providerThread.pendingBackgroundTasks?.length ?? 0) > 0,
5700 ),
5701 "thread B roster populated",
5702 );
5703 assert.isTrue(yield* hasPendingBackgroundWorkForThread(providerThreadB));
5704 assert.isTrue(yield* hasPendingBackgroundWork);
5705 // Starting B's process clears only B's process-scoped level; A stays
5706 // empty from the sibling replacement clear above.
5707 assert.isFalse(yield* hasPendingBackgroundWorkForThread(providerThreadA));
5708
5709 yield* Queue.offer(
5710 processB.queue,
5711 claudeSdkFrame({
5712 type: "result",
5713 subtype: "error_during_execution",
5714 duration_ms: 10,
5715 duration_api_ms: 10,
5716 is_error: true,
5717 num_turns: 1,
5718 result: "B failed",
5719 stop_reason: "end_turn",
5720 total_cost_usd: 0,
5721 usage: {
5722 input_tokens: 1,
5723 output_tokens: 1,
5724 cache_creation_input_tokens: 0,
5725 cache_read_input_tokens: 0,
5726 },
5727 modelUsage: {},
5728 permission_denials: [],
5729 errors: ["B failed"],
5730 terminal_reason: "model_error",
5731 uuid: "00000000-0000-4000-8000-000000000304",
5732 session_id: nativeIds[1],
5733 }),
5734 );
5735 yield* awaitUntil(
5736 () =>
5737 events.some(
5738 (event) =>
5739 event.type === "turn.terminal" &&
5740 event.providerThreadId === providerThreadB.id &&
5741 event.status === "failed",
5742 ),
5743 "thread B failed terminal",
5744 );
5745
5746 const rosterBAfterFail = providerThreadRosterEvents(events).findLast(
5747 (event) => event.providerThread.id === providerThreadB.id,
5748 )?.providerThread.pendingBackgroundTasks;
5749 assert.deepEqual(rosterBAfterFail ?? [], []);
5750 assert.isFalse(yield* hasPendingBackgroundWorkForThread(providerThreadA));
5751 assert.isFalse(yield* hasPendingBackgroundWorkForThread(providerThreadB));
5752 assert.isFalse(yield* hasPendingBackgroundWork);
5753 }).pipe(
5754 Effect.provide(
5755 Layer.mergeAll(
5756 IdAllocator.layer,
5757 McpProviderSessions.layer,
5758 ScriptProgress.layerUnavailable,
5759 NodeServices.layer,
5760 ),
5761 ),
5762 ),
5763 ),
5764 );
5765
5766 it.effect.each(["full-access", "approval-required"] as const)(
5767 "keeps a queued wake turn's tool callback with its continuation in %s mode",
5768 (runtimeMode) =>
5769 Effect.scoped(
5770 Effect.gen(function* () {
5771 const harness = yield* makeWakeHarness;
5772 const now = yield* DateTime.now;
5773 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
5774 runtimeMode,
5775 interactionMode: "default",
5776 cwd: "/workspace",
5777 });
5778 const firstAttempt = RunAttemptId.make("attempt-claude-wake-callback-1");
5779 const userAttempt = RunAttemptId.make("attempt-claude-wake-callback-2");
5780 const continuationAttempt = RunAttemptId.make("attempt-claude-wake-callback-3");
5781 const planToolUseId = "toolu_01WakePlanExitPlanMode";
5782 const planMarkdown = "# Wake plan\n\n1. Report the background result.";
5783 const stamp = (frame: SDKMessage, promptIndex: number) =>
5784 claudeSdkFrame({
5785 ...frame,
5786 user_message_uuid: harness.promptUuid(promptIndex),
5787 });
5788 const runOf = (attemptId: RunAttemptId) => RunId.make(`run-${attemptId}`);
5789 const planToolUse = claudeSdkFrame({
5790 type: "assistant",
5791 message: {
5792 model: "claude-sonnet-4-6",
5793 id: "msg_wake_plan",
5794 type: "message",
5795 role: "assistant",
5796 content: [
5797 {
5798 type: "tool_use",
5799 id: planToolUseId,
5800 name: "ExitPlanMode",
5801 input: { plan: planMarkdown },
5802 },
5803 ],
5804 },
5805 parent_tool_use_id: null,
5806 uuid: "00000000-0000-4000-8000-000000000741",
5807 session_id: WAKE_NATIVE_SESSION,
5808 });
5809 const planToolResult = claudeSdkFrame({
5810 type: "user",
5811 message: {
5812 role: "user",
5813 content: [
5814 {
5815 type: "tool_result",
5816 tool_use_id: planToolUseId,
5817 content:
5818 "The client captured your proposed plan. Stop here and wait for the user's feedback or implementation request in a later turn.",
5819 is_error: true,
5820 },
5821 ],
5822 },
5823 parent_tool_use_id: null,
5824 uuid: "00000000-0000-4000-8000-000000000742",
5825 session_id: WAKE_NATIVE_SESSION,
5826 });
5827
5828 // Turn 1 launches background work and echoes its prompt early.
5829 yield* harness.runtime.startTurn(
5830 makeClaudeTestTurnInput({
5831 threadId: harness.threadId,
5832 providerThread: harness.providerThread,
5833 now,
5834 attemptId: firstAttempt,
5835 text: "Run the build in the background.",
5836 attachments: [],
5837 runtimePolicy,
5838 }),
5839 );
5840 yield* Queue.offer(harness.sdkMessages, stamp(wakeTaskStarted, 0));
5841 yield* Queue.offer(
5842 harness.sdkMessages,
5843 stamp(
5844 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000740", result: "STARTED" }),
5845 0,
5846 ),
5847 );
5848 yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal");
5849
5850 // The task finishes while the user's next prompt is queued, and the
5851 // CLI runs the wake turn first. That wake turn calls ExitPlanMode,
5852 // whose permission callback fires between its tool_use and result.
5853 yield* harness.runtime.startTurn(
5854 makeClaudeTestTurnInput({
5855 threadId: harness.threadId,
5856 providerThread: harness.providerThread,
5857 now,
5858 attemptId: userAttempt,
5859 text: "Reply with exactly: USER_REPLY",
5860 attachments: [],
5861 runtimePolicy,
5862 providerTurnOrdinal: 2,
5863 }),
5864 );
5865 yield* Queue.offer(harness.sdkMessages, wakeNotification);
5866 yield* Queue.offer(harness.sdkMessages, planToolUse);
5867 // The SDK asks for permission only after streaming the tool_use.
5868 yield* awaitUntil(
5869 () => Queue.sizeUnsafe(harness.sdkMessages) === 0,
5870 "the tool_use frame to be consumed",
5871 );
5872 let settleYields = 0;
5873 yield* awaitUntil(() => settleYields++ >= 50, "the tool_use frame to be handled");
5874 const canUseTool = harness.getOpenedOptions()?.canUseTool;
5875 assert.isFunction(canUseTool);
5876 const callback = yield* Effect.promise(() =>
5877 canUseTool!(
5878 "ExitPlanMode",
5879 { plan: planMarkdown },
5880 {
5881 signal: new AbortController().signal,
5882 toolUseID: planToolUseId,
5883 requestId: "request-wake-plan",
5884 },
5885 ),
5886 );
5887 assert.equal(callback?.behavior, "deny");
5888 assert.include(
5889 callback?.behavior === "deny" ? callback.message : "",
5890 "The client captured your proposed plan",
5891 );
5892 yield* Queue.offer(harness.sdkMessages, planToolResult);
5893 yield* Queue.offer(harness.sdkMessages, wakeResult);
5894 yield* awaitUntil(
5895 () => harness.continuationRequests.length === 1,
5896 "continuation request",
5897 );
5898
5899 // The prompt's own turn follows and echoes its uuid.
5900 yield* Queue.offer(
5901 harness.sdkMessages,
5902 stamp(
5903 makeAssistantTextFrame({
5904 uuid: "00000000-0000-4000-8000-000000000743",
5905 text: "USER_REPLY",
5906 }),
5907 1,
5908 ),
5909 );
5910 yield* Queue.offer(
5911 harness.sdkMessages,
5912 stamp(
5913 makeResultFrame({
5914 uuid: "00000000-0000-4000-8000-000000000744",
5915 result: "USER_REPLY",
5916 }),
5917 1,
5918 ),
5919 );
5920 yield* awaitUntil(() => harness.terminalEvents().length === 2, "user turn terminal");
5921
5922 yield* harness.runtime.startTurn(
5923 makeClaudeTestTurnInput({
5924 threadId: harness.threadId,
5925 providerThread: harness.providerThread,
5926 now,
5927 attemptId: continuationAttempt,
5928 text: "Background task completed.",
5929 attachments: [],
5930 runtimePolicy,
5931 providerTurnOrdinal: 3,
5932 messageCreatedBy: "agent",
5933 messageCreationSource: "provider",
5934 }),
5935 );
5936 yield* awaitUntil(() => harness.terminalEvents().length === 3, "continuation terminal");
5937
5938 const latestItems = new Map(
5939 harness.events.flatMap((event) =>
5940 event.type === "turn_item.updated"
5941 ? [[String(event.turnItem.id), event.turnItem]]
5942 : [],
5943 ),
5944 );
5945 const planTool = [...latestItems.values()].find(
5946 (item) =>
5947 item.type === "dynamic_tool" && item.nativeItemRef?.nativeId === planToolUseId,
5948 );
5949 const proposedPlans = [...latestItems.values()].filter(
5950 (item) => item.type === "proposed_plan",
5951 );
5952 const plans = harness.events.flatMap((event) =>
5953 event.type === "plan.updated" && event.plan.kind === "proposed_plan"
5954 ? [event.plan]
5955 : [],
5956 );
5957 // Every update of the tool, from start to its (denied) result, is in
5958 // the continuation run; the user's turn never starts or fails it.
5959 const planToolRuns = harness.events.flatMap((event) =>
5960 event.type === "turn_item.updated" &&
5961 event.turnItem.nativeItemRef?.nativeId === planToolUseId &&
5962 event.turnItem.type === "dynamic_tool"
5963 ? [event.turnItem.runId]
5964 : [],
5965 );
5966 assert.isNotEmpty(planToolRuns);
5967 assert.isTrue(planToolRuns.every((runId) => runId === runOf(continuationAttempt)));
5968 assert.equal(planTool?.runId, runOf(continuationAttempt));
5969 // Claude was told the plan was captured, so it must be projected.
5970 assert.lengthOf(proposedPlans, 1);
5971 assert.equal(proposedPlans[0]?.runId, runOf(continuationAttempt));
5972 assert.isTrue(plans.length > 0);
5973 assert.isTrue(plans.every((plan) => plan.runId === runOf(continuationAttempt)));
5974 // Nothing from the wake turn reached the user's run.
5975 const userRunItems = [...latestItems.values()].filter(
5976 (item) => item.runId === runOf(userAttempt),
5977 );
5978 assert.deepEqual(
5979 userRunItems.map((item) => item.type),
5980 ["assistant_message"],
5981 );
5982 }).pipe(
5983 Effect.provide(
5984 Layer.mergeAll(
5985 IdAllocator.layer,
5986 McpProviderSessions.layer,
5987 ScriptProgress.layerUnavailable,
5988 NodeServices.layer,
5989 ),
5990 ),
5991 ),
5992 ),
5993 );
5994
5995 it.effect("stores a Bash result's stdout and stderr as command output", () =>
5996 Effect.scoped(
5997 Effect.gen(function* () {
5998 const harness = yield* makeWakeHarness;
5999 const now = yield* DateTime.now;
6000 const attemptId = RunAttemptId.make("attempt-claude-bash-output");
6001 const bashToolUseId = "toolu_01BashOutput";
6002
6003 yield* harness.runtime.startTurn(
6004 makeClaudeTestTurnInput({
6005 threadId: harness.threadId,
6006 providerThread: harness.providerThread,
6007 now,
6008 attemptId,
6009 text: "Run it.",
6010 attachments: [],
6011 }),
6012 );
6013 yield* Queue.offer(
6014 harness.sdkMessages,
6015 claudeSdkFrame({
6016 type: "assistant",
6017 message: {
6018 model: "claude-sonnet-4-6",
6019 id: "msg_bash_output",
6020 type: "message",
6021 role: "assistant",
6022 content: [
6023 {
6024 type: "tool_use",
6025 id: bashToolUseId,
6026 name: "Bash",
6027 input: { command: "git status" },
6028 },
6029 ],
6030 },
6031 parent_tool_use_id: null,
6032 uuid: "00000000-0000-4000-8000-000000000790",
6033 session_id: WAKE_NATIVE_SESSION,
6034 }),
6035 );
6036 yield* Queue.offer(
6037 harness.sdkMessages,
6038 claudeSdkFrame({
6039 type: "user",
6040 message: {
6041 role: "user",
6042 content: [
6043 { type: "tool_result", tool_use_id: bashToolUseId, content: "On branch main" },
6044 ],
6045 },
6046 parent_tool_use_id: null,
6047 uuid: "00000000-0000-4000-8000-000000000791",
6048 session_id: WAKE_NATIVE_SESSION,
6049 tool_use_result: {
6050 stdout: "On branch main",
6051 stderr: "warning: dirty",
6052 interrupted: false,
6053 isImage: false,
6054 },
6055 }),
6056 );
6057 yield* Queue.offer(
6058 harness.sdkMessages,
6059 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000792", result: "Done." }),
6060 );
6061 yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal");
6062
6063 const bash = harness.events.findLast(
6064 (event) =>
6065 event.type === "turn_item.updated" &&
6066 event.turnItem.nativeItemRef?.nativeId === bashToolUseId,
6067 );
6068 assert.equal(
6069 bash?.type === "turn_item.updated" && bash.turnItem.type === "command_execution"
6070 ? bash.turnItem.output
6071 : undefined,
6072 "On branch main\nwarning: dirty",
6073 );
6074 }).pipe(
6075 Effect.provide(
6076 Layer.mergeAll(
6077 IdAllocator.layer,
6078 McpProviderSessions.layer,
6079 ScriptProgress.layerUnavailable,
6080 NodeServices.layer,
6081 ),
6082 ),
6083 ),
6084 ),
6085 );
6086
6087 it.effect("answers an approval a held wake turn raises without waiting for the echo", () =>
6088 Effect.scoped(
6089 Effect.gen(function* () {
6090 const harness = yield* makeWakeHarness;
6091 const now = yield* DateTime.now;
6092 const approvalPolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
6093 runtimeMode: "approval-required",
6094 interactionMode: "default",
6095 cwd: "/workspace",
6096 });
6097 const firstAttempt = RunAttemptId.make("attempt-claude-wake-approval-1");
6098 const userAttempt = RunAttemptId.make("attempt-claude-wake-approval-2");
6099 const bashToolUseId = "toolu_01WakeApprovalBash";
6100 const stamp = (frame: SDKMessage, promptIndex: number) =>
6101 claudeSdkFrame({
6102 ...frame,
6103 user_message_uuid: harness.promptUuid(promptIndex),
6104 });
6105
6106 yield* harness.runtime.startTurn(
6107 makeClaudeTestTurnInput({
6108 threadId: harness.threadId,
6109 providerThread: harness.providerThread,
6110 now,
6111 attemptId: firstAttempt,
6112 text: "First.",
6113 attachments: [],
6114 runtimePolicy: approvalPolicy,
6115 }),
6116 );
6117 yield* Queue.offer(
6118 harness.sdkMessages,
6119 stamp(
6120 makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000780", text: "One." }),
6121 0,
6122 ),
6123 );
6124 yield* Queue.offer(
6125 harness.sdkMessages,
6126 stamp(
6127 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000781", result: "One." }),
6128 0,
6129 ),
6130 );
6131 yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal");
6132
6133 yield* harness.runtime.startTurn(
6134 makeClaudeTestTurnInput({
6135 threadId: harness.threadId,
6136 providerThread: harness.providerThread,
6137 now,
6138 attemptId: userAttempt,
6139 text: "Second.",
6140 attachments: [],
6141 providerTurnOrdinal: 2,
6142 runtimePolicy: approvalPolicy,
6143 }),
6144 );
6145 // A queued wake turn asks to run Bash while its output is held.
6146 yield* Queue.offer(
6147 harness.sdkMessages,
6148 claudeSdkFrame({
6149 type: "assistant",
6150 message: {
6151 model: "claude-sonnet-4-6",
6152 id: "msg_wake_bash",
6153 type: "message",
6154 role: "assistant",
6155 content: [
6156 {
6157 type: "tool_use",
6158 id: bashToolUseId,
6159 name: "Bash",
6160 input: { command: "git status" },
6161 },
6162 ],
6163 },
6164 parent_tool_use_id: null,
6165 uuid: "00000000-0000-4000-8000-000000000782",
6166 session_id: WAKE_NATIVE_SESSION,
6167 }),
6168 );
6169 yield* awaitUntil(
6170 () => Queue.sizeUnsafe(harness.sdkMessages) === 0,
6171 "the tool_use frame to be consumed",
6172 );
6173 let settleYields = 0;
6174 yield* awaitUntil(() => settleYields++ >= 50, "the tool_use frame to be held");
6175 const permission = yield* Effect.promise(() =>
6176 harness.getOpenedOptions()!.canUseTool!(
6177 "Bash",
6178 { command: "git status" },
6179 {
6180 signal: new AbortController().signal,
6181 toolUseID: bashToolUseId,
6182 requestId: "request-wake-bash",
6183 },
6184 ),
6185 ).pipe(Effect.forkScoped);
6186
6187 // The request is raised at once, releasing the held output to the
6188 // pending prompt turn (where it went before output was held), so the
6189 // user can answer it and the SDK is not left waiting on the echo.
6190 yield* awaitUntil(
6191 () => harness.events.some((event) => event.type === "runtime_request.updated"),
6192 "the approval request",
6193 );
6194 const request = harness.events.findLast(
6195 (event) => event.type === "runtime_request.updated",
6196 );
6197 const bashItems = harness.events.flatMap((event) =>
6198 event.type === "turn_item.updated" &&
6199 event.turnItem.nativeItemRef?.nativeId === bashToolUseId
6200 ? [event.turnItem.runId]
6201 : [],
6202 );
6203 assert.isNotEmpty(bashItems);
6204 assert.isTrue(bashItems.every((runId) => runId === RunId.make(`run-${userAttempt}`)));
6205 if (request?.type !== "runtime_request.updated") return;
6206 yield* harness.runtime.respondToRuntimeRequest({
6207 requestId: request.runtimeRequest.id,
6208 decision: "accept",
6209 });
6210 const result = yield* Fiber.join(permission);
6211 assert.equal(result?.behavior, "allow");
6212 }).pipe(
6213 Effect.provide(
6214 Layer.mergeAll(
6215 IdAllocator.layer,
6216 McpProviderSessions.layer,
6217 ScriptProgress.layerUnavailable,
6218 NodeServices.layer,
6219 ),
6220 ),
6221 ),
6222 ),
6223 );
6224
6225 it.effect("drops a zero-turn task-notification result while awaiting a prompt echo", () =>
6226 Effect.scoped(
6227 Effect.gen(function* () {
6228 const harness = yield* makeWakeHarness;
6229 const now = yield* DateTime.now;
6230 const firstAttempt = RunAttemptId.make("attempt-claude-echo-debris-1");
6231 const secondAttempt = RunAttemptId.make("attempt-claude-echo-debris-2");
6232 const stamp = (frame: SDKMessage, promptIndex: number) =>
6233 claudeSdkFrame({
6234 ...frame,
6235 user_message_uuid: harness.promptUuid(promptIndex),
6236 });
6237
6238 yield* harness.runtime.startTurn(
6239 makeClaudeTestTurnInput({
6240 threadId: harness.threadId,
6241 providerThread: harness.providerThread,
6242 now,
6243 attemptId: firstAttempt,
6244 text: "First.",
6245 attachments: [],
6246 }),
6247 );
6248 // The first turn echoes on its first frame: this process echoes early.
6249 yield* Queue.offer(
6250 harness.sdkMessages,
6251 stamp(
6252 makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000760", text: "One." }),
6253 0,
6254 ),
6255 );
6256 yield* Queue.offer(
6257 harness.sdkMessages,
6258 stamp(
6259 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000761", result: "One." }),
6260 0,
6261 ),
6262 );
6263 yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal");
6264
6265 yield* harness.runtime.startTurn(
6266 makeClaudeTestTurnInput({
6267 threadId: harness.threadId,
6268 providerThread: harness.providerThread,
6269 now,
6270 attemptId: secondAttempt,
6271 text: "Second.",
6272 attachments: [],
6273 providerTurnOrdinal: 2,
6274 }),
6275 );
6276 // Lifecycle debris ahead of the prompt's own turn: a stale wake's
6277 // unstamped output, then its zero-turn result.
6278 yield* Queue.offer(
6279 harness.sdkMessages,
6280 makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000764", text: "Stale." }),
6281 );
6282 yield* Queue.offer(harness.sdkMessages, staleTaskNotificationResult);
6283 yield* Queue.offer(
6284 harness.sdkMessages,
6285 stamp(
6286 makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000762", text: "Two." }),
6287 1,
6288 ),
6289 );
6290 yield* Queue.offer(
6291 harness.sdkMessages,
6292 stamp(
6293 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000763", result: "Two." }),
6294 1,
6295 ),
6296 );
6297 yield* awaitUntil(() => harness.terminalEvents().length === 2, "second turn terminal");
6298 assert.equal(harness.terminalEvents()[1]?.status, "completed");
6299 assert.lengthOf(harness.continuationRequests, 0);
6300 // The debris' held output is released to the prompt's turn with its
6301 // echo, as it streamed before the gate existed.
6302 assert.deepEqual(
6303 harness.events.flatMap((event) =>
6304 event.type === "message.updated" && event.message.role === "assistant"
6305 ? [event.message.text]
6306 : [],
6307 ),
6308 ["One.", "Stale.", "Two."],
6309 );
6310 assert.isFalse(
6311 harness.events.some(
6312 (event) =>
6313 event.type === "message.updated" &&
6314 event.message.text === STALE_TASK_NOTIFICATION_RESULT_TEXT,
6315 ),
6316 );
6317 }).pipe(
6318 Effect.provide(
6319 Layer.mergeAll(
6320 IdAllocator.layer,
6321 McpProviderSessions.layer,
6322 ScriptProgress.layerUnavailable,
6323 NodeServices.layer,
6324 ),
6325 ),
6326 ),
6327 ),
6328 );
6329
6330 it.effect("releases output held for a prompt echo when the stream ends", () =>
6331 Effect.scoped(
6332 Effect.gen(function* () {
6333 const harness = yield* makeWakeHarnessWithOptions({
6334 close: (sdkMessages) => Queue.shutdown(sdkMessages),
6335 });
6336 const now = yield* DateTime.now;
6337 const firstAttempt = RunAttemptId.make("attempt-claude-echo-1");
6338 const secondAttempt = RunAttemptId.make("attempt-claude-echo-2");
6339 const stamp = (frame: SDKMessage, promptIndex: number) =>
6340 claudeSdkFrame({
6341 ...frame,
6342 user_message_uuid: harness.promptUuid(promptIndex),
6343 });
6344 const assistantTexts = () =>
6345 harness.events.flatMap((event) =>
6346 event.type === "message.updated" && event.message.role === "assistant"
6347 ? [event.message.text]
6348 : [],
6349 );
6350
6351 // The first turn echoes its prompt uuid on its first frame, so this
6352 // CLI process is known to echo early.
6353 yield* harness.runtime.startTurn(
6354 makeClaudeTestTurnInput({
6355 threadId: harness.threadId,
6356 providerThread: harness.providerThread,
6357 now,
6358 attemptId: firstAttempt,
6359 text: "First.",
6360 attachments: [],
6361 }),
6362 );
6363 yield* Queue.offer(
6364 harness.sdkMessages,
6365 stamp(
6366 makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000701", text: "One." }),
6367 0,
6368 ),
6369 );
6370 yield* Queue.offer(
6371 harness.sdkMessages,
6372 stamp(
6373 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000702", result: "One." }),
6374 0,
6375 ),
6376 );
6377 yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal");
6378
6379 // The second turn's first frame carries no echo, so it is held; the
6380 // stream then dies before any result.
6381 yield* harness.runtime.startTurn(
6382 makeClaudeTestTurnInput({
6383 threadId: harness.threadId,
6384 providerThread: harness.providerThread,
6385 now,
6386 attemptId: secondAttempt,
6387 text: "Second.",
6388 attachments: [],
6389 providerTurnOrdinal: 2,
6390 }),
6391 );
6392 yield* Queue.offer(
6393 harness.sdkMessages,
6394 makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000703", text: "Two." }),
6395 );
6396 let heldYields = 0;
6397 yield* awaitUntil(() => heldYields++ >= 50, "unechoed frame to be held");
6398 assert.deepEqual(assistantTexts(), ["One."]);
6399
6400 yield* Queue.shutdown(harness.sdkMessages);
6401 yield* awaitUntil(() => harness.terminalEvents().length === 2, "second turn terminal");
6402 assert.deepEqual(assistantTexts(), ["One.", "Two."]);
6403 assert.equal(harness.terminalEvents()[1]?.status, "failed");
6404 }).pipe(
6405 Effect.provide(
6406 Layer.mergeAll(
6407 IdAllocator.layer,
6408 McpProviderSessions.layer,
6409 ScriptProgress.layerUnavailable,
6410 NodeServices.layer,
6411 ),
6412 ),
6413 ),
6414 ),
6415 );
6416
6417 it.effect("gives the same run attempt a fresh prompt uuid on every offer", () =>
6418 Effect.scoped(
6419 Effect.gen(function* () {
6420 // Two servers sharing a database copy allocate identical run attempt
6421 // ids and resume the same Claude session. Claude acks a prompt whose
6422 // uuid its transcript already holds without ever running a turn.
6423 const original = yield* makeWakeHarness;
6424 const copy = yield* makeWakeHarness;
6425 const now = yield* DateTime.now;
6426 for (const harness of [original, copy]) {
6427 yield* harness.runtime.startTurn(
6428 makeClaudeTestTurnInput({
6429 threadId: harness.threadId,
6430 providerThread: harness.providerThread,
6431 now,
6432 attemptId: RunAttemptId.make("attempt-claude-shared-copy"),
6433 text: "Continue where you left off.",
6434 attachments: [],
6435 }),
6436 );
6437 }
6438 assert.notEqual(original.promptUuid(0), copy.promptUuid(0));
6439 }).pipe(
6440 Effect.provide(
6441 Layer.mergeAll(
6442 IdAllocator.layer,
6443 McpProviderSessions.layer,
6444 ScriptProgress.layerUnavailable,
6445 NodeServices.layer,
6446 ),
6447 ),
6448 ),
6449 ),
6450 );
6451
6452 it.effect("fails a prompt Claude completes without starting a turn for it", () =>
6453 Effect.scoped(
6454 Effect.gen(function* () {
6455 // Claude answers a prompt whose uuid its transcript already holds with
6456 // a lone completed lifecycle frame and never runs a turn for it.
6457 const harness = yield* makeWakeHarness;
6458 const now = yield* DateTime.now;
6459 yield* harness.runtime.startTurn(
6460 makeClaudeTestTurnInput({
6461 threadId: harness.threadId,
6462 providerThread: harness.providerThread,
6463 now,
6464 attemptId: RunAttemptId.make("attempt-claude-duplicate-prompt"),
6465 text: "Bump this PR to the latest main.",
6466 attachments: [],
6467 }),
6468 );
6469 yield* Queue.offer(
6470 harness.sdkMessages,
6471 claudeSdkFrame({
6472 type: "command_lifecycle",
6473 command_uuid: harness.promptUuid(0),
6474 state: "completed",
6475 uuid: "00000000-0000-4000-8000-000000000790",
6476 session_id: WAKE_NATIVE_SESSION,
6477 }),
6478 );
6479 yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal");
6480 const [terminal] = harness.terminalEvents();
6481 assert.equal(terminal?.status, "failed");
6482 if (terminal?.status !== "failed") return;
6483 assert.equal(terminal.threadDisposition, "reusable");
6484 assert.include(terminal.failure.message, "never started a turn");
6485 }).pipe(
6486 Effect.provide(
6487 Layer.mergeAll(
6488 IdAllocator.layer,
6489 McpProviderSessions.layer,
6490 ScriptProgress.layerUnavailable,
6491 NodeServices.layer,
6492 ),
6493 ),
6494 ),
6495 ),
6496 );
6497
6498 it.effect("keeps a started prompt running until its result", () =>
6499 Effect.scoped(
6500 Effect.gen(function* () {
6501 const harness = yield* makeWakeHarness;
6502 const now = yield* DateTime.now;
6503 yield* harness.runtime.startTurn(
6504 makeClaudeTestTurnInput({
6505 threadId: harness.threadId,
6506 providerThread: harness.providerThread,
6507 now,
6508 attemptId: RunAttemptId.make("attempt-claude-started-prompt"),
6509 text: "Bump this PR to the latest main.",
6510 attachments: [],
6511 }),
6512 );
6513 const lifecycle = (state: string, uuid: string) =>
6514 claudeSdkFrame({
6515 type: "command_lifecycle",
6516 command_uuid: harness.promptUuid(0),
6517 state,
6518 uuid,
6519 session_id: WAKE_NATIVE_SESSION,
6520 });
6521 yield* Queue.offer(
6522 harness.sdkMessages,
6523 lifecycle("queued", "00000000-0000-4000-8000-000000000791"),
6524 );
6525 yield* Queue.offer(
6526 harness.sdkMessages,
6527 lifecycle("started", "00000000-0000-4000-8000-000000000792"),
6528 );
6529 // The CLI can report the prompt completed before its turn's result.
6530 yield* Queue.offer(
6531 harness.sdkMessages,
6532 lifecycle("completed", "00000000-0000-4000-8000-000000000793"),
6533 );
6534 yield* Queue.offer(
6535 harness.sdkMessages,
6536 claudeSdkFrame({
6537 ...makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000794", result: "Done." }),
6538 user_message_uuid: harness.promptUuid(0),
6539 }),
6540 );
6541 yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal");
6542 assert.equal(harness.terminalEvents()[0]?.status, "completed");
6543 }).pipe(
6544 Effect.provide(
6545 Layer.mergeAll(
6546 IdAllocator.layer,
6547 McpProviderSessions.layer,
6548 ScriptProgress.layerUnavailable,
6549 NodeServices.layer,
6550 ),
6551 ),
6552 ),
6553 ),
6554 );
6555
6556 it.effect("buffers wake output and requests a single continuation run", () =>
6557 Effect.scoped(
6558 Effect.gen(function* () {
6559 const harness = yield* makeWakeHarness;
6560 const now = yield* DateTime.now;
6561
6562 yield* harness.runtime.startTurn(
6563 makeClaudeTestTurnInput({
6564 threadId: harness.threadId,
6565 providerThread: harness.providerThread,
6566 now,
6567 attemptId: RunAttemptId.make("attempt-claude-wake-1"),
6568 text: "Run the build in the background.",
6569 attachments: [],
6570 }),
6571 );
6572 yield* Queue.offer(harness.sdkMessages, wakeTaskStarted);
6573 yield* Queue.offer(harness.sdkMessages, turnOneResult);
6574 yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal");
6575 assert.equal(harness.terminalEvents()[0]?.status, "completed");
6576 assert.isTrue(yield* harness.hasPendingBackgroundWork);
6577 assert.lengthOf(harness.continuationRequests, 0);
6578
6579 yield* Queue.offer(harness.sdkMessages, wakeNotification);
6580 let quietYields = 0;
6581 yield* awaitUntil(() => quietYields++ >= 50, "notification-only quiet window");
6582 assert.lengthOf(harness.continuationRequests, 0);
6583 assert.isFalse(yield* harness.hasPendingBackgroundWork);
6584
6585 yield* Queue.offer(harness.sdkMessages, wakeAssistant);
6586 yield* awaitUntil(() => harness.continuationRequests.length === 1, "continuation request");
6587 assert.equal(harness.continuationRequests[0]?.threadId, harness.threadId);
6588 assert.equal(harness.continuationRequests[0]?.providerThreadId, harness.providerThread.id);
6589 assert.equal(harness.continuationRequests[0]?.driver, ClaudeAdapterV2.CLAUDE_PROVIDER);
6590 assert.equal(harness.continuationRequests[0]?.detail, WAKE_SUMMARY);
6591
6592 yield* Queue.offer(harness.sdkMessages, wakeResult);
6593 let settleYields = 0;
6594 yield* awaitUntil(() => settleYields++ >= 50, "wake result to settle into the buffer");
6595 assert.lengthOf(harness.continuationRequests, 1);
6596 assert.lengthOf(harness.terminalEvents(), 1);
6597 assert.isTrue(yield* harness.hasPendingBackgroundWork);
6598 }).pipe(
6599 Effect.provide(
6600 Layer.mergeAll(
6601 IdAllocator.layer,
6602 McpProviderSessions.layer,
6603 ScriptProgress.layerUnavailable,
6604 NodeServices.layer,
6605 ),
6606 ),
6607 ),
6608 ),
6609 );
6610
6611 it.effect("starts the wake run when Claude opens the wake turn, before its output", () =>
6612 Effect.scoped(
6613 Effect.gen(function* () {
6614 const harness = yield* makeWakeHarness;
6615 const now = yield* DateTime.now;
6616
6617 yield* harness.runtime.startTurn(
6618 makeClaudeTestTurnInput({
6619 threadId: harness.threadId,
6620 providerThread: harness.providerThread,
6621 now,
6622 attemptId: RunAttemptId.make("attempt-claude-wake-init-1"),
6623 text: "Run the build in the background.",
6624 attachments: [],
6625 }),
6626 );
6627 yield* Queue.offer(harness.sdkMessages, wakeTaskStarted);
6628 yield* Queue.offer(harness.sdkMessages, turnOneResult);
6629 yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal");
6630
6631 yield* harness.offerAndWait(wakeNotification);
6632 assert.lengthOf(harness.continuationRequests, 0);
6633 yield* harness.offerAndWait(wakeTurnInit);
6634 assert.lengthOf(harness.continuationRequests, 1);
6635 assert.equal(harness.continuationRequests[0]?.detail, WAKE_SUMMARY);
6636
6637 // The run attaches while Claude still thinks: only the notification
6638 // and `init` are buffered, so the run waits for the turn's output.
6639 yield* harness.runtime.startTurn(
6640 makeClaudeTestTurnInput({
6641 threadId: harness.threadId,
6642 providerThread: harness.providerThread,
6643 now,
6644 attemptId: RunAttemptId.make("attempt-claude-wake-init-2"),
6645 text: "Background task completed.",
6646 attachments: [],
6647 providerTurnOrdinal: 2,
6648 messageCreatedBy: "agent",
6649 messageCreationSource: "provider",
6650 }),
6651 );
6652 assert.lengthOf(harness.terminalEvents(), 1);
6653
6654 yield* harness.offerAndWait(wakeAssistant);
6655 yield* harness.offerAndWait(wakeResult);
6656 yield* awaitUntil(() => harness.terminalEvents().length === 2, "wake run terminal");
6657 assert.equal(harness.terminalEvents()[1]?.status, "completed");
6658 assert.lengthOf(harness.continuationRequests, 1);
6659 assert.isTrue(
6660 harness.events.some(
6661 (event) => event.type === "message.updated" && event.message.text === WAKE_RESULT_TEXT,
6662 ),
6663 );
6664 }).pipe(
6665 Effect.provide(
6666 Layer.mergeAll(
6667 IdAllocator.layer,
6668 McpProviderSessions.layer,
6669 ScriptProgress.layerUnavailable,
6670 NodeServices.layer,
6671 ),
6672 ),
6673 ),
6674 ),
6675 );
6676
6677 it.effect("does not offer a continuation for notification-only opaque work", () =>
6678 Effect.scoped(
6679 Effect.gen(function* () {
6680 const harness = yield* makeWakeHarness;
6681 const now = yield* DateTime.now;
6682
6683 yield* harness.runtime.startTurn(
6684 makeClaudeTestTurnInput({
6685 threadId: harness.threadId,
6686 providerThread: harness.providerThread,
6687 now,
6688 attemptId: RunAttemptId.make("attempt-claude-notification-only"),
6689 text: "Start opaque background work.",
6690 attachments: [],
6691 }),
6692 );
6693 yield* Queue.offer(harness.sdkMessages, wakeTaskStarted);
6694 yield* Queue.offer(harness.sdkMessages, turnOneResult);
6695 yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal");
6696 assert.isTrue(yield* harness.hasPendingBackgroundWork);
6697
6698 yield* Queue.offer(harness.sdkMessages, wakeNotification);
6699 let quietYields = 0;
6700 yield* awaitUntil(() => quietYields++ >= 100, "notification-only quiet window");
6701 assert.lengthOf(harness.continuationRequests, 0);
6702 assert.lengthOf(harness.terminalEvents(), 1);
6703 assert.isFalse(yield* harness.hasPendingBackgroundWork);
6704
6705 yield* harness.runtime.startTurn(
6706 makeClaudeTestTurnInput({
6707 threadId: harness.threadId,
6708 providerThread: harness.providerThread,
6709 now,
6710 attemptId: RunAttemptId.make("attempt-claude-notification-only-continuation"),
6711 text: "Background task completed.",
6712 attachments: [],
6713 providerTurnOrdinal: 2,
6714 messageCreatedBy: "agent",
6715 messageCreationSource: "provider",
6716 }),
6717 );
6718 yield* awaitUntil(
6719 () => harness.terminalEvents().length === 2,
6720 "notification-only continuation terminal",
6721 );
6722 assert.equal(harness.terminalEvents()[1]?.status, "completed");
6723 assert.lengthOf(harness.offeredMessages, 1);
6724 }).pipe(
6725 Effect.provide(
6726 Layer.mergeAll(
6727 IdAllocator.layer,
6728 McpProviderSessions.layer,
6729 ScriptProgress.layerUnavailable,
6730 NodeServices.layer,
6731 ),
6732 ),
6733 ),
6734 ),
6735 );
6736
6737 it.effect("carries a rejected wake rate limit into the continuation failure", () =>
6738 Effect.scoped(
6739 Effect.gen(function* () {
6740 const harness = yield* makeWakeHarness;
6741 const now = yield* DateTime.now;
6742 // 2026-09-25T11:10 AEST: the reset a real wake reported while the CLI
6743 // blocked its notification turn ("resets 11:10am (Australia/Sydney)").
6744 const resetsAt = 1790298600;
6745
6746 yield* harness.runtime.startTurn(
6747 makeClaudeTestTurnInput({
6748 threadId: harness.threadId,
6749 providerThread: harness.providerThread,
6750 now,
6751 attemptId: RunAttemptId.make("attempt-claude-wake-limit-1"),
6752 text: "Run the build in the background.",
6753 attachments: [],
6754 }),
6755 );
6756 yield* Queue.offer(harness.sdkMessages, wakeTaskStarted);
6757 yield* Queue.offer(harness.sdkMessages, turnOneResult);
6758 yield* Queue.take(harness.terminalReceipts);
6759
6760 // The CLI wakes with a rejected window before the continuation turn
6761 // exists, then blocks the wake turn itself.
6762 yield* Queue.offer(harness.sdkMessages, wakeNotification);
6763 yield* harness.offerAndWait(
6764 claudeSdkFrame({
6765 type: "rate_limit_event",
6766 rate_limit_info: {
6767 status: "rejected",
6768 rateLimitType: "five_hour",
6769 resetsAt,
6770 overageStatus: "rejected",
6771 },
6772 uuid: "00000000-0000-4000-8000-000000000630",
6773 session_id: WAKE_NATIVE_SESSION,
6774 }),
6775 );
6776 // The parked rate-limit frame rides along with the wake output; it
6777 // must not request a continuation on its own.
6778 assert.lengthOf(harness.continuationRequests, 0);
6779 yield* harness.offerAndWait(
6780 makeAssistantErrorFrame({
6781 uuid: "00000000-0000-4000-8000-000000000631",
6782 error: "rate_limit",
6783 }),
6784 );
6785 assert.lengthOf(harness.continuationRequests, 1);
6786 yield* harness.offerAndWait(
6787 makeResultFrame({
6788 uuid: "00000000-0000-4000-8000-000000000632",
6789 result: "You've hit your session limit · resets 11:10am (Australia/Sydney)",
6790 isError: true,
6791 apiErrorStatus: 429,
6792 terminalReason: "api_error",
6793 origin: { kind: "task-notification" },
6794 }),
6795 );
6796 assert.lengthOf(harness.continuationRequests, 1);
6797
6798 yield* harness.runtime.startTurn(
6799 makeClaudeTestTurnInput({
6800 threadId: harness.threadId,
6801 providerThread: harness.providerThread,
6802 now,
6803 attemptId: RunAttemptId.make("attempt-claude-wake-limit-2"),
6804 text: "Background task completed.",
6805 attachments: [],
6806 providerTurnOrdinal: 2,
6807 messageCreatedBy: "agent",
6808 messageCreationSource: "provider",
6809 }),
6810 );
6811 const terminal = yield* Queue.take(harness.terminalReceipts);
6812 assert.equal(terminal?.status, "failed");
6813 if (terminal === undefined || terminal.status !== "failed") return;
6814 assert.equal(terminal.failure.class, "usage_limit");
6815 assert.equal(terminal.failure.resetAt, "2026-09-25T01:10:00.000Z");
6816 // The rejected window is announced once the replay has a turn to own it.
6817 const pause = yield* Queue.take(harness.systemNoticeReceipts);
6818 assert.equal(pause.turnItem.type, "system_notice");
6819 if (pause.turnItem.type !== "system_notice") return;
6820 assert.include(pause.turnItem.message, "This turn is paused until the 5-hour limit");
6821 }).pipe(
6822 Effect.provide(
6823 Layer.mergeAll(
6824 IdAllocator.layer,
6825 McpProviderSessions.layer,
6826 ScriptProgress.layerUnavailable,
6827 NodeServices.layer,
6828 ),
6829 ),
6830 ),
6831 ),
6832 );
6833
6834 it.effect("leaves buffered wake messages for the continuation queued behind a user turn", () =>
6835 Effect.scoped(
6836 Effect.gen(function* () {
6837 const harness = yield* makeWakeHarness;
6838 const now = yield* DateTime.now;
6839
6840 yield* harness.runtime.startTurn(
6841 makeClaudeTestTurnInput({
6842 threadId: harness.threadId,
6843 providerThread: harness.providerThread,
6844 now,
6845 attemptId: RunAttemptId.make("attempt-claude-wake-4a"),
6846 text: "Run the build in the background.",
6847 attachments: [],
6848 }),
6849 );
6850 yield* Queue.offer(harness.sdkMessages, wakeTaskStarted);
6851 yield* Queue.offer(harness.sdkMessages, turnOneResult);
6852 yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal");
6853 yield* Queue.offer(harness.sdkMessages, wakeNotification);
6854 yield* Queue.offer(harness.sdkMessages, wakeResult);
6855 yield* awaitUntil(() => harness.continuationRequests.length === 1, "continuation request");
6856
6857 yield* harness.runtime.startTurn(
6858 makeClaudeTestTurnInput({
6859 threadId: harness.threadId,
6860 providerThread: harness.providerThread,
6861 now,
6862 attemptId: RunAttemptId.make("attempt-claude-wake-4b"),
6863 text: "How is the build going?",
6864 attachments: [],
6865 providerTurnOrdinal: 2,
6866 }),
6867 );
6868
6869 // The user prompt reaches the CLI and the buffer stays untouched: the
6870 // wake result must not settle the user turn or surface under it.
6871 yield* awaitUntil(() => harness.offeredMessages.length === 2, "user prompt offered");
6872 assert.lengthOf(harness.terminalEvents(), 1);
6873 assert.isTrue(yield* harness.hasPendingBackgroundWork);
6874
6875 yield* Queue.offer(
6876 harness.sdkMessages,
6877 makeResultFrame({
6878 uuid: "00000000-0000-4000-8000-000000000105",
6879 result: "The build passed; nothing else pending.",
6880 }),
6881 );
6882 yield* awaitUntil(() => harness.terminalEvents().length === 2, "user turn terminal");
6883 assert.equal(harness.terminalEvents()[1]?.status, "completed");
6884 assert.isFalse(
6885 harness.events.some(
6886 (event) => event.type === "message.updated" && event.message.text === WAKE_RESULT_TEXT,
6887 ),
6888 );
6889
6890 // The continuation run queued behind the user turn drains the wake
6891 // output afterwards.
6892 yield* harness.runtime.startTurn(
6893 makeClaudeTestTurnInput({
6894 threadId: harness.threadId,
6895 providerThread: harness.providerThread,
6896 now,
6897 attemptId: RunAttemptId.make("attempt-claude-wake-4c"),
6898 text: "Background task completed.",
6899 attachments: [],
6900 providerTurnOrdinal: 3,
6901 messageCreatedBy: "agent",
6902 messageCreationSource: "provider",
6903 }),
6904 );
6905 yield* awaitUntil(() => harness.terminalEvents().length === 3, "continuation terminal");
6906 assert.equal(harness.terminalEvents()[2]?.status, "completed");
6907 assert.lengthOf(harness.offeredMessages, 2);
6908 assert.isTrue(
6909 harness.events.some(
6910 (event) => event.type === "message.updated" && event.message.text === WAKE_RESULT_TEXT,
6911 ),
6912 );
6913 assert.isFalse(
6914 harness.events.some(
6915 (event) =>
6916 event.type !== "provider_thread.updated" &&
6917 JSON.stringify(event).includes(WAKE_TASK_ID),
6918 ),
6919 );
6920 assert.isFalse(yield* harness.hasPendingBackgroundWork);
6921 }).pipe(
6922 Effect.provide(
6923 Layer.mergeAll(
6924 IdAllocator.layer,
6925 McpProviderSessions.layer,
6926 ScriptProgress.layerUnavailable,
6927 NodeServices.layer,
6928 ),
6929 ),
6930 ),
6931 ),
6932 );
6933
6934 it.effect("terminalizes an agent server wake from a positive task-notification result", () =>
6935 Effect.scoped(
6936 Effect.gen(function* () {
6937 const harness = yield* makeWakeHarness;
6938 const now = yield* DateTime.now;
6939 const wakeText = "The background command completed.";
6940
6941 yield* harness.runtime.startTurn(
6942 makeClaudeTestTurnInput({
6943 threadId: harness.threadId,
6944 providerThread: harness.providerThread,
6945 now,
6946 attemptId: RunAttemptId.make("attempt-claude-agent-server-wake"),
6947 text: "Background task completed.",
6948 attachments: [],
6949 messageCreatedBy: "agent",
6950 messageCreationSource: "server",
6951 }),
6952 );
6953 yield* Queue.offer(
6954 harness.sdkMessages,
6955 makeAssistantTextFrame({
6956 uuid: "00000000-0000-4000-8000-00000000010b",
6957 text: wakeText,
6958 }),
6959 );
6960 yield* Queue.offer(
6961 harness.sdkMessages,
6962 makeResultFrame({
6963 uuid: "00000000-0000-4000-8000-00000000010c",
6964 result: wakeText,
6965 numTurns: 154,
6966 origin: { kind: "task-notification" },
6967 }),
6968 );
6969 yield* awaitUntil(() => harness.terminalEvents().length === 1, "server wake terminal");
6970 assert.equal(harness.terminalEvents()[0]?.status, "completed");
6971
6972 yield* harness.runtime.startTurn(
6973 makeClaudeTestTurnInput({
6974 threadId: harness.threadId,
6975 providerThread: harness.providerThread,
6976 now,
6977 attemptId: RunAttemptId.make("attempt-claude-agent-server-next"),
6978 text: "What finished?",
6979 attachments: [],
6980 providerTurnOrdinal: 2,
6981 }),
6982 );
6983 yield* Queue.offer(
6984 harness.sdkMessages,
6985 makeResultFrame({
6986 uuid: "00000000-0000-4000-8000-00000000010d",
6987 result: "The background command finished.",
6988 }),
6989 );
6990 yield* awaitUntil(() => harness.terminalEvents().length === 2, "queued turn terminal");
6991 assert.equal(harness.terminalEvents()[1]?.status, "completed");
6992 }).pipe(
6993 Effect.provide(
6994 Layer.mergeAll(
6995 IdAllocator.layer,
6996 McpProviderSessions.layer,
6997 ScriptProgress.layerUnavailable,
6998 NodeServices.layer,
6999 ),
7000 ),
7001 ),
7002 ),
7003 );
7004
7005 it.effect("terminalizes a user mobile turn from a positive task-notification result", () =>
7006 Effect.scoped(
7007 Effect.gen(function* () {
7008 const harness = yield* makeWakeHarness;
7009 const now = yield* DateTime.now;
7010 const fallbackText = "The ordinary mobile turn completed.";
7011
7012 yield* harness.runtime.startTurn(
7013 makeClaudeTestTurnInput({
7014 threadId: harness.threadId,
7015 providerThread: harness.providerThread,
7016 now,
7017 attemptId: RunAttemptId.make("attempt-claude-mobile-notif-origin"),
7018 text: "Complete this task.",
7019 attachments: [],
7020 messageCreationSource: "mobile",
7021 }),
7022 );
7023 yield* Queue.offer(
7024 harness.sdkMessages,
7025 makeResultFrame({
7026 uuid: "00000000-0000-4000-8000-00000000010e",
7027 result: fallbackText,
7028 numTurns: 60,
7029 origin: { kind: "task-notification" },
7030 }),
7031 );
7032 yield* awaitUntil(() => harness.terminalEvents().length === 1, "mobile turn terminal");
7033 assert.equal(harness.terminalEvents()[0]?.status, "completed");
7034 assert.isTrue(
7035 harness.events.some(
7036 (event) => event.type === "message.updated" && event.message.text === fallbackText,
7037 ),
7038 );
7039 }).pipe(
7040 Effect.provide(
7041 Layer.mergeAll(
7042 IdAllocator.layer,
7043 McpProviderSessions.layer,
7044 ScriptProgress.layerUnavailable,
7045 NodeServices.layer,
7046 ),
7047 ),
7048 ),
7049 ),
7050 );
7051
7052 it.effect("ignores a zero-turn task-notification origin result during a normal user turn", () =>
7053 Effect.scoped(
7054 Effect.gen(function* () {
7055 const harness = yield* makeWakeHarness;
7056 const now = yield* DateTime.now;
7057 const probeAssistantText = "Probe after stale task-notification result.";
7058 const recoveryAssistantText = "Recovered after the interrupt; continuing.";
7059 const staleResultText = STALE_TASK_NOTIFICATION_RESULT_TEXT;
7060 const hasMessageText = (text: string) =>
7061 harness.events.some(
7062 (event) => event.type === "message.updated" && event.message.text === text,
7063 );
7064
7065 yield* harness.runtime.startTurn(
7066 makeClaudeTestTurnInput({
7067 threadId: harness.threadId,
7068 providerThread: harness.providerThread,
7069 now,
7070 attemptId: RunAttemptId.make("attempt-claude-stale-notif-1"),
7071 text: "Continue after interrupt.",
7072 attachments: [],
7073 }),
7074 );
7075 yield* awaitUntil(() => harness.offeredMessages.length === 1, "recovery prompt offered");
7076
7077 // Live interleaving seen after interrupt recovery: a stale stopped
7078 // task_notification and its task-notification-origin result arrive
7079 // before the real root assistant stream.
7080 yield* Queue.offer(
7081 harness.sdkMessages,
7082 claudeSdkFrame({
7083 type: "system",
7084 subtype: "task_notification",
7085 task_id: "task-stale-stopped",
7086 tool_use_id: "toolu-stale-stopped",
7087 status: "stopped",
7088 output_file: "/tmp/task-stale-stopped.log",
7089 summary: "",
7090 uuid: "00000000-0000-4000-8000-000000000107",
7091 session_id: WAKE_NATIVE_SESSION,
7092 }),
7093 );
7094 yield* Queue.offer(harness.sdkMessages, staleTaskNotificationResult);
7095 // Queue-ordered probe: once this assistant text is emitted, the stale
7096 // origin result ahead of it has been consumed.
7097 yield* Queue.offer(
7098 harness.sdkMessages,
7099 makeAssistantTextFrame({
7100 uuid: "00000000-0000-4000-8000-00000000010a",
7101 text: probeAssistantText,
7102 }),
7103 );
7104
7105 yield* awaitUntil(
7106 () => hasMessageText(probeAssistantText),
7107 "probe assistant after stale task-notification result",
7108 );
7109 assert.lengthOf(harness.terminalEvents(), 0);
7110 assert.isFalse(hasMessageText(staleResultText));
7111
7112 yield* Queue.offer(
7113 harness.sdkMessages,
7114 makeAssistantTextFrame({
7115 uuid: "00000000-0000-4000-8000-000000000108",
7116 text: recoveryAssistantText,
7117 }),
7118 );
7119 yield* Queue.offer(
7120 harness.sdkMessages,
7121 makeResultFrame({
7122 uuid: "00000000-0000-4000-8000-000000000109",
7123 result: recoveryAssistantText,
7124 }),
7125 );
7126
7127 yield* awaitUntil(() => harness.terminalEvents().length === 1, "user turn terminal");
7128 assert.equal(harness.terminalEvents()[0]?.status, "completed");
7129 assert.isTrue(hasMessageText(recoveryAssistantText));
7130 assert.isFalse(hasMessageText(staleResultText));
7131 }).pipe(
7132 Effect.provide(
7133 Layer.mergeAll(
7134 IdAllocator.layer,
7135 McpProviderSessions.layer,
7136 ScriptProgress.layerUnavailable,
7137 NodeServices.layer,
7138 ),
7139 ),
7140 ),
7141 ),
7142 );
7143
7144 it.effect("terminalizes a zero-turn task-notification result in a provider continuation", () =>
7145 Effect.scoped(
7146 Effect.gen(function* () {
7147 const harness = yield* makeWakeHarness;
7148 const now = yield* DateTime.now;
7149
7150 yield* harness.runtime.startTurn(
7151 makeClaudeTestTurnInput({
7152 threadId: harness.threadId,
7153 providerThread: harness.providerThread,
7154 now,
7155 attemptId: RunAttemptId.make("attempt-claude-zero-continuation-1"),
7156 text: "Run the build in the background.",
7157 attachments: [],
7158 }),
7159 );
7160 yield* Queue.offer(harness.sdkMessages, wakeTaskStarted);
7161 yield* Queue.offer(harness.sdkMessages, turnOneResult);
7162 yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal");
7163 yield* Queue.offer(harness.sdkMessages, wakeNotification);
7164 yield* Queue.offer(
7165 harness.sdkMessages,
7166 makeResultFrame({
7167 uuid: "00000000-0000-4000-8000-00000000010f",
7168 result: "Wake result with no model turns.",
7169 numTurns: 0,
7170 origin: { kind: "task-notification" },
7171 }),
7172 );
7173 yield* awaitUntil(() => harness.continuationRequests.length === 1, "continuation request");
7174
7175 yield* harness.runtime.startTurn(
7176 makeClaudeTestTurnInput({
7177 threadId: harness.threadId,
7178 providerThread: harness.providerThread,
7179 now,
7180 attemptId: RunAttemptId.make("attempt-claude-zero-continuation-2"),
7181 text: "Background task completed.",
7182 attachments: [],
7183 providerTurnOrdinal: 2,
7184 messageCreatedBy: "agent",
7185 messageCreationSource: "provider",
7186 }),
7187 );
7188 yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal");
7189 assert.equal(harness.terminalEvents()[1]?.status, "completed");
7190 }).pipe(
7191 Effect.provide(
7192 Layer.mergeAll(
7193 IdAllocator.layer,
7194 McpProviderSessions.layer,
7195 ScriptProgress.layerUnavailable,
7196 NodeServices.layer,
7197 ),
7198 ),
7199 ),
7200 ),
7201 );
7202
7203 it.effect(
7204 "emits one interrupted terminal for a positive task-notification result racing interrupt",
7205 () =>
7206 Effect.scoped(
7207 Effect.gen(function* () {
7208 const closeGate = yield* Deferred.make<void>();
7209 const testScope = yield* Scope.Scope;
7210 yield* Scope.addFinalizer(testScope, Deferred.succeed(closeGate, undefined));
7211 const interruptStarted = yield* Deferred.make<void>();
7212 const harness = yield* makeWakeHarnessWithOptions({
7213 close: (sdkMessages) =>
7214 Deferred.await(closeGate).pipe(Effect.andThen(Queue.shutdown(sdkMessages))),
7215 stopTask: () => Effect.void,
7216 interrupt: Deferred.succeed(interruptStarted, undefined),
7217 });
7218 const idAllocator = yield* IdAllocator.IdAllocatorV2;
7219 const now = yield* DateTime.now;
7220 const attemptId = RunAttemptId.make("attempt-claude-interrupt-positive-notif");
7221 const providerTurnId = idAllocator.derive.providerTurn({
7222 driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
7223 nativeTurnId: `turn:${attemptId}`,
7224 });
7225
7226 yield* harness.runtime.startTurn(
7227 makeClaudeTestTurnInput({
7228 threadId: harness.threadId,
7229 providerThread: harness.providerThread,
7230 now,
7231 attemptId,
7232 text: "Stop this task.",
7233 attachments: [],
7234 }),
7235 );
7236 yield* harness.runtime
7237 .interruptTurn({ providerThread: harness.providerThread, providerTurnId })
7238 .pipe(Effect.forkScoped);
7239 yield* Deferred.await(interruptStarted);
7240 yield* Queue.offer(
7241 harness.sdkMessages,
7242 makeResultFrame({
7243 uuid: "00000000-0000-4000-8000-000000000115",
7244 result: "Late result after interrupt.",
7245 numTurns: 7,
7246 origin: { kind: "task-notification" },
7247 }),
7248 );
7249 const terminalized = Exit.isSuccess(
7250 yield* awaitUntil(
7251 () => harness.terminalEvents().length === 1,
7252 "interrupted terminal",
7253 ).pipe(Effect.exit),
7254 );
7255 yield* Deferred.succeed(closeGate, undefined);
7256 assert.isTrue(terminalized);
7257 assert.equal(harness.terminalEvents()[0]?.status, "interrupted");
7258 assert.lengthOf(harness.terminalEvents(), 1);
7259 }).pipe(
7260 Effect.provide(
7261 Layer.mergeAll(
7262 IdAllocator.layer,
7263 McpProviderSessions.layer,
7264 ScriptProgress.layerUnavailable,
7265 NodeServices.layer,
7266 ),
7267 ),
7268 ),
7269 ),
7270 );
7271
7272 it.effect("drops zero-turn task-notification debris racing interrupt", () =>
7273 Effect.scoped(
7274 Effect.gen(function* () {
7275 const closeGate = yield* Deferred.make<void>();
7276 const testScope = yield* Scope.Scope;
7277 yield* Scope.addFinalizer(testScope, Deferred.succeed(closeGate, undefined));
7278 const interruptStarted = yield* Deferred.make<void>();
7279 const harness = yield* makeWakeHarnessWithOptions({
7280 close: (sdkMessages) =>
7281 Deferred.await(closeGate).pipe(Effect.andThen(Queue.shutdown(sdkMessages))),
7282 stopTask: () => Effect.void,
7283 interrupt: Deferred.succeed(interruptStarted, undefined),
7284 });
7285 const idAllocator = yield* IdAllocator.IdAllocatorV2;
7286 const now = yield* DateTime.now;
7287 const attemptId = RunAttemptId.make("attempt-claude-interrupt-zero-notif");
7288 const providerTurnId = idAllocator.derive.providerTurn({
7289 driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
7290 nativeTurnId: `turn:${attemptId}`,
7291 });
7292 const staleText = "Zero-turn debris must not leak.";
7293
7294 yield* harness.runtime.startTurn(
7295 makeClaudeTestTurnInput({
7296 threadId: harness.threadId,
7297 providerThread: harness.providerThread,
7298 now,
7299 attemptId,
7300 text: "Stop this task.",
7301 attachments: [],
7302 }),
7303 );
7304 yield* harness.runtime
7305 .interruptTurn({ providerThread: harness.providerThread, providerTurnId })
7306 .pipe(Effect.forkScoped);
7307 yield* Deferred.await(interruptStarted);
7308 yield* Queue.offer(
7309 harness.sdkMessages,
7310 makeResultFrame({
7311 uuid: "00000000-0000-4000-8000-000000000116",
7312 result: staleText,
7313 numTurns: 0,
7314 origin: { kind: "task-notification" },
7315 }),
7316 );
7317 let debrisYields = 0;
7318 yield* awaitUntil(() => debrisYields++ >= 50, "zero-turn debris consumed");
7319 yield* Deferred.succeed(closeGate, undefined);
7320 yield* awaitUntil(() => harness.terminalEvents().length === 1, "interrupted terminal");
7321 assert.lengthOf(harness.terminalEvents(), 1);
7322 assert.equal(harness.terminalEvents()[0]?.status, "interrupted");
7323 assert.isFalse(
7324 harness.events.some(
7325 (event) => event.type === "message.updated" && event.message.text === staleText,
7326 ),
7327 );
7328 }).pipe(
7329 Effect.provide(
7330 Layer.mergeAll(
7331 IdAllocator.layer,
7332 McpProviderSessions.layer,
7333 ScriptProgress.layerUnavailable,
7334 NodeServices.layer,
7335 ),
7336 ),
7337 ),
7338 ),
7339 );
7340
7341 it.effect("fails a positive task-notification error result", () =>
7342 Effect.scoped(
7343 Effect.gen(function* () {
7344 const harness = yield* makeWakeHarness;
7345 const now = yield* DateTime.now;
7346
7347 yield* harness.runtime.startTurn(
7348 makeClaudeTestTurnInput({
7349 threadId: harness.threadId,
7350 providerThread: harness.providerThread,
7351 now,
7352 attemptId: RunAttemptId.make("attempt-claude-positive-notif-error"),
7353 text: "Run the task.",
7354 attachments: [],
7355 }),
7356 );
7357 yield* Queue.offer(
7358 harness.sdkMessages,
7359 makeResultFrame({
7360 uuid: "00000000-0000-4000-8000-000000000117",
7361 result: "The task failed.",
7362 numTurns: 7,
7363 origin: { kind: "task-notification" },
7364 subtype: "error_during_execution",
7365 isError: true,
7366 errors: ["The task failed."],
7367 }),
7368 );
7369 yield* awaitUntil(() => harness.terminalEvents().length === 1, "failed terminal");
7370 assert.equal(harness.terminalEvents()[0]?.status, "failed");
7371 }).pipe(
7372 Effect.provide(
7373 Layer.mergeAll(
7374 IdAllocator.layer,
7375 McpProviderSessions.layer,
7376 ScriptProgress.layerUnavailable,
7377 NodeServices.layer,
7378 ),
7379 ),
7380 ),
7381 ),
7382 );
7383
7384 it.effect("settles a continuation turn immediately when no wake output is buffered", () =>
7385 Effect.scoped(
7386 Effect.gen(function* () {
7387 const harness = yield* makeWakeHarness;
7388 const now = yield* DateTime.now;
7389
7390 yield* harness.runtime.startTurn(
7391 makeClaudeTestTurnInput({
7392 threadId: harness.threadId,
7393 providerThread: harness.providerThread,
7394 now,
7395 attemptId: RunAttemptId.make("attempt-claude-wake-3"),
7396 text: "Background task completed.",
7397 attachments: [],
7398 messageCreatedBy: "agent",
7399 messageCreationSource: "provider",
7400 }),
7401 );
7402
7403 yield* awaitUntil(() => harness.terminalEvents().length === 1, "spurious terminal");
7404 assert.equal(harness.terminalEvents()[0]?.status, "completed");
7405 assert.lengthOf(harness.offeredMessages, 0);
7406 }).pipe(
7407 Effect.provide(
7408 Layer.mergeAll(
7409 IdAllocator.layer,
7410 McpProviderSessions.layer,
7411 ScriptProgress.layerUnavailable,
7412 NodeServices.layer,
7413 ),
7414 ),
7415 ),
7416 ),
7417 );
7418
7419 // The CLI sends one frame per content block, each carrying the id of the
7420 // native message it belongs to.
7421 const makeSubagentAssistantFrames = (input: {
7422 readonly parentToolUseId: string;
7423 readonly uuid: string;
7424 readonly messageId?: string;
7425 readonly text?: string;
7426 readonly bashToolUseId?: string;
7427 }): ReadonlyArray<SDKMessage> =>
7428 [
7429 ...(input.text === undefined ? [] : [{ type: "text", text: input.text }]),
7430 ...(input.bashToolUseId === undefined
7431 ? []
7432 : [
7433 {
7434 type: "tool_use",
7435 id: input.bashToolUseId,
7436 name: "Bash",
7437 input: { command: "git log -5" },
7438 },
7439 ]),
7440 ].map((block, index) =>
7441 claudeSdkFrame({
7442 type: "assistant",
7443 message: {
7444 model: "claude-sonnet-4-6",
7445 id: input.messageId ?? `msg_${input.uuid}`,
7446 type: "message",
7447 role: "assistant",
7448 content: [block],
7449 },
7450 parent_tool_use_id: input.parentToolUseId,
7451 uuid: index === 0 ? input.uuid : `${input.uuid}:${index}`,
7452 session_id: WAKE_NATIVE_SESSION,
7453 }),
7454 );
7455 const makeSubagentToolResultFrame = (input: {
7456 readonly parentToolUseId: string;
7457 readonly uuid: string;
7458 readonly toolUseId: string;
7459 }) =>
7460 claudeSdkFrame({
7461 type: "user",
7462 message: {
7463 role: "user",
7464 content: [{ type: "tool_result", tool_use_id: input.toolUseId, content: "ok" }],
7465 },
7466 parent_tool_use_id: input.parentToolUseId,
7467 uuid: input.uuid,
7468 session_id: WAKE_NATIVE_SESSION,
7469 });
7470 const makeSubagentTaskStartedFrame = (input: {
7471 readonly taskId: string;
7472 readonly toolUseId: string;
7473 readonly uuid: string;
7474 }) =>
7475 claudeSdkFrame({
7476 type: "system",
7477 subtype: "task_started",
7478 task_id: input.taskId,
7479 tool_use_id: input.toolUseId,
7480 description: "Audit recent commits",
7481 subagent_type: "general-purpose",
7482 is_backgrounded: true,
7483 task_type: "local_agent",
7484 prompt: "Audit the last five commits.",
7485 uuid: input.uuid,
7486 session_id: WAKE_NATIVE_SESSION,
7487 });
7488 const makeSubagentNotificationFrame = (input: {
7489 readonly taskId: string;
7490 readonly toolUseId: string;
7491 readonly summary: string;
7492 readonly uuid: string;
7493 }) =>
7494 claudeSdkFrame({
7495 type: "system",
7496 subtype: "task_notification",
7497 task_id: input.taskId,
7498 tool_use_id: input.toolUseId,
7499 status: "completed",
7500 output_file: `/tmp/${input.taskId}.output`,
7501 summary: input.summary,
7502 uuid: input.uuid,
7503 session_id: WAKE_NATIVE_SESSION,
7504 });
7505 const subagentRouting = (
7506 events: ReadonlyArray<ProviderAdapter.ProviderAdapterV2Event>,
7507 nativeToolIds: ReadonlyArray<string>,
7508 ) => {
7509 const childThreadId =
7510 events.find((event) => event.type === "subagent.updated")?.subagent.childThreadId ??
7511 undefined;
7512 const toolThreadIds = new Map<string, Set<string>>();
7513 for (const event of events) {
7514 const nativeId =
7515 event.type === "turn_item.updated" ? event.turnItem.nativeItemRef?.nativeId : undefined;
7516 if (event.type === "turn_item.updated" && nativeId && nativeToolIds.includes(nativeId)) {
7517 toolThreadIds.set(
7518 nativeId,
7519 (toolThreadIds.get(nativeId) ?? new Set()).add(event.turnItem.threadId),
7520 );
7521 }
7522 }
7523 const assistantTexts = (threadId: string | undefined) =>
7524 events.flatMap((event) =>
7525 event.type === "message.updated" &&
7526 event.message.role === "assistant" &&
7527 event.message.threadId === threadId
7528 ? [event.message.text]
7529 : [],
7530 );
7531 return { childThreadId, toolThreadIds, assistantTexts };
7532 };
7533
7534 it.effect("a subagent re-run in the foreground does not join a later wake", () =>
7535 Effect.scoped(
7536 Effect.gen(function* () {
7537 const harness = yield* makeWakeHarness;
7538 const now = yield* DateTime.now;
7539 const taskId = "task-foreground-rerun";
7540 const toolUseId = "toolu_foreground_rerun";
7541 const userTurn = (attempt: string, providerTurnOrdinal: number) =>
7542 harness.runtime.startTurn(
7543 makeClaudeTestTurnInput({
7544 threadId: harness.threadId,
7545 providerThread: harness.providerThread,
7546 now,
7547 attemptId: RunAttemptId.make(attempt),
7548 text: "Go on.",
7549 attachments: [],
7550 providerTurnOrdinal,
7551 }),
7552 );
7553 const result = (uuid: string) => makeResultFrame({ uuid, result: "Done." });
7554 const ended = (uuid: string) =>
7555 makeSubagentNotificationFrame({ taskId, toolUseId, summary: "AUDITED", uuid });
7556
7557 yield* userTurn("attempt-rerun-1", 1);
7558 yield* Queue.offer(
7559 harness.sdkMessages,
7560 makeSubagentTaskStartedFrame({
7561 taskId,
7562 toolUseId,
7563 uuid: "00000000-0000-4000-8000-000000000901",
7564 }),
7565 );
7566 yield* Queue.offer(harness.sdkMessages, result("00000000-0000-4000-8000-000000000902"));
7567 yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal");
7568 // The backgrounded subagent ends idle; its wake names it.
7569 yield* Queue.offer(harness.sdkMessages, ended("00000000-0000-4000-8000-000000000903"));
7570 yield* awaitUntil(() => harness.continuationRequests.length === 1, "subagent wake");
7571 yield* Queue.offer(harness.sdkMessages, result("00000000-0000-4000-8000-000000000904"));
7572 yield* harness.runtime.startTurn(
7573 makeClaudeTestTurnInput({
7574 threadId: harness.threadId,
7575 providerThread: harness.providerThread,
7576 now,
7577 attemptId: RunAttemptId.make("attempt-rerun-2"),
7578 text: "Background task completed.",
7579 attachments: [],
7580 providerTurnOrdinal: 2,
7581 messageCreatedBy: "agent",
7582 messageCreationSource: "provider",
7583 }),
7584 );
7585 yield* awaitUntil(() => harness.terminalEvents().length === 2, "wake terminal");
7586
7587 // A later turn re-runs it in the foreground and starts a background command.
7588 yield* userTurn("attempt-rerun-3", 3);
7589 yield* Queue.offer(
7590 harness.sdkMessages,
7591 claudeSdkFrame({
7592 ...makeSubagentTaskStartedFrame({
7593 taskId,
7594 toolUseId,
7595 uuid: "00000000-0000-4000-8000-000000000905",
7596 }),
7597 is_backgrounded: false,
7598 }),
7599 );
7600 yield* Queue.offer(harness.sdkMessages, ended("00000000-0000-4000-8000-000000000906"));
7601 yield* Queue.offer(harness.sdkMessages, wakeTaskStarted);
7602 yield* Queue.offer(harness.sdkMessages, result("00000000-0000-4000-8000-000000000907"));
7603 yield* awaitUntil(() => harness.terminalEvents().length === 3, "third turn terminal");
7604
7605 yield* Queue.offer(harness.sdkMessages, wakeNotification);
7606 yield* Queue.offer(harness.sdkMessages, wakeAssistant);
7607 yield* awaitUntil(() => harness.continuationRequests.length === 2, "command wake");
7608 assert.equal(
7609 harness.continuationRequests[1]?.notification?.summary,
7610 `Command "${WAKE_TASK_DESCRIPTION}" finished`,
7611 );
7612 }).pipe(
7613 Effect.provide(
7614 Layer.mergeAll(
7615 IdAllocator.layer,
7616 McpProviderSessions.layer,
7617 ScriptProgress.layerUnavailable,
7618 NodeServices.layer,
7619 ),
7620 ),
7621 ),
7622 ),
7623 );
7624
7625 it.effect("a turn that fails to start does not expire a queued wake's report", () =>
7626 Effect.scoped(
7627 Effect.gen(function* () {
7628 const harness = yield* makeWakeHarness;
7629 const now = yield* DateTime.now;
7630 const userTurn = (
7631 attempt: string,
7632 providerTurnOrdinal: number,
7633 attachments: ProviderAdapter.ProviderAdapterV2TurnInput["message"]["attachments"] = [],
7634 ) =>
7635 harness.runtime.startTurn(
7636 makeClaudeTestTurnInput({
7637 threadId: harness.threadId,
7638 providerThread: harness.providerThread,
7639 now,
7640 attemptId: RunAttemptId.make(attempt),
7641 text: "Go on.",
7642 attachments,
7643 providerTurnOrdinal,
7644 }),
7645 );
7646
7647 yield* userTurn("attempt-failed-start-1", 1);
7648 yield* Queue.offer(harness.sdkMessages, wakeTaskStarted);
7649 yield* Queue.offer(harness.sdkMessages, turnOneResult);
7650 yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal");
7651 // The command ends idle; Claude has not started its wake yet.
7652 yield* Queue.offer(harness.sdkMessages, wakeNotification);
7653 let notificationYields = 0;
7654 yield* awaitUntil(() => notificationYields++ >= 50, "notification to be recorded");
7655 assert.isFalse(yield* harness.hasPendingBackgroundWork);
7656
7657 // The next prompt names an attachment that is gone, so it never reaches Claude.
7658 const missing = ChatImageAttachment.make({
7659 type: "image",
7660 id: ChatAttachmentId.make("thread-claude-wake-12345678-1234-1234-1234-123456789abc"),
7661 name: "gone.png",
7662 mimeType: "image/png",
7663 sizeBytes: 4,
7664 });
7665 const failed = yield* Effect.exit(userTurn("attempt-failed-start-2", 2, [missing]));
7666 assert.isTrue(Exit.isFailure(failed));
7667
7668 // The user's next prompt runs before Claude's wake does.
7669 yield* userTurn("attempt-failed-start-3", 2);
7670 yield* Queue.offer(
7671 harness.sdkMessages,
7672 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000911", result: "Answered." }),
7673 );
7674 yield* awaitUntil(() => harness.terminalEvents().length === 2, "second turn terminal");
7675
7676 yield* Queue.offer(harness.sdkMessages, wakeAssistant);
7677 yield* awaitUntil(() => harness.continuationRequests.length === 1, "command wake");
7678 assert.equal(
7679 harness.continuationRequests[0]?.notification?.summary,
7680 `Command "${WAKE_TASK_DESCRIPTION}" finished`,
7681 );
7682 }).pipe(
7683 Effect.provide(
7684 Layer.mergeAll(
7685 IdAllocator.layer,
7686 McpProviderSessions.layer,
7687 ScriptProgress.layerUnavailable,
7688 NodeServices.layer,
7689 ),
7690 ),
7691 ),
7692 ),
7693 );
7694
7695 it.effect("keeps a subagent a queued wake turn launches with its continuation", () =>
7696 Effect.scoped(
7697 Effect.gen(function* () {
7698 const TASK_ID = "a-wake-launched-subagent";
7699 const TOOL_USE_ID = "toolu_01WakeLaunchedAgent";
7700 const harness = yield* makeWakeHarness;
7701 const now = yield* DateTime.now;
7702 const firstAttempt = RunAttemptId.make("attempt-claude-wake-subagent-1");
7703 const userAttempt = RunAttemptId.make("attempt-claude-wake-subagent-2");
7704 const continuationAttempt = RunAttemptId.make("attempt-claude-wake-subagent-3");
7705 const runOf = (attemptId: RunAttemptId) => RunId.make(`run-${attemptId}`);
7706 const stamp = (frame: SDKMessage, promptIndex: number) =>
7707 claudeSdkFrame({
7708 ...frame,
7709 user_message_uuid: harness.promptUuid(promptIndex),
7710 });
7711
7712 yield* harness.runtime.startTurn(
7713 makeClaudeTestTurnInput({
7714 threadId: harness.threadId,
7715 providerThread: harness.providerThread,
7716 now,
7717 attemptId: firstAttempt,
7718 text: "Run the build in the background.",
7719 attachments: [],
7720 }),
7721 );
7722 yield* Queue.offer(harness.sdkMessages, stamp(wakeTaskStarted, 0));
7723 yield* Queue.offer(
7724 harness.sdkMessages,
7725 stamp(
7726 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000770", result: "STARTED" }),
7727 0,
7728 ),
7729 );
7730 yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal");
7731
7732 // While the user's prompt is queued, the wake turn launches a
7733 // subagent; its lifecycle and child frames follow the wake turn.
7734 yield* harness.runtime.startTurn(
7735 makeClaudeTestTurnInput({
7736 threadId: harness.threadId,
7737 providerThread: harness.providerThread,
7738 now,
7739 attemptId: userAttempt,
7740 text: "Reply with exactly: USER_REPLY",
7741 attachments: [],
7742 providerTurnOrdinal: 2,
7743 }),
7744 );
7745 const wakeFrames = [
7746 wakeNotification,
7747 claudeSdkFrame({
7748 type: "assistant",
7749 message: {
7750 model: "claude-sonnet-4-6",
7751 id: "msg_wake_agent",
7752 type: "message",
7753 role: "assistant",
7754 content: [
7755 {
7756 type: "tool_use",
7757 id: TOOL_USE_ID,
7758 name: "Agent",
7759 input: { description: "Audit recent commits", prompt: "Audit them." },
7760 },
7761 ],
7762 },
7763 parent_tool_use_id: null,
7764 uuid: "00000000-0000-4000-8000-000000000771",
7765 session_id: WAKE_NATIVE_SESSION,
7766 }),
7767 makeSubagentTaskStartedFrame({
7768 taskId: TASK_ID,
7769 toolUseId: TOOL_USE_ID,
7770 uuid: "00000000-0000-4000-8000-000000000772",
7771 }),
7772 ...makeSubagentAssistantFrames({
7773 parentToolUseId: TOOL_USE_ID,
7774 uuid: "00000000-0000-4000-8000-000000000773",
7775 text: "AUDIT_DONE",
7776 }),
7777 makeSubagentNotificationFrame({
7778 taskId: TASK_ID,
7779 toolUseId: TOOL_USE_ID,
7780 summary: "AUDIT_DONE",
7781 uuid: "00000000-0000-4000-8000-000000000774",
7782 }),
7783 claudeSdkFrame({
7784 type: "user",
7785 message: {
7786 role: "user",
7787 content: [{ type: "tool_result", tool_use_id: TOOL_USE_ID, content: "AUDIT_DONE" }],
7788 },
7789 parent_tool_use_id: null,
7790 uuid: "00000000-0000-4000-8000-000000000775",
7791 session_id: WAKE_NATIVE_SESSION,
7792 }),
7793 wakeResult,
7794 stamp(
7795 makeAssistantTextFrame({
7796 uuid: "00000000-0000-4000-8000-000000000776",
7797 text: "USER_REPLY",
7798 }),
7799 1,
7800 ),
7801 stamp(
7802 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000777", result: "USER_REPLY" }),
7803 1,
7804 ),
7805 ];
7806 for (const frame of wakeFrames) {
7807 yield* Queue.offer(harness.sdkMessages, frame);
7808 }
7809 yield* awaitUntil(() => harness.terminalEvents().length === 2, "user turn terminal");
7810
7811 yield* harness.runtime.startTurn(
7812 makeClaudeTestTurnInput({
7813 threadId: harness.threadId,
7814 providerThread: harness.providerThread,
7815 now,
7816 attemptId: continuationAttempt,
7817 text: "Background task completed.",
7818 attachments: [],
7819 providerTurnOrdinal: 3,
7820 messageCreatedBy: "agent",
7821 messageCreationSource: "provider",
7822 }),
7823 );
7824 yield* awaitUntil(() => harness.terminalEvents().length === 3, "continuation terminal");
7825
7826 const subagentRuns = harness.events.flatMap((event) =>
7827 event.type === "subagent.updated" && event.subagent.nativeTaskRef?.nativeId === TASK_ID
7828 ? [event.subagent.runId]
7829 : [],
7830 );
7831 assert.isNotEmpty(subagentRuns);
7832 assert.isTrue(subagentRuns.every((runId) => runId === runOf(continuationAttempt)));
7833 const finalSubagent = harness.events.findLast(
7834 (event) =>
7835 event.type === "subagent.updated" && event.subagent.nativeTaskRef?.nativeId === TASK_ID,
7836 );
7837 assert.equal(
7838 finalSubagent?.type === "subagent.updated" && finalSubagent.subagent.status,
7839 "completed",
7840 );
7841 const userRunItems = harness.events.flatMap((event) =>
7842 event.type === "turn_item.updated" && event.turnItem.runId === runOf(userAttempt)
7843 ? [event.turnItem.type]
7844 : [],
7845 );
7846 assert.deepEqual([...new Set(userRunItems)], ["assistant_message"]);
7847 }).pipe(
7848 Effect.provide(
7849 Layer.mergeAll(
7850 IdAllocator.layer,
7851 McpProviderSessions.layer,
7852 ScriptProgress.layerUnavailable,
7853 NodeServices.layer,
7854 ),
7855 ),
7856 ),
7857 ),
7858 );
7859
7860 it.effect("routes a subagent that starts while the root turn is idle", () =>
7861 Effect.scoped(
7862 Effect.gen(function* () {
7863 const TASK_ID = "task-idle-subagent";
7864 const TOOL_USE_ID = "toolu-idle-subagent";
7865 const FINAL_REPORT = "Idle auditor done.";
7866 const harness = yield* makeWakeHarness;
7867 const now = yield* DateTime.now;
7868 yield* harness.runtime.startTurn(
7869 makeClaudeTestTurnInput({
7870 threadId: harness.threadId,
7871 providerThread: harness.providerThread,
7872 now,
7873 attemptId: RunAttemptId.make("attempt-claude-idle-subagent-1"),
7874 text: "Wait for background work.",
7875 attachments: [],
7876 }),
7877 );
7878 yield* Queue.offer(
7879 harness.sdkMessages,
7880 makeResultFrame({
7881 uuid: "00000000-0000-4000-8000-000000000331",
7882 result: "Waiting in the background.",
7883 }),
7884 );
7885 yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal");
7886
7887 // A native wake turn launches a new subagent while T3 has no turn.
7888 const idleFrames = [
7889 makeSubagentTaskStartedFrame({
7890 taskId: TASK_ID,
7891 toolUseId: TOOL_USE_ID,
7892 uuid: "00000000-0000-4000-8000-000000000332",
7893 }),
7894 ...makeSubagentAssistantFrames({
7895 parentToolUseId: TOOL_USE_ID,
7896 uuid: "00000000-0000-4000-8000-000000000333",
7897 text: "Idle auditor working.",
7898 bashToolUseId: "toolu-idle-bash",
7899 }),
7900 makeSubagentToolResultFrame({
7901 parentToolUseId: TOOL_USE_ID,
7902 uuid: "00000000-0000-4000-8000-000000000334",
7903 toolUseId: "toolu-idle-bash",
7904 }),
7905 ...makeSubagentAssistantFrames({
7906 parentToolUseId: TOOL_USE_ID,
7907 uuid: "00000000-0000-4000-8000-000000000335",
7908 text: FINAL_REPORT,
7909 }),
7910 makeSubagentNotificationFrame({
7911 taskId: TASK_ID,
7912 toolUseId: TOOL_USE_ID,
7913 summary: FINAL_REPORT,
7914 uuid: "00000000-0000-4000-8000-000000000336",
7915 }),
7916 ];
7917 for (const frame of idleFrames) {
7918 yield* Queue.offer(harness.sdkMessages, frame);
7919 }
7920 yield* awaitUntil(() => harness.continuationRequests.length === 1, "continuation request");
7921 yield* Queue.offer(
7922 harness.sdkMessages,
7923 makeResultFrame({
7924 uuid: "00000000-0000-4000-8000-000000000337",
7925 result: "The idle auditor finished.",
7926 }),
7927 );
7928 yield* harness.runtime.startTurn(
7929 makeClaudeTestTurnInput({
7930 threadId: harness.threadId,
7931 providerThread: harness.providerThread,
7932 now,
7933 attemptId: RunAttemptId.make("attempt-claude-idle-subagent-2"),
7934 text: "Background task completed.",
7935 attachments: [],
7936 providerTurnOrdinal: 2,
7937 messageCreatedBy: "agent",
7938 messageCreationSource: "provider",
7939 }),
7940 );
7941 yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal");
7942
7943 const routing = subagentRouting(harness.events, ["toolu-idle-bash"]);
7944 assert.isDefined(routing.childThreadId);
7945 assert.deepEqual(
7946 [...(routing.toolThreadIds.get("toolu-idle-bash") ?? [])],
7947 [routing.childThreadId],
7948 );
7949 assert.deepEqual(routing.assistantTexts(routing.childThreadId), [
7950 "Idle auditor working.",
7951 FINAL_REPORT,
7952 ]);
7953 assert.deepEqual(routing.assistantTexts(harness.threadId), [
7954 "Waiting in the background.",
7955 "The idle auditor finished.",
7956 ]);
7957 const finalSubagent = harness.events.findLast((event) => event.type === "subagent.updated");
7958 assert.equal(
7959 finalSubagent?.type === "subagent.updated" && finalSubagent.subagent.status,
7960 "completed",
7961 );
7962 }).pipe(
7963 Effect.provide(
7964 Layer.mergeAll(
7965 IdAllocator.layer,
7966 McpProviderSessions.layer,
7967 ScriptProgress.layerUnavailable,
7968 NodeServices.layer,
7969 ),
7970 ),
7971 ),
7972 ),
7973 );
7974
7975 it.effect("streams a background subagent's work while the root turn is idle", () =>
7976 Effect.scoped(
7977 Effect.gen(function* () {
7978 const TASK_ID = "task-live-idle";
7979 const TOOL_USE_ID = "toolu-live-idle";
7980 const STRADDLING_BASH = "toolu-live-straddling-bash";
7981 const IDLE_BASH = "toolu-live-idle-bash";
7982 const harness = yield* makeWakeHarness;
7983 const now = yield* DateTime.now;
7984 const latestToolItem = (nativeId: string) =>
7985 harness.events.findLast(
7986 (event) =>
7987 event.type === "turn_item.updated" &&
7988 event.turnItem.nativeItemRef?.nativeId === nativeId,
7989 );
7990 yield* harness.runtime.startTurn(
7991 makeClaudeTestTurnInput({
7992 threadId: harness.threadId,
7993 providerThread: harness.providerThread,
7994 now,
7995 attemptId: RunAttemptId.make("attempt-claude-live-idle-1"),
7996 text: "Audit in the background.",
7997 attachments: [],
7998 }),
7999 );
8000 // The root settles while the subagent's first Bash call still runs.
8001 for (const frame of [
8002 makeSubagentTaskStartedFrame({
8003 taskId: TASK_ID,
8004 toolUseId: TOOL_USE_ID,
8005 uuid: "00000000-0000-4000-8000-000000000951",
8006 }),
8007 ...makeSubagentAssistantFrames({
8008 parentToolUseId: TOOL_USE_ID,
8009 uuid: "00000000-0000-4000-8000-000000000952",
8010 bashToolUseId: STRADDLING_BASH,
8011 }),
8012 makeResultFrame({
8013 uuid: "00000000-0000-4000-8000-000000000953",
8014 result: "Auditing in the background.",
8015 }),
8016 ]) {
8017 yield* Queue.offer(harness.sdkMessages, frame);
8018 }
8019 yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal");
8020
8021 for (const frame of [
8022 makeSubagentToolResultFrame({
8023 parentToolUseId: TOOL_USE_ID,
8024 uuid: "00000000-0000-4000-8000-000000000954",
8025 toolUseId: STRADDLING_BASH,
8026 }),
8027 ...makeSubagentAssistantFrames({
8028 parentToolUseId: TOOL_USE_ID,
8029 uuid: "00000000-0000-4000-8000-000000000955",
8030 text: "Still auditing.",
8031 bashToolUseId: IDLE_BASH,
8032 }),
8033 claudeSdkFrame({
8034 type: "system",
8035 subtype: "task_progress",
8036 task_id: TASK_ID,
8037 description: "Reading the last commits",
8038 uuid: "00000000-0000-4000-8000-000000000956",
8039 session_id: WAKE_NATIVE_SESSION,
8040 }),
8041 ]) {
8042 yield* Queue.offer(harness.sdkMessages, frame);
8043 }
8044 // Before anything wakes the root, the idle work is already projected.
8045 yield* awaitUntil(() => {
8046 const event = latestToolItem(IDLE_BASH);
8047 return event?.type === "turn_item.updated" && event.turnItem.status === "running";
8048 }, "idle Bash call");
8049 yield* awaitUntil(
8050 () =>
8051 harness.events.some(
8052 (event) =>
8053 event.type === "subagent.updated" &&
8054 event.subagent.progress === "Reading the last commits",
8055 ),
8056 "idle progress",
8057 );
8058 const routing = subagentRouting(harness.events, [STRADDLING_BASH, IDLE_BASH]);
8059 assert.deepEqual(routing.assistantTexts(routing.childThreadId), ["Still auditing."]);
8060 // The call that spans the turn end keeps the type and input its
8061 // tool_use gave it during the open turn.
8062 const straddling = latestToolItem(STRADDLING_BASH);
8063 const straddlingItem =
8064 straddling?.type === "turn_item.updated" ? straddling.turnItem : undefined;
8065 assert.equal(straddlingItem?.status, "completed");
8066 assert.equal(
8067 straddlingItem?.type === "command_execution" ? straddlingItem.input : undefined,
8068 "git log -5",
8069 );
8070 assert.equal(harness.continuationRequests.length, 0);
8071
8072 for (const frame of [
8073 makeSubagentToolResultFrame({
8074 parentToolUseId: TOOL_USE_ID,
8075 uuid: "00000000-0000-4000-8000-000000000957",
8076 toolUseId: IDLE_BASH,
8077 }),
8078 makeSubagentNotificationFrame({
8079 taskId: TASK_ID,
8080 toolUseId: TOOL_USE_ID,
8081 summary: "Still auditing.",
8082 uuid: "00000000-0000-4000-8000-000000000958",
8083 }),
8084 ]) {
8085 yield* Queue.offer(harness.sdkMessages, frame);
8086 }
8087 yield* awaitUntil(() => harness.continuationRequests.length === 1, "continuation request");
8088 yield* Queue.offer(
8089 harness.sdkMessages,
8090 makeResultFrame({
8091 uuid: "00000000-0000-4000-8000-000000000959",
8092 result: "The auditor finished.",
8093 }),
8094 );
8095 yield* harness.runtime.startTurn(
8096 makeClaudeTestTurnInput({
8097 threadId: harness.threadId,
8098 providerThread: harness.providerThread,
8099 now,
8100 attemptId: RunAttemptId.make("attempt-claude-live-idle-2"),
8101 text: "Background task completed.",
8102 attachments: [],
8103 providerTurnOrdinal: 2,
8104 messageCreatedBy: "agent",
8105 messageCreationSource: "provider",
8106 }),
8107 );
8108 yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal");
8109
8110 const finalRouting = subagentRouting(harness.events, [STRADDLING_BASH, IDLE_BASH]);
8111 assert.deepEqual(finalRouting.assistantTexts(finalRouting.childThreadId), [
8112 "Still auditing.",
8113 ]);
8114 for (const nativeId of [STRADDLING_BASH, IDLE_BASH]) {
8115 assert.deepEqual(
8116 [...(finalRouting.toolThreadIds.get(nativeId) ?? [])],
8117 [finalRouting.childThreadId],
8118 );
8119 const statuses = harness.events.flatMap((event) =>
8120 event.type === "turn_item.updated" &&
8121 event.turnItem.nativeItemRef?.nativeId === nativeId
8122 ? [`${event.turnItem.type}:${event.turnItem.status}`]
8123 : [],
8124 );
8125 assert.deepEqual(statuses, ["command_execution:running", "command_execution:completed"]);
8126 }
8127 const finalSubagent = harness.events.findLast((event) => event.type === "subagent.updated");
8128 assert.equal(
8129 finalSubagent?.type === "subagent.updated" && finalSubagent.subagent.status,
8130 "completed",
8131 );
8132 }).pipe(
8133 Effect.provide(
8134 Layer.mergeAll(
8135 IdAllocator.layer,
8136 McpProviderSessions.layer,
8137 ScriptProgress.layerUnavailable,
8138 NodeServices.layer,
8139 ),
8140 ),
8141 ),
8142 ),
8143 );
8144
8145 it.effect("keeps a nested subagent's launch in order while the root turn is idle", () =>
8146 Effect.scoped(
8147 Effect.gen(function* () {
8148 const OUTER_TASK_ID = "task-idle-outer";
8149 const OUTER_TOOL_USE_ID = "toolu-idle-outer";
8150 const NESTED_TASK_ID = "task-idle-nested";
8151 const NESTED_TOOL_USE_ID = "toolu-idle-nested";
8152 const harness = yield* makeWakeHarness;
8153 const now = yield* DateTime.now;
8154 yield* harness.runtime.startTurn(
8155 makeClaudeTestTurnInput({
8156 threadId: harness.threadId,
8157 providerThread: harness.providerThread,
8158 now,
8159 attemptId: RunAttemptId.make("attempt-claude-idle-nested-1"),
8160 text: "Audit in the background.",
8161 attachments: [],
8162 }),
8163 );
8164 for (const frame of [
8165 makeSubagentTaskStartedFrame({
8166 taskId: OUTER_TASK_ID,
8167 toolUseId: OUTER_TOOL_USE_ID,
8168 uuid: "00000000-0000-4000-8000-000000000971",
8169 }),
8170 makeResultFrame({
8171 uuid: "00000000-0000-4000-8000-000000000972",
8172 result: "Auditing in the background.",
8173 }),
8174 ]) {
8175 yield* Queue.offer(harness.sdkMessages, frame);
8176 }
8177 yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal");
8178
8179 // While the root is idle, the subagent runs a nested one to completion.
8180 for (const frame of [
8181 claudeSdkFrame({
8182 type: "assistant",
8183 message: {
8184 model: "claude-sonnet-4-6",
8185 id: "msg_idle_nested_launch",
8186 type: "message",
8187 role: "assistant",
8188 content: [
8189 {
8190 type: "tool_use",
8191 id: NESTED_TOOL_USE_ID,
8192 name: "Agent",
8193 input: { description: "Nested check", prompt: "Reply with NESTED_OK" },
8194 },
8195 ],
8196 },
8197 parent_tool_use_id: OUTER_TOOL_USE_ID,
8198 uuid: "00000000-0000-4000-8000-000000000973",
8199 session_id: WAKE_NATIVE_SESSION,
8200 }),
8201 claudeSdkFrame({
8202 type: "system",
8203 subtype: "task_started",
8204 task_id: NESTED_TASK_ID,
8205 tool_use_id: NESTED_TOOL_USE_ID,
8206 description: "Nested check",
8207 subagent_type: "general-purpose",
8208 is_backgrounded: false,
8209 spawn_depth: 2,
8210 task_type: "local_agent",
8211 prompt: "Reply with NESTED_OK",
8212 uuid: "00000000-0000-4000-8000-000000000974",
8213 session_id: WAKE_NATIVE_SESSION,
8214 }),
8215 // The outer subagent's progress and own work don't wait for the
8216 // nested start.
8217 claudeSdkFrame({
8218 type: "system",
8219 subtype: "task_progress",
8220 task_id: OUTER_TASK_ID,
8221 description: "Waiting on the nested check",
8222 uuid: "00000000-0000-4000-8000-000000000984",
8223 session_id: WAKE_NATIVE_SESSION,
8224 }),
8225 ...makeSubagentAssistantFrames({
8226 parentToolUseId: OUTER_TOOL_USE_ID,
8227 uuid: "00000000-0000-4000-8000-000000000988",
8228 messageId: "msg_idle_outer_working",
8229 text: "OUTER_WORKING",
8230 }),
8231 ]) {
8232 yield* Queue.offer(harness.sdkMessages, frame);
8233 }
8234 yield* awaitUntil(
8235 () =>
8236 harness.events.some(
8237 (event) => event.type === "message.updated" && event.message.text === "OUTER_WORKING",
8238 ),
8239 "outer work while the nested start waits",
8240 );
8241 assert.lengthOf(harness.continuationRequests, 0);
8242 for (const frame of [
8243 ...makeSubagentAssistantFrames({
8244 parentToolUseId: NESTED_TOOL_USE_ID,
8245 uuid: "00000000-0000-4000-8000-000000000975",
8246 text: "NESTED_OK",
8247 }),
8248 makeSubagentNotificationFrame({
8249 taskId: NESTED_TASK_ID,
8250 toolUseId: NESTED_TOOL_USE_ID,
8251 summary: "NESTED_OK",
8252 uuid: "00000000-0000-4000-8000-000000000976",
8253 }),
8254 makeSubagentToolResultFrame({
8255 parentToolUseId: OUTER_TOOL_USE_ID,
8256 uuid: "00000000-0000-4000-8000-000000000977",
8257 toolUseId: NESTED_TOOL_USE_ID,
8258 }),
8259 ...makeSubagentAssistantFrames({
8260 parentToolUseId: OUTER_TOOL_USE_ID,
8261 uuid: "00000000-0000-4000-8000-000000000978",
8262 text: "OUTER_DONE",
8263 }),
8264 makeSubagentNotificationFrame({
8265 taskId: OUTER_TASK_ID,
8266 toolUseId: OUTER_TOOL_USE_ID,
8267 summary: "OUTER_DONE",
8268 uuid: "00000000-0000-4000-8000-000000000979",
8269 }),
8270 ]) {
8271 yield* Queue.offer(harness.sdkMessages, frame);
8272 }
8273 yield* awaitUntil(() => harness.continuationRequests.length === 1, "continuation request");
8274 yield* awaitUntil(
8275 () =>
8276 harness.events.some(
8277 (event) =>
8278 event.type === "subagent.updated" &&
8279 event.subagent.progress === "Waiting on the nested check",
8280 ),
8281 "outer progress",
8282 );
8283 yield* Queue.offer(
8284 harness.sdkMessages,
8285 makeResultFrame({
8286 uuid: "00000000-0000-4000-8000-000000000980",
8287 result: "The audit finished.",
8288 }),
8289 );
8290 yield* harness.runtime.startTurn(
8291 makeClaudeTestTurnInput({
8292 threadId: harness.threadId,
8293 providerThread: harness.providerThread,
8294 now,
8295 attemptId: RunAttemptId.make("attempt-claude-idle-nested-2"),
8296 text: "Background task completed.",
8297 attachments: [],
8298 providerTurnOrdinal: 2,
8299 messageCreatedBy: "agent",
8300 messageCreationSource: "provider",
8301 }),
8302 );
8303 yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal");
8304
8305 // The nested Agent call is the nested subagent, never a plain tool row.
8306 assert.isFalse(
8307 harness.events.some(
8308 (event) =>
8309 event.type === "turn_item.updated" &&
8310 event.turnItem.nativeItemRef?.nativeId === NESTED_TOOL_USE_ID,
8311 ),
8312 );
8313 const finalSubagents = new Map(
8314 harness.events.flatMap((event) =>
8315 event.type === "subagent.updated"
8316 ? [[event.subagent.nativeTaskRef?.nativeId, event.subagent] as const]
8317 : [],
8318 ),
8319 );
8320 const outer = finalSubagents.get(OUTER_TASK_ID);
8321 const nested = finalSubagents.get(NESTED_TASK_ID);
8322 assert.equal(outer?.status, "completed");
8323 assert.equal(nested?.status, "completed");
8324 assert.equal(nested?.parentNodeId, outer?.id);
8325 assert.deepEqual(
8326 subagentRouting(harness.events, []).assistantTexts(outer?.childThreadId ?? undefined),
8327 ["OUTER_WORKING", "OUTER_DONE"],
8328 );
8329 }).pipe(
8330 Effect.provide(
8331 Layer.mergeAll(
8332 IdAllocator.layer,
8333 McpProviderSessions.layer,
8334 ScriptProgress.layerUnavailable,
8335 NodeServices.layer,
8336 ),
8337 ),
8338 ),
8339 ),
8340 );
8341
8342 it.effect("ends an earlier turn's nested subagent while the root turn is idle", () =>
8343 Effect.scoped(
8344 Effect.gen(function* () {
8345 const OUTER_TASK_ID = "task-earlier-outer";
8346 const OUTER_TOOL_USE_ID = "toolu-earlier-outer";
8347 const NESTED_TASK_ID = "task-earlier-nested";
8348 const NESTED_TOOL_USE_ID = "toolu-earlier-nested";
8349 const harness = yield* makeWakeHarness;
8350 const now = yield* DateTime.now;
8351 const userTurn = (attemptId: string, providerTurnOrdinal: number) =>
8352 harness.runtime.startTurn(
8353 makeClaudeTestTurnInput({
8354 threadId: harness.threadId,
8355 providerThread: harness.providerThread,
8356 now,
8357 attemptId: RunAttemptId.make(attemptId),
8358 text: "Keep auditing.",
8359 attachments: [],
8360 providerTurnOrdinal,
8361 }),
8362 );
8363 // Turn 1 launches the outer subagent, which starts a nested one.
8364 yield* userTurn("attempt-claude-earlier-nested-1", 1);
8365 for (const frame of [
8366 makeSubagentTaskStartedFrame({
8367 taskId: OUTER_TASK_ID,
8368 toolUseId: OUTER_TOOL_USE_ID,
8369 uuid: "00000000-0000-4000-8000-000000000a01",
8370 }),
8371 claudeSdkFrame({
8372 type: "assistant",
8373 message: {
8374 model: "claude-sonnet-4-6",
8375 id: "msg_earlier_nested_launch",
8376 type: "message",
8377 role: "assistant",
8378 content: [
8379 {
8380 type: "tool_use",
8381 id: NESTED_TOOL_USE_ID,
8382 name: "Agent",
8383 input: { description: "Nested check", prompt: "Reply with NESTED_OK" },
8384 },
8385 ],
8386 },
8387 parent_tool_use_id: OUTER_TOOL_USE_ID,
8388 uuid: "00000000-0000-4000-8000-000000000a02",
8389 session_id: WAKE_NATIVE_SESSION,
8390 }),
8391 claudeSdkFrame({
8392 type: "system",
8393 subtype: "task_started",
8394 task_id: NESTED_TASK_ID,
8395 tool_use_id: NESTED_TOOL_USE_ID,
8396 description: "Nested check",
8397 subagent_type: "general-purpose",
8398 is_backgrounded: false,
8399 spawn_depth: 2,
8400 task_type: "local_agent",
8401 prompt: "Reply with NESTED_OK",
8402 uuid: "00000000-0000-4000-8000-000000000a03",
8403 session_id: WAKE_NATIVE_SESSION,
8404 }),
8405 makeResultFrame({
8406 uuid: "00000000-0000-4000-8000-000000000a04",
8407 result: "Auditing in the background.",
8408 }),
8409 ]) {
8410 yield* Queue.offer(harness.sdkMessages, frame);
8411 }
8412 yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal");
8413 // A later turn settles, so its context, which never saw the nested
8414 // launch, is the one the root's idle frames go through.
8415 yield* userTurn("attempt-claude-earlier-nested-2", 2);
8416 yield* Queue.offer(
8417 harness.sdkMessages,
8418 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000a05", result: "Still on it." }),
8419 );
8420 yield* awaitUntil(() => harness.terminalEvents().length === 2, "second turn terminal");
8421
8422 // The nested subagent ends and its Agent call returns while the root
8423 // is idle. The outer subagent's next message marks when both are handled.
8424 for (const frame of [
8425 makeSubagentNotificationFrame({
8426 taskId: NESTED_TASK_ID,
8427 toolUseId: NESTED_TOOL_USE_ID,
8428 summary: "NESTED_OK",
8429 uuid: "00000000-0000-4000-8000-000000000a06",
8430 }),
8431 makeSubagentToolResultFrame({
8432 parentToolUseId: OUTER_TOOL_USE_ID,
8433 uuid: "00000000-0000-4000-8000-000000000a07",
8434 toolUseId: NESTED_TOOL_USE_ID,
8435 }),
8436 ...makeSubagentAssistantFrames({
8437 parentToolUseId: OUTER_TOOL_USE_ID,
8438 uuid: "00000000-0000-4000-8000-000000000a08",
8439 text: "OUTER_AFTER_NESTED",
8440 }),
8441 ]) {
8442 yield* Queue.offer(harness.sdkMessages, frame);
8443 }
8444 yield* awaitUntil(
8445 () =>
8446 harness.events.some(
8447 (event) =>
8448 event.type === "message.updated" && event.message.text === "OUTER_AFTER_NESTED",
8449 ),
8450 "outer work after the nested call returns",
8451 );
8452
8453 // The nested Agent call is the nested subagent, never a plain tool row.
8454 assert.isFalse(
8455 harness.events.some(
8456 (event) =>
8457 event.type === "turn_item.updated" &&
8458 event.turnItem.nativeItemRef?.nativeId === NESTED_TOOL_USE_ID,
8459 ),
8460 );
8461 const nested = harness.events.findLast(
8462 (event) =>
8463 event.type === "subagent.updated" &&
8464 event.subagent.nativeTaskRef?.nativeId === NESTED_TASK_ID,
8465 );
8466 assert.equal(nested?.type === "subagent.updated" && nested.subagent.status, "completed");
8467 assert.lengthOf(harness.continuationRequests, 0);
8468 }).pipe(
8469 Effect.provide(
8470 Layer.mergeAll(
8471 IdAllocator.layer,
8472 McpProviderSessions.layer,
8473 ScriptProgress.layerUnavailable,
8474 NodeServices.layer,
8475 ),
8476 ),
8477 ),
8478 ),
8479 );
8480
8481 it.effect("ends a subagent's open call when the CLI exits while the root turn is idle", () =>
8482 Effect.scoped(
8483 Effect.gen(function* () {
8484 const TOOL_USE_ID = "toolu-idle-exit";
8485 const BASH = "toolu-idle-exit-bash";
8486 const harness = yield* makeWakeHarness;
8487 const now = yield* DateTime.now;
8488 const bashStatuses = () =>
8489 harness.events.flatMap((event) =>
8490 event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === BASH
8491 ? [`${event.turnItem.type}:${event.turnItem.status}`]
8492 : [],
8493 );
8494 yield* harness.runtime.startTurn(
8495 makeClaudeTestTurnInput({
8496 threadId: harness.threadId,
8497 providerThread: harness.providerThread,
8498 now,
8499 attemptId: RunAttemptId.make("attempt-claude-idle-exit"),
8500 text: "Audit in the background.",
8501 attachments: [],
8502 }),
8503 );
8504 for (const frame of [
8505 makeSubagentTaskStartedFrame({
8506 taskId: "task-idle-exit",
8507 toolUseId: TOOL_USE_ID,
8508 uuid: "00000000-0000-4000-8000-000000000981",
8509 }),
8510 ...makeSubagentAssistantFrames({
8511 parentToolUseId: TOOL_USE_ID,
8512 uuid: "00000000-0000-4000-8000-000000000982",
8513 bashToolUseId: BASH,
8514 }),
8515 makeResultFrame({
8516 uuid: "00000000-0000-4000-8000-000000000983",
8517 result: "Auditing in the background.",
8518 }),
8519 ]) {
8520 yield* Queue.offer(harness.sdkMessages, frame);
8521 }
8522 yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal");
8523 assert.deepEqual(bashStatuses(), ["command_execution:running"]);
8524
8525 // The CLI exits before the call returns; its result can never arrive.
8526 yield* Queue.shutdown(harness.sdkMessages);
8527 yield* awaitUntil(() => bashStatuses().length === 2, "call ended");
8528 assert.deepEqual(bashStatuses(), ["command_execution:running", "command_execution:failed"]);
8529 }).pipe(
8530 Effect.provide(
8531 Layer.mergeAll(
8532 IdAllocator.layer,
8533 McpProviderSessions.layer,
8534 ScriptProgress.layerUnavailable,
8535 NodeServices.layer,
8536 ),
8537 ),
8538 ),
8539 ),
8540 );
8541
8542 it.effect("ends a subagent's open call when Stop's close of the CLI times out", () =>
8543 Effect.scoped(
8544 Effect.gen(function* () {
8545 const TOOL_USE_ID = "toolu-idle-close-timeout";
8546 const BASH = "toolu-idle-close-timeout-bash";
8547 // close() leaves the stream open, so Stop gives up waiting for it.
8548 const harness = yield* makeWakeHarnessWithOptions();
8549 const now = yield* DateTime.now;
8550 const bashStatuses = () =>
8551 harness.events.flatMap((event) =>
8552 event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === BASH
8553 ? [`${event.turnItem.type}:${event.turnItem.status}`]
8554 : [],
8555 );
8556 yield* harness.runtime.startTurn(
8557 makeClaudeTestTurnInput({
8558 threadId: harness.threadId,
8559 providerThread: harness.providerThread,
8560 now,
8561 attemptId: RunAttemptId.make("attempt-claude-idle-close-timeout"),
8562 text: "Audit in the background.",
8563 attachments: [],
8564 }),
8565 );
8566 for (const frame of [
8567 makeSubagentTaskStartedFrame({
8568 taskId: "task-idle-close-timeout",
8569 toolUseId: TOOL_USE_ID,
8570 uuid: "00000000-0000-4000-8000-000000000985",
8571 }),
8572 ...makeSubagentAssistantFrames({
8573 parentToolUseId: TOOL_USE_ID,
8574 uuid: "00000000-0000-4000-8000-000000000986",
8575 bashToolUseId: BASH,
8576 }),
8577 makeResultFrame({
8578 uuid: "00000000-0000-4000-8000-000000000987",
8579 result: "Auditing in the background.",
8580 }),
8581 ]) {
8582 yield* Queue.offer(harness.sdkMessages, frame);
8583 }
8584 yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal");
8585 assert.deepEqual(bashStatuses(), ["command_execution:running"]);
8586
8587 const stop = yield* harness.runtime
8588 .interruptTurn({
8589 providerThread: harness.providerThread,
8590 providerTurnId: harness.terminalEvents()[0]!.providerTurnId,
8591 requestRuntimeRestart: true,
8592 })
8593 .pipe(Effect.forkScoped);
8594 yield* TestClock.adjust("10 seconds");
8595 yield* Fiber.join(stop);
8596 assert.deepEqual(bashStatuses(), [
8597 "command_execution:running",
8598 "command_execution:interrupted",
8599 ]);
8600 }).pipe(
8601 Effect.provide(
8602 Layer.mergeAll(
8603 IdAllocator.layer,
8604 McpProviderSessions.layer,
8605 ScriptProgress.layerUnavailable,
8606 NodeServices.layer,
8607 ),
8608 ),
8609 ),
8610 ),
8611 );
8612
8613 it.effect.each(["before", "after"] as const)(
8614 "keeps a subagent call's result when the CLI exits before the drain (result %s the nested launch's)",
8615 (bashResultOrder) =>
8616 Effect.scoped(
8617 Effect.gen(function* () {
8618 const OUTER_TASK_ID = "task-exit-buffered-outer";
8619 const OUTER_TOOL_USE_ID = "toolu-exit-buffered-outer";
8620 const NESTED_TASK_ID = "task-exit-buffered-nested";
8621 const NESTED_TOOL_USE_ID = "toolu-exit-buffered-nested";
8622 const BASH = "toolu-exit-buffered-bash";
8623 const harness = yield* makeWakeHarnessWithOptions({ freshQueueOnReopen: true });
8624 const now = yield* DateTime.now;
8625 const bashItems = () =>
8626 harness.events.flatMap((event) =>
8627 event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === BASH
8628 ? [
8629 `${event.turnItem.type}:${event.turnItem.status}:${JSON.stringify("input" in event.turnItem ? event.turnItem.input : null)}`,
8630 ]
8631 : [],
8632 );
8633 yield* harness.runtime.startTurn(
8634 makeClaudeTestTurnInput({
8635 threadId: harness.threadId,
8636 providerThread: harness.providerThread,
8637 now,
8638 attemptId: RunAttemptId.make("attempt-claude-exit-buffered-1"),
8639 text: "Audit in the background.",
8640 attachments: [],
8641 }),
8642 );
8643 for (const frame of [
8644 makeSubagentTaskStartedFrame({
8645 taskId: OUTER_TASK_ID,
8646 toolUseId: OUTER_TOOL_USE_ID,
8647 uuid: "00000000-0000-4000-8000-000000001001",
8648 }),
8649 makeResultFrame({
8650 uuid: "00000000-0000-4000-8000-000000001002",
8651 result: "Auditing in the background.",
8652 }),
8653 ]) {
8654 yield* Queue.offer(harness.sdkMessages, frame);
8655 }
8656 yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal");
8657
8658 // While the root is idle, the subagent starts Bash; it is projected at once.
8659 for (const frame of makeSubagentAssistantFrames({
8660 parentToolUseId: OUTER_TOOL_USE_ID,
8661 uuid: "00000000-0000-4000-8000-000000001003",
8662 bashToolUseId: BASH,
8663 })) {
8664 yield* Queue.offer(harness.sdkMessages, frame);
8665 }
8666 yield* awaitUntil(() => bashItems().length === 1, "idle Bash call");
8667
8668 // A nested start buffers, and its launch result waits behind it. Bash's
8669 // successful result arrives before or after that launch result.
8670 const bashResultFrame = makeSubagentToolResultFrame({
8671 parentToolUseId: OUTER_TOOL_USE_ID,
8672 uuid: "00000000-0000-4000-8000-000000001006",
8673 toolUseId: BASH,
8674 });
8675 for (const frame of [
8676 claudeSdkFrame({
8677 type: "assistant",
8678 message: {
8679 model: "claude-sonnet-4-6",
8680 id: "msg_exit_buffered_nested_launch",
8681 type: "message",
8682 role: "assistant",
8683 content: [
8684 {
8685 type: "tool_use",
8686 id: NESTED_TOOL_USE_ID,
8687 name: "Agent",
8688 input: { description: "Nested check", prompt: "Reply with NESTED_OK" },
8689 },
8690 ],
8691 },
8692 parent_tool_use_id: OUTER_TOOL_USE_ID,
8693 uuid: "00000000-0000-4000-8000-000000001004",
8694 session_id: WAKE_NATIVE_SESSION,
8695 }),
8696 claudeSdkFrame({
8697 type: "system",
8698 subtype: "task_started",
8699 task_id: NESTED_TASK_ID,
8700 tool_use_id: NESTED_TOOL_USE_ID,
8701 description: "Nested check",
8702 subagent_type: "general-purpose",
8703 is_backgrounded: false,
8704 spawn_depth: 2,
8705 task_type: "local_agent",
8706 prompt: "Reply with NESTED_OK",
8707 uuid: "00000000-0000-4000-8000-000000001005",
8708 session_id: WAKE_NATIVE_SESSION,
8709 }),
8710 ...(bashResultOrder === "before" ? [bashResultFrame] : []),
8711 ...makeSubagentAssistantFrames({
8712 parentToolUseId: NESTED_TOOL_USE_ID,
8713 uuid: "00000000-0000-4000-8000-000000001007",
8714 text: "NESTED_OK",
8715 }),
8716 makeSubagentNotificationFrame({
8717 taskId: NESTED_TASK_ID,
8718 toolUseId: NESTED_TOOL_USE_ID,
8719 summary: "NESTED_OK",
8720 uuid: "00000000-0000-4000-8000-000000001008",
8721 }),
8722 makeSubagentToolResultFrame({
8723 parentToolUseId: OUTER_TOOL_USE_ID,
8724 uuid: "00000000-0000-4000-8000-000000001009",
8725 toolUseId: NESTED_TOOL_USE_ID,
8726 }),
8727 ...(bashResultOrder === "after" ? [bashResultFrame] : []),
8728 makeSubagentNotificationFrame({
8729 taskId: OUTER_TASK_ID,
8730 toolUseId: OUTER_TOOL_USE_ID,
8731 summary: "OUTER_DONE",
8732 uuid: "00000000-0000-4000-8000-000000001010",
8733 }),
8734 ]) {
8735 yield* Queue.offer(harness.sdkMessages, frame);
8736 }
8737 yield* awaitUntil(
8738 () => harness.continuationRequests.length === 1,
8739 "continuation request",
8740 );
8741
8742 // The CLI exits before the continuation run drains the wake buffer.
8743 yield* Queue.shutdown(harness.sdkMessages);
8744 let quietYields = 0;
8745 yield* awaitUntil(() => quietYields++ >= 50, "query exit");
8746 yield* harness.runtime.startTurn(
8747 makeClaudeTestTurnInput({
8748 threadId: harness.threadId,
8749 providerThread: harness.providerThread,
8750 now,
8751 attemptId: RunAttemptId.make("attempt-claude-exit-buffered-2"),
8752 text: "Background task completed.",
8753 attachments: [],
8754 providerTurnOrdinal: 2,
8755 messageCreatedBy: "agent",
8756 messageCreationSource: "provider",
8757 }),
8758 );
8759 // The continuation opened a new CLI process: the first one's exit was handled.
8760 assert.equal(harness.processQueues.length, 2);
8761 yield* Queue.offer(
8762 harness.processQueues[1]!,
8763 makeResultFrame({
8764 uuid: "00000000-0000-4000-8000-000000001011",
8765 result: "The audit finished.",
8766 }),
8767 );
8768 yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal");
8769
8770 const input = JSON.stringify("git log -5");
8771 assert.deepEqual(bashItems(), [
8772 `command_execution:running:${input}`,
8773 `command_execution:completed:${input}`,
8774 ]);
8775 }).pipe(
8776 Effect.provide(
8777 Layer.mergeAll(
8778 IdAllocator.layer,
8779 McpProviderSessions.layer,
8780 ScriptProgress.layerUnavailable,
8781 NodeServices.layer,
8782 ),
8783 ),
8784 ),
8785 ),
8786 );
8787
8788 it.effect(
8789 "ends a subagent's SendMessage with its result when the CLI exits while the resume waits",
8790 () =>
8791 Effect.scoped(
8792 Effect.gen(function* () {
8793 const SENDER_TASK_ID = "task-exit-resume-sender";
8794 const SENDER_TOOL_USE_ID = "toolu-exit-resume-sender";
8795 const RESUMED_TASK_ID = "task-exit-resume-target";
8796 const RESUMED_TOOL_USE_ID = "toolu-exit-resume-target";
8797 const SEND = "toolu-exit-resume-sendmessage";
8798 const SEND_INPUT = { to: RESUMED_TASK_ID, message: "Check the last commit again." };
8799 const harness = yield* makeWakeHarnessWithOptions({ freshQueueOnReopen: true });
8800 const now = yield* DateTime.now;
8801 const sendItems = () =>
8802 harness.events.flatMap((event) =>
8803 event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === SEND
8804 ? [
8805 `${event.turnItem.type}:${event.turnItem.status}:${JSON.stringify("input" in event.turnItem ? event.turnItem.input : null)}`,
8806 ]
8807 : [],
8808 );
8809 yield* harness.runtime.startTurn(
8810 makeClaudeTestTurnInput({
8811 threadId: harness.threadId,
8812 providerThread: harness.providerThread,
8813 now,
8814 attemptId: RunAttemptId.make("attempt-claude-exit-resume-1"),
8815 text: "Run two auditors in the background.",
8816 attachments: [],
8817 }),
8818 );
8819 // The target subagent finishes inside the turn; the sender keeps running.
8820 for (const frame of [
8821 makeSubagentTaskStartedFrame({
8822 taskId: SENDER_TASK_ID,
8823 toolUseId: SENDER_TOOL_USE_ID,
8824 uuid: "00000000-0000-4000-8000-000000001101",
8825 }),
8826 makeSubagentTaskStartedFrame({
8827 taskId: RESUMED_TASK_ID,
8828 toolUseId: RESUMED_TOOL_USE_ID,
8829 uuid: "00000000-0000-4000-8000-000000001102",
8830 }),
8831 makeSubagentNotificationFrame({
8832 taskId: RESUMED_TASK_ID,
8833 toolUseId: RESUMED_TOOL_USE_ID,
8834 summary: "First answer.",
8835 uuid: "00000000-0000-4000-8000-000000001103",
8836 }),
8837 makeResultFrame({
8838 uuid: "00000000-0000-4000-8000-000000001104",
8839 result: "Auditing in the background.",
8840 }),
8841 ]) {
8842 yield* Queue.offer(harness.sdkMessages, frame);
8843 }
8844 yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal");
8845
8846 // While the root is idle, the sender resumes the finished subagent.
8847 // Its SendMessage call is projected at once.
8848 yield* Queue.offer(
8849 harness.sdkMessages,
8850 claudeSdkFrame({
8851 type: "assistant",
8852 message: {
8853 model: "claude-sonnet-4-6",
8854 id: "msg_exit_resume_sendmessage",
8855 type: "message",
8856 role: "assistant",
8857 content: [{ type: "tool_use", id: SEND, name: "SendMessage", input: SEND_INPUT }],
8858 },
8859 parent_tool_use_id: SENDER_TOOL_USE_ID,
8860 uuid: "00000000-0000-4000-8000-000000001105",
8861 session_id: WAKE_NATIVE_SESSION,
8862 }),
8863 );
8864 yield* awaitUntil(() => sendItems().length >= 1, "idle SendMessage call");
8865
8866 // The resume's task_started buffers. The SendMessage result names that
8867 // start's tool use, but its call already shows as a row, so it ends it.
8868 for (const frame of [
8869 claudeSdkFrame({
8870 type: "system",
8871 subtype: "task_started",
8872 task_id: RESUMED_TASK_ID,
8873 tool_use_id: SEND,
8874 description: "Audit recent commits",
8875 is_backgrounded: true,
8876 task_type: "local_agent",
8877 prompt: SEND_INPUT.message,
8878 uuid: "00000000-0000-4000-8000-000000001106",
8879 session_id: WAKE_NATIVE_SESSION,
8880 }),
8881 claudeSdkFrame({
8882 type: "user",
8883 message: {
8884 role: "user",
8885 content: [{ type: "tool_result", tool_use_id: SEND, content: "Message sent." }],
8886 },
8887 parent_tool_use_id: SENDER_TOOL_USE_ID,
8888 uuid: "00000000-0000-4000-8000-000000001107",
8889 session_id: WAKE_NATIVE_SESSION,
8890 }),
8891 makeSubagentNotificationFrame({
8892 taskId: RESUMED_TASK_ID,
8893 toolUseId: SEND,
8894 summary: "Second answer.",
8895 uuid: "00000000-0000-4000-8000-000000001108",
8896 }),
8897 ]) {
8898 yield* Queue.offer(harness.sdkMessages, frame);
8899 }
8900 yield* awaitUntil(
8901 () => harness.continuationRequests.length === 1,
8902 "continuation request",
8903 );
8904 const beforeExit = sendItems();
8905
8906 // The CLI exits before the continuation run drains the wake buffer.
8907 yield* Queue.shutdown(harness.sdkMessages);
8908 let quietYields = 0;
8909 yield* awaitUntil(() => quietYields++ >= 50, "query exit");
8910 yield* harness.runtime.startTurn(
8911 makeClaudeTestTurnInput({
8912 threadId: harness.threadId,
8913 providerThread: harness.providerThread,
8914 now,
8915 attemptId: RunAttemptId.make("attempt-claude-exit-resume-2"),
8916 text: "Background task completed.",
8917 attachments: [],
8918 providerTurnOrdinal: 2,
8919 messageCreatedBy: "agent",
8920 messageCreationSource: "provider",
8921 }),
8922 );
8923 // The continuation opened a new CLI process: the first one's exit was handled.
8924 assert.equal(harness.processQueues.length, 2);
8925 yield* Queue.offer(
8926 harness.processQueues[1]!,
8927 makeResultFrame({
8928 uuid: "00000000-0000-4000-8000-000000001109",
8929 result: "The audit finished.",
8930 }),
8931 );
8932 yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal");
8933
8934 const input = JSON.stringify(SEND_INPUT);
8935 assert.deepEqual(beforeExit, [`dynamic_tool:running:${input}`]);
8936 assert.deepEqual(sendItems(), [
8937 `dynamic_tool:running:${input}`,
8938 `dynamic_tool:completed:${input}`,
8939 ]);
8940 }).pipe(
8941 Effect.provide(
8942 Layer.mergeAll(
8943 IdAllocator.layer,
8944 McpProviderSessions.layer,
8945 ScriptProgress.layerUnavailable,
8946 NodeServices.layer,
8947 ),
8948 ),
8949 ),
8950 ),
8951 );
8952
8953 it.effect("releases held frames before the notification that first names the tool use", () =>
8954 Effect.scoped(
8955 Effect.gen(function* () {
8956 const TASK_ID = "task-late-tool-use";
8957 const TOOL_USE_ID = "toolu-late-tool-use";
8958 const harness = yield* makeWakeHarness;
8959 const now = yield* DateTime.now;
8960 yield* harness.runtime.startTurn(
8961 makeClaudeTestTurnInput({
8962 threadId: harness.threadId,
8963 providerThread: harness.providerThread,
8964 now,
8965 attemptId: RunAttemptId.make("attempt-claude-late-tool-use"),
8966 text: "Run an auditor.",
8967 attachments: [],
8968 }),
8969 );
8970 const frames = [
8971 claudeSdkFrame({
8972 type: "system",
8973 subtype: "task_started",
8974 task_id: TASK_ID,
8975 description: "Audit recent commits",
8976 task_type: "local_agent",
8977 prompt: "Audit the last five commits.",
8978 uuid: "00000000-0000-4000-8000-000000000341",
8979 session_id: WAKE_NATIVE_SESSION,
8980 }),
8981 // task_started carried no tool_use_id, so these frames are held
8982 // until the notification pairs the task with its tool use. The SDK
8983 // types tool_use_id as optional; no recorded or logged task_started
8984 // has omitted it, so this guards the typed contract only.
8985 ...makeSubagentAssistantFrames({
8986 parentToolUseId: TOOL_USE_ID,
8987 uuid: "00000000-0000-4000-8000-000000000342",
8988 text: "Working.",
8989 }),
8990 // The final answer arrives as one snapshot per text block.
8991 ...makeSubagentAssistantFrames({
8992 parentToolUseId: TOOL_USE_ID,
8993 uuid: "00000000-0000-4000-8000-000000000344",
8994 messageId: "msg_late_final",
8995 text: "Part one.",
8996 }),
8997 ...makeSubagentAssistantFrames({
8998 parentToolUseId: TOOL_USE_ID,
8999 uuid: "00000000-0000-4000-8000-000000000345",
9000 messageId: "msg_late_final",
9001 text: "Part two.",
9002 }),
9003 makeSubagentNotificationFrame({
9004 taskId: TASK_ID,
9005 toolUseId: TOOL_USE_ID,
9006 summary: "Part one.\n\nPart two.",
9007 uuid: "00000000-0000-4000-8000-000000000346",
9008 }),
9009 makeResultFrame({
9010 uuid: "00000000-0000-4000-8000-000000000347",
9011 result: "The auditor finished.",
9012 }),
9013 ];
9014 for (const frame of frames) {
9015 yield* Queue.offer(harness.sdkMessages, frame);
9016 }
9017 yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal");
9018
9019 const routing = subagentRouting(harness.events, []);
9020 assert.deepEqual(routing.assistantTexts(routing.childThreadId), [
9021 "Working.",
9022 "Part one.",
9023 "Part two.",
9024 ]);
9025 assert.deepEqual(routing.assistantTexts(harness.threadId), ["The auditor finished."]);
9026 }).pipe(
9027 Effect.provide(
9028 Layer.mergeAll(
9029 IdAllocator.layer,
9030 McpProviderSessions.layer,
9031 ScriptProgress.layerUnavailable,
9032 NodeServices.layer,
9033 ),
9034 ),
9035 ),
9036 ),
9037 );
9038
9039 it.effect.each([
9040 ["", "none"],
9041 [" behind another subagent's start without one", "before"],
9042 [" when another subagent's start without one arrives between its frames", "between"],
9043 ] as const)(
9044 "keeps a subagent's frames in order when a late progress names its tool use while the root is idle%s",
9045 ([, otherStart]) =>
9046 Effect.scoped(
9047 Effect.gen(function* () {
9048 const TASK_ID = "task-late-owner-idle";
9049 const TOOL_USE_ID = "toolu-late-owner-idle";
9050 const BASH = "toolu-late-owner-idle-bash";
9051 const harness = yield* makeWakeHarness;
9052 const now = yield* DateTime.now;
9053 const bashStatuses = () =>
9054 harness.events.flatMap((event) =>
9055 event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === BASH
9056 ? [`${event.turnItem.type}:${event.turnItem.status}`]
9057 : [],
9058 );
9059 yield* harness.runtime.startTurn(
9060 makeClaudeTestTurnInput({
9061 threadId: harness.threadId,
9062 providerThread: harness.providerThread,
9063 now,
9064 attemptId: RunAttemptId.make("attempt-claude-late-owner-idle-1"),
9065 text: "Audit in the background.",
9066 attachments: [],
9067 }),
9068 );
9069 // task_started without tool_use_id (optional in the SDK type), then
9070 // the root settles.
9071 for (const frame of [
9072 claudeSdkFrame({
9073 type: "system",
9074 subtype: "task_started",
9075 task_id: TASK_ID,
9076 description: "Audit recent commits",
9077 subagent_type: "general-purpose",
9078 is_backgrounded: true,
9079 task_type: "local_agent",
9080 prompt: "Audit the last five commits.",
9081 uuid: "00000000-0000-4000-8000-000000000a01",
9082 session_id: WAKE_NATIVE_SESSION,
9083 }),
9084 makeResultFrame({
9085 uuid: "00000000-0000-4000-8000-000000000a02",
9086 result: "Auditing in the background.",
9087 }),
9088 ]) {
9089 yield* Queue.offer(harness.sdkMessages, frame);
9090 }
9091 yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal");
9092
9093 // Another subagent starts while the root is idle, also without a
9094 // tool_use_id, so its start waits in the wake buffer.
9095 const otherStartFrame = claudeSdkFrame({
9096 type: "system",
9097 subtype: "task_started",
9098 task_id: "task-late-owner-other",
9099 description: "Check the changelog",
9100 subagent_type: "general-purpose",
9101 is_backgrounded: true,
9102 task_type: "local_agent",
9103 prompt: "Check the changelog.",
9104 uuid: "00000000-0000-4000-8000-000000000a09",
9105 session_id: WAKE_NATIVE_SESSION,
9106 });
9107 const progressFrame = claudeSdkFrame({
9108 type: "system",
9109 subtype: "task_progress",
9110 task_id: TASK_ID,
9111 tool_use_id: TOOL_USE_ID,
9112 description: "Reading the last commits",
9113 usage: { total_tokens: 1, tool_uses: 1, duration_ms: 1 },
9114 uuid: "00000000-0000-4000-8000-000000000a04",
9115 session_id: WAKE_NATIVE_SESSION,
9116 });
9117 const bashResultFrame = makeSubagentToolResultFrame({
9118 parentToolUseId: TOOL_USE_ID,
9119 uuid: "00000000-0000-4000-8000-000000000a05",
9120 toolUseId: BASH,
9121 });
9122 // Root idle: the child's Bash call arrives before anything names the
9123 // owner's tool use; progress names it before or after the call returns.
9124 for (const frame of [
9125 ...(otherStart === "before" ? [otherStartFrame] : []),
9126 ...makeSubagentAssistantFrames({
9127 parentToolUseId: TOOL_USE_ID,
9128 uuid: "00000000-0000-4000-8000-000000000a03",
9129 text: "First.",
9130 bashToolUseId: BASH,
9131 }),
9132 ...(otherStart === "between"
9133 ? [otherStartFrame, bashResultFrame, progressFrame]
9134 : [progressFrame, bashResultFrame]),
9135 ...makeSubagentAssistantFrames({
9136 parentToolUseId: TOOL_USE_ID,
9137 uuid: "00000000-0000-4000-8000-000000000a06",
9138 text: "Second.",
9139 }),
9140 makeSubagentNotificationFrame({
9141 taskId: TASK_ID,
9142 toolUseId: TOOL_USE_ID,
9143 summary: "Second.",
9144 uuid: "00000000-0000-4000-8000-000000000a07",
9145 }),
9146 ]) {
9147 yield* Queue.offer(harness.sdkMessages, frame);
9148 }
9149 yield* awaitUntil(
9150 () => harness.continuationRequests.length === 1,
9151 "continuation request",
9152 );
9153 yield* Queue.offer(
9154 harness.sdkMessages,
9155 makeResultFrame({
9156 uuid: "00000000-0000-4000-8000-000000000a08",
9157 result: "The auditor finished.",
9158 }),
9159 );
9160 yield* harness.runtime.startTurn(
9161 makeClaudeTestTurnInput({
9162 threadId: harness.threadId,
9163 providerThread: harness.providerThread,
9164 now,
9165 attemptId: RunAttemptId.make("attempt-claude-late-owner-idle-2"),
9166 text: "Background task completed.",
9167 attachments: [],
9168 providerTurnOrdinal: 2,
9169 messageCreatedBy: "agent",
9170 messageCreationSource: "provider",
9171 }),
9172 );
9173 yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal");
9174
9175 const routing = subagentRouting(harness.events, [BASH]);
9176 const childTexts = routing.assistantTexts(routing.childThreadId);
9177 const statuses = bashStatuses();
9178 // Observed order of child texts and Bash item updates, for diagnosis.
9179 const trace = harness.events.flatMap((event) =>
9180 event.type === "message.updated" &&
9181 event.message.role === "assistant" &&
9182 event.message.threadId === routing.childThreadId
9183 ? [`text:${event.message.text}#${event.message.id}`]
9184 : event.type === "turn_item.updated" &&
9185 event.turnItem.nativeItemRef?.nativeId === BASH
9186 ? [`bash:${event.turnItem.status}`]
9187 : [],
9188 );
9189 assert.deepEqual(childTexts, ["First.", "Second."], `trace: ${JSON.stringify(trace)}`);
9190 assert.deepEqual(
9191 [...(routing.toolThreadIds.get(BASH) ?? [])],
9192 [routing.childThreadId],
9193 `trace: ${JSON.stringify(trace)}`,
9194 );
9195 assert.deepEqual(
9196 statuses,
9197 ["command_execution:running", "command_execution:completed"],
9198 `trace: ${JSON.stringify(trace)}`,
9199 );
9200 // Progress that waited behind the subagent's frames still reaches its
9201 // card, and asks for no continuation of its own.
9202 assert.isTrue(
9203 harness.events.some(
9204 (event) =>
9205 event.type === "subagent.updated" &&
9206 event.subagent.progress === "Reading the last commits",
9207 ),
9208 );
9209 assert.lengthOf(harness.continuationRequests, 1);
9210 }).pipe(
9211 Effect.provide(
9212 Layer.mergeAll(
9213 IdAllocator.layer,
9214 McpProviderSessions.layer,
9215 ScriptProgress.layerUnavailable,
9216 NodeServices.layer,
9217 ),
9218 ),
9219 ),
9220 ),
9221 );
9222
9223 it.effect("does not resolve a newer API retry when replaying a held subagent frame", () =>
9224 Effect.scoped(
9225 Effect.gen(function* () {
9226 const TOOL_USE_ID = "toolu-retry-subagent";
9227 const harness = yield* makeWakeHarness;
9228 const now = yield* DateTime.now;
9229 yield* harness.runtime.startTurn(
9230 makeClaudeTestTurnInput({
9231 threadId: harness.threadId,
9232 providerThread: harness.providerThread,
9233 now,
9234 attemptId: RunAttemptId.make("attempt-claude-retry-subagent"),
9235 text: "Run an auditor.",
9236 attachments: [],
9237 }),
9238 );
9239 const frames = [
9240 ...makeSubagentAssistantFrames({
9241 parentToolUseId: TOOL_USE_ID,
9242 uuid: "00000000-0000-4000-8000-000000000351",
9243 text: "Held before registration.",
9244 }),
9245 claudeSdkFrame({
9246 type: "system",
9247 subtype: "api_retry",
9248 attempt: 2,
9249 max_retries: 10,
9250 retry_delay_ms: 1_500,
9251 error_status: 529,
9252 error: "overloaded",
9253 uuid: "00000000-0000-4000-8000-000000000352",
9254 session_id: WAKE_NATIVE_SESSION,
9255 }),
9256 makeSubagentTaskStartedFrame({
9257 taskId: "task-retry-subagent",
9258 toolUseId: TOOL_USE_ID,
9259 uuid: "00000000-0000-4000-8000-000000000353",
9260 }),
9261 ];
9262 for (const frame of frames) {
9263 yield* Queue.offer(harness.sdkMessages, frame);
9264 }
9265 const childTexts = () => {
9266 const routing = subagentRouting(harness.events, []);
9267 return routing.childThreadId === undefined
9268 ? []
9269 : routing.assistantTexts(routing.childThreadId);
9270 };
9271 yield* awaitUntil(() => childTexts().length === 1, "replayed subagent text");
9272 const retryStatuses = harness.events.flatMap((event) =>
9273 event.type === "turn_item.updated" &&
9274 event.turnItem.type === "error" &&
9275 event.turnItem.retry !== undefined
9276 ? [event.turnItem.status]
9277 : [],
9278 );
9279 assert.deepEqual(retryStatuses, ["running"]);
9280 }).pipe(
9281 Effect.provide(
9282 Layer.mergeAll(
9283 IdAllocator.layer,
9284 McpProviderSessions.layer,
9285 ScriptProgress.layerUnavailable,
9286 NodeServices.layer,
9287 ),
9288 ),
9289 ),
9290 ),
9291 );
9292
9293 it.effect("holds subagent frames that precede task_started and shows its result once", () =>
9294 Effect.scoped(
9295 Effect.gen(function* () {
9296 const TASK_ID = "task-early-subagent";
9297 const TOOL_USE_ID = "toolu-early-subagent";
9298 const FINAL_REPORT = "Early auditor done.";
9299 const harness = yield* makeWakeHarness;
9300 const now = yield* DateTime.now;
9301 yield* harness.runtime.startTurn(
9302 makeClaudeTestTurnInput({
9303 threadId: harness.threadId,
9304 providerThread: harness.providerThread,
9305 now,
9306 attemptId: RunAttemptId.make("attempt-claude-early-subagent"),
9307 text: "Run an auditor.",
9308 attachments: [],
9309 }),
9310 );
9311 const frames = [
9312 // The SDK can forward child frames before the task_started that
9313 // registers their subagent.
9314 ...makeSubagentAssistantFrames({
9315 parentToolUseId: TOOL_USE_ID,
9316 uuid: "00000000-0000-4000-8000-000000000321",
9317 text: "Starting early.",
9318 bashToolUseId: "toolu-early-bash",
9319 }),
9320 makeSubagentToolResultFrame({
9321 parentToolUseId: TOOL_USE_ID,
9322 uuid: "00000000-0000-4000-8000-000000000322",
9323 toolUseId: "toolu-early-bash",
9324 }),
9325 makeSubagentTaskStartedFrame({
9326 taskId: TASK_ID,
9327 toolUseId: TOOL_USE_ID,
9328 uuid: "00000000-0000-4000-8000-000000000323",
9329 }),
9330 ...makeSubagentAssistantFrames({
9331 parentToolUseId: TOOL_USE_ID,
9332 uuid: "00000000-0000-4000-8000-000000000324",
9333 text: "Still working.",
9334 }),
9335 // Progress without a tool_use_id replaces the subagent entry while
9336 // the per-turn tool-use alias still points at the previous one.
9337 claudeSdkFrame({
9338 type: "system",
9339 subtype: "task_progress",
9340 task_id: TASK_ID,
9341 description: "Checking the diffs",
9342 uuid: "00000000-0000-4000-8000-000000000325",
9343 session_id: WAKE_NATIVE_SESSION,
9344 }),
9345 ...makeSubagentAssistantFrames({
9346 parentToolUseId: TOOL_USE_ID,
9347 uuid: "00000000-0000-4000-8000-000000000326",
9348 text: FINAL_REPORT,
9349 }),
9350 makeSubagentNotificationFrame({
9351 taskId: TASK_ID,
9352 toolUseId: TOOL_USE_ID,
9353 summary: FINAL_REPORT,
9354 uuid: "00000000-0000-4000-8000-000000000327",
9355 }),
9356 makeResultFrame({
9357 uuid: "00000000-0000-4000-8000-000000000328",
9358 result: "The auditor finished.",
9359 }),
9360 ];
9361 for (const frame of frames) {
9362 yield* Queue.offer(harness.sdkMessages, frame);
9363 }
9364 yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal");
9365
9366 const routing = subagentRouting(harness.events, ["toolu-early-bash"]);
9367 assert.isDefined(routing.childThreadId);
9368 assert.deepEqual(
9369 [...(routing.toolThreadIds.get("toolu-early-bash") ?? [])],
9370 [routing.childThreadId],
9371 );
9372 assert.deepEqual(routing.assistantTexts(routing.childThreadId), [
9373 "Starting early.",
9374 "Still working.",
9375 FINAL_REPORT,
9376 ]);
9377 assert.deepEqual(routing.assistantTexts(harness.threadId), ["The auditor finished."]);
9378 }).pipe(
9379 Effect.provide(
9380 Layer.mergeAll(
9381 IdAllocator.layer,
9382 McpProviderSessions.layer,
9383 ScriptProgress.layerUnavailable,
9384 NodeServices.layer,
9385 ),
9386 ),
9387 ),
9388 ),
9389 );
9390
9391 it.effect.each([false, true])(
9392 "drops an idle start superseded by live completion, later resume %s",
9393 (resume) =>
9394 Effect.scoped(
9395 Effect.gen(function* () {
9396 const harness = yield* makeWakeHarness;
9397 const startTurn = (ordinal: number, continuation = false) =>
9398 harness.runtime.startTurn(
9399 makeClaudeTestTurnInput({
9400 threadId: harness.threadId,
9401 providerThread: harness.providerThread,
9402 now: DateTime.makeUnsafe(0),
9403 attemptId: RunAttemptId.make(`attempt-stale-start-${ordinal}`),
9404 providerTurnOrdinal: ordinal,
9405 text: "Continue work.",
9406 attachments: [],
9407 ...(continuation
9408 ? {
9409 messageCreatedBy: "agent" as const,
9410 messageCreationSource: "provider" as const,
9411 }
9412 : {}),
9413 }),
9414 );
9415 const started = (taskId: string, suffix: string) =>
9416 makeSubagentTaskStartedFrame({
9417 taskId,
9418 toolUseId: `toolu-${taskId}`,
9419 uuid: `00000000-0000-4000-8000-${suffix}`,
9420 });
9421 const finished = (taskId: string, suffix: string) =>
9422 makeSubagentNotificationFrame({
9423 taskId,
9424 toolUseId: `toolu-${taskId}`,
9425 summary: "Finished audit.",
9426 uuid: `00000000-0000-4000-8000-${suffix}`,
9427 });
9428 yield* startTurn(1);
9429 yield* harness.offerAndWait(started("stale-agent", "000000004001"));
9430 yield* harness.offerAndWait(started("wake-agent", "000000004002"));
9431 yield* harness.offerAndWait(
9432 makeResultFrame({
9433 uuid: "00000000-0000-4000-8000-000000004003",
9434 result: "Agents launched.",
9435 }),
9436 );
9437 yield* Queue.take(harness.terminalReceipts);
9438 // The background task resumes in the idle gap, before a user turn.
9439 yield* harness.offerAndWait(started("stale-agent", "000000004004"));
9440 yield* startTurn(2);
9441 yield* harness.offerAndWait(finished("stale-agent", "000000004005"));
9442 yield* harness.offerAndWait(
9443 makeResultFrame({
9444 uuid: "00000000-0000-4000-8000-000000004006",
9445 result: "User turn finished.",
9446 }),
9447 );
9448 yield* Queue.take(harness.terminalReceipts);
9449 if (resume) yield* harness.offerAndWait(started("stale-agent", "000000004007"));
9450 yield* harness.offerAndWait(finished("wake-agent", "000000004008"));
9451 yield* Queue.take(harness.continuationReceipts);
9452 yield* startTurn(3, true);
9453 yield* harness.offerAndWait(
9454 makeResultFrame({
9455 uuid: "00000000-0000-4000-8000-000000004009",
9456 result: "Wake finished.",
9457 }),
9458 );
9459 yield* Queue.take(harness.terminalReceipts);
9460 const latest = harness.events.findLast(
9461 (event) =>
9462 event.type === "subagent.updated" &&
9463 event.subagent.nativeTaskRef?.nativeId === "stale-agent",
9464 );
9465 assert.equal(latest?.type, "subagent.updated");
9466 if (latest?.type !== "subagent.updated") return;
9467 assert.equal(latest.subagent.status, resume ? "running" : "completed");
9468 assert.equal(latest.subagent.result, resume ? null : "Finished audit.");
9469 assert.equal(latest.subagent.completedAt === null, resume);
9470 }).pipe(
9471 Effect.provide(
9472 Layer.mergeAll(
9473 IdAllocator.layer,
9474 McpProviderSessions.layer,
9475 ScriptProgress.layerUnavailable,
9476 NodeServices.layer,
9477 ),
9478 ),
9479 ),
9480 ),
9481 );
9482
9483 const makeStopFixture = (options?: Parameters<typeof makeWakeHarnessWithOptions>[0]) =>
9484 Effect.gen(function* () {
9485 const requests = yield* Queue.unbounded<void>();
9486 const harness = yield* makeWakeHarnessWithOptions({
9487 ...options,
9488 stopTask: (id) =>
9489 Queue.offer(requests, undefined).pipe(
9490 Effect.andThen(options?.stopTask?.(id) ?? Effect.void),
9491 ),
9492 });
9493 let sequence = 4500;
9494 const uuid = () => `00000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}`;
9495 const startTurn = (continuation = false) =>
9496 harness.runtime.startTurn(
9497 makeClaudeTestTurnInput({
9498 threadId: harness.threadId,
9499 providerThread: harness.providerThread,
9500 now: DateTime.makeUnsafe(0),
9501 attemptId: RunAttemptId.make(`attempt-stop-${continuation ? 2 : 1}`),
9502 providerTurnOrdinal: continuation ? 2 : 1,
9503 text: "Run an audit.",
9504 attachments: [],
9505 ...(continuation
9506 ? { messageCreatedBy: "agent" as const, messageCreationSource: "provider" as const }
9507 : {}),
9508 }),
9509 );
9510 const start = (toolUseId = "toolu-stop-agent") =>
9511 harness.offerAndWait(
9512 makeSubagentTaskStartedFrame({
9513 taskId: "stop-agent",
9514 toolUseId,
9515 uuid: uuid(),
9516 }),
9517 );
9518 const notify = (
9519 status: "completed" | "failed" | "stopped",
9520 summary = "Native outcome.",
9521 toolUseId = "toolu-stop-agent",
9522 ) =>
9523 harness.offerAndWait(
9524 claudeSdkFrame({
9525 ...makeSubagentNotificationFrame({
9526 taskId: "stop-agent",
9527 toolUseId,
9528 summary,
9529 uuid: uuid(),
9530 }),
9531 status,
9532 }),
9533 );
9534 const stop = (requestedAt?: DateTime.Utc) =>
9535 harness.runtime.stopSubagent!({
9536 providerThread: harness.providerThread,
9537 nativeTaskId: "stop-agent",
9538 ...(requestedAt === undefined ? {} : { requestedAt }),
9539 });
9540 const finishParent = () =>
9541 harness
9542 .offerAndWait(makeResultFrame({ uuid: uuid(), result: "Launched." }))
9543 .pipe(Effect.andThen(Queue.take(harness.terminalReceipts)));
9544 yield* startTurn();
9545 yield* start();
9546 yield* Queue.take(harness.subagentReceipts);
9547 return {
9548 ...harness,
9549 start,
9550 notify,
9551 stop,
9552 finishParent,
9553 replay: () => startTurn(true),
9554 uuid,
9555 requests,
9556 };
9557 });
9558
9559 it.effect.each([
9560 [false, "no notification"],
9561 [true, "no notification"],
9562 [false, "stop fails"],
9563 [false, "stop hangs"],
9564 ] as const)("never infers an outcome from Stop, owner settled %s and %s", ([idle, failure]) =>
9565 Effect.scoped(
9566 Effect.gen(function* () {
9567 const requested = yield* Deferred.make<void>();
9568 const fixture = yield* makeStopFixture({
9569 stopTask: () =>
9570 Deferred.succeed(requested, undefined).pipe(
9571 Effect.andThen(
9572 failure === "stop fails"
9573 ? new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({
9574 method: "stopTask",
9575 cause: "Task is no longer live",
9576 })
9577 : failure === "stop hangs"
9578 ? Effect.never
9579 : Effect.void,
9580 ),
9581 ),
9582 });
9583 if (idle) yield* fixture.finishParent();
9584 const stopping = yield* fixture.stop().pipe(Effect.exit, Effect.forkScoped);
9585 yield* Deferred.await(requested);
9586 yield* TestClock.adjust("2 seconds");
9587 const result = yield* Fiber.join(stopping);
9588 assert.isTrue(Exit.isFailure(result));
9589 assert.equal(
9590 fixture.events.findLast((event) => event.type === "subagent.updated")?.subagent.status,
9591 "running",
9592 );
9593 assert.isTrue(yield* fixture.hasPendingBackgroundWork);
9594 yield* fixture.notify("completed");
9595 assert.equal((yield* Queue.take(fixture.subagentReceipts)).subagent.status, "completed");
9596 assert.isFalse(yield* fixture.hasPendingBackgroundWork);
9597 }).pipe(
9598 Effect.provide(
9599 Layer.mergeAll(
9600 IdAllocator.layer,
9601 McpProviderSessions.layer,
9602 ScriptProgress.layerUnavailable,
9603 NodeServices.layer,
9604 ),
9605 ),
9606 ),
9607 ),
9608 );
9609
9610 it.effect.each([1, 2, 3, 4, 5, 6, 7, 8])(
9611 "accepts completion during Stop at scheduler step %s",
9612 (pauseAt) =>
9613 Effect.scoped(
9614 Effect.gen(function* () {
9615 const fixture = yield* makeStopFixture();
9616 const resumptions = yield* Queue.unbounded<() => void>();
9617 const scheduler = yield* Scheduler.Scheduler;
9618 const dispatcher = scheduler.makeDispatcher();
9619 let checks = 0;
9620 // Pause Stop before its SDK request and deliver the outcome while it is suspended.
9621 const stopping = yield* fixture.stop().pipe(
9622 Effect.provideService(Scheduler.Scheduler, {
9623 executionMode: scheduler.executionMode,
9624 shouldYield: () => ++checks === pauseAt,
9625 makeDispatcher: () => ({
9626 scheduleTask: (task, priority) => {
9627 if (checks === pauseAt) Queue.offerUnsafe(resumptions, task);
9628 else dispatcher.scheduleTask(task, priority);
9629 },
9630 flush: () => dispatcher.flush(),
9631 }),
9632 }),
9633 Effect.exit,
9634 Effect.forkScoped,
9635 );
9636 const resume = yield* Queue.take(resumptions);
9637 yield* fixture.notify("completed");
9638 const completed = yield* Queue.take(fixture.subagentReceipts);
9639 yield* Effect.sync(resume);
9640 yield* TestClock.adjust("2 seconds");
9641 assert.deepEqual(
9642 yield* Fiber.join(stopping),
9643 Exit.succeed({
9644 status: "completed" as const,
9645 completedAt: completed.subagent.completedAt!,
9646 result: "Native outcome.",
9647 }),
9648 );
9649 }).pipe(
9650 Effect.provide(
9651 Layer.mergeAll(
9652 IdAllocator.layer,
9653 McpProviderSessions.layer,
9654 ScriptProgress.layerUnavailable,
9655 NodeServices.layer,
9656 ),
9657 ),
9658 ),
9659 ),
9660 );
9661
9662 it.effect("retains native confirmation after the synchronous Agent tool result", () =>
9663 Effect.scoped(
9664 Effect.gen(function* () {
9665 const fixture = yield* makeStopFixture({
9666 stopTask: () => Effect.die("Task already completed"),
9667 });
9668 yield* fixture.notify("completed");
9669 const completed = yield* Queue.take(fixture.subagentReceipts);
9670 yield* fixture.offerAndWait(
9671 claudeSdkFrame({
9672 type: "user",
9673 message: {
9674 role: "user",
9675 content: [
9676 { type: "tool_result", tool_use_id: "toolu-stop-agent", content: "Tool result." },
9677 ],
9678 },
9679 parent_tool_use_id: null,
9680 uuid: fixture.uuid(),
9681 session_id: WAKE_NATIVE_SESSION,
9682 }),
9683 );
9684 assert.equal((yield* Queue.take(fixture.subagentReceipts)).subagent.result, "Tool result.");
9685 assert.deepEqual(yield* fixture.stop(), {
9686 status: "completed",
9687 completedAt: completed.subagent.completedAt!,
9688 result: "Native outcome.",
9689 });
9690 }).pipe(
9691 Effect.provide(
9692 Layer.mergeAll(
9693 IdAllocator.layer,
9694 McpProviderSessions.layer,
9695 ScriptProgress.layerUnavailable,
9696 NodeServices.layer,
9697 ),
9698 ),
9699 ),
9700 ),
9701 );
9702
9703 it.effect.each(["completed", "failed", "stopped"] as const)(
9704 "applies idle %s once, preserves a genuine resume, and only confirms native outcomes",
9705 (status) =>
9706 Effect.scoped(
9707 Effect.gen(function* () {
9708 let stops = 0;
9709 const fixture = yield* makeStopFixture({
9710 stopTask: () => Effect.sync(() => void stops++),
9711 });
9712 yield* fixture.finishParent();
9713 yield* TestClock.adjust("1 second");
9714 yield* fixture.notify(status);
9715 const completed = yield* Queue.take(fixture.subagentReceipts);
9716 const expected = status === "stopped" ? "cancelled" : status;
9717 assert.equal(completed.subagent.status, expected);
9718 assert.lengthOf(fixture.offeredMessages, 1);
9719 assert.isFalse(yield* fixture.hasPendingBackgroundWork);
9720 assert.deepEqual(yield* fixture.stop(), {
9721 status: expected,
9722 completedAt: completed.subagent.completedAt!,
9723 result: "Native outcome.",
9724 });
9725 assert.equal(stops, 0);
9726 yield* TestClock.adjust("1 second");
9727 yield* fixture.start("toolu-resume");
9728 assert.isUndefined(yield* fixture.stop(completed.subagent.completedAt!));
9729 assert.equal(stops, 0);
9730 const stopping = yield* fixture.stop().pipe(Effect.exit, Effect.forkScoped);
9731 yield* Queue.take(fixture.requests);
9732 const beforeReplay = fixture.events.length;
9733 yield* fixture.replay();
9734 const reopened = yield* Queue.take(fixture.subagentReceipts);
9735 assert.equal(reopened.subagent.status, "running");
9736 assert.isNull(reopened.subagent.completedAt);
9737 assert.isFalse(
9738 fixture.events
9739 .slice(beforeReplay)
9740 .some(
9741 (event) => event.type === "subagent.updated" && event.subagent.status !== "running",
9742 ),
9743 );
9744 yield* TestClock.adjust("2 seconds");
9745 assert.isTrue(Exit.isFailure(yield* Fiber.join(stopping)));
9746 assert.equal(stops, 1);
9747 yield* fixture.notify("completed", "Resumed outcome.", "toolu-resume");
9748 const resumed = yield* Queue.take(fixture.subagentReceipts);
9749 assert.equal(resumed.subagent.result, "Resumed outcome.");
9750 assert.isAbove(
9751 DateTime.toEpochMillis(resumed.subagent.completedAt!),
9752 DateTime.toEpochMillis(completed.subagent.completedAt!),
9753 );
9754 }).pipe(
9755 Effect.provide(
9756 Layer.mergeAll(
9757 IdAllocator.layer,
9758 McpProviderSessions.layer,
9759 ScriptProgress.layerUnavailable,
9760 NodeServices.layer,
9761 ),
9762 ),
9763 ),
9764 ),
9765 );
9766
9767 it.effect.each([
9768 [false, "stopped"],
9769 [true, "stopped"],
9770 [false, "completed"],
9771 ] as const)(
9772 "stops one native subagent while its sibling keeps running, owner settled %s, outcome %s",
9773 ([ownerSettled, outcome]) =>
9774 Effect.scoped(
9775 Effect.gen(function* () {
9776 const stopped: string[] = [];
9777 const stopRequested = yield* Deferred.make<void>();
9778 let closes = 0;
9779 const harness = yield* makeWakeHarnessWithOptions({
9780 stopTask: (taskId) =>
9781 Effect.sync(() => {
9782 stopped.push(taskId);
9783 }).pipe(Effect.andThen(Deferred.succeed(stopRequested, undefined))),
9784 interrupt: Effect.die("A child stop must not interrupt its owner"),
9785 close: () =>
9786 Effect.sync(() => {
9787 closes++;
9788 }),
9789 });
9790 yield* harness.runtime.startTurn(
9791 makeClaudeTestTurnInput({
9792 threadId: harness.threadId,
9793 providerThread: harness.providerThread,
9794 now: yield* DateTime.now,
9795 attemptId: RunAttemptId.make("attempt-native-child-stop"),
9796 text: "Start two agents.",
9797 attachments: [],
9798 }),
9799 );
9800 for (const [taskId, toolUseId, uuid] of [
9801 ["agent-stop", "toolu-stop", "00000000-0000-4000-8000-000000000801"],
9802 ["agent-keep", "toolu-keep", "00000000-0000-4000-8000-000000000802"],
9803 ]) {
9804 yield* harness.offerAndWait(
9805 makeSubagentTaskStartedFrame({ taskId: taskId!, toolUseId: toolUseId!, uuid: uuid! }),
9806 );
9807 yield* Queue.take(harness.subagentReceipts);
9808 }
9809 if (ownerSettled) {
9810 yield* harness.offerAndWait(
9811 makeResultFrame({
9812 uuid: "00000000-0000-4000-8000-000000000803",
9813 result: "Agents are working.",
9814 }),
9815 );
9816 yield* Queue.take(harness.terminalReceipts);
9817 }
9818 const stop = harness.runtime.stopSubagent;
9819 assert.isDefined(stop);
9820 const stopping = yield* stop!({
9821 providerThread: harness.providerThread,
9822 nativeTaskId: "agent-stop",
9823 }).pipe(Effect.forkScoped);
9824 yield* Deferred.await(stopRequested);
9825 assert.deepEqual(stopped, ["agent-stop"]);
9826 yield* harness.offerAndWait(
9827 claudeSdkFrame({
9828 ...makeSubagentNotificationFrame({
9829 taskId: "agent-stop",
9830 toolUseId: "toolu-stop",
9831 summary: "Stopped by user",
9832 uuid: "00000000-0000-4000-8000-000000000804",
9833 }),
9834 status: outcome,
9835 }),
9836 );
9837 if (ownerSettled) {
9838 yield* harness.runtime.startTurn(
9839 makeClaudeTestTurnInput({
9840 threadId: harness.threadId,
9841 providerThread: harness.providerThread,
9842 now: yield* DateTime.now,
9843 attemptId: RunAttemptId.make("attempt-native-child-stop-wake"),
9844 text: "Background task stopped.",
9845 attachments: [],
9846 providerTurnOrdinal: 2,
9847 messageCreatedBy: "agent",
9848 messageCreationSource: "provider",
9849 }),
9850 );
9851 }
9852 yield* Fiber.join(stopping);
9853 const stoppedEvent = yield* Queue.take(harness.subagentReceipts);
9854 assert.equal(
9855 stoppedEvent.subagent.status,
9856 outcome === "stopped" ? "cancelled" : "completed",
9857 );
9858 const tasks = harness.events.filter((event) => event.type === "subagent.updated");
9859 assert.equal(
9860 tasks.findLast((event) => event.subagent.nativeTaskRef?.nativeId === "agent-stop")
9861 ?.subagent.status,
9862 outcome === "stopped" ? "cancelled" : "completed",
9863 );
9864 assert.equal(
9865 tasks.findLast((event) => event.subagent.nativeTaskRef?.nativeId === "agent-keep")
9866 ?.subagent.status,
9867 "running",
9868 );
9869 assert.lengthOf(harness.terminalEvents(), ownerSettled ? 1 : 0);
9870 yield* stop!({ providerThread: harness.providerThread, nativeTaskId: "agent-stop" });
9871 assert.deepEqual(stopped, ["agent-stop"]);
9872 assert.equal(closes, 0);
9873 }).pipe(
9874 Effect.provide(
9875 Layer.mergeAll(
9876 IdAllocator.layer,
9877 McpProviderSessions.layer,
9878 ScriptProgress.layerUnavailable,
9879 NodeServices.layer,
9880 ),
9881 ),
9882 ),
9883 ),
9884 );
9885
9886 it.effect.each(["requested", "observed-before", "observed-after", "inherit", "unknown"] as const)(
9887 "records the subagent model from %s without inheriting the parent override",
9888 (source) =>
9889 Effect.scoped(
9890 Effect.gen(function* () {
9891 const harness = yield* makeWakeHarness;
9892 const now = yield* DateTime.now;
9893 const toolUseId = "toolu-subagent-model";
9894 const parentModel = "claude-opus-4-6";
9895 const observedModel = "claude-haiku-4-5-20251001";
9896 yield* harness.runtime.startTurn(
9897 makeClaudeTestTurnInput({
9898 threadId: harness.threadId,
9899 providerThread: harness.providerThread,
9900 now,
9901 attemptId: RunAttemptId.make("attempt-subagent-model"),
9902 text: "Spawn a Haiku subagent.",
9903 attachments: [],
9904 modelSelection: { ...CLAUDE_TEST_MODEL_SELECTION, model: parentModel },
9905 }),
9906 );
9907 const observed = claudeSdkFrame({
9908 type: "assistant",
9909 parent_tool_use_id: toolUseId,
9910 message: {
9911 model: observedModel,
9912 id: "msg_subagent_model_observed",
9913 type: "message",
9914 role: "assistant",
9915 content: [{ type: "text", text: "Solving." }],
9916 },
9917 uuid: "00000000-0000-4000-8000-000000000206",
9918 session_id: WAKE_NATIVE_SESSION,
9919 });
9920 if (source === "observed-before") yield* Queue.offer(harness.sdkMessages, observed);
9921 yield* Queue.offer(
9922 harness.sdkMessages,
9923 claudeSdkFrame({
9924 type: "assistant",
9925 parent_tool_use_id: null,
9926 message: {
9927 model: parentModel,
9928 id: "msg_subagent_model_launch",
9929 type: "message",
9930 role: "assistant",
9931 content: [
9932 {
9933 type: "tool_use",
9934 id: toolUseId,
9935 name: "Agent",
9936 input: {
9937 description: "Haiku puzzle",
9938 subagent_type: "general-purpose",
9939 ...(source === "unknown"
9940 ? {}
9941 : { model: source === "inherit" ? "inherit" : "haiku" }),
9942 prompt: "Solve the puzzle.",
9943 },
9944 },
9945 ],
9946 },
9947 uuid: "00000000-0000-4000-8000-000000000209",
9948 session_id: WAKE_NATIVE_SESSION,
9949 }),
9950 );
9951 yield* Queue.offer(
9952 harness.sdkMessages,
9953 claudeSdkFrame({
9954 type: "system",
9955 subtype: "task_started",
9956 task_id: "task-subagent-model",
9957 tool_use_id: toolUseId,
9958 description: "Haiku puzzle",
9959 task_type: "local_agent",
9960 uuid: "00000000-0000-4000-8000-000000000207",
9961 session_id: WAKE_NATIVE_SESSION,
9962 }),
9963 );
9964 if (source === "observed-after") yield* Queue.offer(harness.sdkMessages, observed);
9965 yield* Queue.offer(
9966 harness.sdkMessages,
9967 makeResultFrame({
9968 uuid: "00000000-0000-4000-8000-000000000208",
9969 result: "Spawned the subagent.",
9970 }),
9971 );
9972 yield* Queue.take(harness.terminalReceipts);
9973 const subagents = harness.events.filter((event) => event.type === "subagent.updated");
9974 // Aliases and dated ids are recorded as the catalog slug.
9975 const observedSlug = "claude-haiku-4-5";
9976 const initialModel =
9977 source === "observed-before"
9978 ? observedSlug
9979 : source === "inherit"
9980 ? parentModel
9981 : source === "unknown"
9982 ? null
9983 : "claude-haiku-5-5";
9984 assert.equal(subagents[0]?.subagent.model, initialModel);
9985 assert.equal(
9986 subagents.at(-1)?.subagent.model,
9987 source.startsWith("observed") ? observedSlug : initialModel,
9988 );
9989 const child = harness.events.find((event) => event.type === "app_thread.created");
9990 assert.equal(child?.appThread.modelSelection?.model, initialModel ?? parentModel);
9991 }).pipe(
9992 Effect.provide(
9993 Layer.mergeAll(
9994 IdAllocator.layer,
9995 McpProviderSessions.layer,
9996 ScriptProgress.layerUnavailable,
9997 NodeServices.layer,
9998 ),
9999 ),
10000 ),
10001 ),
10002 );
10003
10004 it.effect.each([
10005 { agentModel: "opus", slug: "claude-opus-5-5", requested: undefined, expected: "max" },
10006 { agentModel: "opus", slug: "claude-opus-5-5", requested: "low", expected: "low" },
10007 // claude-haiku-4-5 offers no effort, so none is sent or shown for it.
10008 { agentModel: "haiku-4.5", slug: "claude-haiku-4-5", requested: undefined, expected: null },
10009 ])(
10010 "records the effort a $agentModel subagent runs at (Agent effort $requested)",
10011 ({ agentModel, slug, requested, expected }) =>
10012 Effect.scoped(
10013 Effect.gen(function* () {
10014 const harness = yield* makeWakeHarness;
10015 const now = yield* DateTime.now;
10016 const toolUseId = "toolu-subagent-effort";
10017 const parentSelection = {
10018 instanceId: CLAUDE_TEST_MODEL_SELECTION.instanceId,
10019 model: "claude-opus-4-6",
10020 options: [
10021 { id: "effort", value: "max" },
10022 { id: "fastMode", value: false },
10023 ],
10024 } satisfies ModelSelection;
10025 yield* harness.runtime.startTurn(
10026 makeClaudeTestTurnInput({
10027 threadId: harness.threadId,
10028 providerThread: harness.providerThread,
10029 now,
10030 attemptId: RunAttemptId.make("attempt-subagent-effort"),
10031 text: "Spawn a subagent.",
10032 attachments: [],
10033 modelSelection: parentSelection,
10034 }),
10035 );
10036 for (const frame of [
10037 claudeSdkFrame({
10038 type: "assistant",
10039 parent_tool_use_id: null,
10040 message: {
10041 model: parentSelection.model,
10042 id: "msg_subagent_effort_launch",
10043 type: "message",
10044 role: "assistant",
10045 content: [
10046 {
10047 type: "tool_use",
10048 id: toolUseId,
10049 name: "Agent",
10050 input: {
10051 description: "Research",
10052 subagent_type: "general-purpose",
10053 model: agentModel,
10054 ...(requested === undefined ? {} : { effort: requested }),
10055 prompt: "Research the topic.",
10056 },
10057 },
10058 ],
10059 },
10060 uuid: "00000000-0000-4000-8000-000000000211",
10061 session_id: WAKE_NATIVE_SESSION,
10062 }),
10063 claudeSdkFrame({
10064 type: "system",
10065 subtype: "task_started",
10066 task_id: "task-subagent-effort",
10067 tool_use_id: toolUseId,
10068 description: "Research",
10069 task_type: "local_agent",
10070 uuid: "00000000-0000-4000-8000-000000000212",
10071 session_id: WAKE_NATIVE_SESSION,
10072 }),
10073 // The reply names the model the alias resolved to.
10074 claudeSdkFrame({
10075 type: "assistant",
10076 parent_tool_use_id: toolUseId,
10077 message: {
10078 model: slug,
10079 id: "msg_subagent_effort_observed",
10080 type: "message",
10081 role: "assistant",
10082 content: [{ type: "text", text: "Researching." }],
10083 },
10084 uuid: "00000000-0000-4000-8000-000000000213",
10085 session_id: WAKE_NATIVE_SESSION,
10086 }),
10087 makeResultFrame({
10088 uuid: "00000000-0000-4000-8000-000000000214",
10089 result: "Spawned the subagent.",
10090 }),
10091 ]) {
10092 yield* Queue.offer(harness.sdkMessages, frame);
10093 }
10094 yield* Queue.take(harness.terminalReceipts);
10095
10096 // The alias is stored as its slug, so the reply is no model change,
10097 // and the parent's fast mode stays with the parent.
10098 const subagentSelection = {
10099 instanceId: parentSelection.instanceId,
10100 model: slug,
10101 ...(expected === null ? {} : { options: [{ id: "effort", value: expected }] }),
10102 };
10103 const child = harness.events.find((event) => event.type === "app_thread.created");
10104 assert.deepEqual(child?.appThread.modelSelection, subagentSelection);
10105 const subagents = harness.events.filter((event) => event.type === "subagent.updated");
10106 assert.deepEqual(
10107 subagents.map((event) => event.subagent.modelSelection),
10108 subagents.map(() => subagentSelection),
10109 );
10110 }).pipe(
10111 Effect.provide(
10112 Layer.mergeAll(
10113 IdAllocator.layer,
10114 McpProviderSessions.layer,
10115 ScriptProgress.layerUnavailable,
10116 NodeServices.layer,
10117 ),
10118 ),
10119 ),
10120 ),
10121 );
10122
10123 it.effect("resolves a subagent's model and effort against the instance's custom models", () =>
10124 Effect.scoped(
10125 Effect.gen(function* () {
10126 const TUNED_TOOL_USE_ID = "toolu-effort-custom-tuned";
10127 const SHADOW_TOOL_USE_ID = "toolu-effort-custom-shadow";
10128 const harness = yield* makeWakeHarnessWithOptions({
10129 settings: CUSTOM_MODEL_CLAUDE_SETTINGS,
10130 });
10131 const now = yield* DateTime.now;
10132 yield* harness.runtime.startTurn(
10133 makeClaudeTestTurnInput({
10134 threadId: harness.threadId,
10135 providerThread: harness.providerThread,
10136 now,
10137 attemptId: RunAttemptId.make("attempt-subagent-custom-effort"),
10138 text: "Spawn subagents on custom models.",
10139 attachments: [],
10140 }),
10141 );
10142 for (const frame of [
10143 claudeSdkFrame({
10144 type: "assistant",
10145 parent_tool_use_id: null,
10146 message: {
10147 model: CLAUDE_TEST_MODEL_SELECTION.model,
10148 id: "msg_custom_effort_launch",
10149 type: "message",
10150 role: "assistant",
10151 content: [
10152 {
10153 type: "tool_use",
10154 id: TUNED_TOOL_USE_ID,
10155 name: "Agent",
10156 input: {
10157 description: "Tuned",
10158 subagent_type: "general-purpose",
10159 model: "claude-custom-tuned",
10160 effort: "brutal",
10161 prompt: "Check one thing.",
10162 },
10163 },
10164 {
10165 type: "tool_use",
10166 id: SHADOW_TOOL_USE_ID,
10167 name: "Agent",
10168 input: {
10169 description: "Shadow",
10170 subagent_type: "general-purpose",
10171 model: "opus",
10172 prompt: "Check another thing.",
10173 },
10174 },
10175 ],
10176 },
10177 uuid: "00000000-0000-4000-8000-000000000231",
10178 session_id: WAKE_NATIVE_SESSION,
10179 }),
10180 ...[TUNED_TOOL_USE_ID, SHADOW_TOOL_USE_ID].map((id, index) =>
10181 claudeSdkFrame({
10182 type: "system",
10183 subtype: "task_started",
10184 task_id: `task-${id}`,
10185 tool_use_id: id,
10186 description: id,
10187 task_type: "local_agent",
10188 uuid: `00000000-0000-4000-8000-00000000023${index + 2}`,
10189 session_id: WAKE_NATIVE_SESSION,
10190 }),
10191 ),
10192 makeResultFrame({
10193 uuid: "00000000-0000-4000-8000-000000000234",
10194 result: "Spawned the subagents.",
10195 }),
10196 ]) {
10197 yield* Queue.offer(harness.sdkMessages, frame);
10198 }
10199 yield* Queue.take(harness.terminalReceipts);
10200
10201 const selections = Object.fromEntries(
10202 harness.events.flatMap((event) =>
10203 event.type === "subagent.updated"
10204 ? [[event.subagent.nativeTaskRef?.nativeId, event.subagent.modelSelection] as const]
10205 : [],
10206 ),
10207 );
10208 const instanceId = CLAUDE_TEST_MODEL_SELECTION.instanceId;
10209 assert.deepEqual(selections, {
10210 // The custom model's own effort levels apply.
10211 [`task-${TUNED_TOOL_USE_ID}`]: {
10212 instanceId,
10213 model: "claude-custom-tuned",
10214 options: [{ id: "effort", value: "brutal" }],
10215 },
10216 // The custom slug is not resolved to the built-in model it shadows.
10217 [`task-${SHADOW_TOOL_USE_ID}`]: { instanceId, model: "opus" },
10218 });
10219 }).pipe(
10220 Effect.provide(
10221 Layer.mergeAll(
10222 IdAllocator.layer,
10223 McpProviderSessions.layer,
10224 ScriptProgress.layerUnavailable,
10225 NodeServices.layer,
10226 ),
10227 ),
10228 ),
10229 ),
10230 );
10231
10232 it.effect("gives a nested subagent its own effort, else the one its owner runs at", () =>
10233 Effect.scoped(
10234 Effect.gen(function* () {
10235 const OUTER_TOOL_USE_ID = "toolu-effort-outer";
10236 const INHERITING_TOOL_USE_ID = "toolu-effort-nested-inheriting";
10237 const OVERRIDING_TOOL_USE_ID = "toolu-effort-nested-overriding";
10238 const harness = yield* makeWakeHarness;
10239 const now = yield* DateTime.now;
10240 const parentSelection = {
10241 instanceId: CLAUDE_TEST_MODEL_SELECTION.instanceId,
10242 model: "claude-opus-4-6",
10243 options: [{ id: "effort", value: "high" }],
10244 } satisfies ModelSelection;
10245 yield* harness.runtime.startTurn(
10246 makeClaudeTestTurnInput({
10247 threadId: harness.threadId,
10248 providerThread: harness.providerThread,
10249 now,
10250 attemptId: RunAttemptId.make("attempt-subagent-nested-effort"),
10251 text: "Spawn a subagent that spawns two more.",
10252 attachments: [],
10253 modelSelection: parentSelection,
10254 }),
10255 );
10256 const agentCall = (id: string, effort?: string) => ({
10257 type: "tool_use" as const,
10258 id,
10259 name: "Agent",
10260 input: {
10261 description: id,
10262 subagent_type: "general-purpose",
10263 ...(effort === undefined ? {} : { effort }),
10264 prompt: "Check one thing.",
10265 },
10266 });
10267 const taskStarted = (id: string, uuid: string) =>
10268 claudeSdkFrame({
10269 type: "system",
10270 subtype: "task_started",
10271 task_id: `task-${id}`,
10272 tool_use_id: id,
10273 description: id,
10274 task_type: "local_agent",
10275 uuid,
10276 session_id: WAKE_NATIVE_SESSION,
10277 });
10278 for (const frame of [
10279 claudeSdkFrame({
10280 type: "assistant",
10281 parent_tool_use_id: null,
10282 message: {
10283 model: parentSelection.model,
10284 id: "msg_nested_effort_outer_launch",
10285 type: "message",
10286 role: "assistant",
10287 content: [agentCall(OUTER_TOOL_USE_ID, "low")],
10288 },
10289 uuid: "00000000-0000-4000-8000-000000000221",
10290 session_id: WAKE_NATIVE_SESSION,
10291 }),
10292 taskStarted(OUTER_TOOL_USE_ID, "00000000-0000-4000-8000-000000000222"),
10293 // The outer subagent's own Agent calls arrive in its snapshot.
10294 claudeSdkFrame({
10295 type: "assistant",
10296 parent_tool_use_id: OUTER_TOOL_USE_ID,
10297 message: {
10298 model: parentSelection.model,
10299 id: "msg_nested_effort_inner_launch",
10300 type: "message",
10301 role: "assistant",
10302 content: [
10303 agentCall(INHERITING_TOOL_USE_ID),
10304 agentCall(OVERRIDING_TOOL_USE_ID, "max"),
10305 ],
10306 },
10307 uuid: "00000000-0000-4000-8000-000000000223",
10308 session_id: WAKE_NATIVE_SESSION,
10309 }),
10310 taskStarted(INHERITING_TOOL_USE_ID, "00000000-0000-4000-8000-000000000224"),
10311 taskStarted(OVERRIDING_TOOL_USE_ID, "00000000-0000-4000-8000-000000000225"),
10312 makeResultFrame({
10313 uuid: "00000000-0000-4000-8000-000000000226",
10314 result: "Spawned the subagents.",
10315 }),
10316 ]) {
10317 yield* Queue.offer(harness.sdkMessages, frame);
10318 }
10319 yield* Queue.take(harness.terminalReceipts);
10320
10321 const efforts = new Map(
10322 harness.events.flatMap((event) =>
10323 event.type === "subagent.updated"
10324 ? [
10325 [
10326 event.subagent.nativeTaskRef?.nativeId,
10327 getModelSelectionStringOptionValue(event.subagent.modelSelection, "effort"),
10328 ] as const,
10329 ]
10330 : [],
10331 ),
10332 );
10333 assert.deepEqual(Object.fromEntries(efforts), {
10334 [`task-${OUTER_TOOL_USE_ID}`]: "low",
10335 [`task-${INHERITING_TOOL_USE_ID}`]: "low",
10336 [`task-${OVERRIDING_TOOL_USE_ID}`]: "max",
10337 });
10338 const childEfforts = harness.events.flatMap((event) =>
10339 event.type === "app_thread.created" && event.appThread.id !== harness.threadId
10340 ? [getModelSelectionStringOptionValue(event.appThread.modelSelection, "effort")]
10341 : [],
10342 );
10343 assert.deepEqual(childEfforts, ["low", "low", "max"]);
10344 }).pipe(
10345 Effect.provide(
10346 Layer.mergeAll(
10347 IdAllocator.layer,
10348 McpProviderSessions.layer,
10349 ScriptProgress.layerUnavailable,
10350 NodeServices.layer,
10351 ),
10352 ),
10353 ),
10354 ),
10355 );
10356
10357 it.effect("extracts text from direct content-block subagent results", () =>
10358 Effect.scoped(
10359 Effect.gen(function* () {
10360 const SUBAGENT_TASK_ID = "task-direct-content-blocks";
10361 const SUBAGENT_TOOL_USE_ID = "toolu-direct-content-blocks";
10362 const harness = yield* makeWakeHarness;
10363 const now = yield* DateTime.now;
10364 const subagentEvents = () =>
10365 harness.events.filter(
10366 (
10367 event,
10368 ): event is Extract<
10369 ProviderAdapter.ProviderAdapterV2Event,
10370 { type: "subagent.updated" }
10371 > => event.type === "subagent.updated",
10372 );
10373
10374 yield* harness.runtime.startTurn(
10375 makeClaudeTestTurnInput({
10376 threadId: harness.threadId,
10377 providerThread: harness.providerThread,
10378 now,
10379 attemptId: RunAttemptId.make("attempt-claude-direct-content-blocks"),
10380 text: "Delegate this task.",
10381 attachments: [],
10382 }),
10383 );
10384 yield* Queue.offer(
10385 harness.sdkMessages,
10386 claudeSdkFrame({
10387 type: "system",
10388 subtype: "task_started",
10389 task_id: SUBAGENT_TASK_ID,
10390 tool_use_id: SUBAGENT_TOOL_USE_ID,
10391 description: "Delegated task",
10392 subagent_type: "general-purpose",
10393 task_type: "local_agent",
10394 prompt: "Return the result.",
10395 uuid: "00000000-0000-4000-8000-000000000206",
10396 session_id: WAKE_NATIVE_SESSION,
10397 }),
10398 );
10399 yield* awaitUntil(() => subagentEvents().length === 1, "subagent node created");
10400
10401 yield* Queue.offer(
10402 harness.sdkMessages,
10403 claudeSdkFrame({
10404 type: "user",
10405 message: {
10406 role: "user",
10407 content: [
10408 {
10409 type: "tool_result",
10410 tool_use_id: SUBAGENT_TOOL_USE_ID,
10411 content: [
10412 { type: "text", text: "First line." },
10413 { type: "text", text: "Second line." },
10414 ],
10415 },
10416 ],
10417 },
10418 parent_tool_use_id: null,
10419 uuid: "00000000-0000-4000-8000-000000000207",
10420 session_id: WAKE_NATIVE_SESSION,
10421 }),
10422 );
10423 yield* awaitUntil(
10424 () => subagentEvents().at(-1)?.subagent.status === "completed",
10425 "subagent terminal",
10426 );
10427
10428 assert.equal(subagentEvents().at(-1)?.subagent.result, "First line.\nSecond line.");
10429
10430 yield* Queue.offer(
10431 harness.sdkMessages,
10432 makeResultFrame({
10433 uuid: "00000000-0000-4000-8000-000000000208",
10434 result: "Delegation completed.",
10435 }),
10436 );
10437 yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal");
10438 }).pipe(
10439 Effect.provide(
10440 Layer.mergeAll(
10441 IdAllocator.layer,
10442 McpProviderSessions.layer,
10443 ScriptProgress.layerUnavailable,
10444 NodeServices.layer,
10445 ),
10446 ),
10447 ),
10448 ),
10449 );
10450
10451 it.effect("releases the idle pin when a post-settle subagent stops without completing", () =>
10452 Effect.scoped(
10453 Effect.gen(function* () {
10454 const SUBAGENT_TASK_ID = "task-wake-subagent-stopped";
10455 const subagentTaskStarted = claudeSdkFrame({
10456 type: "system",
10457 subtype: "task_started",
10458 task_id: SUBAGENT_TASK_ID,
10459 tool_use_id: "toolu-wake-subagent-stopped",
10460 description: "Long-running research task",
10461 subagent_type: "general-purpose",
10462 task_type: "local_agent",
10463 prompt: "Investigate the flaky test.",
10464 uuid: "00000000-0000-4000-8000-000000000301",
10465 session_id: WAKE_NATIVE_SESSION,
10466 });
10467 const subagentStoppedNotification = claudeSdkFrame({
10468 type: "system",
10469 subtype: "task_notification",
10470 task_id: SUBAGENT_TASK_ID,
10471 tool_use_id: "toolu-wake-subagent-stopped",
10472 status: "stopped",
10473 output_file: "/tmp/task-wake-subagent-stopped.output",
10474 summary: "Agent was stopped before finishing.",
10475 uuid: "00000000-0000-4000-8000-000000000302",
10476 session_id: WAKE_NATIVE_SESSION,
10477 });
10478
10479 const harness = yield* makeWakeHarness;
10480 const now = yield* DateTime.now;
10481 const subagentEvents = () =>
10482 harness.events.filter(
10483 (
10484 event,
10485 ): event is Extract<
10486 ProviderAdapter.ProviderAdapterV2Event,
10487 { type: "subagent.updated" }
10488 > => event.type === "subagent.updated",
10489 );
10490
10491 yield* harness.runtime.startTurn(
10492 makeClaudeTestTurnInput({
10493 threadId: harness.threadId,
10494 providerThread: harness.providerThread,
10495 now,
10496 attemptId: RunAttemptId.make("attempt-claude-wake-7a"),
10497 text: "Spawn a background subagent and stop.",
10498 attachments: [],
10499 }),
10500 );
10501 yield* Queue.offer(harness.sdkMessages, subagentTaskStarted);
10502 yield* awaitUntil(() => subagentEvents().length >= 1, "subagent node created");
10503 yield* Queue.offer(
10504 harness.sdkMessages,
10505 makeResultFrame({
10506 uuid: "00000000-0000-4000-8000-000000000303",
10507 result: "Spawned the subagent in the background.",
10508 }),
10509 );
10510 yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal");
10511 assert.isTrue(yield* harness.hasPendingBackgroundWork);
10512
10513 yield* Queue.offer(harness.sdkMessages, subagentStoppedNotification);
10514 yield* awaitUntil(() => harness.continuationRequests.length === 1, "continuation request");
10515
10516 yield* Queue.offer(
10517 harness.sdkMessages,
10518 makeResultFrame({
10519 uuid: "00000000-0000-4000-8000-000000000304",
10520 result: "The subagent was stopped.",
10521 }),
10522 );
10523 yield* harness.runtime.startTurn(
10524 makeClaudeTestTurnInput({
10525 threadId: harness.threadId,
10526 providerThread: harness.providerThread,
10527 now,
10528 attemptId: RunAttemptId.make("attempt-claude-wake-7b"),
10529 text: "Background task completed.",
10530 attachments: [],
10531 providerTurnOrdinal: 2,
10532 messageCreatedBy: "agent",
10533 messageCreationSource: "provider",
10534 }),
10535 );
10536 yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal");
10537
10538 assert.equal(subagentEvents().at(-1)?.subagent.status, "cancelled");
10539 assert.isFalse(yield* harness.hasPendingBackgroundWork);
10540 }).pipe(
10541 Effect.provide(
10542 Layer.mergeAll(
10543 IdAllocator.layer,
10544 McpProviderSessions.layer,
10545 ScriptProgress.layerUnavailable,
10546 NodeServices.layer,
10547 ),
10548 ),
10549 ),
10550 ),
10551 );
10552
10553 it.effect("re-opens a resumed subagent whose task_started races past settle", () =>
10554 Effect.scoped(
10555 Effect.gen(function* () {
10556 const SUBAGENT_TASK_ID = "task-resume-postsettle";
10557 const SUBAGENT_TOOL_USE_ID = "toolu-resume-postsettle";
10558 const RESUME_TOOL_USE_ID = "toolu-resume-postsettle-sendmessage";
10559 const FIRST_SUMMARY = "Answered early.";
10560 const SECOND_SUMMARY = "RESUME_SETTLE_DONE";
10561 const subagentTaskStarted = claudeSdkFrame({
10562 type: "system",
10563 subtype: "task_started",
10564 task_id: SUBAGENT_TASK_ID,
10565 tool_use_id: SUBAGENT_TOOL_USE_ID,
10566 description: "Sleep then echo done token",
10567 subagent_type: "general-purpose",
10568 task_type: "local_agent",
10569 prompt: "Run the shell command, then return exactly RESUME_SETTLE_DONE.",
10570 uuid: "00000000-0000-4000-8000-000000000501",
10571 session_id: WAKE_NATIVE_SESSION,
10572 });
10573 const firstNotification = claudeSdkFrame({
10574 type: "system",
10575 subtype: "task_notification",
10576 task_id: SUBAGENT_TASK_ID,
10577 tool_use_id: SUBAGENT_TOOL_USE_ID,
10578 status: "completed",
10579 output_file: "/tmp/task-resume-postsettle.output",
10580 summary: FIRST_SUMMARY,
10581 uuid: "00000000-0000-4000-8000-000000000502",
10582 session_id: WAKE_NATIVE_SESSION,
10583 });
10584 const resumeTaskStarted = claudeSdkFrame({
10585 type: "system",
10586 subtype: "task_started",
10587 task_id: SUBAGENT_TASK_ID,
10588 tool_use_id: RESUME_TOOL_USE_ID,
10589 description: "Sleep then echo done token",
10590 is_backgrounded: true,
10591 task_type: "local_agent",
10592 uuid: "00000000-0000-4000-8000-000000000505",
10593 session_id: WAKE_NATIVE_SESSION,
10594 });
10595 // As recorded in claude_background_subagent_lifecycle: the resumed
10596 // run's notification carries the SendMessage call's tool_use_id.
10597 const secondNotification = claudeSdkFrame({
10598 type: "system",
10599 subtype: "task_notification",
10600 task_id: SUBAGENT_TASK_ID,
10601 tool_use_id: RESUME_TOOL_USE_ID,
10602 status: "completed",
10603 output_file: "/tmp/task-resume-postsettle.output",
10604 summary: SECOND_SUMMARY,
10605 uuid: "00000000-0000-4000-8000-000000000506",
10606 session_id: WAKE_NATIVE_SESSION,
10607 });
10608
10609 const harness = yield* makeWakeHarness;
10610 const now = yield* DateTime.now;
10611 const subagentEvents = () =>
10612 harness.events.filter(
10613 (
10614 event,
10615 ): event is Extract<
10616 ProviderAdapter.ProviderAdapterV2Event,
10617 { type: "subagent.updated" }
10618 > => event.type === "subagent.updated",
10619 );
10620
10621 yield* harness.runtime.startTurn(
10622 makeClaudeTestTurnInput({
10623 threadId: harness.threadId,
10624 providerThread: harness.providerThread,
10625 now,
10626 attemptId: RunAttemptId.make("attempt-claude-wake-9a"),
10627 text: "Spawn a background subagent and stop.",
10628 attachments: [],
10629 }),
10630 );
10631 yield* Queue.offer(harness.sdkMessages, subagentTaskStarted);
10632 yield* awaitUntil(() => subagentEvents().length >= 1, "subagent node created");
10633 yield* Queue.offer(
10634 harness.sdkMessages,
10635 makeResultFrame({
10636 uuid: "00000000-0000-4000-8000-000000000503",
10637 result: "Spawned the subagent in the background.",
10638 }),
10639 );
10640 yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal");
10641
10642 yield* Queue.offer(harness.sdkMessages, firstNotification);
10643 yield* awaitUntil(
10644 () => harness.continuationRequests.length === 1,
10645 "first continuation request",
10646 );
10647 yield* Queue.offer(
10648 harness.sdkMessages,
10649 makeResultFrame({
10650 uuid: "00000000-0000-4000-8000-000000000504",
10651 result: "The subagent answered early.",
10652 }),
10653 );
10654 yield* harness.runtime.startTurn(
10655 makeClaudeTestTurnInput({
10656 threadId: harness.threadId,
10657 providerThread: harness.providerThread,
10658 now,
10659 attemptId: RunAttemptId.make("attempt-claude-wake-9b"),
10660 text: "Background task completed.",
10661 attachments: [],
10662 providerTurnOrdinal: 2,
10663 messageCreatedBy: "agent",
10664 messageCreationSource: "provider",
10665 }),
10666 );
10667 yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal");
10668 assert.equal(subagentEvents().at(-1)?.subagent.status, "completed");
10669 assert.equal(subagentEvents().at(-1)?.subagent.result, FIRST_SUMMARY);
10670 assert.isFalse(yield* harness.hasPendingBackgroundWork);
10671
10672 // The resume task_started races past settle: no turn is active, so it
10673 // must re-open the session registry entry (pinning idle again) and
10674 // buffer for replay. Its notification then counts as wake evidence
10675 // and carries the new summary as the continuation detail. The resume
10676 // rides on a SendMessage tool call whose frames race past settle too;
10677 // on drain replay the SendMessage tool_result is a delivery ACK and
10678 // must not terminalize the re-opened subagent.
10679 yield* Queue.offer(
10680 harness.sdkMessages,
10681 claudeSdkFrame({
10682 type: "assistant",
10683 message: {
10684 model: "claude-sonnet-4-6",
10685 id: "msg_resume_postsettle_sendmessage",
10686 type: "message",
10687 role: "assistant",
10688 content: [
10689 {
10690 type: "tool_use",
10691 id: RESUME_TOOL_USE_ID,
10692 name: "SendMessage",
10693 input: {
10694 to: SUBAGENT_TASK_ID,
10695 summary: "Resume the subagent",
10696 message: "Continue and return the token.",
10697 },
10698 },
10699 ],
10700 },
10701 parent_tool_use_id: null,
10702 uuid: "00000000-0000-4000-8000-000000000508",
10703 session_id: WAKE_NATIVE_SESSION,
10704 }),
10705 );
10706 yield* Queue.offer(harness.sdkMessages, resumeTaskStarted);
10707 // Shaped like the recorded SendMessage ACK in
10708 // claude_background_subagent_lifecycle: the text block is the JSON of
10709 // tool_use_result, and "message" names the agent's short id.
10710 const resumeAck = {
10711 success: true,
10712 message: `Resuming agent ${SUBAGENT_TASK_ID.slice(0, 7)}`,
10713 resumedAgentId: SUBAGENT_TASK_ID,
10714 pin: { id: SUBAGENT_TASK_ID, name: SUBAGENT_TASK_ID, ref: "42ab31" },
10715 };
10716 yield* Queue.offer(
10717 harness.sdkMessages,
10718 claudeSdkFrame({
10719 type: "user",
10720 message: {
10721 role: "user",
10722 content: [
10723 {
10724 tool_use_id: RESUME_TOOL_USE_ID,
10725 type: "tool_result",
10726 content: [
10727 {
10728 type: "text",
10729 text: encodeJsonString(resumeAck),
10730 },
10731 ],
10732 },
10733 ],
10734 },
10735 parent_tool_use_id: null,
10736 uuid: "00000000-0000-4000-8000-000000000509",
10737 session_id: WAKE_NATIVE_SESSION,
10738 tool_use_result: resumeAck,
10739 }),
10740 );
10741 // The resumed run's own work follows its task_started into the wake
10742 // buffer: nothing ingests the child thread until the drain.
10743 for (const frame of [
10744 ...makeSubagentAssistantFrames({
10745 parentToolUseId: SUBAGENT_TOOL_USE_ID,
10746 uuid: "00000000-0000-4000-8000-000000000961",
10747 text: "RESUMED_WORK",
10748 bashToolUseId: "toolu-resume-postsettle-bash",
10749 }),
10750 makeSubagentToolResultFrame({
10751 parentToolUseId: SUBAGENT_TOOL_USE_ID,
10752 uuid: "00000000-0000-4000-8000-000000000962",
10753 toolUseId: "toolu-resume-postsettle-bash",
10754 }),
10755 ]) {
10756 yield* Queue.offer(harness.sdkMessages, frame);
10757 }
10758 yield* Queue.offer(harness.sdkMessages, secondNotification);
10759 yield* awaitUntil(
10760 () => harness.continuationRequests.length === 2,
10761 "second continuation request",
10762 );
10763 assert.equal(harness.continuationRequests[1]?.detail, SECOND_SUMMARY);
10764 assert.isTrue(yield* harness.hasPendingBackgroundWork);
10765
10766 yield* Queue.offer(
10767 harness.sdkMessages,
10768 makeResultFrame({
10769 uuid: "00000000-0000-4000-8000-000000000507",
10770 result: "The subagent finished with RESUME_SETTLE_DONE.",
10771 }),
10772 );
10773 yield* harness.runtime.startTurn(
10774 makeClaudeTestTurnInput({
10775 threadId: harness.threadId,
10776 providerThread: harness.providerThread,
10777 now,
10778 attemptId: RunAttemptId.make("attempt-claude-wake-9c"),
10779 text: "Background task completed.",
10780 attachments: [],
10781 providerTurnOrdinal: 3,
10782 messageCreatedBy: "agent",
10783 messageCreationSource: "provider",
10784 }),
10785 );
10786 yield* awaitUntil(
10787 () => harness.terminalEvents().length === 3,
10788 "resume continuation terminal",
10789 );
10790
10791 // The drained replay re-opens the row (running, stale result cleared)
10792 // before the second notification terminalizes it again.
10793 const statuses = subagentEvents().map((event) => event.subagent.status);
10794 const firstCompleted = statuses.indexOf("completed");
10795 const reopenedIndex = statuses.lastIndexOf("running");
10796 assert.isAbove(reopenedIndex, firstCompleted);
10797 assert.isNull(subagentEvents()[reopenedIndex]?.subagent.result);
10798 // The drain-replayed reopen re-attributes the subagent to the
10799 // continuation run performing the replay, so that run's ingestion
10800 // fiber routes the resumed lifecycle and lingers past settle until
10801 // the resumed task completes.
10802 assert.equal(subagentEvents()[reopenedIndex]?.subagent.runId, "run-attempt-claude-wake-9c");
10803 // The execution node re-opens too, even though the registry entry was
10804 // already pre-opened by the wake buffer before the drain replay.
10805 const nodeStatuses = harness.events
10806 .filter(
10807 (
10808 event,
10809 ): event is Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "node.updated" }> =>
10810 event.type === "node.updated" &&
10811 event.node.kind === "subagent" &&
10812 event.node.nativeItemRef?.nativeId === SUBAGENT_TASK_ID,
10813 )
10814 .map((event) => event.node.status);
10815 assert.isAbove(nodeStatuses.lastIndexOf("running"), nodeStatuses.indexOf("completed"));
10816 const finalSubagent = subagentEvents().at(-1)?.subagent;
10817 assert.equal(finalSubagent?.status, "completed");
10818 assert.equal(finalSubagent?.result, SECOND_SUMMARY);
10819 // The completion keeps the resuming run's attribution.
10820 assert.equal(finalSubagent?.runId, "run-attempt-claude-wake-9c");
10821 // Its work is projected once, by the drain that re-opens it.
10822 const reopenIndex = harness.events.findIndex(
10823 (event) =>
10824 event.type === "subagent.updated" &&
10825 event.subagent.status === "running" &&
10826 event.subagent.runId === "run-attempt-claude-wake-9c",
10827 );
10828 assert.isAtLeast(reopenIndex, 0);
10829 const resumedWorkIndexes = harness.events.flatMap((event, index) =>
10830 event.type === "message.updated" && event.message.text === "RESUMED_WORK" ? [index] : [],
10831 );
10832 assert.lengthOf(resumedWorkIndexes, 1);
10833 assert.isAbove(resumedWorkIndexes[0] ?? -1, reopenIndex);
10834 assert.isFalse(yield* harness.hasPendingBackgroundWork);
10835 }).pipe(
10836 Effect.provide(
10837 Layer.mergeAll(
10838 IdAllocator.layer,
10839 McpProviderSessions.layer,
10840 ScriptProgress.layerUnavailable,
10841 NodeServices.layer,
10842 ),
10843 ),
10844 ),
10845 ),
10846 );
10847
10848 it.effect("resets Waiting roster and wake eligibility when the CLI process is replaced", () =>
10849 Effect.scoped(
10850 Effect.gen(function* () {
10851 const fileSystem = yield* FileSystem.FileSystem;
10852 const idAllocator = yield* IdAllocator.IdAllocatorV2;
10853 const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({
10854 prefix: "t3-claude-v2-process-reset-",
10855 });
10856 const processQueues: Array<Queue.Queue<SDKMessage>> = [];
10857 const events: Array<ProviderAdapter.ProviderAdapterV2Event> = [];
10858 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
10859 [];
10860 const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({
10861 instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
10862 settings: DEFAULT_CLAUDE_SETTINGS,
10863 environment: {},
10864 attachmentsDir,
10865 fileSystem,
10866 path: yield* Path.Path,
10867 crypto: yield* Crypto.Crypto,
10868 idAllocator,
10869 continuationRequests: {
10870 offer: (request) =>
10871 Effect.sync(() => {
10872 continuationRequests.push(request);
10873 }),
10874 },
10875 queryRunner: {
10876 allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION),
10877 open: () =>
10878 Effect.gen(function* () {
10879 const sdkMessages = yield* Queue.unbounded<SDKMessage>();
10880 processQueues.push(sdkMessages);
10881 return {
10882 messages: Stream.fromQueue(sdkMessages),
10883 offer: () => Effect.void,
10884 setModel: () => Effect.void,
10885 applyFlagSettings: () => Effect.void,
10886 setPermissionMode: () => Effect.void,
10887 stopTask: () => Effect.void,
10888 interrupt: Effect.void,
10889 // End this process stream so openQuery can replace it.
10890 close: Queue.shutdown(sdkMessages),
10891 };
10892 }),
10893 forkSession: () => Effect.die("unused forkSession"),
10894 subagentLaunchToolUseId: () => Effect.succeed(null),
10895 assertComplete: Effect.void,
10896 },
10897 });
10898 const threadId = ThreadId.make("thread-claude-process-reset");
10899 const runtime = yield* adapter.openSession({
10900 threadId,
10901 providerSessionId: ProviderSessionId.make("provider-session-claude-process-reset"),
10902 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
10903 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
10904 });
10905 const providerThread = yield* runtime.ensureThread({
10906 threadId,
10907 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
10908 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
10909 });
10910 yield* runtime.events.pipe(
10911 Stream.runForEach((event) =>
10912 Effect.sync(() => {
10913 events.push(event);
10914 }),
10915 ),
10916 Effect.forkScoped,
10917 );
10918 if (runtime.hasPendingBackgroundWork === undefined) {
10919 return yield* Effect.die("Claude adapter runtime must expose hasPendingBackgroundWork.");
10920 }
10921 const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork;
10922 const now = yield* DateTime.now;
10923
10924 yield* runtime.startTurn(
10925 makeClaudeTestTurnInput({
10926 threadId,
10927 providerThread,
10928 now,
10929 attemptId: RunAttemptId.make("attempt-claude-process-reset-a"),
10930 text: "Run the build in the background.",
10931 attachments: [],
10932 }),
10933 );
10934 assert.equal(processQueues.length, 1);
10935 const firstProcess = processQueues[0]!;
10936 yield* Queue.offer(firstProcess, wakeTaskStarted);
10937 // The shell leaves the roster before its notification arrives, so
10938 // nothing runs in this process any more and a model change may
10939 // replace it. Wake eligibility outlives the empty level.
10940 yield* Queue.offer(
10941 firstProcess,
10942 claudeSdkFrame({
10943 type: "system",
10944 subtype: "background_tasks_changed",
10945 tasks: [],
10946 uuid: "00000000-0000-4000-8000-000000000603",
10947 session_id: WAKE_NATIVE_SESSION,
10948 }),
10949 );
10950 yield* Queue.offer(firstProcess, turnOneResult);
10951 yield* awaitUntil(
10952 () => events.some((event) => event.type === "turn.terminal"),
10953 "first turn terminal",
10954 );
10955 assert.isFalse(yield* hasPendingBackgroundWork);
10956
10957 const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION;
10958 // ProviderTurnStartService marks the thread active before startTurn;
10959 // the process-reset clear must preserve that status.
10960 const activeProviderThread = {
10961 ...providerThread,
10962 status: "active" as const,
10963 } satisfies OrchestrationV2ProviderThread;
10964 yield* runtime.startTurn(
10965 makeClaudeTestTurnInput({
10966 threadId,
10967 providerThread: activeProviderThread,
10968 now,
10969 attemptId: RunAttemptId.make("attempt-claude-process-reset-b"),
10970 text: "Continue after process restart.",
10971 attachments: [],
10972 providerTurnOrdinal: 2,
10973 modelSelection: alternateModel,
10974 }),
10975 );
10976 assert.equal(processQueues.length, 2);
10977
10978 // Process-scoped level resets to empty on CLI (re)start while the
10979 // starting turn's provider thread remains active (not idle).
10980 yield* awaitUntil(
10981 () =>
10982 providerThreadRosterEvents(events).some(
10983 (event) =>
10984 event.providerThread.status === "active" &&
10985 (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0 &&
10986 // Prefer the post-replace clear over the initial empty thread.
10987 event.providerThread.updatedAt !== undefined,
10988 ),
10989 "roster cleared on process replace while remaining active",
10990 );
10991 // After replace, the in-memory Waiting probe must be false even if a
10992 // late empty-level event was already present before background work.
10993 assert.isFalse(yield* hasPendingBackgroundWork);
10994 const emptyActiveRosterEvents = providerThreadRosterEvents(events).filter(
10995 (event) =>
10996 event.providerThread.status === "active" &&
10997 (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0,
10998 );
10999 assert.isAtLeast(emptyActiveRosterEvents.length, 1);
11000 assert.deepEqual(
11001 emptyActiveRosterEvents.at(-1)?.providerThread.pendingBackgroundTasks ?? [],
11002 [],
11003 );
11004 assert.equal(emptyActiveRosterEvents.at(-1)?.providerThread.status, "active");
11005
11006 // A late notification from the previous process must not wake after
11007 // eligibility was reset with the process. Offer on the new process
11008 // stream (the old queue is shut down).
11009 const secondProcess = processQueues[1]!;
11010 yield* Queue.offer(secondProcess, wakeNotification);
11011 let settleYields = 0;
11012 yield* awaitUntil(() => settleYields++ >= 50, "stale notification settle");
11013 assert.lengthOf(continuationRequests, 0);
11014
11015 yield* Queue.offer(
11016 secondProcess,
11017 makeResultFrame({
11018 uuid: "00000000-0000-4000-8000-000000000602",
11019 result: "Process restart turn finished.",
11020 }),
11021 );
11022 yield* awaitUntil(
11023 () => events.filter((event) => event.type === "turn.terminal").length === 2,
11024 "second turn terminal",
11025 );
11026 }).pipe(
11027 Effect.provide(
11028 Layer.mergeAll(
11029 IdAllocator.layer,
11030 McpProviderSessions.layer,
11031 ScriptProgress.layerUnavailable,
11032 NodeServices.layer,
11033 ),
11034 ),
11035 ),
11036 ),
11037 );
11038
11039 it.effect(
11040 "preserves buffered local_bash notification classification across model/policy query replacement",
11041 () =>
11042 Effect.scoped(
11043 Effect.gen(function* () {
11044 const fileSystem = yield* FileSystem.FileSystem;
11045 const idAllocator = yield* IdAllocator.IdAllocatorV2;
11046 const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({
11047 prefix: "t3-claude-v2-buffer-replace-",
11048 });
11049 const processQueues: Array<Queue.Queue<SDKMessage>> = [];
11050 const events: Array<ProviderAdapter.ProviderAdapterV2Event> = [];
11051 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
11052 [];
11053 const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({
11054 instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
11055 settings: DEFAULT_CLAUDE_SETTINGS,
11056 environment: {},
11057 attachmentsDir,
11058 fileSystem,
11059 path: yield* Path.Path,
11060 crypto: yield* Crypto.Crypto,
11061 idAllocator,
11062 continuationRequests: {
11063 offer: (request) =>
11064 Effect.sync(() => {
11065 continuationRequests.push(request);
11066 }),
11067 },
11068 queryRunner: {
11069 allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION),
11070 open: () =>
11071 Effect.gen(function* () {
11072 const sdkMessages = yield* Queue.unbounded<SDKMessage>();
11073 processQueues.push(sdkMessages);
11074 return {
11075 messages: Stream.fromQueue(sdkMessages),
11076 offer: () => Effect.void,
11077 setModel: () => Effect.void,
11078 applyFlagSettings: () => Effect.void,
11079 setPermissionMode: () => Effect.void,
11080 stopTask: () => Effect.void,
11081 interrupt: Effect.void,
11082 close: Queue.shutdown(sdkMessages),
11083 };
11084 }),
11085 forkSession: () => Effect.die("unused forkSession"),
11086 subagentLaunchToolUseId: () => Effect.succeed(null),
11087 assertComplete: Effect.void,
11088 },
11089 });
11090 const threadId = ThreadId.make("thread-claude-buffer-replace");
11091 const runtime = yield* adapter.openSession({
11092 threadId,
11093 providerSessionId: ProviderSessionId.make("provider-session-claude-buffer-replace"),
11094 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
11095 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
11096 });
11097 const providerThread = yield* runtime.ensureThread({
11098 threadId,
11099 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
11100 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
11101 });
11102 yield* runtime.events.pipe(
11103 Stream.runForEach((event) =>
11104 Effect.sync(() => {
11105 events.push(event);
11106 }),
11107 ),
11108 Effect.forkScoped,
11109 );
11110 if (runtime.hasPendingBackgroundWork === undefined) {
11111 return yield* Effect.die(
11112 "Claude adapter runtime must expose hasPendingBackgroundWork.",
11113 );
11114 }
11115 const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork;
11116 const now = yield* DateTime.now;
11117
11118 yield* runtime.startTurn(
11119 makeClaudeTestTurnInput({
11120 threadId,
11121 providerThread,
11122 now,
11123 attemptId: RunAttemptId.make("attempt-claude-buffer-replace-a"),
11124 text: "Run the build in the background.",
11125 attachments: [],
11126 }),
11127 );
11128 assert.equal(processQueues.length, 1);
11129 const firstProcess = processQueues[0]!;
11130 yield* Queue.offer(firstProcess, wakeTaskStarted);
11131 yield* Queue.offer(firstProcess, turnOneResult);
11132 yield* awaitUntil(
11133 () => events.some((event) => event.type === "turn.terminal"),
11134 "first turn terminal",
11135 );
11136 assert.isTrue(yield* hasPendingBackgroundWork);
11137
11138 // Idle completion notification buffers before any continuation runs.
11139 yield* Queue.offer(firstProcess, wakeNotification);
11140 let quietYields = 0;
11141 yield* awaitUntil(() => quietYields++ >= 50, "notification-only quiet window");
11142 assert.lengthOf(continuationRequests, 0);
11143
11144 // User turn changes model, replacing the query while the wake buffer
11145 // stays queued for the later provider continuation.
11146 const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION;
11147 yield* runtime.startTurn(
11148 makeClaudeTestTurnInput({
11149 threadId,
11150 providerThread: { ...providerThread, status: "active" },
11151 now,
11152 attemptId: RunAttemptId.make("attempt-claude-buffer-replace-user"),
11153 text: "Switch model while background work completes.",
11154 attachments: [],
11155 providerTurnOrdinal: 2,
11156 modelSelection: alternateModel,
11157 }),
11158 );
11159 assert.equal(processQueues.length, 2);
11160 const secondProcess = processQueues[1]!;
11161 yield* Queue.offer(
11162 secondProcess,
11163 makeResultFrame({
11164 uuid: "00000000-0000-4000-8000-000000000701",
11165 result: "User turn finished after model switch.",
11166 }),
11167 );
11168 yield* awaitUntil(
11169 () => events.filter((event) => event.type === "turn.terminal").length === 2,
11170 "user turn terminal after replace",
11171 );
11172 // The terminal notification remains buffered for classification, but
11173 // notification-only traffic no longer pins pending work.
11174 assert.isFalse(yield* hasPendingBackgroundWork);
11175 assert.lengthOf(continuationRequests, 0);
11176
11177 // Continuation drains the buffered local_bash notification with no
11178 // fabricated subagent/node and attributes the wake result text.
11179 yield* Queue.offer(secondProcess, wakeResult);
11180 yield* awaitUntil(() => continuationRequests.length === 1, "continuation after result");
11181 assert.equal(continuationRequests[0]?.detail, WAKE_SUMMARY);
11182 yield* runtime.startTurn(
11183 makeClaudeTestTurnInput({
11184 threadId,
11185 providerThread: { ...providerThread, status: "active" },
11186 now,
11187 attemptId: RunAttemptId.make("attempt-claude-buffer-replace-cont"),
11188 text: "Background task completed.",
11189 attachments: [],
11190 providerTurnOrdinal: 3,
11191 modelSelection: alternateModel,
11192 messageCreatedBy: "agent",
11193 messageCreationSource: "provider",
11194 }),
11195 );
11196 yield* awaitUntil(
11197 () => events.filter((event) => event.type === "turn.terminal").length === 3,
11198 "continuation terminal after buffered drain",
11199 );
11200 assert.isTrue(
11201 events.some(
11202 (event) =>
11203 event.type === "message.updated" && event.message.text === WAKE_RESULT_TEXT,
11204 ),
11205 );
11206 assert.isFalse(
11207 events.some(
11208 (event) =>
11209 event.type === "subagent.updated" ||
11210 (event.type === "node.updated" && event.node.kind === "subagent"),
11211 ),
11212 );
11213 // Must not re-project the opaque task id as anything but roster history.
11214 assert.isFalse(
11215 events.some(
11216 (event) =>
11217 event.type !== "provider_thread.updated" &&
11218 JSON.stringify(event).includes(WAKE_TASK_ID),
11219 ),
11220 );
11221 assert.isFalse(yield* hasPendingBackgroundWork);
11222 }).pipe(
11223 Effect.provide(
11224 Layer.mergeAll(
11225 IdAllocator.layer,
11226 McpProviderSessions.layer,
11227 ScriptProgress.layerUnavailable,
11228 NodeServices.layer,
11229 ),
11230 ),
11231 ),
11232 ),
11233 );
11234
11235 it.effect(
11236 "does not opaque-misclassify a buffered subagent notification across model/policy query replacement",
11237 () =>
11238 Effect.scoped(
11239 Effect.gen(function* () {
11240 const SUBAGENT_TASK_ID = "task-buffer-replace-subagent";
11241 const SUBAGENT_TOOL_USE_ID = "toolu-buffer-replace-subagent";
11242 const SUBAGENT_SUMMARY = "SUB_BUFFER_REPLACE_DONE";
11243 const subagentTaskStarted = claudeSdkFrame({
11244 type: "system",
11245 subtype: "task_started",
11246 task_id: SUBAGENT_TASK_ID,
11247 tool_use_id: SUBAGENT_TOOL_USE_ID,
11248 description: "Background research",
11249 subagent_type: "general-purpose",
11250 task_type: "local_agent",
11251 prompt: "Research then return SUB_BUFFER_REPLACE_DONE.",
11252 uuid: "00000000-0000-4000-8000-000000000801",
11253 session_id: WAKE_NATIVE_SESSION,
11254 });
11255 const subagentNotification = claudeSdkFrame({
11256 type: "system",
11257 subtype: "task_notification",
11258 task_id: SUBAGENT_TASK_ID,
11259 tool_use_id: SUBAGENT_TOOL_USE_ID,
11260 status: "completed",
11261 output_file: "/tmp/task-buffer-replace-subagent.output",
11262 summary: SUBAGENT_SUMMARY,
11263 uuid: "00000000-0000-4000-8000-000000000802",
11264 session_id: WAKE_NATIVE_SESSION,
11265 });
11266 const subagentAsyncAck = claudeSdkFrame({
11267 type: "user",
11268 message: {
11269 role: "user",
11270 content: [
11271 {
11272 type: "tool_result",
11273 tool_use_id: SUBAGENT_TOOL_USE_ID,
11274 content: [{ type: "text", text: "Async agent launched successfully." }],
11275 },
11276 ],
11277 },
11278 parent_tool_use_id: null,
11279 uuid: "00000000-0000-4000-8000-000000000803",
11280 session_id: WAKE_NATIVE_SESSION,
11281 tool_use_result: {
11282 isAsync: true,
11283 status: "async_launched",
11284 agentId: SUBAGENT_TASK_ID,
11285 prompt: "Research then return SUB_BUFFER_REPLACE_DONE.",
11286 },
11287 });
11288
11289 const fileSystem = yield* FileSystem.FileSystem;
11290 const idAllocator = yield* IdAllocator.IdAllocatorV2;
11291 const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({
11292 prefix: "t3-claude-v2-subagent-buffer-replace-",
11293 });
11294 const processQueues: Array<Queue.Queue<SDKMessage>> = [];
11295 const events: Array<ProviderAdapter.ProviderAdapterV2Event> = [];
11296 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
11297 [];
11298 const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({
11299 instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
11300 settings: DEFAULT_CLAUDE_SETTINGS,
11301 environment: {},
11302 attachmentsDir,
11303 fileSystem,
11304 path: yield* Path.Path,
11305 crypto: yield* Crypto.Crypto,
11306 idAllocator,
11307 continuationRequests: {
11308 offer: (request) =>
11309 Effect.sync(() => {
11310 continuationRequests.push(request);
11311 }),
11312 },
11313 queryRunner: {
11314 allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION),
11315 open: () =>
11316 Effect.gen(function* () {
11317 const sdkMessages = yield* Queue.unbounded<SDKMessage>();
11318 processQueues.push(sdkMessages);
11319 return {
11320 messages: Stream.fromQueue(sdkMessages),
11321 offer: () => Effect.void,
11322 setModel: () => Effect.void,
11323 applyFlagSettings: () => Effect.void,
11324 setPermissionMode: () => Effect.void,
11325 stopTask: () => Effect.void,
11326 interrupt: Effect.void,
11327 close: Queue.shutdown(sdkMessages),
11328 };
11329 }),
11330 forkSession: () => Effect.die("unused forkSession"),
11331 subagentLaunchToolUseId: () => Effect.succeed(null),
11332 assertComplete: Effect.void,
11333 },
11334 });
11335 const threadId = ThreadId.make("thread-claude-subagent-buffer-replace");
11336 const runtime = yield* adapter.openSession({
11337 threadId,
11338 providerSessionId: ProviderSessionId.make(
11339 "provider-session-claude-subagent-buffer-replace",
11340 ),
11341 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
11342 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
11343 });
11344 const providerThread = yield* runtime.ensureThread({
11345 threadId,
11346 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
11347 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
11348 });
11349 yield* runtime.events.pipe(
11350 Stream.runForEach((event) =>
11351 Effect.sync(() => {
11352 events.push(event);
11353 }),
11354 ),
11355 Effect.forkScoped,
11356 );
11357 if (runtime.hasPendingBackgroundWork === undefined) {
11358 return yield* Effect.die(
11359 "Claude adapter runtime must expose hasPendingBackgroundWork.",
11360 );
11361 }
11362 const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork;
11363 const subagentEvents = () =>
11364 events.filter(
11365 (
11366 event,
11367 ): event is Extract<
11368 ProviderAdapter.ProviderAdapterV2Event,
11369 { type: "subagent.updated" }
11370 > => event.type === "subagent.updated",
11371 );
11372 const now = yield* DateTime.now;
11373
11374 yield* runtime.startTurn(
11375 makeClaudeTestTurnInput({
11376 threadId,
11377 providerThread,
11378 now,
11379 attemptId: RunAttemptId.make("attempt-claude-subagent-buffer-replace-a"),
11380 text: "Spawn a background subagent and stop.",
11381 attachments: [],
11382 }),
11383 );
11384 assert.equal(processQueues.length, 1);
11385 const firstProcess = processQueues[0]!;
11386 yield* Queue.offer(firstProcess, subagentTaskStarted);
11387 yield* awaitUntil(() => subagentEvents().length >= 1, "subagent node created");
11388 assert.equal(subagentEvents()[0]?.subagent.status, "running");
11389 yield* Queue.offer(firstProcess, subagentAsyncAck);
11390 yield* Queue.offer(
11391 firstProcess,
11392 makeResultFrame({
11393 uuid: "00000000-0000-4000-8000-000000000804",
11394 result: "Spawned the subagent in the background.",
11395 }),
11396 );
11397 yield* awaitUntil(
11398 () => events.some((event) => event.type === "turn.terminal"),
11399 "first turn terminal",
11400 );
11401 assert.isTrue(yield* hasPendingBackgroundWork);
11402
11403 // Session-registered subagent completion buffers; no opaque tombstone.
11404 yield* Queue.offer(firstProcess, subagentNotification);
11405 yield* awaitUntil(() => continuationRequests.length === 1, "continuation after notify");
11406 assert.equal(continuationRequests[0]?.detail, SUBAGENT_SUMMARY);
11407
11408 // Model-changing user turn replaces the query while continuation stays
11409 // queued. Process reset must not invent opaque classification for the
11410 // buffered subagent notification.
11411 const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION;
11412 yield* runtime.startTurn(
11413 makeClaudeTestTurnInput({
11414 threadId,
11415 providerThread: { ...providerThread, status: "active" },
11416 now,
11417 attemptId: RunAttemptId.make("attempt-claude-subagent-buffer-replace-user"),
11418 text: "Switch model while the subagent completes.",
11419 attachments: [],
11420 providerTurnOrdinal: 2,
11421 modelSelection: alternateModel,
11422 }),
11423 );
11424 assert.equal(processQueues.length, 2);
11425 const secondProcess = processQueues[1]!;
11426 yield* Queue.offer(
11427 secondProcess,
11428 makeResultFrame({
11429 uuid: "00000000-0000-4000-8000-000000000805",
11430 result: "User turn finished after model switch.",
11431 }),
11432 );
11433 yield* awaitUntil(
11434 () => events.filter((event) => event.type === "turn.terminal").length === 2,
11435 "user turn terminal after replace",
11436 );
11437 // The actual completion was applied while idle, before wake replay.
11438 assert.isFalse(yield* hasPendingBackgroundWork);
11439 assert.equal(subagentEvents().at(-1)?.subagent.status, "completed");
11440 assert.lengthOf(continuationRequests, 1);
11441
11442 yield* Queue.offer(
11443 secondProcess,
11444 makeResultFrame({
11445 uuid: "00000000-0000-4000-8000-000000000806",
11446 result: "The subagent finished with SUB_BUFFER_REPLACE_DONE.",
11447 }),
11448 );
11449 yield* runtime.startTurn(
11450 makeClaudeTestTurnInput({
11451 threadId,
11452 providerThread: { ...providerThread, status: "active" },
11453 now,
11454 attemptId: RunAttemptId.make("attempt-claude-subagent-buffer-replace-cont"),
11455 text: "Background task completed.",
11456 attachments: [],
11457 providerTurnOrdinal: 3,
11458 modelSelection: alternateModel,
11459 messageCreatedBy: "agent",
11460 messageCreationSource: "provider",
11461 }),
11462 );
11463 yield* awaitUntil(
11464 () => events.filter((event) => event.type === "turn.terminal").length === 3,
11465 "continuation terminal after buffered subagent drain",
11466 );
11467
11468 const finalSubagent = subagentEvents().at(-1)?.subagent;
11469 assert.equal(finalSubagent?.status, "completed");
11470 assert.equal(finalSubagent?.result, SUBAGENT_SUMMARY);
11471 assert.equal(finalSubagent?.runId, subagentEvents()[0]?.subagent.runId);
11472 const subagentNodeEvents = events.filter(
11473 (
11474 event,
11475 ): event is Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "node.updated" }> =>
11476 event.type === "node.updated" &&
11477 event.node.kind === "subagent" &&
11478 event.node.nativeItemRef?.nativeId === SUBAGENT_TASK_ID,
11479 );
11480 assert.equal(subagentNodeEvents.at(-1)?.node.status, "completed");
11481 assert.isFalse(yield* hasPendingBackgroundWork);
11482 }).pipe(
11483 Effect.provide(
11484 Layer.mergeAll(
11485 IdAllocator.layer,
11486 McpProviderSessions.layer,
11487 ScriptProgress.layerUnavailable,
11488 NodeServices.layer,
11489 ),
11490 ),
11491 ),
11492 ),
11493 );
11494
11495 it.effect("refuses a thinking change that would kill a running background subagent", () =>
11496 Effect.scoped(
11497 Effect.gen(function* () {
11498 const SUBAGENT_TASK_ID = "task-model-change-running-subagent";
11499 const SUBAGENT_TOOL_USE_ID = "toolu-model-change-running-subagent";
11500 const fileSystem = yield* FileSystem.FileSystem;
11501 const idAllocator = yield* IdAllocator.IdAllocatorV2;
11502 const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({
11503 prefix: "t3-claude-v2-model-change-running-subagent-",
11504 });
11505 const processQueues: Array<Queue.Queue<SDKMessage>> = [];
11506 const events: Array<ProviderAdapter.ProviderAdapterV2Event> = [];
11507 const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({
11508 instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
11509 settings: DEFAULT_CLAUDE_SETTINGS,
11510 environment: {},
11511 attachmentsDir,
11512 fileSystem,
11513 path: yield* Path.Path,
11514 crypto: yield* Crypto.Crypto,
11515 idAllocator,
11516 continuationRequests: { offer: () => Effect.void },
11517 queryRunner: {
11518 allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION),
11519 open: () =>
11520 Effect.gen(function* () {
11521 const sdkMessages = yield* Queue.unbounded<SDKMessage>();
11522 processQueues.push(sdkMessages);
11523 return {
11524 messages: Stream.fromQueue(sdkMessages),
11525 offer: () => Effect.void,
11526 setModel: () => Effect.void,
11527 applyFlagSettings: () => Effect.void,
11528 setPermissionMode: () => Effect.void,
11529 stopTask: () => Effect.void,
11530 interrupt: Effect.void,
11531 close: Queue.shutdown(sdkMessages),
11532 };
11533 }),
11534 forkSession: () => Effect.die("unused forkSession"),
11535 subagentLaunchToolUseId: () => Effect.succeed(null),
11536 assertComplete: Effect.void,
11537 },
11538 });
11539 const threadId = ThreadId.make("thread-claude-model-change-running-subagent");
11540 const runtime = yield* adapter.openSession({
11541 threadId,
11542 providerSessionId: ProviderSessionId.make(
11543 "provider-session-claude-model-change-running-subagent",
11544 ),
11545 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
11546 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
11547 });
11548 const providerThread = yield* runtime.ensureThread({
11549 threadId,
11550 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
11551 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
11552 });
11553 yield* runtime.events.pipe(
11554 Stream.runForEach((event) =>
11555 Effect.sync(() => {
11556 events.push(event);
11557 }),
11558 ),
11559 Effect.forkScoped,
11560 );
11561 const terminals = () => events.filter((event) => event.type === "turn.terminal");
11562 const now = yield* DateTime.now;
11563
11564 yield* runtime.startTurn(
11565 makeClaudeTestTurnInput({
11566 threadId,
11567 providerThread,
11568 now,
11569 attemptId: RunAttemptId.make("attempt-claude-model-change-running-subagent-a"),
11570 text: "Spawn a background subagent and stop.",
11571 attachments: [],
11572 }),
11573 );
11574 const firstProcess = processQueues[0]!;
11575 yield* Queue.offer(
11576 firstProcess,
11577 claudeSdkFrame({
11578 type: "system",
11579 subtype: "task_started",
11580 task_id: SUBAGENT_TASK_ID,
11581 tool_use_id: SUBAGENT_TOOL_USE_ID,
11582 description: "Background research",
11583 subagent_type: "general-purpose",
11584 task_type: "local_agent",
11585 prompt: "Research, then report.",
11586 uuid: "00000000-0000-4000-8000-000000000901",
11587 session_id: WAKE_NATIVE_SESSION,
11588 }),
11589 );
11590 yield* Queue.offer(
11591 firstProcess,
11592 makeResultFrame({
11593 uuid: "00000000-0000-4000-8000-000000000902",
11594 result: "Spawned the subagent in the background.",
11595 }),
11596 );
11597 yield* awaitUntil(() => terminals().length === 1, "first turn terminal");
11598 const settledTurn = terminals()[0]!;
11599
11600 // The subagent runs inside the first CLI process. A thinking change
11601 // needs another process, so the turn must not start and close this one.
11602 const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION;
11603 const switchTurn = (attempt: string) =>
11604 runtime.startTurn(
11605 makeClaudeTestTurnInput({
11606 threadId,
11607 providerThread: { ...providerThread, status: "active" },
11608 now,
11609 attemptId: RunAttemptId.make(attempt),
11610 text: "Switch model while the subagent runs.",
11611 attachments: [],
11612 providerTurnOrdinal: 2,
11613 modelSelection: alternateModel,
11614 }),
11615 );
11616 const refused = yield* switchTurn("attempt-claude-model-change-running-subagent-b").pipe(
11617 Effect.flip,
11618 );
11619 assert.equal(
11620 makeProviderFailure({ cause: refused, class: "provider_error" }).message,
11621 new ClaudeAdapterV2.ClaudeBackgroundWorkBlocksQueryReplacementError().message,
11622 );
11623 assert.lengthOf(processQueues, 1);
11624
11625 // Stop ends the background work, so the switch may replace the process.
11626 yield* runtime.interruptTurn({
11627 providerThread,
11628 providerTurnId: settledTurn.providerTurnId,
11629 requestRuntimeRestart: true,
11630 });
11631 yield* switchTurn("attempt-claude-model-change-running-subagent-c");
11632 assert.lengthOf(processQueues, 2);
11633
11634 // The stopped subagent never reports its end, so it must not block
11635 // later changes on the replacement process either.
11636 yield* Queue.offer(
11637 processQueues[1]!,
11638 makeResultFrame({
11639 uuid: "00000000-0000-4000-8000-000000000903",
11640 result: "Switched model.",
11641 }),
11642 );
11643 yield* awaitUntil(() => terminals().length === 2, "switched turn terminal");
11644 yield* runtime.startTurn(
11645 makeClaudeTestTurnInput({
11646 threadId,
11647 providerThread: { ...providerThread, status: "active" },
11648 now,
11649 attemptId: RunAttemptId.make("attempt-claude-model-change-running-subagent-d"),
11650 text: "Switch back.",
11651 attachments: [],
11652 providerTurnOrdinal: 3,
11653 }),
11654 );
11655 assert.lengthOf(processQueues, 3);
11656 }).pipe(
11657 Effect.provide(
11658 Layer.mergeAll(
11659 IdAllocator.layer,
11660 McpProviderSessions.layer,
11661 ScriptProgress.layerUnavailable,
11662 NodeServices.layer,
11663 ),
11664 ),
11665 ),
11666 ),
11667 );
11668
11669 it.effect(
11670 "keeps the process and its roster when a model change meets a running background shell",
11671 () =>
11672 Effect.scoped(
11673 Effect.gen(function* () {
11674 const fileSystem = yield* FileSystem.FileSystem;
11675 const idAllocator = yield* IdAllocator.IdAllocatorV2;
11676 const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({
11677 prefix: "t3-claude-v2-replace-open-fail-",
11678 });
11679 let openCount = 0;
11680 const processQueues: Array<Queue.Queue<SDKMessage>> = [];
11681 const events: Array<ProviderAdapter.ProviderAdapterV2Event> = [];
11682 const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({
11683 instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
11684 settings: DEFAULT_CLAUDE_SETTINGS,
11685 environment: {},
11686 attachmentsDir,
11687 fileSystem,
11688 path: yield* Path.Path,
11689 crypto: yield* Crypto.Crypto,
11690 idAllocator,
11691 continuationRequests: {
11692 offer: () => Effect.void,
11693 },
11694 queryRunner: {
11695 allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION),
11696 open: () => {
11697 openCount += 1;
11698 if (openCount === 2) {
11699 return Effect.fail(
11700 new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({
11701 method: "open",
11702 cause: "forced replacement open failure",
11703 }),
11704 );
11705 }
11706 return Effect.gen(function* () {
11707 const sdkMessages = yield* Queue.unbounded<SDKMessage>();
11708 processQueues.push(sdkMessages);
11709 return {
11710 messages: Stream.fromQueue(sdkMessages),
11711 offer: () => Effect.void,
11712 setModel: () => Effect.void,
11713 applyFlagSettings: () => Effect.void,
11714 setPermissionMode: () => Effect.void,
11715 stopTask: () => Effect.void,
11716 interrupt: Effect.void,
11717 close: Queue.shutdown(sdkMessages),
11718 };
11719 });
11720 },
11721 forkSession: () => Effect.die("unused forkSession"),
11722 subagentLaunchToolUseId: () => Effect.succeed(null),
11723 assertComplete: Effect.void,
11724 },
11725 });
11726 const threadId = ThreadId.make("thread-claude-replace-open-fail");
11727 const runtime = yield* adapter.openSession({
11728 threadId,
11729 providerSessionId: ProviderSessionId.make("provider-session-claude-replace-open-fail"),
11730 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
11731 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
11732 });
11733 const providerThread = yield* runtime.ensureThread({
11734 threadId,
11735 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
11736 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
11737 });
11738 yield* runtime.events.pipe(
11739 Stream.runForEach((event) =>
11740 Effect.sync(() => {
11741 events.push(event);
11742 }),
11743 ),
11744 Effect.forkScoped,
11745 );
11746 if (runtime.hasPendingBackgroundWork === undefined) {
11747 return yield* Effect.die(
11748 "Claude adapter runtime must expose hasPendingBackgroundWork.",
11749 );
11750 }
11751 const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork;
11752 const now = yield* DateTime.now;
11753
11754 yield* runtime.startTurn(
11755 makeClaudeTestTurnInput({
11756 threadId,
11757 providerThread,
11758 now,
11759 attemptId: RunAttemptId.make("attempt-claude-replace-open-fail-a"),
11760 text: "Run the build in the background.",
11761 attachments: [],
11762 }),
11763 );
11764 assert.equal(processQueues.length, 1);
11765 yield* Queue.offer(processQueues[0]!, wakeTaskStarted);
11766 yield* Queue.offer(processQueues[0]!, turnOneResult);
11767 yield* awaitUntil(
11768 () => events.some((event) => event.type === "turn.terminal"),
11769 "first turn terminal",
11770 );
11771 assert.isTrue(yield* hasPendingBackgroundWork);
11772
11773 const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION;
11774 const failedStart = yield* runtime
11775 .startTurn(
11776 makeClaudeTestTurnInput({
11777 threadId,
11778 providerThread: { ...providerThread, status: "active" },
11779 now,
11780 attemptId: RunAttemptId.make("attempt-claude-replace-open-fail-b"),
11781 text: "Replace process but fail open.",
11782 attachments: [],
11783 providerTurnOrdinal: 2,
11784 modelSelection: alternateModel,
11785 }),
11786 )
11787 .pipe(Effect.exit);
11788 assert.isTrue(Exit.isFailure(failedStart));
11789 // The shell runs in the first process, so it is never closed and
11790 // no replacement is opened.
11791 assert.equal(openCount, 1);
11792 assert.isTrue(yield* hasPendingBackgroundWork);
11793 }).pipe(
11794 Effect.provide(
11795 Layer.mergeAll(
11796 IdAllocator.layer,
11797 McpProviderSessions.layer,
11798 ScriptProgress.layerUnavailable,
11799 NodeServices.layer,
11800 ),
11801 ),
11802 ),
11803 ),
11804 );
11805
11806 it.effect(
11807 "clears buffered wake and continuation state when same-native-thread replacement open fails",
11808 () =>
11809 Effect.scoped(
11810 Effect.gen(function* () {
11811 const fileSystem = yield* FileSystem.FileSystem;
11812 const idAllocator = yield* IdAllocator.IdAllocatorV2;
11813 const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({
11814 prefix: "t3-claude-v2-replace-open-fail-wake-",
11815 });
11816 let openCount = 0;
11817 const processQueues: Array<Queue.Queue<SDKMessage>> = [];
11818 const events: Array<ProviderAdapter.ProviderAdapterV2Event> = [];
11819 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
11820 [];
11821 const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({
11822 instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
11823 settings: DEFAULT_CLAUDE_SETTINGS,
11824 environment: {},
11825 attachmentsDir,
11826 fileSystem,
11827 path: yield* Path.Path,
11828 crypto: yield* Crypto.Crypto,
11829 idAllocator,
11830 continuationRequests: {
11831 offer: (request) =>
11832 Effect.sync(() => {
11833 continuationRequests.push(request);
11834 }),
11835 },
11836 queryRunner: {
11837 allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION),
11838 open: () => {
11839 openCount += 1;
11840 if (openCount === 2) {
11841 return Effect.fail(
11842 new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({
11843 method: "open",
11844 cause: "forced replacement open failure",
11845 }),
11846 );
11847 }
11848 return Effect.gen(function* () {
11849 const sdkMessages = yield* Queue.unbounded<SDKMessage>();
11850 processQueues.push(sdkMessages);
11851 return {
11852 messages: Stream.fromQueue(sdkMessages),
11853 offer: () => Effect.void,
11854 setModel: () => Effect.void,
11855 applyFlagSettings: () => Effect.void,
11856 setPermissionMode: () => Effect.void,
11857 stopTask: () => Effect.void,
11858 interrupt: Effect.void,
11859 close: Queue.shutdown(sdkMessages),
11860 };
11861 });
11862 },
11863 forkSession: () => Effect.die("unused forkSession"),
11864 subagentLaunchToolUseId: () => Effect.succeed(null),
11865 assertComplete: Effect.void,
11866 },
11867 });
11868 const threadId = ThreadId.make("thread-claude-replace-open-fail-wake");
11869 const runtime = yield* adapter.openSession({
11870 threadId,
11871 providerSessionId: ProviderSessionId.make(
11872 "provider-session-claude-replace-open-fail-wake",
11873 ),
11874 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
11875 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
11876 });
11877 const providerThread = yield* runtime.ensureThread({
11878 threadId,
11879 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
11880 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
11881 });
11882 yield* runtime.events.pipe(
11883 Stream.runForEach((event) =>
11884 Effect.sync(() => {
11885 events.push(event);
11886 }),
11887 ),
11888 Effect.forkScoped,
11889 );
11890 if (runtime.hasPendingBackgroundWork === undefined) {
11891 return yield* Effect.die(
11892 "Claude adapter runtime must expose hasPendingBackgroundWork.",
11893 );
11894 }
11895 const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork;
11896 const now = yield* DateTime.now;
11897
11898 yield* runtime.startTurn(
11899 makeClaudeTestTurnInput({
11900 threadId,
11901 providerThread,
11902 now,
11903 attemptId: RunAttemptId.make("attempt-claude-replace-open-fail-wake-a"),
11904 text: "Run the build in the background.",
11905 attachments: [],
11906 }),
11907 );
11908 yield* Queue.offer(processQueues[0]!, wakeTaskStarted);
11909 yield* Queue.offer(processQueues[0]!, turnOneResult);
11910 yield* awaitUntil(
11911 () => events.filter((event) => event.type === "turn.terminal").length === 1,
11912 "first turn terminal",
11913 );
11914 yield* Queue.offer(processQueues[0]!, wakeNotification);
11915 yield* Queue.offer(processQueues[0]!, wakeAssistant);
11916 yield* awaitUntil(() => continuationRequests.length === 1, "first continuation request");
11917 assert.isTrue(yield* hasPendingBackgroundWork);
11918
11919 const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION;
11920 const failedStart = yield* runtime
11921 .startTurn(
11922 makeClaudeTestTurnInput({
11923 threadId,
11924 providerThread: { ...providerThread, status: "active" },
11925 now,
11926 attemptId: RunAttemptId.make("attempt-claude-replace-open-fail-wake-b"),
11927 text: "Replace process but fail open.",
11928 attachments: [],
11929 providerTurnOrdinal: 2,
11930 modelSelection: alternateModel,
11931 }),
11932 )
11933 .pipe(Effect.exit);
11934 assert.isTrue(Exit.isFailure(failedStart));
11935 assert.isFalse(yield* hasPendingBackgroundWork);
11936
11937 yield* runtime.startTurn(
11938 makeClaudeTestTurnInput({
11939 threadId,
11940 providerThread: { ...providerThread, status: "active" },
11941 now,
11942 attemptId: RunAttemptId.make("attempt-claude-replace-open-fail-wake-c"),
11943 text: "Retry after the failed replacement.",
11944 attachments: [],
11945 providerTurnOrdinal: 2,
11946 modelSelection: alternateModel,
11947 }),
11948 );
11949 const retryProcess = processQueues[1]!;
11950 const retryTaskId = "task-wake-build-after-retry";
11951 yield* Queue.offer(
11952 retryProcess,
11953 claudeSdkFrame({
11954 type: "system",
11955 subtype: "task_started",
11956 task_id: retryTaskId,
11957 tool_use_id: "toolu-wake-build-after-retry",
11958 description: "npm run build after retry",
11959 is_backgrounded: true,
11960 task_type: "local_bash",
11961 uuid: "00000000-0000-4000-8000-000000000901",
11962 session_id: WAKE_NATIVE_SESSION,
11963 }),
11964 );
11965 yield* Queue.offer(
11966 retryProcess,
11967 makeResultFrame({
11968 uuid: "00000000-0000-4000-8000-000000000902",
11969 result: "Kicked off the retry build in the background.",
11970 }),
11971 );
11972 yield* awaitUntil(
11973 () => events.filter((event) => event.type === "turn.terminal").length === 2,
11974 "retry turn terminal",
11975 );
11976 yield* Queue.offer(
11977 retryProcess,
11978 claudeSdkFrame({
11979 type: "system",
11980 subtype: "task_notification",
11981 task_id: retryTaskId,
11982 tool_use_id: "toolu-wake-build-after-retry",
11983 status: "completed",
11984 output_file: "/tmp/task-wake-build-after-retry.log",
11985 summary: "Retry build completed successfully",
11986 uuid: "00000000-0000-4000-8000-000000000903",
11987 session_id: WAKE_NATIVE_SESSION,
11988 }),
11989 );
11990 yield* Queue.offer(
11991 retryProcess,
11992 makeAssistantTextFrame({
11993 uuid: "00000000-0000-4000-8000-000000000904",
11994 text: "The retry build has finished.",
11995 }),
11996 );
11997 yield* awaitUntil(
11998 () => continuationRequests.length === 2,
11999 "continuation request after retry",
12000 );
12001 assert.equal(continuationRequests[1]?.detail, "Retry build completed successfully");
12002 }).pipe(
12003 Effect.provide(
12004 Layer.mergeAll(
12005 IdAllocator.layer,
12006 McpProviderSessions.layer,
12007 ScriptProgress.layerUnavailable,
12008 NodeServices.layer,
12009 ),
12010 ),
12011 ),
12012 ),
12013 );
12014
12015 it.effect("does not invent process reset state on a first-ever failed open", () =>
12016 Effect.scoped(
12017 Effect.gen(function* () {
12018 const fileSystem = yield* FileSystem.FileSystem;
12019 const idAllocator = yield* IdAllocator.IdAllocatorV2;
12020 const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({
12021 prefix: "t3-claude-v2-first-open-fail-",
12022 });
12023 const events: Array<ProviderAdapter.ProviderAdapterV2Event> = [];
12024 const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({
12025 instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
12026 settings: DEFAULT_CLAUDE_SETTINGS,
12027 environment: {},
12028 attachmentsDir,
12029 fileSystem,
12030 path: yield* Path.Path,
12031 crypto: yield* Crypto.Crypto,
12032 idAllocator,
12033 continuationRequests: {
12034 offer: () => Effect.void,
12035 },
12036 queryRunner: {
12037 allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION),
12038 open: () =>
12039 Effect.fail(
12040 new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({
12041 method: "open",
12042 cause: "forced first open failure",
12043 }),
12044 ),
12045 forkSession: () => Effect.die("unused forkSession"),
12046 subagentLaunchToolUseId: () => Effect.succeed(null),
12047 assertComplete: Effect.void,
12048 },
12049 });
12050 const threadId = ThreadId.make("thread-claude-first-open-fail");
12051 const runtime = yield* adapter.openSession({
12052 threadId,
12053 providerSessionId: ProviderSessionId.make("provider-session-claude-first-open-fail"),
12054 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
12055 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
12056 });
12057 const providerThread = yield* runtime.ensureThread({
12058 threadId,
12059 modelSelection: CLAUDE_TEST_MODEL_SELECTION,
12060 runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
12061 });
12062 yield* runtime.events.pipe(
12063 Stream.runForEach((event) =>
12064 Effect.sync(() => {
12065 events.push(event);
12066 }),
12067 ),
12068 Effect.forkScoped,
12069 );
12070 const now = yield* DateTime.now;
12071 const failedStart = yield* runtime
12072 .startTurn(
12073 makeClaudeTestTurnInput({
12074 threadId,
12075 providerThread,
12076 now,
12077 attemptId: RunAttemptId.make("attempt-claude-first-open-fail"),
12078 text: "First open fails.",
12079 attachments: [],
12080 }),
12081 )
12082 .pipe(Effect.exit);
12083 assert.isTrue(Exit.isFailure(failedStart));
12084 // No live process ever existed: do not emit a fabricated empty roster.
12085 assert.lengthOf(providerThreadRosterEvents(events), 0);
12086 }).pipe(
12087 Effect.provide(
12088 Layer.mergeAll(
12089 IdAllocator.layer,
12090 McpProviderSessions.layer,
12091 ScriptProgress.layerUnavailable,
12092 NodeServices.layer,
12093 ),
12094 ),
12095 ),
12096 ),
12097 );
12098
12099 const WORKFLOW_TASK_ID = "task-workflow-run";
12100 const WORKFLOW_TOOL_USE_ID = "toolu-workflow-run";
12101 const makeWorkflowHarnessWithOptions = (
12102 options?: Parameters<typeof makeWakeHarnessWithOptions>[0],
12103 ) =>
12104 Effect.gen(function* () {
12105 const fileSystem = yield* FileSystem.FileSystem;
12106 const configDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-wf-" });
12107 const transcriptDir = NodePath.join(configDir, "projects", "workflow");
12108 yield* fileSystem.makeDirectory(transcriptDir, { recursive: true });
12109 yield* fileSystem.writeFileString(
12110 NodePath.join(transcriptDir, "agent-a1.jsonl"),
12111 '{"type":"assistant","message":{"role":"assistant","id":"msg_a1","content":[{"type":"text","text":"A1 from the transcript"}]}}\n',
12112 );
12113 const harness = yield* makeWakeHarnessWithOptions({
12114 ...options,
12115 environment: { ...options?.environment, CLAUDE_CONFIG_DIR: configDir },
12116 });
12117 return { ...harness, transcriptDir };
12118 });
12119 const makeWorkflowHarness = makeWorkflowHarnessWithOptions();
12120
12121 const workflowToolUse = claudeSdkFrame({
12122 type: "assistant",
12123 message: {
12124 model: "claude-sonnet-4-6",
12125 content: [
12126 {
12127 type: "tool_use",
12128 id: WORKFLOW_TOOL_USE_ID,
12129 name: "Workflow",
12130 input: { script: "alpha.js" },
12131 },
12132 ],
12133 },
12134 parent_tool_use_id: null,
12135 uuid: "00000000-0000-4000-8000-000000001001",
12136 session_id: WAKE_NATIVE_SESSION,
12137 });
12138 const workflowTaskStarted = claudeSdkFrame({
12139 type: "system",
12140 subtype: "task_started",
12141 task_id: WORKFLOW_TASK_ID,
12142 tool_use_id: WORKFLOW_TOOL_USE_ID,
12143 description: "Run the alpha workflow",
12144 task_type: "local_workflow",
12145 workflow_name: "probe-wf",
12146 uuid: "00000000-0000-4000-8000-000000001002",
12147 session_id: WAKE_NATIVE_SESSION,
12148 });
12149 const workflowLaunchAck = (transcriptDir: string) =>
12150 claudeSdkFrame({
12151 type: "user",
12152 message: {
12153 role: "user",
12154 content: [
12155 {
12156 type: "tool_result",
12157 tool_use_id: WORKFLOW_TOOL_USE_ID,
12158 content: [{ type: "text", text: "Workflow launched." }],
12159 },
12160 ],
12161 },
12162 parent_tool_use_id: null,
12163 uuid: "00000000-0000-4000-8000-000000001003",
12164 session_id: WAKE_NATIVE_SESSION,
12165 tool_use_result: {
12166 taskType: "local_workflow",
12167 runId: "wf_probe",
12168 transcriptDir,
12169 },
12170 });
12171 const workflowSnapshot = (input: {
12172 readonly uuid: string;
12173 readonly state: "queued" | "start" | "done";
12174 readonly secondState?: "queued" | "start" | "done";
12175 readonly startedAt?: number;
12176 readonly label?: string;
12177 readonly prompt?: string;
12178 readonly model?: string;
12179 readonly attempt?: number;
12180 readonly omitResult?: boolean;
12181 }) =>
12182 claudeSdkFrame({
12183 type: "system",
12184 subtype: "task_progress",
12185 task_id: WORKFLOW_TASK_ID,
12186 tool_use_id: WORKFLOW_TOOL_USE_ID,
12187 description: "Alpha: alpha:one",
12188 workflow_progress: [
12189 { type: "workflow_phase", index: 1, title: "Alpha" },
12190 {
12191 type: "workflow_agent",
12192 index: 1,
12193 label: input.label ?? "alpha:one",
12194 agentId: "a1",
12195 state: input.state,
12196 phaseIndex: 1,
12197 phaseTitle: "Alpha",
12198 model: input.model ?? "claude-opus-5[1m]",
12199 promptPreview: input.prompt ?? "Reply with exactly: A1",
12200 ...(input.startedAt === undefined ? {} : { startedAt: input.startedAt }),
12201 ...(input.attempt === undefined ? {} : { attempt: input.attempt }),
12202 ...(input.state === "done" && input.omitResult !== true
12203 ? { resultPreview: "A1 excerpt" }
12204 : {}),
12205 },
12206 {
12207 type: "workflow_agent",
12208 index: 2,
12209 label: "alpha:two",
12210 agentId: "a2",
12211 state: input.secondState ?? input.state,
12212 phaseIndex: 1,
12213 phaseTitle: "Alpha",
12214 promptPreview: "Reply with exactly: A2",
12215 ...(input.state === "done" ? { resultPreview: "A2 excerpt" } : {}),
12216 },
12217 ],
12218 uuid: input.uuid,
12219 session_id: WAKE_NATIVE_SESSION,
12220 });
12221 const workflowNotification = (input: {
12222 readonly uuid: string;
12223 readonly status: "completed" | "failed" | "stopped";
12224 }) =>
12225 claudeSdkFrame({
12226 type: "system",
12227 subtype: "task_notification",
12228 task_id: WORKFLOW_TASK_ID,
12229 tool_use_id: WORKFLOW_TOOL_USE_ID,
12230 status: input.status,
12231 summary: "Workflow finished.",
12232 output_file: "/tmp/workflow.output",
12233 uuid: input.uuid,
12234 session_id: WAKE_NATIVE_SESSION,
12235 });
12236 const workflowCoordinatorEvents = (
12237 events: ReadonlyArray<ProviderAdapter.ProviderAdapterV2Event>,
12238 ) =>
12239 events.filter(
12240 (
12241 event,
12242 ): event is Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "subagent.updated" }> =>
12243 event.type === "subagent.updated" &&
12244 event.subagent.nativeTaskRef?.nativeId === WORKFLOW_TASK_ID,
12245 );
12246 const workflowMemberEvents = (
12247 events: ReadonlyArray<ProviderAdapter.ProviderAdapterV2Event>,
12248 index: number,
12249 ) =>
12250 events.filter(
12251 (
12252 event,
12253 ): event is Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "subagent.updated" }> =>
12254 event.type === "subagent.updated" &&
12255 event.subagent.nativeTaskRef?.nativeId?.endsWith(`:agent:${index}`) === true,
12256 );
12257 const threadMessages = (
12258 events: ReadonlyArray<ProviderAdapter.ProviderAdapterV2Event>,
12259 threadId: ThreadId | null | undefined,
12260 ) =>
12261 events.filter(
12262 (
12263 event,
12264 ): event is Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "message.updated" }> =>
12265 event.type === "message.updated" && event.message.threadId === threadId,
12266 );
12267
12268 const launchWorkflow = Effect.fnUntraced(function* (input: {
12269 readonly harness: Effect.Success<typeof makeWorkflowHarness>;
12270 readonly snapshotUuid: string;
12271 readonly state?: "queued" | "start";
12272 }) {
12273 yield* input.harness.offerAndWait(workflowToolUse);
12274 yield* input.harness.offerAndWait(workflowTaskStarted);
12275 yield* input.harness.offerAndWait(workflowLaunchAck(input.harness.transcriptDir));
12276 yield* input.harness.offerAndWait(
12277 workflowSnapshot({ uuid: input.snapshotUuid, state: input.state ?? "start" }),
12278 );
12279 yield* awaitUntil(
12280 () => workflowMemberEvents(input.harness.events, 2).length === 1,
12281 "workflow members projected",
12282 );
12283 });
12284
12285 it.effect.each(["Agent", "Workflow"] as const)(
12286 "keeps a workflow running after its %s launch reply without an async flag",
12287 (toolName) =>
12288 Effect.scoped(
12289 Effect.gen(function* () {
12290 const harness = yield* makeWorkflowHarness;
12291 yield* harness.runtime.startTurn(
12292 makeClaudeTestTurnInput({
12293 threadId: harness.threadId,
12294 providerThread: harness.providerThread,
12295 now: yield* DateTime.now,
12296 attemptId: RunAttemptId.make(`attempt-workflow-launch-${toolName}`),
12297 text: "Run the workflow.",
12298 attachments: [],
12299 }),
12300 );
12301 yield* harness.offerAndWait(
12302 claudeSdkFrame({
12303 ...workflowToolUse,
12304 message: {
12305 model: "claude-sonnet-4-6",
12306 content: [
12307 { type: "tool_use", id: WORKFLOW_TOOL_USE_ID, name: toolName, input: {} },
12308 ],
12309 },
12310 }),
12311 );
12312 yield* harness.offerAndWait(workflowTaskStarted);
12313 yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir));
12314 yield* harness.offerAndWait(
12315 workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001200", state: "start" }),
12316 );
12317 yield* Queue.offer(
12318 harness.sdkMessages,
12319 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001201", result: "Launched." }),
12320 );
12321 yield* Queue.take(harness.terminalReceipts);
12322 assert.equal(
12323 workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status,
12324 "running",
12325 );
12326 assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, "running");
12327 assert.isTrue(yield* harness.hasPendingBackgroundWork);
12328 const toolItems = harness.events.flatMap((event) =>
12329 event.type === "turn_item.updated" &&
12330 event.turnItem.type === "dynamic_tool" &&
12331 event.turnItem.nativeItemRef?.nativeId === WORKFLOW_TOOL_USE_ID
12332 ? [event.turnItem.status]
12333 : [],
12334 );
12335 assert.deepEqual(toolItems, toolName === "Workflow" ? ["running", "completed"] : []);
12336 yield* harness.offerAndWait(
12337 workflowNotification({
12338 uuid: "00000000-0000-4000-8000-000000001202",
12339 status: "completed",
12340 }),
12341 );
12342 yield* Queue.take(harness.continuationReceipts);
12343 yield* harness.runtime.startTurn(
12344 makeClaudeTestTurnInput({
12345 threadId: harness.threadId,
12346 providerThread: harness.providerThread,
12347 now: yield* DateTime.now,
12348 attemptId: RunAttemptId.make(`attempt-workflow-complete-${toolName}`),
12349 text: "Workflow finished.",
12350 attachments: [],
12351 providerTurnOrdinal: 2,
12352 messageCreatedBy: "agent",
12353 messageCreationSource: "provider",
12354 }),
12355 );
12356 yield* Queue.offer(
12357 harness.sdkMessages,
12358 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001205", result: "Finished." }),
12359 );
12360 yield* Queue.take(harness.terminalReceipts);
12361 assert.equal(
12362 workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status,
12363 "completed",
12364 );
12365 assert.equal(
12366 workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status,
12367 "completed",
12368 );
12369 assert.isFalse(yield* harness.hasPendingBackgroundWork);
12370 }).pipe(
12371 Effect.provide(
12372 Layer.mergeAll(
12373 IdAllocator.layer,
12374 McpProviderSessions.layer,
12375 ScriptProgress.layerUnavailable,
12376 NodeServices.layer,
12377 ),
12378 ),
12379 ),
12380 ),
12381 );
12382
12383 it.effect("scopes workflow member artifacts to the provider thread", () =>
12384 Effect.scoped(
12385 Effect.gen(function* () {
12386 const memberArtifacts: Array<Array<string>> = [];
12387 const memberThreads: Array<ThreadId | null | undefined> = [];
12388 for (const nativeSessionId of ["workflow-session-a", "workflow-session-b"]) {
12389 const harness = yield* makeWorkflowHarnessWithOptions({ nativeSessionId });
12390 yield* harness.runtime.startTurn(
12391 makeClaudeTestTurnInput({
12392 threadId: harness.threadId,
12393 providerThread: harness.providerThread,
12394 now: yield* DateTime.now,
12395 attemptId: RunAttemptId.make(`attempt-${nativeSessionId}`),
12396 text: "Run the workflow.",
12397 attachments: [],
12398 }),
12399 );
12400 for (const frame of [
12401 workflowToolUse,
12402 workflowTaskStarted,
12403 workflowLaunchAck(harness.transcriptDir),
12404 workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001203", state: "done" }),
12405 ]) {
12406 yield* harness.offerAndWait(claudeSdkFrame({ ...frame, session_id: nativeSessionId }));
12407 }
12408 yield* Queue.offer(
12409 harness.sdkMessages,
12410 claudeSdkFrame({
12411 ...makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001204", result: "Done." }),
12412 session_id: nativeSessionId,
12413 }),
12414 );
12415 yield* Queue.take(harness.terminalReceipts);
12416 const member = workflowMemberEvents(harness.events, 1).at(-1)?.subagent;
12417 assert.isDefined(member);
12418 memberThreads.push(member?.childThreadId);
12419 const ids = harness.events.flatMap<string>((event) => {
12420 if (event.type === "subagent.updated" && event.subagent.id === member?.id)
12421 return [event.subagent.id];
12422 if (event.type === "node.updated" && event.node.threadId === member?.childThreadId)
12423 return [event.node.id];
12424 if (
12425 event.type === "message.updated" &&
12426 event.message.threadId === member?.childThreadId
12427 )
12428 return [event.message.id];
12429 if (
12430 event.type === "turn_item.updated" &&
12431 event.turnItem.threadId === member?.childThreadId
12432 )
12433 return [event.turnItem.id];
12434 return [];
12435 });
12436 assert.isAtLeast(new Set(ids).size, 6);
12437 memberArtifacts.push(ids);
12438 }
12439 assert.notEqual(memberThreads[0], memberThreads[1]);
12440 assert.deepEqual(
12441 memberArtifacts[0]?.filter((id) => memberArtifacts[1]?.includes(id)),
12442 [],
12443 );
12444 }).pipe(
12445 Effect.provide(
12446 Layer.mergeAll(
12447 IdAllocator.layer,
12448 McpProviderSessions.layer,
12449 ScriptProgress.layerUnavailable,
12450 NodeServices.layer,
12451 ),
12452 ),
12453 ),
12454 ),
12455 );
12456
12457 it.effect("stops only a live workflow task and waits for its stopped notification", () =>
12458 Effect.scoped(
12459 Effect.gen(function* () {
12460 const stoppedTasks: Array<string> = [];
12461 let interrupted = false;
12462 let closed = false;
12463 const harness = yield* makeWorkflowHarnessWithOptions({
12464 stopTask: (taskId) =>
12465 Effect.sync(() => {
12466 stoppedTasks.push(taskId);
12467 }),
12468 interrupt: Effect.sync(() => {
12469 interrupted = true;
12470 }),
12471 close: () =>
12472 Effect.sync(() => {
12473 closed = true;
12474 }),
12475 });
12476 yield* harness.runtime.startTurn(
12477 makeClaudeTestTurnInput({
12478 threadId: harness.threadId,
12479 providerThread: harness.providerThread,
12480 now: yield* DateTime.now,
12481 attemptId: RunAttemptId.make("attempt-workflow-stop"),
12482 text: "Run the workflow.",
12483 attachments: [],
12484 }),
12485 );
12486 yield* harness.offerAndWait(workflowToolUse);
12487 yield* harness.offerAndWait(workflowTaskStarted);
12488 yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir));
12489 yield* harness.offerAndWait(
12490 workflowSnapshot({
12491 uuid: "00000000-0000-4000-8000-000000001100",
12492 state: "start",
12493 }),
12494 );
12495 let member = yield* Queue.take(harness.subagentReceipts);
12496 while (!member.subagent.nativeTaskRef?.nativeId?.endsWith(":agent:2")) {
12497 member = yield* Queue.take(harness.subagentReceipts);
12498 }
12499 const stopTask = harness.runtime.stopTask!;
12500 for (const taskId of ["missing-task", `${WORKFLOW_TASK_ID}:agent:1`]) {
12501 const exit = yield* stopTask({ providerThread: harness.providerThread, taskId }).pipe(
12502 Effect.exit,
12503 );
12504 assert.isTrue(Exit.isFailure(exit));
12505 }
12506 const wrongThread = {
12507 ...harness.providerThread,
12508 nativeThreadRef: {
12509 driver: harness.providerThread.driver,
12510 nativeId: "wrong-session",
12511 strength: "strong" as const,
12512 },
12513 };
12514 assert.isTrue(
12515 Exit.isFailure(
12516 yield* stopTask({
12517 providerThread: wrongThread,
12518 taskId: WORKFLOW_TASK_ID,
12519 }).pipe(Effect.exit),
12520 ),
12521 );
12522 assert.deepEqual(stoppedTasks, []);
12523 yield* stopTask({ providerThread: harness.providerThread, taskId: WORKFLOW_TASK_ID });
12524 assert.deepEqual(stoppedTasks, [WORKFLOW_TASK_ID]);
12525 assert.isFalse(interrupted);
12526 assert.isFalse(closed);
12527 assert.equal(workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, "running");
12528 yield* harness.offerAndWait(
12529 workflowNotification({
12530 uuid: "00000000-0000-4000-8000-000000001101",
12531 status: "stopped",
12532 }),
12533 );
12534 member = yield* Queue.take(harness.subagentReceipts);
12535 while (!member.subagent.nativeTaskRef?.nativeId?.endsWith(":agent:2")) {
12536 member = yield* Queue.take(harness.subagentReceipts);
12537 }
12538 assert.equal(
12539 workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status,
12540 "cancelled",
12541 );
12542 for (const index of [1, 2]) {
12543 assert.equal(
12544 workflowMemberEvents(harness.events, index).at(-1)?.subagent.status,
12545 "cancelled",
12546 );
12547 }
12548 assert.isTrue(
12549 Exit.isFailure(
12550 yield* stopTask({
12551 providerThread: harness.providerThread,
12552 taskId: WORKFLOW_TASK_ID,
12553 }).pipe(Effect.exit),
12554 ),
12555 );
12556 assert.deepEqual(stoppedTasks, [WORKFLOW_TASK_ID]);
12557 }).pipe(
12558 Effect.provide(
12559 Layer.mergeAll(
12560 IdAllocator.layer,
12561 McpProviderSessions.layer,
12562 ScriptProgress.layerUnavailable,
12563 NodeServices.layer,
12564 ),
12565 ),
12566 ),
12567 ),
12568 );
12569
12570 it.effect("projects each workflow member as its own subagent thread", () =>
12571 Effect.scoped(
12572 Effect.gen(function* () {
12573 const harness = yield* makeWorkflowHarness;
12574 const now = yield* DateTime.now;
12575 const coordinatorTurnItems = () =>
12576 harness.events.filter(
12577 (event) =>
12578 event.type === "turn_item.updated" &&
12579 event.turnItem.type === "subagent" &&
12580 event.turnItem.nativeItemRef?.nativeId === WORKFLOW_TASK_ID,
12581 );
12582
12583 yield* harness.runtime.startTurn(
12584 makeClaudeTestTurnInput({
12585 threadId: harness.threadId,
12586 providerThread: harness.providerThread,
12587 now,
12588 attemptId: RunAttemptId.make("attempt-claude-workflow-members"),
12589 text: "Run the alpha workflow.",
12590 attachments: [],
12591 }),
12592 );
12593 yield* launchWorkflow({
12594 harness,
12595 snapshotUuid: "00000000-0000-4000-8000-000000001004",
12596 state: "queued",
12597 });
12598
12599 const coordinator = workflowCoordinatorEvents(harness.events).at(-1)?.subagent;
12600 const first = workflowMemberEvents(harness.events, 1).at(-1)?.subagent;
12601 const second = workflowMemberEvents(harness.events, 2).at(-1)?.subagent;
12602 assert.equal(first?.status, "pending");
12603 assert.isNull(first?.startedAt);
12604 assert.equal(second?.status, "pending");
12605 assert.isNull(second?.startedAt);
12606 const coordinatorThread = harness.events.find(
12607 (event) =>
12608 event.type === "app_thread.created" &&
12609 event.appThread.id === coordinator?.childThreadId,
12610 );
12611 assert.equal(
12612 coordinatorThread?.type === "app_thread.created"
12613 ? coordinatorThread.appThread.title
12614 : null,
12615 "Workflow · probe-wf",
12616 );
12617 assert.equal(first?.title, "alpha:one");
12618 assert.equal(first?.prompt, "Reply with exactly: A1");
12619 assert.equal(first?.model, "claude-opus-5[1m]");
12620 // Members hang off the coordinator's own thread, not the launching run's.
12621 assert.equal(first?.threadId, coordinator?.childThreadId);
12622 assert.equal(second?.threadId, coordinator?.childThreadId);
12623 assert.notEqual(first?.childThreadId, second?.childThreadId);
12624
12625 assert.equal(
12626 harness.events.find(
12627 (
12628 event,
12629 ): event is Extract<
12630 ProviderAdapter.ProviderAdapterV2Event,
12631 { type: "app_thread.created" }
12632 > => event.type === "app_thread.created" && event.appThread.id === first?.childThreadId,
12633 )?.appThread.title,
12634 "alpha:one",
12635 );
12636 const memberNodes = harness.events.filter(
12637 (
12638 event,
12639 ): event is Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "node.updated" }> =>
12640 event.type === "node.updated" &&
12641 event.node.nativeItemRef?.nativeId === first?.nativeTaskRef?.nativeId,
12642 );
12643 assert.deepEqual(
12644 memberNodes.map((event) => event.node.kind),
12645 ["subagent", "root_turn"],
12646 );
12647 assert.deepEqual(
12648 memberNodes.map((event) => [event.node.status, event.node.startedAt]),
12649 [
12650 ["pending", null],
12651 ["pending", null],
12652 ],
12653 );
12654 assert.deepEqual(
12655 threadMessages(harness.events, first?.childThreadId).map((event) => event.message.text),
12656 ["Reply with exactly: A1"],
12657 );
12658 assert.deepEqual(
12659 threadMessages(harness.events, second?.childThreadId).map((event) => event.message.text),
12660 ["Reply with exactly: A2"],
12661 );
12662
12663 yield* TestClock.adjust("60 seconds");
12664 const memberStartedAt = DateTime.add(now, { seconds: 30 });
12665 yield* harness.offerAndWait(
12666 workflowSnapshot({
12667 uuid: "00000000-0000-4000-8000-000000001007",
12668 state: "start",
12669 secondState: "queued",
12670 startedAt: DateTime.toEpochMillis(memberStartedAt),
12671 }),
12672 );
12673 let started = yield* Queue.take(harness.subagentReceipts);
12674 while (started.subagent.id !== first?.id || started.subagent.status !== "running") {
12675 started = yield* Queue.take(harness.subagentReceipts);
12676 }
12677 assert.deepEqual(started.subagent.startedAt, memberStartedAt);
12678 const startedRoot = harness.events.findLast(
12679 (event) => event.type === "node.updated" && event.node.threadId === first?.childThreadId,
12680 );
12681 assert.equal(
12682 startedRoot?.type === "node.updated" ? startedRoot.node.status : null,
12683 "running",
12684 );
12685 assert.deepEqual(
12686 startedRoot?.type === "node.updated" ? startedRoot.node.startedAt : null,
12687 memberStartedAt,
12688 );
12689
12690 const turnItemsBeforeRepeat = coordinatorTurnItems().length;
12691 yield* Queue.offer(
12692 harness.sdkMessages,
12693 workflowSnapshot({
12694 uuid: "00000000-0000-4000-8000-000000001005",
12695 state: "start",
12696 secondState: "queued",
12697 }),
12698 );
12699 // The coordinator's turn item is emitted after its members are
12700 // projected, so a re-emitted member would already be in `events`.
12701 yield* awaitUntil(
12702 () => coordinatorTurnItems().length > turnItemsBeforeRepeat,
12703 "repeated workflow snapshot",
12704 );
12705 assert.lengthOf(workflowMemberEvents(harness.events, 1), 2);
12706 assert.lengthOf(workflowMemberEvents(harness.events, 2), 1);
12707
12708 const firstThreadId = first?.childThreadId;
12709 const firstThreadEvents = () =>
12710 harness.events.filter(
12711 (
12712 event,
12713 ): event is Extract<
12714 ProviderAdapter.ProviderAdapterV2Event,
12715 { type: "app_thread.created" }
12716 > => event.type === "app_thread.created" && event.appThread.id === firstThreadId,
12717 );
12718 const turnItemsBeforeMetadata = coordinatorTurnItems().length;
12719 yield* Queue.offer(
12720 harness.sdkMessages,
12721 workflowSnapshot({
12722 uuid: "00000000-0000-4000-8000-000000001017",
12723 state: "start",
12724 secondState: "queued",
12725 label: "alpha:review",
12726 prompt: "Review the change",
12727 model: "claude-sonnet-4-6",
12728 }),
12729 );
12730 yield* awaitUntil(
12731 () => coordinatorTurnItems().length > turnItemsBeforeMetadata,
12732 "updated workflow member metadata",
12733 );
12734 assert.equal(
12735 workflowMemberEvents(harness.events, 1).at(-1)?.subagent.title,
12736 "alpha:review",
12737 );
12738 assert.equal(
12739 workflowMemberEvents(harness.events, 1).at(-1)?.subagent.prompt,
12740 "Review the change",
12741 );
12742 assert.equal(
12743 workflowMemberEvents(harness.events, 1).at(-1)?.subagent.model,
12744 "claude-sonnet-4-6",
12745 );
12746 assert.equal(firstThreadEvents().at(-1)?.appThread.title, "alpha:review");
12747 assert.equal(
12748 firstThreadEvents().at(-1)?.appThread.modelSelection.model,
12749 "claude-sonnet-4-6",
12750 );
12751 assert.deepEqual(
12752 firstThreadEvents().at(-1)?.appThread.createdAt,
12753 firstThreadEvents()[0]?.appThread.createdAt,
12754 );
12755 assert.equal(
12756 threadMessages(harness.events, firstThreadId).at(-1)?.message.text,
12757 "Review the change",
12758 );
12759
12760 yield* TestClock.adjust("10 seconds");
12761 yield* harness.offerAndWait(
12762 workflowSnapshot({
12763 uuid: "00000000-0000-4000-8000-000000001018",
12764 state: "done",
12765 secondState: "queued",
12766 omitResult: true,
12767 }),
12768 );
12769 let finished = yield* Queue.take(harness.subagentReceipts);
12770 while (finished.subagent.id !== first?.id || finished.subagent.status !== "completed") {
12771 finished = yield* Queue.take(harness.subagentReceipts);
12772 }
12773 const completedAt = finished.subagent.completedAt;
12774 assert.deepEqual(completedAt, DateTime.add(now, { seconds: 70 }));
12775 yield* TestClock.adjust("60 seconds");
12776 yield* harness.offerAndWait(
12777 workflowSnapshot({
12778 uuid: "00000000-0000-4000-8000-000000001019",
12779 state: "done",
12780 secondState: "queued",
12781 }),
12782 );
12783 let excerpt = yield* Queue.take(harness.subagentReceipts);
12784 while (excerpt.subagent.id !== first?.id || excerpt.subagent.result !== "A1 excerpt") {
12785 excerpt = yield* Queue.take(harness.subagentReceipts);
12786 }
12787 assert.deepEqual(excerpt.subagent.completedAt, completedAt);
12788 const finishedRoot = harness.events.findLast(
12789 (event) => event.type === "node.updated" && event.node.threadId === firstThreadId,
12790 );
12791 assert.deepEqual(
12792 finishedRoot?.type === "node.updated" ? finishedRoot.node.completedAt : undefined,
12793 completedAt,
12794 );
12795
12796 yield* harness.offerAndWait(
12797 workflowNotification({ uuid: "00000000-0000-4000-8000-000000001008", status: "stopped" }),
12798 );
12799 yield* Queue.offer(
12800 harness.sdkMessages,
12801 makeResultFrame({
12802 uuid: "00000000-0000-4000-8000-000000001006",
12803 result: "Launched the workflow.",
12804 }),
12805 );
12806 yield* Queue.take(harness.terminalReceipts);
12807 const stopped = workflowMemberEvents(harness.events, 2).at(-1)?.subagent;
12808 assert.equal(stopped?.status, "cancelled");
12809 assert.isNull(stopped?.startedAt);
12810 const stoppedRoot = harness.events.findLast(
12811 (event) => event.type === "node.updated" && event.node.threadId === second?.childThreadId,
12812 );
12813 assert.equal(
12814 stoppedRoot?.type === "node.updated" ? stoppedRoot.node.status : null,
12815 "cancelled",
12816 );
12817 assert.isNull(
12818 stoppedRoot?.type === "node.updated" ? stoppedRoot.node.startedAt : undefined,
12819 );
12820 }).pipe(
12821 Effect.provide(
12822 Layer.mergeAll(
12823 IdAllocator.layer,
12824 McpProviderSessions.layer,
12825 ScriptProgress.layerUnavailable,
12826 NodeServices.layer,
12827 ),
12828 ),
12829 ),
12830 ),
12831 );
12832
12833 it.effect("projects workflow member threads after resuming a restarted session", () =>
12834 Effect.scoped(
12835 Effect.gen(function* () {
12836 const harness = yield* makeWorkflowHarnessWithOptions({
12837 subagentLaunchToolUseId: WORKFLOW_TOOL_USE_ID,
12838 });
12839 yield* harness.runtime.startTurn(
12840 makeClaudeTestTurnInput({
12841 threadId: harness.threadId,
12842 providerThread: harness.providerThread,
12843 now: yield* DateTime.now,
12844 attemptId: RunAttemptId.make("attempt-claude-workflow-resume"),
12845 text: "Continue the workflow.",
12846 attachments: [],
12847 }),
12848 );
12849 yield* harness.offerAndWait(
12850 claudeSdkFrame({
12851 ...workflowToolUse,
12852 message: {
12853 model: "claude-sonnet-4-6",
12854 content: [
12855 { type: "tool_use", id: "toolu-resume-workflow", name: "SendMessage", input: {} },
12856 ],
12857 },
12858 }),
12859 );
12860 yield* harness.offerAndWait(
12861 claudeSdkFrame({
12862 ...workflowTaskStarted,
12863 tool_use_id: "toolu-resume-workflow",
12864 prompt: "Continue the workflow.",
12865 }),
12866 );
12867 yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir));
12868 yield* harness.offerAndWait(
12869 workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001040", state: "start" }),
12870 );
12871 yield* harness.offerAndWait(
12872 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001041", result: "Resumed." }),
12873 );
12874 yield* Queue.take(harness.terminalReceipts);
12875 const member = workflowMemberEvents(harness.events, 1).at(-1)?.subagent;
12876 const coordinator = workflowCoordinatorEvents(harness.events).at(-1)?.subagent;
12877 assert.isDefined(member);
12878 assert.equal(member?.status, "running");
12879 assert.isTrue(
12880 harness.events.some(
12881 (event) =>
12882 event.type === "app_thread.created" && event.appThread.id === member?.childThreadId,
12883 ),
12884 );
12885 assert.deepEqual(
12886 threadMessages(harness.events, member?.childThreadId).map((event) => event.message.text),
12887 ["Reply with exactly: A1"],
12888 );
12889 assert.isFalse(
12890 harness.events.some(
12891 (event) =>
12892 event.type === "app_thread.created" &&
12893 event.appThread.id === coordinator?.childThreadId,
12894 ),
12895 );
12896 }).pipe(
12897 Effect.provide(
12898 Layer.mergeAll(
12899 IdAllocator.layer,
12900 McpProviderSessions.layer,
12901 ScriptProgress.layerUnavailable,
12902 NodeServices.layer,
12903 ),
12904 ),
12905 ),
12906 ),
12907 );
12908
12909 it.effect("does not republish a workflow roster on progress-only frames", () =>
12910 Effect.scoped(
12911 Effect.gen(function* () {
12912 const harness = yield* makeWorkflowHarness;
12913 const now = yield* DateTime.now;
12914 yield* harness.runtime.startTurn(
12915 makeClaudeTestTurnInput({
12916 threadId: harness.threadId,
12917 providerThread: harness.providerThread,
12918 now,
12919 attemptId: RunAttemptId.make("attempt-claude-workflow-progress-only"),
12920 text: "Run the alpha workflow.",
12921 attachments: [],
12922 }),
12923 );
12924 yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001120" });
12925 const coordinatorCount = () => workflowCoordinatorEvents(harness.events).length;
12926 const offerProgressOnly = (index: number) =>
12927 Queue.offer(
12928 harness.sdkMessages,
12929 claudeSdkFrame({
12930 type: "system",
12931 subtype: "task_progress",
12932 task_id: WORKFLOW_TASK_ID,
12933 tool_use_id: WORKFLOW_TOOL_USE_ID,
12934 description: "Member is working",
12935 uuid: `00000000-0000-4000-8000-${String(1200 + index).padStart(12, "0")}`,
12936 session_id: WAKE_NATIVE_SESSION,
12937 }),
12938 );
12939
12940 const inTurnCount = coordinatorCount();
12941 for (let index = 0; index < 5; index++) yield* offerProgressOnly(index);
12942 yield* Queue.offer(
12943 harness.sdkMessages,
12944 workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001121", state: "start" }),
12945 );
12946 yield* awaitUntil(() => coordinatorCount() > inTurnCount, "in-turn workflow snapshot");
12947 assert.equal(coordinatorCount(), inTurnCount + 1);
12948
12949 yield* Queue.offer(
12950 harness.sdkMessages,
12951 makeResultFrame({
12952 uuid: "00000000-0000-4000-8000-000000001122",
12953 result: "Launched the workflow.",
12954 }),
12955 );
12956 yield* Queue.take(harness.terminalReceipts);
12957
12958 const betweenTurnCount = coordinatorCount();
12959 for (let index = 5; index < 10; index++) yield* offerProgressOnly(index);
12960 yield* Queue.offer(
12961 harness.sdkMessages,
12962 workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001123", state: "done" }),
12963 );
12964 yield* awaitUntil(
12965 () => coordinatorCount() > betweenTurnCount,
12966 "between-turn workflow snapshot",
12967 );
12968 assert.equal(coordinatorCount(), betweenTurnCount + 1);
12969 }).pipe(
12970 Effect.provide(
12971 Layer.mergeAll(
12972 IdAllocator.layer,
12973 McpProviderSessions.layer,
12974 ScriptProgress.layerUnavailable,
12975 NodeServices.layer,
12976 ),
12977 ),
12978 ),
12979 ),
12980 );
12981
12982 it.effect("keeps resumed workflow tools open after stale interrupted cleanup", () =>
12983 Effect.scoped(
12984 Effect.gen(function* () {
12985 const clock = yield* Clock.Clock;
12986 const cleanupPaused = yield* Deferred.make<void>();
12987 const releaseCleanup = yield* Deferred.make<void>();
12988 let readsUntilPause = 0;
12989 yield* Effect.gen(function* () {
12990 const harness = yield* makeWorkflowHarnessWithOptions({
12991 close: Queue.shutdown,
12992 freshQueueOnReopen: true,
12993 });
12994 const oldAttempt = RunAttemptId.make("attempt-workflow-stale-cleanup-old");
12995 const oldBash = "toolu-workflow-stale-cleanup-old-bash";
12996 const newBash = "toolu-workflow-stale-cleanup-new-bash";
12997 const resumeTool = "toolu-workflow-stale-cleanup-resume";
12998 const toolUpdates = (nativeId: string) =>
12999 harness.events.flatMap((event) =>
13000 event.type === "turn_item.updated" &&
13001 event.turnItem.type === "command_execution" &&
13002 event.turnItem.nativeItemRef?.nativeId === nativeId
13003 ? [event.turnItem]
13004 : [],
13005 );
13006 yield* harness.runtime.startTurn(
13007 makeClaudeTestTurnInput({
13008 threadId: harness.threadId,
13009 providerThread: harness.providerThread,
13010 now: yield* DateTime.now,
13011 attemptId: oldAttempt,
13012 text: "Run the workflow.",
13013 attachments: [],
13014 }),
13015 );
13016 yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001150" });
13017 for (const frame of makeSubagentAssistantFrames({
13018 parentToolUseId: WORKFLOW_TOOL_USE_ID,
13019 uuid: "00000000-0000-4000-8000-000000001151",
13020 bashToolUseId: oldBash,
13021 })) {
13022 yield* harness.offerAndWait(frame);
13023 }
13024 yield* awaitUntil(() => toolUpdates(oldBash).length === 1, "old workflow tool running");
13025 readsUntilPause = 2;
13026 const stop = yield* harness.runtime
13027 .interruptTurn({
13028 providerThread: harness.providerThread,
13029 providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({
13030 driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
13031 nativeTurnId: `turn:${oldAttempt}`,
13032 }),
13033 })
13034 .pipe(Effect.forkScoped);
13035 yield* Deferred.await(cleanupPaused);
13036 yield* TestClock.adjust("10 seconds");
13037 yield* Fiber.join(stop);
13038 yield* Queue.take(harness.terminalReceipts);
13039 const oldToolCount = toolUpdates(oldBash).length;
13040 yield* harness.runtime.startTurn(
13041 makeClaudeTestTurnInput({
13042 threadId: harness.threadId,
13043 providerThread: harness.providerThread,
13044 now: yield* DateTime.now,
13045 attemptId: RunAttemptId.make("attempt-workflow-stale-cleanup-new"),
13046 text: "Resume the workflow.",
13047 attachments: [],
13048 providerTurnOrdinal: 2,
13049 }),
13050 );
13051 const resumedQueue = harness.processQueues[1]!;
13052 yield* Queue.offer(
13053 resumedQueue,
13054 claudeSdkFrame({
13055 ...workflowTaskStarted,
13056 tool_use_id: resumeTool,
13057 prompt: "Resume the workflow.",
13058 uuid: "00000000-0000-4000-8000-000000001152",
13059 }),
13060 );
13061 for (const frame of makeSubagentAssistantFrames({
13062 parentToolUseId: resumeTool,
13063 uuid: "00000000-0000-4000-8000-000000001153",
13064 bashToolUseId: newBash,
13065 })) {
13066 yield* Queue.offer(resumedQueue, frame);
13067 }
13068 yield* awaitUntil(
13069 () => toolUpdates(newBash).length === 1,
13070 "resumed workflow tool running",
13071 );
13072 yield* Deferred.succeed(releaseCleanup, undefined);
13073 yield* Queue.take(harness.terminalReceipts);
13074 assert.lengthOf(toolUpdates(oldBash), oldToolCount);
13075 assert.deepEqual(
13076 toolUpdates(newBash).map((item) => item.status),
13077 ["running"],
13078 );
13079 yield* Queue.offer(
13080 resumedQueue,
13081 makeSubagentToolResultFrame({
13082 parentToolUseId: resumeTool,
13083 uuid: "00000000-0000-4000-8000-000000001154",
13084 toolUseId: newBash,
13085 }),
13086 );
13087 yield* awaitUntil(
13088 () => toolUpdates(newBash).at(-1)?.status === "completed",
13089 "resumed workflow tool completed",
13090 );
13091 assert.equal(toolUpdates(newBash).at(-1)?.output, "ok");
13092 }).pipe(
13093 Effect.provideService(Clock.Clock, {
13094 ...clock,
13095 currentTimeMillis: Effect.gen(function* () {
13096 if (readsUntilPause > 0 && --readsUntilPause === 0) {
13097 yield* Deferred.succeed(cleanupPaused, undefined);
13098 yield* Deferred.await(releaseCleanup);
13099 }
13100 return yield* clock.currentTimeMillis;
13101 }),
13102 }),
13103 );
13104 }).pipe(
13105 Effect.provide(
13106 Layer.mergeAll(
13107 IdAllocator.layer,
13108 McpProviderSessions.layer,
13109 ScriptProgress.layerUnavailable,
13110 NodeServices.layer,
13111 ),
13112 ),
13113 ),
13114 ),
13115 );
13116
13117 it.effect.each(["root result", "query close"] as const)(
13118 "keeps a waiting continuation inactive until replay can handle %s",
13119 (arrival) =>
13120 Effect.scoped(
13121 Effect.gen(function* () {
13122 const crypto = yield* Crypto.Crypto;
13123 const digestStarted = yield* Deferred.make<void>();
13124 const releaseDigest = yield* Deferred.make<void>();
13125 const closeStarted = yield* Deferred.make<void>();
13126 let pauseDigest = false;
13127 yield* Effect.gen(function* () {
13128 const harness = yield* makeWorkflowHarnessWithOptions({
13129 freshQueueOnReopen: true,
13130 close: (messages) =>
13131 Deferred.succeed(closeStarted, undefined).pipe(
13132 Effect.andThen(Queue.shutdown(messages)),
13133 ),
13134 });
13135 const start = (attemptId: string, ordinal: number, continuation: boolean) =>
13136 harness.runtime.startTurn(
13137 makeClaudeTestTurnInput({
13138 threadId: harness.threadId,
13139 providerThread: harness.providerThread,
13140 now: DateTime.makeUnsafe(0),
13141 attemptId: RunAttemptId.make(attemptId),
13142 providerTurnOrdinal: ordinal,
13143 text: "Continue working.",
13144 attachments: [],
13145 ...(continuation
13146 ? { messageCreatedBy: "agent", messageCreationSource: "provider" }
13147 : {}),
13148 }),
13149 );
13150 yield* start("attempt-workflow-waiting-launch", 1, false);
13151 yield* launchWorkflow({
13152 harness,
13153 snapshotUuid: "00000000-0000-4000-8000-000000003200",
13154 });
13155 const fileSystem = yield* FileSystem.FileSystem;
13156 const liveTranscriptDir = NodePath.join(harness.transcriptDir, "live");
13157 yield* fileSystem.makeDirectory(liveTranscriptDir);
13158 yield* fileSystem.writeFileString(
13159 NodePath.join(liveTranscriptDir, "agent-a1.jsonl"),
13160 '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Live member answer"}]}}\n',
13161 );
13162 const liveTaskId = "task-workflow-live-while-waiting";
13163 const liveToolUseId = "toolu-workflow-live-while-waiting";
13164 yield* harness.offerAndWait(
13165 claudeSdkFrame({
13166 type: "assistant",
13167 message: {
13168 model: "claude-sonnet-4-6",
13169 content: [{ type: "tool_use", id: liveToolUseId, name: "Workflow", input: {} }],
13170 },
13171 parent_tool_use_id: null,
13172 session_id: WAKE_NATIVE_SESSION,
13173 uuid: "00000000-0000-4000-8000-000000003201",
13174 }),
13175 );
13176 yield* harness.offerAndWait(
13177 claudeSdkFrame({
13178 ...workflowTaskStarted,
13179 task_id: liveTaskId,
13180 tool_use_id: liveToolUseId,
13181 uuid: "00000000-0000-4000-8000-000000003202",
13182 }),
13183 );
13184 yield* harness.offerAndWait(
13185 claudeSdkFrame({
13186 type: "user",
13187 message: {
13188 role: "user",
13189 content: [
13190 { type: "tool_result", tool_use_id: liveToolUseId, content: "Launched." },
13191 ],
13192 },
13193 tool_use_result: {
13194 taskType: "local_workflow",
13195 runId: "wf_live_while_waiting",
13196 transcriptDir: liveTranscriptDir,
13197 },
13198 parent_tool_use_id: null,
13199 session_id: WAKE_NATIVE_SESSION,
13200 uuid: "00000000-0000-4000-8000-000000003203",
13201 }),
13202 );
13203 const liveProgress = (state: "start" | "done", uuid: string) =>
13204 claudeSdkFrame({
13205 ...workflowSnapshot({ state, uuid }),
13206 task_id: liveTaskId,
13207 tool_use_id: liveToolUseId,
13208 });
13209 yield* harness.offerAndWait(
13210 liveProgress("start", "00000000-0000-4000-8000-000000003204"),
13211 );
13212 yield* harness.offerAndWait(
13213 makeResultFrame({
13214 uuid: "00000000-0000-4000-8000-000000003205",
13215 result: "Launched both workflows.",
13216 }),
13217 );
13218 yield* Queue.take(harness.terminalReceipts);
13219 yield* harness.offerAndWait(
13220 workflowNotification({
13221 uuid: "00000000-0000-4000-8000-000000003206",
13222 status: "completed",
13223 }),
13224 );
13225 yield* Queue.take(harness.continuationReceipts);
13226 yield* harness.offerAndWait(wakeTurnInit);
13227 pauseDigest = true;
13228 const progress = yield* harness
13229 .offerAndWait(liveProgress("done", "00000000-0000-4000-8000-000000003207"))
13230 .pipe(Effect.forkScoped);
13231 yield* Deferred.await(digestStarted);
13232 const replay = yield* start("attempt-workflow-waiting-replay", 2, true).pipe(
13233 Effect.forkScoped({ startImmediately: true }),
13234 );
13235 if (arrival === "query close") {
13236 const stop = yield* harness.runtime
13237 .interruptTurn({
13238 providerThread: harness.providerThread,
13239 providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({
13240 driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
13241 nativeTurnId: "turn:attempt-workflow-waiting-replay",
13242 }),
13243 requestRuntimeRestart: true,
13244 })
13245 .pipe(Effect.forkScoped);
13246 yield* Deferred.await(closeStarted);
13247 yield* Deferred.succeed(releaseDigest, undefined);
13248 yield* Fiber.join(progress);
13249 yield* Fiber.join(stop);
13250 assert.isTrue(Exit.isFailure(yield* Fiber.await(replay)));
13251 } else {
13252 const offered = yield* Deferred.make<void>();
13253 const result = yield* harness
13254 .offerAndWait(wakeResult, offered)
13255 .pipe(Effect.forkScoped);
13256 yield* Deferred.await(offered);
13257 yield* Deferred.succeed(releaseDigest, undefined);
13258 yield* Fiber.join(progress);
13259 yield* Fiber.join(replay);
13260 yield* Fiber.join(result);
13261 yield* Queue.take(harness.terminalReceipts);
13262 assert.lengthOf(harness.continuationRequests, 1);
13263 }
13264 yield* start("attempt-workflow-waiting-next-user", 3, false);
13265 }).pipe(
13266 Effect.provideService(Crypto.Crypto, {
13267 ...crypto,
13268 digest: (algorithm, data) =>
13269 Effect.gen(function* () {
13270 if (pauseDigest) {
13271 pauseDigest = false;
13272 yield* Deferred.succeed(digestStarted, undefined);
13273 yield* Deferred.await(releaseDigest);
13274 }
13275 return yield* crypto.digest(algorithm, data);
13276 }),
13277 }),
13278 );
13279 }).pipe(
13280 Effect.provide(
13281 Layer.mergeAll(
13282 IdAllocator.layer,
13283 McpProviderSessions.layer,
13284 ScriptProgress.layerUnavailable,
13285 NodeServices.layer,
13286 ),
13287 ),
13288 ),
13289 ),
13290 );
13291
13292 it.effect.each(["registry", "transcript"] as const)(
13293 "does not replay stale idle progress over a completed workflow after awaiting %s",
13294 (boundary) =>
13295 Effect.scoped(
13296 Effect.gen(function* () {
13297 const clock = yield* Clock.Clock;
13298 const crypto = yield* Crypto.Crypto;
13299 const progressRead = yield* Deferred.make<void>();
13300 const releaseProgress = yield* Deferred.make<void>();
13301 let pauseNextRead = false;
13302 yield* Effect.gen(function* () {
13303 const harness = yield* makeWorkflowHarness;
13304 yield* harness.runtime.startTurn(
13305 makeClaudeTestTurnInput({
13306 threadId: harness.threadId,
13307 providerThread: harness.providerThread,
13308 now: yield* DateTime.now,
13309 attemptId: RunAttemptId.make("attempt-claude-idle-race-launch"),
13310 text: "Run the workflow.",
13311 attachments: [],
13312 }),
13313 );
13314 yield* launchWorkflow({
13315 harness,
13316 snapshotUuid: "00000000-0000-4000-8000-000000001060",
13317 });
13318 yield* harness.offerAndWait(
13319 makeResultFrame({
13320 uuid: "00000000-0000-4000-8000-000000001061",
13321 result: "Launched.",
13322 }),
13323 );
13324 yield* Queue.take(harness.terminalReceipts);
13325 yield* harness.offerAndWait(
13326 workflowNotification({
13327 uuid: "00000000-0000-4000-8000-000000001062",
13328 status: "completed",
13329 }),
13330 );
13331 if (boundary === "registry") {
13332 yield* harness.offerAndWait(
13333 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001063", result: "Done." }),
13334 );
13335 }
13336 const continuationInput = makeClaudeTestTurnInput({
13337 threadId: harness.threadId,
13338 providerThread: harness.providerThread,
13339 now: yield* DateTime.now,
13340 attemptId: RunAttemptId.make("attempt-claude-idle-race-continuation"),
13341 text: "Continue.",
13342 attachments: [],
13343 providerTurnOrdinal: 2,
13344 messageCreatedBy: "agent",
13345 messageCreationSource: "provider",
13346 });
13347 pauseNextRead = true;
13348 const progress = yield* harness
13349 .offerAndWait(
13350 workflowSnapshot({
13351 uuid: "00000000-0000-4000-8000-000000001064",
13352 state: boundary === "transcript" ? "done" : "start",
13353 secondState: "start",
13354 }),
13355 )
13356 .pipe(Effect.forkScoped);
13357 yield* Deferred.await(progressRead);
13358 const continuation = yield* harness.runtime
13359 .startTurn(continuationInput)
13360 .pipe(Effect.forkScoped);
13361 yield* Deferred.succeed(releaseProgress, undefined);
13362 yield* Fiber.join(progress);
13363 yield* Fiber.join(continuation);
13364 if (boundary === "registry") {
13365 yield* Queue.take(harness.terminalReceipts);
13366 } else {
13367 const lastMemberId = workflowMemberEvents(harness.events, 2)[0]?.subagent.id;
13368 while (true) {
13369 const event = yield* Queue.take(harness.subagentReceipts);
13370 if (event.subagent.id === lastMemberId && event.subagent.status === "completed")
13371 break;
13372 }
13373 }
13374 if (boundary === "transcript") {
13375 yield* harness.offerAndWait(
13376 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001065", result: "Done." }),
13377 );
13378 yield* Queue.take(harness.terminalReceipts);
13379 }
13380 assert.isFalse(yield* harness.hasPendingBackgroundWork);
13381 assert.equal(
13382 workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status,
13383 "completed",
13384 );
13385 const member = workflowMemberEvents(harness.events, 2).at(-1)?.subagent;
13386 assert.equal(member?.status, "completed");
13387 const root = harness.events.findLast(
13388 (event) =>
13389 event.type === "node.updated" && event.node.threadId === member?.childThreadId,
13390 );
13391 assert.equal(root?.type === "node.updated" ? root.node.status : null, "completed");
13392 const item = harness.events.findLast(
13393 (event) =>
13394 event.type === "turn_item.updated" &&
13395 event.turnItem.type === "subagent" &&
13396 event.turnItem.nativeItemRef?.nativeId === WORKFLOW_TASK_ID,
13397 );
13398 assert.equal(
13399 item?.type === "turn_item.updated" ? item.turnItem.status : null,
13400 "completed",
13401 );
13402 }).pipe(
13403 Effect.provideService(Clock.Clock, {
13404 ...clock,
13405 currentTimeMillis: Effect.gen(function* () {
13406 if (boundary === "registry" && pauseNextRead) {
13407 pauseNextRead = false;
13408 yield* Deferred.succeed(progressRead, undefined);
13409 yield* Deferred.await(releaseProgress);
13410 }
13411 return yield* clock.currentTimeMillis;
13412 }),
13413 }),
13414 Effect.provideService(Crypto.Crypto, {
13415 ...crypto,
13416 digest: (algorithm, data) =>
13417 Effect.gen(function* () {
13418 if (boundary === "transcript" && pauseNextRead) {
13419 pauseNextRead = false;
13420 yield* Deferred.succeed(progressRead, undefined);
13421 yield* Deferred.await(releaseProgress);
13422 }
13423 return yield* crypto.digest(algorithm, data);
13424 }),
13425 }),
13426 );
13427 }).pipe(
13428 Effect.provide(
13429 Layer.mergeAll(
13430 IdAllocator.layer,
13431 McpProviderSessions.layer,
13432 ScriptProgress.layerUnavailable,
13433 NodeServices.layer,
13434 ),
13435 ),
13436 ),
13437 ),
13438 );
13439
13440 it.effect.each([
13441 "root result",
13442 "resume after root result",
13443 "completion before buffered start",
13444 ] as const)("orders %s arriving during workflow transcript replay", (arrival) =>
13445 Effect.scoped(
13446 Effect.gen(function* () {
13447 const crypto = yield* Crypto.Crypto;
13448 const digestStarted = yield* Deferred.make<void>();
13449 const releaseDigest = yield* Deferred.make<void>();
13450 const liveFrameOffered = yield* Deferred.make<void>();
13451 let pauseDigest = false;
13452 yield* Effect.gen(function* () {
13453 const harness = yield* makeWorkflowHarnessWithOptions({ close: Queue.shutdown });
13454 const taskId = "task-resumed-during-workflow-replay";
13455 const taskStart = (toolUseId: string, prompt: string, uuid: string) =>
13456 claudeSdkFrame({
13457 ...workflowTaskStarted,
13458 task_id: taskId,
13459 task_type: "local_agent",
13460 is_backgrounded: true,
13461 subagent_type: "general-purpose",
13462 spawn_depth: 1,
13463 workflow_name: undefined,
13464 tool_use_id: toolUseId,
13465 prompt,
13466 uuid,
13467 });
13468 const taskFinished = (toolUseId: string, summary: string, uuid: string) =>
13469 claudeSdkFrame({
13470 ...workflowNotification({ uuid, status: "completed" }),
13471 task_id: taskId,
13472 tool_use_id: toolUseId,
13473 summary,
13474 });
13475 yield* harness.runtime.startTurn(
13476 makeClaudeTestTurnInput({
13477 threadId: harness.threadId,
13478 providerThread: harness.providerThread,
13479 now: yield* DateTime.now,
13480 attemptId: RunAttemptId.make("attempt-workflow-replay-launch"),
13481 text: "Run the workflow and a background subagent.",
13482 attachments: [],
13483 }),
13484 );
13485 yield* harness.offerAndWait(workflowToolUse);
13486 yield* harness.offerAndWait(workflowTaskStarted);
13487 yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir));
13488 yield* harness.offerAndWait(
13489 workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000003001", state: "start" }),
13490 );
13491 if (arrival !== "completion before buffered start") {
13492 yield* harness.offerAndWait(
13493 taskStart("toolu-initial", "Initial work.", "00000000-0000-4000-8000-000000003002"),
13494 );
13495 }
13496 yield* harness.offerAndWait(
13497 makeResultFrame({
13498 uuid: "00000000-0000-4000-8000-000000003003",
13499 result: "Launched both tasks.",
13500 }),
13501 );
13502 yield* Queue.take(harness.terminalReceipts);
13503 yield* harness.offerAndWait(
13504 workflowNotification({
13505 uuid: "00000000-0000-4000-8000-000000003004",
13506 status: "completed",
13507 }),
13508 );
13509 yield* Queue.take(harness.continuationReceipts);
13510 if (arrival === "completion before buffered start") {
13511 yield* harness.offerAndWait(
13512 taskStart("toolu-initial", "Initial work.", "00000000-0000-4000-8000-000000003005"),
13513 );
13514 } else {
13515 yield* harness.offerAndWait(
13516 taskFinished(
13517 "toolu-initial",
13518 "Initial result.",
13519 "00000000-0000-4000-8000-000000003005",
13520 ),
13521 );
13522 }
13523 if (arrival === "root result") {
13524 yield* harness.offerAndWait(
13525 claudeSdkFrame({
13526 type: "system",
13527 subtype: "init",
13528 session_id: WAKE_NATIVE_SESSION,
13529 uuid: "00000000-0000-4000-8000-000000003006",
13530 }),
13531 );
13532 }
13533 pauseDigest = true;
13534 const replay = yield* harness.runtime
13535 .startTurn(
13536 makeClaudeTestTurnInput({
13537 threadId: harness.threadId,
13538 providerThread: harness.providerThread,
13539 now: yield* DateTime.now,
13540 attemptId: RunAttemptId.make("attempt-workflow-replay-continuation"),
13541 providerTurnOrdinal: 2,
13542 text: "Continue.",
13543 attachments: [],
13544 messageCreatedBy: "agent",
13545 messageCreationSource: "provider",
13546 }),
13547 )
13548 .pipe(Effect.forkScoped);
13549 yield* Deferred.await(digestStarted);
13550 const liveFrames = yield* Effect.gen(function* () {
13551 if (arrival === "completion before buffered start") {
13552 yield* harness.offerAndWait(
13553 taskFinished(
13554 "toolu-initial",
13555 "Initial result.",
13556 "00000000-0000-4000-8000-000000003007",
13557 ),
13558 liveFrameOffered,
13559 );
13560 return;
13561 }
13562 yield* harness.offerAndWait(
13563 claudeSdkFrame({
13564 ...makeResultFrame({
13565 uuid: "00000000-0000-4000-8000-000000003008",
13566 result: "Native wake turn finished.",
13567 }),
13568 origin: { kind: "task-notification" },
13569 num_turns: 1,
13570 }),
13571 liveFrameOffered,
13572 );
13573 if (arrival === "resume after root result") {
13574 yield* harness.offerAndWait(
13575 claudeSdkFrame({
13576 type: "system",
13577 subtype: "init",
13578 session_id: WAKE_NATIVE_SESSION,
13579 uuid: "00000000-0000-4000-8000-000000003009",
13580 }),
13581 );
13582 yield* harness.offerAndWait(
13583 taskStart(
13584 "toolu-latest",
13585 "Latest live resume.",
13586 "00000000-0000-4000-8000-000000003010",
13587 ),
13588 );
13589 }
13590 }).pipe(Effect.forkScoped);
13591 yield* Deferred.await(liveFrameOffered);
13592 yield* Deferred.succeed(releaseDigest, undefined);
13593 yield* Fiber.join(replay);
13594 yield* Fiber.join(liveFrames);
13595 if (arrival === "resume after root result") {
13596 yield* Queue.take(harness.terminalReceipts);
13597 yield* Queue.take(harness.continuationReceipts);
13598 yield* harness.runtime.startTurn(
13599 makeClaudeTestTurnInput({
13600 threadId: harness.threadId,
13601 providerThread: harness.providerThread,
13602 now: yield* DateTime.now,
13603 attemptId: RunAttemptId.make("attempt-workflow-replay-resume-continuation"),
13604 providerTurnOrdinal: 3,
13605 text: "Continue.",
13606 attachments: [],
13607 messageCreatedBy: "agent",
13608 messageCreationSource: "provider",
13609 }),
13610 );
13611 }
13612 if (arrival !== "root result") {
13613 yield* harness.offerAndWait(
13614 makeResultFrame({
13615 uuid: "00000000-0000-4000-8000-000000003011",
13616 result: "Workflow answer delivered.",
13617 }),
13618 );
13619 }
13620 yield* Queue.take(harness.terminalReceipts);
13621 const latest = harness.events
13622 .flatMap((event) =>
13623 event.type === "subagent.updated" && event.subagent.nativeTaskRef?.nativeId === taskId
13624 ? [event.subagent]
13625 : [],
13626 )
13627 .at(-1);
13628 if (arrival === "resume after root result") {
13629 assert.equal(latest?.status, "running");
13630 assert.equal(latest?.prompt, "Latest live resume.");
13631 assert.isNull(latest?.result);
13632 assert.isTrue(yield* harness.hasPendingBackgroundWork);
13633 } else {
13634 assert.equal(latest?.status, "completed");
13635 assert.equal(latest?.result, "Initial result.");
13636 assert.isFalse(yield* harness.hasPendingBackgroundWork);
13637 assert.lengthOf(harness.continuationRequests, 1);
13638 // The native wake turn ended once; no extra provider result will
13639 // arrive to settle a mistakenly opened continuation.
13640 yield* harness.runtime.startTurn(
13641 makeClaudeTestTurnInput({
13642 threadId: harness.threadId,
13643 providerThread: harness.providerThread,
13644 now: yield* DateTime.now,
13645 attemptId: RunAttemptId.make("attempt-workflow-replay-next-user"),
13646 providerTurnOrdinal: 3,
13647 text: "Next task.",
13648 attachments: [],
13649 }),
13650 );
13651 }
13652 }).pipe(
13653 Effect.provideService(Crypto.Crypto, {
13654 ...crypto,
13655 digest: (algorithm, data) =>
13656 Effect.gen(function* () {
13657 if (pauseDigest) {
13658 pauseDigest = false;
13659 yield* Deferred.succeed(digestStarted, undefined);
13660 yield* Deferred.await(releaseDigest);
13661 }
13662 return yield* crypto.digest(algorithm, data);
13663 }),
13664 }),
13665 );
13666 }).pipe(
13667 Effect.provide(
13668 Layer.mergeAll(
13669 IdAllocator.layer,
13670 McpProviderSessions.layer,
13671 ScriptProgress.layerUnavailable,
13672 NodeServices.layer,
13673 ),
13674 ),
13675 ),
13676 ),
13677 );
13678
13679 it.effect("preserves buffered workflow outcomes when interrupted during replay", () =>
13680 Effect.scoped(
13681 Effect.gen(function* () {
13682 const crypto = yield* Crypto.Crypto;
13683 const digestStarted = yield* Deferred.make<void>();
13684 const releaseDigest = yield* Deferred.make<void>();
13685 let pauseDigest = false;
13686 yield* Effect.gen(function* () {
13687 const harness = yield* makeWorkflowHarnessWithOptions({ close: Queue.shutdown });
13688 const secondTaskId = "task-workflow-buffered-completion";
13689 const secondToolUseId = "toolu-workflow-buffered-completion";
13690 yield* harness.runtime.startTurn(
13691 makeClaudeTestTurnInput({
13692 threadId: harness.threadId,
13693 providerThread: harness.providerThread,
13694 now: yield* DateTime.now,
13695 attemptId: RunAttemptId.make("attempt-workflow-buffered-launch"),
13696 text: "Run both workflows.",
13697 attachments: [],
13698 }),
13699 );
13700 yield* launchWorkflow({
13701 harness,
13702 snapshotUuid: "00000000-0000-4000-8000-000000003100",
13703 });
13704 yield* harness.offerAndWait(
13705 claudeSdkFrame({
13706 ...workflowTaskStarted,
13707 task_id: secondTaskId,
13708 tool_use_id: secondToolUseId,
13709 uuid: "00000000-0000-4000-8000-000000003101",
13710 }),
13711 );
13712 yield* harness.offerAndWait(
13713 claudeSdkFrame({
13714 ...workflowSnapshot({
13715 uuid: "00000000-0000-4000-8000-000000003102",
13716 state: "start",
13717 }),
13718 task_id: secondTaskId,
13719 tool_use_id: secondToolUseId,
13720 }),
13721 );
13722 yield* harness.offerAndWait(
13723 makeResultFrame({
13724 uuid: "00000000-0000-4000-8000-000000003103",
13725 result: "Launched both workflows.",
13726 }),
13727 );
13728 yield* Queue.take(harness.terminalReceipts);
13729 yield* harness.offerAndWait(
13730 workflowNotification({
13731 uuid: "00000000-0000-4000-8000-000000003104",
13732 status: "completed",
13733 }),
13734 );
13735 yield* Queue.take(harness.continuationReceipts);
13736 yield* harness.offerAndWait(
13737 claudeSdkFrame({
13738 ...workflowNotification({
13739 uuid: "00000000-0000-4000-8000-000000003105",
13740 status: "completed",
13741 }),
13742 task_id: secondTaskId,
13743 tool_use_id: secondToolUseId,
13744 }),
13745 );
13746 const continuationInput = makeClaudeTestTurnInput({
13747 threadId: harness.threadId,
13748 providerThread: harness.providerThread,
13749 now: yield* DateTime.now,
13750 attemptId: RunAttemptId.make("attempt-workflow-buffered-replay"),
13751 providerTurnOrdinal: 2,
13752 text: "Continue.",
13753 attachments: [],
13754 messageCreatedBy: "agent",
13755 messageCreationSource: "provider",
13756 });
13757 pauseDigest = true;
13758 const replay = yield* harness.runtime
13759 .startTurn(continuationInput)
13760 .pipe(Effect.forkScoped);
13761 yield* Deferred.await(digestStarted);
13762 yield* harness.runtime.interruptTurn({
13763 providerThread: harness.providerThread,
13764 providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({
13765 driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
13766 nativeTurnId: `turn:${continuationInput.attemptId}`,
13767 }),
13768 });
13769 yield* Deferred.succeed(releaseDigest, undefined);
13770 yield* Fiber.join(replay);
13771 const second = harness.events
13772 .flatMap((event) =>
13773 event.type === "subagent.updated" &&
13774 event.subagent.nativeTaskRef?.nativeId === secondTaskId
13775 ? [event.subagent]
13776 : [],
13777 )
13778 .at(-1);
13779 assert.equal(second?.status, "completed");
13780 assert.equal(second?.result, "Workflow finished.");
13781 }).pipe(
13782 Effect.provideService(Crypto.Crypto, {
13783 ...crypto,
13784 digest: (algorithm, data) =>
13785 Effect.gen(function* () {
13786 if (pauseDigest) {
13787 pauseDigest = false;
13788 yield* Deferred.succeed(digestStarted, undefined);
13789 yield* Deferred.await(releaseDigest);
13790 }
13791 return yield* crypto.digest(algorithm, data);
13792 }),
13793 }),
13794 );
13795 }).pipe(
13796 Effect.provide(
13797 Layer.mergeAll(
13798 IdAllocator.layer,
13799 McpProviderSessions.layer,
13800 ScriptProgress.layerUnavailable,
13801 NodeServices.layer,
13802 ),
13803 ),
13804 ),
13805 ),
13806 );
13807
13808 it.effect.each([
13809 ["api_error", false, false],
13810 ["aborted_streaming", false, false],
13811 ["api_error", true, false],
13812 ["api_error", false, true],
13813 ["aborted_streaming", false, true],
13814 ] as const)(
13815 "stops the run's live workflow after root result %s, continuation=%s, pending Stop=%s",
13816 ([terminalReason, continuation, pendingStop]) =>
13817 Effect.scoped(
13818 Effect.gen(function* () {
13819 const stoppedTasks: Array<string> = [];
13820 const stopRequested = yield* Deferred.make<void>();
13821 const harness = yield* makeWorkflowHarnessWithOptions({
13822 stopTask: (taskId) =>
13823 Effect.sync(() => void stoppedTasks.push(taskId)).pipe(
13824 Effect.andThen(Deferred.succeed(stopRequested, undefined)),
13825 Effect.asVoid,
13826 ),
13827 });
13828 const now = yield* DateTime.now;
13829 const turnInput = {
13830 threadId: harness.threadId,
13831 providerThread: harness.providerThread,
13832 now,
13833 attemptId: RunAttemptId.make("attempt-claude-workflow-root-failure"),
13834 text: "Run the workflow.",
13835 attachments: [],
13836 };
13837 yield* harness.runtime.startTurn(makeClaudeTestTurnInput(turnInput));
13838 yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001050" });
13839 const stopping = pendingStop
13840 ? yield* harness.runtime.stopSubagent!({
13841 providerThread: harness.providerThread,
13842 nativeTaskId: WORKFLOW_TASK_ID,
13843 }).pipe(Effect.exit, Effect.forkScoped)
13844 : undefined;
13845 if (pendingStop) yield* Deferred.await(stopRequested);
13846 if (continuation) {
13847 yield* harness.offerAndWait(
13848 makeResultFrame({
13849 uuid: "00000000-0000-4000-8000-000000001051",
13850 result: "Launched.",
13851 }),
13852 );
13853 yield* Queue.take(harness.terminalReceipts);
13854 yield* harness.offerAndWait(
13855 claudeSdkFrame({
13856 ...workflowTaskStarted,
13857 uuid: "00000000-0000-4000-8000-000000001052",
13858 }),
13859 );
13860 }
13861 yield* harness.offerAndWait(
13862 makeResultFrame({
13863 uuid: "00000000-0000-4000-8000-000000001053",
13864 result: "Root turn ended.",
13865 terminalReason,
13866 isError: terminalReason === "api_error",
13867 }),
13868 );
13869 if (continuation) {
13870 yield* harness.runtime.startTurn(
13871 makeClaudeTestTurnInput({
13872 ...turnInput,
13873 attemptId: RunAttemptId.make("attempt-claude-workflow-root-failure-wake"),
13874 providerTurnOrdinal: 2,
13875 messageCreatedBy: "agent",
13876 messageCreationSource: "provider",
13877 }),
13878 );
13879 }
13880 const terminal = yield* Queue.take(harness.terminalReceipts);
13881 assert.equal(terminal.status, terminalReason === "api_error" ? "failed" : "interrupted");
13882 assert.equal(terminal.threadDisposition, "reusable");
13883 // The run following the workflow (its launcher, or the run a resumed
13884 // workflow was re-attributed to) stops following it, so it is stopped
13885 // instead of left running out of sight.
13886 const settled = terminalReason === "api_error" ? "failed" : "cancelled";
13887 assert.deepEqual(
13888 stoppedTasks,
13889 pendingStop ? [WORKFLOW_TASK_ID, WORKFLOW_TASK_ID] : [WORKFLOW_TASK_ID],
13890 );
13891 if (stopping !== undefined) {
13892 // Parent failure assigns a local outcome without a task_notification.
13893 yield* TestClock.adjust("2 seconds");
13894 assert.isTrue(Exit.isFailure(yield* Fiber.join(stopping)));
13895 // Local parent cleanup still cannot provide a confirmed outcome on retry.
13896 const retry = yield* harness.runtime.stopSubagent!({
13897 providerThread: harness.providerThread,
13898 nativeTaskId: WORKFLOW_TASK_ID,
13899 }).pipe(Effect.exit, Effect.forkScoped);
13900 yield* TestClock.adjust("2 seconds");
13901 assert.isTrue(Exit.isFailure(yield* Fiber.join(retry)));
13902 }
13903 assert.isFalse(yield* harness.hasPendingBackgroundWork);
13904 assert.equal(workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, settled);
13905 for (const index of [1, 2]) {
13906 assert.equal(
13907 workflowMemberEvents(harness.events, index).at(-1)?.subagent.status,
13908 settled,
13909 );
13910 }
13911 }).pipe(
13912 Effect.provide(
13913 Layer.mergeAll(
13914 IdAllocator.layer,
13915 McpProviderSessions.layer,
13916 ScriptProgress.layerUnavailable,
13917 NodeServices.layer,
13918 ),
13919 ),
13920 ),
13921 ),
13922 );
13923
13924 it.effect.each([
13925 ["stopped", false, false, false],
13926 ["failed", false, false, false],
13927 ["completed", false, false, false],
13928 ["stopped", true, false, false],
13929 ["failed", true, false, false],
13930 ["completed", true, false, false],
13931 ["interrupted", false, false, false],
13932 ["interrupted", true, false, false],
13933 ["interrupt_timeout", true, false, false],
13934 ["stop_buffered", true, false, false],
13935 ["stop_buffered", true, false, "buffered"],
13936 ["stop_buffered", true, false, "live"],
13937 ["drain_buffered", true, false, "live"],
13938 ["drain_buffered", true, false, "live_after_buffered"],
13939 ["query_failed", false, false, false],
13940 ["query_failed", true, false, false],
13941 ["query_failed", true, false, "buffered"],
13942 ["query_failed", true, true, false],
13943 ] as const)(
13944 "projects workflow members on %s, between turns=%s, after an unrelated failed turn=%s, resume=%s",
13945 ([status, betweenTurns, afterFailedTurn, resumed]) =>
13946 Effect.scoped(
13947 Effect.gen(function* () {
13948 const interrupted = status === "interrupted" || status === "interrupt_timeout";
13949 const liveResume = resumed === "live" || resumed === "live_after_buffered";
13950 const expected =
13951 status === "drain_buffered"
13952 ? resumed === "live_after_buffered"
13953 ? "completed"
13954 : "running"
13955 : status === "stopped" || interrupted || (status === "stop_buffered" && resumed)
13956 ? "cancelled"
13957 : status === "query_failed"
13958 ? "failed"
13959 : status === "stop_buffered"
13960 ? "completed"
13961 : status;
13962 const harness = yield* makeWorkflowHarnessWithOptions({
13963 close: status === "interrupt_timeout" ? () => Effect.void : Queue.shutdown,
13964 });
13965 const now = yield* DateTime.now;
13966 yield* harness.runtime.startTurn(
13967 makeClaudeTestTurnInput({
13968 threadId: harness.threadId,
13969 providerThread: harness.providerThread,
13970 now,
13971 attemptId: RunAttemptId.make("attempt-workflow-terminal"),
13972 text: "Run the workflow.",
13973 attachments: [],
13974 }),
13975 );
13976 yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001010" });
13977 if (betweenTurns) {
13978 yield* Queue.offer(
13979 harness.sdkMessages,
13980 makeResultFrame({
13981 uuid: "00000000-0000-4000-8000-000000001013",
13982 result: "Launched.",
13983 }),
13984 );
13985 yield* Queue.take(harness.terminalReceipts);
13986 if (afterFailedTurn || status === "interrupt_timeout") {
13987 yield* harness.runtime.startTurn(
13988 makeClaudeTestTurnInput({
13989 threadId: harness.threadId,
13990 providerThread: harness.providerThread,
13991 now,
13992 attemptId: RunAttemptId.make("attempt-unrelated-failed"),
13993 providerTurnOrdinal: 2,
13994 text: "Unrelated follow-up.",
13995 attachments: [],
13996 }),
13997 );
13998 if (afterFailedTurn) {
13999 yield* Queue.offer(
14000 harness.sdkMessages,
14001 makeResultFrame({
14002 uuid: "00000000-0000-4000-8000-000000001015",
14003 result: "Unrelated follow-up failed.",
14004 terminalReason: "api_error",
14005 isError: true,
14006 }),
14007 );
14008 yield* Queue.take(harness.terminalReceipts);
14009 }
14010 }
14011 if (status === "query_failed") {
14012 yield* harness.offerAndWait(
14013 workflowSnapshot({
14014 uuid: "00000000-0000-4000-8000-000000001014",
14015 state: "start",
14016 label: "Updated between turns",
14017 }),
14018 );
14019 }
14020 }
14021 const bufferedCompletion =
14022 status === "stop_buffered" || status === "drain_buffered" || resumed;
14023 if ((!interrupted && status !== "query_failed") || resumed)
14024 yield* Queue.offer(
14025 harness.sdkMessages,
14026 workflowNotification({
14027 uuid: "00000000-0000-4000-8000-000000001011",
14028 status: bufferedCompletion ? "completed" : status,
14029 }),
14030 );
14031 if (betweenTurns && ((status !== "query_failed" && !interrupted) || resumed)) {
14032 yield* Queue.take(harness.continuationReceipts);
14033 }
14034 if (resumed) {
14035 if (resumed === "live_after_buffered") {
14036 yield* harness.offerAndWait(
14037 claudeSdkFrame({
14038 ...workflowTaskStarted,
14039 tool_use_id: "toolu-workflow-earlier-resume",
14040 prompt: "Earlier buffered resume.",
14041 uuid: "00000000-0000-4000-8000-000000001019",
14042 }),
14043 );
14044 yield* harness.offerAndWait(
14045 claudeSdkFrame({
14046 ...workflowNotification({
14047 uuid: "00000000-0000-4000-8000-000000001020",
14048 status: "completed",
14049 }),
14050 tool_use_id: "toolu-workflow-earlier-resume",
14051 }),
14052 );
14053 }
14054 if (liveResume) {
14055 yield* harness.runtime.startTurn(
14056 makeClaudeTestTurnInput({
14057 threadId: harness.threadId,
14058 providerThread: harness.providerThread,
14059 now,
14060 attemptId: RunAttemptId.make("attempt-workflow-live-resume"),
14061 providerTurnOrdinal: 2,
14062 text: "Continue the workflow.",
14063 attachments: [],
14064 }),
14065 );
14066 }
14067 yield* harness.offerAndWait(
14068 claudeSdkFrame({
14069 ...workflowTaskStarted,
14070 tool_use_id: "toolu-workflow-resume",
14071 prompt: "Continue the workflow.",
14072 uuid: "00000000-0000-4000-8000-000000001016",
14073 }),
14074 );
14075 yield* harness.offerAndWait(
14076 workflowSnapshot({
14077 uuid: "00000000-0000-4000-8000-000000001017",
14078 state: "start",
14079 attempt: 2,
14080 label: "Updated between turns",
14081 }),
14082 );
14083 if (liveResume) {
14084 if (resumed === "live_after_buffered") {
14085 yield* harness.offerAndWait(
14086 claudeSdkFrame({
14087 ...workflowNotification({
14088 uuid: "00000000-0000-4000-8000-000000001021",
14089 status: "completed",
14090 }),
14091 tool_use_id: "toolu-workflow-resume",
14092 summary: "Latest workflow result.",
14093 }),
14094 );
14095 }
14096 yield* harness.offerAndWait(
14097 makeResultFrame({
14098 uuid: "00000000-0000-4000-8000-000000001018",
14099 result: "Resumed the workflow.",
14100 }),
14101 );
14102 yield* Queue.take(harness.terminalReceipts);
14103 }
14104 }
14105 if (betweenTurns && status !== "query_failed" && !interrupted && !bufferedCompletion) {
14106 yield* harness.runtime.startTurn(
14107 makeClaudeTestTurnInput({
14108 threadId: harness.threadId,
14109 providerThread: harness.providerThread,
14110 now,
14111 attemptId: RunAttemptId.make("attempt-workflow-terminal-wake"),
14112 text: "Workflow finished.",
14113 attachments: [],
14114 providerTurnOrdinal: 2,
14115 messageCreatedBy: "agent",
14116 messageCreationSource: "provider",
14117 }),
14118 );
14119 }
14120 if (interrupted || status === "stop_buffered") {
14121 const interrupt = harness.runtime.interruptTurn({
14122 providerThread: harness.providerThread,
14123 providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({
14124 driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
14125 nativeTurnId:
14126 status === "interrupt_timeout"
14127 ? "turn:attempt-unrelated-failed"
14128 : liveResume
14129 ? "turn:attempt-workflow-live-resume"
14130 : "turn:attempt-workflow-terminal",
14131 }),
14132 ...(betweenTurns ? { requestRuntimeRestart: true } : {}),
14133 });
14134 if (status === "interrupt_timeout") {
14135 const stop = yield* interrupt.pipe(Effect.forkScoped);
14136 yield* TestClock.adjust("10 seconds");
14137 yield* Fiber.join(stop);
14138 assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "interrupted");
14139 assert.isFalse(yield* harness.hasPendingBackgroundWork);
14140 } else {
14141 yield* interrupt;
14142 }
14143 } else if (status === "query_failed") {
14144 yield* Queue.shutdown(harness.sdkMessages);
14145 } else if (status === "drain_buffered") {
14146 yield* harness.runtime.startTurn(
14147 makeClaudeTestTurnInput({
14148 threadId: harness.threadId,
14149 providerThread: harness.providerThread,
14150 now,
14151 attemptId: RunAttemptId.make("attempt-workflow-terminal-wake"),
14152 text: "Workflow finished.",
14153 attachments: [],
14154 providerTurnOrdinal: 3,
14155 messageCreatedBy: "agent",
14156 messageCreationSource: "provider",
14157 }),
14158 );
14159 assert.equal(
14160 workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status,
14161 expected,
14162 );
14163 } else {
14164 yield* Queue.offer(
14165 harness.sdkMessages,
14166 makeResultFrame({
14167 uuid: "00000000-0000-4000-8000-000000001012",
14168 result: "Finished.",
14169 }),
14170 );
14171 }
14172 if (
14173 betweenTurns &&
14174 (status === "query_failed" || interrupted || status === "stop_buffered")
14175 ) {
14176 // The launching turn already ended. Await the last member's
14177 // terminal event, which follows its root node update.
14178 const lastMemberId = workflowMemberEvents(harness.events, 2)[0]?.subagent.id;
14179 while (true) {
14180 const update = yield* Queue.take(harness.subagentReceipts);
14181 if (update.subagent.id === lastMemberId && update.subagent.status !== "running")
14182 break;
14183 }
14184 assert.isFalse(yield* harness.hasPendingBackgroundWork);
14185 if (status === "query_failed") {
14186 assert.equal(
14187 workflowMemberEvents(harness.events, 1).at(-1)?.subagent.title,
14188 "Updated between turns",
14189 );
14190 }
14191 } else {
14192 yield* Queue.take(harness.terminalReceipts);
14193 }
14194 assert.equal(workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, expected);
14195 if (status === "drain_buffered")
14196 assert.equal(yield* harness.hasPendingBackgroundWork, expected === "running");
14197 if (resumed === "live_after_buffered") {
14198 assert.equal(
14199 workflowCoordinatorEvents(harness.events).at(-1)?.subagent.result,
14200 "Latest workflow result.",
14201 );
14202 }
14203 if (resumed) {
14204 assert.notEqual(
14205 workflowCoordinatorEvents(harness.events).at(-1)?.subagent.result,
14206 "Workflow finished.",
14207 );
14208 }
14209 for (const index of [1, 2]) {
14210 const updates = workflowMemberEvents(harness.events, index);
14211 const member = updates.at(-1)?.subagent;
14212 assert.equal(member?.status, expected);
14213 assert.deepEqual(member?.startedAt, updates[0]?.subagent.startedAt);
14214 const root = harness.events.findLast(
14215 (event) =>
14216 event.type === "node.updated" && event.node.threadId === member?.childThreadId,
14217 );
14218 assert.equal(root?.type === "node.updated" ? root.node.status : null, expected);
14219 }
14220 }).pipe(
14221 Effect.provide(
14222 Layer.mergeAll(
14223 IdAllocator.layer,
14224 McpProviderSessions.layer,
14225 ScriptProgress.layerUnavailable,
14226 NodeServices.layer,
14227 ),
14228 ),
14229 ),
14230 ),
14231 );
14232
14233 it.effect("reads a transcript that appears after a terminal workflow notification", () =>
14234 Effect.scoped(
14235 Effect.gen(function* () {
14236 const harness = yield* makeWorkflowHarness;
14237 const now = yield* DateTime.now;
14238 yield* harness.runtime.startTurn(
14239 makeClaudeTestTurnInput({
14240 threadId: harness.threadId,
14241 providerThread: harness.providerThread,
14242 now,
14243 attemptId: RunAttemptId.make("attempt-claude-workflow-late-terminal-answer"),
14244 text: "Run the alpha workflow.",
14245 attachments: [],
14246 }),
14247 );
14248 yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001030" });
14249 const secondThreadId = workflowMemberEvents(harness.events, 2)[0]?.subagent.childThreadId;
14250 yield* Queue.offer(
14251 harness.sdkMessages,
14252 workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001031", state: "done" }),
14253 );
14254 yield* awaitUntil(
14255 () => threadMessages(harness.events, secondThreadId).length === 2,
14256 "workflow member excerpt",
14257 );
14258 const notification = workflowNotification({
14259 uuid: "00000000-0000-4000-8000-000000001032",
14260 status: "completed",
14261 });
14262 yield* Queue.offer(harness.sdkMessages, notification);
14263 yield* awaitUntil(
14264 () =>
14265 harness.events.some(
14266 (event) =>
14267 event.type === "turn_item.updated" &&
14268 event.turnItem.type === "subagent" &&
14269 event.turnItem.nativeItemRef?.nativeId === WORKFLOW_TASK_ID &&
14270 event.turnItem.status === "completed",
14271 ),
14272 "terminal workflow notification",
14273 );
14274 NodeFS.writeFileSync(
14275 NodePath.join(harness.transcriptDir, "agent-a2.jsonl"),
14276 '{"type":"assistant","message":{"role":"assistant","id":"msg_late","content":[{"type":"text","text":"A2 final answer"}]}}\n',
14277 );
14278 yield* Queue.offer(
14279 harness.sdkMessages,
14280 claudeSdkFrame({ ...notification, uuid: "00000000-0000-4000-8000-000000001033" }),
14281 );
14282 yield* awaitUntil(
14283 () =>
14284 threadMessages(harness.events, secondThreadId).at(-1)?.message.text ===
14285 "A2 final answer",
14286 "late terminal transcript answer",
14287 );
14288 yield* Queue.offer(
14289 harness.sdkMessages,
14290 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001034", result: "Done." }),
14291 );
14292 yield* Queue.take(harness.terminalReceipts);
14293 }).pipe(
14294 Effect.provide(
14295 Layer.mergeAll(
14296 IdAllocator.layer,
14297 McpProviderSessions.layer,
14298 ScriptProgress.layerUnavailable,
14299 NodeServices.layer,
14300 ),
14301 ),
14302 ),
14303 ),
14304 );
14305
14306 it.effect("answers settled workflow members from a transcript, or the snapshot excerpt", () =>
14307 Effect.scoped(
14308 Effect.gen(function* () {
14309 const harness = yield* makeWorkflowHarness;
14310 const now = yield* DateTime.now;
14311
14312 yield* harness.runtime.startTurn(
14313 makeClaudeTestTurnInput({
14314 threadId: harness.threadId,
14315 providerThread: harness.providerThread,
14316 now,
14317 attemptId: RunAttemptId.make("attempt-claude-workflow-settled"),
14318 text: "Run the alpha workflow in the background.",
14319 attachments: [],
14320 }),
14321 );
14322 yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001007" });
14323 const firstThreadId = workflowMemberEvents(harness.events, 1)[0]?.subagent.childThreadId;
14324 const secondThreadId = workflowMemberEvents(harness.events, 2)[0]?.subagent.childThreadId;
14325 const currentAssistantAnswers = () => [
14326 ...new Map(
14327 threadMessages(harness.events, firstThreadId)
14328 .filter((event) => event.message.role === "assistant")
14329 .map((event) => [event.message.id, event.message.text]),
14330 ).values(),
14331 ];
14332 yield* Queue.offer(
14333 harness.sdkMessages,
14334 makeResultFrame({
14335 uuid: "00000000-0000-4000-8000-000000001008",
14336 result: "Launched the workflow.",
14337 }),
14338 );
14339 yield* Queue.take(harness.terminalReceipts);
14340 yield* harness.runtime.startTurn(
14341 makeClaudeTestTurnInput({
14342 threadId: harness.threadId,
14343 providerThread: harness.providerThread,
14344 now,
14345 attemptId: RunAttemptId.make("attempt-unrelated-failure"),
14346 text: "Do another task.",
14347 attachments: [],
14348 providerTurnOrdinal: 2,
14349 }),
14350 );
14351 yield* Queue.offer(
14352 harness.sdkMessages,
14353 makeResultFrame({
14354 uuid: "00000000-0000-4000-8000-000000001014",
14355 result: "Another task failed.",
14356 terminalReason: "api_error",
14357 isError: true,
14358 }),
14359 );
14360 yield* Queue.take(harness.terminalReceipts);
14361 assert.equal(workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, "running");
14362 assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, "running");
14363
14364 // The run outlives the turn that launched it, so this snapshot arrives
14365 // with no turn context at all.
14366 yield* Queue.offer(
14367 harness.sdkMessages,
14368 workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001009", state: "done" }),
14369 );
14370 yield* awaitUntil(
14371 () => threadMessages(harness.events, secondThreadId).length === 2,
14372 "settled workflow member answers",
14373 );
14374
14375 const coordinator = workflowCoordinatorEvents(harness.events).at(-1)?.subagent;
14376 assert.deepEqual(
14377 coordinator?.workflow?.agents.map((member) => member.state),
14378 ["completed", "completed"],
14379 );
14380 assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, "completed");
14381 assert.equal(workflowMemberEvents(harness.events, 2).at(-1)?.subagent.status, "completed");
14382 assert.deepEqual(
14383 threadMessages(harness.events, firstThreadId).map((event) => event.message.text),
14384 ["Reply with exactly: A1", "A1 from the transcript"],
14385 );
14386 assert.deepEqual(
14387 threadMessages(harness.events, secondThreadId).map((event) => event.message.text),
14388 ["Reply with exactly: A2", "Partial answer from workflow progress:\n\nA2 excerpt"],
14389 );
14390 const coordinatorEventsBeforeRepeat = workflowCoordinatorEvents(harness.events).length;
14391 const memberEventsBeforeRepeat = workflowMemberEvents(harness.events, 2).length;
14392 NodeFS.appendFileSync(
14393 NodePath.join(harness.transcriptDir, "agent-a1.jsonl"),
14394 '{"type":"assistant","message":{"role":"assistant","id":"msg_a1","content":[{"type":"text","text":"A1 continued"}]}}\n',
14395 );
14396 yield* harness.offerAndWait(
14397 workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001015", state: "done" }),
14398 );
14399 yield* awaitUntil(
14400 () => workflowCoordinatorEvents(harness.events).length > coordinatorEventsBeforeRepeat,
14401 "repeated settled workflow snapshot",
14402 );
14403 yield* awaitUntil(
14404 () =>
14405 threadMessages(harness.events, firstThreadId).at(-1)?.message.text ===
14406 "A1 from the transcript\n\nA1 continued",
14407 "continued workflow transcript answer",
14408 );
14409 assert.lengthOf(workflowMemberEvents(harness.events, 2), memberEventsBeforeRepeat);
14410 assert.lengthOf(threadMessages(harness.events, secondThreadId), 2);
14411 assert.equal(
14412 threadMessages(harness.events, firstThreadId).at(-1)?.message.text,
14413 "A1 from the transcript\n\nA1 continued",
14414 );
14415 NodeFS.writeFileSync(
14416 NodePath.join(harness.transcriptDir, "agent-a2.jsonl"),
14417 '{"type":"assistant","message":{"role":"assistant","id":"msg_a2","content":[{"type":"text","text":"A2 from the transcript"}]}}\n',
14418 );
14419 yield* harness.offerAndWait(
14420 workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001016", state: "done" }),
14421 );
14422 yield* awaitUntil(
14423 () => threadMessages(harness.events, secondThreadId).length === 3,
14424 "late workflow transcript answer",
14425 );
14426 assert.lengthOf(workflowMemberEvents(harness.events, 2), memberEventsBeforeRepeat);
14427 assert.equal(
14428 threadMessages(harness.events, secondThreadId).at(-1)?.message.text,
14429 "A2 from the transcript",
14430 );
14431 const previousStartedAt = workflowMemberEvents(harness.events, 1).at(-1)?.subagent
14432 .startedAt;
14433 yield* TestClock.adjust(1_000);
14434 NodeFS.writeFileSync(
14435 NodePath.join(harness.transcriptDir, "agent-a1.jsonl"),
14436 [
14437 '{"type":"assistant","message":{"role":"assistant","id":"msg_first","content":[{"type":"text","text":"First answer"}]}}',
14438 '{"type":"assistant","message":{"role":"assistant","id":"msg_second","content":[{"type":"text","text":"Second answer"}]}}',
14439 ].join("\n") + "\n",
14440 );
14441 yield* harness.offerAndWait(
14442 workflowSnapshot({
14443 uuid: "00000000-0000-4000-8000-000000001018",
14444 state: "done",
14445 attempt: 2,
14446 }),
14447 );
14448 yield* awaitUntil(
14449 () => currentAssistantAnswers().join("\n\n").includes("Second answer"),
14450 "two-turn workflow member answer",
14451 );
14452 assert.equal(currentAssistantAnswers().join("\n\n"), "First answer\n\nSecond answer");
14453 assert.notDeepEqual(
14454 workflowMemberEvents(harness.events, 1).at(-1)?.subagent.startedAt,
14455 previousStartedAt,
14456 );
14457 yield* Queue.offer(
14458 harness.sdkMessages,
14459 workflowSnapshot({
14460 uuid: "00000000-0000-4000-8000-000000001019",
14461 state: "start",
14462 attempt: 3,
14463 }),
14464 );
14465 yield* awaitUntil(
14466 () => workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status === "running",
14467 "retried workflow member running",
14468 );
14469 assert.notInclude(currentAssistantAnswers().join("\n\n"), "First answer");
14470 assert.notInclude(currentAssistantAnswers().join("\n\n"), "Second answer");
14471 const answerEventsDuringRetry = threadMessages(harness.events, firstThreadId).length;
14472 yield* Queue.offer(
14473 harness.sdkMessages,
14474 workflowSnapshot({
14475 uuid: "00000000-0000-4000-8000-000000001020",
14476 state: "start",
14477 attempt: 3,
14478 }),
14479 );
14480 NodeFS.writeFileSync(
14481 NodePath.join(harness.transcriptDir, "agent-a1.jsonl"),
14482 '{"type":"assistant","message":{"role":"assistant","id":"msg_retry","content":[{"type":"text","text":"A1 retry answer"}]}}\n',
14483 );
14484 yield* Queue.offer(
14485 harness.sdkMessages,
14486 workflowSnapshot({
14487 uuid: "00000000-0000-4000-8000-000000001021",
14488 state: "done",
14489 attempt: 3,
14490 }),
14491 );
14492 yield* awaitUntil(
14493 () =>
14494 threadMessages(harness.events, firstThreadId).at(-1)?.message.text ===
14495 "A1 retry answer",
14496 "retried workflow member answer",
14497 );
14498 assert.deepEqual(currentAssistantAnswers(), ["A1 retry answer"]);
14499 assert.lengthOf(threadMessages(harness.events, firstThreadId), answerEventsDuringRetry + 1);
14500 NodeFS.rmSync(NodePath.join(harness.transcriptDir, "agent-a1.jsonl"));
14501 yield* Queue.offer(
14502 harness.sdkMessages,
14503 workflowSnapshot({
14504 uuid: "00000000-0000-4000-8000-000000001022",
14505 state: "start",
14506 attempt: 4,
14507 }),
14508 );
14509 yield* awaitUntil(
14510 () => currentAssistantAnswers().at(-1) === "Retry in progress.",
14511 "retry progress answer",
14512 );
14513 yield* Queue.offer(
14514 harness.sdkMessages,
14515 workflowSnapshot({
14516 uuid: "00000000-0000-4000-8000-000000001023",
14517 state: "done",
14518 attempt: 4,
14519 omitResult: true,
14520 }),
14521 );
14522 yield* awaitUntil(
14523 () => currentAssistantAnswers().at(-1) === "No answer from this workflow attempt.",
14524 "empty retry answer",
14525 );
14526 assert.deepEqual(currentAssistantAnswers(), ["No answer from this workflow attempt."]);
14527 }).pipe(
14528 Effect.provide(
14529 Layer.mergeAll(
14530 IdAllocator.layer,
14531 McpProviderSessions.layer,
14532 ScriptProgress.layerUnavailable,
14533 NodeServices.layer,
14534 ),
14535 ),
14536 ),
14537 ),
14538 );
14539
14540 describe("native goals", () => {
14541 const syntheticFrame = (uuid: string, text: string) =>
14542 claudeSdkFrame({
14543 type: "assistant",
14544 message: {
14545 model: "<synthetic>",
14546 id: uuid,
14547 type: "message",
14548 role: "assistant",
14549 content: [{ type: "text", text }],
14550 stop_reason: "end_turn",
14551 stop_sequence: null,
14552 usage: {
14553 input_tokens: 0,
14554 output_tokens: 0,
14555 cache_creation_input_tokens: 0,
14556 cache_read_input_tokens: 0,
14557 },
14558 },
14559 parent_tool_use_id: null,
14560 uuid,
14561 session_id: WAKE_NATIVE_SESSION,
14562 });
14563 const stopHookFeedback = (uuid: string, condition: string, reason: string) =>
14564 claudeSdkFrame({
14565 type: "user",
14566 message: {
14567 role: "user",
14568 content: [{ type: "text", text: `Stop hook feedback:\n[${condition}]: ${reason}` }],
14569 },
14570 parent_tool_use_id: null,
14571 isSynthetic: true,
14572 uuid,
14573 session_id: WAKE_NATIVE_SESSION,
14574 });
14575 const goalStatuses = (events: ReadonlyArray<ProviderAdapter.ProviderAdapterV2Event>) =>
14576 events.flatMap((event) =>
14577 event.type === "provider_thread.updated" && event.providerThread.goal != null
14578 ? [event.providerThread.goal]
14579 : [],
14580 );
14581
14582 it.effect("tracks a /goal through unmet checks until Claude stops on its own", () =>
14583 Effect.gen(function* () {
14584 const harness = yield* makeWakeHarness;
14585 const condition = "all tests pass";
14586 yield* harness.runtime.startTurn(
14587 makeClaudeTestTurnInput({
14588 threadId: harness.threadId,
14589 providerThread: harness.providerThread,
14590 now: yield* DateTime.now,
14591 attemptId: RunAttemptId.make("goal-attempt"),
14592 text: `/goal ${condition}`,
14593 attachments: [],
14594 }),
14595 );
14596 for (const frame of [
14597 syntheticFrame("goal-set", `Goal set: ${condition}`),
14598 makeAssistantTextFrame({ uuid: "goal-work-1", text: "Fixing the first test." }),
14599 stopHookFeedback("goal-check-1", condition, "One test still fails."),
14600 makeAssistantTextFrame({ uuid: "goal-work-2", text: "All tests pass now." }),
14601 makeResultFrame({ uuid: "goal-result", result: "All tests pass now." }),
14602 ]) {
14603 yield* Queue.offer(harness.sdkMessages, frame);
14604 }
14605 const terminal = yield* Queue.take(harness.terminalReceipts);
14606 assert.equal(terminal.status, "completed");
14607 assert.deepEqual(goalStatuses(harness.events), [
14608 { objective: condition, status: "active", checks: 0 },
14609 {
14610 objective: condition,
14611 status: "active",
14612 checks: 1,
14613 lastCheck: "One test still fails.",
14614 },
14615 { objective: condition, status: "complete", checks: 1 },
14616 ]);
14617 }).pipe(
14618 Effect.scoped,
14619 Effect.provide(
14620 Layer.mergeAll(
14621 IdAllocator.layer,
14622 McpProviderSessions.layer,
14623 ScriptProgress.layerUnavailable,
14624 NodeServices.layer,
14625 ),
14626 ),
14627 ),
14628 );
14629
14630 it.effect("keeps a goal active when a hook stops the turn before the goal passes", () =>
14631 Effect.gen(function* () {
14632 const harness = yield* makeWakeHarness;
14633 const condition = "the deploy succeeds";
14634 yield* harness.runtime.startTurn(
14635 makeClaudeTestTurnInput({
14636 threadId: harness.threadId,
14637 providerThread: harness.providerThread,
14638 now: yield* DateTime.now,
14639 attemptId: RunAttemptId.make("goal-hook-stop-attempt"),
14640 text: `/goal ${condition}`,
14641 attachments: [],
14642 }),
14643 );
14644 for (const frame of [
14645 syntheticFrame("goal-hook-set", `Goal set: ${condition}`),
14646 makeAssistantTextFrame({ uuid: "goal-hook-work", text: "Deploying." }),
14647 makeResultFrame({
14648 uuid: "goal-hook-result",
14649 result: "Deploying.",
14650 terminalReason: "hook_stopped",
14651 }),
14652 ]) {
14653 yield* Queue.offer(harness.sdkMessages, frame);
14654 }
14655 yield* Queue.take(harness.terminalReceipts);
14656 assert.deepEqual(goalStatuses(harness.events).at(-1), {
14657 objective: condition,
14658 status: "active",
14659 checks: 0,
14660 });
14661 }).pipe(
14662 Effect.scoped,
14663 Effect.provide(
14664 Layer.mergeAll(
14665 IdAllocator.layer,
14666 McpProviderSessions.layer,
14667 ScriptProgress.layerUnavailable,
14668 NodeServices.layer,
14669 ),
14670 ),
14671 ),
14672 );
14673
14674 it.effect("keeps a goal active after a command turn with no model output", () =>
14675 Effect.gen(function* () {
14676 const harness = yield* makeWakeHarness;
14677 const condition = "the build is green";
14678 yield* harness.runtime.startTurn(
14679 makeClaudeTestTurnInput({
14680 threadId: harness.threadId,
14681 providerThread: {
14682 ...harness.providerThread,
14683 goal: { objective: condition, status: "active", checks: 2 },
14684 },
14685 now: yield* DateTime.now,
14686 attemptId: RunAttemptId.make("goal-show-attempt"),
14687 text: "/goal",
14688 attachments: [],
14689 }),
14690 );
14691 yield* Queue.offer(
14692 harness.sdkMessages,
14693 syntheticFrame("goal-show", `Goal active: ${condition} (2 turns)`),
14694 );
14695 yield* Queue.offer(
14696 harness.sdkMessages,
14697 makeResultFrame({ uuid: "goal-show-result", result: "", numTurns: 0 }),
14698 );
14699 yield* Queue.take(harness.terminalReceipts);
14700 assert.deepEqual(goalStatuses(harness.events).at(-1), {
14701 objective: condition,
14702 status: "active",
14703 checks: 2,
14704 });
14705 }).pipe(
14706 Effect.scoped,
14707 Effect.provide(
14708 Layer.mergeAll(
14709 IdAllocator.layer,
14710 McpProviderSessions.layer,
14711 ScriptProgress.layerUnavailable,
14712 NodeServices.layer,
14713 ),
14714 ),
14715 ),
14716 );
14717 });
14718});
14719
14720describe("ClaudeAdapterV2 query message stream", () => {
14721 it.effect("closes the query when the message stream is interrupted mid-read", () =>
14722 Effect.gen(function* () {
14723 let closed = false;
14724 let releaseRead = () => {};
14725 const readStarted = Promise.withResolvers<void>();
14726 // Never yields a message — the read stays pending until close() flips
14727 // `closed` and releases the in-flight await.
14728 // oxlint-disable-next-line require-yield
14729 async function* sdkMessages(): AsyncGenerator<SDKMessage, void> {
14730 for (;;) {
14731 if (closed) return;
14732 await new Promise<void>((resolve) => {
14733 releaseRead = resolve;
14734 readStarted.resolve();
14735 });
14736 }
14737 }
14738 const generator = sdkMessages();
14739 const close = () => {
14740 closed = true;
14741 releaseRead();
14742 };
14743 const query = {
14744 next: () => generator.next(),
14745 return: async (value?: void) => {
14746 close();
14747 return generator.return(value);
14748 },
14749 throw: (error?: unknown) => generator.throw(error),
14750 [Symbol.asyncIterator]: () => generator,
14751 close,
14752 } as unknown as ClaudeQuery;
14753
14754 const scope = yield* Scope.make();
14755 yield* Stream.fromAsyncIterable(
14756 ClaudeAdapterV2.claudeQueryMessages(query),
14757 (cause) => cause,
14758 ).pipe(
14759 Stream.runForEach(() => Effect.void),
14760 Effect.forkIn(scope),
14761 );
14762 yield* Effect.promise(() => readStarted.promise);
14763
14764 // Iterating query[Symbol.asyncIterator]() directly deadlocks here:
14765 // the raw generator's return() queues behind the in-flight read and
14766 // scope close never completes.
14767 yield* Scope.close(scope, Exit.void);
14768 assert.isTrue(closed);
14769 }),
14770 );
14771});