1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
2import {
3 extractCopilotSubagentUpdate,
4 makeCopilotSubagentRouting,
5 normalizeDevinSessionUpdate,
6 normalizeDevinToolCall,
7 extractDevinSubagentUpdate,
8} from "@t3tools/provider-acp-registry/testing";
9// @effect-diagnostics nodeBuiltinImport:off
10import * as NodeChildProcess from "node:child_process";
11import * as NodeFS from "node:fs";
12import * as NodePath from "node:path";
13
14import * as NodeServices from "@effect/platform-node/NodeServices";
15import { assert, describe, it } from "@effect/vitest";
16import {
17 CheckpointId,
18 EnvironmentId,
19 MessageId,
20 type ModelSelection,
21 NodeId,
22 ProjectId,
23 ProviderDriverKind,
24 ProviderInstanceId,
25 ProviderSessionId,
26 ProviderThreadId,
27 RunAttemptId,
28 RunId,
29 ThreadId,
30 type OrchestrationV2ProviderThread,
31 type ProviderOptionDescriptor,
32} from "@t3tools/contracts";
33import { GrokSettings } from "@t3tools/provider-grok/settings";
34import * as HostProcess from "@t3tools/shared/HostProcess";
35import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime";
36import * as DateTime from "effect/DateTime";
37import * as Deferred from "effect/Deferred";
38import type * as Duration from "effect/Duration";
39import * as Cause from "effect/Cause";
40import * as Clock from "effect/Clock";
41import * as Effect from "effect/Effect";
42import * as Exit from "effect/Exit";
43import * as FileSystem from "effect/FileSystem";
44import * as Fiber from "effect/Fiber";
45import * as Layer from "effect/Layer";
46import * as Option from "effect/Option";
47import * as Path from "effect/Path";
48import * as PlatformError from "effect/PlatformError";
49import * as Queue from "effect/Queue";
50import * as Schema from "effect/Schema";
51import * as Sink from "effect/Sink";
52import * as Stream from "effect/Stream";
53import * as Scope from "effect/Scope";
54import * as TestClock from "effect/testing/TestClock";
55import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
56import * as EffectAcpErrors from "effect-acp/errors";
57import type * as EffectAcpProtocol from "effect-acp/protocol";
58import type * as EffectAcpSchema from "effect-acp/compat";
59
60import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";
61import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
62import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
63import {
64 ACP_EMPTY_OPTION_VALUE,
65 ACP_SESSION_MODE_OPTION_ID,
66 acpProviderOptionDescriptors,
67} from "@t3tools/provider-acp/server/sessionConfig";
68import {
69 extractXAiAcpSubagentEndNotice,
70 extractXAiAcpSubagentUpdate,
71 makeXAiPromptCompletionRuntime,
72 normalizeXAiAcpToolCallState,
73 registerXAiBackgroundTaskTracking,
74} from "@t3tools/provider-grok/testing";
75import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
76import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
77import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests";
78import {
79 AcpProviderCapabilitiesV2,
80 acpProviderItemNativeId,
81 acpScopedNativeId,
82 acpCarryoverTerminalShouldClearContinuation,
83 acpPostSettleContinuationOfferEvidence,
84 acpIsAppOwnedWakeTurn,
85 acpPostSettleMonitorPromptShouldSuppress,
86 acpPostSettleWakeEvidence,
87 acpPostSettleWakeShouldBuffer,
88 acpProjectedCommandExitCode,
89 acpToolCallDiffPatch,
90 acpTurnStartShouldPreserveContinuation,
91 makeAcpAdapterV2,
92 type AcpAdapterV2ExtensionContext,
93 type AcpAdapterV2Flavor,
94 type AcpAdapterV2RuntimeInput,
95 type AcpAdapterV2SubagentUpdate,
96} from "@t3tools/provider-acp/server/adapter";
97
98import { makeGrokAdapterV2 } from "@t3tools/provider-grok/testing";
99import {
100 acpRegistryPromptFailure,
101 registerMistralVibeAcpExtensions,
102} from "@t3tools/provider-acp-registry/testing";
103
104const DEFAULT_GROK_SETTINGS = Schema.decodeSync(GrokSettings)({});
105
106const layerHost = TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer));
107
108const layerTest = Layer.mergeAll(
109 NodeServices.layer,
110 IdAllocator.layer,
111 McpProviderSessions.layer,
112 ScriptProgress.layerUnavailable,
113 layerHost,
114);
115const ACP_TEST_DRIVER = ProviderDriverKind.make("acp-test");
116const decodeUnknownJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown));
117
118describe("acpProjectedCommandExitCode", () => {
119 const successOutput = { type: "Bash", exit_code: 0 };
120 const failedOutput = { type: "Bash", exit_code: 1 };
121
122 it("omits exit codes for non-terminal and interrupted tool statuses", () => {
123 assert.equal(acpProjectedCommandExitCode("pending", successOutput), undefined);
124 assert.equal(acpProjectedCommandExitCode("running", successOutput), undefined);
125 assert.equal(acpProjectedCommandExitCode("interrupted", successOutput), undefined);
126 });
127
128 it("projects real exit codes only for completed and failed tools", () => {
129 assert.equal(acpProjectedCommandExitCode("completed", successOutput), 0);
130 assert.equal(acpProjectedCommandExitCode("completed", failedOutput), 1);
131 assert.equal(acpProjectedCommandExitCode("failed", failedOutput), 1);
132 assert.equal(acpProjectedCommandExitCode("completed", {}), undefined);
133 });
134});
135
136describe("acpToolCallDiffPatch", () => {
137 it("builds a patch per file from ACP v1 oldText/newText, with /dev/null for a new file", () => {
138 assert.equal(
139 acpToolCallDiffPatch([
140 { type: "diff", path: "/repo/new.txt", oldText: null, newText: "hello\n" },
141 { type: "diff", path: "/repo/a.ts", oldText: "a\nb\nc\n", newText: "a\nB\nc\n" },
142 { type: "diff", path: "/repo/same.ts", oldText: "x\n", newText: "x\n" },
143 ]),
144 [
145 "--- /dev/null",
146 "+++ /repo/new.txt",
147 "@@ -0,0 +1,1 @@",
148 "+hello",
149 "",
150 "--- /repo/a.ts",
151 "+++ /repo/a.ts",
152 "@@ -1,3 +1,3 @@",
153 " a",
154 "-b",
155 "+B",
156 " c",
157 "",
158 ].join("\n"),
159 );
160 });
161
162 it("keeps the ACP v2 patch text as sent", () => {
163 const text = "diff --git a/repo/a.ts b/repo/a.ts\n";
164 assert.equal(
165 acpToolCallDiffPatch([
166 {
167 type: "diff",
168 changes: [{ operation: "modify", path: "/repo/a.ts" }],
169 patch: { format: "git_patch", text },
170 },
171 ]),
172 text,
173 );
174 });
175
176 it("drops the patch for a rewrite too large to diff cheaply", () => {
177 const lines = (prefix: string) =>
178 Array.from({ length: 2_000 }, (_, index) => `${prefix} ${index}`).join("\n");
179 assert.isUndefined(
180 acpToolCallDiffPatch([
181 { type: "diff", path: "/repo/big.ts", oldText: lines("old"), newText: lines("new") },
182 ]),
183 );
184 });
185});
186
187describe("ACP continuation ownership", () => {
188 it("preserves a continuation offered during non-buffered carryover handling", () => {
189 assert.isFalse(
190 acpCarryoverTerminalShouldClearContinuation({
191 continuationOffered: true,
192 wakeBufferLength: 0,
193 }),
194 );
195 assert.isFalse(
196 acpCarryoverTerminalShouldClearContinuation({
197 continuationOffered: false,
198 wakeBufferLength: 1,
199 }),
200 );
201 assert.isTrue(
202 acpCarryoverTerminalShouldClearContinuation({
203 continuationOffered: false,
204 wakeBufferLength: 0,
205 }),
206 );
207 });
208
209 it("preserves only user-raced offers that still own buffered wake traffic", () => {
210 assert.isTrue(
211 acpTurnStartShouldPreserveContinuation({
212 continuationRequested: true,
213 isContinuationTurn: false,
214 wakeBufferLength: 1,
215 }),
216 );
217 assert.isFalse(
218 acpTurnStartShouldPreserveContinuation({
219 continuationRequested: true,
220 isContinuationTurn: false,
221 wakeBufferLength: 0,
222 }),
223 );
224 assert.isFalse(
225 acpTurnStartShouldPreserveContinuation({
226 continuationRequested: true,
227 isContinuationTurn: true,
228 wakeBufferLength: 1,
229 }),
230 );
231 });
232});
233
234const taskkillPlatformError = (method: string) =>
235 PlatformError.systemError({ _tag: "Unknown", module: "taskkill-test", method });
236
237function makeTaskkillSpawner(input: {
238 readonly exitCode?: number;
239 readonly exitFailure?: boolean;
240 readonly output?: string;
241 readonly outputFailure?: boolean;
242 readonly spawnFailure?: boolean;
243 readonly commands?: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }>;
244}) {
245 return ChildProcessSpawner.make((command) => {
246 const value = command as unknown as {
247 readonly command: string;
248 readonly args: ReadonlyArray<string>;
249 };
250 input.commands?.push({ command: value.command, args: value.args });
251 if (input.spawnFailure === true) return Effect.fail(taskkillPlatformError("spawn"));
252 const output = input.outputFailure
253 ? Stream.fail(taskkillPlatformError("output"))
254 : Stream.encodeText(Stream.make(input.output ?? ""));
255 return Effect.succeed(
256 ChildProcessSpawner.makeHandle({
257 pid: ChildProcessSpawner.ProcessId(1234),
258 exitCode: input.exitFailure
259 ? Effect.fail(taskkillPlatformError("exitCode"))
260 : Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)),
261 isRunning: Effect.succeed(false),
262 kill: () => Effect.void,
263 unref: Effect.succeed(Effect.void),
264 stdin: Sink.drain,
265 stdout: Stream.empty,
266 stderr: Stream.empty,
267 all: output,
268 getInputFd: () => Sink.drain,
269 getOutputFd: () => Stream.empty,
270 }),
271 );
272 });
273}
274
275function processExists(pid: number): boolean {
276 try {
277 process.kill(pid, 0);
278 return true;
279 } catch {
280 return false;
281 }
282}
283
284const waitForProcesses = (pids: ReadonlyArray<number>) =>
285 Effect.gen(function* () {
286 while (!pids.every(processExists)) {
287 yield* Effect.sleep("10 millis");
288 }
289 }).pipe(Effect.timeoutOption("2 seconds"));
290
291const waitForProcessesToExit = (pids: ReadonlyArray<number>) =>
292 Effect.gen(function* () {
293 while (pids.some(processExists)) {
294 yield* Effect.sleep("10 millis");
295 }
296 }).pipe(Effect.timeoutOption("2 seconds"));
297
298function linuxProcessStart(pid: number): string | undefined {
299 try {
300 const stat = NodeFS.readFileSync(`/proc/${pid}/stat`, "utf8");
301 const commandEnd = stat.lastIndexOf(")");
302 return commandEnd < 0
303 ? undefined
304 : stat
305 .slice(commandEnd + 2)
306 .trim()
307 .split(/\s+/)[19];
308 } catch {
309 return undefined;
310 }
311}
312
313function cleanupPublishedDetachedFixture(path: string): void {
314 let published: Array<number>;
315 try {
316 published = NodeFS.readFileSync(path, "utf8")
317 .trim()
318 .split(/\s+/)
319 .map(Number)
320 .filter((pid) => Number.isSafeInteger(pid) && pid > 1);
321 } catch {
322 return;
323 }
324 const roots = published.filter((pid) => {
325 try {
326 return NodeFS.readFileSync(`/proc/${pid}/cmdline`, "utf8").includes(path);
327 } catch {
328 return false;
329 }
330 });
331 const owned = new Map<number, string>();
332 const pending = [...roots];
333 while (pending.length > 0) {
334 const pid = pending.shift();
335 if (pid === undefined || owned.has(pid)) continue;
336 const start = linuxProcessStart(pid);
337 if (start === undefined) continue;
338 owned.set(pid, start);
339 try {
340 pending.push(
341 ...NodeFS.readFileSync(`/proc/${pid}/task/${pid}/children`, "utf8")
342 .trim()
343 .split(/\s+/)
344 .filter(Boolean)
345 .map(Number),
346 );
347 } catch {
348 // The process already exited.
349 }
350 }
351 for (const [pid, start] of [...owned.entries()].toReversed()) {
352 if (linuxProcessStart(pid) !== start) continue;
353 try {
354 process.kill(pid, "SIGKILL");
355 } catch {
356 // The exact fixture process already exited.
357 }
358 }
359}
360
361const waitForPublishedProcessIds = (
362 fileSystem: FileSystem.FileSystem,
363 path: string,
364 count: number,
365) =>
366 Effect.gen(function* () {
367 while (true) {
368 const ids = (yield* fileSystem.readFileString(path))
369 .trim()
370 .split(/\s+/)
371 .filter(Boolean)
372 .map(Number);
373 if (ids.length === count && ids.every((pid) => Number.isSafeInteger(pid) && pid > 1)) {
374 return ids;
375 }
376 yield* Effect.sleep("10 millis");
377 }
378 }).pipe(Effect.timeoutOption("2 seconds"));
379
380function makeMockRuntime(input: {
381 readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
382 readonly mockAgentPath: string;
383 readonly environment?:
384 | Readonly<Record<string, string>>
385 | ((runtimeOrdinal: number) => Readonly<Record<string, string>>);
386 readonly protocolEvents?: Queue.Queue<EffectAcpProtocol.AcpProtocolLogEvent>;
387 readonly cancelBehavior?: AcpSessionRuntime.AcpSessionRuntimeOptions["cancelBehavior"];
388 readonly ownDescendantProcessGroups?: boolean;
389 readonly ownDetachedProcessGroup?: boolean;
390 readonly processGroupPlatform?: NodeJS.Platform;
391 readonly processGroupTerminationGrace?: Duration.Input;
392 readonly linuxCgroupController?: AcpSessionRuntime.AcpSessionRuntimeOptions["linuxCgroupController"];
393 readonly posixProcessTreeController?: AcpSessionRuntime.AcpSessionRuntimeOptions["posixProcessTreeController"];
394 readonly windowsProcessTreeTerminator?: AcpSessionRuntime.AcpSessionRuntimeOptions["windowsProcessTreeTerminator"];
395 readonly wrapCancel?: (
396 cancel: AcpSessionRuntime.AcpSessionRuntime["Service"]["cancel"],
397 ) => AcpSessionRuntime.AcpSessionRuntime["Service"]["cancel"];
398 readonly wrapOutgoingResponse?: (
399 onOutgoingResponse: NonNullable<AcpAdapterV2RuntimeInput["onOutgoingResponse"]>,
400 ) => NonNullable<AcpAdapterV2RuntimeInput["onOutgoingResponse"]>;
401 readonly wrapRuntime?: (
402 runtime: AcpSessionRuntime.AcpSessionRuntime["Service"],
403 runtimeOrdinal: number,
404 ) => AcpSessionRuntime.AcpSessionRuntime["Service"];
405}): AcpAdapterV2Flavor["makeRuntime"] {
406 let runtimeOrdinal = 0;
407 return (runtimeInput) =>
408 Effect.gen(function* () {
409 runtimeOrdinal += 1;
410 const protocolEvents = input.protocolEvents;
411 const protocolLogging =
412 protocolEvents === undefined
413 ? runtimeInput.protocolLogging
414 : {
415 ...runtimeInput.protocolLogging,
416 logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) =>
417 Queue.offer(protocolEvents, event).pipe(
418 Effect.andThen(runtimeInput.protocolLogging.logger?.(event) ?? Effect.void),
419 Effect.asVoid,
420 ),
421 };
422 const context = yield* Layer.build(
423 AcpSessionRuntime.layer({
424 ...runtimeInput,
425 ...(input.cancelBehavior === undefined ? {} : { cancelBehavior: input.cancelBehavior }),
426 ...(input.ownDetachedProcessGroup === undefined
427 ? {}
428 : { ownDetachedProcessGroup: input.ownDetachedProcessGroup }),
429 ...(input.ownDescendantProcessGroups === undefined
430 ? {}
431 : { ownDescendantProcessGroups: input.ownDescendantProcessGroups }),
432 ...(input.ownDetachedProcessGroup === true
433 ? { processGroupPlatform: input.processGroupPlatform ?? "linux" }
434 : {}),
435 ...(input.processGroupTerminationGrace === undefined
436 ? {}
437 : { processGroupTerminationGrace: input.processGroupTerminationGrace }),
438 ...(input.linuxCgroupController === undefined
439 ? {}
440 : { linuxCgroupController: input.linuxCgroupController }),
441 ...(input.posixProcessTreeController === undefined
442 ? {}
443 : { posixProcessTreeController: input.posixProcessTreeController }),
444 ...(input.windowsProcessTreeTerminator === undefined
445 ? {}
446 : { windowsProcessTreeTerminator: input.windowsProcessTreeTerminator }),
447 protocolLogging,
448 spawn: {
449 command: process.execPath,
450 args: [input.mockAgentPath],
451 cwd: runtimeInput.cwd,
452 env: {
453 T3_ACP_SESSION_LIFECYCLE: "1",
454 ...(typeof input.environment === "function"
455 ? input.environment(runtimeOrdinal)
456 : input.environment),
457 },
458 },
459 authMethodId: "test",
460 ...(input.wrapOutgoingResponse === undefined ||
461 runtimeInput.onOutgoingResponse === undefined
462 ? {}
463 : {
464 onOutgoingResponse: input.wrapOutgoingResponse(runtimeInput.onOutgoingResponse),
465 }),
466 }).pipe(
467 Layer.provide(
468 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner),
469 ),
470 ),
471 );
472 const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(
473 Effect.provide(context),
474 );
475 const wrapped = {
476 ...runtime,
477 ...(input.wrapCancel === undefined ? {} : { cancel: input.wrapCancel(runtime.cancel) }),
478 };
479 return input.wrapRuntime?.(wrapped, runtimeOrdinal) ?? wrapped;
480 });
481}
482
483function rawProtocolMethod(event: EffectAcpProtocol.AcpProtocolLogEvent): string | undefined {
484 if (event.stage !== "raw" || typeof event.payload !== "string") return undefined;
485 for (const line of event.payload.split("\n")) {
486 const trimmed = line.trim();
487 if (trimmed.length === 0) continue;
488 const decoded = Option.getOrUndefined(decodeUnknownJson(trimmed));
489 if (typeof decoded === "object" && decoded !== null && "method" in decoded) {
490 const method = (decoded as { readonly method?: unknown }).method;
491 if (typeof method === "string") return method;
492 }
493 }
494 return undefined;
495}
496
497function rawProtocolRequest(
498 event: EffectAcpProtocol.AcpProtocolLogEvent,
499): { readonly method?: unknown; readonly params?: unknown } | undefined {
500 if (event.stage !== "raw" || typeof event.payload !== "string") return undefined;
501 for (const line of event.payload.split("\n")) {
502 const trimmed = line.trim();
503 if (trimmed.length === 0) continue;
504 const decoded = Option.getOrUndefined(decodeUnknownJson(trimmed));
505 if (typeof decoded === "object" && decoded !== null && "method" in decoded) {
506 return decoded;
507 }
508 }
509 return undefined;
510}
511
512function rawProtocolRequestParam(
513 event: EffectAcpProtocol.AcpProtocolLogEvent,
514 key: string,
515): unknown {
516 const params = rawProtocolRequest(event)?.params;
517 return typeof params === "object" && params !== null ? Reflect.get(params, key) : undefined;
518}
519
520function rawProtocolPromptText(event: EffectAcpProtocol.AcpProtocolLogEvent): string {
521 const prompt = rawProtocolRequestParam(event, "prompt");
522 if (!Array.isArray(prompt)) return "";
523 return prompt
524 .flatMap((block) =>
525 typeof block === "object" &&
526 block !== null &&
527 Reflect.get(block, "type") === "text" &&
528 typeof Reflect.get(block, "text") === "string"
529 ? [Reflect.get(block, "text") as string]
530 : [],
531 )
532 .join("\n");
533}
534
535const pollProtocolMethods = (events: Queue.Queue<EffectAcpProtocol.AcpProtocolLogEvent>) =>
536 Effect.gen(function* () {
537 const methods: string[] = [];
538 let polled = 0;
539 let event = yield* Queue.poll(events);
540 while (Option.isSome(event) && polled < 256) {
541 polled += 1;
542 const method = rawProtocolMethod(event.value);
543 if (method !== undefined) methods.push(method);
544 event = yield* Queue.poll(events);
545 }
546 return methods;
547 });
548
549function makeTurnInput(input: {
550 readonly threadId: ThreadId;
551 readonly providerThread: OrchestrationV2ProviderThread;
552 readonly instanceId: ProviderInstanceId;
553 readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy;
554 readonly now: DateTime.Utc;
555 readonly ordinal?: number;
556 readonly modelSelection?: ModelSelection;
557 /** agent+provider marks a post-settle continuation attach (drains wakeBuffer). */
558 readonly messageCreatedBy?: "user" | "agent";
559 readonly messageCreationSource?: "web" | "mobile" | "mcp" | "provider" | "server";
560 readonly messageText?: string;
561}): ProviderAdapter.ProviderAdapterV2TurnInput {
562 const ordinal = input.ordinal ?? 1;
563 const suffix = `${input.threadId}:${ordinal}`;
564 const modelSelection =
565 input.modelSelection ?? ({ instanceId: input.instanceId, model: "default" } as const);
566 return {
567 appThread: {
568 createdBy: "user",
569 creationSource: "web",
570 id: input.threadId,
571 projectId: ProjectId.make(`project:${input.threadId}`),
572 title: "ACP adapter test",
573 providerInstanceId: input.instanceId,
574 modelSelection,
575 runtimeMode: "approval-required",
576 interactionMode: "default",
577 branch: null,
578 worktreePath: null,
579 activeProviderThreadId: input.providerThread.id,
580 lineage: {
581 parentThreadId: null,
582 relationshipToParent: null,
583 rootThreadId: input.threadId,
584 },
585 forkedFrom: null,
586 createdAt: input.now,
587 updatedAt: input.now,
588 archivedAt: null,
589 settledOverride: null,
590 settledAt: null,
591 lastVisitedAt: null,
592 deletedAt: null,
593 },
594 threadId: input.threadId,
595 runId: RunId.make(`run:${suffix}`),
596 runOrdinal: ordinal,
597 providerTurnOrdinal: ordinal,
598 attemptId: RunAttemptId.make(`attempt:${suffix}`),
599 rootNodeId: NodeId.make(`node:${suffix}`),
600 providerThread: input.providerThread,
601 message: {
602 createdBy: input.messageCreatedBy ?? "user",
603 creationSource: input.messageCreationSource ?? "web",
604 messageId: MessageId.make(`message:${suffix}`),
605 text: input.messageText ?? "test prompt",
606 attachments: [],
607 },
608 modelSelection,
609 runtimePolicy: input.runtimePolicy,
610 };
611}
612
613describe("AcpAdapterV2", () => {
614 it.live.each(["failed", "recovered", "completed", "cancelled"] as const)(
615 "projects Mistral retry notices and their %s outcome",
616 (outcome) =>
617 Effect.gen(function* () {
618 const path = yield* Path.Path;
619 const instanceId = ProviderInstanceId.make(`vibe-retry-${outcome}`);
620 const threadId = ThreadId.make(`thread-vibe-retry-${outcome}`);
621 const adapter = yield* makeAcpAdapterV2({
622 instanceId,
623 selfInvocation: yield* resolveSelfInvocation(),
624 flavor: {
625 driver: ProviderDriverKind.make("acpRegistry"),
626 capabilities: AcpProviderCapabilitiesV2,
627 registerExtensions: registerMistralVibeAcpExtensions,
628 promptFailure: (cause) => acpRegistryPromptFailure("mistral-vibe", cause),
629 makeRuntime: makeMockRuntime({
630 childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner,
631 mockAgentPath: yield* path.fromFileUrl(
632 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
633 ),
634 environment: { T3_ACP_VIBE_RETRY_OUTCOME: outcome },
635 }),
636 },
637 });
638 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
639 runtimeMode: "full-access",
640 interactionMode: "default",
641 cwd: process.cwd(),
642 });
643 const modelSelection = { instanceId, model: "default" } as const;
644 const runtime = yield* adapter.openSession({
645 threadId,
646 providerSessionId: ProviderSessionId.make(`session-vibe-retry-${outcome}`),
647 modelSelection,
648 runtimePolicy,
649 });
650 const providerThread = yield* runtime.ensureThread({
651 threadId,
652 modelSelection,
653 runtimePolicy,
654 });
655 yield* runtime.startTurn(
656 makeTurnInput({
657 threadId,
658 providerThread,
659 instanceId,
660 runtimePolicy,
661 now: yield* DateTime.now,
662 }),
663 );
664 const events = yield* runtime.events.pipe(
665 Stream.takeUntil((event) => event.type === "turn.terminal"),
666 Stream.runCollect,
667 );
668 const retries = events.flatMap((event) =>
669 event.type === "turn_item.updated" && event.turnItem.type === "error"
670 ? [event.turnItem]
671 : [],
672 );
673 const running = retries.filter((item) => item.status === "running");
674 assert.deepEqual(
675 running.map((item) => item.retry?.attempt),
676 [1, 2],
677 );
678 assert.equal(new Set(retries.map((item) => item.id)).size, 1);
679 assert.include(running[0]!.failure.message, "Rate limit reached");
680 assert.notInclude(running[0]!.failure.message, "private-key");
681 const terminal = events.find((event) => event.type === "turn.terminal");
682 assert.isDefined(terminal);
683 if (terminal?.type !== "turn.terminal") return yield* Effect.die("Missing terminal");
684 if (outcome === "failed") {
685 assert.equal(terminal.status, "failed");
686 assert.equal(terminal.failure?.class, "usage_limit");
687 assert.include(terminal.failure?.message ?? "", "Rate limit exceeded for mistral");
688 if (terminal.status === "failed") assert.equal(terminal.retry?.attempt, 2);
689 } else if (outcome === "cancelled") {
690 assert.equal(terminal.status, "cancelled");
691 assert.equal(retries.at(-1)?.status, "cancelled");
692 assert.equal(retries.at(-1)?.title, "Provider retry stopped");
693 } else {
694 assert.equal(terminal.status, "completed");
695 assert.equal(retries.at(-1)?.status, "completed");
696 assert.equal(retries.at(-1)?.title, "Provider recovered");
697 }
698 }).pipe(Effect.provide(layerTest), Effect.scoped),
699 );
700
701 it("preserves legacy ids and scopes v2 ids by provider instance", () => {
702 const instanceId = ProviderInstanceId.make("acp-identity-test");
703 assert.equal(
704 acpProviderItemNativeId({ instanceId, itemIdentityVersion: undefined, nativeId: "item-1" }),
705 "item-1",
706 );
707 assert.equal(
708 acpProviderItemNativeId({ instanceId, itemIdentityVersion: 2, nativeId: "item-1" }),
709 acpScopedNativeId(instanceId, "item-1"),
710 );
711 });
712
713 it.effect("starts the MCP bridge directly from the self-contained runtime", () =>
714 Effect.gen(function* () {
715 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
716 const path = yield* Path.Path;
717 const selfInvocation = yield* resolveSelfInvocation().pipe(
718 Effect.provideService(HostProcess.IsExecutable, true),
719 );
720 const mockAgentPath = yield* path.fromFileUrl(
721 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
722 );
723
724 const instanceId = ProviderInstanceId.make("acp-test-self-contained-mcp-bridge");
725 const threadId = ThreadId.make("thread-acp-self-contained-mcp-bridge");
726 yield* (yield* McpProviderSessions.McpProviderSessions).set({
727 environmentId: EnvironmentId.make("environment-acp-self-contained-mcp-bridge"),
728 threadId,
729 providerSessionId: "mcp-session-acp-self-contained-mcp-bridge",
730 providerInstanceId: instanceId,
731 endpoint: "http://127.0.0.1:43123/mcp",
732 authorizationHeader: "Bearer self-contained-mcp-bridge-token",
733 browserToolsAvailable: false,
734 });
735
736 let runtimeInput: AcpAdapterV2RuntimeInput | undefined;
737 const makeRuntime = makeMockRuntime({ childProcessSpawner, mockAgentPath });
738 const adapter = yield* makeAcpAdapterV2({
739 instanceId,
740 flavor: {
741 driver: ACP_TEST_DRIVER,
742 capabilities: AcpProviderCapabilitiesV2,
743 makeRuntime: (input) =>
744 Effect.sync(() => {
745 runtimeInput = input;
746 }).pipe(Effect.andThen(makeRuntime(input))),
747 },
748 selfInvocation,
749 });
750 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
751 runtimeMode: "full-access",
752 interactionMode: "default",
753 cwd: process.cwd(),
754 });
755 const modelSelection = { instanceId, model: "default" } as const;
756 yield* adapter.openSession({
757 threadId,
758 providerSessionId: ProviderSessionId.make("provider-session-acp-self-contained-mcp-bridge"),
759 modelSelection,
760 runtimePolicy,
761 });
762
763 const mcpServer = runtimeInput?.mcpServers[0];
764 if (mcpServer === undefined || !("command" in mcpServer)) {
765 return yield* Effect.die("ACP runtime must receive the t3-code stdio MCP server");
766 }
767 assert.equal(mcpServer.command, process.execPath);
768 assert.deepEqual(mcpServer.args, ["acp-mcp-bridge"]);
769 assert.equal(runtimeInput?.processEnvironment?.T3_ACP_MCP_NODE, process.execPath);
770 assert.equal(runtimeInput?.processEnvironment?.T3_ACP_MCP_ENTRYPOINT, undefined);
771 }).pipe(Effect.provide(layerTest), Effect.scoped),
772 );
773
774 it.live("refreshes ACP prompt instructions when the interaction mode changes", () =>
775 Effect.gen(function* () {
776 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
777 const path = yield* Path.Path;
778 const selfInvocation = yield* resolveSelfInvocation();
779 const mockAgentPath = yield* path.fromFileUrl(
780 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
781 );
782 const protocolEvents = yield* Queue.unbounded<EffectAcpProtocol.AcpProtocolLogEvent>();
783 const instanceId = ProviderInstanceId.make("acp-test-instruction-transitions");
784 const threadId = ThreadId.make("thread-acp-instruction-transitions");
785 yield* (yield* McpProviderSessions.McpProviderSessions).set({
786 environmentId: EnvironmentId.make("environment-acp-instruction-transitions"),
787 threadId,
788 providerSessionId: "mcp-session-acp-instruction-transitions",
789 providerInstanceId: instanceId,
790 endpoint: "http://127.0.0.1:43123/mcp",
791 authorizationHeader: "Bearer instruction-transition-token",
792 browserToolsAvailable: false,
793 });
794 const adapter = yield* makeAcpAdapterV2({
795 instanceId,
796 flavor: {
797 driver: ACP_TEST_DRIVER,
798 capabilities: AcpProviderCapabilitiesV2,
799 makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, protocolEvents }),
800 },
801 selfInvocation,
802 });
803 const policy = (interactionMode: "default" | "plan") =>
804 ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
805 runtimeMode: "full-access",
806 interactionMode,
807 cwd: process.cwd(),
808 });
809 const defaultPolicy = policy("default");
810 const modelSelection = { instanceId, model: "default" } as const;
811 const runtime = yield* adapter.openSession({
812 threadId,
813 providerSessionId: ProviderSessionId.make("provider-session-acp-instruction-transitions"),
814 modelSelection,
815 runtimePolicy: defaultPolicy,
816 });
817 const providerThread = yield* runtime.ensureThread({
818 threadId,
819 modelSelection,
820 runtimePolicy: defaultPolicy,
821 });
822 const now = yield* DateTime.now;
823 const runTurn = Effect.fnUntraced(function* (
824 ordinal: number,
825 runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy,
826 messageText: string,
827 ) {
828 yield* runtime.startTurn(
829 makeTurnInput({
830 threadId,
831 providerThread,
832 instanceId,
833 runtimePolicy,
834 now,
835 ordinal,
836 messageText,
837 }),
838 );
839 yield* runtime.events.pipe(
840 Stream.takeUntil((event) => event.type === "turn.terminal"),
841 Stream.runDrain,
842 );
843 const methods: Array<string> = [];
844 while (true) {
845 const event = yield* Queue.take(protocolEvents);
846 if (event.direction !== "outgoing") continue;
847 const method = rawProtocolMethod(event);
848 if (method === undefined) continue;
849 methods.push(method);
850 if (method === "session/prompt") {
851 return { methods, prompt: rawProtocolPromptText(event) };
852 }
853 }
854 });
855
856 const command = yield* runTurn(0, defaultPolicy, "/compact");
857 assert.isTrue(command.prompt.startsWith("/compact"));
858 assert.notInclude(command.prompt, "<t3_code_instructions>");
859 const firstDefault = yield* runTurn(1, defaultPolicy, "First default request.");
860 assert.include(firstDefault.prompt, "T3 Code interaction mode: Default");
861 assert.include(firstDefault.prompt, "T3 Code collaborative browser");
862 assert.include(firstDefault.prompt, "T3 Code orchestration");
863 assert.notInclude(
864 firstDefault.methods,
865 "session/set_config_option",
866 "Build should preserve the agent's advertised mode default",
867 );
868 assert.include(
869 (yield* runTurn(2, defaultPolicy, "Second default request.")).prompt,
870 "Second default request.",
871 );
872
873 const planPolicy = policy("plan");
874 const firstPlan = yield* runTurn(3, planPolicy, "Plan this change.");
875 assert.include(firstPlan.prompt, "T3 Code interaction mode: Plan");
876 assert.include(firstPlan.methods, "session/set_config_option");
877 assert.include(
878 (yield* runTurn(4, planPolicy, "Continue planning.")).prompt,
879 "Continue planning.",
880 );
881 const restoredBuild = yield* runTurn(5, defaultPolicy, "Implement the change.");
882 assert.include(restoredBuild.prompt, "T3 Code interaction mode: Default");
883 assert.include(
884 restoredBuild.methods,
885 "session/set_config_option",
886 "Build should restore the native mode that T3 temporarily replaced for Plan",
887 );
888 }).pipe(Effect.provide(layerTest), Effect.scoped),
889 );
890
891 it.live("reapplies a selection after a switch away from it failed partway", () =>
892 Effect.gen(function* () {
893 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
894 const path = yield* Path.Path;
895 const mockAgentPath = yield* path.fromFileUrl(
896 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
897 );
898 const protocolEvents = yield* Queue.unbounded<EffectAcpProtocol.AcpProtocolLogEvent>();
899 const instanceId = ProviderInstanceId.make("acp-test-partial-selection-reapply");
900 const threadId = ThreadId.make("thread-acp-partial-selection-reapply");
901 const adapter = yield* makeAcpAdapterV2({
902 instanceId,
903 flavor: {
904 driver: ACP_TEST_DRIVER,
905 capabilities: AcpProviderCapabilitiesV2,
906 makeRuntime: makeMockRuntime({
907 childProcessSpawner,
908 mockAgentPath,
909 protocolEvents,
910 // The model switch lands, then the agent rejects the mode step.
911 environment: { T3_ACP_FAIL_SET_MODE_CONFIG_OPTION: "1" },
912 }),
913 },
914 selfInvocation: yield* resolveSelfInvocation(),
915 });
916 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
917 runtimeMode: "full-access",
918 interactionMode: "default",
919 cwd: process.cwd(),
920 });
921 const selectionA = { instanceId, model: "composer-2" } as const;
922 const selectionB = {
923 instanceId,
924 model: "composer-2[fast=true]",
925 options: [{ id: ACP_SESSION_MODE_OPTION_ID, value: "code" }],
926 } as const;
927 const runtime = yield* adapter.openSession({
928 threadId,
929 providerSessionId: ProviderSessionId.make("provider-session-acp-partial-selection"),
930 modelSelection: selectionA,
931 runtimePolicy,
932 });
933 const providerThread = yield* runtime.ensureThread({
934 threadId,
935 modelSelection: selectionA,
936 runtimePolicy,
937 });
938 const now = yield* DateTime.now;
939 const turnInput = (ordinal: number, modelSelection: ModelSelection) =>
940 makeTurnInput({
941 threadId,
942 providerThread,
943 instanceId,
944 runtimePolicy,
945 now,
946 ordinal,
947 modelSelection,
948 });
949
950 const switchToB = yield* Effect.exit(runtime.startTurn(turnInput(1, selectionB)));
951 assert.isTrue(Exit.isFailure(switchToB), "the agent rejected B's mode step");
952 yield* Queue.clear(protocolEvents);
953
954 yield* runtime.startTurn(turnInput(2, selectionA));
955 yield* runtime.events.pipe(
956 Stream.takeUntil((event) => event.type === "turn.terminal"),
957 Stream.runDrain,
958 );
959 const modelSetsBeforePrompt: Array<unknown> = [];
960 while (true) {
961 const event = yield* Queue.take(protocolEvents);
962 if (event.direction !== "outgoing") continue;
963 const method = rawProtocolMethod(event);
964 if (method === "session/prompt") break;
965 if (
966 method === "session/set_config_option" &&
967 rawProtocolRequestParam(event, "configId") === "model"
968 ) {
969 modelSetsBeforePrompt.push(rawProtocolRequestParam(event, "value"));
970 }
971 }
972 assert.deepEqual(
973 modelSetsBeforePrompt,
974 ["composer-2"],
975 "the native model was left on B, so the turn on A must set it back first",
976 );
977 }).pipe(Effect.provide(layerTest), Effect.scoped),
978 );
979
980 it.effect("starts a new replay message after ACP v2 plan boundaries", () =>
981 Effect.gen(function* () {
982 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
983 const path = yield* Path.Path;
984 const selfInvocation = yield* resolveSelfInvocation();
985 const mockAgentPath = yield* path.fromFileUrl(
986 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
987 );
988 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
989 let sessionUpdateHandler: Parameters<RuntimeService["handleSessionUpdate"]>[0] | undefined;
990 const instanceId = ProviderInstanceId.make("acp-test-v2-plan-replay-boundary");
991 const adapter = yield* makeAcpAdapterV2({
992 instanceId,
993 flavor: {
994 driver: ACP_TEST_DRIVER,
995 capabilities: AcpProviderCapabilitiesV2,
996 makeRuntime: makeMockRuntime({
997 childProcessSpawner,
998 mockAgentPath,
999 wrapRuntime: (runtime) => ({
1000 ...runtime,
1001 handleSessionUpdate: (handler) =>
1002 Effect.sync(() => {
1003 sessionUpdateHandler = handler;
1004 }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))),
1005 }),
1006 }),
1007 },
1008 selfInvocation,
1009 });
1010 const threadId = ThreadId.make("thread-acp-v2-plan-replay-boundary");
1011 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
1012 runtimeMode: "full-access",
1013 interactionMode: "default",
1014 cwd: process.cwd(),
1015 });
1016 const modelSelection = { instanceId, model: "default" } as const;
1017 const runtime = yield* adapter.openSession({
1018 threadId,
1019 providerSessionId: ProviderSessionId.make("provider-session-acp-v2-plan-replay-boundary"),
1020 modelSelection,
1021 runtimePolicy,
1022 });
1023 const providerThread = yield* runtime.ensureThread({
1024 threadId,
1025 modelSelection,
1026 runtimePolicy,
1027 });
1028 assert.isDefined(sessionUpdateHandler);
1029
1030 yield* sessionUpdateHandler!({
1031 sessionId: "mock-session-1",
1032 update: {
1033 sessionUpdate: "agent_message_chunk",
1034 content: { type: "text", text: "before plan" },
1035 },
1036 });
1037 yield* sessionUpdateHandler!({
1038 sessionId: "mock-session-1",
1039 update: {
1040 sessionUpdate: "plan_update",
1041 plan: {
1042 type: "items",
1043 planId: "plan-1",
1044 entries: [{ content: "Continue", priority: "medium", status: "in_progress" }],
1045 },
1046 },
1047 });
1048 yield* sessionUpdateHandler!({
1049 sessionId: "mock-session-1",
1050 update: {
1051 sessionUpdate: "agent_message_chunk",
1052 content: { type: "text", text: "after plan" },
1053 },
1054 });
1055
1056 const snapshot = yield* runtime.readThreadSnapshot({ providerThread });
1057 assert.deepEqual(
1058 snapshot.messages.map((message) => message.text),
1059 ["before plan", "after plan"],
1060 );
1061 }).pipe(Effect.provide(layerTest), Effect.scoped),
1062 );
1063
1064 it.effect("persists a bounded subset of streamed root and child tool updates", () =>
1065 Effect.gen(function* () {
1066 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1067 const path = yield* Path.Path;
1068 const mockAgentPath = yield* path.fromFileUrl(
1069 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
1070 );
1071 type Runtime = AcpSessionRuntime.AcpSessionRuntime["Service"];
1072 let handler: Parameters<Runtime["handleSessionUpdate"]>[0] | undefined;
1073 // Devin streams a file write as it generates it: each update resends the
1074 // whole file so far, a few characters longer, with no status.
1075 const file = `${"x = 1\n".repeat(800)}END_OF_FILE\n`;
1076 const chunks = 200;
1077 const streamedWrite = (
1078 toolCallId: string,
1079 parentAgentId?: string,
1080 ): Array<EffectAcpSchema.SessionUpdate> => {
1081 const meta =
1082 parentAgentId === undefined ? {} : { "cognition.ai/subagent_context": { parentAgentId } };
1083 const write = (text: string, status?: "completed") => ({
1084 sessionUpdate: "tool_call_update" as const,
1085 toolCallId,
1086 title: "Writing ./audit.py",
1087 ...(status === undefined ? {} : { status }),
1088 content: [
1089 { type: "diff" as const, path: "/repo/audit.py", oldText: null, newText: text },
1090 ],
1091 rawInput: { file_path: "/repo/audit.py", content: text },
1092 _meta: meta,
1093 });
1094 return [
1095 {
1096 sessionUpdate: "tool_call",
1097 toolCallId,
1098 title: "Writing …",
1099 kind: "edit",
1100 status: "pending",
1101 _meta: meta,
1102 },
1103 ...Array.from({ length: chunks }, (_, index) =>
1104 write(file.slice(0, Math.ceil((file.length * (index + 1)) / chunks))),
1105 ),
1106 write(file, "completed"),
1107 ];
1108 };
1109 const instanceId = ProviderInstanceId.make("devin-streamed-write");
1110 const adapter = yield* makeAcpAdapterV2({
1111 instanceId,
1112 selfInvocation: yield* resolveSelfInvocation(),
1113 flavor: {
1114 driver: ACP_TEST_DRIVER,
1115 capabilities: AcpProviderCapabilitiesV2,
1116 normalizeSessionUpdate: normalizeDevinSessionUpdate,
1117 normalizeToolCall: normalizeDevinToolCall,
1118 extractSubagentUpdate: extractDevinSubagentUpdate,
1119 makeRuntime: makeMockRuntime({
1120 childProcessSpawner,
1121 mockAgentPath,
1122 wrapRuntime: (runtime) => ({
1123 ...runtime,
1124 handleSessionUpdate: (next) =>
1125 Effect.sync(() => {
1126 handler = next;
1127 }).pipe(Effect.andThen(runtime.handleSessionUpdate(next))),
1128 prompt: () =>
1129 Effect.gen(function* () {
1130 const updates = [
1131 {
1132 sessionUpdate: "tool_call_update",
1133 toolCallId: "child-a",
1134 status: "in_progress",
1135 _meta: {
1136 "cognition.ai/subagent_started": {
1137 agentId: "child-a",
1138 title: "Write audit script",
1139 task: "Write audit.py.",
1140 },
1141 },
1142 },
1143 ...streamedWrite("root-write"),
1144 ...streamedWrite("child-write", "child-a"),
1145 // Same-length output replacements are still live output.
1146 ...Array.from({ length: 5 }, (_, index) => ({
1147 sessionUpdate: "tool_call_update" as const,
1148 toolCallId: "ticker",
1149 title: "Watch ticks",
1150 kind: "execute" as const,
1151 status: "in_progress" as const,
1152 rawOutput: `tick ${index + 1}`,
1153 })),
1154 ...Array.from({ length: 5 }, (_, index) => ({
1155 sessionUpdate: "tool_call_update" as const,
1156 toolCallId: "text-ticker",
1157 title: "Watch ticks",
1158 kind: "execute" as const,
1159 status: "in_progress" as const,
1160 rawOutput: { type: "Text", text: `tock ${index + 1}` },
1161 })),
1162 {
1163 sessionUpdate: "tool_call_update",
1164 toolCallId: "child-a",
1165 status: "completed",
1166 _meta: {
1167 "cognition.ai/subagent_completed": {
1168 agentId: "child-a",
1169 success: true,
1170 summary: "Wrote audit.py",
1171 },
1172 },
1173 },
1174 ] satisfies Array<EffectAcpSchema.SessionUpdate>;
1175 for (const update of updates)
1176 yield* handler!({ sessionId: "mock-session-1", update });
1177 return { stopReason: "end_turn" as const };
1178 }),
1179 }),
1180 }),
1181 },
1182 });
1183 const threadId = ThreadId.make("devin-streamed-write");
1184 const modelSelection = { instanceId, model: "default" };
1185 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
1186 runtimeMode: "full-access",
1187 interactionMode: "default",
1188 cwd: process.cwd(),
1189 });
1190 const runtime = yield* adapter.openSession({
1191 threadId,
1192 providerSessionId: ProviderSessionId.make("devin-streamed-write-session"),
1193 modelSelection,
1194 runtimePolicy,
1195 });
1196 const providerThread = yield* runtime.ensureThread({
1197 threadId,
1198 modelSelection,
1199 runtimePolicy,
1200 });
1201 yield* runtime.startTurn(
1202 makeTurnInput({
1203 threadId,
1204 providerThread,
1205 instanceId,
1206 runtimePolicy,
1207 now: yield* DateTime.now,
1208 }),
1209 );
1210 const items = Array.from(
1211 yield* runtime.events.pipe(
1212 Stream.takeUntil((event) => event.type === "turn.terminal"),
1213 Stream.runCollect,
1214 ),
1215 ).flatMap((event) => (event.type === "turn_item.updated" ? [event.turnItem] : []));
1216 for (const toolCallId of ["root-write", "child-write"]) {
1217 const writes = items.filter((item) => item.nativeItemRef?.nativeId?.endsWith(toolCallId));
1218 assert.isAtLeast(writes.length, chunks / 10, toolCallId);
1219 assert.isAtMost(writes.length, chunks / 5, toolCallId);
1220 assert.equal(writes.at(-1)?.status, "completed", toolCallId);
1221 assert.include(JSON.stringify(writes.at(-1)), "END_OF_FILE", toolCallId);
1222 }
1223 for (const [toolCallId, word] of [
1224 ["ticker", "tick"],
1225 ["text-ticker", "tock"],
1226 ] as const) {
1227 const ticks = items.filter((item) => item.nativeItemRef?.nativeId?.endsWith(toolCallId));
1228 for (let tick = 1; tick <= 5; tick++) {
1229 assert.isTrue(
1230 ticks.some((item) => JSON.stringify(item).includes(`${word} ${tick}`)),
1231 `${word} ${tick} must persist`,
1232 );
1233 }
1234 }
1235 }).pipe(Effect.provide(layerTest), Effect.scoped),
1236 );
1237
1238 it.effect("keeps Devin parent paragraphs intact while projecting native child work", () =>
1239 Effect.gen(function* () {
1240 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1241 const path = yield* Path.Path;
1242 const mockAgentPath = yield* path.fromFileUrl(
1243 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
1244 );
1245 type Runtime = AcpSessionRuntime.AcpSessionRuntime["Service"];
1246 let handler: Parameters<Runtime["handleSessionUpdate"]>[0] | undefined;
1247 let createTerminal: Parameters<Runtime["handleCreateTerminal"]>[0] | undefined;
1248 const instanceId = ProviderInstanceId.make("devin-replay");
1249 const adapter = yield* makeAcpAdapterV2({
1250 instanceId,
1251 // Production Devin runs commands through client terminals.
1252 clientTerminals: { shellCommands: true },
1253 selfInvocation: yield* resolveSelfInvocation(),
1254 flavor: {
1255 driver: ACP_TEST_DRIVER,
1256 capabilities: AcpProviderCapabilitiesV2,
1257 normalizeSessionUpdate: normalizeDevinSessionUpdate,
1258 normalizeToolCall: normalizeDevinToolCall,
1259 extractSubagentUpdate: extractDevinSubagentUpdate,
1260 makeRuntime: makeMockRuntime({
1261 childProcessSpawner,
1262 mockAgentPath,
1263 wrapRuntime: (runtime) => ({
1264 ...runtime,
1265 handleSessionUpdate: (next) =>
1266 Effect.sync(() => {
1267 handler = next;
1268 }).pipe(Effect.andThen(runtime.handleSessionUpdate(next))),
1269 handleCreateTerminal: (next) =>
1270 Effect.sync(() => {
1271 createTerminal = next;
1272 }).pipe(Effect.andThen(runtime.handleCreateTerminal(next))),
1273 prompt: () =>
1274 Effect.gen(function* () {
1275 assert.isDefined(handler);
1276 assert.isDefined(createTerminal);
1277 const fallbackTerminal = yield* createTerminal(
1278 { sessionId: "mock-session-1", command: "true acp-mcp-call task_status {}" },
1279 { requestId: "child-mcp-terminal", method: "terminal/create" },
1280 );
1281 // Production thread 54aeb6d7 split after "(command". Metadata shapes
1282 // below were captured from live Devin sessions showy-mile/fragrant-chamomile.
1283 const updates = [
1284 {
1285 sessionUpdate: "tool_call_update",
1286 toolCallId: "child-a",
1287 status: "in_progress",
1288 _meta: {
1289 "cognition.ai/subagent_started": {
1290 agentId: "child-a",
1291 title: "Map orchestration",
1292 task: "Run pwd, then reply ONE.",
1293 model: " \t ",
1294 },
1295 },
1296 },
1297 {
1298 sessionUpdate: "tool_call_update",
1299 toolCallId: "child-a",
1300 status: "in_progress",
1301 _meta: {
1302 "cognition.ai/subagent_started": {
1303 agentId: "child-a",
1304 title: "Map orchestration",
1305 task: "Run pwd, then reply ONE.",
1306 model: "SWE-1.7 Medium",
1307 depth: 1,
1308 isBackground: true,
1309 },
1310 },
1311 },
1312 {
1313 sessionUpdate: "agent_message_chunk",
1314 content: { type: "text", text: "Map orchestration (command" },
1315 _meta: { "cognition.ai/streamingMessageId": "parent-message" },
1316 },
1317 {
1318 sessionUpdate: "tool_call_update",
1319 toolCallId: "parent-tool",
1320 status: "completed",
1321 title: "Parent tool finished",
1322 },
1323 {
1324 sessionUpdate: "tool_call",
1325 toolCallId: "child-pwd",
1326 title: "Tool",
1327 kind: "execute",
1328 status: "in_progress",
1329 rawInput: { command: "pwd" },
1330 _meta: {
1331 "cognition.ai/inferenceToolName": "exec",
1332 "cognition.ai/subagent_context": { parentAgentId: "child-a" },
1333 },
1334 },
1335 {
1336 sessionUpdate: "tool_call",
1337 toolCallId: "child-weather",
1338 title: "Check weather",
1339 status: "completed",
1340 rawInput: { server: "weather", tool: "get_weather", city: "Berlin" },
1341 rawOutput: {
1342 result: {
1343 _meta: {
1344 source: { name: "Weather", logoUrl: "https://example.com/weather.png" },
1345 },
1346 content: [{ type: "text", text: "Sunny" }],
1347 },
1348 },
1349 _meta: {
1350 is_mcp_tool_call: true,
1351 "cognition.ai/subagent_context": { parentAgentId: "child-a" },
1352 },
1353 },
1354 {
1355 sessionUpdate: "tool_call",
1356 toolCallId: "child-mcp-fallback",
1357 title: "Ran command",
1358 kind: "execute",
1359 status: "completed",
1360 content: [{ type: "terminal", terminalId: fallbackTerminal.terminalId }],
1361 _meta: { "cognition.ai/subagent_context": { parentAgentId: "child-a" } },
1362 },
1363 {
1364 sessionUpdate: "tool_call",
1365 toolCallId: "parent-weather",
1366 title: "Check weather",
1367 status: "completed",
1368 rawInput: { server: "weather", tool: "get_weather", city: "Berlin" },
1369 rawOutput: {
1370 _meta: {
1371 source: { name: "Weather", logoUrl: "https://example.com/weather.png" },
1372 },
1373 content: [{ type: "text", text: "Sunny" }],
1374 },
1375 _meta: { is_mcp_tool_call: true },
1376 },
1377 {
1378 sessionUpdate: "agent_message_chunk",
1379 content: { type: "text", text: " → decider → event)." },
1380 _meta: { "cognition.ai/streamingMessageId": "parent-message" },
1381 },
1382 {
1383 sessionUpdate: "tool_call_update",
1384 toolCallId: "child-pwd",
1385 status: "completed",
1386 rawOutput: "probe-workspace",
1387 _meta: { "cognition.ai/subagent_context": { parentAgentId: "child-a" } },
1388 },
1389 {
1390 sessionUpdate: "agent_message_chunk",
1391 content: { type: "text", text: "Checking the code." },
1392 _meta: {
1393 "cognition.ai/streamingMessageId": "child-progress",
1394 "cognition.ai/subagent_context": { parentAgentId: "child-a" },
1395 },
1396 },
1397 {
1398 sessionUpdate: "agent_message_chunk",
1399 content: { type: "text", text: "ONE" },
1400 _meta: {
1401 "cognition.ai/streamingMessageId": "child-result",
1402 "cognition.ai/subagent_context": { parentAgentId: "child-a" },
1403 },
1404 },
1405 {
1406 sessionUpdate: "tool_call",
1407 toolCallId: "child-later",
1408 title: "Later tool",
1409 status: "completed",
1410 _meta: { "cognition.ai/subagent_context": { parentAgentId: "child-a" } },
1411 },
1412 {
1413 sessionUpdate: "tool_call_update",
1414 toolCallId: "child-a",
1415 status: "completed",
1416 _meta: {
1417 "cognition.ai/subagent_completed": {
1418 agentId: "child-a",
1419 success: true,
1420 summary: "Final report: ONE",
1421 depth: 1,
1422 },
1423 },
1424 },
1425 ] satisfies Array<EffectAcpSchema.SessionUpdate>;
1426 for (const update of updates)
1427 yield* handler!({ sessionId: "mock-session-1", update });
1428 return { stopReason: "end_turn" as const };
1429 }),
1430 }),
1431 }),
1432 },
1433 });
1434 const threadId = ThreadId.make("devin-replay-parent");
1435 const modelSelection = { instanceId, model: "default" };
1436 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
1437 runtimeMode: "full-access",
1438 interactionMode: "default",
1439 cwd: process.cwd(),
1440 });
1441 const runtime = yield* adapter.openSession({
1442 threadId,
1443 providerSessionId: ProviderSessionId.make("devin-replay-session"),
1444 modelSelection,
1445 runtimePolicy,
1446 });
1447 const providerThread = yield* runtime.ensureThread({
1448 threadId,
1449 modelSelection,
1450 runtimePolicy,
1451 });
1452 yield* runtime.startTurn(
1453 makeTurnInput({
1454 threadId,
1455 providerThread,
1456 instanceId,
1457 runtimePolicy,
1458 now: yield* DateTime.now,
1459 }),
1460 );
1461 const events = Array.from(
1462 yield* runtime.events.pipe(
1463 Stream.takeUntil((event) => event.type === "turn.terminal"),
1464 Stream.runCollect,
1465 ),
1466 );
1467 const items = events.flatMap((event) =>
1468 event.type === "turn_item.updated" ? [event.turnItem] : [],
1469 );
1470 const parentMessages = items.filter(
1471 (item) => item.threadId === threadId && item.type === "assistant_message",
1472 );
1473 assert.equal(new Set(parentMessages.map((item) => item.id)).size, 1);
1474 const lastParent = parentMessages.at(-1);
1475 assert.equal(
1476 lastParent?.type === "assistant_message" ? lastParent.text : undefined,
1477 "Map orchestration (command → decider → event).",
1478 );
1479 const tasks = events.flatMap((event) =>
1480 event.type === "subagent.updated" ? [event.subagent] : [],
1481 );
1482 const task = tasks.at(-1);
1483 assert.isNull(tasks[0]?.model);
1484 const childThread = events.find(
1485 (event) =>
1486 event.type === "app_thread.created" && event.appThread.id === task?.childThreadId,
1487 );
1488 assert.equal(
1489 childThread?.type === "app_thread.created"
1490 ? childThread.appThread.modelSelection.model
1491 : undefined,
1492 modelSelection.model,
1493 );
1494 assert.equal(task?.model, "SWE-1.7 Medium");
1495 assert.equal(task?.status, "completed");
1496 assert.equal(task?.result, "Final report: ONE");
1497 const childMessages = new Map(
1498 items.flatMap((item) =>
1499 item.threadId === task?.childThreadId && item.type === "assistant_message"
1500 ? [[item.id, item.text] as const]
1501 : [],
1502 ),
1503 );
1504 assert.deepEqual([...childMessages.values()], ["Checking the code.", "ONE"]);
1505 assert.equal(task?.prompt, "Run pwd, then reply ONE.");
1506 // Terminal-fallback MCP calls in a child session keep their T3 identity.
1507 assert.isTrue(
1508 items.some(
1509 (item) =>
1510 item.threadId === task?.childThreadId &&
1511 item.type === "dynamic_tool" &&
1512 item.toolName === "t3-code.task_status",
1513 ),
1514 );
1515 const childMcp = items.find(
1516 (item) =>
1517 item.threadId === task?.childThreadId &&
1518 item.type === "dynamic_tool" &&
1519 item.toolName === "weather.get_weather",
1520 );
1521 const parentMcp = items.find(
1522 (item) =>
1523 item.threadId === threadId &&
1524 item.type === "dynamic_tool" &&
1525 item.toolName === "weather.get_weather",
1526 );
1527 for (const item of [parentMcp, childMcp]) {
1528 assert.equal(item?.title, "get weather");
1529 assert.deepEqual(item?.toolSource, {
1530 key: "mcp:weather",
1531 name: "Weather",
1532 kind: "integration",
1533 icon: { _tag: "themed-logo", logoUrl: "https://example.com/weather.png" },
1534 });
1535 assert.deepEqual(item?.toolIcon, item?.toolSource?.icon);
1536 }
1537 assert.isTrue(
1538 items.some(
1539 (item) =>
1540 item.threadId === task?.childThreadId &&
1541 item.type === "user_message" &&
1542 item.text === task.prompt,
1543 ),
1544 );
1545 assert.isTrue(
1546 items.some(
1547 (item) =>
1548 item.threadId === task?.childThreadId &&
1549 item.type === "dynamic_tool" &&
1550 item.status === "completed" &&
1551 item.output === "probe-workspace",
1552 ),
1553 );
1554 const childAnswers = items.filter(
1555 (item) => item.threadId === task?.childThreadId && item.type === "assistant_message",
1556 );
1557 assert.equal(new Set(childAnswers.map((item) => item.ordinal)).size, 2);
1558 const parentTools = items.filter(
1559 (item) => item.threadId === threadId && item.type === "dynamic_tool",
1560 );
1561 assert.equal(parentTools.length, 2);
1562 assert.equal(parentTools[0]?.title, "Parent tool finished");
1563 }).pipe(Effect.provide(layerTest), Effect.scoped),
1564 );
1565
1566 it.effect("routes interleaved Copilot subagent output into subagent threads", () =>
1567 Effect.gen(function* () {
1568 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1569 const path = yield* Path.Path;
1570 const mockAgentPath = yield* path.fromFileUrl(
1571 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
1572 );
1573 type Runtime = AcpSessionRuntime.AcpSessionRuntime["Service"];
1574 let handler: Parameters<Runtime["handleSessionUpdate"]>[0] | undefined;
1575 let sessionEvent:
1576 | ((event: unknown) => Effect.Effect<void, EffectAcpErrors.AcpError>)
1577 | undefined;
1578 const routing = makeCopilotSubagentRouting();
1579 const instanceId = ProviderInstanceId.make("copilot-replay");
1580 const adapter = yield* makeAcpAdapterV2({
1581 instanceId,
1582 selfInvocation: yield* resolveSelfInvocation(),
1583 flavor: {
1584 driver: ACP_TEST_DRIVER,
1585 capabilities: AcpProviderCapabilitiesV2,
1586 registerExtensions: routing.registerExtensions,
1587 normalizeSessionUpdate: routing.normalizeSessionUpdate,
1588 extractSubagentUpdate: extractCopilotSubagentUpdate,
1589 makeRuntime: makeMockRuntime({
1590 childProcessSpawner,
1591 mockAgentPath,
1592 wrapRuntime: (runtime) => ({
1593 ...runtime,
1594 handleSessionUpdate: (next) =>
1595 Effect.sync(() => {
1596 handler = next;
1597 }).pipe(Effect.andThen(runtime.handleSessionUpdate(next))),
1598 handleExtNotification: (method, payload, next) =>
1599 Effect.sync(() => {
1600 if (method === "github.com/copilot/sessionEvent") {
1601 sessionEvent = next as typeof sessionEvent;
1602 }
1603 }).pipe(Effect.andThen(runtime.handleExtNotification(method, payload, next))),
1604 prompt: () =>
1605 Effect.gen(function* () {
1606 assert.isDefined(handler);
1607 assert.isDefined(sessionEvent);
1608 const sessionId = "mock-session-1";
1609 const raw = (type: string, data: object, agentId?: string) =>
1610 sessionEvent!({ sessionId, type, data, ...(agentId ? { agentId } : {}) });
1611 const update = (next: EffectAcpSchema.SessionUpdate) =>
1612 handler!({ sessionId, update: next });
1613 // Copilot sends each chunk's raw delta just before the chunk.
1614 const chunk = (text: string, task?: string) =>
1615 raw("assistant.message_delta", {
1616 deltaContent: text,
1617 ...(task ? { parentToolCallId: task } : {}),
1618 }).pipe(
1619 Effect.andThen(
1620 update({
1621 sessionUpdate: "agent_message_chunk",
1622 content: { type: "text", text },
1623 }),
1624 ),
1625 );
1626 const launch = (toolCallId: string, description: string, mode: string) =>
1627 update({
1628 sessionUpdate: "tool_call",
1629 toolCallId,
1630 title: description,
1631 kind: "other",
1632 status: "pending",
1633 rawInput: {
1634 description,
1635 prompt: `${description}.`,
1636 agent_type: "explore",
1637 mode,
1638 },
1639 });
1640 // Shapes captured from Copilot CLI 1.0.95 with two sync and one
1641 // background subagent launched in one batch.
1642 yield* chunk("Launching ");
1643 yield* launch("task-a", "Describe a.txt", "sync");
1644 yield* launch("task-b", "Describe b.txt", "sync");
1645 yield* launch("task-bg", "List directory", "background");
1646 yield* raw("subagent.started", { toolCallId: "task-a" }, "agent-a");
1647 yield* raw("subagent.started", { toolCallId: "task-b" }, "agent-b");
1648 yield* raw("subagent.started", { toolCallId: "task-bg" }, "agent-bg");
1649 yield* update({
1650 sessionUpdate: "tool_call_update",
1651 toolCallId: "task-bg",
1652 status: "completed",
1653 rawOutput: { content: "Agent started in background with agent_id: agent-bg." },
1654 });
1655 yield* update({
1656 sessionUpdate: "tool_call",
1657 toolCallId: "view-a",
1658 title: "Viewing a.txt",
1659 kind: "read",
1660 status: "completed",
1661 rawInput: { path: "a.txt" },
1662 _meta: { "github.com/copilot": { agentId: "agent-a" } },
1663 });
1664 yield* chunk("`a.txt` says", "task-a");
1665 yield* chunk("`b.txt` has", "task-b");
1666 yield* chunk(" hello.", "task-a");
1667 yield* chunk("a.txt b.txt", "task-bg");
1668 yield* chunk(" three lines.", "task-b");
1669 for (const [task, agent, text] of [
1670 ["task-a", "agent-a", "`a.txt` says hello."],
1671 ["task-b", "agent-b", "`b.txt` has three lines."],
1672 ] as const) {
1673 yield* update({
1674 sessionUpdate: "tool_call_update",
1675 toolCallId: task,
1676 status: "completed",
1677 rawOutput: { content: text },
1678 });
1679 yield* raw("subagent.completed", { toolCallId: task }, agent);
1680 }
1681 yield* raw("subagent.completed", { toolCallId: "task-bg" }, "agent-bg");
1682 yield* chunk("all done.");
1683 return { stopReason: "end_turn" as const };
1684 }),
1685 }),
1686 }),
1687 },
1688 });
1689 const threadId = ThreadId.make("copilot-replay-parent");
1690 const modelSelection = { instanceId, model: "default" };
1691 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
1692 runtimeMode: "full-access",
1693 interactionMode: "default",
1694 cwd: process.cwd(),
1695 });
1696 const runtime = yield* adapter.openSession({
1697 threadId,
1698 providerSessionId: ProviderSessionId.make("copilot-replay-session"),
1699 modelSelection,
1700 runtimePolicy,
1701 });
1702 const providerThread = yield* runtime.ensureThread({
1703 threadId,
1704 modelSelection,
1705 runtimePolicy,
1706 });
1707 yield* runtime.startTurn(
1708 makeTurnInput({
1709 threadId,
1710 providerThread,
1711 instanceId,
1712 runtimePolicy,
1713 now: yield* DateTime.now,
1714 }),
1715 );
1716 const events = Array.from(
1717 yield* runtime.events.pipe(
1718 Stream.takeUntil((event) => event.type === "turn.terminal"),
1719 Stream.runCollect,
1720 ),
1721 );
1722 const items = events.flatMap((event) =>
1723 event.type === "turn_item.updated" ? [event.turnItem] : [],
1724 );
1725 const lastText = (thread: string | null | undefined) =>
1726 new Map(
1727 items.flatMap((item) =>
1728 item.threadId === thread && item.type === "assistant_message"
1729 ? [[item.id, item.text] as const]
1730 : [],
1731 ),
1732 );
1733 assert.deepEqual([...lastText(threadId).values()].join(""), "Launching all done.");
1734 const tasks = new Map(
1735 events.flatMap((event) =>
1736 event.type === "subagent.updated"
1737 ? [[event.subagent.nativeTaskRef?.nativeId, event.subagent] as const]
1738 : [],
1739 ),
1740 );
1741 const outputs = Object.fromEntries(
1742 [...tasks].map(([id, task]) => [
1743 id,
1744 [task.status, [...lastText(task.childThreadId).values()].join("")],
1745 ]),
1746 );
1747 assert.deepEqual(outputs, {
1748 "task-a": ["completed", "`a.txt` says hello."],
1749 "task-b": ["completed", "`b.txt` has three lines."],
1750 "task-bg": ["completed", "a.txt b.txt"],
1751 });
1752 assert.isTrue(
1753 items.some(
1754 (item) =>
1755 item.threadId === tasks.get("task-a")?.childThreadId &&
1756 item.type === "dynamic_tool" &&
1757 item.title === "Read a.txt",
1758 ),
1759 );
1760 assert.isFalse(
1761 items.some((item) => item.threadId === threadId && item.type === "dynamic_tool"),
1762 );
1763 }).pipe(Effect.provide(layerTest), Effect.scoped),
1764 );
1765
1766 it.effect("projects ACP v2 fidelity updates into first-class orchestration items", () =>
1767 Effect.gen(function* () {
1768 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1769 const path = yield* Path.Path;
1770 const selfInvocation = yield* resolveSelfInvocation();
1771 const mockAgentPath = yield* path.fromFileUrl(
1772 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
1773 );
1774 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
1775 let sessionUpdateHandler: Parameters<RuntimeService["handleSessionUpdate"]>[0] | undefined;
1776 const instanceId = ProviderInstanceId.make("acp-test-v2-fidelity");
1777 const adapter = yield* makeAcpAdapterV2({
1778 instanceId,
1779 flavor: {
1780 driver: ACP_TEST_DRIVER,
1781 capabilities: AcpProviderCapabilitiesV2,
1782 makeRuntime: makeMockRuntime({
1783 childProcessSpawner,
1784 mockAgentPath,
1785 environment: { T3_ACP_EMIT_V2_FIDELITY: "1" },
1786 wrapRuntime: (runtime) => ({
1787 ...runtime,
1788 handleSessionUpdate: (handler) =>
1789 Effect.sync(() => {
1790 sessionUpdateHandler = handler;
1791 }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))),
1792 }),
1793 }),
1794 },
1795 selfInvocation,
1796 });
1797 const threadId = ThreadId.make("thread-acp-v2-fidelity");
1798 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
1799 runtimeMode: "full-access",
1800 interactionMode: "default",
1801 cwd: process.cwd(),
1802 });
1803 const modelSelection = { instanceId, model: "default" } as const;
1804 const runtime = yield* adapter.openSession({
1805 threadId,
1806 providerSessionId: ProviderSessionId.make("provider-session-acp-v2-fidelity"),
1807 modelSelection,
1808 runtimePolicy,
1809 });
1810 const providerThread = yield* runtime.ensureThread({
1811 threadId,
1812 modelSelection,
1813 runtimePolicy,
1814 });
1815 const now = yield* DateTime.now;
1816 yield* runtime.startTurn(
1817 makeTurnInput({
1818 threadId,
1819 providerThread,
1820 instanceId,
1821 runtimePolicy,
1822 now,
1823 ordinal: 1,
1824 }),
1825 );
1826 const events = Array.from(
1827 yield* runtime.events.pipe(
1828 Stream.takeUntil((event) => event.type === "turn.terminal"),
1829 Stream.runCollect,
1830 ),
1831 );
1832 const plans = events.flatMap((event) => (event.type === "plan.updated" ? [event.plan] : []));
1833 const planA = plans.filter((plan) => plan.kind === "proposed_plan");
1834 const planB = plans.find((plan) => plan.kind === "todo_list");
1835 assert.equal(planA.length, 2);
1836 assert.equal(planA[0]?.id, planA[1]?.id);
1837 assert.equal(planA[1]?.status, "superseded");
1838 assert.isDefined(planB);
1839 assert.notEqual(planA[0]?.id, planB?.id);
1840
1841 const items = events.flatMap((event) =>
1842 event.type === "turn_item.updated" ? [event.turnItem] : [],
1843 );
1844 assert.isFalse(
1845 items.some((item) => item.type === "user_message" && item.text === "stale user text"),
1846 "the active ACP prompt echo must not create a second user message",
1847 );
1848 assert.isTrue(items.some((item) => item.type === "user_message" && item.text.length === 0));
1849 assert.equal(
1850 items.filter((item) => item.type === "assistant_message").at(-1)?.text,
1851 "authoritative answer",
1852 );
1853 assert.isTrue(
1854 items.some((item) => item.type === "reasoning" && item.text === "final thought"),
1855 );
1856 assert.deepInclude(
1857 items.flatMap((item) =>
1858 item.type === "command_execution" ? [{ input: item.input, output: item.output }] : [],
1859 ),
1860 { input: "printf proof", output: "proof" },
1861 );
1862 assert.deepInclude(
1863 items.flatMap((item) =>
1864 item.type === "command_execution" ? [{ input: item.input, output: item.output }] : [],
1865 ),
1866 { input: "cat probe.txt", output: "after\n" },
1867 );
1868 assert.isTrue(
1869 items.some((item) => item.title === "Action required" && item.status === "waiting"),
1870 );
1871 assert.isTrue(
1872 items.some(
1873 (item) =>
1874 item.type === "file_change" &&
1875 item.changes?.[0]?.operation === "move" &&
1876 item.changes[0]?.oldPath === "/workspace/old.ts",
1877 ),
1878 );
1879 const read = items.find((item) => item.type === "dynamic_tool" && item.toolName === "Read");
1880 assert.deepEqual(
1881 read?.type === "dynamic_tool" ? { title: read.title, input: read.input } : null,
1882 { title: "Read src/env.ts", input: { path: "src/env.ts" } },
1883 );
1884 const search = items.find((item) => item.type === "file_search");
1885 assert.deepEqual(
1886 search?.type === "file_search" ? { title: search.title, pattern: search.pattern } : null,
1887 { title: "Searched TODO in web", pattern: "apps/web" },
1888 );
1889 const webItem = (nativeId: string, status: string) => {
1890 const item = items.findLast(
1891 (candidate) =>
1892 candidate.type === "web_search" &&
1893 candidate.status === status &&
1894 candidate.nativeItemRef?.nativeId?.endsWith(nativeId) === true,
1895 );
1896 return item?.type === "web_search"
1897 ? { title: item.title, patterns: item.patterns, results: item.results }
1898 : null;
1899 };
1900 assert.deepEqual(webItem("grok-x-search", "running"), {
1901 title: "X search",
1902 patterns: undefined,
1903 results: undefined,
1904 });
1905 assert.deepEqual(webItem("grok-x-search", "completed"), {
1906 title: "X search: conversation_id:42",
1907 patterns: ["conversation_id:42"],
1908 results: undefined,
1909 });
1910 assert.deepEqual(webItem("grok-web-search", "completed"), {
1911 title: "Web search: t3 code",
1912 patterns: ["t3 code"],
1913 results: [{ url: "https://t3.codes" }, { url: "https://github.com/pingdotgg/t3code" }],
1914 });
1915 assert.deepEqual(webItem("grok-web-fetch", "completed")?.results, [
1916 { url: "https://t3.codes", snippet: "T3 Code page" },
1917 ]);
1918 const completedCompaction = items.find(
1919 (item) =>
1920 item.type === "compaction" &&
1921 item.status === "completed" &&
1922 item.summary === "Retained decisions.",
1923 );
1924 assert.isDefined(completedCompaction);
1925 assert.notProperty(completedCompaction!, "afterTokenCount");
1926
1927 assert.isDefined(sessionUpdateHandler);
1928 yield* sessionUpdateHandler!({
1929 sessionId: "mock-session-1",
1930 update: {
1931 sessionUpdate: "agent_message",
1932 messageId: "late-authoritative-message",
1933 content: [{ type: "text", text: "late authoritative text" }],
1934 },
1935 });
1936 yield* sessionUpdateHandler!({
1937 sessionId: "mock-session-1",
1938 update: {
1939 sessionUpdate: "agent_message",
1940 messageId: "late-authoritative-message",
1941 },
1942 });
1943 const afterOmittedPatch = yield* runtime.readThreadSnapshot({ providerThread });
1944 assert.isTrue(
1945 afterOmittedPatch.messages.some((message) => message.text === "late authoritative text"),
1946 "omitted late message content must preserve the authoritative text",
1947 );
1948 }).pipe(Effect.provide(layerTest), Effect.scoped),
1949 );
1950
1951 it.live(
1952 "loads the persisted ACP session during startup without creating a throwaway session",
1953 () =>
1954 Effect.gen(function* () {
1955 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1956 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1957 const path = yield* Path.Path;
1958 const selfInvocation = yield* resolveSelfInvocation();
1959 const mockAgentPath = yield* path.fromFileUrl(
1960 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
1961 );
1962 const protocolEvents = yield* Queue.unbounded<EffectAcpProtocol.AcpProtocolLogEvent>();
1963 const instanceId = ProviderInstanceId.make("acp-test-eager-resume");
1964 const adapter = yield* makeAcpAdapterV2({
1965 instanceId,
1966 flavor: {
1967 driver: ACP_TEST_DRIVER,
1968 capabilities: AcpProviderCapabilitiesV2,
1969 makeRuntime: makeMockRuntime({
1970 childProcessSpawner,
1971 mockAgentPath,
1972 protocolEvents,
1973 }),
1974 },
1975 selfInvocation,
1976 });
1977 const threadId = ThreadId.make("thread-acp-eager-resume");
1978 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
1979 runtimeMode: "full-access",
1980 interactionMode: "default",
1981 cwd: process.cwd(),
1982 });
1983 const modelSelection = { instanceId, model: "default" } as const;
1984 const runtime = yield* adapter.openSession({
1985 threadId,
1986 providerSessionId: ProviderSessionId.make("provider-session-acp-eager-resume"),
1987 modelSelection,
1988 runtimePolicy,
1989 initialNativeThreadId: "persisted-session",
1990 });
1991 const providerThread = yield* runtime.ensureThread({
1992 threadId,
1993 modelSelection,
1994 runtimePolicy,
1995 });
1996
1997 assert.equal(providerThread.nativeThreadRef?.nativeId, "persisted-session");
1998 assert.isNull(providerThread.nativeMetadata);
1999 const startupMethods = yield* pollProtocolMethods(protocolEvents);
2000 assert.include(startupMethods, "session/resume");
2001 assert.notInclude(startupMethods, "session/new");
2002
2003 yield* runtime.resumeThread({ providerThread, modelSelection, runtimePolicy });
2004 assert.notInclude(yield* pollProtocolMethods(protocolEvents), "session/resume");
2005 const now = yield* DateTime.now;
2006 yield* runtime.startTurn(
2007 makeTurnInput({
2008 threadId,
2009 providerThread,
2010 instanceId,
2011 runtimePolicy,
2012 now,
2013 ordinal: 1,
2014 }),
2015 );
2016 const terminal = Array.from(
2017 yield* runtime.events.pipe(
2018 Stream.takeUntil((event) => event.type === "turn.terminal"),
2019 Stream.runCollect,
2020 ),
2021 ).find((event) => event.type === "turn.terminal");
2022 assert.equal(
2023 terminal?.providerTurnId,
2024 idAllocator.derive.providerTurn({
2025 driver: ACP_TEST_DRIVER,
2026 nativeTurnId: "persisted-session:turn:1",
2027 }),
2028 );
2029 }).pipe(Effect.provide(layerTest), Effect.scoped),
2030 );
2031
2032 it.live("preserves new-session fallback when an eager ACP session load is stale", () =>
2033 Effect.gen(function* () {
2034 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
2035 const idAllocator = yield* IdAllocator.IdAllocatorV2;
2036 const path = yield* Path.Path;
2037 const selfInvocation = yield* resolveSelfInvocation();
2038 const mockAgentPath = yield* path.fromFileUrl(
2039 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
2040 );
2041 const protocolEvents = yield* Queue.unbounded<EffectAcpProtocol.AcpProtocolLogEvent>();
2042 const instanceId = ProviderInstanceId.make("acp-test-stale-eager-resume");
2043 const adapter = yield* makeAcpAdapterV2({
2044 instanceId,
2045 flavor: {
2046 driver: ACP_TEST_DRIVER,
2047 capabilities: AcpProviderCapabilitiesV2,
2048 makeRuntime: makeMockRuntime({
2049 childProcessSpawner,
2050 mockAgentPath,
2051 protocolEvents,
2052 environment: (runtimeOrdinal) =>
2053 runtimeOrdinal === 1 ? { T3_ACP_FAIL_LOAD_SESSION: "1" } : {},
2054 }),
2055 },
2056 selfInvocation,
2057 });
2058 const threadId = ThreadId.make("thread-acp-stale-eager-resume");
2059 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
2060 runtimeMode: "full-access",
2061 interactionMode: "default",
2062 cwd: process.cwd(),
2063 });
2064 const modelSelection = { instanceId, model: "default" } as const;
2065 const runtime = yield* adapter.openSession({
2066 threadId,
2067 providerSessionId: ProviderSessionId.make("provider-session-acp-stale-eager-resume"),
2068 modelSelection,
2069 runtimePolicy,
2070 initialNativeThreadId: "stale-session",
2071 });
2072 const replacementThread = yield* runtime.ensureThread({
2073 threadId,
2074 modelSelection,
2075 runtimePolicy,
2076 });
2077 const staleThread: OrchestrationV2ProviderThread = {
2078 ...replacementThread,
2079 nativeMetadata: null,
2080 nativeThreadRef: {
2081 driver: ACP_TEST_DRIVER,
2082 nativeId: "stale-session",
2083 strength: "strong",
2084 },
2085 };
2086
2087 const startupMethods = yield* pollProtocolMethods(protocolEvents);
2088 assert.equal(startupMethods.filter((method) => method === "session/resume").length, 1);
2089 assert.equal(startupMethods.filter((method) => method === "session/new").length, 1);
2090
2091 const resumeError = yield* runtime
2092 .resumeThread({ providerThread: staleThread, modelSelection, runtimePolicy })
2093 .pipe(Effect.flip);
2094 assert.equal(resumeError._tag, "ProviderAdapterResumeThreadError");
2095 assert.notInclude(yield* pollProtocolMethods(protocolEvents), "session/resume");
2096 assert.notEqual(replacementThread.nativeThreadRef?.nativeId, "stale-session");
2097 const preservedReplacement = yield* runtime.ensureThread({
2098 threadId,
2099 modelSelection,
2100 runtimePolicy,
2101 });
2102 assert.equal(preservedReplacement.nativeMetadata?.itemIdentityVersion, 2);
2103 const now = yield* DateTime.now;
2104 yield* runtime.startTurn(
2105 makeTurnInput({
2106 threadId,
2107 providerThread: preservedReplacement,
2108 instanceId,
2109 runtimePolicy,
2110 now,
2111 ordinal: 1,
2112 }),
2113 );
2114 const terminal = Array.from(
2115 yield* runtime.events.pipe(
2116 Stream.takeUntil((event) => event.type === "turn.terminal"),
2117 Stream.runCollect,
2118 ),
2119 ).find((event) => event.type === "turn.terminal");
2120 assert.equal(
2121 terminal?.providerTurnId,
2122 idAllocator.derive.providerTurn({
2123 driver: ACP_TEST_DRIVER,
2124 nativeTurnId: acpScopedNativeId(
2125 instanceId,
2126 `${preservedReplacement.nativeThreadRef?.nativeId}:turn:1`,
2127 ),
2128 }),
2129 );
2130 }).pipe(Effect.provide(layerTest), Effect.scoped),
2131 );
2132
2133 it.live("cleans detached fixtures when an assertion aborts the test scope", () =>
2134 Effect.gen(function* () {
2135 if ((yield* HostProcess.Platform) !== "linux") return;
2136 const fileSystem = yield* FileSystem.FileSystem;
2137 let published: ReadonlyArray<number> = [];
2138 const failed = yield* Effect.scoped(
2139 Effect.gen(function* () {
2140 const commandPidPath = yield* fileSystem.makeTempFileScoped({
2141 prefix: "t3-acp-forced-failure-command-",
2142 });
2143 yield* Effect.addFinalizer(() =>
2144 Effect.sync(() => cleanupPublishedDetachedFixture(commandPidPath)),
2145 );
2146 const fixture = NodeChildProcess.spawn(
2147 "bash",
2148 [
2149 "-c",
2150 'sleep 120 & child=$!; printf "%s %s\\n" "$$" "$child" > "$1"; wait "$child"',
2151 "bash",
2152 commandPidPath,
2153 ],
2154 { detached: true, stdio: "ignore" },
2155 );
2156 fixture.unref();
2157 published = Option.getOrThrow(
2158 yield* waitForPublishedProcessIds(fileSystem, commandPidPath, 2),
2159 );
2160 return yield* Effect.fail("forced assertion failure");
2161 }),
2162 ).pipe(Effect.exit);
2163
2164 assert.isTrue(Exit.isFailure(failed));
2165 assert.isTrue(
2166 Option.isSome(yield* waitForProcessesToExit(published)),
2167 "detached cleanup finalizer must reap the Bash and sleep fixture",
2168 );
2169 }).pipe(Effect.provide(layerTest)),
2170 );
2171
2172 it.live("replaces an unexpectedly terminated ACP runtime before the next turn", () =>
2173 Effect.gen(function* () {
2174 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
2175 const path = yield* Path.Path;
2176 const selfInvocation = yield* resolveSelfInvocation();
2177 const mockAgentPath = yield* path.fromFileUrl(
2178 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
2179 );
2180 const runtimeInputs: AcpAdapterV2RuntimeInput[] = [];
2181 let runtimeOrdinalSeen = 0;
2182 const baseMakeRuntime = makeMockRuntime({
2183 childProcessSpawner,
2184 mockAgentPath,
2185 environment: (runtimeOrdinal) => {
2186 runtimeOrdinalSeen = runtimeOrdinal;
2187 return {};
2188 },
2189 });
2190 const instanceId = ProviderInstanceId.make("acp-test-unexpected-termination");
2191 const adapter = yield* makeAcpAdapterV2({
2192 instanceId,
2193 flavor: {
2194 driver: ACP_TEST_DRIVER,
2195 capabilities: AcpProviderCapabilitiesV2,
2196 makeRuntime: (runtimeInput) =>
2197 Effect.sync(() => {
2198 runtimeInputs.push(runtimeInput);
2199 }).pipe(Effect.andThen(baseMakeRuntime(runtimeInput))),
2200 },
2201 selfInvocation,
2202 });
2203 const threadId = ThreadId.make("thread-acp-unexpected-termination");
2204 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
2205 runtimeMode: "full-access",
2206 interactionMode: "default",
2207 cwd: process.cwd(),
2208 });
2209 const modelSelection = { instanceId, model: "default" } as const;
2210 const runtime = yield* adapter.openSession({
2211 threadId,
2212 providerSessionId: ProviderSessionId.make("provider-session-acp-unexpected-termination"),
2213 modelSelection,
2214 runtimePolicy,
2215 });
2216 const providerThread = yield* runtime.ensureThread({
2217 threadId,
2218 modelSelection,
2219 runtimePolicy,
2220 });
2221 assert.equal(runtimeOrdinalSeen, 1);
2222 yield* runtimeInputs[0]!.onTermination!(
2223 new EffectAcpErrors.AcpTransportError({
2224 detail: "Injected unexpected writer termination",
2225 cause: "test",
2226 }),
2227 );
2228
2229 yield* runtime.startTurn(
2230 makeTurnInput({
2231 threadId,
2232 providerThread,
2233 instanceId,
2234 runtimePolicy,
2235 now: yield* DateTime.now,
2236 }),
2237 );
2238 yield* runtime.events.pipe(
2239 Stream.filter((event) => event.type === "turn.terminal"),
2240 Stream.runHead,
2241 );
2242 assert.equal(runtimeOrdinalSeen, 2);
2243 assert.lengthOf(runtimeInputs, 2);
2244 }).pipe(Effect.provide(layerTest), Effect.scoped),
2245 );
2246
2247 it.live("reaps detached native work when the provider exits before explicit teardown", () =>
2248 Effect.gen(function* () {
2249 if ((yield* HostProcess.Platform) !== "linux") return;
2250 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
2251 const fileSystem = yield* FileSystem.FileSystem;
2252 const path = yield* Path.Path;
2253 const selfInvocation = yield* resolveSelfInvocation();
2254 const mockAgentPath = yield* path.fromFileUrl(
2255 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
2256 );
2257 const commandPidPath = yield* fileSystem.makeTempFileScoped({
2258 prefix: "t3-acp-provider-exit-command-",
2259 });
2260 yield* Effect.addFinalizer(() =>
2261 Effect.sync(() => cleanupPublishedDetachedFixture(commandPidPath)),
2262 );
2263 const instanceId = ProviderInstanceId.make("acp-test-provider-exit");
2264 const adapter = yield* makeAcpAdapterV2({
2265 instanceId,
2266 flavor: {
2267 driver: ACP_TEST_DRIVER,
2268 capabilities: AcpProviderCapabilitiesV2,
2269 terminateRuntimeProcessGroupOnInterrupt: true,
2270 makeRuntime: makeMockRuntime({
2271 childProcessSpawner,
2272 mockAgentPath,
2273 ownDescendantProcessGroups: true,
2274 ownDetachedProcessGroup: true,
2275 processGroupTerminationGrace: 0,
2276 environment: {
2277 T3_ACP_EMIT_RUNNING_COMMAND_THEN_HANG: "1",
2278 T3_ACP_EXIT_AFTER_RUNNING_COMMAND_LAUNCH: "1",
2279 T3_ACP_RUNNING_COMMAND_PID_PATH: commandPidPath,
2280 T3_ACP_RUNNING_COMMAND_SEPARATE_SESSION: "1",
2281 },
2282 }),
2283 },
2284 selfInvocation,
2285 });
2286 const threadId = ThreadId.make("thread-acp-provider-exit-running-command");
2287 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
2288 runtimeMode: "full-access",
2289 interactionMode: "default",
2290 cwd: process.cwd(),
2291 });
2292 const modelSelection = { instanceId, model: "default" } as const;
2293 const runtime = yield* adapter.openSession({
2294 threadId,
2295 providerSessionId: ProviderSessionId.make("provider-session-acp-provider-exit"),
2296 modelSelection,
2297 runtimePolicy,
2298 });
2299 const providerThread = yield* runtime.ensureThread({
2300 threadId,
2301 modelSelection,
2302 runtimePolicy,
2303 });
2304 yield* runtime
2305 .startTurn(
2306 makeTurnInput({
2307 threadId,
2308 providerThread,
2309 instanceId,
2310 runtimePolicy,
2311 now: yield* DateTime.now,
2312 }),
2313 )
2314 .pipe(Effect.exit, Effect.forkScoped);
2315
2316 const published = yield* waitForPublishedProcessIds(fileSystem, commandPidPath, 3);
2317 assert.isTrue(Option.isSome(published), "detached fixture must publish all process IDs");
2318 const pids = Option.getOrThrow(published);
2319 assert.isTrue(
2320 Option.isSome(yield* waitForProcessesToExit(pids)),
2321 "provider termination must reap the detached launcher, Bash, and sleep processes",
2322 );
2323 }).pipe(Effect.provide(layerTest), Effect.scoped),
2324 );
2325
2326 it.live("surfaces reduced guarantee when delegated cgroup containment is unavailable", () =>
2327 Effect.gen(function* () {
2328 if ((yield* HostProcess.Platform) !== "linux") return;
2329 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
2330 const path = yield* Path.Path;
2331 const selfInvocation = yield* resolveSelfInvocation();
2332 const mockAgentPath = yield* path.fromFileUrl(
2333 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
2334 );
2335 let containment:
2336 | AcpSessionRuntime.AcpSessionRuntime["Service"]["processContainment"]
2337 | undefined;
2338 const instanceId = ProviderInstanceId.make("acp-test-cgroup-unavailable");
2339 const adapter = yield* makeAcpAdapterV2({
2340 instanceId,
2341 flavor: {
2342 driver: ACP_TEST_DRIVER,
2343 capabilities: AcpProviderCapabilitiesV2,
2344 makeRuntime: makeMockRuntime({
2345 childProcessSpawner,
2346 linuxCgroupController: null,
2347 mockAgentPath,
2348 ownDescendantProcessGroups: true,
2349 ownDetachedProcessGroup: true,
2350 wrapRuntime: (runtime) => {
2351 containment = runtime.processContainment;
2352 return runtime;
2353 },
2354 }),
2355 },
2356 selfInvocation,
2357 });
2358 const threadId = ThreadId.make("thread-acp-cgroup-unavailable");
2359 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
2360 runtimeMode: "full-access",
2361 interactionMode: "default",
2362 cwd: process.cwd(),
2363 });
2364 const modelSelection = { instanceId, model: "default" } as const;
2365 const runtime = yield* adapter.openSession({
2366 threadId,
2367 providerSessionId: ProviderSessionId.make("provider-session-acp-cgroup-unavailable"),
2368 modelSelection,
2369 runtimePolicy,
2370 });
2371 yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy });
2372 assert.equal(containment, "process-ledger-reduced-guarantee");
2373 }).pipe(Effect.provide(layerTest), Effect.scoped),
2374 );
2375
2376 it.live("cleans a cgroup lease when the pre-exec join wrapper fails", () =>
2377 Effect.gen(function* () {
2378 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
2379 const path = yield* Path.Path;
2380 const selfInvocation = yield* resolveSelfInvocation();
2381 const mockAgentPath = yield* path.fromFileUrl(
2382 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
2383 );
2384 let createCalls = 0;
2385 let cgroupExists = true;
2386 let killCalls = 0;
2387 let removeCalls = 0;
2388 const cgroupController: AcpSessionRuntime.AcpLinuxCgroupController = {
2389 create: () => {
2390 createCalls += 1;
2391 return {
2392 contains: () => false,
2393 exists: () => cgroupExists,
2394 path: "/definitely-missing/t3-acp-cgroup",
2395 relativePath: "/definitely-missing/t3-acp-cgroup",
2396 kill: () => {
2397 killCalls += 1;
2398 },
2399 populated: () => false,
2400 remove: () => {
2401 removeCalls += 1;
2402 cgroupExists = false;
2403 },
2404 };
2405 },
2406 };
2407 const instanceId = ProviderInstanceId.make("acp-test-cgroup-join-failure");
2408 const adapter = yield* makeAcpAdapterV2({
2409 instanceId,
2410 flavor: {
2411 driver: ACP_TEST_DRIVER,
2412 capabilities: AcpProviderCapabilitiesV2,
2413 makeRuntime: makeMockRuntime({
2414 childProcessSpawner,
2415 linuxCgroupController: cgroupController,
2416 mockAgentPath,
2417 ownDescendantProcessGroups: true,
2418 ownDetachedProcessGroup: true,
2419 }),
2420 },
2421 selfInvocation,
2422 });
2423 const threadId = ThreadId.make("thread-acp-cgroup-join-failure");
2424 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
2425 runtimeMode: "full-access",
2426 interactionMode: "default",
2427 cwd: process.cwd(),
2428 });
2429 const modelSelection = { instanceId, model: "default" } as const;
2430 const sessionScope = yield* Scope.make();
2431 const opened = yield* adapter
2432 .openSession({
2433 threadId,
2434 providerSessionId: ProviderSessionId.make("provider-session-acp-cgroup-join-failure"),
2435 modelSelection,
2436 runtimePolicy,
2437 })
2438 .pipe(Effect.provideService(Scope.Scope, sessionScope), Effect.exit);
2439 assert.isTrue(Exit.isFailure(opened));
2440 yield* Scope.close(sessionScope, Exit.void);
2441 assert.equal(createCalls, 1);
2442 assert.isAtLeast(killCalls, 1);
2443 assert.isAtLeast(removeCalls, 1);
2444 }).pipe(Effect.provide(layerTest), Effect.scoped),
2445 );
2446
2447 it.effect("negotiates and executes optional native session forks through the ACP runtime", () =>
2448 Effect.gen(function* () {
2449 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
2450 const path = yield* Path.Path;
2451 const selfInvocation = yield* resolveSelfInvocation();
2452 const mockAgentPath = yield* path.fromFileUrl(
2453 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
2454 );
2455 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
2456 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
2457 let createTerminal: Parameters<RuntimeService["handleCreateTerminal"]>[0] | undefined;
2458 let readTerminalOutput: Parameters<RuntimeService["handleTerminalOutput"]>[0] | undefined;
2459 let waitForTerminalExit:
2460 | Parameters<RuntimeService["handleTerminalWaitForExit"]>[0]
2461 | undefined;
2462 const makeRuntime = makeMockRuntime({
2463 childProcessSpawner,
2464 mockAgentPath,
2465 protocolEvents,
2466 wrapRuntime: (runtime) => ({
2467 ...runtime,
2468 handleCreateTerminal: (handler) =>
2469 Effect.sync(() => {
2470 createTerminal = handler;
2471 }).pipe(Effect.andThen(runtime.handleCreateTerminal(handler))),
2472 handleTerminalOutput: (handler) =>
2473 Effect.sync(() => {
2474 readTerminalOutput = handler;
2475 }).pipe(Effect.andThen(runtime.handleTerminalOutput(handler))),
2476 handleTerminalWaitForExit: (handler) =>
2477 Effect.sync(() => {
2478 waitForTerminalExit = handler;
2479 }).pipe(Effect.andThen(runtime.handleTerminalWaitForExit(handler))),
2480 }),
2481 });
2482
2483 const instanceId = ProviderInstanceId.make("acp-test");
2484 const adapter = yield* makeAcpAdapterV2({
2485 instanceId,
2486 flavor: {
2487 driver: ACP_TEST_DRIVER,
2488 capabilities: AcpProviderCapabilitiesV2,
2489 makeRuntime,
2490 },
2491 selfInvocation,
2492 clientTerminals: {},
2493 });
2494 const sourceThreadId = ThreadId.make("thread-acp-native-fork-source");
2495 const targetThreadId = ThreadId.make("thread-acp-native-fork-target");
2496 yield* (yield* McpProviderSessions.McpProviderSessions).set({
2497 environmentId: EnvironmentId.make("environment-acp-native-fork-source"),
2498 threadId: sourceThreadId,
2499 providerSessionId: "mcp-session-acp-native-fork-source",
2500 providerInstanceId: instanceId,
2501 endpoint: "http://127.0.0.1:43123/mcp",
2502 authorizationHeader: "Bearer source-thread-token",
2503 browserToolsAvailable: true,
2504 });
2505 yield* (yield* McpProviderSessions.McpProviderSessions).set({
2506 environmentId: EnvironmentId.make("environment-acp-native-fork"),
2507 threadId: targetThreadId,
2508 providerSessionId: "mcp-session-acp-native-fork",
2509 providerInstanceId: instanceId,
2510 endpoint: "http://127.0.0.1:43123/mcp",
2511 authorizationHeader: "Bearer target-thread-token",
2512 browserToolsAvailable: true,
2513 });
2514 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
2515 runtimeMode: "full-access",
2516 interactionMode: "default",
2517 cwd: process.cwd(),
2518 });
2519 const modelSelection = { instanceId, model: "default" } as const;
2520 const runtime = yield* adapter.openSession({
2521 threadId: sourceThreadId,
2522 providerSessionId: ProviderSessionId.make("provider-session-acp-native-fork"),
2523 modelSelection,
2524 runtimePolicy,
2525 });
2526
2527 assert.isTrue(runtime.providerSession.capabilities.threads.canForkThread);
2528 assert.isTrue(runtime.providerSession.capabilities.threads.canReadThreadSnapshot);
2529 assert.isTrue(runtime.providerSession.capabilities.sessions.supportsModelSwitchInSession);
2530 assert.isFalse(
2531 runtime.providerSession.capabilities.sessions.supportsRuntimeModeSwitchInSession,
2532 );
2533
2534 const sourceProviderThread = yield* runtime.ensureThread({
2535 threadId: sourceThreadId,
2536 modelSelection,
2537 runtimePolicy,
2538 });
2539 const forkedProviderThread = yield* runtime.forkThread({
2540 sourceProviderThread,
2541 targetThreadId,
2542 });
2543 const forkRequestEvent = Option.getOrThrow(
2544 yield* Stream.fromQueue(protocolEvents).pipe(
2545 Stream.filter(
2546 (event) =>
2547 event.direction === "outgoing" && rawProtocolMethod(event) === "session/fork",
2548 ),
2549 Stream.runHead,
2550 ),
2551 );
2552 const forkRequest = Option.getOrThrow(
2553 Option.fromNullishOr(rawProtocolRequest(forkRequestEvent)),
2554 );
2555
2556 assert.equal(sourceProviderThread.nativeThreadRef?.nativeId, "mock-session-1");
2557 assert.equal(forkedProviderThread.nativeThreadRef?.nativeId, "mock-session-1-fork");
2558 assert.equal(forkedProviderThread.appThreadId, targetThreadId);
2559 assert.equal(forkedProviderThread.forkedFrom?.providerThreadId, sourceProviderThread.id);
2560 assert.deepEqual(forkRequest.params, {
2561 sessionId: "mock-session-1",
2562 cwd: process.cwd(),
2563 mcpServers: [
2564 {
2565 type: "stdio",
2566 name: "t3-code",
2567 command: process.execPath,
2568 args: [
2569 process.argv[1] === undefined ? "t3" : NodePath.resolve(process.argv[1]),
2570 "acp-mcp-bridge",
2571 ],
2572 env: [
2573 { name: "ELECTRON_RUN_AS_NODE", value: "1" },
2574 { name: "T3_ACP_MCP_ENDPOINT", value: "http://127.0.0.1:43123/mcp" },
2575 { name: "T3_ACP_MCP_AUTHORIZATION", value: "Bearer target-thread-token" },
2576 ],
2577 },
2578 ],
2579 });
2580 // Re-reading another binding must not reassign the forked native
2581 // session's credential scope.
2582 yield* runtime.ensureThread({
2583 threadId: sourceThreadId,
2584 modelSelection,
2585 runtimePolicy,
2586 });
2587 if (
2588 createTerminal === undefined ||
2589 readTerminalOutput === undefined ||
2590 waitForTerminalExit === undefined
2591 ) {
2592 return yield* Effect.die("ACP runtime must register terminal handlers");
2593 }
2594 const unknownTerminal = yield* createTerminal(
2595 {
2596 sessionId: "mock-child-session-without-credential-scope",
2597 command: process.execPath,
2598 args: ["-e", "process.stdout.write(process.env.T3_ACP_MCP_AUTHORIZATION ?? '')"],
2599 },
2600 { requestId: "test-unknown-terminal-create", method: "terminal/create" },
2601 );
2602 yield* waitForTerminalExit(
2603 {
2604 sessionId: "mock-child-session-without-credential-scope",
2605 terminalId: unknownTerminal.terminalId,
2606 },
2607 { requestId: "test-unknown-terminal-wait", method: "terminal/wait_for_exit" },
2608 );
2609 const unknownTerminalOutput = yield* readTerminalOutput(
2610 {
2611 sessionId: "mock-child-session-without-credential-scope",
2612 terminalId: unknownTerminal.terminalId,
2613 },
2614 { requestId: "test-unknown-terminal-output", method: "terminal/output" },
2615 );
2616 assert.equal(unknownTerminalOutput.output, "");
2617
2618 const terminal = yield* createTerminal(
2619 {
2620 sessionId: "mock-session-1-fork",
2621 command: process.execPath,
2622 args: ["-e", "process.stdout.write(process.env.T3_ACP_MCP_AUTHORIZATION ?? '')"],
2623 },
2624 { requestId: "test-terminal-create", method: "terminal/create" },
2625 );
2626 yield* waitForTerminalExit(
2627 {
2628 sessionId: "mock-session-1-fork",
2629 terminalId: terminal.terminalId,
2630 },
2631 { requestId: "test-terminal-wait", method: "terminal/wait_for_exit" },
2632 );
2633 const terminalOutput = yield* readTerminalOutput(
2634 {
2635 sessionId: "mock-session-1-fork",
2636 terminalId: terminal.terminalId,
2637 },
2638 { requestId: "test-terminal-output", method: "terminal/output" },
2639 );
2640 assert.equal(terminalOutput.output, "Bearer target-thread-token");
2641 }).pipe(Effect.provide(layerTest), Effect.scoped),
2642 );
2643
2644 it.effect(
2645 "answers fs requests method-not-found when the flavor does not opt into client fs",
2646 () =>
2647 Effect.gen(function* () {
2648 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
2649 const fileSystem = yield* FileSystem.FileSystem;
2650 const path = yield* Path.Path;
2651 const selfInvocation = yield* resolveSelfInvocation();
2652 const mockAgentPath = yield* path.fromFileUrl(
2653 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
2654 );
2655 const workspace = yield* fileSystem.makeTempDirectoryScoped({
2656 prefix: "t3-acp-no-client-fs-",
2657 });
2658 const probePath = path.join(workspace, "planted.txt");
2659 const probeLogPath = path.join(workspace, "fs-probe.jsonl");
2660 const protocolEvents = yield* Queue.unbounded<EffectAcpProtocol.AcpProtocolLogEvent>();
2661 const instanceId = ProviderInstanceId.make("acp-test-no-client-fs");
2662 const adapter = yield* makeAcpAdapterV2({
2663 instanceId,
2664 flavor: {
2665 driver: ACP_TEST_DRIVER,
2666 capabilities: AcpProviderCapabilitiesV2,
2667 makeRuntime: makeMockRuntime({
2668 childProcessSpawner,
2669 mockAgentPath,
2670 protocolEvents,
2671 environment: {
2672 T3_ACP_CLIENT_FS_PROBE_PATH: probePath,
2673 T3_ACP_CLIENT_FS_PROBE_LOG_PATH: probeLogPath,
2674 },
2675 }),
2676 },
2677 selfInvocation,
2678 });
2679 const threadId = ThreadId.make("thread-acp-no-client-fs");
2680 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
2681 runtimeMode: "full-access",
2682 interactionMode: "default",
2683 cwd: workspace,
2684 });
2685 const modelSelection = { instanceId, model: "default" } as const;
2686 const runtime = yield* adapter.openSession({
2687 threadId,
2688 providerSessionId: ProviderSessionId.make("provider-session-acp-no-client-fs"),
2689 modelSelection,
2690 runtimePolicy,
2691 });
2692 const providerThread = yield* runtime.ensureThread({
2693 threadId,
2694 modelSelection,
2695 runtimePolicy,
2696 });
2697 yield* runtime.startTurn(
2698 makeTurnInput({
2699 threadId,
2700 providerThread,
2701 instanceId,
2702 runtimePolicy,
2703 now: yield* DateTime.now,
2704 }),
2705 );
2706 yield* runtime.events.pipe(
2707 Stream.takeUntil((event) => event.type === "turn.terminal"),
2708 Stream.runDrain,
2709 );
2710
2711 const initialize = Option.getOrThrow(
2712 yield* Stream.fromQueue(protocolEvents).pipe(
2713 Stream.filter(
2714 (event) =>
2715 event.direction === "outgoing" && rawProtocolMethod(event) === "initialize",
2716 ),
2717 Stream.runHead,
2718 ),
2719 );
2720 assert.deepInclude(
2721 (rawProtocolRequest(initialize)?.params as { clientCapabilities?: unknown })
2722 ?.clientCapabilities,
2723 { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
2724 );
2725 const outcomes = (yield* fileSystem.readFileString(probeLogPath))
2726 .trim()
2727 .split("\n")
2728 .map((line) => Option.getOrThrow(decodeUnknownJson(line)));
2729 assert.deepEqual(outcomes, [
2730 { method: "fs/write_text_file", errorCode: -32601 },
2731 { method: "fs/read_text_file", errorCode: -32601 },
2732 ]);
2733 assert.isFalse(yield* fileSystem.exists(probePath));
2734 }).pipe(Effect.provide(layerTest), Effect.scoped),
2735 );
2736
2737 it.effect("does not turn an unknown permission approval into an execute grant", () =>
2738 Effect.gen(function* () {
2739 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
2740 const path = yield* Path.Path;
2741 const selfInvocation = yield* resolveSelfInvocation();
2742 const mockAgentPath = yield* path.fromFileUrl(
2743 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
2744 );
2745 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
2746 let requestPermission: Parameters<RuntimeService["handleRequestPermission"]>[0] | undefined;
2747 let createTerminal: Parameters<RuntimeService["handleCreateTerminal"]>[0] | undefined;
2748 let runtimeInput: AcpAdapterV2RuntimeInput | undefined;
2749 const makeRuntime = makeMockRuntime({
2750 childProcessSpawner,
2751 mockAgentPath,
2752 environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" },
2753 wrapRuntime: (runtime) => ({
2754 ...runtime,
2755 handleRequestPermission: (handler) =>
2756 Effect.sync(() => {
2757 requestPermission = handler;
2758 }).pipe(Effect.andThen(runtime.handleRequestPermission(handler))),
2759 handleCreateTerminal: (handler) =>
2760 Effect.sync(() => {
2761 createTerminal = handler;
2762 }).pipe(Effect.andThen(runtime.handleCreateTerminal(handler))),
2763 }),
2764 });
2765 const instanceId = ProviderInstanceId.make("acp-test-unknown-permission-grant");
2766 const adapter = yield* makeAcpAdapterV2({
2767 instanceId,
2768 flavor: {
2769 driver: ACP_TEST_DRIVER,
2770 capabilities: AcpProviderCapabilitiesV2,
2771 interruptPromptOnCancel: true,
2772 makeRuntime: (input) =>
2773 Effect.sync(() => {
2774 runtimeInput = input;
2775 }).pipe(Effect.andThen(makeRuntime(input))),
2776 },
2777 selfInvocation,
2778 clientTerminals: {},
2779 });
2780 const threadId = ThreadId.make("thread-acp-unknown-permission-grant");
2781 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
2782 runtimeMode: "approval-required",
2783 interactionMode: "default",
2784 cwd: process.cwd(),
2785 });
2786 const modelSelection = { instanceId, model: "default" } as const;
2787 const runtime = yield* adapter.openSession({
2788 threadId,
2789 providerSessionId: ProviderSessionId.make("provider-session-acp-unknown-permission-grant"),
2790 modelSelection,
2791 runtimePolicy,
2792 });
2793 const providerThread = yield* runtime.ensureThread({
2794 threadId,
2795 modelSelection,
2796 runtimePolicy,
2797 });
2798 const turnFiber = yield* runtime
2799 .startTurn(
2800 makeTurnInput({
2801 threadId,
2802 providerThread,
2803 instanceId,
2804 runtimePolicy,
2805 now: yield* DateTime.now,
2806 }),
2807 )
2808 .pipe(Effect.forkScoped);
2809 if (requestPermission === undefined || createTerminal === undefined) {
2810 return yield* Effect.die("ACP runtime must register permission and terminal handlers");
2811 }
2812
2813 const permissionFiber = yield* requestPermission(
2814 {
2815 sessionId: "mock-session-1",
2816 toolCall: {
2817 toolCallId: "unknown-permission-tool",
2818 title: "Unknown permission kind",
2819 },
2820 options: [{ optionId: "allow-once", name: "Allow once", kind: "allow_once" }],
2821 },
2822 { requestId: "unknown-permission-request", method: "session/request_permission" },
2823 ).pipe(Effect.forkScoped);
2824 const pending = Option.getOrThrow(
2825 yield* runtime.events.pipe(
2826 Stream.filter(
2827 (event) =>
2828 event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending",
2829 ),
2830 Stream.runHead,
2831 ),
2832 );
2833 if (
2834 pending.type !== "runtime_request.updated" ||
2835 pending.runtimeRequest.providerTurnId === null
2836 ) {
2837 return yield* Effect.die("Expected an unknown ACP permission request");
2838 }
2839 const responseFiber = yield* runtime
2840 .respondToRuntimeRequest({ requestId: pending.runtimeRequest.id, decision: "accept" })
2841 .pipe(Effect.forkScoped);
2842 assert.equal((yield* Fiber.join(permissionFiber)).outcome.outcome, "selected");
2843 if (runtimeInput?.onOutgoingResponse === undefined) {
2844 return yield* Effect.die("ACP runtime must expose native response acknowledgements");
2845 }
2846 yield* runtimeInput.onOutgoingResponse("unknown-permission-request");
2847 yield* Fiber.join(responseFiber);
2848
2849 const terminalExit = yield* createTerminal(
2850 {
2851 sessionId: "mock-session-1",
2852 command: process.execPath,
2853 args: ["-e", "process.stdout.write('unexpected')"],
2854 },
2855 { requestId: "unknown-permission-terminal", method: "terminal/create" },
2856 ).pipe(Effect.exit);
2857 assert.isTrue(Exit.isFailure(terminalExit));
2858
2859 yield* runtime.interruptTurn({
2860 providerThread,
2861 providerTurnId: pending.runtimeRequest.providerTurnId,
2862 });
2863 yield* Fiber.join(turnFiber);
2864 }).pipe(Effect.provide(layerTest), Effect.scoped),
2865 );
2866
2867 it.effect("fails missing native ACP session ids through the typed start-turn error channel", () =>
2868 Effect.gen(function* () {
2869 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
2870 const path = yield* Path.Path;
2871 const selfInvocation = yield* resolveSelfInvocation();
2872 const mockAgentPath = yield* path.fromFileUrl(
2873 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
2874 );
2875 const instanceId = ProviderInstanceId.make("acp-test-missing-native-thread");
2876 const adapter = yield* makeAcpAdapterV2({
2877 instanceId,
2878 flavor: {
2879 driver: ACP_TEST_DRIVER,
2880 capabilities: AcpProviderCapabilitiesV2,
2881 makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath }),
2882 },
2883 selfInvocation,
2884 });
2885 const threadId = ThreadId.make("thread-acp-missing-native-thread");
2886 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
2887 runtimeMode: "full-access",
2888 interactionMode: "default",
2889 cwd: process.cwd(),
2890 });
2891 const modelSelection = { instanceId, model: "default" } as const;
2892 const runtime = yield* adapter.openSession({
2893 threadId,
2894 providerSessionId: ProviderSessionId.make("provider-session-acp-missing-native-thread"),
2895 modelSelection,
2896 runtimePolicy,
2897 });
2898 const providerThread = yield* runtime.ensureThread({
2899 threadId,
2900 modelSelection,
2901 runtimePolicy,
2902 });
2903 const now = yield* DateTime.now;
2904 const error = yield* runtime
2905 .startTurn(
2906 makeTurnInput({
2907 threadId,
2908 providerThread: { ...providerThread, nativeThreadRef: null },
2909 instanceId,
2910 runtimePolicy,
2911 now,
2912 }),
2913 )
2914 .pipe(Effect.flip);
2915
2916 assert.equal(error._tag, "ProviderAdapterTurnStartError");
2917 assert.instanceOf(error.cause, ProviderAdapter.ProviderAdapterProtocolError);
2918 assert.include(String(error.cause), "missing its ACP session id");
2919 }).pipe(Effect.provide(layerTest), Effect.scoped),
2920 );
2921
2922 it.effect("replaces the ACP session and clears conversation state on rollback", () =>
2923 Effect.gen(function* () {
2924 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
2925 const idAllocator = yield* IdAllocator.IdAllocatorV2;
2926 const path = yield* Path.Path;
2927 const selfInvocation = yield* resolveSelfInvocation();
2928 const mockAgentPath = yield* path.fromFileUrl(
2929 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
2930 );
2931 const instanceId = ProviderInstanceId.make("acp-test-rollback-session");
2932 const rollbackThreadId = ThreadId.make("thread-acp-rollback-session-target");
2933 yield* (yield* McpProviderSessions.McpProviderSessions).set({
2934 environmentId: EnvironmentId.make("environment-acp-rollback-session-target"),
2935 threadId: rollbackThreadId,
2936 providerSessionId: "mcp-session-acp-rollback-session-target",
2937 providerInstanceId: instanceId,
2938 endpoint: "http://127.0.0.1:43124/mcp",
2939 authorizationHeader: "Bearer rollback-target-token",
2940 browserToolsAvailable: false,
2941 });
2942 const runtimeInputs: Array<AcpAdapterV2RuntimeInput> = [];
2943 const makeRuntime = makeMockRuntime({
2944 childProcessSpawner,
2945 mockAgentPath,
2946 environment: { T3_ACP_PROMPT_DELAY_MS: "100" },
2947 });
2948 const adapter = yield* makeAcpAdapterV2({
2949 instanceId,
2950 flavor: {
2951 driver: ACP_TEST_DRIVER,
2952 capabilities: AcpProviderCapabilitiesV2,
2953 makeRuntime: (runtimeInput) =>
2954 Effect.sync(() => runtimeInputs.push(runtimeInput)).pipe(
2955 Effect.andThen(makeRuntime(runtimeInput)),
2956 ),
2957 },
2958 selfInvocation,
2959 });
2960 const threadId = ThreadId.make("thread-acp-rollback-session");
2961 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
2962 runtimeMode: "full-access",
2963 interactionMode: "default",
2964 cwd: process.cwd(),
2965 });
2966 const modelSelection = { instanceId, model: "default" } as const;
2967 const runtime = yield* adapter.openSession({
2968 threadId,
2969 providerSessionId: ProviderSessionId.make("provider-session-acp-rollback-session"),
2970 modelSelection,
2971 runtimePolicy,
2972 });
2973 assert.isTrue(runtime.providerSession.capabilities.threads.canRollbackThread);
2974 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
2975 yield* runtime.events.pipe(
2976 Stream.runForEach((event) => Queue.offer(events, event)),
2977 Effect.forkScoped,
2978 );
2979 const providerThread = yield* runtime.ensureThread({
2980 threadId,
2981 modelSelection,
2982 runtimePolicy,
2983 });
2984 const firstSessionId = providerThread.nativeThreadRef?.nativeId;
2985 yield* runtime.startTurn(
2986 makeTurnInput({
2987 threadId,
2988 providerThread,
2989 instanceId,
2990 runtimePolicy,
2991 now: yield* DateTime.now,
2992 }),
2993 );
2994 const activeRollback = yield* runtime
2995 .rollbackThread({
2996 providerThread,
2997 providerThreadTurns: [],
2998 target: {
2999 type: "thread_start",
3000 checkpointId: CheckpointId.make("checkpoint-acp-active-rollback"),
3001 appRunOrdinal: 0,
3002 },
3003 })
3004 .pipe(Effect.result);
3005 assert.equal(activeRollback._tag, "Failure");
3006 if (activeRollback._tag === "Failure") {
3007 assert.include(String(activeRollback.failure.cause), "while turn");
3008 }
3009 const firstProviderTurnId = idAllocator.derive.providerTurn({
3010 driver: ACP_TEST_DRIVER,
3011 nativeTurnId: acpScopedNativeId(instanceId, `${firstSessionId}:turn:1`),
3012 });
3013 while (true) {
3014 const event = yield* Queue.take(events);
3015 if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) break;
3016 }
3017 const snapshotBeforeRollback = yield* runtime.readThreadSnapshot({ providerThread });
3018
3019 const rolledBack = yield* runtime.rollbackThread({
3020 providerThread: { ...providerThread, appThreadId: rollbackThreadId },
3021 providerThreadTurns: snapshotBeforeRollback.providerTurns,
3022 target: {
3023 type: "thread_start",
3024 checkpointId: CheckpointId.make("checkpoint-acp-rollback-session"),
3025 appRunOrdinal: 0,
3026 },
3027 });
3028 assert.isString(rolledBack.providerThread.nativeThreadRef?.nativeId);
3029 assert.deepEqual(rolledBack.providerTurns, []);
3030 assert.equal(
3031 runtimeInputs[1]?.processEnvironment?.T3_ACP_MCP_AUTHORIZATION,
3032 "Bearer rollback-target-token",
3033 );
3034 const replacementMcpServer = runtimeInputs[1]?.mcpServers[0];
3035 const replacementMcpEnvironment =
3036 replacementMcpServer !== undefined &&
3037 "env" in replacementMcpServer &&
3038 Array.isArray(replacementMcpServer.env)
3039 ? replacementMcpServer.env
3040 : undefined;
3041 assert.equal(
3042 replacementMcpEnvironment?.find((variable) => variable.name === "T3_ACP_MCP_AUTHORIZATION")
3043 ?.value,
3044 "Bearer rollback-target-token",
3045 );
3046 const snapshotAfterRollback = yield* runtime.readThreadSnapshot({
3047 providerThread: rolledBack.providerThread,
3048 });
3049 assert.deepEqual(snapshotAfterRollback.providerTurns, []);
3050 assert.deepEqual(snapshotAfterRollback.messages, []);
3051
3052 yield* runtime.startTurn(
3053 makeTurnInput({
3054 threadId: rollbackThreadId,
3055 providerThread: rolledBack.providerThread,
3056 instanceId,
3057 runtimePolicy,
3058 now: yield* DateTime.now,
3059 ordinal: 2,
3060 }),
3061 );
3062 const secondProviderTurnId = idAllocator.derive.providerTurn({
3063 driver: ACP_TEST_DRIVER,
3064 nativeTurnId: acpScopedNativeId(
3065 instanceId,
3066 `${rolledBack.providerThread.nativeThreadRef?.nativeId}:turn:2`,
3067 ),
3068 });
3069 let secondStatus: string | null = null;
3070 while (secondStatus === null) {
3071 const event = yield* Queue.take(events);
3072 if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) {
3073 secondStatus = event.status;
3074 }
3075 }
3076 assert.equal(secondStatus, "completed");
3077 }).pipe(Effect.provide(layerTest), Effect.scoped),
3078 );
3079
3080 it.effect("quarantines callbacks from a failed rollback replacement before retrying", () =>
3081 Effect.gen(function* () {
3082 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
3083 const path = yield* Path.Path;
3084 const selfInvocation = yield* resolveSelfInvocation();
3085 const mockAgentPath = yield* path.fromFileUrl(
3086 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
3087 );
3088 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
3089 const sessionUpdateHandlers: Array<
3090 Parameters<RuntimeService["handleSessionUpdate"]>[0] | undefined
3091 > = [];
3092 const backgroundMutationHandlers: Array<
3093 AcpAdapterV2ExtensionContext["applyBackgroundTaskMutation"] | undefined
3094 > = [];
3095 const availableCommandUpdates: Array<ReadonlyArray<EffectAcpSchema.AvailableCommand>> = [];
3096 let registeredExtensionOrdinal = 0;
3097 let runtimeOrdinalSeen = 0;
3098 const makeRuntime = makeMockRuntime({
3099 childProcessSpawner,
3100 mockAgentPath,
3101 wrapRuntime: (runtime, runtimeOrdinal) => {
3102 runtimeOrdinalSeen = runtimeOrdinal;
3103 return {
3104 ...runtime,
3105 handleSessionUpdate: (handler) =>
3106 Effect.sync(() => {
3107 sessionUpdateHandlers[runtimeOrdinal - 1] = handler;
3108 }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))),
3109 ...(runtimeOrdinal === 2
3110 ? {
3111 start: () =>
3112 Effect.fail(
3113 new EffectAcpErrors.AcpTransportError({
3114 detail: "Forced first rollback replacement failure",
3115 cause: "test",
3116 }),
3117 ),
3118 }
3119 : runtimeOrdinal === 3
3120 ? {
3121 start: () =>
3122 Effect.suspend(() => {
3123 const stagedSessionUpdate = sessionUpdateHandlers[2];
3124 if (stagedSessionUpdate === undefined) {
3125 return Effect.die("replacement startup must buffer session updates");
3126 }
3127 return stagedSessionUpdate({
3128 sessionId: "mock-session-3",
3129 update: {
3130 sessionUpdate: "available_commands_update",
3131 availableCommands: [
3132 {
3133 name: "staged-command",
3134 description: "published while the replacement starts",
3135 },
3136 ],
3137 },
3138 }).pipe(Effect.andThen(runtime.start()));
3139 }),
3140 }
3141 : {}),
3142 };
3143 },
3144 });
3145 const instanceId = ProviderInstanceId.make("acp-test-rollback-retry-generation");
3146 const adapter = yield* makeAcpAdapterV2({
3147 instanceId,
3148 flavor: {
3149 driver: ACP_TEST_DRIVER,
3150 capabilities: AcpProviderCapabilitiesV2,
3151 enablePostSettleContinuation: true,
3152 onAvailableCommandsUpdate: (commands) =>
3153 Effect.sync(() => {
3154 availableCommandUpdates.push(commands);
3155 }),
3156 registerExtensions: (context) =>
3157 Effect.sync(() => {
3158 backgroundMutationHandlers[registeredExtensionOrdinal] =
3159 context.applyBackgroundTaskMutation;
3160 registeredExtensionOrdinal += 1;
3161 }),
3162 makeRuntime,
3163 },
3164 selfInvocation,
3165 continuationRequests: { offer: () => Effect.void },
3166 });
3167 const threadId = ThreadId.make("thread-acp-rollback-retry-generation");
3168 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
3169 runtimeMode: "full-access",
3170 interactionMode: "default",
3171 cwd: process.cwd(),
3172 });
3173 const modelSelection = { instanceId, model: "default" } as const;
3174 const runtime = yield* adapter.openSession({
3175 threadId,
3176 providerSessionId: ProviderSessionId.make("provider-session-acp-rollback-retry-generation"),
3177 modelSelection,
3178 runtimePolicy,
3179 });
3180 if (runtime.hasPendingBackgroundWork === undefined) {
3181 return yield* Effect.die("ACP runtime must expose background work state");
3182 }
3183 const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork;
3184 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
3185 yield* runtime.events.pipe(
3186 Stream.runForEach((event) => Queue.offer(events, event)),
3187 Effect.forkScoped,
3188 );
3189 const providerThread = yield* runtime.ensureThread({
3190 threadId,
3191 modelSelection,
3192 runtimePolicy,
3193 });
3194
3195 yield* runtime.rollbackThread({
3196 providerThread,
3197 providerThreadTurns: [],
3198 target: {
3199 type: "thread_start",
3200 checkpointId: CheckpointId.make("checkpoint-acp-rollback-retry-generation"),
3201 appRunOrdinal: 0,
3202 },
3203 });
3204
3205 assert.equal(runtimeOrdinalSeen, 3);
3206 assert.deepEqual(
3207 availableCommandUpdates.map((commands) => commands.map((command) => command.name)),
3208 [["staged-command"]],
3209 );
3210 const failedReplacementHandler = sessionUpdateHandlers[1];
3211 const failedBackgroundMutationHandler = backgroundMutationHandlers[1];
3212 assert.isDefined(failedReplacementHandler);
3213 assert.isDefined(failedBackgroundMutationHandler);
3214 while (Option.isSome(yield* Queue.poll(events))) {
3215 // Discard setup events before exercising the stale callback.
3216 }
3217 yield* failedReplacementHandler!({
3218 sessionId: "failed-replacement-session",
3219 update: {
3220 sessionUpdate: "agent_message_chunk",
3221 content: { type: "text", text: "stale failed replacement callback" },
3222 },
3223 });
3224 assert.isTrue(Option.isNone(yield* Queue.poll(events)));
3225 yield* failedBackgroundMutationHandler!({
3226 sessionId: "mock-session-3",
3227 taskId: "stale-failed-replacement-task",
3228 status: "running",
3229 });
3230 assert.isFalse(yield* hasPendingBackgroundWork);
3231 }).pipe(Effect.provide(layerTest), Effect.scoped),
3232 );
3233
3234 it.effect("keeps the original ACP session usable when a staged replacement terminates", () =>
3235 Effect.gen(function* () {
3236 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
3237 const idAllocator = yield* IdAllocator.IdAllocatorV2;
3238 const path = yield* Path.Path;
3239 const selfInvocation = yield* resolveSelfInvocation();
3240 const mockAgentPath = yield* path.fromFileUrl(
3241 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
3242 );
3243 let runtimeOrdinal = 0;
3244 const makeBaseRuntime = makeMockRuntime({
3245 childProcessSpawner,
3246 mockAgentPath,
3247 });
3248 const makeRuntime: AcpAdapterV2Flavor["makeRuntime"] = (runtimeInput) =>
3249 Effect.gen(function* () {
3250 const currentOrdinal = runtimeOrdinal + 1;
3251 runtimeOrdinal = currentOrdinal;
3252 const currentRuntime = yield* makeBaseRuntime(runtimeInput);
3253 if (currentOrdinal === 1) return currentRuntime;
3254 return {
3255 ...currentRuntime,
3256 start: () =>
3257 currentRuntime.start().pipe(
3258 Effect.tap(() =>
3259 runtimeInput.onTermination(
3260 new EffectAcpErrors.AcpTransportError({
3261 detail: "Forced staged rollback replacement termination",
3262 cause: "test",
3263 }),
3264 ),
3265 ),
3266 ),
3267 };
3268 });
3269 const instanceId = ProviderInstanceId.make("acp-test-rollback-failure-compensation");
3270 const adapter = yield* makeAcpAdapterV2({
3271 instanceId,
3272 flavor: {
3273 driver: ACP_TEST_DRIVER,
3274 capabilities: AcpProviderCapabilitiesV2,
3275 makeRuntime,
3276 },
3277 selfInvocation,
3278 });
3279 const threadId = ThreadId.make("thread-acp-rollback-failure-compensation");
3280 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
3281 runtimeMode: "full-access",
3282 interactionMode: "default",
3283 cwd: process.cwd(),
3284 });
3285 const modelSelection = { instanceId, model: "default" } as const;
3286 const runtime = yield* adapter.openSession({
3287 threadId,
3288 providerSessionId: ProviderSessionId.make(
3289 "provider-session-acp-rollback-failure-compensation",
3290 ),
3291 modelSelection,
3292 runtimePolicy,
3293 });
3294 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
3295 yield* runtime.events.pipe(
3296 Stream.runForEach((event) => Queue.offer(events, event)),
3297 Effect.forkScoped,
3298 );
3299 const providerThread = yield* runtime.ensureThread({
3300 threadId,
3301 modelSelection,
3302 runtimePolicy,
3303 });
3304
3305 const rollback = yield* runtime
3306 .rollbackThread({
3307 providerThread,
3308 providerThreadTurns: [],
3309 target: {
3310 type: "thread_start",
3311 checkpointId: CheckpointId.make("checkpoint-acp-rollback-failure-compensation"),
3312 appRunOrdinal: 0,
3313 },
3314 })
3315 .pipe(Effect.result);
3316 assert.equal(rollback._tag, "Failure");
3317
3318 yield* runtime.startTurn(
3319 makeTurnInput({
3320 threadId,
3321 providerThread,
3322 instanceId,
3323 runtimePolicy,
3324 now: yield* DateTime.now,
3325 }),
3326 );
3327 const providerTurnId = idAllocator.derive.providerTurn({
3328 driver: ACP_TEST_DRIVER,
3329 nativeTurnId: acpScopedNativeId(
3330 instanceId,
3331 `${providerThread.nativeThreadRef?.nativeId}:turn:1`,
3332 ),
3333 });
3334 while (true) {
3335 const event = yield* Queue.take(events);
3336 if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) {
3337 assert.equal(event.status, "completed");
3338 break;
3339 }
3340 }
3341 }).pipe(Effect.provide(layerTest), Effect.scoped),
3342 );
3343
3344 it.effect("closes an idle ACP session exactly once through the transition permit", () =>
3345 Effect.gen(function* () {
3346 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
3347 const path = yield* Path.Path;
3348 const selfInvocation = yield* resolveSelfInvocation();
3349 const mockAgentPath = yield* path.fromFileUrl(
3350 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
3351 );
3352 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
3353 const instanceId = ProviderInstanceId.make("acp-test");
3354 const adapter = yield* makeAcpAdapterV2({
3355 instanceId,
3356 flavor: {
3357 driver: ACP_TEST_DRIVER,
3358 capabilities: AcpProviderCapabilitiesV2,
3359 makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, protocolEvents }),
3360 },
3361 selfInvocation,
3362 });
3363 const threadId = ThreadId.make("thread-acp-idle-finalizer");
3364 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
3365 runtimeMode: "full-access",
3366 interactionMode: "default",
3367 cwd: process.cwd(),
3368 });
3369 const modelSelection = { instanceId, model: "default" } as const;
3370 const sessionScope = yield* Scope.make();
3371 yield* adapter
3372 .openSession({
3373 threadId,
3374 providerSessionId: ProviderSessionId.make("provider-session-acp-idle-finalizer"),
3375 modelSelection,
3376 runtimePolicy,
3377 })
3378 .pipe(Effect.provideService(Scope.Scope, sessionScope));
3379 yield* pollProtocolMethods(protocolEvents);
3380 yield* Scope.close(sessionScope, Exit.void);
3381 const finalizerMethods = yield* pollProtocolMethods(protocolEvents);
3382 assert.equal(finalizerMethods.filter((method) => method === "session/close").length, 1);
3383 }).pipe(Effect.provide(layerTest)),
3384 );
3385
3386 it.effect.each(["grok-build", "composer-2"])(
3387 "Grok configures the native session for %s",
3388 (model) =>
3389 Effect.gen(function* () {
3390 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
3391 const path = yield* Path.Path;
3392 const selfInvocation = yield* resolveSelfInvocation();
3393 const mockAgentPath = yield* path.fromFileUrl(
3394 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
3395 );
3396 const protocolEvents = yield* Queue.unbounded<EffectAcpProtocol.AcpProtocolLogEvent>();
3397 const instanceId = ProviderInstanceId.make("grok-test");
3398 const adapter = yield* makeGrokAdapterV2({
3399 instanceId,
3400 settings: DEFAULT_GROK_SETTINGS,
3401 environment: {},
3402 hostPlatform: yield* HostProcess.Platform,
3403 selfInvocation: yield* resolveSelfInvocation(),
3404 // Production Grok runtimes are wrapped by the x.ai prompt runtime.
3405 makeRuntime: (input) =>
3406 makeMockRuntime({ childProcessSpawner, mockAgentPath, protocolEvents })(input).pipe(
3407 Effect.flatMap(makeXAiPromptCompletionRuntime),
3408 ),
3409 });
3410 yield* adapter.openSession({
3411 threadId: ThreadId.make(`grok-model-${model}`),
3412 providerSessionId: ProviderSessionId.make(`grok-model-${model}`),
3413 modelSelection: { instanceId, model },
3414 runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
3415 runtimeMode: "full-access",
3416 interactionMode: "default",
3417 cwd: process.cwd(),
3418 }),
3419 });
3420 const requests = (yield* Queue.takeAll(protocolEvents)).map(rawProtocolRequest);
3421 assert.equal(
3422 requests.filter(
3423 (request) =>
3424 request?.method === "session/set_config_option" &&
3425 typeof request.params === "object" &&
3426 request.params !== null &&
3427 "configId" in request.params &&
3428 request.params.configId === "model",
3429 ).length,
3430 model === "grok-build" ? 0 : 1,
3431 );
3432 }).pipe(Effect.provide(layerTest), Effect.scoped),
3433 );
3434
3435 it.live("Grok reapplies an explicit return to the session's setup-time model", () =>
3436 Effect.gen(function* () {
3437 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
3438 const path = yield* Path.Path;
3439 const selfInvocation = yield* resolveSelfInvocation();
3440 const mockAgentPath = yield* path.fromFileUrl(
3441 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
3442 );
3443 const protocolEvents = yield* Queue.unbounded<EffectAcpProtocol.AcpProtocolLogEvent>();
3444 const instanceId = ProviderInstanceId.make("grok-test-switch-back");
3445 const adapter = yield* makeGrokAdapterV2({
3446 instanceId,
3447 settings: DEFAULT_GROK_SETTINGS,
3448 environment: {},
3449 hostPlatform: yield* HostProcess.Platform,
3450 selfInvocation: yield* resolveSelfInvocation(),
3451 // Production Grok runtimes are wrapped by the x.ai prompt runtime.
3452 makeRuntime: (input) =>
3453 makeMockRuntime({ childProcessSpawner, mockAgentPath, protocolEvents })(input).pipe(
3454 Effect.flatMap(makeXAiPromptCompletionRuntime),
3455 ),
3456 });
3457 const threadId = ThreadId.make("grok-model-switch-back");
3458 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
3459 runtimeMode: "full-access",
3460 interactionMode: "default",
3461 cwd: process.cwd(),
3462 });
3463 const runtime = yield* adapter.openSession({
3464 threadId,
3465 providerSessionId: ProviderSessionId.make("grok-model-switch-back"),
3466 modelSelection: { instanceId, model: "composer-2" },
3467 runtimePolicy,
3468 });
3469 const providerThread = yield* runtime.ensureThread({
3470 threadId,
3471 modelSelection: { instanceId, model: "composer-2" },
3472 runtimePolicy,
3473 });
3474 // The mock session starts on default. Switching away and explicitly
3475 // back must send the model configuration change; stale metadata can make
3476 // the return trip a silent no-op that left the session on the alt model.
3477 for (const model of ["default", "composer-2"]) {
3478 yield* runtime.startTurn(
3479 makeTurnInput({
3480 threadId,
3481 providerThread,
3482 instanceId,
3483 runtimePolicy,
3484 now: yield* DateTime.now,
3485 modelSelection: { instanceId, model },
3486 }),
3487 );
3488 yield* runtime.events.pipe(
3489 Stream.filter((event) => event.type === "turn.terminal"),
3490 Stream.runHead,
3491 );
3492 }
3493 const requests = (yield* Queue.takeAll(protocolEvents)).map(rawProtocolRequest);
3494 assert.equal(
3495 requests.filter(
3496 (request) =>
3497 request?.method === "session/set_config_option" &&
3498 typeof request.params === "object" &&
3499 request.params !== null &&
3500 "configId" in request.params &&
3501 request.params.configId === "model",
3502 ).length,
3503 3,
3504 );
3505 }).pipe(Effect.provide(layerTest), Effect.scoped),
3506 );
3507
3508 it.effect("skips requested options that the active ACP session does not expose", () =>
3509 Effect.gen(function* () {
3510 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
3511 const path = yield* Path.Path;
3512 const selfInvocation = yield* resolveSelfInvocation();
3513 const mockAgentPath = yield* path.fromFileUrl(
3514 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
3515 );
3516 const instanceId = ProviderInstanceId.make("acp-test");
3517 const adapter = yield* makeAcpAdapterV2({
3518 instanceId,
3519 flavor: {
3520 driver: ACP_TEST_DRIVER,
3521 capabilities: AcpProviderCapabilitiesV2,
3522 makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath }),
3523 },
3524 selfInvocation,
3525 });
3526 const threadId = ThreadId.make("thread-acp-unsupported-option");
3527 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
3528 runtimeMode: "full-access",
3529 interactionMode: "default",
3530 cwd: process.cwd(),
3531 });
3532 // A stale composer selection (probe-time union descriptor) must not wedge
3533 // the session open in a retry loop; the option is skipped and the agent
3534 // default applies.
3535 const runtime = yield* adapter.openSession({
3536 threadId,
3537 providerSessionId: ProviderSessionId.make("provider-session-acp-unsupported-option"),
3538 modelSelection: {
3539 instanceId,
3540 model: "default",
3541 options: [{ id: "missing-option", value: "high" }],
3542 },
3543 runtimePolicy,
3544 });
3545
3546 assert.equal(runtime.providerSession.status, "ready");
3547 }).pipe(Effect.provide(layerTest), Effect.scoped),
3548 );
3549
3550 it.effect("reconfigures a loaded ACP session from its own active setup metadata", () =>
3551 Effect.gen(function* () {
3552 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
3553 const path = yield* Path.Path;
3554 const selfInvocation = yield* resolveSelfInvocation();
3555 const mockAgentPath = yield* path.fromFileUrl(
3556 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
3557 );
3558 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
3559 const instanceId = ProviderInstanceId.make("acp-test");
3560 const adapter = yield* makeAcpAdapterV2({
3561 instanceId,
3562 flavor: {
3563 driver: ACP_TEST_DRIVER,
3564 capabilities: AcpProviderCapabilitiesV2,
3565 makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, protocolEvents }),
3566 },
3567 selfInvocation,
3568 });
3569 const firstThreadId = ThreadId.make("thread-acp-active-setup:first");
3570 const secondThreadId = ThreadId.make("thread-acp-active-setup:second");
3571 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
3572 runtimeMode: "full-access",
3573 interactionMode: "default",
3574 cwd: process.cwd(),
3575 });
3576 const initialSelection = { instanceId, model: "default" } satisfies ModelSelection;
3577 const alternateSelection = {
3578 instanceId,
3579 model: "composer-2",
3580 } satisfies ModelSelection;
3581 const originalSelection = {
3582 instanceId,
3583 model: "gpt-5.3-codex[reasoning=medium,fast=false]",
3584 } satisfies ModelSelection;
3585 const runtime = yield* adapter.openSession({
3586 threadId: firstThreadId,
3587 providerSessionId: ProviderSessionId.make("provider-session-acp-active-setup"),
3588 modelSelection: initialSelection,
3589 runtimePolicy,
3590 });
3591 const firstProviderThread = yield* runtime.ensureThread({
3592 threadId: firstThreadId,
3593 modelSelection: initialSelection,
3594 runtimePolicy,
3595 });
3596 const now = yield* DateTime.now;
3597 yield* runtime.startTurn(
3598 makeTurnInput({
3599 threadId: firstThreadId,
3600 providerThread: firstProviderThread,
3601 instanceId,
3602 runtimePolicy,
3603 modelSelection: alternateSelection,
3604 now,
3605 }),
3606 );
3607 yield* runtime.events.pipe(
3608 Stream.filter((event) => event.type === "turn.terminal"),
3609 Stream.runHead,
3610 );
3611
3612 const secondProviderThread: OrchestrationV2ProviderThread = {
3613 ...firstProviderThread,
3614 id: ProviderThreadId.make("provider-thread-acp-active-setup:second"),
3615 appThreadId: secondThreadId,
3616 nativeThreadRef: {
3617 driver: ACP_TEST_DRIVER,
3618 nativeId: "mock-session-2",
3619 strength: "strong",
3620 },
3621 status: "idle",
3622 };
3623 yield* runtime.resumeThread({
3624 providerThread: secondProviderThread,
3625 modelSelection: alternateSelection,
3626 runtimePolicy,
3627 });
3628 yield* runtime.startTurn(
3629 makeTurnInput({
3630 threadId: secondThreadId,
3631 providerThread: secondProviderThread,
3632 instanceId,
3633 runtimePolicy,
3634 modelSelection: originalSelection,
3635 now,
3636 ordinal: 2,
3637 }),
3638 );
3639 yield* runtime.events.pipe(
3640 Stream.filter((event) => event.type === "turn.terminal"),
3641 Stream.runHead,
3642 );
3643
3644 const modelConfigurationRequests = Array.from(yield* Queue.takeAll(protocolEvents)).filter(
3645 (event) =>
3646 event.direction === "outgoing" &&
3647 rawProtocolMethod(event) === "session/set_config_option" &&
3648 rawProtocolRequestParam(event, "configId") === "model",
3649 );
3650 assert.lengthOf(modelConfigurationRequests, 2);
3651 }).pipe(Effect.provide(layerTest), Effect.scoped),
3652 );
3653
3654 it.effect("keeps an agent's empty-valued default choice selectable", () =>
3655 Effect.gen(function* () {
3656 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
3657 const path = yield* Path.Path;
3658 const selfInvocation = yield* resolveSelfInvocation();
3659 const mockAgentPath = yield* path.fromFileUrl(
3660 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
3661 );
3662 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
3663 const instanceId = ProviderInstanceId.make("acp-test");
3664 let agentDescriptor: ProviderOptionDescriptor | undefined;
3665 const adapter = yield* makeAcpAdapterV2({
3666 instanceId,
3667 flavor: {
3668 driver: ACP_TEST_DRIVER,
3669 capabilities: AcpProviderCapabilitiesV2,
3670 makeRuntime: makeMockRuntime({
3671 childProcessSpawner,
3672 mockAgentPath,
3673 environment: { T3_ACP_EMIT_EMPTY_DEFAULT_AGENT_OPTION: "1" },
3674 protocolEvents,
3675 }),
3676 onSessionConfigurationUpdate: (configOptions, modeState) =>
3677 Effect.sync(() => {
3678 agentDescriptor = acpProviderOptionDescriptors({ configOptions, modeState }).find(
3679 (descriptor) => descriptor.id === "agent",
3680 );
3681 }),
3682 },
3683 selfInvocation,
3684 });
3685 const threadId = ThreadId.make("thread-acp-empty-default-agent");
3686 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
3687 runtimeMode: "full-access",
3688 interactionMode: "default",
3689 cwd: process.cwd(),
3690 });
3691 const customAgentSelection = {
3692 instanceId,
3693 model: "default",
3694 options: [{ id: "agent", value: "example" }],
3695 } satisfies ModelSelection;
3696 const runtime = yield* adapter.openSession({
3697 threadId,
3698 providerSessionId: ProviderSessionId.make("provider-session-acp-empty-default-agent"),
3699 modelSelection: customAgentSelection,
3700 runtimePolicy,
3701 });
3702 const providerThread = yield* runtime.ensureThread({
3703 threadId,
3704 modelSelection: customAgentSelection,
3705 runtimePolicy,
3706 });
3707 assert.deepEqual(agentDescriptor, {
3708 id: "agent",
3709 label: "Agent",
3710 type: "select",
3711 options: [
3712 { id: ACP_EMPTY_OPTION_VALUE, label: "Copilot" },
3713 { id: "example", label: "example" },
3714 ],
3715 currentValue: "example",
3716 });
3717
3718 yield* runtime.startTurn(
3719 makeTurnInput({
3720 threadId,
3721 providerThread,
3722 instanceId,
3723 runtimePolicy,
3724 modelSelection: {
3725 instanceId,
3726 model: "default",
3727 options: [{ id: "agent", value: ACP_EMPTY_OPTION_VALUE }],
3728 },
3729 now: yield* DateTime.now,
3730 }),
3731 );
3732 yield* runtime.events.pipe(
3733 Stream.filter((event) => event.type === "turn.terminal"),
3734 Stream.runHead,
3735 );
3736
3737 assert.equal(
3738 agentDescriptor?.type === "select" ? agentDescriptor.currentValue : undefined,
3739 ACP_EMPTY_OPTION_VALUE,
3740 );
3741 const sentAgentValues = Array.from(yield* Queue.takeAll(protocolEvents))
3742 .filter(
3743 (event) =>
3744 event.direction === "outgoing" &&
3745 rawProtocolMethod(event) === "session/set_config_option" &&
3746 rawProtocolRequestParam(event, "configId") === "agent",
3747 )
3748 .map((event) => rawProtocolRequestParam(event, "value"));
3749 assert.deepEqual(sentAgentValues, ["example", ""]);
3750 }).pipe(Effect.provide(layerTest), Effect.scoped),
3751 );
3752
3753 it.live("terminalizes an empty successful foreground Bash tool when the turn completes", () =>
3754 Effect.gen(function* () {
3755 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
3756 const idAllocator = yield* IdAllocator.IdAllocatorV2;
3757 const path = yield* Path.Path;
3758 const selfInvocation = yield* resolveSelfInvocation();
3759 const mockAgentPath = yield* path.fromFileUrl(
3760 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
3761 );
3762 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
3763 const instanceId = ProviderInstanceId.make("acp-test");
3764 const adapter = yield* makeAcpAdapterV2({
3765 instanceId,
3766 flavor: {
3767 driver: ACP_TEST_DRIVER,
3768 capabilities: AcpProviderCapabilitiesV2,
3769 makeRuntime: makeMockRuntime({
3770 childProcessSpawner,
3771 mockAgentPath,
3772 environment: (runtimeOrdinal) =>
3773 runtimeOrdinal === 1 ? { T3_ACP_EMIT_EMPTY_SUCCESSFUL_BASH_THEN_HANG: "1" } : {},
3774 ownDetachedProcessGroup: true,
3775 protocolEvents,
3776 }),
3777 normalizeToolCall: normalizeXAiAcpToolCallState,
3778 restartRuntimeAfterInterrupt: true,
3779 terminateRuntimeProcessGroupOnInterrupt: true,
3780 },
3781 selfInvocation,
3782 });
3783 const threadId = ThreadId.make("thread-acp-empty-successful-bash");
3784 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
3785 runtimeMode: "full-access",
3786 interactionMode: "default",
3787 cwd: process.cwd(),
3788 });
3789 const modelSelection = { instanceId, model: "default" } as const;
3790 const runtime = yield* adapter.openSession({
3791 threadId,
3792 providerSessionId: ProviderSessionId.make("provider-session-acp-empty-successful-bash"),
3793 modelSelection,
3794 runtimePolicy,
3795 });
3796 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
3797 yield* runtime.events.pipe(
3798 Stream.runForEach((event) => Queue.offer(events, event)),
3799 Effect.forkScoped,
3800 );
3801 const providerThread = yield* runtime.ensureThread({
3802 threadId,
3803 modelSelection,
3804 runtimePolicy,
3805 });
3806 const now = yield* DateTime.now;
3807 yield* runtime.startTurn(
3808 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
3809 );
3810
3811 const statuses: string[] = [];
3812 let runningStartedAt: DateTime.Utc | null = null;
3813 let completedStartedAt: DateTime.Utc | null = null;
3814 let completedAt: DateTime.Utc | null = null;
3815 let completedInput: string | null = null;
3816 let completedOutput: string | null | undefined;
3817 let completedExitCode: number | null | undefined;
3818 let runningProjectedExitCode: number | undefined = undefined;
3819 let terminal = false;
3820 while (!terminal) {
3821 const event = yield* Queue.take(events);
3822 if (
3823 event.type === "turn_item.updated" &&
3824 event.turnItem.nativeItemRef?.nativeId === "tool-call-empty-success-1"
3825 ) {
3826 statuses.push(event.turnItem.status);
3827 if (event.turnItem.status === "running") {
3828 runningStartedAt ??= event.turnItem.startedAt;
3829 if (event.turnItem.type === "command_execution") {
3830 runningProjectedExitCode = event.turnItem.exitCode;
3831 }
3832 }
3833 if (event.turnItem.status === "completed") {
3834 completedStartedAt = event.turnItem.startedAt;
3835 completedAt = event.turnItem.completedAt;
3836 if (event.turnItem.type === "command_execution") {
3837 completedInput = event.turnItem.input;
3838 completedOutput = event.turnItem.output;
3839 completedExitCode = event.turnItem.exitCode;
3840 }
3841 }
3842 }
3843 if (event.type === "turn.terminal") terminal = true;
3844 }
3845
3846 assert.deepEqual(statuses, ["running", "running", "completed"]);
3847 assert.deepEqual(completedStartedAt, runningStartedAt);
3848 assert.isNotNull(completedAt);
3849 assert.equal(completedInput, "true");
3850 assert.equal(completedOutput, undefined);
3851 assert.equal(
3852 runningProjectedExitCode,
3853 undefined,
3854 "mid-stream exit_code must not project until the tool is terminal",
3855 );
3856 assert.equal(completedExitCode, 0);
3857
3858 yield* Queue.takeAll(protocolEvents);
3859 yield* runtime
3860 .startTurn(
3861 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }),
3862 )
3863 .pipe(Effect.forkScoped);
3864 yield* Stream.fromQueue(protocolEvents).pipe(
3865 Stream.filter(
3866 (event) =>
3867 event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt",
3868 ),
3869 Stream.runHead,
3870 );
3871 const secondProviderTurnId = idAllocator.derive.providerTurn({
3872 driver: ACP_TEST_DRIVER,
3873 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"),
3874 });
3875 yield* runtime.interruptTurn({
3876 providerThread,
3877 providerTurnId: secondProviderTurnId,
3878 requestRuntimeRestart: true,
3879 });
3880 yield* runtime.startTurn(
3881 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 3 }),
3882 );
3883 const loadAfterRestart = yield* Stream.fromQueue(protocolEvents).pipe(
3884 Stream.filter(
3885 (event) =>
3886 event.direction === "outgoing" && rawProtocolMethod(event) === "session/resume",
3887 ),
3888 Stream.runHead,
3889 );
3890 assert.isTrue(Option.isSome(loadAfterRestart));
3891 }).pipe(Effect.provide(layerTest), Effect.scoped),
3892 );
3893
3894 it.effect("drains native ACP cancellation before admitting the next prompt", () =>
3895 Effect.gen(function* () {
3896 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
3897 const path = yield* Path.Path;
3898 const selfInvocation = yield* resolveSelfInvocation();
3899 const protocolEvents = yield* Queue.unbounded<EffectAcpProtocol.AcpProtocolLogEvent>();
3900 const native: { current?: AcpSessionRuntime.AcpSessionRuntime["Service"] } = {};
3901 const instanceId = ProviderInstanceId.make("acp-native-cancel");
3902 const adapter = yield* makeAcpAdapterV2({
3903 instanceId,
3904 flavor: {
3905 driver: ACP_TEST_DRIVER,
3906 capabilities: AcpProviderCapabilitiesV2,
3907 makeRuntime: makeMockRuntime({
3908 childProcessSpawner,
3909 mockAgentPath: yield* path.fromFileUrl(
3910 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
3911 ),
3912 environment: { T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL: "1" },
3913 protocolEvents,
3914 cancelBehavior: "wait-for-prompt",
3915 wrapRuntime: (runtime) => {
3916 native.current = runtime;
3917 return runtime;
3918 },
3919 }),
3920 },
3921 selfInvocation,
3922 });
3923 const threadId = ThreadId.make("thread-acp-native-cancel");
3924 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
3925 runtimeMode: "full-access",
3926 interactionMode: "default",
3927 cwd: process.cwd(),
3928 });
3929 const modelSelection = { instanceId, model: "default" } as const;
3930 const runtime = yield* adapter.openSession({
3931 threadId,
3932 providerSessionId: ProviderSessionId.make("session-acp-native-cancel"),
3933 modelSelection,
3934 runtimePolicy,
3935 });
3936 const providerThread = yield* runtime.ensureThread({
3937 threadId,
3938 modelSelection,
3939 runtimePolicy,
3940 });
3941 const now = yield* DateTime.now;
3942 yield* runtime.startTurn(
3943 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
3944 );
3945 const started = Option.getOrThrow(
3946 yield* runtime.events.pipe(
3947 Stream.filter(
3948 (event) =>
3949 event.type === "turn_item.updated" &&
3950 event.turnItem.nativeItemRef?.nativeId === "native-cancel-tool",
3951 ),
3952 Stream.runHead,
3953 ),
3954 );
3955 if (
3956 started.type !== "turn_item.updated" ||
3957 started.turnItem.providerTurnId === null ||
3958 native.current === undefined
3959 ) {
3960 return yield* Effect.die("Expected the native cancellable command");
3961 }
3962 const providerTurnId = started.turnItem.providerTurnId;
3963 const interrupt = yield* runtime
3964 .interruptTurn({ providerThread, providerTurnId })
3965 .pipe(Effect.forkScoped);
3966 yield* Stream.fromQueue(protocolEvents).pipe(
3967 Stream.filter(
3968 (event) =>
3969 event.direction === "incoming" &&
3970 typeof event.payload === "string" &&
3971 event.payload.includes("native-cancel-received"),
3972 ),
3973 Stream.runHead,
3974 );
3975 yield* native.current.request("_test/finish-cancel", {});
3976 yield* Fiber.join(interrupt);
3977 const terminal = Option.getOrThrow(
3978 yield* runtime.events.pipe(
3979 Stream.filter(
3980 (event) => event.type === "turn.terminal" && event.providerTurnId === providerTurnId,
3981 ),
3982 Stream.runHead,
3983 ),
3984 );
3985 assert.equal(terminal.type === "turn.terminal" && terminal.status, "interrupted");
3986 yield* runtime.startTurn(
3987 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }),
3988 );
3989 const nextTerminal = Option.getOrThrow(
3990 yield* runtime.events.pipe(
3991 Stream.filter(
3992 (event) => event.type === "turn.terminal" && event.providerTurnId !== providerTurnId,
3993 ),
3994 Stream.runHead,
3995 ),
3996 );
3997 assert.equal(nextTerminal.type === "turn.terminal" && nextTerminal.status, "completed");
3998 }).pipe(Effect.provide(layerTest), Effect.scoped),
3999 );
4000
4001 it.effect("cancels pending permission requests while interrupting an ACP turn", () =>
4002 Effect.gen(function* () {
4003 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
4004 const path = yield* Path.Path;
4005 const selfInvocation = yield* resolveSelfInvocation();
4006 const mockAgentPath = yield* path.fromFileUrl(
4007 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
4008 );
4009 const releaseCancel = yield* Deferred.make<void>();
4010 const instanceId = ProviderInstanceId.make("acp-test");
4011 const adapter = yield* makeAcpAdapterV2({
4012 instanceId,
4013 flavor: {
4014 driver: ACP_TEST_DRIVER,
4015 capabilities: AcpProviderCapabilitiesV2,
4016 makeRuntime: makeMockRuntime({
4017 childProcessSpawner,
4018 mockAgentPath,
4019 environment: { T3_ACP_EMIT_TOOL_CALLS: "1" },
4020 wrapCancel: (cancel) => Deferred.await(releaseCancel).pipe(Effect.andThen(cancel)),
4021 }),
4022 },
4023 selfInvocation,
4024 });
4025 const threadId = ThreadId.make("thread-acp-cancel-permission");
4026 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
4027 runtimeMode: "approval-required",
4028 interactionMode: "default",
4029 cwd: process.cwd(),
4030 });
4031 const modelSelection = { instanceId, model: "default" } as const;
4032 const runtime = yield* adapter.openSession({
4033 threadId,
4034 providerSessionId: ProviderSessionId.make("provider-session-acp-cancel-permission"),
4035 modelSelection,
4036 runtimePolicy,
4037 });
4038 const providerThread = yield* runtime.ensureThread({
4039 threadId,
4040 modelSelection,
4041 runtimePolicy,
4042 });
4043 const now = yield* DateTime.now;
4044 yield* runtime.startTurn(
4045 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
4046 );
4047
4048 const pendingRequest = Option.getOrThrow(
4049 yield* runtime.events.pipe(
4050 Stream.filter(
4051 (event) =>
4052 event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending",
4053 ),
4054 Stream.runHead,
4055 ),
4056 );
4057 if (
4058 pendingRequest.type !== "runtime_request.updated" ||
4059 pendingRequest.runtimeRequest.providerTurnId === null
4060 ) {
4061 return yield* Effect.die("Expected a pending ACP permission request with a provider turn");
4062 }
4063
4064 const interruptFiber = yield* runtime
4065 .interruptTurn({
4066 providerThread,
4067 providerTurnId: pendingRequest.runtimeRequest.providerTurnId,
4068 })
4069 .pipe(Effect.forkScoped);
4070
4071 const cancelledRequest = Option.getOrThrow(
4072 yield* runtime.events.pipe(
4073 Stream.filter(
4074 (event) =>
4075 event.type === "runtime_request.updated" &&
4076 event.runtimeRequest.id === pendingRequest.runtimeRequest.id &&
4077 event.runtimeRequest.status === "cancelled",
4078 ),
4079 Stream.runHead,
4080 ),
4081 );
4082 assert.equal(cancelledRequest.type, "runtime_request.updated");
4083 yield* Deferred.succeed(releaseCancel, undefined);
4084 yield* Fiber.join(interruptFiber);
4085 const terminal = Option.getOrThrow(
4086 yield* runtime.events.pipe(
4087 Stream.filter((event) => event.type === "turn.terminal"),
4088 Stream.runHead,
4089 ),
4090 );
4091 assert.equal(terminal.type, "turn.terminal");
4092 }).pipe(Effect.provide(layerTest), Effect.scoped),
4093 );
4094
4095 it.live("keeps hard teardown excluded until a permission response is enqueued", () =>
4096 Effect.gen(function* () {
4097 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
4098 const path = yield* Path.Path;
4099 const selfInvocation = yield* resolveSelfInvocation();
4100 const mockAgentPath = yield* path.fromFileUrl(
4101 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
4102 );
4103 const responseEnqueued = yield* Deferred.make<void>();
4104 const releaseResponseAcknowledgement = yield* Deferred.make<void>();
4105 const instanceId = ProviderInstanceId.make("acp-test");
4106 const adapter = yield* makeAcpAdapterV2({
4107 instanceId,
4108 flavor: {
4109 driver: ACP_TEST_DRIVER,
4110 capabilities: AcpProviderCapabilitiesV2,
4111 makeRuntime: makeMockRuntime({
4112 childProcessSpawner,
4113 mockAgentPath,
4114 environment: { T3_ACP_EMIT_TOOL_CALLS: "1" },
4115 wrapOutgoingResponse: (onOutgoingResponse) => (requestId) =>
4116 Deferred.succeed(responseEnqueued, undefined).pipe(
4117 Effect.andThen(Deferred.await(releaseResponseAcknowledgement)),
4118 Effect.andThen(onOutgoingResponse(requestId)),
4119 ),
4120 }),
4121 },
4122 selfInvocation,
4123 });
4124 const threadId = ThreadId.make("thread-acp-response-wins-permission");
4125 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
4126 runtimeMode: "approval-required",
4127 interactionMode: "default",
4128 cwd: process.cwd(),
4129 });
4130 const modelSelection = { instanceId, model: "default" } as const;
4131 const runtime = yield* adapter.openSession({
4132 threadId,
4133 providerSessionId: ProviderSessionId.make("provider-session-acp-response-wins-permission"),
4134 modelSelection,
4135 runtimePolicy,
4136 });
4137 const providerThread = yield* runtime.ensureThread({
4138 threadId,
4139 modelSelection,
4140 runtimePolicy,
4141 });
4142 yield* runtime.startTurn(
4143 makeTurnInput({
4144 threadId,
4145 providerThread,
4146 instanceId,
4147 runtimePolicy,
4148 now: yield* DateTime.now,
4149 }),
4150 );
4151 const pending = Option.getOrThrow(
4152 yield* runtime.events.pipe(
4153 Stream.filter(
4154 (event) =>
4155 event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending",
4156 ),
4157 Stream.runHead,
4158 ),
4159 );
4160 if (
4161 pending.type !== "runtime_request.updated" ||
4162 pending.runtimeRequest.providerTurnId === null
4163 ) {
4164 return yield* Effect.die("Expected a pending permission request");
4165 }
4166 const responseFiber = yield* runtime
4167 .respondToRuntimeRequest({ requestId: pending.runtimeRequest.id, decision: "accept" })
4168 .pipe(Effect.forkScoped);
4169 yield* Deferred.await(responseEnqueued);
4170 const interruptFiber = yield* runtime
4171 .interruptTurn({
4172 providerThread,
4173 providerTurnId: pending.runtimeRequest.providerTurnId,
4174 requestRuntimeRestart: true,
4175 })
4176 .pipe(Effect.forkScoped);
4177 yield* Effect.yieldNow;
4178 assert.isUndefined(responseFiber.pollUnsafe());
4179 assert.isUndefined(interruptFiber.pollUnsafe());
4180
4181 yield* Deferred.succeed(releaseResponseAcknowledgement, undefined);
4182 yield* Fiber.join(responseFiber);
4183 yield* Fiber.join(interruptFiber);
4184 }).pipe(Effect.provide(layerTest), Effect.scoped),
4185 );
4186
4187 it.live("carries elicitation request identity through the completed stdout write", () =>
4188 Effect.gen(function* () {
4189 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
4190 const path = yield* Path.Path;
4191 const selfInvocation = yield* resolveSelfInvocation();
4192 const mockAgentPath = yield* path.fromFileUrl(
4193 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
4194 );
4195 const responseWritten = yield* Deferred.make<void>();
4196 const releaseResponseAcknowledgement = yield* Deferred.make<void>();
4197 const instanceId = ProviderInstanceId.make("acp-test-reordered-elicitation");
4198 const adapter = yield* makeAcpAdapterV2({
4199 instanceId,
4200 flavor: {
4201 driver: ACP_TEST_DRIVER,
4202 capabilities: AcpProviderCapabilitiesV2,
4203 makeRuntime: makeMockRuntime({
4204 childProcessSpawner,
4205 mockAgentPath,
4206 environment: { T3_ACP_EMIT_ELICITATION: "1" },
4207 wrapOutgoingResponse: (onOutgoingResponse) => (requestId) =>
4208 Deferred.succeed(responseWritten, undefined).pipe(
4209 Effect.andThen(Deferred.await(releaseResponseAcknowledgement)),
4210 Effect.andThen(onOutgoingResponse(requestId)),
4211 ),
4212 }),
4213 },
4214 selfInvocation,
4215 });
4216 const threadId = ThreadId.make("thread-acp-reordered-elicitation");
4217 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
4218 runtimeMode: "approval-required",
4219 interactionMode: "default",
4220 cwd: process.cwd(),
4221 });
4222 const modelSelection = { instanceId, model: "default" } as const;
4223 const runtime = yield* adapter.openSession({
4224 threadId,
4225 providerSessionId: ProviderSessionId.make("provider-session-acp-reordered-elicitation"),
4226 modelSelection,
4227 runtimePolicy,
4228 });
4229 const providerThread = yield* runtime.ensureThread({
4230 threadId,
4231 modelSelection,
4232 runtimePolicy,
4233 });
4234 yield* runtime.startTurn(
4235 makeTurnInput({
4236 threadId,
4237 providerThread,
4238 instanceId,
4239 runtimePolicy,
4240 now: yield* DateTime.now,
4241 }),
4242 );
4243 const pending = Option.getOrThrow(
4244 yield* runtime.events.pipe(
4245 Stream.filter(
4246 (event) =>
4247 event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending",
4248 ),
4249 Stream.runHead,
4250 ),
4251 );
4252 if (pending.type !== "runtime_request.updated") {
4253 return yield* Effect.die("Expected a pending elicitation request");
4254 }
4255 const responseFiber = yield* runtime
4256 .respondToRuntimeRequest({
4257 requestId: pending.runtimeRequest.id,
4258 answers: { approved: ["true"] },
4259 })
4260 .pipe(Effect.forkScoped);
4261
4262 yield* Deferred.await(responseWritten);
4263 assert.isUndefined(responseFiber.pollUnsafe());
4264 yield* Deferred.succeed(releaseResponseAcknowledgement, undefined);
4265 yield* Fiber.join(responseFiber);
4266 }).pipe(Effect.provide(layerTest), Effect.scoped),
4267 );
4268
4269 it.live("auto-approves tagged MCP elicitations under full-access policy", () =>
4270 Effect.gen(function* () {
4271 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
4272 const path = yield* Path.Path;
4273 const selfInvocation = yield* resolveSelfInvocation();
4274 const mockAgentPath = yield* path.fromFileUrl(
4275 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
4276 );
4277 const instanceId = ProviderInstanceId.make("acp-test-mcp-approval-elicitation");
4278 const adapter = yield* makeAcpAdapterV2({
4279 instanceId,
4280 flavor: {
4281 driver: ACP_TEST_DRIVER,
4282 capabilities: AcpProviderCapabilitiesV2,
4283 makeRuntime: makeMockRuntime({
4284 childProcessSpawner,
4285 mockAgentPath,
4286 environment: { T3_ACP_EMIT_MCP_TOOL_APPROVAL_ELICITATION: "1" },
4287 }),
4288 },
4289 selfInvocation,
4290 });
4291 const threadId = ThreadId.make("thread-acp-mcp-approval-elicitation");
4292 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
4293 runtimeMode: "full-access",
4294 interactionMode: "default",
4295 cwd: process.cwd(),
4296 });
4297 const modelSelection = { instanceId, model: "default" } as const;
4298 const runtime = yield* adapter.openSession({
4299 threadId,
4300 providerSessionId: ProviderSessionId.make("provider-session-acp-mcp-approval-elicitation"),
4301 modelSelection,
4302 runtimePolicy,
4303 });
4304 const providerThread = yield* runtime.ensureThread({
4305 threadId,
4306 modelSelection,
4307 runtimePolicy,
4308 });
4309 yield* runtime.startTurn(
4310 makeTurnInput({
4311 threadId,
4312 providerThread,
4313 instanceId,
4314 runtimePolicy,
4315 now: yield* DateTime.now,
4316 }),
4317 );
4318 const events = Array.from(
4319 yield* runtime.events.pipe(
4320 Stream.takeUntil((event) => event.type === "turn.terminal"),
4321 Stream.runCollect,
4322 ),
4323 );
4324
4325 assert.isFalse(events.some((event) => event.type === "runtime_request.updated"));
4326 assert.isTrue(events.some((event) => event.type === "turn.terminal"));
4327 }).pipe(Effect.provide(layerTest), Effect.scoped),
4328 );
4329
4330 it.live("fails a held native response acknowledgement before normal session close", () =>
4331 Effect.gen(function* () {
4332 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
4333 const path = yield* Path.Path;
4334 const selfInvocation = yield* resolveSelfInvocation();
4335 const mockAgentPath = yield* path.fromFileUrl(
4336 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
4337 );
4338 const responseWritten = yield* Deferred.make<void>();
4339 const releaseResponseAcknowledgement = yield* Deferred.make<void>();
4340 const responseLifecycle: Array<string> = [];
4341 const instanceId = ProviderInstanceId.make("acp-test-normal-close-held-response");
4342 const adapter = yield* makeAcpAdapterV2({
4343 instanceId,
4344 flavor: {
4345 driver: ACP_TEST_DRIVER,
4346 capabilities: AcpProviderCapabilitiesV2,
4347 makeRuntime: makeMockRuntime({
4348 childProcessSpawner,
4349 mockAgentPath,
4350 environment: { T3_ACP_EMIT_TOOL_CALLS: "1" },
4351 wrapOutgoingResponse: (onOutgoingResponse) => (requestId) =>
4352 Deferred.succeed(responseWritten, undefined).pipe(
4353 Effect.andThen(Deferred.await(releaseResponseAcknowledgement)),
4354 Effect.andThen(onOutgoingResponse(requestId)),
4355 ),
4356 }),
4357 },
4358 selfInvocation,
4359 testHooks: {
4360 onNativeResponseLifecycle: (event) =>
4361 Effect.sync(() => {
4362 responseLifecycle.push(event.type);
4363 }),
4364 },
4365 });
4366 const threadId = ThreadId.make("thread-acp-normal-close-held-response");
4367 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
4368 runtimeMode: "approval-required",
4369 interactionMode: "default",
4370 cwd: process.cwd(),
4371 });
4372 const modelSelection = { instanceId, model: "default" } as const;
4373 const sessionScope = yield* Scope.make();
4374 const runtime = yield* adapter
4375 .openSession({
4376 threadId,
4377 providerSessionId: ProviderSessionId.make(
4378 "provider-session-acp-normal-close-held-response",
4379 ),
4380 modelSelection,
4381 runtimePolicy,
4382 })
4383 .pipe(Effect.provideService(Scope.Scope, sessionScope));
4384 const providerThread = yield* runtime.ensureThread({
4385 threadId,
4386 modelSelection,
4387 runtimePolicy,
4388 });
4389 yield* runtime.startTurn(
4390 makeTurnInput({
4391 threadId,
4392 providerThread,
4393 instanceId,
4394 runtimePolicy,
4395 now: yield* DateTime.now,
4396 }),
4397 );
4398 const pending = Option.getOrThrow(
4399 yield* runtime.events.pipe(
4400 Stream.filter(
4401 (event) =>
4402 event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending",
4403 ),
4404 Stream.runHead,
4405 ),
4406 );
4407 if (pending.type !== "runtime_request.updated") {
4408 return yield* Effect.die("Expected a pending permission request");
4409 }
4410 const responseFiber = yield* runtime
4411 .respondToRuntimeRequest({ requestId: pending.runtimeRequest.id, decision: "accept" })
4412 .pipe(Effect.exit, Effect.forkScoped);
4413 yield* Deferred.await(responseWritten);
4414
4415 const closeFiber = yield* Scope.close(sessionScope, Exit.void).pipe(Effect.forkScoped);
4416 while (!responseLifecycle.includes("failed")) {
4417 yield* Effect.yieldNow;
4418 }
4419 const responseExit = yield* Fiber.join(responseFiber);
4420 if (Exit.isSuccess(responseExit)) {
4421 assert.fail("normal close must fail a response whose transport acknowledgement is held");
4422 }
4423 assert.include(Cause.pretty(responseExit.cause), "ACP session transport closed");
4424 assert.include(responseLifecycle, "removed");
4425 assert.include(responseLifecycle, "failed");
4426 yield* Deferred.succeed(releaseResponseAcknowledgement, undefined);
4427 yield* Fiber.join(closeFiber);
4428 }).pipe(Effect.provide(layerTest), Effect.scoped),
4429 );
4430
4431 it.live("rejects delayed native response registration when normal close wins the permit", () =>
4432 Effect.gen(function* () {
4433 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
4434 const path = yield* Path.Path;
4435 const selfInvocation = yield* resolveSelfInvocation();
4436 const mockAgentPath = yield* path.fromFileUrl(
4437 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
4438 );
4439 const registrationStarted = yield* Deferred.make<void>();
4440 const releaseRegistration = yield* Deferred.make<void>();
4441 const transportClosed = yield* Deferred.make<void>();
4442 const releaseTransportClose = yield* Deferred.make<void>();
4443 const responseLifecycle: Array<string> = [];
4444 const instanceId = ProviderInstanceId.make("acp-test-close-wins-registration");
4445 const adapter = yield* makeAcpAdapterV2({
4446 instanceId,
4447 flavor: {
4448 driver: ACP_TEST_DRIVER,
4449 capabilities: AcpProviderCapabilitiesV2,
4450 makeRuntime: makeMockRuntime({
4451 childProcessSpawner,
4452 mockAgentPath,
4453 environment: { T3_ACP_EMIT_TOOL_CALLS: "1" },
4454 }),
4455 },
4456 selfInvocation,
4457 testHooks: {
4458 afterNativeResponseTransportClosed: () =>
4459 Deferred.succeed(transportClosed, undefined).pipe(
4460 Effect.andThen(Deferred.await(releaseTransportClose)),
4461 ),
4462 beforeNativeResponseAdmissionCheck: () =>
4463 Deferred.succeed(registrationStarted, undefined).pipe(
4464 Effect.andThen(Deferred.await(releaseRegistration)),
4465 ),
4466 onNativeResponseLifecycle: (event) =>
4467 Effect.sync(() => {
4468 responseLifecycle.push(event.type);
4469 }),
4470 },
4471 });
4472 const threadId = ThreadId.make("thread-acp-close-wins-registration");
4473 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
4474 runtimeMode: "approval-required",
4475 interactionMode: "default",
4476 cwd: process.cwd(),
4477 });
4478 const modelSelection = { instanceId, model: "default" } as const;
4479 const sessionScope = yield* Scope.make();
4480 const runtime = yield* adapter
4481 .openSession({
4482 threadId,
4483 providerSessionId: ProviderSessionId.make("provider-session-acp-close-wins-registration"),
4484 modelSelection,
4485 runtimePolicy,
4486 })
4487 .pipe(Effect.provideService(Scope.Scope, sessionScope));
4488 const providerThread = yield* runtime.ensureThread({
4489 threadId,
4490 modelSelection,
4491 runtimePolicy,
4492 });
4493 yield* runtime.startTurn(
4494 makeTurnInput({
4495 threadId,
4496 providerThread,
4497 instanceId,
4498 runtimePolicy,
4499 now: yield* DateTime.now,
4500 }),
4501 );
4502 const pending = Option.getOrThrow(
4503 yield* runtime.events.pipe(
4504 Stream.filter(
4505 (event) =>
4506 event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending",
4507 ),
4508 Stream.runHead,
4509 ),
4510 );
4511 if (pending.type !== "runtime_request.updated") {
4512 return yield* Effect.die("Expected a pending permission request");
4513 }
4514 const responseFiber = yield* runtime
4515 .respondToRuntimeRequest({ requestId: pending.runtimeRequest.id, decision: "accept" })
4516 .pipe(Effect.exit, Effect.forkScoped);
4517 yield* Deferred.await(registrationStarted);
4518
4519 const closeFiber = yield* Scope.close(sessionScope, Exit.void).pipe(Effect.forkScoped);
4520 yield* Deferred.await(transportClosed);
4521 yield* Deferred.succeed(releaseRegistration, undefined);
4522 while (!responseLifecycle.includes("admission_rejected")) {
4523 yield* Effect.yieldNow;
4524 }
4525 const responseExit = yield* Fiber.join(responseFiber);
4526 if (Exit.isSuccess(responseExit)) {
4527 assert.fail("normal close must reject a response delayed before transport registration");
4528 }
4529 assert.include(Cause.pretty(responseExit.cause), "ACP session transport closed");
4530 assert.equal(responseLifecycle.filter((event) => event === "registered").length, 1);
4531 assert.equal(responseLifecycle.filter((event) => event === "removed").length, 1);
4532 assert.equal(responseLifecycle.filter((event) => event === "admission_rejected").length, 1);
4533 yield* Deferred.succeed(releaseTransportClose, undefined);
4534 yield* Fiber.join(closeFiber);
4535 }).pipe(Effect.provide(layerTest), Effect.scoped),
4536 );
4537
4538 it.live("bounds a missing pending permission response acknowledgement", () =>
4539 Effect.gen(function* () {
4540 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
4541 const path = yield* Path.Path;
4542 const selfInvocation = yield* resolveSelfInvocation();
4543 const mockAgentPath = yield* path.fromFileUrl(
4544 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
4545 );
4546 const responseEnqueued = yield* Deferred.make<void>();
4547 const releaseNativeHook = yield* Deferred.make<void>();
4548 const responseLifecycle: Array<string> = [];
4549 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
4550 const instanceId = ProviderInstanceId.make("acp-test-pending-response-timeout");
4551 const adapter = yield* makeAcpAdapterV2({
4552 instanceId,
4553 flavor: {
4554 driver: ACP_TEST_DRIVER,
4555 capabilities: AcpProviderCapabilitiesV2,
4556 restartRuntimeAfterInterrupt: true,
4557 terminateRuntimeProcessGroupOnInterrupt: true,
4558 makeRuntime: makeMockRuntime({
4559 childProcessSpawner,
4560 mockAgentPath,
4561 environment: {
4562 T3_ACP_EMIT_TOOL_CALLS: "1",
4563 T3_ACP_HANG_AFTER_PERMISSION: "1",
4564 },
4565 ownDetachedProcessGroup: true,
4566 processGroupPlatform: "win32",
4567 protocolEvents,
4568 windowsProcessTreeTerminator: (pid) =>
4569 Deferred.succeed(releaseNativeHook, undefined).pipe(
4570 Effect.andThen(
4571 Effect.sync(() => {
4572 process.kill(pid, "SIGTERM");
4573 }),
4574 ),
4575 ),
4576 wrapOutgoingResponse: (onOutgoingResponse) => (requestId) =>
4577 Deferred.succeed(responseEnqueued, undefined).pipe(
4578 Effect.andThen(Deferred.await(releaseNativeHook)),
4579 Effect.andThen(onOutgoingResponse(requestId)),
4580 ),
4581 }),
4582 },
4583 selfInvocation,
4584 testHooks: {
4585 onNativeResponseLifecycle: (event) =>
4586 Effect.sync(() => {
4587 responseLifecycle.push(event.type);
4588 }),
4589 },
4590 });
4591 const threadId = ThreadId.make("thread-acp-pending-response-timeout");
4592 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
4593 runtimeMode: "approval-required",
4594 interactionMode: "default",
4595 cwd: process.cwd(),
4596 });
4597 const modelSelection = { instanceId, model: "default" } as const;
4598 const sessionScope = yield* Scope.make();
4599 const runtime = yield* adapter
4600 .openSession({
4601 threadId,
4602 providerSessionId: ProviderSessionId.make(
4603 "provider-session-acp-pending-response-timeout",
4604 ),
4605 modelSelection,
4606 runtimePolicy,
4607 })
4608 .pipe(Effect.provideService(Scope.Scope, sessionScope));
4609 yield* pollProtocolMethods(protocolEvents);
4610 const providerThread = yield* runtime.ensureThread({
4611 threadId,
4612 modelSelection,
4613 runtimePolicy,
4614 });
4615 yield* runtime.startTurn(
4616 makeTurnInput({
4617 threadId,
4618 providerThread,
4619 instanceId,
4620 runtimePolicy,
4621 now: yield* DateTime.now,
4622 }),
4623 );
4624 const pending = Option.getOrThrow(
4625 yield* runtime.events.pipe(
4626 Stream.filter(
4627 (event) =>
4628 event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending",
4629 ),
4630 Stream.runHead,
4631 ),
4632 );
4633 if (
4634 pending.type !== "runtime_request.updated" ||
4635 pending.runtimeRequest.providerTurnId === null
4636 ) {
4637 return yield* Effect.die("Expected a pending permission request");
4638 }
4639 const responseExit = yield* runtime
4640 .respondToRuntimeRequest({ requestId: pending.runtimeRequest.id, decision: "accept" })
4641 .pipe(Effect.exit);
4642 if (Exit.isSuccess(responseExit)) {
4643 assert.fail("missing native response acknowledgement must fail the pending response");
4644 }
4645 assert.include(Cause.pretty(responseExit.cause), "Native response acknowledgement timed out");
4646 assert.isTrue(yield* Deferred.isDone(responseEnqueued));
4647 assert.isFalse(yield* Deferred.isDone(releaseNativeHook));
4648 assert.isBelow(responseLifecycle.indexOf("removed"), responseLifecycle.indexOf("failed"));
4649 assert.includeMembers(responseLifecycle, [
4650 "registered",
4651 "removed",
4652 "failed",
4653 "timer_exited",
4654 "timer_started",
4655 "watcher_exited",
4656 "watcher_started",
4657 ]);
4658
4659 yield* runtime.interruptTurn({
4660 providerThread,
4661 providerTurnId: pending.runtimeRequest.providerTurnId,
4662 requestRuntimeRestart: true,
4663 });
4664 assert.isTrue(yield* Deferred.isDone(releaseNativeHook));
4665 yield* Effect.sleep("50 millis");
4666 assert.include(responseLifecycle, "late_noop");
4667 yield* Scope.close(sessionScope, Exit.void);
4668 assert.notInclude(yield* pollProtocolMethods(protocolEvents), "session/close");
4669 }).pipe(Effect.provide(layerTest), Effect.scoped),
4670 );
4671
4672 it.live("defers caller cancellation until a pending response acknowledgement is bounded", () =>
4673 Effect.gen(function* () {
4674 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
4675 const path = yield* Path.Path;
4676 const selfInvocation = yield* resolveSelfInvocation();
4677 const mockAgentPath = yield* path.fromFileUrl(
4678 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
4679 );
4680 const responseEnqueued = yield* Deferred.make<void>();
4681 const releaseNativeHook = yield* Deferred.make<void>();
4682 const instanceId = ProviderInstanceId.make("acp-test-pending-response-cancel");
4683 const adapter = yield* makeAcpAdapterV2({
4684 instanceId,
4685 flavor: {
4686 driver: ACP_TEST_DRIVER,
4687 capabilities: AcpProviderCapabilitiesV2,
4688 restartRuntimeAfterInterrupt: true,
4689 terminateRuntimeProcessGroupOnInterrupt: true,
4690 makeRuntime: makeMockRuntime({
4691 childProcessSpawner,
4692 mockAgentPath,
4693 environment: {
4694 T3_ACP_EMIT_TOOL_CALLS: "1",
4695 T3_ACP_HANG_AFTER_PERMISSION: "1",
4696 },
4697 ownDetachedProcessGroup: true,
4698 processGroupPlatform: "win32",
4699 windowsProcessTreeTerminator: (pid) =>
4700 Deferred.succeed(releaseNativeHook, undefined).pipe(
4701 Effect.andThen(
4702 Effect.sync(() => {
4703 process.kill(pid, "SIGTERM");
4704 }),
4705 ),
4706 ),
4707 wrapOutgoingResponse: (onOutgoingResponse) => (requestId) =>
4708 Deferred.succeed(responseEnqueued, undefined).pipe(
4709 Effect.andThen(Deferred.await(releaseNativeHook)),
4710 Effect.andThen(onOutgoingResponse(requestId)),
4711 ),
4712 }),
4713 },
4714 selfInvocation,
4715 });
4716 const threadId = ThreadId.make("thread-acp-pending-response-cancel");
4717 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
4718 runtimeMode: "approval-required",
4719 interactionMode: "default",
4720 cwd: process.cwd(),
4721 });
4722 const modelSelection = { instanceId, model: "default" } as const;
4723 const runtime = yield* adapter.openSession({
4724 threadId,
4725 providerSessionId: ProviderSessionId.make("provider-session-acp-pending-response-cancel"),
4726 modelSelection,
4727 runtimePolicy,
4728 });
4729 const providerThread = yield* runtime.ensureThread({
4730 threadId,
4731 modelSelection,
4732 runtimePolicy,
4733 });
4734 yield* runtime.startTurn(
4735 makeTurnInput({
4736 threadId,
4737 providerThread,
4738 instanceId,
4739 runtimePolicy,
4740 now: yield* DateTime.now,
4741 }),
4742 );
4743 const pending = Option.getOrThrow(
4744 yield* runtime.events.pipe(
4745 Stream.filter(
4746 (event) =>
4747 event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending",
4748 ),
4749 Stream.runHead,
4750 ),
4751 );
4752 if (
4753 pending.type !== "runtime_request.updated" ||
4754 pending.runtimeRequest.providerTurnId === null
4755 ) {
4756 return yield* Effect.die("Expected a pending permission request");
4757 }
4758 const responseFiber = yield* runtime
4759 .respondToRuntimeRequest({ requestId: pending.runtimeRequest.id, decision: "accept" })
4760 .pipe(Effect.forkScoped);
4761 yield* Deferred.await(responseEnqueued);
4762 const cancellationFiber = yield* Fiber.interrupt(responseFiber).pipe(Effect.forkScoped);
4763 const interruptFiber = yield* runtime
4764 .interruptTurn({
4765 providerThread,
4766 providerTurnId: pending.runtimeRequest.providerTurnId,
4767 requestRuntimeRestart: true,
4768 })
4769 .pipe(Effect.forkScoped);
4770 yield* Effect.sleep("100 millis");
4771 assert.isUndefined(cancellationFiber.pollUnsafe());
4772 assert.isUndefined(interruptFiber.pollUnsafe());
4773 assert.isFalse(yield* Deferred.isDone(releaseNativeHook));
4774
4775 yield* Fiber.join(cancellationFiber);
4776 yield* Fiber.join(interruptFiber);
4777 assert.isTrue(yield* Deferred.isDone(releaseNativeHook));
4778 }).pipe(Effect.provide(layerTest), Effect.scoped),
4779 );
4780
4781 it.live("waits for immediate allow and deny permission responses before hard teardown", () =>
4782 Effect.gen(function* () {
4783 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
4784 const idAllocator = yield* IdAllocator.IdAllocatorV2;
4785 const path = yield* Path.Path;
4786 const selfInvocation = yield* resolveSelfInvocation();
4787 const mockAgentPath = yield* path.fromFileUrl(
4788 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
4789 );
4790
4791 for (const [name, sandboxPolicy] of [
4792 ["allow", undefined],
4793 ["deny", { type: "readOnly" } as const],
4794 ] as const) {
4795 yield* Effect.gen(function* () {
4796 const responseEnqueued = yield* Deferred.make<void>();
4797 const releaseResponseAcknowledgement = yield* Deferred.make<void>();
4798 const instanceId = ProviderInstanceId.make(`acp-test-${name}`);
4799 const adapter = yield* makeAcpAdapterV2({
4800 instanceId,
4801 flavor: {
4802 driver: ACP_TEST_DRIVER,
4803 capabilities: AcpProviderCapabilitiesV2,
4804 restartRuntimeAfterInterrupt: true,
4805 terminateRuntimeProcessGroupOnInterrupt: true,
4806 makeRuntime: makeMockRuntime({
4807 childProcessSpawner,
4808 mockAgentPath,
4809 environment: { T3_ACP_EMIT_TOOL_CALLS: "1" },
4810 ownDetachedProcessGroup: true,
4811 processGroupPlatform: "win32",
4812 windowsProcessTreeTerminator: (pid) =>
4813 Effect.sync(() => {
4814 process.kill(pid, "SIGTERM");
4815 }),
4816 wrapOutgoingResponse: (onOutgoingResponse) => (requestId) =>
4817 Deferred.succeed(responseEnqueued, undefined).pipe(
4818 Effect.andThen(Deferred.await(releaseResponseAcknowledgement)),
4819 Effect.andThen(onOutgoingResponse(requestId)),
4820 ),
4821 }),
4822 },
4823 selfInvocation,
4824 });
4825 const threadId = ThreadId.make(`thread-acp-immediate-permission-${name}`);
4826 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
4827 runtimeMode: "full-access",
4828 interactionMode: "default",
4829 approvalPolicy: "never",
4830 cwd: process.cwd(),
4831 ...(sandboxPolicy === undefined ? {} : { sandboxPolicy }),
4832 });
4833 const modelSelection = { instanceId, model: "default" } as const;
4834 const runtime = yield* adapter.openSession({
4835 threadId,
4836 providerSessionId: ProviderSessionId.make(
4837 `provider-session-acp-immediate-permission-${name}`,
4838 ),
4839 modelSelection,
4840 runtimePolicy,
4841 });
4842 const providerThread = yield* runtime.ensureThread({
4843 threadId,
4844 modelSelection,
4845 runtimePolicy,
4846 });
4847 const turnFiber = yield* runtime
4848 .startTurn(
4849 makeTurnInput({
4850 threadId,
4851 providerThread,
4852 instanceId,
4853 runtimePolicy,
4854 now: yield* DateTime.now,
4855 }),
4856 )
4857 .pipe(Effect.forkDetach);
4858 yield* Deferred.await(responseEnqueued);
4859 const interruptFiber = yield* runtime
4860 .interruptTurn({
4861 providerThread,
4862 providerTurnId: idAllocator.derive.providerTurn({
4863 driver: ACP_TEST_DRIVER,
4864 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
4865 }),
4866 requestRuntimeRestart: true,
4867 })
4868 .pipe(Effect.forkScoped);
4869 yield* Effect.yieldNow;
4870 assert.isUndefined(interruptFiber.pollUnsafe());
4871
4872 yield* Deferred.succeed(releaseResponseAcknowledgement, undefined);
4873 yield* Fiber.join(interruptFiber);
4874 yield* Fiber.interrupt(turnFiber).pipe(Effect.forkDetach);
4875 }).pipe(Effect.scoped);
4876 }
4877 }).pipe(Effect.provide(layerTest), Effect.scoped),
4878 );
4879
4880 it.live("waits for immediate URL elicitation responses before hard teardown", () =>
4881 Effect.gen(function* () {
4882 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
4883 const idAllocator = yield* IdAllocator.IdAllocatorV2;
4884 const path = yield* Path.Path;
4885 const selfInvocation = yield* resolveSelfInvocation();
4886 const mockAgentPath = yield* path.fromFileUrl(
4887 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
4888 );
4889 const responseEnqueued = yield* Deferred.make<void>();
4890 const releaseResponseAcknowledgement = yield* Deferred.make<void>();
4891 const instanceId = ProviderInstanceId.make("acp-test-url-elicitation");
4892 const adapter = yield* makeAcpAdapterV2({
4893 instanceId,
4894 flavor: {
4895 driver: ACP_TEST_DRIVER,
4896 capabilities: AcpProviderCapabilitiesV2,
4897 restartRuntimeAfterInterrupt: true,
4898 terminateRuntimeProcessGroupOnInterrupt: true,
4899 makeRuntime: makeMockRuntime({
4900 childProcessSpawner,
4901 mockAgentPath,
4902 environment: { T3_ACP_EMIT_URL_ELICITATION: "1" },
4903 ownDetachedProcessGroup: true,
4904 processGroupPlatform: "win32",
4905 windowsProcessTreeTerminator: (pid) =>
4906 Effect.sync(() => {
4907 process.kill(pid, "SIGTERM");
4908 }),
4909 wrapOutgoingResponse: (onOutgoingResponse) => (requestId) =>
4910 Deferred.succeed(responseEnqueued, undefined).pipe(
4911 Effect.andThen(Deferred.await(releaseResponseAcknowledgement)),
4912 Effect.andThen(onOutgoingResponse(requestId)),
4913 ),
4914 }),
4915 },
4916 selfInvocation,
4917 });
4918 const threadId = ThreadId.make("thread-acp-immediate-url-elicitation");
4919 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
4920 runtimeMode: "full-access",
4921 interactionMode: "default",
4922 cwd: process.cwd(),
4923 });
4924 const modelSelection = { instanceId, model: "default" } as const;
4925 const runtime = yield* adapter.openSession({
4926 threadId,
4927 providerSessionId: ProviderSessionId.make("provider-session-acp-immediate-url-elicitation"),
4928 modelSelection,
4929 runtimePolicy,
4930 });
4931 const providerThread = yield* runtime.ensureThread({
4932 threadId,
4933 modelSelection,
4934 runtimePolicy,
4935 });
4936 const turnFiber = yield* runtime
4937 .startTurn(
4938 makeTurnInput({
4939 threadId,
4940 providerThread,
4941 instanceId,
4942 runtimePolicy,
4943 now: yield* DateTime.now,
4944 }),
4945 )
4946 .pipe(Effect.forkDetach);
4947 yield* Deferred.await(responseEnqueued);
4948 const interruptFiber = yield* runtime
4949 .interruptTurn({
4950 providerThread,
4951 providerTurnId: idAllocator.derive.providerTurn({
4952 driver: ACP_TEST_DRIVER,
4953 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
4954 }),
4955 requestRuntimeRestart: true,
4956 })
4957 .pipe(Effect.forkScoped);
4958 yield* Effect.yieldNow;
4959 assert.isUndefined(interruptFiber.pollUnsafe());
4960
4961 yield* Deferred.succeed(releaseResponseAcknowledgement, undefined);
4962 yield* Fiber.join(interruptFiber);
4963 yield* Fiber.interrupt(turnFiber).pipe(Effect.forkDetach);
4964 }).pipe(Effect.provide(layerTest), Effect.scoped),
4965 );
4966
4967 it.live("bounds a missing immediate response acknowledgement before hard teardown", () =>
4968 Effect.gen(function* () {
4969 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
4970 const idAllocator = yield* IdAllocator.IdAllocatorV2;
4971 const path = yield* Path.Path;
4972 const selfInvocation = yield* resolveSelfInvocation();
4973 const mockAgentPath = yield* path.fromFileUrl(
4974 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
4975 );
4976 const responseEnqueued = yield* Deferred.make<void>();
4977 const releaseNativeHook = yield* Deferred.make<void>();
4978 const instanceId = ProviderInstanceId.make("acp-test-missing-response-ack");
4979 const adapter = yield* makeAcpAdapterV2({
4980 instanceId,
4981 flavor: {
4982 driver: ACP_TEST_DRIVER,
4983 capabilities: AcpProviderCapabilitiesV2,
4984 restartRuntimeAfterInterrupt: true,
4985 terminateRuntimeProcessGroupOnInterrupt: true,
4986 makeRuntime: makeMockRuntime({
4987 childProcessSpawner,
4988 mockAgentPath,
4989 environment: { T3_ACP_EMIT_TOOL_CALLS: "1" },
4990 ownDetachedProcessGroup: true,
4991 processGroupPlatform: "win32",
4992 windowsProcessTreeTerminator: (pid) =>
4993 Deferred.succeed(releaseNativeHook, undefined).pipe(
4994 Effect.andThen(
4995 Effect.sync(() => {
4996 process.kill(pid, "SIGTERM");
4997 }),
4998 ),
4999 ),
5000 wrapOutgoingResponse: (onOutgoingResponse) => (requestId) =>
5001 Deferred.succeed(responseEnqueued, undefined).pipe(
5002 Effect.andThen(Deferred.await(releaseNativeHook)),
5003 Effect.andThen(onOutgoingResponse(requestId)),
5004 ),
5005 }),
5006 },
5007 selfInvocation,
5008 });
5009 const threadId = ThreadId.make("thread-acp-missing-response-ack");
5010 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
5011 runtimeMode: "full-access",
5012 interactionMode: "default",
5013 approvalPolicy: "never",
5014 cwd: process.cwd(),
5015 });
5016 const modelSelection = { instanceId, model: "default" } as const;
5017 const runtime = yield* adapter.openSession({
5018 threadId,
5019 providerSessionId: ProviderSessionId.make("provider-session-acp-missing-response-ack"),
5020 modelSelection,
5021 runtimePolicy,
5022 });
5023 const providerThread = yield* runtime.ensureThread({
5024 threadId,
5025 modelSelection,
5026 runtimePolicy,
5027 });
5028 const turnFiber = yield* runtime
5029 .startTurn(
5030 makeTurnInput({
5031 threadId,
5032 providerThread,
5033 instanceId,
5034 runtimePolicy,
5035 now: yield* DateTime.now,
5036 }),
5037 )
5038 .pipe(Effect.forkDetach);
5039 yield* Deferred.await(responseEnqueued);
5040 const startedAt = yield* Clock.currentTimeMillis;
5041 yield* runtime.interruptTurn({
5042 providerThread,
5043 providerTurnId: idAllocator.derive.providerTurn({
5044 driver: ACP_TEST_DRIVER,
5045 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
5046 }),
5047 requestRuntimeRestart: true,
5048 });
5049 assert.isAtLeast((yield* Clock.currentTimeMillis) - startedAt, 1_500);
5050 yield* Fiber.interrupt(turnFiber).pipe(Effect.forkDetach);
5051 }).pipe(Effect.provide(layerTest), Effect.scoped),
5052 );
5053
5054 it.live("rejects an elicitation response when hard teardown wins admission", () =>
5055 Effect.gen(function* () {
5056 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
5057 const path = yield* Path.Path;
5058 const selfInvocation = yield* resolveSelfInvocation();
5059 const mockAgentPath = yield* path.fromFileUrl(
5060 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
5061 );
5062 const teardownStarted = yield* Deferred.make<void>();
5063 const releaseTeardown = yield* Deferred.make<void>();
5064 const instanceId = ProviderInstanceId.make("acp-test");
5065 const adapter = yield* makeAcpAdapterV2({
5066 instanceId,
5067 flavor: {
5068 driver: ACP_TEST_DRIVER,
5069 capabilities: AcpProviderCapabilitiesV2,
5070 restartRuntimeAfterInterrupt: true,
5071 terminateRuntimeProcessGroupOnInterrupt: true,
5072 makeRuntime: makeMockRuntime({
5073 childProcessSpawner,
5074 mockAgentPath,
5075 environment: { T3_ACP_EMIT_ELICITATION: "1" },
5076 ownDetachedProcessGroup: true,
5077 processGroupPlatform: "win32",
5078 windowsProcessTreeTerminator: (pid) =>
5079 Deferred.succeed(teardownStarted, undefined).pipe(
5080 Effect.andThen(Deferred.await(releaseTeardown)),
5081 Effect.andThen(
5082 Effect.sync(() => {
5083 process.kill(pid, "SIGTERM");
5084 }),
5085 ),
5086 ),
5087 }),
5088 },
5089 selfInvocation,
5090 });
5091 const threadId = ThreadId.make("thread-acp-teardown-wins-elicitation");
5092 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
5093 runtimeMode: "approval-required",
5094 interactionMode: "default",
5095 cwd: process.cwd(),
5096 });
5097 const modelSelection = { instanceId, model: "default" } as const;
5098 const runtime = yield* adapter.openSession({
5099 threadId,
5100 providerSessionId: ProviderSessionId.make("provider-session-acp-teardown-wins-elicitation"),
5101 modelSelection,
5102 runtimePolicy,
5103 });
5104 const providerThread = yield* runtime.ensureThread({
5105 threadId,
5106 modelSelection,
5107 runtimePolicy,
5108 });
5109 yield* runtime.startTurn(
5110 makeTurnInput({
5111 threadId,
5112 providerThread,
5113 instanceId,
5114 runtimePolicy,
5115 now: yield* DateTime.now,
5116 }),
5117 );
5118 const pending = Option.getOrThrow(
5119 yield* runtime.events.pipe(
5120 Stream.filter(
5121 (event) =>
5122 event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending",
5123 ),
5124 Stream.runHead,
5125 ),
5126 );
5127 if (
5128 pending.type !== "runtime_request.updated" ||
5129 pending.runtimeRequest.providerTurnId === null
5130 ) {
5131 return yield* Effect.die("Expected a pending elicitation request");
5132 }
5133 const interruptFiber = yield* runtime
5134 .interruptTurn({
5135 providerThread,
5136 providerTurnId: pending.runtimeRequest.providerTurnId,
5137 requestRuntimeRestart: true,
5138 })
5139 .pipe(Effect.forkScoped);
5140 yield* Deferred.await(teardownStarted);
5141 const responseFiber = yield* runtime
5142 .respondToRuntimeRequest({
5143 requestId: pending.runtimeRequest.id,
5144 answers: { approved: ["true"] },
5145 })
5146 .pipe(Effect.exit, Effect.forkScoped);
5147 yield* Effect.yieldNow;
5148 assert.isUndefined(responseFiber.pollUnsafe());
5149
5150 yield* Deferred.succeed(releaseTeardown, undefined);
5151 yield* Fiber.join(interruptFiber);
5152 const responseExit = yield* Fiber.join(responseFiber);
5153 if (Exit.isSuccess(responseExit)) {
5154 assert.fail("teardown winning admission must reject the elicitation response");
5155 }
5156 assert.include(Cause.pretty(responseExit.cause), "No pending ACP runtime request");
5157 }).pipe(Effect.provide(layerTest), Effect.scoped),
5158 );
5159
5160 it.effect("releases an ACP turn when cancellation times out", () =>
5161 Effect.gen(function* () {
5162 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
5163 const idAllocator = yield* IdAllocator.IdAllocatorV2;
5164 const path = yield* Path.Path;
5165 const selfInvocation = yield* resolveSelfInvocation();
5166 const mockAgentPath = yield* path.fromFileUrl(
5167 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
5168 );
5169 const instanceId = ProviderInstanceId.make("acp-test");
5170 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
5171 const adapter = yield* makeAcpAdapterV2({
5172 instanceId,
5173 flavor: {
5174 driver: ACP_TEST_DRIVER,
5175 capabilities: AcpProviderCapabilitiesV2,
5176 makeRuntime: makeMockRuntime({
5177 childProcessSpawner,
5178 mockAgentPath,
5179 environment: { T3_ACP_PROMPT_DELAY_MS: "5000" },
5180 protocolEvents,
5181 }),
5182 },
5183 selfInvocation,
5184 });
5185 const threadId = ThreadId.make("thread-acp-cancel-timeout");
5186 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
5187 runtimeMode: "full-access",
5188 interactionMode: "default",
5189 cwd: process.cwd(),
5190 });
5191 const modelSelection = { instanceId, model: "default" } as const;
5192 const runtime = yield* adapter.openSession({
5193 threadId,
5194 providerSessionId: ProviderSessionId.make("provider-session-acp-cancel-timeout"),
5195 modelSelection,
5196 runtimePolicy,
5197 });
5198 const providerThread = yield* runtime.ensureThread({
5199 threadId,
5200 modelSelection,
5201 runtimePolicy,
5202 });
5203 const now = yield* DateTime.now;
5204 const firstTurn = makeTurnInput({
5205 threadId,
5206 providerThread,
5207 instanceId,
5208 runtimePolicy,
5209 now,
5210 });
5211 yield* runtime.startTurn(firstTurn);
5212 yield* Stream.fromQueue(protocolEvents).pipe(
5213 Stream.filter(
5214 (event) =>
5215 event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt",
5216 ),
5217 Stream.runHead,
5218 );
5219 const providerTurnId = idAllocator.derive.providerTurn({
5220 driver: ACP_TEST_DRIVER,
5221 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
5222 });
5223 const interruptFiber = yield* runtime
5224 .interruptTurn({ providerThread, providerTurnId })
5225 .pipe(Effect.flip, Effect.forkScoped);
5226 yield* Stream.fromQueue(protocolEvents).pipe(
5227 Stream.filter(
5228 (event) =>
5229 event.direction === "outgoing" && rawProtocolMethod(event) === "session/cancel",
5230 ),
5231 Stream.runHead,
5232 );
5233 yield* TestClock.adjust("10 seconds");
5234 const interruptError = yield* Fiber.join(interruptFiber);
5235 assert.equal(interruptError._tag, "ProviderAdapterInterruptError");
5236
5237 yield* runtime.startTurn(
5238 makeTurnInput({
5239 threadId,
5240 providerThread,
5241 instanceId,
5242 runtimePolicy,
5243 now,
5244 ordinal: 2,
5245 }),
5246 );
5247 yield* Stream.fromQueue(protocolEvents).pipe(
5248 Stream.filter(
5249 (event) =>
5250 event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt",
5251 ),
5252 Stream.runHead,
5253 );
5254 }).pipe(Effect.provide(layerTest), Effect.scoped),
5255 );
5256
5257 it.live("treats a second hard Stop as success when the turn is already gone", () =>
5258 Effect.gen(function* () {
5259 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
5260 const idAllocator = yield* IdAllocator.IdAllocatorV2;
5261 const path = yield* Path.Path;
5262 const selfInvocation = yield* resolveSelfInvocation();
5263 const mockAgentPath = yield* path.fromFileUrl(
5264 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
5265 );
5266 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
5267 const instanceId = ProviderInstanceId.make("acp-test");
5268 const adapter = yield* makeAcpAdapterV2({
5269 instanceId,
5270 flavor: {
5271 driver: ACP_TEST_DRIVER,
5272 capabilities: AcpProviderCapabilitiesV2,
5273 interruptPromptOnCancel: false,
5274 restartRuntimeAfterInterrupt: true,
5275 terminateRuntimeProcessGroupOnInterrupt: true,
5276 makeRuntime: makeMockRuntime({
5277 childProcessSpawner,
5278 mockAgentPath,
5279 ownDetachedProcessGroup: true,
5280 environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" },
5281 protocolEvents,
5282 }),
5283 },
5284 selfInvocation,
5285 });
5286 const threadId = ThreadId.make("thread-acp-double-stop");
5287 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
5288 runtimeMode: "full-access",
5289 interactionMode: "default",
5290 cwd: process.cwd(),
5291 });
5292 const modelSelection = { instanceId, model: "default" } as const;
5293 const runtime = yield* adapter.openSession({
5294 threadId,
5295 providerSessionId: ProviderSessionId.make("provider-session-acp-double-stop"),
5296 modelSelection,
5297 runtimePolicy,
5298 });
5299 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
5300 yield* runtime.events.pipe(
5301 Stream.runForEach((event) => Queue.offer(events, event)),
5302 Effect.forkScoped,
5303 );
5304 const providerThread = yield* runtime.ensureThread({
5305 threadId,
5306 modelSelection,
5307 runtimePolicy,
5308 });
5309 const now = yield* DateTime.now;
5310 yield* runtime
5311 .startTurn(makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }))
5312 .pipe(Effect.forkScoped);
5313 yield* Stream.fromQueue(protocolEvents).pipe(
5314 Stream.filter(
5315 (event) =>
5316 event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt",
5317 ),
5318 Stream.runHead,
5319 );
5320 const providerTurnId = idAllocator.derive.providerTurn({
5321 driver: ACP_TEST_DRIVER,
5322 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
5323 });
5324 yield* runtime.interruptTurn({
5325 providerThread,
5326 providerTurnId,
5327 requestRuntimeRestart: true,
5328 });
5329 // Second durable interrupt after activeTurn is cleared must not fail.
5330 const second = yield* Effect.exit(
5331 runtime.interruptTurn({
5332 providerThread,
5333 providerTurnId,
5334 requestRuntimeRestart: true,
5335 }),
5336 );
5337 assert.isTrue(Exit.isSuccess(second), "duplicate hard Stop must be idempotent");
5338 let terminal: string | null = null;
5339 while (terminal === null) {
5340 const event = yield* Queue.take(events);
5341 if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) {
5342 terminal = event.status;
5343 }
5344 }
5345 assert.equal(terminal, "interrupted");
5346 }).pipe(Effect.provide(layerTest), Effect.scoped),
5347 );
5348
5349 it.effect("finalizes a settled turn held open for background work when interrupted", () =>
5350 Effect.gen(function* () {
5351 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
5352 const idAllocator = yield* IdAllocator.IdAllocatorV2;
5353 const path = yield* Path.Path;
5354 const selfInvocation = yield* resolveSelfInvocation();
5355 const mockAgentPath = yield* path.fromFileUrl(
5356 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
5357 );
5358 const instanceId = ProviderInstanceId.make("acp-test");
5359 const promptSettled = yield* Deferred.make<void>();
5360 const adapter = yield* makeAcpAdapterV2({
5361 testHooks: {
5362 afterPromptSettledWithBackgroundWork: () =>
5363 Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid),
5364 },
5365 instanceId,
5366 flavor: {
5367 driver: ACP_TEST_DRIVER,
5368 capabilities: AcpProviderCapabilitiesV2,
5369 deferFinalizeForBackgroundWork: true,
5370 extractSubagentUpdate: (toolCall) =>
5371 toolCall.toolCallId === "tool-call-generic-1"
5372 ? {
5373 nativeTaskId: "task-generic-1",
5374 prompt: "background subagent",
5375 title: "background subagent",
5376 model: null,
5377 status: "running",
5378 childSessionId: null,
5379 result: null,
5380 }
5381 : undefined,
5382 makeRuntime: makeMockRuntime({
5383 childProcessSpawner,
5384 mockAgentPath,
5385 environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" },
5386 }),
5387 },
5388 selfInvocation,
5389 });
5390 const threadId = ThreadId.make("thread-acp-interrupt-background-hold");
5391 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
5392 runtimeMode: "full-access",
5393 interactionMode: "default",
5394 cwd: process.cwd(),
5395 });
5396 const modelSelection = { instanceId, model: "default" } as const;
5397 const runtime = yield* adapter.openSession({
5398 threadId,
5399 providerSessionId: ProviderSessionId.make("provider-session-acp-interrupt-background"),
5400 modelSelection,
5401 runtimePolicy,
5402 });
5403 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
5404 yield* runtime.events.pipe(
5405 Stream.runForEach((event) => Queue.offer(events, event)),
5406 Effect.forkScoped,
5407 );
5408 const providerThread = yield* runtime.ensureThread({
5409 threadId,
5410 modelSelection,
5411 runtimePolicy,
5412 });
5413 const now = yield* DateTime.now;
5414 yield* runtime.startTurn(
5415 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
5416 );
5417 // The still-running subagent defers finalize after session/prompt returns.
5418 yield* Deferred.await(promptSettled);
5419
5420 const providerTurnId = idAllocator.derive.providerTurn({
5421 driver: ACP_TEST_DRIVER,
5422 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
5423 });
5424 yield* runtime.interruptTurn({ providerThread, providerTurnId });
5425
5426 let terminalStatus: string | null = null;
5427 while (terminalStatus === null) {
5428 const event = yield* Queue.take(events);
5429 if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) {
5430 terminalStatus = event.status;
5431 }
5432 }
5433 assert.equal(terminalStatus, "interrupted");
5434 }).pipe(Effect.provide(layerTest), Effect.scoped),
5435 );
5436
5437 it.effect(
5438 "carries a live subagent lineage across an interrupt so the next turn can complete it",
5439 () =>
5440 Effect.gen(function* () {
5441 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
5442 const idAllocator = yield* IdAllocator.IdAllocatorV2;
5443 const path = yield* Path.Path;
5444 const selfInvocation = yield* resolveSelfInvocation();
5445 const mockAgentPath = yield* path.fromFileUrl(
5446 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
5447 );
5448 const instanceId = ProviderInstanceId.make("acp-test");
5449 let subagentPhase: "spawn" | "complete" = "spawn";
5450 const promptSettled = yield* Deferred.make<void>();
5451 const adapter = yield* makeAcpAdapterV2({
5452 testHooks: {
5453 afterPromptSettledWithBackgroundWork: () =>
5454 Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid),
5455 },
5456 instanceId,
5457 flavor: {
5458 driver: ACP_TEST_DRIVER,
5459 capabilities: AcpProviderCapabilitiesV2,
5460 deferFinalizeForBackgroundWork: true,
5461 extractSubagentUpdate: (toolCall) =>
5462 toolCall.toolCallId !== "tool-call-generic-1"
5463 ? undefined
5464 : subagentPhase === "spawn"
5465 ? {
5466 nativeTaskId: "task-generic-1",
5467 prompt: "background subagent",
5468 title: "background subagent",
5469 model: null,
5470 status: "running",
5471 childSessionId: null,
5472 result: null,
5473 }
5474 : // Hydration-only shape (empty prompt, null title): without a
5475 // carried-over lineage this update is dropped and the item
5476 // stays running forever.
5477 {
5478 nativeTaskId: "task-generic-1",
5479 prompt: "",
5480 title: null,
5481 model: null,
5482 status: "completed",
5483 childSessionId: null,
5484 result: "SUB_DONE",
5485 },
5486 makeRuntime: makeMockRuntime({
5487 childProcessSpawner,
5488 mockAgentPath,
5489 environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" },
5490 }),
5491 },
5492 selfInvocation,
5493 });
5494 const threadId = ThreadId.make("thread-acp-subagent-carryover");
5495 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
5496 runtimeMode: "full-access",
5497 interactionMode: "default",
5498 cwd: process.cwd(),
5499 });
5500 const modelSelection = { instanceId, model: "default" } as const;
5501 const runtime = yield* adapter.openSession({
5502 threadId,
5503 providerSessionId: ProviderSessionId.make("provider-session-acp-subagent-carryover"),
5504 modelSelection,
5505 runtimePolicy,
5506 });
5507 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
5508 yield* runtime.events.pipe(
5509 Stream.runForEach((event) => Queue.offer(events, event)),
5510 Effect.forkScoped,
5511 );
5512 const providerThread = yield* runtime.ensureThread({
5513 threadId,
5514 modelSelection,
5515 runtimePolicy,
5516 });
5517 const now = yield* DateTime.now;
5518 yield* runtime.startTurn(
5519 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
5520 );
5521 yield* Deferred.await(promptSettled);
5522
5523 const firstProviderTurnId = idAllocator.derive.providerTurn({
5524 driver: ACP_TEST_DRIVER,
5525 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
5526 });
5527 yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId });
5528
5529 let subagentTurnItemId: string | null = null;
5530 let firstTerminalStatus: string | null = null;
5531 while (firstTerminalStatus === null) {
5532 const event = yield* Queue.take(events);
5533 if (event.type === "turn_item.updated" && event.turnItem.type === "subagent") {
5534 subagentTurnItemId = event.turnItem.id;
5535 }
5536 if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) {
5537 firstTerminalStatus = event.status;
5538 }
5539 }
5540 assert.equal(firstTerminalStatus, "interrupted");
5541 assert.notEqual(subagentTurnItemId, null);
5542
5543 subagentPhase = "complete";
5544 const secondNow = yield* DateTime.now;
5545 yield* runtime.startTurn(
5546 makeTurnInput({
5547 threadId,
5548 providerThread,
5549 instanceId,
5550 runtimePolicy,
5551 now: secondNow,
5552 ordinal: 2,
5553 }),
5554 );
5555 const secondProviderTurnId = idAllocator.derive.providerTurn({
5556 driver: ACP_TEST_DRIVER,
5557 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"),
5558 });
5559 let carriedItemStatus: string | null = null;
5560 let secondTerminalStatus: string | null = null;
5561 while (secondTerminalStatus === null) {
5562 const event = yield* Queue.take(events);
5563 if (
5564 event.type === "turn_item.updated" &&
5565 event.turnItem.type === "subagent" &&
5566 event.turnItem.id === subagentTurnItemId
5567 ) {
5568 carriedItemStatus = event.turnItem.status;
5569 }
5570 if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) {
5571 secondTerminalStatus = event.status;
5572 }
5573 }
5574 assert.equal(carriedItemStatus, "completed");
5575 assert.equal(secondTerminalStatus, "completed");
5576 }).pipe(Effect.provide(layerTest), Effect.scoped),
5577 );
5578
5579 it.effect(
5580 "pins hasPendingBackgroundWork while carryover holds a live subagent after root settle",
5581 () =>
5582 Effect.gen(function* () {
5583 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
5584 const idAllocator = yield* IdAllocator.IdAllocatorV2;
5585 const path = yield* Path.Path;
5586 const selfInvocation = yield* resolveSelfInvocation();
5587 const mockAgentPath = yield* path.fromFileUrl(
5588 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
5589 );
5590 const instanceId = ProviderInstanceId.make("acp-test");
5591 let subagentPhase: "spawn" | "complete" = "spawn";
5592 const promptSettled = yield* Deferred.make<void>();
5593 const adapter = yield* makeAcpAdapterV2({
5594 testHooks: {
5595 afterPromptSettledWithBackgroundWork: () =>
5596 Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid),
5597 },
5598 instanceId,
5599 flavor: {
5600 driver: ACP_TEST_DRIVER,
5601 capabilities: AcpProviderCapabilitiesV2,
5602 deferFinalizeForBackgroundWork: true,
5603 enablePostSettleContinuation: true,
5604 extractSubagentUpdate: (toolCall) =>
5605 toolCall.toolCallId !== "tool-call-generic-1"
5606 ? undefined
5607 : subagentPhase === "spawn"
5608 ? {
5609 nativeTaskId: "task-generic-1",
5610 prompt: "background subagent",
5611 title: "background subagent",
5612 model: null,
5613 status: "running",
5614 childSessionId: null,
5615 result: null,
5616 }
5617 : {
5618 nativeTaskId: "task-generic-1",
5619 prompt: "",
5620 title: null,
5621 model: null,
5622 status: "completed",
5623 childSessionId: null,
5624 result: "SUB_DONE",
5625 },
5626 makeRuntime: makeMockRuntime({
5627 childProcessSpawner,
5628 mockAgentPath,
5629 environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" },
5630 }),
5631 },
5632 selfInvocation,
5633 continuationRequests: { offer: () => Effect.void },
5634 });
5635 const threadId = ThreadId.make("thread-acp-carryover-pending-pin");
5636 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
5637 runtimeMode: "full-access",
5638 interactionMode: "default",
5639 cwd: process.cwd(),
5640 });
5641 const modelSelection = { instanceId, model: "default" } as const;
5642 const runtime = yield* adapter.openSession({
5643 threadId,
5644 providerSessionId: ProviderSessionId.make("provider-session-acp-carryover-pending-pin"),
5645 modelSelection,
5646 runtimePolicy,
5647 });
5648 if (runtime.hasPendingBackgroundWork === undefined) {
5649 return yield* Effect.die(
5650 "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.",
5651 );
5652 }
5653 const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork;
5654 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
5655 yield* runtime.events.pipe(
5656 Stream.runForEach((event) => Queue.offer(events, event)),
5657 Effect.forkScoped,
5658 );
5659 const providerThread = yield* runtime.ensureThread({
5660 threadId,
5661 modelSelection,
5662 runtimePolicy,
5663 });
5664 const now = yield* DateTime.now;
5665 yield* runtime.startTurn(
5666 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
5667 );
5668 yield* Deferred.await(promptSettled);
5669
5670 const firstProviderTurnId = idAllocator.derive.providerTurn({
5671 driver: ACP_TEST_DRIVER,
5672 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
5673 });
5674 yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId });
5675
5676 let firstTerminalStatus: string | null = null;
5677 while (firstTerminalStatus === null) {
5678 const event = yield* Queue.take(events);
5679 if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) {
5680 firstTerminalStatus = event.status;
5681 }
5682 }
5683 assert.equal(firstTerminalStatus, "interrupted");
5684 // Root settled with a live projected subagent in carryover: pin idle release.
5685 assert.isTrue(
5686 yield* hasPendingBackgroundWork,
5687 "carryover live subagent must pin hasPendingBackgroundWork after root settle",
5688 );
5689
5690 subagentPhase = "complete";
5691 const secondNow = yield* DateTime.now;
5692 yield* runtime.startTurn(
5693 makeTurnInput({
5694 threadId,
5695 providerThread,
5696 instanceId,
5697 runtimePolicy,
5698 now: secondNow,
5699 ordinal: 2,
5700 }),
5701 );
5702 const secondProviderTurnId = idAllocator.derive.providerTurn({
5703 driver: ACP_TEST_DRIVER,
5704 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"),
5705 });
5706 let secondTerminalStatus: string | null = null;
5707 while (secondTerminalStatus === null) {
5708 const event = yield* Queue.take(events);
5709 if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) {
5710 secondTerminalStatus = event.status;
5711 }
5712 }
5713 assert.equal(secondTerminalStatus, "completed");
5714 // Carryover is consumed into the next turn and terminalized; pin clears.
5715 assert.isFalse(
5716 yield* hasPendingBackgroundWork,
5717 "hasPendingBackgroundWork must clear after carryover subagent terminals",
5718 );
5719 }).pipe(Effect.provide(layerTest), Effect.scoped),
5720 );
5721
5722 it.effect("handles a child terminal after carryover rehydrate", () =>
5723 Effect.gen(function* () {
5724 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
5725 const idAllocator = yield* IdAllocator.IdAllocatorV2;
5726 const path = yield* Path.Path;
5727 const selfInvocation = yield* resolveSelfInvocation();
5728 const mockAgentPath = yield* path.fromFileUrl(
5729 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
5730 );
5731 const secondPromptWireReturned = yield* Deferred.make<void>();
5732 const releaseSecondPromptCompletion = yield* Deferred.make<void>();
5733 const instanceId = ProviderInstanceId.make("acp-test");
5734 const childSessionId = "mock-child-session-active-carryover";
5735 let promptCount = 0;
5736 let subagentPhase: "spawn" | "complete" = "spawn";
5737 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
5738 let sessionUpdateHandler: Parameters<RuntimeService["handleSessionUpdate"]>[0] | undefined;
5739 const promptSettled = yield* Deferred.make<void>();
5740 const adapter = yield* makeAcpAdapterV2({
5741 testHooks: {
5742 afterPromptSettledWithBackgroundWork: () =>
5743 Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid),
5744 },
5745 instanceId,
5746 flavor: {
5747 driver: ACP_TEST_DRIVER,
5748 capabilities: AcpProviderCapabilitiesV2,
5749 deferFinalizeForBackgroundWork: true,
5750 enablePostSettleContinuation: true,
5751 extractSubagentUpdate: (toolCall) =>
5752 toolCall.toolCallId !== "tool-call-generic-1"
5753 ? undefined
5754 : subagentPhase === "spawn"
5755 ? {
5756 nativeTaskId: "task-generic-1",
5757 prompt: "background subagent",
5758 title: "background subagent",
5759 model: null,
5760 status: "running",
5761 childSessionId,
5762 result: null,
5763 }
5764 : {
5765 nativeTaskId: "task-generic-1",
5766 prompt: "",
5767 title: null,
5768 model: null,
5769 status: "completed",
5770 childSessionId,
5771 result: "SUB_DONE",
5772 },
5773 makeRuntime: makeMockRuntime({
5774 childProcessSpawner,
5775 mockAgentPath,
5776 environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" },
5777 wrapRuntime: (runtime) => ({
5778 ...runtime,
5779 handleSessionUpdate: (handler) =>
5780 Effect.sync(() => {
5781 sessionUpdateHandler = handler;
5782 }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))),
5783 prompt: (payload) =>
5784 Effect.gen(function* () {
5785 const currentPrompt = ++promptCount;
5786 const result = yield* runtime.prompt(payload);
5787 if (currentPrompt === 2) {
5788 yield* Deferred.succeed(secondPromptWireReturned, undefined);
5789 yield* Deferred.await(releaseSecondPromptCompletion);
5790 }
5791 return result;
5792 }),
5793 }),
5794 }),
5795 },
5796 selfInvocation,
5797 continuationRequests: { offer: () => Effect.void },
5798 });
5799 const threadId = ThreadId.make("thread-acp-active-carryover-pending-pin");
5800 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
5801 runtimeMode: "full-access",
5802 interactionMode: "default",
5803 cwd: process.cwd(),
5804 });
5805 const modelSelection = { instanceId, model: "default" } as const;
5806 const runtime = yield* adapter.openSession({
5807 threadId,
5808 providerSessionId: ProviderSessionId.make(
5809 "provider-session-acp-active-carryover-pending-pin",
5810 ),
5811 modelSelection,
5812 runtimePolicy,
5813 });
5814 if (runtime.hasPendingBackgroundWork === undefined) {
5815 return yield* Effect.die(
5816 "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.",
5817 );
5818 }
5819 const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork;
5820 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
5821 yield* runtime.events.pipe(
5822 Stream.runForEach((event) => Queue.offer(events, event)),
5823 Effect.forkScoped,
5824 );
5825 const providerThread = yield* runtime.ensureThread({
5826 threadId,
5827 modelSelection,
5828 runtimePolicy,
5829 });
5830 const now = yield* DateTime.now;
5831 yield* runtime.startTurn(
5832 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
5833 );
5834 yield* Deferred.await(promptSettled);
5835
5836 const firstProviderTurnId = idAllocator.derive.providerTurn({
5837 driver: ACP_TEST_DRIVER,
5838 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
5839 });
5840 yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId });
5841
5842 let firstTerminalStatus: string | null = null;
5843 while (firstTerminalStatus === null) {
5844 const event = yield* Queue.take(events);
5845 if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) {
5846 firstTerminalStatus = event.status;
5847 }
5848 }
5849 assert.equal(firstTerminalStatus, "interrupted");
5850 assert.isTrue(yield* hasPendingBackgroundWork);
5851 assert.isDefined(sessionUpdateHandler, "session update handler must be wired");
5852
5853 const secondNow = yield* DateTime.now;
5854 const secondTurnFiber = yield* runtime
5855 .startTurn(
5856 makeTurnInput({
5857 threadId,
5858 providerThread,
5859 instanceId,
5860 runtimePolicy,
5861 now: secondNow,
5862 ordinal: 2,
5863 }),
5864 )
5865 .pipe(Effect.forkScoped);
5866 yield* Deferred.await(secondPromptWireReturned);
5867 assert.isTrue(
5868 yield* hasPendingBackgroundWork,
5869 "rehydrated live subagent must pin from activeTurn",
5870 );
5871 while (Option.isSome(yield* Queue.poll(events))) {
5872 // Discard setup events so the assertions below cover only child-session
5873 // tool traffic after rehydration.
5874 }
5875
5876 subagentPhase = "complete";
5877 yield* sessionUpdateHandler!({
5878 sessionId: childSessionId,
5879 update: {
5880 sessionUpdate: "tool_call_update",
5881 toolCallId: "tool-call-generic-1",
5882 title: "background subagent",
5883 kind: "other",
5884 status: "completed",
5885 rawOutput: { content: "SUB_DONE" },
5886 },
5887 });
5888 // Duplicate terminal replay is harmless, and an older running frame cannot
5889 // resurrect the completed lineage.
5890 yield* sessionUpdateHandler!({
5891 sessionId: childSessionId,
5892 update: {
5893 sessionUpdate: "tool_call_update",
5894 toolCallId: "tool-call-generic-1",
5895 title: "background subagent",
5896 kind: "other",
5897 status: "completed",
5898 rawOutput: { content: "SUB_DONE" },
5899 },
5900 });
5901 subagentPhase = "spawn";
5902 yield* sessionUpdateHandler!({
5903 sessionId: childSessionId,
5904 update: {
5905 sessionUpdate: "tool_call_update",
5906 toolCallId: "tool-call-generic-1",
5907 title: "background subagent",
5908 kind: "other",
5909 status: "in_progress",
5910 rawOutput: {},
5911 },
5912 });
5913 yield* Effect.yieldNow;
5914 yield* Effect.yieldNow;
5915 assert.isFalse(
5916 yield* hasPendingBackgroundWork,
5917 "active-turn pin must clear after the child-session terminal is projected",
5918 );
5919 let completedSubagentUpdates = 0;
5920 let resurrectedSubagentUpdates = 0;
5921 let normalChildToolUpdates = 0;
5922 let polled = yield* Queue.poll(events);
5923 while (Option.isSome(polled)) {
5924 const event = polled.value;
5925 if (event.type === "turn_item.updated") {
5926 if (event.turnItem.type === "subagent") {
5927 if (event.turnItem.status === "completed") {
5928 completedSubagentUpdates += 1;
5929 }
5930 if (
5931 event.turnItem.status === "running" &&
5932 event.turnItem.nativeItemRef?.nativeId === "task-generic-1"
5933 ) {
5934 resurrectedSubagentUpdates += 1;
5935 }
5936 } else if (event.turnItem.nativeItemRef?.nativeId === "tool-call-generic-1") {
5937 normalChildToolUpdates += 1;
5938 }
5939 }
5940 polled = yield* Queue.poll(events);
5941 }
5942 assert.equal(completedSubagentUpdates, 1);
5943 assert.equal(resurrectedSubagentUpdates, 0);
5944 assert.equal(normalChildToolUpdates, 0);
5945
5946 yield* Deferred.succeed(releaseSecondPromptCompletion, undefined);
5947 yield* Fiber.join(secondTurnFiber);
5948 }).pipe(Effect.provide(layerTest), Effect.scoped),
5949 );
5950
5951 it.effect(
5952 "projects a child terminal in the completed-root finalization window exactly once",
5953 () =>
5954 Effect.gen(function* () {
5955 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
5956 const idAllocator = yield* IdAllocator.IdAllocatorV2;
5957 const path = yield* Path.Path;
5958 const selfInvocation = yield* resolveSelfInvocation();
5959 const mockAgentPath = yield* path.fromFileUrl(
5960 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
5961 );
5962 const baseClock = yield* Clock.Clock;
5963 const finalizationClockRead = yield* Deferred.make<void>();
5964 const releaseFinalizationClockRead = yield* Deferred.make<void>();
5965 let blockNextClockRead = false;
5966 const blockingClock: Clock.Clock = {
5967 ...baseClock,
5968 currentTimeMillis: Effect.suspend(() => {
5969 if (!blockNextClockRead) return baseClock.currentTimeMillis;
5970 blockNextClockRead = false;
5971 return Deferred.succeed(finalizationClockRead, undefined).pipe(
5972 Effect.andThen(Deferred.await(releaseFinalizationClockRead)),
5973 Effect.andThen(baseClock.currentTimeMillis),
5974 );
5975 }),
5976 };
5977 yield* Effect.gen(function* () {
5978 const instanceId = ProviderInstanceId.make("acp-test");
5979 const firstChildSessionId = "mock-child-session-finalize-window-first";
5980 const secondChildSessionId = "mock-child-session-finalize-window-second";
5981 let firstSubagentStatus: "running" | "completed" = "running";
5982 // Keep the pending-work pin without blocking the deferred-finalize
5983 // timer, so the test can stop inside finalizeTurn deterministically.
5984 let secondSubagentStatus: "waiting" | "completed" = "waiting";
5985 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
5986 let sessionUpdateHandler:
5987 | Parameters<RuntimeService["handleSessionUpdate"]>[0]
5988 | undefined;
5989 const promptSettled = yield* Deferred.make<void>();
5990 const adapter = yield* makeAcpAdapterV2({
5991 testHooks: {
5992 afterPromptSettledWithBackgroundWork: () =>
5993 Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid),
5994 },
5995 instanceId,
5996 flavor: {
5997 driver: ACP_TEST_DRIVER,
5998 capabilities: AcpProviderCapabilitiesV2,
5999 deferFinalizeForBackgroundWork: true,
6000 enablePostSettleContinuation: true,
6001 extractSubagentUpdate: (toolCall) => {
6002 if (toolCall.toolCallId === "tool-call-generic-1") {
6003 return {
6004 nativeTaskId: "task-finalize-window-first",
6005 prompt: "first background subagent",
6006 title: "first background subagent",
6007 model: null,
6008 status: firstSubagentStatus,
6009 childSessionId: firstChildSessionId,
6010 result: firstSubagentStatus === "completed" ? "FIRST_DONE" : null,
6011 suppressNormalTool: true,
6012 };
6013 }
6014 if (toolCall.toolCallId === "tool-call-generic-2") {
6015 return {
6016 nativeTaskId: "task-finalize-window-second",
6017 prompt: "second background subagent",
6018 title: "second background subagent",
6019 model: null,
6020 status: secondSubagentStatus,
6021 childSessionId: secondChildSessionId,
6022 result: secondSubagentStatus === "completed" ? "SECOND_DONE" : null,
6023 suppressNormalTool: true,
6024 } as AcpAdapterV2SubagentUpdate;
6025 }
6026 return undefined;
6027 },
6028 makeRuntime: makeMockRuntime({
6029 childProcessSpawner,
6030 mockAgentPath,
6031 environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" },
6032 wrapRuntime: (runtime) => ({
6033 ...runtime,
6034 handleSessionUpdate: (handler) =>
6035 Effect.sync(() => {
6036 sessionUpdateHandler = handler;
6037 }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))),
6038 }),
6039 }),
6040 },
6041 selfInvocation,
6042 continuationRequests: { offer: () => Effect.void },
6043 });
6044 const threadId = ThreadId.make("thread-acp-subagent-finalize-window");
6045 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
6046 runtimeMode: "full-access",
6047 interactionMode: "default",
6048 cwd: process.cwd(),
6049 });
6050 const modelSelection = { instanceId, model: "default" } as const;
6051 const runtime = yield* adapter.openSession({
6052 threadId,
6053 providerSessionId: ProviderSessionId.make(
6054 "provider-session-acp-subagent-finalize-window",
6055 ),
6056 modelSelection,
6057 runtimePolicy,
6058 });
6059 if (runtime.hasPendingBackgroundWork === undefined) {
6060 return yield* Effect.die(
6061 "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.",
6062 );
6063 }
6064 const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork;
6065 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
6066 yield* runtime.events.pipe(
6067 Stream.runForEach((event) => Queue.offer(events, event)),
6068 Effect.forkScoped,
6069 );
6070 const providerThread = yield* runtime.ensureThread({
6071 threadId,
6072 modelSelection,
6073 runtimePolicy,
6074 });
6075 const now = yield* DateTime.now;
6076 yield* runtime.startTurn(
6077 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
6078 );
6079 yield* Deferred.await(promptSettled);
6080 yield* TestClock.adjust("1 second");
6081 assert.isDefined(sessionUpdateHandler, "session update handler must be wired");
6082
6083 firstSubagentStatus = "completed";
6084 yield* sessionUpdateHandler!({
6085 sessionId: "mock-session-1",
6086 update: {
6087 sessionUpdate: "tool_call_update",
6088 toolCallId: "tool-call-generic-1",
6089 title: "first background subagent",
6090 kind: "other",
6091 status: "completed",
6092 rawOutput: { content: "FIRST_DONE" },
6093 },
6094 }).pipe(Effect.provideService(Clock.Clock, blockingClock));
6095 for (let attempt = 0; attempt < 10; attempt += 1) {
6096 yield* Effect.yieldNow;
6097 }
6098
6099 yield* sessionUpdateHandler!({
6100 sessionId: "mock-session-1",
6101 update: {
6102 sessionUpdate: "tool_call_update",
6103 toolCallId: "tool-call-generic-2",
6104 title: "second background subagent",
6105 kind: "other",
6106 status: "in_progress",
6107 rawOutput: {},
6108 },
6109 });
6110 assert.isTrue(yield* hasPendingBackgroundWork);
6111
6112 while (Option.isSome(yield* Queue.poll(events))) {
6113 // Discard setup and first-subagent events.
6114 }
6115
6116 blockNextClockRead = true;
6117 const adjustFiber = yield* TestClock.adjust("3 seconds").pipe(Effect.forkScoped);
6118 yield* Deferred.await(finalizationClockRead);
6119 secondSubagentStatus = "completed";
6120 yield* sessionUpdateHandler!({
6121 sessionId: secondChildSessionId,
6122 update: {
6123 sessionUpdate: "tool_call_update",
6124 toolCallId: "tool-call-generic-2",
6125 title: "second background subagent",
6126 kind: "other",
6127 status: "completed",
6128 rawOutput: { content: "SECOND_DONE" },
6129 },
6130 });
6131 yield* Deferred.succeed(releaseFinalizationClockRead, undefined);
6132 yield* Fiber.join(adjustFiber);
6133 yield* Effect.yieldNow;
6134 yield* Effect.yieldNow;
6135
6136 const providerTurnId = idAllocator.derive.providerTurn({
6137 driver: ACP_TEST_DRIVER,
6138 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
6139 });
6140 let completedSubagentUpdates = 0;
6141 let rootTerminalStatus: string | null = null;
6142 let polled = yield* Queue.poll(events);
6143 while (Option.isSome(polled)) {
6144 const event = polled.value;
6145 if (
6146 event.type === "turn_item.updated" &&
6147 event.turnItem.type === "subagent" &&
6148 event.turnItem.nativeItemRef?.nativeId === "task-finalize-window-second" &&
6149 event.turnItem.status === "completed"
6150 ) {
6151 completedSubagentUpdates += 1;
6152 }
6153 if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) {
6154 rootTerminalStatus = event.status;
6155 }
6156 polled = yield* Queue.poll(events);
6157 }
6158 assert.equal(rootTerminalStatus, "completed");
6159 assert.equal(completedSubagentUpdates, 1);
6160 assert.isFalse(
6161 yield* hasPendingBackgroundWork,
6162 "finalize-window terminal must clear the carryover pin",
6163 );
6164 }).pipe(Effect.provideService(Clock.Clock, blockingClock));
6165 }).pipe(Effect.provide(layerTest), Effect.scoped),
6166 );
6167
6168 it.effect(
6169 "defers an interrupted pending-spawn carryover terminal until the next observable attach",
6170 () =>
6171 Effect.gen(function* () {
6172 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
6173 const idAllocator = yield* IdAllocator.IdAllocatorV2;
6174 const path = yield* Path.Path;
6175 const selfInvocation = yield* resolveSelfInvocation();
6176 const mockAgentPath = yield* path.fromFileUrl(
6177 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
6178 );
6179 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
6180 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
6181 [];
6182 const promptSettled = yield* Deferred.make<void>();
6183 const instanceId = ProviderInstanceId.make("acp-test");
6184 const childSessionId = "mock-child-session-post-settle";
6185 let subagentPhase: "spawn" | "complete" = "spawn";
6186 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
6187 let sessionUpdateHandler: Parameters<RuntimeService["handleSessionUpdate"]>[0] | undefined;
6188 const adapter = yield* makeAcpAdapterV2({
6189 testHooks: {
6190 afterPromptSettledWithBackgroundWork: () =>
6191 Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid),
6192 },
6193 instanceId,
6194 flavor: {
6195 driver: ACP_TEST_DRIVER,
6196 capabilities: AcpProviderCapabilitiesV2,
6197 deferFinalizeForBackgroundWork: true,
6198 enablePostSettleContinuation: true,
6199 extractSubagentUpdate: (toolCall) =>
6200 toolCall.toolCallId !== "tool-call-generic-1"
6201 ? undefined
6202 : subagentPhase === "spawn"
6203 ? {
6204 nativeTaskId: "task-generic-1",
6205 prompt: "background subagent",
6206 title: "background subagent",
6207 model: null,
6208 status: "pending",
6209 childSessionId,
6210 result: null,
6211 }
6212 : {
6213 nativeTaskId: "task-generic-1",
6214 prompt: "",
6215 title: null,
6216 model: null,
6217 status: "completed",
6218 childSessionId,
6219 result: "SUB_DONE",
6220 },
6221 makeRuntime: makeMockRuntime({
6222 childProcessSpawner,
6223 mockAgentPath,
6224 environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" },
6225 protocolEvents,
6226 wrapRuntime: (runtime) => ({
6227 ...runtime,
6228 handleSessionUpdate: (handler) =>
6229 Effect.sync(() => {
6230 sessionUpdateHandler = handler;
6231 }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))),
6232 }),
6233 }),
6234 },
6235 selfInvocation,
6236 continuationRequests: {
6237 offer: (request) =>
6238 Effect.sync(() => {
6239 continuationRequests.push(request);
6240 }),
6241 },
6242 });
6243 const threadId = ThreadId.make("thread-acp-carryover-child-session-post-settle");
6244 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
6245 runtimeMode: "full-access",
6246 interactionMode: "default",
6247 cwd: process.cwd(),
6248 });
6249 const modelSelection = { instanceId, model: "default" } as const;
6250 const runtime = yield* adapter.openSession({
6251 threadId,
6252 providerSessionId: ProviderSessionId.make(
6253 "provider-session-acp-carryover-child-session-post-settle",
6254 ),
6255 modelSelection,
6256 runtimePolicy,
6257 });
6258 if (runtime.hasPendingBackgroundWork === undefined) {
6259 return yield* Effect.die(
6260 "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.",
6261 );
6262 }
6263 const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork;
6264 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
6265 yield* runtime.events.pipe(
6266 Stream.runForEach((event) => Queue.offer(events, event)),
6267 Effect.forkScoped,
6268 );
6269 const providerThread = yield* runtime.ensureThread({
6270 threadId,
6271 modelSelection,
6272 runtimePolicy,
6273 });
6274 const now = yield* DateTime.now;
6275 yield* runtime.startTurn(
6276 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
6277 );
6278 yield* Deferred.await(promptSettled);
6279
6280 const firstProviderTurnId = idAllocator.derive.providerTurn({
6281 driver: ACP_TEST_DRIVER,
6282 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
6283 });
6284 yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId });
6285
6286 let firstTerminalStatus: string | null = null;
6287 while (firstTerminalStatus === null) {
6288 const event = yield* Queue.take(events);
6289 if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) {
6290 firstTerminalStatus = event.status;
6291 }
6292 }
6293 assert.equal(firstTerminalStatus, "interrupted");
6294 assert.isTrue(
6295 yield* hasPendingBackgroundWork,
6296 "carryover live subagent must pin hasPendingBackgroundWork after root settle",
6297 );
6298 assert.isDefined(sessionUpdateHandler, "session update handler must be wired");
6299
6300 // The interrupted root's subscription is already closed in execution
6301 // service, so the adapter must retain this terminal without claiming it
6302 // reached the projection.
6303 subagentPhase = "complete";
6304 yield* sessionUpdateHandler!({
6305 sessionId: childSessionId,
6306 update: {
6307 sessionUpdate: "tool_call_update",
6308 toolCallId: "tool-call-generic-1",
6309 title: "background subagent",
6310 kind: "other",
6311 status: "completed",
6312 rawOutput: { content: "SUB_DONE" },
6313 },
6314 });
6315 yield* Effect.yieldNow;
6316 yield* Effect.yieldNow;
6317 let completedBeforeAttach = 0;
6318 let polled = yield* Queue.poll(events);
6319 while (Option.isSome(polled)) {
6320 const event = polled.value;
6321 if (
6322 event.type === "turn_item.updated" &&
6323 event.turnItem.type === "subagent" &&
6324 event.turnItem.status === "completed"
6325 ) {
6326 completedBeforeAttach += 1;
6327 }
6328 polled = yield* Queue.poll(events);
6329 }
6330 assert.equal(
6331 completedBeforeAttach,
6332 0,
6333 "closed interrupted subscription must not be treated as projected",
6334 );
6335 assert.isTrue(
6336 yield* hasPendingBackgroundWork,
6337 "terminal carryover must stay pinned until an attach can project it",
6338 );
6339 assert.lengthOf(
6340 continuationRequests,
6341 0,
6342 "child-session completion must not open a root continuation",
6343 );
6344
6345 const attachNow = yield* DateTime.now;
6346 yield* runtime.startTurn(
6347 makeTurnInput({
6348 threadId,
6349 providerThread,
6350 instanceId,
6351 runtimePolicy,
6352 now: attachNow,
6353 ordinal: 2,
6354 messageCreatedBy: "agent",
6355 messageCreationSource: "provider",
6356 messageText: "Attach deferred completion.",
6357 }),
6358 );
6359 const attachProviderTurnId = idAllocator.derive.providerTurn({
6360 driver: ACP_TEST_DRIVER,
6361 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"),
6362 });
6363 let attachTerminal: string | null = null;
6364 let completedAfterAttach = 0;
6365 while (attachTerminal === null) {
6366 const event = yield* Queue.take(events);
6367 if (
6368 event.type === "turn_item.updated" &&
6369 event.turnItem.type === "subagent" &&
6370 event.turnItem.status === "completed"
6371 ) {
6372 completedAfterAttach += 1;
6373 }
6374 if (event.type === "turn.terminal" && event.providerTurnId === attachProviderTurnId) {
6375 attachTerminal = event.status;
6376 }
6377 }
6378 assert.equal(attachTerminal, "completed");
6379 assert.equal(completedAfterAttach, 1);
6380 assert.isFalse(yield* hasPendingBackgroundWork);
6381 }).pipe(Effect.provide(layerTest), Effect.scoped),
6382 );
6383
6384 it.effect("finishes a settled root's carryover subagent from its structured end", () =>
6385 Effect.gen(function* () {
6386 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
6387 const idAllocator = yield* IdAllocator.IdAllocatorV2;
6388 const path = yield* Path.Path;
6389 const selfInvocation = yield* resolveSelfInvocation();
6390 const mockAgentPath = yield* path.fromFileUrl(
6391 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
6392 );
6393 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
6394 const instanceId = ProviderInstanceId.make("acp-test");
6395 const childSessionId = "019f44a6-4820-7402-925d-bc862ee711dd";
6396 let finishSubagent: AcpAdapterV2ExtensionContext["finishSubagent"] | undefined;
6397 const adapter = yield* makeAcpAdapterV2({
6398 instanceId,
6399 flavor: {
6400 driver: ACP_TEST_DRIVER,
6401 capabilities: AcpProviderCapabilitiesV2,
6402 enablePostSettleContinuation: true,
6403 // The spawn tool reports a background subagent that keeps running.
6404 extractSubagentUpdate: (toolCall) =>
6405 toolCall.toolCallId !== "tool-call-generic-1"
6406 ? undefined
6407 : {
6408 nativeTaskId: "task-generic-1",
6409 prompt: "background subagent",
6410 title: "background subagent",
6411 model: null,
6412 status: "running",
6413 childSessionId,
6414 result: null,
6415 },
6416 registerExtensions: (context) =>
6417 Effect.sync(() => {
6418 finishSubagent = context.finishSubagent;
6419 }),
6420 makeRuntime: makeMockRuntime({
6421 childProcessSpawner,
6422 mockAgentPath,
6423 environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" },
6424 protocolEvents,
6425 }),
6426 },
6427 selfInvocation,
6428 continuationRequests: { offer: () => Effect.void },
6429 });
6430 const threadId = ThreadId.make("thread-acp-carryover-subagent-finished");
6431 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
6432 runtimeMode: "full-access",
6433 interactionMode: "default",
6434 cwd: process.cwd(),
6435 });
6436 const modelSelection = { instanceId, model: "default" } as const;
6437 const runtime = yield* adapter.openSession({
6438 threadId,
6439 providerSessionId: ProviderSessionId.make("provider-session-acp-carryover-finished"),
6440 modelSelection,
6441 runtimePolicy,
6442 });
6443 if (runtime.hasPendingBackgroundWork === undefined) {
6444 return yield* Effect.die("post-settle continuation must expose hasPendingBackgroundWork");
6445 }
6446 const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork;
6447 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
6448 yield* runtime.events.pipe(
6449 Stream.runForEach((event) => Queue.offer(events, event)),
6450 Effect.forkScoped,
6451 );
6452 const providerThread = yield* runtime.ensureThread({
6453 threadId,
6454 modelSelection,
6455 runtimePolicy,
6456 });
6457 yield* runtime.startTurn(
6458 makeTurnInput({
6459 threadId,
6460 providerThread,
6461 instanceId,
6462 runtimePolicy,
6463 now: yield* DateTime.now,
6464 }),
6465 );
6466 const providerTurnId = idAllocator.derive.providerTurn({
6467 driver: ACP_TEST_DRIVER,
6468 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
6469 });
6470 let rootStatus: string | null = null;
6471 while (rootStatus === null) {
6472 const event = yield* Queue.take(events);
6473 if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) {
6474 rootStatus = event.status;
6475 }
6476 }
6477 // The root completed with the subagent still running: it is carryover.
6478 assert.equal(rootStatus, "completed");
6479 assert.isTrue(yield* hasPendingBackgroundWork);
6480 assert.isDefined(finishSubagent);
6481 const subagentStatuses = Effect.gen(function* () {
6482 // Adapter events reach this queue through the events stream fiber.
6483 yield* Effect.yieldNow;
6484 yield* Effect.yieldNow;
6485 const statuses: Array<[string, string | null]> = [];
6486 let polled = yield* Queue.poll(events);
6487 while (Option.isSome(polled)) {
6488 const event = polled.value;
6489 if (event.type === "turn_item.updated" && event.turnItem.type === "subagent") {
6490 statuses.push([event.turnItem.status, event.turnItem.result]);
6491 }
6492 polled = yield* Queue.poll(events);
6493 }
6494 return statuses;
6495 });
6496 yield* subagentStatuses;
6497
6498 // A nested subagent reports to its own parent session, not the root.
6499 yield* finishSubagent!({
6500 sessionId: "some-other-session",
6501 childSessionId,
6502 status: "completed",
6503 result: "WRONG_PARENT",
6504 });
6505 assert.deepEqual(yield* subagentStatuses, [], "non-root notices must be dropped");
6506 assert.isTrue(yield* hasPendingBackgroundWork);
6507
6508 yield* finishSubagent!({
6509 sessionId: "mock-session-1",
6510 childSessionId,
6511 status: "failed",
6512 result: "tool crashed",
6513 });
6514 assert.deepEqual(
6515 yield* subagentStatuses,
6516 [["failed", "tool crashed"]],
6517 "the completed root still owns the run, so the carryover end projects at once",
6518 );
6519 assert.isFalse(
6520 yield* hasPendingBackgroundWork,
6521 "a finished carryover subagent stops pinning",
6522 );
6523 }).pipe(Effect.provide(layerTest), Effect.scoped),
6524 );
6525
6526 it.effect("projects completed-root carryover eagerly and drain cannot resurrect it", () =>
6527 Effect.gen(function* () {
6528 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
6529 const idAllocator = yield* IdAllocator.IdAllocatorV2;
6530 const path = yield* Path.Path;
6531 const selfInvocation = yield* resolveSelfInvocation();
6532 const mockAgentPath = yield* path.fromFileUrl(
6533 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
6534 );
6535 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
6536 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
6537 [];
6538 const instanceId = ProviderInstanceId.make("acp-test");
6539 const childSessionId = "019f44a6-4820-7402-925d-bc862ee711dd";
6540 let subagentPhase: "spawn" | "complete" = "spawn";
6541 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
6542 let sessionUpdateHandler: Parameters<RuntimeService["handleSessionUpdate"]>[0] | undefined;
6543 const adapter = yield* makeAcpAdapterV2({
6544 instanceId,
6545 flavor: {
6546 driver: ACP_TEST_DRIVER,
6547 capabilities: AcpProviderCapabilitiesV2,
6548 enablePostSettleContinuation: true,
6549 normalizeToolCall: normalizeXAiAcpToolCallState,
6550 extractSubagentUpdate: (toolCall) =>
6551 extractXAiAcpSubagentUpdate(toolCall) ??
6552 (toolCall.toolCallId !== "tool-call-generic-1"
6553 ? undefined
6554 : subagentPhase === "spawn"
6555 ? {
6556 nativeTaskId: "task-generic-1",
6557 prompt: "background subagent",
6558 title: "background subagent",
6559 model: null,
6560 status: "running",
6561 childSessionId,
6562 result: null,
6563 }
6564 : {
6565 nativeTaskId: "task-generic-1",
6566 prompt: "",
6567 title: null,
6568 model: null,
6569 status: "completed",
6570 childSessionId,
6571 result: "SUB_DONE",
6572 }),
6573 makeRuntime: makeMockRuntime({
6574 childProcessSpawner,
6575 mockAgentPath,
6576 environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" },
6577 protocolEvents,
6578 wrapRuntime: (runtime) => ({
6579 ...runtime,
6580 handleSessionUpdate: (handler) =>
6581 Effect.sync(() => {
6582 sessionUpdateHandler = handler;
6583 }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))),
6584 }),
6585 }),
6586 },
6587 selfInvocation,
6588 continuationRequests: {
6589 offer: (request) =>
6590 Effect.sync(() => {
6591 continuationRequests.push(request);
6592 }),
6593 },
6594 });
6595 const threadId = ThreadId.make("thread-acp-completed-root-eager-carryover");
6596 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
6597 runtimeMode: "full-access",
6598 interactionMode: "default",
6599 cwd: process.cwd(),
6600 });
6601 const modelSelection = { instanceId, model: "default" } as const;
6602 const runtime = yield* adapter.openSession({
6603 threadId,
6604 providerSessionId: ProviderSessionId.make(
6605 "provider-session-acp-completed-root-eager-carryover",
6606 ),
6607 modelSelection,
6608 runtimePolicy,
6609 });
6610 if (runtime.hasPendingBackgroundWork === undefined) {
6611 return yield* Effect.die(
6612 "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.",
6613 );
6614 }
6615 const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork;
6616 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
6617 yield* runtime.events.pipe(
6618 Stream.runForEach((event) => Queue.offer(events, event)),
6619 Effect.forkScoped,
6620 );
6621 const providerThread = yield* runtime.ensureThread({
6622 threadId,
6623 modelSelection,
6624 runtimePolicy,
6625 });
6626 const now = yield* DateTime.now;
6627 yield* runtime.startTurn(
6628 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
6629 );
6630 yield* Stream.fromQueue(protocolEvents).pipe(
6631 Stream.filter(
6632 (event) =>
6633 event.direction === "incoming" &&
6634 event.stage === "raw" &&
6635 typeof event.payload === "string" &&
6636 event.payload.includes('"stopReason"'),
6637 ),
6638 Stream.runHead,
6639 );
6640 yield* Effect.yieldNow;
6641 yield* Effect.yieldNow;
6642 yield* TestClock.adjust("1 second");
6643
6644 const firstProviderTurnId = idAllocator.derive.providerTurn({
6645 driver: ACP_TEST_DRIVER,
6646 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
6647 });
6648 let firstTerminalStatus: string | null = null;
6649 while (firstTerminalStatus === null) {
6650 const event = yield* Queue.take(events);
6651 if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) {
6652 firstTerminalStatus = event.status;
6653 }
6654 }
6655 assert.equal(firstTerminalStatus, "completed");
6656 assert.isTrue(yield* hasPendingBackgroundWork);
6657 assert.isDefined(sessionUpdateHandler, "session update handler must be wired");
6658
6659 // A production Grok spawn ACK has raw tool status completed but extracts
6660 // as a running subagent with its original non-empty prompt. It buffers and
6661 // offers a continuation after root settlement.
6662 yield* sessionUpdateHandler!({
6663 sessionId: "mock-session-1",
6664 update: {
6665 sessionUpdate: "tool_call",
6666 toolCallId: "tool-call-generic-1",
6667 title: "spawn_subagent",
6668 kind: "other",
6669 status: "completed",
6670 rawInput: {
6671 description: "background subagent",
6672 prompt: "background subagent",
6673 subagent_type: "general-purpose",
6674 },
6675 rawOutput: {
6676 type: "Text",
6677 text: [
6678 "Subagent started in background.",
6679 `subagent_id: ${childSessionId}`,
6680 "type: general-purpose",
6681 "description: background subagent",
6682 "",
6683 `Use get_command_or_subagent_output with task_ids=["${childSessionId}"] and timeout_ms to wait for results.`,
6684 ].join("\n"),
6685 },
6686 },
6687 });
6688
6689 // The child-session terminal bypasses the root wake buffer and projects
6690 // eagerly through the still-observable completed-root subscriber.
6691 subagentPhase = "complete";
6692 yield* sessionUpdateHandler!({
6693 sessionId: childSessionId,
6694 update: {
6695 sessionUpdate: "tool_call_update" as const,
6696 toolCallId: "tool-call-generic-1",
6697 title: "background subagent",
6698 kind: "other" as const,
6699 status: "completed" as const,
6700 rawOutput: { content: "SUB_DONE" },
6701 },
6702 });
6703 yield* Effect.yieldNow;
6704 yield* Effect.yieldNow;
6705
6706 let eagerCompletedUpdates = 0;
6707 let eagerRunningUpdates = 0;
6708 let polled = yield* Queue.poll(events);
6709 while (Option.isSome(polled)) {
6710 const event = polled.value;
6711 if (event.type === "turn_item.updated" && event.turnItem.type === "subagent") {
6712 if (event.turnItem.status === "completed") eagerCompletedUpdates += 1;
6713 if (event.turnItem.status === "running") eagerRunningUpdates += 1;
6714 }
6715 polled = yield* Queue.poll(events);
6716 }
6717 assert.equal(eagerCompletedUpdates, 1, "completed root must project before any attach");
6718 assert.equal(eagerRunningUpdates, 0);
6719 assert.lengthOf(continuationRequests, 1);
6720 assert.isTrue(yield* hasPendingBackgroundWork, "buffered spawn ACK still requires a drain");
6721
6722 const continuationNow = yield* DateTime.now;
6723 const continuationInput = makeTurnInput({
6724 threadId,
6725 providerThread,
6726 instanceId,
6727 runtimePolicy,
6728 now: continuationNow,
6729 ordinal: 2,
6730 messageCreatedBy: "agent",
6731 messageCreationSource: "provider",
6732 messageText: "Background task completed.",
6733 });
6734 yield* runtime.startTurn(continuationInput);
6735 yield* Effect.yieldNow;
6736 let replayedLineages = 0;
6737 let replayedSubagentUpdates = 0;
6738 let replayedTurnItems = 0;
6739 polled = yield* Queue.poll(events);
6740 while (Option.isSome(polled)) {
6741 const event = polled.value;
6742 if (event.type === "app_thread.created") {
6743 replayedLineages += 1;
6744 }
6745 if (event.type === "subagent.updated" && event.subagent.runId === continuationInput.runId) {
6746 replayedSubagentUpdates += 1;
6747 }
6748 if (
6749 event.type === "turn_item.updated" &&
6750 event.turnItem.runId === continuationInput.runId
6751 ) {
6752 replayedTurnItems += 1;
6753 }
6754 polled = yield* Queue.poll(events);
6755 }
6756 assert.equal(replayedLineages, 0, "buffered spawn ACK must not create a second lineage");
6757 assert.equal(
6758 replayedSubagentUpdates,
6759 0,
6760 "buffered spawn ACK must not re-open the terminal subagent",
6761 );
6762 assert.equal(
6763 replayedTurnItems,
6764 0,
6765 "buffered spawn ACK must not create a continuation-owned turn item",
6766 );
6767 assert.isFalse(yield* hasPendingBackgroundWork);
6768 }).pipe(Effect.provide(layerTest), Effect.scoped),
6769 );
6770
6771 it.effect(
6772 "user attach flushes a deferred terminal but leaves wake traffic for its continuation",
6773 () =>
6774 Effect.gen(function* () {
6775 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
6776 const idAllocator = yield* IdAllocator.IdAllocatorV2;
6777 const path = yield* Path.Path;
6778 const selfInvocation = yield* resolveSelfInvocation();
6779 const mockAgentPath = yield* path.fromFileUrl(
6780 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
6781 );
6782 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
6783 [];
6784 const bufferedAssistantText = "BUFFERED_WAKE_AFTER_USER_ATTACH";
6785 const instanceId = ProviderInstanceId.make("acp-test");
6786 let subagentPhase: "spawn" | "complete" = "spawn";
6787 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
6788 let sessionUpdateHandler: Parameters<RuntimeService["handleSessionUpdate"]>[0] | undefined;
6789 const promptSettled = yield* Deferred.make<void>();
6790 const adapter = yield* makeAcpAdapterV2({
6791 testHooks: {
6792 afterPromptSettledWithBackgroundWork: () =>
6793 Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid),
6794 },
6795 instanceId,
6796 flavor: {
6797 driver: ACP_TEST_DRIVER,
6798 capabilities: AcpProviderCapabilitiesV2,
6799 deferFinalizeForBackgroundWork: true,
6800 enablePostSettleContinuation: true,
6801 extractSubagentUpdate: (toolCall) =>
6802 toolCall.toolCallId !== "tool-call-generic-1"
6803 ? undefined
6804 : subagentPhase === "spawn"
6805 ? {
6806 nativeTaskId: "task-generic-1",
6807 prompt: "background subagent",
6808 title: "background subagent",
6809 model: null,
6810 status: "running",
6811 childSessionId: null,
6812 result: null,
6813 }
6814 : {
6815 nativeTaskId: "task-generic-1",
6816 prompt: "",
6817 title: null,
6818 model: null,
6819 status: "completed",
6820 childSessionId: null,
6821 result: "SUB_DONE",
6822 },
6823 makeRuntime: makeMockRuntime({
6824 childProcessSpawner,
6825 mockAgentPath,
6826 environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" },
6827 wrapRuntime: (runtime) => ({
6828 ...runtime,
6829 handleSessionUpdate: (handler) =>
6830 Effect.sync(() => {
6831 sessionUpdateHandler = handler;
6832 }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))),
6833 }),
6834 }),
6835 },
6836 selfInvocation,
6837 continuationRequests: {
6838 offer: (request) =>
6839 Effect.sync(() => {
6840 continuationRequests.push(request);
6841 }),
6842 },
6843 });
6844 const threadId = ThreadId.make("thread-acp-carryover-root-session-continuation");
6845 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
6846 runtimeMode: "full-access",
6847 interactionMode: "default",
6848 cwd: process.cwd(),
6849 });
6850 const modelSelection = { instanceId, model: "default" } as const;
6851 const runtime = yield* adapter.openSession({
6852 threadId,
6853 providerSessionId: ProviderSessionId.make(
6854 "provider-session-acp-carryover-root-session-continuation",
6855 ),
6856 modelSelection,
6857 runtimePolicy,
6858 });
6859 if (runtime.hasPendingBackgroundWork === undefined) {
6860 return yield* Effect.die(
6861 "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.",
6862 );
6863 }
6864 const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork;
6865 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
6866 yield* runtime.events.pipe(
6867 Stream.runForEach((event) => Queue.offer(events, event)),
6868 Effect.forkScoped,
6869 );
6870 const providerThread = yield* runtime.ensureThread({
6871 threadId,
6872 modelSelection,
6873 runtimePolicy,
6874 });
6875 const now = yield* DateTime.now;
6876 yield* runtime.startTurn(
6877 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
6878 );
6879 yield* Deferred.await(promptSettled);
6880
6881 const firstProviderTurnId = idAllocator.derive.providerTurn({
6882 driver: ACP_TEST_DRIVER,
6883 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
6884 });
6885 yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId });
6886
6887 let firstTerminalStatus: string | null = null;
6888 while (firstTerminalStatus === null) {
6889 const event = yield* Queue.take(events);
6890 if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) {
6891 firstTerminalStatus = event.status;
6892 }
6893 }
6894 assert.equal(firstTerminalStatus, "interrupted");
6895 assert.isTrue(
6896 yield* hasPendingBackgroundWork,
6897 "carryover live subagent must pin hasPendingBackgroundWork after root settle",
6898 );
6899 assert.isDefined(sessionUpdateHandler, "session update handler must be wired");
6900
6901 // Root-session terminal tool is wake evidence and will buffer: in-memory
6902 // only so the continuation drain projects exactly once.
6903 subagentPhase = "complete";
6904 yield* sessionUpdateHandler!({
6905 sessionId: "mock-session-1",
6906 update: {
6907 sessionUpdate: "tool_call_update",
6908 toolCallId: "tool-call-generic-1",
6909 title: "background subagent",
6910 kind: "other",
6911 status: "completed",
6912 rawOutput: { content: "SUB_DONE" },
6913 },
6914 });
6915 yield* sessionUpdateHandler!({
6916 sessionId: "mock-session-1",
6917 update: {
6918 sessionUpdate: "agent_message_chunk",
6919 content: { type: "text", text: bufferedAssistantText },
6920 },
6921 });
6922 yield* Effect.yieldNow;
6923 yield* Effect.yieldNow;
6924
6925 let completedBeforeDrain = 0;
6926 let polled = yield* Queue.poll(events);
6927 while (Option.isSome(polled)) {
6928 const event = polled.value;
6929 if (
6930 event.type === "turn_item.updated" &&
6931 event.turnItem.type === "subagent" &&
6932 event.turnItem.status === "completed"
6933 ) {
6934 completedBeforeDrain += 1;
6935 }
6936 polled = yield* Queue.poll(events);
6937 }
6938 assert.equal(
6939 completedBeforeDrain,
6940 0,
6941 "interrupted root-session terminal must wait for an observable attach",
6942 );
6943 assert.lengthOf(
6944 continuationRequests,
6945 1,
6946 "root-session post-settle subagent terminal must offer a continuation",
6947 );
6948 assert.isTrue(
6949 yield* hasPendingBackgroundWork,
6950 "interrupted unprojected terminal must pin hasPendingBackgroundWork until attach",
6951 );
6952
6953 const userTurnNow = yield* DateTime.now;
6954 yield* runtime.startTurn(
6955 makeTurnInput({
6956 threadId,
6957 providerThread,
6958 instanceId,
6959 runtimePolicy,
6960 now: userTurnNow,
6961 ordinal: 2,
6962 messageText: "What finished while the prior turn was settling?",
6963 }),
6964 );
6965 yield* TestClock.adjust("3 seconds");
6966 yield* Effect.yieldNow;
6967 yield* Effect.yieldNow;
6968
6969 const userProviderTurnId = idAllocator.derive.providerTurn({
6970 driver: ACP_TEST_DRIVER,
6971 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"),
6972 });
6973 let userTurnTerminal: string | null = null;
6974 let completedSubagentTurnItems = 0;
6975 let bufferedTextSeenInUserTurn = false;
6976 while (userTurnTerminal === null) {
6977 const event = yield* Queue.take(events);
6978 if (
6979 event.type === "turn_item.updated" &&
6980 event.turnItem.type === "subagent" &&
6981 event.turnItem.status === "completed"
6982 ) {
6983 completedSubagentTurnItems += 1;
6984 }
6985 if (
6986 event.type === "message.updated" &&
6987 event.message.role === "assistant" &&
6988 event.message.text.includes(bufferedAssistantText)
6989 ) {
6990 bufferedTextSeenInUserTurn = true;
6991 }
6992 if (event.type === "turn.terminal" && event.providerTurnId === userProviderTurnId) {
6993 userTurnTerminal = event.status;
6994 }
6995 }
6996 assert.equal(userTurnTerminal, "completed");
6997 assert.equal(
6998 completedSubagentTurnItems,
6999 1,
7000 "user attach must project the deferred terminal subagent turn item once",
7001 );
7002 assert.isFalse(bufferedTextSeenInUserTurn, "user attach must not drain wake traffic");
7003 assert.isTrue(
7004 yield* hasPendingBackgroundWork,
7005 "wake traffic must remain pinned for the already-dispatched continuation",
7006 );
7007
7008 const continuationNow = yield* DateTime.now;
7009 yield* runtime.startTurn(
7010 makeTurnInput({
7011 threadId,
7012 providerThread,
7013 instanceId,
7014 runtimePolicy,
7015 now: continuationNow,
7016 ordinal: 3,
7017 messageCreatedBy: "agent",
7018 messageCreationSource: "provider",
7019 messageText: "Background task completed.",
7020 }),
7021 );
7022 yield* TestClock.adjust("3 seconds");
7023 const continuationProviderTurnId = idAllocator.derive.providerTurn({
7024 driver: ACP_TEST_DRIVER,
7025 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:3"),
7026 });
7027 let continuationTerminal: string | null = null;
7028 let bufferedTextSeenInContinuation = false;
7029 while (continuationTerminal === null) {
7030 const event = yield* Queue.take(events);
7031 if (
7032 event.type === "message.updated" &&
7033 event.message.role === "assistant" &&
7034 event.message.text.includes(bufferedAssistantText)
7035 ) {
7036 bufferedTextSeenInContinuation = true;
7037 }
7038 if (
7039 event.type === "turn.terminal" &&
7040 event.providerTurnId === continuationProviderTurnId
7041 ) {
7042 continuationTerminal = event.status;
7043 }
7044 }
7045 assert.equal(continuationTerminal, "completed");
7046 assert.isTrue(
7047 bufferedTextSeenInContinuation,
7048 "queued continuation must drain the wake content after the user run",
7049 );
7050 assert.isFalse(yield* hasPendingBackgroundWork);
7051 }).pipe(Effect.provide(layerTest), Effect.scoped),
7052 );
7053
7054 it.effect(
7055 "projects a carryover terminal when an in-turn-handled tool is re-reported post-settle",
7056 () =>
7057 Effect.gen(function* () {
7058 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
7059 const idAllocator = yield* IdAllocator.IdAllocatorV2;
7060 const path = yield* Path.Path;
7061 const selfInvocation = yield* resolveSelfInvocation();
7062 const mockAgentPath = yield* path.fromFileUrl(
7063 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
7064 );
7065 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
7066 [];
7067 const promptWireReturned = yield* Deferred.make<void>();
7068 const releasePromptCompletion = yield* Deferred.make<void>();
7069 const instanceId = ProviderInstanceId.make("acp-test");
7070 let subagentPhase: "spawn" | "complete" = "spawn";
7071 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
7072 let sessionUpdateHandler: Parameters<RuntimeService["handleSessionUpdate"]>[0] | undefined;
7073 const promptSettled = yield* Deferred.make<void>();
7074 const adapter = yield* makeAcpAdapterV2({
7075 testHooks: {
7076 afterPromptSettledWithBackgroundWork: () =>
7077 Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid),
7078 },
7079 instanceId,
7080 flavor: {
7081 driver: ACP_TEST_DRIVER,
7082 capabilities: AcpProviderCapabilitiesV2,
7083 deferFinalizeForBackgroundWork: true,
7084 enablePostSettleContinuation: true,
7085 extractBackgroundTaskId: (toolCall) =>
7086 toolCall.toolCallId === "tool-call-generic-1" ? "task-generic-1" : undefined,
7087 extractSubagentUpdate: (toolCall) =>
7088 toolCall.toolCallId !== "tool-call-generic-1"
7089 ? undefined
7090 : subagentPhase === "spawn"
7091 ? {
7092 nativeTaskId: "task-generic-1",
7093 prompt: "background subagent",
7094 title: "background subagent",
7095 model: null,
7096 status: "running",
7097 childSessionId: null,
7098 result: null,
7099 }
7100 : {
7101 nativeTaskId: "task-generic-1",
7102 prompt: "",
7103 title: null,
7104 model: null,
7105 status: "completed",
7106 childSessionId: null,
7107 result: "SUB_DONE",
7108 },
7109 makeRuntime: makeMockRuntime({
7110 childProcessSpawner,
7111 mockAgentPath,
7112 environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" },
7113 wrapRuntime: (runtime) => ({
7114 ...runtime,
7115 handleSessionUpdate: (handler) =>
7116 Effect.sync(() => {
7117 sessionUpdateHandler = handler;
7118 }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))),
7119 prompt: (payload) =>
7120 Effect.gen(function* () {
7121 const result = yield* runtime.prompt(payload);
7122 yield* Deferred.succeed(promptWireReturned, undefined);
7123 yield* Deferred.await(releasePromptCompletion);
7124 return result;
7125 }),
7126 }),
7127 }),
7128 },
7129 selfInvocation,
7130 continuationRequests: {
7131 offer: (request) =>
7132 Effect.sync(() => {
7133 continuationRequests.push(request);
7134 }),
7135 },
7136 });
7137 const threadId = ThreadId.make("thread-acp-carryover-already-handled-re-report");
7138 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
7139 runtimeMode: "full-access",
7140 interactionMode: "default",
7141 cwd: process.cwd(),
7142 });
7143 const modelSelection = { instanceId, model: "default" } as const;
7144 const runtime = yield* adapter.openSession({
7145 threadId,
7146 providerSessionId: ProviderSessionId.make(
7147 "provider-session-acp-carryover-already-handled-re-report",
7148 ),
7149 modelSelection,
7150 runtimePolicy,
7151 });
7152 if (runtime.hasPendingBackgroundWork === undefined) {
7153 return yield* Effect.die(
7154 "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.",
7155 );
7156 }
7157 const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork;
7158 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
7159 yield* runtime.events.pipe(
7160 Stream.runForEach((event) => Queue.offer(events, event)),
7161 Effect.forkScoped,
7162 );
7163 const providerThread = yield* runtime.ensureThread({
7164 threadId,
7165 modelSelection,
7166 runtimePolicy,
7167 });
7168 const now = yield* DateTime.now;
7169 yield* runtime.startTurn(
7170 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
7171 );
7172 yield* Deferred.await(promptWireReturned);
7173 assert.isDefined(sessionUpdateHandler, "session update handler must be wired");
7174
7175 // The root turn consumes a terminal re-report while the native prompt is
7176 // still open. The subagent flavor keeps its carried lineage running.
7177 yield* sessionUpdateHandler!({
7178 sessionId: "mock-session-1",
7179 update: {
7180 sessionUpdate: "tool_call_update",
7181 toolCallId: "tool-call-generic-1",
7182 title: "background subagent",
7183 kind: "other",
7184 status: "completed",
7185 rawOutput: { content: "IN_TURN_RESULT" },
7186 },
7187 });
7188 yield* Deferred.succeed(releasePromptCompletion, undefined);
7189 yield* Deferred.await(promptSettled);
7190
7191 const firstProviderTurnId = idAllocator.derive.providerTurn({
7192 driver: ACP_TEST_DRIVER,
7193 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
7194 });
7195 yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId });
7196
7197 let firstTerminalStatus: string | null = null;
7198 while (firstTerminalStatus === null) {
7199 const event = yield* Queue.take(events);
7200 if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) {
7201 firstTerminalStatus = event.status;
7202 }
7203 }
7204 assert.equal(firstTerminalStatus, "interrupted");
7205 assert.isTrue(
7206 yield* hasPendingBackgroundWork,
7207 "live carryover must remain pending after the superseded root settles",
7208 );
7209
7210 // The same tool is now a terminal carryover update. Since it was handled
7211 // in-turn, bufferPostSettleWake drops it instead of offering a drain.
7212 subagentPhase = "complete";
7213 yield* sessionUpdateHandler!({
7214 sessionId: "mock-session-1",
7215 update: {
7216 sessionUpdate: "tool_call_update",
7217 toolCallId: "tool-call-generic-1",
7218 title: "background subagent",
7219 kind: "other",
7220 status: "completed",
7221 rawOutput: { content: "SUB_DONE" },
7222 },
7223 });
7224 yield* Effect.yieldNow;
7225 yield* Effect.yieldNow;
7226
7227 let completedSubagentTurnItems = 0;
7228 let polled = yield* Queue.poll(events);
7229 while (Option.isSome(polled)) {
7230 const event = polled.value;
7231 if (
7232 event.type === "turn_item.updated" &&
7233 event.turnItem.type === "subagent" &&
7234 event.turnItem.status === "completed"
7235 ) {
7236 completedSubagentTurnItems += 1;
7237 }
7238 polled = yield* Queue.poll(events);
7239 }
7240 assert.equal(
7241 completedSubagentTurnItems,
7242 0,
7243 "an interrupted root's non-buffered re-report must wait for an observable attach",
7244 );
7245 assert.lengthOf(
7246 continuationRequests,
7247 0,
7248 "an in-turn-handled re-report must not offer a continuation",
7249 );
7250 assert.isTrue(
7251 yield* hasPendingBackgroundWork,
7252 "the unprojected terminal carryover must remain pinned",
7253 );
7254
7255 const attachNow = yield* DateTime.now;
7256 yield* runtime.startTurn(
7257 makeTurnInput({
7258 threadId,
7259 providerThread,
7260 instanceId,
7261 runtimePolicy,
7262 now: attachNow,
7263 ordinal: 2,
7264 messageCreatedBy: "agent",
7265 messageCreationSource: "provider",
7266 messageText: "Attach deferred completion.",
7267 }),
7268 );
7269 const attachProviderTurnId = idAllocator.derive.providerTurn({
7270 driver: ACP_TEST_DRIVER,
7271 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"),
7272 });
7273 let attachTerminal: string | null = null;
7274 while (attachTerminal === null) {
7275 const event = yield* Queue.take(events);
7276 if (
7277 event.type === "turn_item.updated" &&
7278 event.turnItem.type === "subagent" &&
7279 event.turnItem.status === "completed"
7280 ) {
7281 completedSubagentTurnItems += 1;
7282 }
7283 if (event.type === "turn.terminal" && event.providerTurnId === attachProviderTurnId) {
7284 attachTerminal = event.status;
7285 }
7286 }
7287 assert.equal(completedSubagentTurnItems, 1);
7288 assert.isFalse(yield* hasPendingBackgroundWork);
7289 }).pipe(Effect.provide(layerTest), Effect.scoped),
7290 );
7291
7292 it.effect("projects an interrupted root-session end notice at the next attach", () =>
7293 Effect.gen(function* () {
7294 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
7295 const idAllocator = yield* IdAllocator.IdAllocatorV2;
7296 const path = yield* Path.Path;
7297 const selfInvocation = yield* resolveSelfInvocation();
7298 const mockAgentPath = yield* path.fromFileUrl(
7299 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
7300 );
7301 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
7302 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
7303 [];
7304 const promptSettled = yield* Deferred.make<void>();
7305 const instanceId = ProviderInstanceId.make("acp-test");
7306 const childSessionId = "019f5470-bf92-7a90-afb3-5a6cea5b34a3";
7307 let subagentPhase: "spawn" | "complete" = "spawn";
7308 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
7309 let sessionUpdateHandler: Parameters<RuntimeService["handleSessionUpdate"]>[0] | undefined;
7310 const adapter = yield* makeAcpAdapterV2({
7311 testHooks: {
7312 afterPromptSettledWithBackgroundWork: () =>
7313 Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid),
7314 },
7315 instanceId,
7316 flavor: {
7317 driver: ACP_TEST_DRIVER,
7318 capabilities: AcpProviderCapabilitiesV2,
7319 deferFinalizeForBackgroundWork: true,
7320 enablePostSettleContinuation: true,
7321 extractSubagentEndNotice: (text) => {
7322 // Prefer the production parser; fall back only if the harness text
7323 // is too short for its UUID + outcome rules.
7324 const parsed = extractXAiAcpSubagentEndNotice(text);
7325 if (parsed !== undefined) return parsed;
7326 if (!text.includes(childSessionId)) return undefined;
7327 if (/completed successfully/i.test(text)) {
7328 return { childSessionId, status: "completed" as const };
7329 }
7330 return undefined;
7331 },
7332 extractSubagentUpdate: (toolCall) =>
7333 toolCall.toolCallId !== "tool-call-generic-1"
7334 ? undefined
7335 : subagentPhase === "spawn"
7336 ? {
7337 nativeTaskId: "task-generic-1",
7338 prompt: "background subagent",
7339 title: "background subagent",
7340 model: null,
7341 status: "running",
7342 childSessionId,
7343 result: null,
7344 }
7345 : {
7346 nativeTaskId: "task-generic-1",
7347 prompt: "",
7348 title: null,
7349 model: null,
7350 status: "completed",
7351 childSessionId,
7352 result: "SUB_DONE",
7353 },
7354 makeRuntime: makeMockRuntime({
7355 childProcessSpawner,
7356 mockAgentPath,
7357 environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" },
7358 protocolEvents,
7359 wrapRuntime: (runtime) => ({
7360 ...runtime,
7361 handleSessionUpdate: (handler) =>
7362 Effect.sync(() => {
7363 sessionUpdateHandler = handler;
7364 }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))),
7365 }),
7366 }),
7367 },
7368 selfInvocation,
7369 continuationRequests: {
7370 offer: (request) =>
7371 Effect.sync(() => {
7372 continuationRequests.push(request);
7373 }),
7374 },
7375 });
7376 const threadId = ThreadId.make("thread-acp-carryover-root-end-notice");
7377 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
7378 runtimeMode: "full-access",
7379 interactionMode: "default",
7380 cwd: process.cwd(),
7381 });
7382 const modelSelection = { instanceId, model: "default" } as const;
7383 const runtime = yield* adapter.openSession({
7384 threadId,
7385 providerSessionId: ProviderSessionId.make("provider-session-acp-carryover-root-end-notice"),
7386 modelSelection,
7387 runtimePolicy,
7388 });
7389 if (runtime.hasPendingBackgroundWork === undefined) {
7390 return yield* Effect.die(
7391 "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.",
7392 );
7393 }
7394 const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork;
7395 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
7396 yield* runtime.events.pipe(
7397 Stream.runForEach((event) => Queue.offer(events, event)),
7398 Effect.forkScoped,
7399 );
7400 const providerThread = yield* runtime.ensureThread({
7401 threadId,
7402 modelSelection,
7403 runtimePolicy,
7404 });
7405 const now = yield* DateTime.now;
7406 yield* runtime.startTurn(
7407 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
7408 );
7409 yield* Deferred.await(promptSettled);
7410
7411 const firstProviderTurnId = idAllocator.derive.providerTurn({
7412 driver: ACP_TEST_DRIVER,
7413 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
7414 });
7415 yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId });
7416
7417 let firstTerminalStatus: string | null = null;
7418 while (firstTerminalStatus === null) {
7419 const event = yield* Queue.take(events);
7420 if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) {
7421 firstTerminalStatus = event.status;
7422 }
7423 }
7424 assert.equal(firstTerminalStatus, "interrupted");
7425 assert.isTrue(
7426 yield* hasPendingBackgroundWork,
7427 "carryover live subagent must pin hasPendingBackgroundWork after root settle",
7428 );
7429 assert.isDefined(sessionUpdateHandler, "session update handler must be wired");
7430
7431 // End notices are root user_message_chunk text and never buffer. The
7432 // interrupted root still cannot project them until the next subscription
7433 // attaches.
7434 yield* sessionUpdateHandler!({
7435 sessionId: "mock-session-1",
7436 update: {
7437 sessionUpdate: "user_message_chunk",
7438 content: {
7439 type: "text",
7440 text: `Background subagent "${childSessionId}" (general-purpose: "background subagent") completed successfully.`,
7441 },
7442 },
7443 });
7444 // Do not use Effect.timeout under TestClock; poll after yielding so a
7445 // missed projection fails the assertion instead of hanging the suite.
7446 yield* Effect.yieldNow;
7447 yield* Effect.yieldNow;
7448
7449 let completedSubagentTurnItems = 0;
7450 let polled = yield* Queue.poll(events);
7451 while (Option.isSome(polled)) {
7452 const event = polled.value;
7453 if (
7454 event.type === "turn_item.updated" &&
7455 event.turnItem.type === "subagent" &&
7456 event.turnItem.status === "completed"
7457 ) {
7458 completedSubagentTurnItems += 1;
7459 }
7460 polled = yield* Queue.poll(events);
7461 }
7462 assert.equal(
7463 completedSubagentTurnItems,
7464 0,
7465 "root-session end notice must remain memory-only after interrupted settle",
7466 );
7467 assert.lengthOf(
7468 continuationRequests,
7469 0,
7470 "root-session end notice must not open a continuation",
7471 );
7472 assert.isTrue(
7473 yield* hasPendingBackgroundWork,
7474 "hasPendingBackgroundWork must retain the unprojected end notice",
7475 );
7476
7477 const attachNow = yield* DateTime.now;
7478 yield* runtime.startTurn(
7479 makeTurnInput({
7480 threadId,
7481 providerThread,
7482 instanceId,
7483 runtimePolicy,
7484 now: attachNow,
7485 ordinal: 2,
7486 messageCreatedBy: "agent",
7487 messageCreationSource: "provider",
7488 messageText: "Attach deferred completion.",
7489 }),
7490 );
7491 const attachProviderTurnId = idAllocator.derive.providerTurn({
7492 driver: ACP_TEST_DRIVER,
7493 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"),
7494 });
7495 let attachTerminal: string | null = null;
7496 while (attachTerminal === null) {
7497 const event = yield* Queue.take(events);
7498 if (
7499 event.type === "turn_item.updated" &&
7500 event.turnItem.type === "subagent" &&
7501 event.turnItem.status === "completed"
7502 ) {
7503 completedSubagentTurnItems += 1;
7504 }
7505 if (event.type === "turn.terminal" && event.providerTurnId === attachProviderTurnId) {
7506 attachTerminal = event.status;
7507 }
7508 }
7509 assert.equal(completedSubagentTurnItems, 1);
7510 assert.isFalse(yield* hasPendingBackgroundWork);
7511 }).pipe(Effect.provide(layerTest), Effect.scoped),
7512 );
7513
7514 it.effect(
7515 "preserves wakeBuffer when a child-session completes while a continuation is pending",
7516 () =>
7517 Effect.gen(function* () {
7518 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
7519 const idAllocator = yield* IdAllocator.IdAllocatorV2;
7520 const path = yield* Path.Path;
7521 const selfInvocation = yield* resolveSelfInvocation();
7522 const mockAgentPath = yield* path.fromFileUrl(
7523 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
7524 );
7525 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
7526 [];
7527 const instanceId = ProviderInstanceId.make("acp-test");
7528 const childSessionId = "mock-child-session-pending-continuation";
7529 const bufferedAssistantText = "POST_SETTLE_BUFFERED_ASSISTANT_TEXT";
7530 let subagentPhase: "spawn" | "complete" = "spawn";
7531 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
7532 let sessionUpdateHandler: Parameters<RuntimeService["handleSessionUpdate"]>[0] | undefined;
7533 const promptSettled = yield* Deferred.make<void>();
7534 const adapter = yield* makeAcpAdapterV2({
7535 testHooks: {
7536 afterPromptSettledWithBackgroundWork: () =>
7537 Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid),
7538 },
7539 instanceId,
7540 flavor: {
7541 driver: ACP_TEST_DRIVER,
7542 capabilities: AcpProviderCapabilitiesV2,
7543 deferFinalizeForBackgroundWork: true,
7544 enablePostSettleContinuation: true,
7545 extractSubagentUpdate: (toolCall) =>
7546 toolCall.toolCallId !== "tool-call-generic-1"
7547 ? undefined
7548 : subagentPhase === "spawn"
7549 ? {
7550 nativeTaskId: "task-generic-1",
7551 prompt: "background subagent",
7552 title: "background subagent",
7553 model: null,
7554 status: "running",
7555 childSessionId,
7556 result: null,
7557 }
7558 : {
7559 nativeTaskId: "task-generic-1",
7560 prompt: "",
7561 title: null,
7562 model: null,
7563 status: "completed",
7564 childSessionId,
7565 result: "SUB_DONE",
7566 },
7567 makeRuntime: makeMockRuntime({
7568 childProcessSpawner,
7569 mockAgentPath,
7570 environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" },
7571 wrapRuntime: (runtime) => ({
7572 ...runtime,
7573 handleSessionUpdate: (handler) =>
7574 Effect.sync(() => {
7575 sessionUpdateHandler = handler;
7576 }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))),
7577 }),
7578 }),
7579 },
7580 selfInvocation,
7581 continuationRequests: {
7582 offer: (request) =>
7583 Effect.sync(() => {
7584 continuationRequests.push(request);
7585 }),
7586 },
7587 });
7588 const threadId = ThreadId.make("thread-acp-carryover-child-pending-continuation");
7589 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
7590 runtimeMode: "full-access",
7591 interactionMode: "default",
7592 cwd: process.cwd(),
7593 });
7594 const modelSelection = { instanceId, model: "default" } as const;
7595 const runtime = yield* adapter.openSession({
7596 threadId,
7597 providerSessionId: ProviderSessionId.make(
7598 "provider-session-acp-carryover-child-pending-continuation",
7599 ),
7600 modelSelection,
7601 runtimePolicy,
7602 });
7603 if (runtime.hasPendingBackgroundWork === undefined) {
7604 return yield* Effect.die(
7605 "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.",
7606 );
7607 }
7608 const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork;
7609 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
7610 yield* runtime.events.pipe(
7611 Stream.runForEach((event) => Queue.offer(events, event)),
7612 Effect.forkScoped,
7613 );
7614 const providerThread = yield* runtime.ensureThread({
7615 threadId,
7616 modelSelection,
7617 runtimePolicy,
7618 });
7619 const now = yield* DateTime.now;
7620 yield* runtime.startTurn(
7621 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
7622 );
7623 yield* Deferred.await(promptSettled);
7624
7625 const firstProviderTurnId = idAllocator.derive.providerTurn({
7626 driver: ACP_TEST_DRIVER,
7627 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
7628 });
7629 yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId });
7630
7631 let firstTerminalStatus: string | null = null;
7632 while (firstTerminalStatus === null) {
7633 const event = yield* Queue.take(events);
7634 if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) {
7635 firstTerminalStatus = event.status;
7636 }
7637 }
7638 assert.equal(firstTerminalStatus, "interrupted");
7639 assert.isTrue(
7640 yield* hasPendingBackgroundWork,
7641 "carryover live subagent must pin hasPendingBackgroundWork after root settle",
7642 );
7643 assert.isDefined(sessionUpdateHandler, "session update handler must be wired");
7644
7645 // Root-session terminal + distinctive assistant text: both enter wakeBuffer
7646 // and the terminal offers a continuation that will drain them.
7647 subagentPhase = "complete";
7648 yield* sessionUpdateHandler!({
7649 sessionId: "mock-session-1",
7650 update: {
7651 sessionUpdate: "tool_call_update",
7652 toolCallId: "tool-call-generic-1",
7653 title: "background subagent",
7654 kind: "other",
7655 status: "completed",
7656 rawOutput: { content: "SUB_DONE" },
7657 },
7658 });
7659 yield* sessionUpdateHandler!({
7660 sessionId: "mock-session-1",
7661 update: {
7662 sessionUpdate: "agent_message_chunk",
7663 content: { type: "text", text: bufferedAssistantText },
7664 },
7665 });
7666 assert.lengthOf(
7667 continuationRequests,
7668 1,
7669 "root-session terminal must offer a continuation before child completion",
7670 );
7671 assert.isTrue(
7672 yield* hasPendingBackgroundWork,
7673 "pending continuation must keep hasPendingBackgroundWork pinned",
7674 );
7675
7676 // Child path must not wipe wakeBuffer or clear the sticky continuation pin.
7677 yield* sessionUpdateHandler!({
7678 sessionId: childSessionId,
7679 update: {
7680 sessionUpdate: "tool_call_update",
7681 toolCallId: "tool-call-generic-1",
7682 title: "background subagent",
7683 kind: "other",
7684 status: "completed",
7685 rawOutput: { content: "SUB_DONE" },
7686 },
7687 });
7688 yield* Effect.yieldNow;
7689 yield* Effect.yieldNow;
7690
7691 let completedBeforeDrain = 0;
7692 let polled = yield* Queue.poll(events);
7693 while (Option.isSome(polled)) {
7694 const event = polled.value;
7695 if (
7696 event.type === "turn_item.updated" &&
7697 event.turnItem.type === "subagent" &&
7698 event.turnItem.status === "completed"
7699 ) {
7700 completedBeforeDrain += 1;
7701 }
7702 polled = yield* Queue.poll(events);
7703 }
7704 assert.equal(
7705 completedBeforeDrain,
7706 0,
7707 "interrupted child-session terminal must wait for the continuation attach",
7708 );
7709 assert.isTrue(
7710 yield* hasPendingBackgroundWork,
7711 "child-session must not clear the pin while wakeBuffer still has delivery",
7712 );
7713
7714 // Continuation attach drains the preserved buffer and projects it.
7715 const continuationNow = yield* DateTime.now;
7716 yield* runtime.startTurn(
7717 makeTurnInput({
7718 threadId,
7719 providerThread,
7720 instanceId,
7721 runtimePolicy,
7722 now: continuationNow,
7723 ordinal: 2,
7724 messageCreatedBy: "agent",
7725 messageCreationSource: "provider",
7726 messageText: "Background task completed.",
7727 }),
7728 );
7729 yield* TestClock.adjust("3 seconds");
7730 yield* Effect.yieldNow;
7731 yield* Effect.yieldNow;
7732
7733 const continuationProviderTurnId = idAllocator.derive.providerTurn({
7734 driver: ACP_TEST_DRIVER,
7735 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"),
7736 });
7737 let continuationTerminal: string | null = null;
7738 let completedSubagentTurnItems = 0;
7739 let bufferedTextSeen = false;
7740 while (continuationTerminal === null) {
7741 const event = yield* Queue.take(events);
7742 if (
7743 event.type === "turn_item.updated" &&
7744 event.turnItem.type === "subagent" &&
7745 event.turnItem.status === "completed"
7746 ) {
7747 completedSubagentTurnItems += 1;
7748 }
7749 if (
7750 event.type === "message.updated" &&
7751 event.message.role === "assistant" &&
7752 event.message.text.includes(bufferedAssistantText)
7753 ) {
7754 bufferedTextSeen = true;
7755 }
7756 if (
7757 event.type === "turn.terminal" &&
7758 event.providerTurnId === continuationProviderTurnId
7759 ) {
7760 continuationTerminal = event.status;
7761 }
7762 }
7763 assert.equal(continuationTerminal, "completed");
7764 assert.isTrue(
7765 bufferedTextSeen,
7766 "continuation drain must project buffered post-settle assistant text (buffer not wiped)",
7767 );
7768 assert.equal(
7769 completedSubagentTurnItems,
7770 1,
7771 "continuation drain must project the terminal subagent turn item once",
7772 );
7773 assert.isFalse(
7774 yield* hasPendingBackgroundWork,
7775 "pin must clear after the continuation drains, not when the child session completed",
7776 );
7777 }).pipe(Effect.provide(layerTest), Effect.scoped),
7778 );
7779
7780 it.effect(
7781 "projects once when root-session then child-session complete the same carryover subagent",
7782 () =>
7783 Effect.gen(function* () {
7784 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
7785 const idAllocator = yield* IdAllocator.IdAllocatorV2;
7786 const path = yield* Path.Path;
7787 const selfInvocation = yield* resolveSelfInvocation();
7788 const mockAgentPath = yield* path.fromFileUrl(
7789 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
7790 );
7791 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
7792 [];
7793 const instanceId = ProviderInstanceId.make("acp-test");
7794 const childSessionId = "mock-child-session-root-then-child";
7795 let subagentPhase: "spawn" | "complete" = "spawn";
7796 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
7797 let sessionUpdateHandler: Parameters<RuntimeService["handleSessionUpdate"]>[0] | undefined;
7798 const promptSettled = yield* Deferred.make<void>();
7799 const adapter = yield* makeAcpAdapterV2({
7800 testHooks: {
7801 afterPromptSettledWithBackgroundWork: () =>
7802 Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid),
7803 },
7804 instanceId,
7805 flavor: {
7806 driver: ACP_TEST_DRIVER,
7807 capabilities: AcpProviderCapabilitiesV2,
7808 deferFinalizeForBackgroundWork: true,
7809 enablePostSettleContinuation: true,
7810 extractSubagentUpdate: (toolCall) =>
7811 toolCall.toolCallId !== "tool-call-generic-1"
7812 ? undefined
7813 : subagentPhase === "spawn"
7814 ? {
7815 nativeTaskId: "task-generic-1",
7816 prompt: "background subagent",
7817 title: "background subagent",
7818 model: null,
7819 status: "running",
7820 childSessionId,
7821 result: null,
7822 }
7823 : {
7824 nativeTaskId: "task-generic-1",
7825 prompt: "",
7826 title: null,
7827 model: null,
7828 status: "completed",
7829 childSessionId,
7830 result: "SUB_DONE",
7831 },
7832 makeRuntime: makeMockRuntime({
7833 childProcessSpawner,
7834 mockAgentPath,
7835 environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" },
7836 wrapRuntime: (runtime) => ({
7837 ...runtime,
7838 handleSessionUpdate: (handler) =>
7839 Effect.sync(() => {
7840 sessionUpdateHandler = handler;
7841 }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))),
7842 }),
7843 }),
7844 },
7845 selfInvocation,
7846 continuationRequests: {
7847 offer: (request) =>
7848 Effect.sync(() => {
7849 continuationRequests.push(request);
7850 }),
7851 },
7852 });
7853 const threadId = ThreadId.make("thread-acp-carryover-root-then-child");
7854 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
7855 runtimeMode: "full-access",
7856 interactionMode: "default",
7857 cwd: process.cwd(),
7858 });
7859 const modelSelection = { instanceId, model: "default" } as const;
7860 const runtime = yield* adapter.openSession({
7861 threadId,
7862 providerSessionId: ProviderSessionId.make(
7863 "provider-session-acp-carryover-root-then-child",
7864 ),
7865 modelSelection,
7866 runtimePolicy,
7867 });
7868 if (runtime.hasPendingBackgroundWork === undefined) {
7869 return yield* Effect.die(
7870 "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.",
7871 );
7872 }
7873 const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork;
7874 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
7875 yield* runtime.events.pipe(
7876 Stream.runForEach((event) => Queue.offer(events, event)),
7877 Effect.forkScoped,
7878 );
7879 const providerThread = yield* runtime.ensureThread({
7880 threadId,
7881 modelSelection,
7882 runtimePolicy,
7883 });
7884 const now = yield* DateTime.now;
7885 yield* runtime.startTurn(
7886 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
7887 );
7888 yield* Deferred.await(promptSettled);
7889
7890 const firstProviderTurnId = idAllocator.derive.providerTurn({
7891 driver: ACP_TEST_DRIVER,
7892 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
7893 });
7894 yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId });
7895
7896 let firstTerminalStatus: string | null = null;
7897 while (firstTerminalStatus === null) {
7898 const event = yield* Queue.take(events);
7899 if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) {
7900 firstTerminalStatus = event.status;
7901 }
7902 }
7903 assert.equal(firstTerminalStatus, "interrupted");
7904 assert.isTrue(
7905 yield* hasPendingBackgroundWork,
7906 "carryover live subagent must pin hasPendingBackgroundWork after root settle",
7907 );
7908 assert.isDefined(sessionUpdateHandler, "session update handler must be wired");
7909
7910 // The interrupted root-session completion advances in-memory carryover
7911 // without projecting and offers a continuation. Child-session replay
7912 // must not project a second copy before that observable attach.
7913 subagentPhase = "complete";
7914 yield* sessionUpdateHandler!({
7915 sessionId: "mock-session-1",
7916 update: {
7917 sessionUpdate: "tool_call_update",
7918 toolCallId: "tool-call-generic-1",
7919 title: "background subagent",
7920 kind: "other",
7921 status: "completed",
7922 rawOutput: { content: "SUB_DONE" },
7923 },
7924 });
7925 assert.lengthOf(
7926 continuationRequests,
7927 1,
7928 "root-session terminal must still offer a continuation",
7929 );
7930
7931 yield* sessionUpdateHandler!({
7932 sessionId: childSessionId,
7933 update: {
7934 sessionUpdate: "tool_call_update",
7935 toolCallId: "tool-call-generic-1",
7936 title: "background subagent",
7937 kind: "other",
7938 status: "completed",
7939 rawOutput: { content: "SUB_DONE" },
7940 },
7941 });
7942 yield* Effect.yieldNow;
7943 yield* Effect.yieldNow;
7944
7945 let completedBeforeDrain = 0;
7946 let polled = yield* Queue.poll(events);
7947 while (Option.isSome(polled)) {
7948 const event = polled.value;
7949 if (
7950 event.type === "turn_item.updated" &&
7951 event.turnItem.type === "subagent" &&
7952 event.turnItem.status === "completed"
7953 ) {
7954 completedBeforeDrain += 1;
7955 }
7956 polled = yield* Queue.poll(events);
7957 }
7958 assert.equal(
7959 completedBeforeDrain,
7960 0,
7961 "interrupted root-then-child completion must defer to the continuation attach",
7962 );
7963
7964 const continuationNow = yield* DateTime.now;
7965 yield* runtime.startTurn(
7966 makeTurnInput({
7967 threadId,
7968 providerThread,
7969 instanceId,
7970 runtimePolicy,
7971 now: continuationNow,
7972 ordinal: 2,
7973 messageCreatedBy: "agent",
7974 messageCreationSource: "provider",
7975 messageText: "Background task completed.",
7976 }),
7977 );
7978 yield* TestClock.adjust("3 seconds");
7979 yield* Effect.yieldNow;
7980 yield* Effect.yieldNow;
7981
7982 const continuationProviderTurnId = idAllocator.derive.providerTurn({
7983 driver: ACP_TEST_DRIVER,
7984 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"),
7985 });
7986 let continuationTerminal: string | null = null;
7987 let completedSubagentTurnItems = 0;
7988 while (continuationTerminal === null) {
7989 const event = yield* Queue.take(events);
7990 if (
7991 event.type === "turn_item.updated" &&
7992 event.turnItem.type === "subagent" &&
7993 event.turnItem.status === "completed"
7994 ) {
7995 completedSubagentTurnItems += 1;
7996 }
7997 if (
7998 event.type === "turn.terminal" &&
7999 event.providerTurnId === continuationProviderTurnId
8000 ) {
8001 continuationTerminal = event.status;
8002 }
8003 }
8004 assert.equal(continuationTerminal, "completed");
8005 assert.equal(
8006 completedSubagentTurnItems,
8007 1,
8008 "root-then-child completion must project the terminal subagent turn item exactly once",
8009 );
8010 assert.isFalse(
8011 yield* hasPendingBackgroundWork,
8012 "hasPendingBackgroundWork must end false after the continuation drains",
8013 );
8014 }).pipe(Effect.provide(layerTest), Effect.scoped),
8015 );
8016
8017 it.effect(
8018 "preserveRuntimeOnSettledInterrupt keeps the process alive and carries subagents through a settled steering interrupt",
8019 () =>
8020 Effect.gen(function* () {
8021 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
8022 const idAllocator = yield* IdAllocator.IdAllocatorV2;
8023 const path = yield* Path.Path;
8024 const selfInvocation = yield* resolveSelfInvocation();
8025 const mockAgentPath = yield* path.fromFileUrl(
8026 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
8027 );
8028 const instanceId = ProviderInstanceId.make("acp-test");
8029 let subagentPhase: "spawn" | "complete" = "spawn";
8030 let cancelCalled = false;
8031 let runtimeOrdinalSeen = 0;
8032 const promptSettled = yield* Deferred.make<void>();
8033 const adapter = yield* makeAcpAdapterV2({
8034 testHooks: {
8035 afterPromptSettledWithBackgroundWork: () =>
8036 Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid),
8037 },
8038 instanceId,
8039 flavor: {
8040 driver: ACP_TEST_DRIVER,
8041 capabilities: AcpProviderCapabilitiesV2,
8042 deferFinalizeForBackgroundWork: true,
8043 // Hard interrupt flags (stricter than production Grok, which no
8044 // longer sets restartRuntimeOnEveryInterrupt): every interrupt
8045 // would hard-kill the process group without the settled-soft gate
8046 // under test.
8047 restartRuntimeAfterInterrupt: true,
8048 restartRuntimeOnEveryInterrupt: true,
8049 terminateRuntimeProcessGroupOnInterrupt: true,
8050 preserveRuntimeOnSettledInterrupt: true,
8051 extractSubagentUpdate: (toolCall) =>
8052 toolCall.toolCallId !== "tool-call-generic-1"
8053 ? undefined
8054 : subagentPhase === "spawn"
8055 ? {
8056 nativeTaskId: "task-generic-1",
8057 prompt: "background subagent",
8058 title: "background subagent",
8059 model: null,
8060 status: "running",
8061 childSessionId: null,
8062 result: null,
8063 }
8064 : {
8065 nativeTaskId: "task-generic-1",
8066 prompt: "",
8067 title: null,
8068 model: null,
8069 status: "completed",
8070 childSessionId: null,
8071 result: "SUB_DONE",
8072 },
8073 // No ownDetachedProcessGroup: if the interrupt wrongly takes the
8074 // hard path, terminateProcessGroup is missing and the interrupt
8075 // fails loudly with a poisoned session.
8076 makeRuntime: makeMockRuntime({
8077 childProcessSpawner,
8078 mockAgentPath,
8079 environment: (runtimeOrdinal) => {
8080 runtimeOrdinalSeen = Math.max(runtimeOrdinalSeen, runtimeOrdinal);
8081 return { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" };
8082 },
8083 wrapCancel: (cancel) =>
8084 Effect.sync(() => {
8085 cancelCalled = true;
8086 }).pipe(Effect.andThen(cancel)),
8087 }),
8088 },
8089 selfInvocation,
8090 });
8091 const threadId = ThreadId.make("thread-acp-settled-soft-steer");
8092 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
8093 runtimeMode: "full-access",
8094 interactionMode: "default",
8095 cwd: process.cwd(),
8096 });
8097 const modelSelection = { instanceId, model: "default" } as const;
8098 const runtime = yield* adapter.openSession({
8099 threadId,
8100 providerSessionId: ProviderSessionId.make("provider-session-acp-settled-soft-steer"),
8101 modelSelection,
8102 runtimePolicy,
8103 });
8104 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
8105 yield* runtime.events.pipe(
8106 Stream.runForEach((event) => Queue.offer(events, event)),
8107 Effect.forkScoped,
8108 );
8109 const providerThread = yield* runtime.ensureThread({
8110 threadId,
8111 modelSelection,
8112 runtimePolicy,
8113 });
8114 const now = yield* DateTime.now;
8115 yield* runtime.startTurn(
8116 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
8117 );
8118 // The still-running subagent defers finalize after session/prompt returns,
8119 // so the interrupt below hits a settled turn held open for background work.
8120 yield* Deferred.await(promptSettled);
8121
8122 const firstProviderTurnId = idAllocator.derive.providerTurn({
8123 driver: ACP_TEST_DRIVER,
8124 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
8125 });
8126 yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId });
8127 assert.isFalse(
8128 cancelCalled,
8129 "settled soft steer must not send session/cancel (the real Grok CLI kills background subagents on cancel)",
8130 );
8131
8132 let subagentTurnItemId: string | null = null;
8133 let firstTerminalStatus: string | null = null;
8134 while (firstTerminalStatus === null) {
8135 const event = yield* Queue.take(events);
8136 if (event.type === "turn_item.updated" && event.turnItem.type === "subagent") {
8137 subagentTurnItemId = event.turnItem.id;
8138 }
8139 if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) {
8140 firstTerminalStatus = event.status;
8141 }
8142 }
8143 assert.equal(firstTerminalStatus, "interrupted");
8144 assert.notEqual(subagentTurnItemId, null);
8145
8146 subagentPhase = "complete";
8147 const secondNow = yield* DateTime.now;
8148 yield* runtime.startTurn(
8149 makeTurnInput({
8150 threadId,
8151 providerThread,
8152 instanceId,
8153 runtimePolicy,
8154 now: secondNow,
8155 ordinal: 2,
8156 }),
8157 );
8158 // Same runtime process (mock-session-1): a respawn would start
8159 // mock-session-2 and drop the carryover on the session mismatch.
8160 const secondProviderTurnId = idAllocator.derive.providerTurn({
8161 driver: ACP_TEST_DRIVER,
8162 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"),
8163 });
8164 let carriedItemStatus: string | null = null;
8165 let secondTerminalStatus: string | null = null;
8166 while (secondTerminalStatus === null) {
8167 const event = yield* Queue.take(events);
8168 if (
8169 event.type === "turn_item.updated" &&
8170 event.turnItem.type === "subagent" &&
8171 event.turnItem.id === subagentTurnItemId
8172 ) {
8173 carriedItemStatus = event.turnItem.status;
8174 }
8175 if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) {
8176 secondTerminalStatus = event.status;
8177 }
8178 }
8179 assert.equal(carriedItemStatus, "completed");
8180 assert.equal(secondTerminalStatus, "completed");
8181 assert.equal(
8182 runtimeOrdinalSeen,
8183 1,
8184 "settled soft steer must not respawn the ACP runtime process",
8185 );
8186 }).pipe(Effect.provide(layerTest), Effect.scoped),
8187 );
8188
8189 it.live("preserveRuntimeOnSettledInterrupt does not soften a mid-prompt steering interrupt", () =>
8190 Effect.gen(function* () {
8191 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
8192 const idAllocator = yield* IdAllocator.IdAllocatorV2;
8193 const path = yield* Path.Path;
8194 const selfInvocation = yield* resolveSelfInvocation();
8195 const mockAgentPath = yield* path.fromFileUrl(
8196 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
8197 );
8198 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
8199 const instanceId = ProviderInstanceId.make("acp-test");
8200 const adapter = yield* makeAcpAdapterV2({
8201 instanceId,
8202 flavor: {
8203 driver: ACP_TEST_DRIVER,
8204 capabilities: AcpProviderCapabilitiesV2,
8205 restartRuntimeAfterInterrupt: true,
8206 // Local hard-flavor gate: production Grok no longer sets
8207 // restartRuntimeOnEveryInterrupt, but when a flavor does, the
8208 // settled-soft gate must not leak onto an unsettled prompt.
8209 restartRuntimeOnEveryInterrupt: true,
8210 terminateRuntimeProcessGroupOnInterrupt: true,
8211 preserveRuntimeOnSettledInterrupt: true,
8212 // No ownDetachedProcessGroup: the expected hard path fails loudly
8213 // on the missing terminateProcessGroup, proving the settled-soft
8214 // gate did not apply to an unsettled prompt.
8215 makeRuntime: makeMockRuntime({
8216 childProcessSpawner,
8217 mockAgentPath,
8218 environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" },
8219 protocolEvents,
8220 }),
8221 },
8222 selfInvocation,
8223 });
8224 const threadId = ThreadId.make("thread-acp-unsettled-steer-stays-hard");
8225 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
8226 runtimeMode: "full-access",
8227 interactionMode: "default",
8228 cwd: process.cwd(),
8229 });
8230 const modelSelection = { instanceId, model: "default" } as const;
8231 const runtime = yield* adapter.openSession({
8232 threadId,
8233 providerSessionId: ProviderSessionId.make(
8234 "provider-session-acp-unsettled-steer-stays-hard",
8235 ),
8236 modelSelection,
8237 runtimePolicy,
8238 });
8239 const providerThread = yield* runtime.ensureThread({
8240 threadId,
8241 modelSelection,
8242 runtimePolicy,
8243 });
8244 const now = yield* DateTime.now;
8245 yield* runtime
8246 .startTurn(
8247 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }),
8248 )
8249 .pipe(Effect.forkScoped);
8250 yield* Stream.fromQueue(protocolEvents).pipe(
8251 Stream.filter(
8252 (event) =>
8253 event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt",
8254 ),
8255 Stream.runHead,
8256 );
8257 const providerTurnId = idAllocator.derive.providerTurn({
8258 driver: ACP_TEST_DRIVER,
8259 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
8260 });
8261 const interruptExit = yield* runtime
8262 .interruptTurn({ providerThread, providerTurnId })
8263 .pipe(Effect.exit);
8264 if (Exit.isSuccess(interruptExit)) {
8265 assert.fail("mid-prompt steering interrupt must still take the hard teardown path");
8266 }
8267 assert.include(Cause.pretty(interruptExit.cause), "session is poisoned");
8268 }).pipe(Effect.provide(layerTest), Effect.scoped),
8269 );
8270
8271 it.live(
8272 "soft mid-prompt interrupt cancels in place, reuses the runtime, and tracks cancel-backgrounded work",
8273 () =>
8274 Effect.gen(function* () {
8275 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
8276 const idAllocator = yield* IdAllocator.IdAllocatorV2;
8277 const path = yield* Path.Path;
8278 const selfInvocation = yield* resolveSelfInvocation();
8279 const mockAgentPath = yield* path.fromFileUrl(
8280 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
8281 );
8282 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
8283 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
8284 [];
8285 const instanceId = ProviderInstanceId.make("acp-test");
8286 let cancelCalled = false;
8287 let runtimeOrdinalSeen = 0;
8288 const adapter = yield* makeAcpAdapterV2({
8289 instanceId,
8290 flavor: {
8291 driver: ACP_TEST_DRIVER,
8292 capabilities: AcpProviderCapabilitiesV2,
8293 enablePostSettleContinuation: true,
8294 // Production Grok interrupt flags: hard teardown only with
8295 // requestRuntimeRestart (user Stop). Without
8296 // restartRuntimeOnEveryInterrupt a mid-prompt steering interrupt
8297 // stays soft: session/cancel, same process, session reuse.
8298 restartRuntimeAfterInterrupt: true,
8299 terminateRuntimeProcessGroupOnInterrupt: true,
8300 preserveRuntimeOnSettledInterrupt: true,
8301 registerExtensions: ({ runtime: extensionRuntime, applyBackgroundTaskMutation }) =>
8302 registerXAiBackgroundTaskTracking(extensionRuntime, applyBackgroundTaskMutation),
8303 // No ownDetachedProcessGroup: if the interrupt wrongly takes the
8304 // hard path, terminateProcessGroup is missing and the interrupt
8305 // fails loudly with a poisoned session.
8306 makeRuntime: makeMockRuntime({
8307 childProcessSpawner,
8308 mockAgentPath,
8309 environment: (runtimeOrdinal) => {
8310 runtimeOrdinalSeen = Math.max(runtimeOrdinalSeen, runtimeOrdinal);
8311 return {
8312 T3_ACP_EMIT_RUNNING_COMMAND_THEN_HANG_FIRST_PROMPT: "1",
8313 T3_ACP_EMIT_TASK_BACKGROUNDED_AFTER_CANCEL: "1",
8314 };
8315 },
8316 protocolEvents,
8317 wrapCancel: (cancel) =>
8318 Effect.sync(() => {
8319 cancelCalled = true;
8320 }).pipe(Effect.andThen(cancel)),
8321 }),
8322 },
8323 selfInvocation,
8324 continuationRequests: {
8325 offer: (request) =>
8326 Effect.sync(() => {
8327 continuationRequests.push(request);
8328 }),
8329 },
8330 });
8331 const threadId = ThreadId.make("thread-acp-soft-mid-prompt-steer");
8332 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
8333 runtimeMode: "full-access",
8334 interactionMode: "default",
8335 cwd: process.cwd(),
8336 });
8337 const modelSelection = { instanceId, model: "default" } as const;
8338 const runtime = yield* adapter.openSession({
8339 threadId,
8340 providerSessionId: ProviderSessionId.make("provider-session-acp-soft-mid-prompt-steer"),
8341 modelSelection,
8342 runtimePolicy,
8343 });
8344 if (runtime.hasPendingBackgroundWork === undefined) {
8345 return yield* Effect.die(
8346 "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.",
8347 );
8348 }
8349 const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork;
8350 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
8351 yield* runtime.events.pipe(
8352 Stream.runForEach((event) => Queue.offer(events, event)),
8353 Effect.forkScoped,
8354 );
8355 const providerThread = yield* runtime.ensureThread({
8356 threadId,
8357 modelSelection,
8358 runtimePolicy,
8359 });
8360 const now = yield* DateTime.now;
8361 yield* runtime
8362 .startTurn(
8363 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }),
8364 )
8365 .pipe(Effect.forkScoped);
8366 // Wait for the running command tool so the interrupt lands mid-prompt.
8367 yield* Stream.fromQueue(protocolEvents).pipe(
8368 Stream.filter(
8369 (event) =>
8370 event.direction === "incoming" &&
8371 event.stage === "raw" &&
8372 typeof event.payload === "string" &&
8373 event.payload.includes("tool-call-running-1"),
8374 ),
8375 Stream.runHead,
8376 );
8377
8378 const firstProviderTurnId = idAllocator.derive.providerTurn({
8379 driver: ACP_TEST_DRIVER,
8380 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
8381 });
8382 // No requestRuntimeRestart: a steering interrupt, not a user Stop.
8383 yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId });
8384 assert.isTrue(
8385 cancelCalled,
8386 "soft mid-prompt interrupt must send session/cancel to detach the running work",
8387 );
8388
8389 let firstTerminalStatus: string | null = null;
8390 while (firstTerminalStatus === null) {
8391 const event = yield* Queue.take(events);
8392 if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) {
8393 firstTerminalStatus = event.status;
8394 }
8395 }
8396 assert.equal(firstTerminalStatus, "interrupted");
8397 // The cancel handler emitted _x.ai/task_backgrounded for the detached
8398 // command; the tracked task must report as pending background work.
8399 let backgroundTracked = false;
8400 for (let attempt = 0; attempt < 80 && !backgroundTracked; attempt += 1) {
8401 backgroundTracked = yield* hasPendingBackgroundWork;
8402 if (!backgroundTracked) {
8403 yield* Effect.sleep("25 millis");
8404 }
8405 }
8406 assert.isTrue(
8407 backgroundTracked,
8408 "cancel-backgrounded task must be tracked as running background work",
8409 );
8410
8411 // The second prompt reuses the same process and session.
8412 const secondNow = yield* DateTime.now;
8413 yield* runtime.startTurn(
8414 makeTurnInput({
8415 threadId,
8416 providerThread,
8417 instanceId,
8418 runtimePolicy,
8419 now: secondNow,
8420 ordinal: 2,
8421 }),
8422 );
8423 const secondProviderTurnId = idAllocator.derive.providerTurn({
8424 driver: ACP_TEST_DRIVER,
8425 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"),
8426 });
8427 let secondTerminalStatus: string | null = null;
8428 while (secondTerminalStatus === null) {
8429 const event = yield* Queue.take(events);
8430 if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) {
8431 secondTerminalStatus = event.status;
8432 }
8433 }
8434 assert.equal(secondTerminalStatus, "completed");
8435 assert.equal(
8436 runtimeOrdinalSeen,
8437 1,
8438 "soft mid-prompt interrupt must not respawn the ACP runtime process",
8439 );
8440
8441 // _x.ai/task_completed lands ~1.2s after the cancel and clears the
8442 // tracked task without opening a synthetic continuation run.
8443 let backgroundPending = true;
8444 for (let attempt = 0; attempt < 50 && backgroundPending; attempt += 1) {
8445 backgroundPending = yield* hasPendingBackgroundWork;
8446 if (backgroundPending) {
8447 yield* Effect.sleep("100 millis");
8448 }
8449 }
8450 assert.isFalse(
8451 backgroundPending,
8452 "tracked background task must clear after _x.ai/task_completed",
8453 );
8454 assert.lengthOf(
8455 continuationRequests,
8456 0,
8457 "a cancel-backgrounded task completion must not wake a synthetic continuation run",
8458 );
8459 }).pipe(Effect.provide(layerTest), Effect.scoped),
8460 );
8461
8462 it.live("direct Stop quarantine drops late background task mutations from the stopped run", () =>
8463 Effect.gen(function* () {
8464 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
8465 const idAllocator = yield* IdAllocator.IdAllocatorV2;
8466 const path = yield* Path.Path;
8467 const selfInvocation = yield* resolveSelfInvocation();
8468 const mockAgentPath = yield* path.fromFileUrl(
8469 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
8470 );
8471 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
8472 const instanceId = ProviderInstanceId.make("acp-test");
8473 const capturedMutation: {
8474 current:
8475 | ((mutation: {
8476 readonly sessionId: string;
8477 readonly taskId: string;
8478 readonly status: "running" | "completed" | "failed";
8479 }) => Effect.Effect<void>)
8480 | null;
8481 } = { current: null };
8482 const adapter = yield* makeAcpAdapterV2({
8483 instanceId,
8484 flavor: {
8485 driver: ACP_TEST_DRIVER,
8486 capabilities: AcpProviderCapabilitiesV2,
8487 enablePostSettleContinuation: true,
8488 restartRuntimeAfterInterrupt: true,
8489 terminateRuntimeProcessGroupOnInterrupt: true,
8490 preserveRuntimeOnSettledInterrupt: true,
8491 registerExtensions: (context) =>
8492 Effect.sync(() => {
8493 capturedMutation.current = context.applyBackgroundTaskMutation;
8494 }),
8495 makeRuntime: makeMockRuntime({
8496 childProcessSpawner,
8497 mockAgentPath,
8498 environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" },
8499 ownDetachedProcessGroup: true,
8500 protocolEvents,
8501 }),
8502 },
8503 selfInvocation,
8504 continuationRequests: { offer: () => Effect.void },
8505 });
8506 const threadId = ThreadId.make("thread-acp-stop-quarantine-late-task");
8507 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
8508 runtimeMode: "full-access",
8509 interactionMode: "default",
8510 cwd: process.cwd(),
8511 });
8512 const modelSelection = { instanceId, model: "default" } as const;
8513 const runtime = yield* adapter.openSession({
8514 threadId,
8515 providerSessionId: ProviderSessionId.make("provider-session-acp-stop-quarantine-late-task"),
8516 modelSelection,
8517 runtimePolicy,
8518 });
8519 if (runtime.hasPendingBackgroundWork === undefined) {
8520 return yield* Effect.die(
8521 "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.",
8522 );
8523 }
8524 const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork;
8525 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
8526 yield* runtime.events.pipe(
8527 Stream.runForEach((event) => Queue.offer(events, event)),
8528 Effect.forkScoped,
8529 );
8530 const providerThread = yield* runtime.ensureThread({
8531 threadId,
8532 modelSelection,
8533 runtimePolicy,
8534 });
8535 const now = yield* DateTime.now;
8536 yield* runtime
8537 .startTurn(
8538 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }),
8539 )
8540 .pipe(Effect.forkScoped);
8541 yield* Stream.fromQueue(protocolEvents).pipe(
8542 Stream.filter(
8543 (event) =>
8544 event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt",
8545 ),
8546 Stream.runHead,
8547 );
8548 const applyMutation = capturedMutation.current;
8549 if (applyMutation === null) {
8550 return yield* Effect.die("registerExtensions must capture applyBackgroundTaskMutation");
8551 }
8552 // Plumbing sanity: pre-Stop mutations on the root session track and
8553 // clear pending background work through the extension callback.
8554 yield* applyMutation({
8555 sessionId: "mock-session-1",
8556 taskId: "task-pre-stop",
8557 status: "running",
8558 });
8559 assert.isTrue(
8560 yield* hasPendingBackgroundWork,
8561 "a running background task mutation on the root session must track before Stop",
8562 );
8563 yield* applyMutation({
8564 sessionId: "mock-session-1",
8565 taskId: "task-pre-stop",
8566 status: "completed",
8567 });
8568 assert.isFalse(
8569 yield* hasPendingBackgroundWork,
8570 "a completed background task mutation must clear pending background work",
8571 );
8572
8573 const providerTurnId = idAllocator.derive.providerTurn({
8574 driver: ACP_TEST_DRIVER,
8575 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
8576 });
8577 yield* runtime.interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true });
8578 let terminalStatus: string | null = null;
8579 while (terminalStatus === null) {
8580 const event = yield* Queue.take(events);
8581 if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) {
8582 terminalStatus = event.status;
8583 }
8584 }
8585 assert.equal(terminalStatus, "interrupted");
8586
8587 // Residual lifecycle from the stopped run: activeSessionId still points
8588 // at the stopped session until the next turn respawns the runtime, so
8589 // only the direct Stop quarantine stands between this mutation and the
8590 // wake machinery.
8591 yield* applyMutation({
8592 sessionId: "mock-session-1",
8593 taskId: "task-late-after-stop",
8594 status: "running",
8595 });
8596 assert.isFalse(
8597 yield* hasPendingBackgroundWork,
8598 "direct Stop quarantine must drop residual background task mutations from the stopped run",
8599 );
8600 }).pipe(Effect.provide(layerTest), Effect.scoped),
8601 );
8602
8603 it.live("production Grok interrupt flags still hard-kill and respawn on user Stop", () =>
8604 Effect.gen(function* () {
8605 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
8606 const idAllocator = yield* IdAllocator.IdAllocatorV2;
8607 const path = yield* Path.Path;
8608 const selfInvocation = yield* resolveSelfInvocation();
8609 const mockAgentPath = yield* path.fromFileUrl(
8610 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
8611 );
8612 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
8613 const instanceId = ProviderInstanceId.make("acp-test");
8614 let runtimeOrdinalSeen = 0;
8615 const adapter = yield* makeAcpAdapterV2({
8616 instanceId,
8617 flavor: {
8618 driver: ACP_TEST_DRIVER,
8619 capabilities: AcpProviderCapabilitiesV2,
8620 enablePostSettleContinuation: true,
8621 // The full production Grok interrupt flag set after the non-Stop
8622 // softening: no restartRuntimeOnEveryInterrupt. User Stop
8623 // (requestRuntimeRestart) must still take the hard teardown and
8624 // respawn path, not the soft cancel path.
8625 restartRuntimeAfterInterrupt: true,
8626 terminateRuntimeProcessGroupOnInterrupt: true,
8627 preserveRuntimeOnSettledInterrupt: true,
8628 registerExtensions: ({ runtime: extensionRuntime, applyBackgroundTaskMutation }) =>
8629 registerXAiBackgroundTaskTracking(extensionRuntime, applyBackgroundTaskMutation),
8630 makeRuntime: makeMockRuntime({
8631 childProcessSpawner,
8632 mockAgentPath,
8633 // Only hang the interrupted first turn. The replacement process must
8634 // complete normally so startTurn / session-load assertions can finish.
8635 environment: (runtimeOrdinal) => {
8636 runtimeOrdinalSeen = Math.max(runtimeOrdinalSeen, runtimeOrdinal);
8637 return runtimeOrdinal === 1 ? { T3_ACP_HANG_PROMPT_FOREVER: "1" } : {};
8638 },
8639 ownDetachedProcessGroup: true,
8640 protocolEvents,
8641 }),
8642 },
8643 selfInvocation,
8644 continuationRequests: { offer: () => Effect.void },
8645 });
8646 const threadId = ThreadId.make("thread-acp-production-stop-hard-kill");
8647 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
8648 runtimeMode: "full-access",
8649 interactionMode: "default",
8650 cwd: process.cwd(),
8651 });
8652 const modelSelection = { instanceId, model: "default" } as const;
8653 const runtime = yield* adapter.openSession({
8654 threadId,
8655 providerSessionId: ProviderSessionId.make("provider-session-acp-production-stop-hard-kill"),
8656 modelSelection,
8657 runtimePolicy,
8658 });
8659 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
8660 yield* runtime.events.pipe(
8661 Stream.runForEach((event) => Queue.offer(events, event)),
8662 Effect.forkScoped,
8663 );
8664 const providerThread = yield* runtime.ensureThread({
8665 threadId,
8666 modelSelection,
8667 runtimePolicy,
8668 });
8669 const now = yield* DateTime.now;
8670 yield* runtime
8671 .startTurn(
8672 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }),
8673 )
8674 .pipe(Effect.forkScoped);
8675 yield* Stream.fromQueue(protocolEvents).pipe(
8676 Stream.filter(
8677 (event) =>
8678 event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt",
8679 ),
8680 Stream.runHead,
8681 );
8682 const providerTurnId = idAllocator.derive.providerTurn({
8683 driver: ACP_TEST_DRIVER,
8684 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
8685 });
8686 yield* runtime.interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true });
8687 let terminalStatus: string | null = null;
8688 while (terminalStatus === null) {
8689 const event = yield* Queue.take(events);
8690 if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) {
8691 terminalStatus = event.status;
8692 }
8693 }
8694 assert.equal(terminalStatus, "interrupted");
8695
8696 // Effect 4 Queue.takeAll waits for at least one element when empty. After
8697 // the session/prompt stream drain the protocol queue is often empty, so
8698 // takeAll would hang forever. Use clear (non-blocking drain) instead so
8699 // the session/resume wait cannot match residual pre-restart traffic.
8700 yield* Queue.clear(protocolEvents);
8701 yield* runtime.startTurn(
8702 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }),
8703 );
8704 const loadAfterRestart = yield* Stream.fromQueue(protocolEvents).pipe(
8705 Stream.filter(
8706 (event) =>
8707 event.direction === "outgoing" && rawProtocolMethod(event) === "session/resume",
8708 ),
8709 Stream.runHead,
8710 );
8711 assert.isTrue(
8712 Option.isSome(loadAfterRestart),
8713 "user Stop must respawn the runtime and reload the session on the next turn",
8714 );
8715 assert.equal(
8716 runtimeOrdinalSeen,
8717 2,
8718 "user Stop with production Grok flags must replace the ACP runtime process",
8719 );
8720 }).pipe(Effect.provide(layerTest), Effect.scoped),
8721 );
8722
8723 // it.live: ownDetachedProcessGroup teardown uses wall-clock sleeps; under
8724 // it.effect the interrupt timeout on context.completed still needs real time
8725 // after the soft steer clears the turn.
8726 it.live(
8727 "Stop after settled soft steer contains the orphan runtime and respawns without subagent carryover",
8728 () =>
8729 Effect.gen(function* () {
8730 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
8731 const idAllocator = yield* IdAllocator.IdAllocatorV2;
8732 const path = yield* Path.Path;
8733 const selfInvocation = yield* resolveSelfInvocation();
8734 const mockAgentPath = yield* path.fromFileUrl(
8735 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
8736 );
8737 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
8738 const instanceId = ProviderInstanceId.make("acp-test");
8739 let subagentPhase: "spawn" | "complete" = "spawn";
8740 let cancelCalled = false;
8741 let runtimeOrdinalSeen = 0;
8742 const childSessionId = "mock-child-session-1";
8743 const authoritativeSubagentText = "authoritative subagent carryover text";
8744 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
8745 let sessionUpdateHandler: Parameters<RuntimeService["handleSessionUpdate"]>[0] | undefined;
8746 const promptSettled = yield* Deferred.make<void>();
8747 const adapter = yield* makeAcpAdapterV2({
8748 testHooks: {
8749 afterPromptSettledWithBackgroundWork: () =>
8750 Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid),
8751 },
8752 instanceId,
8753 flavor: {
8754 driver: ACP_TEST_DRIVER,
8755 capabilities: AcpProviderCapabilitiesV2,
8756 deferFinalizeForBackgroundWork: true,
8757 // Production Grok interrupt flags: soft settle keeps the process;
8758 // only requestRuntimeRestart (user Stop) hard-kills.
8759 restartRuntimeAfterInterrupt: true,
8760 terminateRuntimeProcessGroupOnInterrupt: true,
8761 preserveRuntimeOnSettledInterrupt: true,
8762 extractSubagentUpdate: (toolCall) =>
8763 toolCall.toolCallId !== "tool-call-generic-1"
8764 ? undefined
8765 : subagentPhase === "spawn"
8766 ? {
8767 nativeTaskId: "task-generic-1",
8768 prompt: "background subagent",
8769 title: "background subagent",
8770 model: null,
8771 status: "running",
8772 childSessionId,
8773 result: null,
8774 }
8775 : {
8776 nativeTaskId: "task-generic-1",
8777 prompt: "",
8778 title: null,
8779 model: null,
8780 status: "completed",
8781 childSessionId: null,
8782 result: "SUB_DONE",
8783 },
8784 makeRuntime: makeMockRuntime({
8785 childProcessSpawner,
8786 mockAgentPath,
8787 environment: (runtimeOrdinal) => {
8788 runtimeOrdinalSeen = Math.max(runtimeOrdinalSeen, runtimeOrdinal);
8789 return { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" };
8790 },
8791 ownDetachedProcessGroup: true,
8792 protocolEvents,
8793 wrapCancel: (cancel) =>
8794 Effect.sync(() => {
8795 cancelCalled = true;
8796 }).pipe(Effect.andThen(cancel)),
8797 wrapRuntime: (runtime) => ({
8798 ...runtime,
8799 handleSessionUpdate: (handler) =>
8800 Effect.sync(() => {
8801 sessionUpdateHandler = handler;
8802 }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))),
8803 }),
8804 }),
8805 },
8806 selfInvocation,
8807 });
8808 const threadId = ThreadId.make("thread-acp-stop-after-soft-steer-orphan");
8809 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
8810 runtimeMode: "full-access",
8811 interactionMode: "default",
8812 cwd: process.cwd(),
8813 });
8814 const modelSelection = { instanceId, model: "default" } as const;
8815 const runtime = yield* adapter.openSession({
8816 threadId,
8817 providerSessionId: ProviderSessionId.make(
8818 "provider-session-acp-stop-after-soft-steer-orphan",
8819 ),
8820 modelSelection,
8821 runtimePolicy,
8822 });
8823 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
8824 yield* runtime.events.pipe(
8825 Stream.runForEach((event) => Queue.offer(events, event)),
8826 Effect.forkScoped,
8827 );
8828 const providerThread = yield* runtime.ensureThread({
8829 threadId,
8830 modelSelection,
8831 runtimePolicy,
8832 });
8833 const now = yield* DateTime.now;
8834 yield* runtime.startTurn(
8835 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
8836 );
8837 yield* Deferred.await(promptSettled);
8838
8839 // Project an authoritative v2 assistant-message upsert onto the carryover
8840 // subagent while the deferred turn is still active.
8841 assert.isDefined(sessionUpdateHandler, "session update handler must be wired");
8842 yield* sessionUpdateHandler!({
8843 sessionId: childSessionId,
8844 update: {
8845 sessionUpdate: "agent_message",
8846 messageId: "child-message-1",
8847 content: [{ type: "text", text: authoritativeSubagentText }],
8848 },
8849 });
8850 let subagentTurnItemId: string | null = null;
8851 let authoritativeTextSeen = false;
8852 for (let attempt = 0; attempt < 64; attempt += 1) {
8853 const maybeEvent = yield* Queue.take(events).pipe(Effect.timeoutOption("50 millis"));
8854 if (Option.isNone(maybeEvent)) break;
8855 const event = maybeEvent.value;
8856 if (event.type === "turn_item.updated" && event.turnItem.type === "subagent") {
8857 subagentTurnItemId = event.turnItem.id;
8858 }
8859 if (
8860 event.type === "message.updated" &&
8861 event.message.text.includes(authoritativeSubagentText)
8862 ) {
8863 authoritativeTextSeen = true;
8864 break;
8865 }
8866 }
8867 assert.isTrue(
8868 authoritativeTextSeen,
8869 "pre-steer child session upsert must project as subagent assistant text",
8870 );
8871
8872 const firstProviderTurnId = idAllocator.derive.providerTurn({
8873 driver: ACP_TEST_DRIVER,
8874 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
8875 });
8876 // Soft steer first: clears the turn, leaves the process alive.
8877 yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId });
8878 assert.isFalse(
8879 cancelCalled,
8880 "settled soft steer must not send session/cancel before the later Stop",
8881 );
8882
8883 let firstTerminalStatus: string | null = null;
8884 while (firstTerminalStatus === null) {
8885 const event = yield* Queue.take(events);
8886 if (event.type === "turn_item.updated" && event.turnItem.type === "subagent") {
8887 subagentTurnItemId = event.turnItem.id;
8888 }
8889 if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) {
8890 firstTerminalStatus = event.status;
8891 }
8892 }
8893 assert.equal(firstTerminalStatus, "interrupted");
8894 assert.notEqual(subagentTurnItemId, null);
8895 assert.equal(
8896 runtimeOrdinalSeen,
8897 1,
8898 "soft steer must leave the original ACP runtime process alive",
8899 );
8900
8901 // User Stop against the already-cleared turn: contain the orphan runtime.
8902 const stopExit = yield* runtime
8903 .interruptTurn({
8904 providerThread,
8905 providerTurnId: firstProviderTurnId,
8906 requestRuntimeRestart: true,
8907 })
8908 .pipe(Effect.exit);
8909 if (Exit.isFailure(stopExit)) {
8910 assert.fail(
8911 `cleared-turn Stop must contain the orphan runtime, not fail: ${Cause.pretty(stopExit.cause)}`,
8912 );
8913 }
8914
8915 // Orphan Stop must terminalize carried-over subagents (soft steer left them
8916 // running on purpose; quarantine alone would leave them stuck "running").
8917 let subagentStopStatus: string | null = null;
8918 let subagentStopResult: string | null | undefined;
8919 let subagentStopProviderThreadId: string | null | undefined;
8920 let subagentUpdatedResult: string | null | undefined;
8921 for (let attempt = 0; attempt < 64; attempt += 1) {
8922 const maybeEvent = yield* Queue.take(events).pipe(Effect.timeoutOption("50 millis"));
8923 if (Option.isNone(maybeEvent)) break;
8924 const event = maybeEvent.value;
8925 if (
8926 event.type === "turn_item.updated" &&
8927 event.turnItem.type === "subagent" &&
8928 event.turnItem.id === subagentTurnItemId
8929 ) {
8930 subagentStopStatus = event.turnItem.status;
8931 subagentStopResult = event.turnItem.result;
8932 subagentStopProviderThreadId = event.turnItem.providerThreadId;
8933 if (subagentStopStatus === "interrupted") break;
8934 }
8935 if (
8936 event.type === "subagent.updated" &&
8937 event.subagent.status === "interrupted" &&
8938 event.subagent.result === authoritativeSubagentText
8939 ) {
8940 subagentUpdatedResult = event.subagent.result;
8941 }
8942 }
8943 assert.equal(
8944 subagentStopStatus,
8945 "interrupted",
8946 "orphan Stop must emit interrupted terminal for turn-1 carryover subagent",
8947 );
8948 assert.equal(
8949 subagentStopResult,
8950 authoritativeSubagentText,
8951 "orphan Stop must merge authoritative assistant text into the interrupted result",
8952 );
8953 assert.equal(
8954 subagentStopProviderThreadId,
8955 providerThread.id,
8956 "orphan Stop parent-level events must use the spawn-time parent provider thread id",
8957 );
8958 assert.equal(
8959 subagentUpdatedResult,
8960 authoritativeSubagentText,
8961 "orphan Stop subagent.updated must also carry the authoritative result",
8962 );
8963
8964 yield* Queue.clear(protocolEvents);
8965 const secondNow = yield* DateTime.now;
8966 yield* runtime.startTurn(
8967 makeTurnInput({
8968 threadId,
8969 providerThread,
8970 instanceId,
8971 runtimePolicy,
8972 now: secondNow,
8973 ordinal: 2,
8974 }),
8975 );
8976 const loadAfterRestart = yield* Stream.fromQueue(protocolEvents).pipe(
8977 Stream.filter(
8978 (event) =>
8979 event.direction === "outgoing" &&
8980 (rawProtocolMethod(event) === "session/resume" ||
8981 rawProtocolMethod(event) === "session/new"),
8982 ),
8983 Stream.runHead,
8984 );
8985 assert.isTrue(
8986 Option.isSome(loadAfterRestart),
8987 "orphan containment must force a runtime respawn before the next turn",
8988 );
8989 assert.equal(
8990 runtimeOrdinalSeen,
8991 2,
8992 "Stop after soft steer must replace the orphan ACP runtime process",
8993 );
8994
8995 // nativeTurnId is `${sessionId}:turn:${ordinal}`. The mock always uses
8996 // mock-session-1; ordinal 2 yields turn:2 on the replacement process.
8997 // Carryover was quarantined by Stop, so turn-1's subagent must not re-attach.
8998 subagentPhase = "complete";
8999 const secondProviderTurnId = idAllocator.derive.providerTurn({
9000 driver: ACP_TEST_DRIVER,
9001 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"),
9002 });
9003 let carriedItemStatus: string | null = null;
9004 let secondTerminalStatus: string | null = null;
9005 while (secondTerminalStatus === null) {
9006 const event = yield* Queue.take(events);
9007 if (
9008 event.type === "turn_item.updated" &&
9009 event.turnItem.type === "subagent" &&
9010 event.turnItem.id === subagentTurnItemId
9011 ) {
9012 carriedItemStatus = event.turnItem.status;
9013 }
9014 if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) {
9015 secondTerminalStatus = event.status;
9016 }
9017 }
9018 assert.isNull(
9019 carriedItemStatus,
9020 "Stop quarantine must drop turn-1 subagent carryover on the respawned runtime",
9021 );
9022 assert.equal(secondTerminalStatus, "completed");
9023 }).pipe(Effect.provide(layerTest), Effect.scoped),
9024 );
9025
9026 it.live("Direct Stop projects an interrupt-deferred terminal exactly once", () =>
9027 Effect.gen(function* () {
9028 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
9029 const idAllocator = yield* IdAllocator.IdAllocatorV2;
9030 const path = yield* Path.Path;
9031 const selfInvocation = yield* resolveSelfInvocation();
9032 const mockAgentPath = yield* path.fromFileUrl(
9033 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
9034 );
9035 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
9036 const instanceId = ProviderInstanceId.make("acp-test");
9037 const childSessionId = "mock-child-session-direct-stop-deferred-terminal";
9038 let subagentPhase: "spawn" | "complete" = "spawn";
9039 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
9040 let sessionUpdateHandler: Parameters<RuntimeService["handleSessionUpdate"]>[0] | undefined;
9041 const adapter = yield* makeAcpAdapterV2({
9042 instanceId,
9043 flavor: {
9044 driver: ACP_TEST_DRIVER,
9045 capabilities: AcpProviderCapabilitiesV2,
9046 deferFinalizeForBackgroundWork: true,
9047 enablePostSettleContinuation: true,
9048 restartRuntimeAfterInterrupt: true,
9049 terminateRuntimeProcessGroupOnInterrupt: true,
9050 preserveRuntimeOnSettledInterrupt: true,
9051 extractSubagentUpdate: (toolCall) =>
9052 toolCall.toolCallId !== "tool-call-generic-1"
9053 ? undefined
9054 : subagentPhase === "spawn"
9055 ? {
9056 nativeTaskId: "task-generic-1",
9057 prompt: "background subagent",
9058 title: "background subagent",
9059 model: null,
9060 status: "running",
9061 childSessionId,
9062 result: null,
9063 }
9064 : {
9065 nativeTaskId: "task-generic-1",
9066 prompt: "",
9067 title: null,
9068 model: null,
9069 status: "completed",
9070 childSessionId,
9071 result: "SUB_DONE",
9072 },
9073 makeRuntime: makeMockRuntime({
9074 childProcessSpawner,
9075 mockAgentPath,
9076 environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" },
9077 ownDetachedProcessGroup: true,
9078 protocolEvents,
9079 wrapRuntime: (runtime) => ({
9080 ...runtime,
9081 handleSessionUpdate: (handler) =>
9082 Effect.sync(() => {
9083 sessionUpdateHandler = handler;
9084 }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))),
9085 }),
9086 }),
9087 },
9088 selfInvocation,
9089 continuationRequests: { offer: () => Effect.void },
9090 });
9091 const threadId = ThreadId.make("thread-acp-direct-stop-deferred-terminal");
9092 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
9093 runtimeMode: "full-access",
9094 interactionMode: "default",
9095 cwd: process.cwd(),
9096 });
9097 const modelSelection = { instanceId, model: "default" } as const;
9098 const runtime = yield* adapter.openSession({
9099 threadId,
9100 providerSessionId: ProviderSessionId.make(
9101 "provider-session-acp-direct-stop-deferred-terminal",
9102 ),
9103 modelSelection,
9104 runtimePolicy,
9105 });
9106 if (runtime.hasPendingBackgroundWork === undefined) {
9107 return yield* Effect.die(
9108 "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.",
9109 );
9110 }
9111 const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork;
9112 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
9113 yield* runtime.events.pipe(
9114 Stream.runForEach((event) => Queue.offer(events, event)),
9115 Effect.forkScoped,
9116 );
9117 const providerThread = yield* runtime.ensureThread({
9118 threadId,
9119 modelSelection,
9120 runtimePolicy,
9121 });
9122 const now = yield* DateTime.now;
9123 yield* runtime.startTurn(
9124 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
9125 );
9126 yield* Stream.fromQueue(protocolEvents).pipe(
9127 Stream.filter(
9128 (event) =>
9129 event.direction === "incoming" &&
9130 event.stage === "raw" &&
9131 typeof event.payload === "string" &&
9132 event.payload.includes('"stopReason"'),
9133 ),
9134 Stream.runHead,
9135 );
9136 yield* Effect.yieldNow;
9137 yield* Effect.yieldNow;
9138
9139 const providerTurnId = idAllocator.derive.providerTurn({
9140 driver: ACP_TEST_DRIVER,
9141 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
9142 });
9143 yield* runtime.interruptTurn({ providerThread, providerTurnId });
9144 let rootTerminal: string | null = null;
9145 while (rootTerminal === null) {
9146 const event = yield* Queue.take(events);
9147 if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) {
9148 rootTerminal = event.status;
9149 }
9150 }
9151 assert.equal(rootTerminal, "interrupted");
9152 assert.isDefined(sessionUpdateHandler, "session update handler must be wired");
9153
9154 subagentPhase = "complete";
9155 yield* sessionUpdateHandler!({
9156 sessionId: childSessionId,
9157 update: {
9158 sessionUpdate: "tool_call_update",
9159 toolCallId: "tool-call-generic-1",
9160 title: "background subagent",
9161 kind: "other",
9162 status: "completed",
9163 rawOutput: { content: "SUB_DONE" },
9164 },
9165 });
9166 yield* Effect.yieldNow;
9167 let completedBeforeStop = 0;
9168 let polled = yield* Queue.poll(events);
9169 while (Option.isSome(polled)) {
9170 const event = polled.value;
9171 if (
9172 event.type === "turn_item.updated" &&
9173 event.turnItem.type === "subagent" &&
9174 event.turnItem.status === "completed"
9175 ) {
9176 completedBeforeStop += 1;
9177 }
9178 polled = yield* Queue.poll(events);
9179 }
9180 assert.equal(completedBeforeStop, 0);
9181 assert.isTrue(
9182 yield* hasPendingBackgroundWork,
9183 "interrupt-deferred terminal must pin until hard-stop projection",
9184 );
9185
9186 const stopExit = yield* runtime
9187 .interruptTurn({
9188 providerThread,
9189 providerTurnId,
9190 requestRuntimeRestart: true,
9191 })
9192 .pipe(Effect.exit);
9193 if (Exit.isFailure(stopExit)) {
9194 assert.fail(`Direct Stop must contain the orphan runtime: ${Cause.pretty(stopExit.cause)}`);
9195 }
9196
9197 let completedAfterStop = 0;
9198 for (let attempt = 0; attempt < 64; attempt += 1) {
9199 const maybeEvent = yield* Queue.take(events).pipe(Effect.timeoutOption("50 millis"));
9200 if (Option.isNone(maybeEvent)) break;
9201 const event = maybeEvent.value;
9202 if (
9203 event.type === "turn_item.updated" &&
9204 event.turnItem.type === "subagent" &&
9205 event.turnItem.status === "completed"
9206 ) {
9207 completedAfterStop += 1;
9208 }
9209 }
9210 assert.equal(completedAfterStop, 1);
9211 assert.isFalse(yield* hasPendingBackgroundWork);
9212 }).pipe(Effect.provide(layerTest), Effect.scoped),
9213 );
9214
9215 it.effect(
9216 "settled soft interrupt skips cancel when prompt wire is settled before completion callback",
9217 () =>
9218 Effect.gen(function* () {
9219 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
9220 const idAllocator = yield* IdAllocator.IdAllocatorV2;
9221 const path = yield* Path.Path;
9222 const selfInvocation = yield* resolveSelfInvocation();
9223 const mockAgentPath = yield* path.fromFileUrl(
9224 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
9225 );
9226 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
9227 const instanceId = ProviderInstanceId.make("acp-test");
9228 let subagentPhase: "spawn" | "complete" = "spawn";
9229 let cancelCalled = false;
9230 const promptPhases: Array<string> = [];
9231 // Gate adapter-visible prompt return so we can open the race window after
9232 // the native stopReason is on the wire. Release lets Effect.tap mark
9233 // promptWireSettled before the completion callback requests the permit;
9234 // interrupt then ORs wire-done with promptSettled under the permit.
9235 const promptWireReturned = yield* Deferred.make<void>();
9236 const releasePromptCompletion = yield* Deferred.make<void>();
9237 const adapter = yield* makeAcpAdapterV2({
9238 instanceId,
9239 flavor: {
9240 driver: ACP_TEST_DRIVER,
9241 capabilities: AcpProviderCapabilitiesV2,
9242 deferFinalizeForBackgroundWork: true,
9243 // Production Grok flags: without settled-soft, a steer soft-cancels.
9244 restartRuntimeAfterInterrupt: true,
9245 terminateRuntimeProcessGroupOnInterrupt: true,
9246 preserveRuntimeOnSettledInterrupt: true,
9247 extractSubagentUpdate: (toolCall) =>
9248 toolCall.toolCallId !== "tool-call-generic-1"
9249 ? undefined
9250 : subagentPhase === "spawn"
9251 ? {
9252 nativeTaskId: "task-generic-1",
9253 prompt: "background subagent",
9254 title: "background subagent",
9255 model: null,
9256 status: "running",
9257 childSessionId: null,
9258 result: null,
9259 }
9260 : {
9261 nativeTaskId: "task-generic-1",
9262 prompt: "",
9263 title: null,
9264 model: null,
9265 status: "completed",
9266 childSessionId: null,
9267 result: "SUB_DONE",
9268 },
9269 makeRuntime: makeMockRuntime({
9270 childProcessSpawner,
9271 mockAgentPath,
9272 environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" },
9273 protocolEvents,
9274 wrapCancel: (cancel) =>
9275 Effect.sync(() => {
9276 cancelCalled = true;
9277 }).pipe(Effect.andThen(cancel)),
9278 wrapRuntime: (runtime) => ({
9279 ...runtime,
9280 prompt: (payload) =>
9281 Effect.gen(function* () {
9282 promptPhases.push("prompt-start");
9283 const result = yield* runtime.prompt(payload);
9284 promptPhases.push("wire-returned");
9285 yield* Deferred.succeed(promptWireReturned, undefined);
9286 yield* Deferred.await(releasePromptCompletion);
9287 promptPhases.push("completion-released");
9288 return result;
9289 }),
9290 }),
9291 }),
9292 },
9293 selfInvocation,
9294 });
9295 const threadId = ThreadId.make("thread-acp-settled-soft-admission-race");
9296 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
9297 runtimeMode: "full-access",
9298 interactionMode: "default",
9299 cwd: process.cwd(),
9300 });
9301 const modelSelection = { instanceId, model: "default" } as const;
9302 const runtime = yield* adapter.openSession({
9303 threadId,
9304 providerSessionId: ProviderSessionId.make(
9305 "provider-session-acp-settled-soft-admission-race",
9306 ),
9307 modelSelection,
9308 runtimePolicy,
9309 });
9310 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
9311 yield* runtime.events.pipe(
9312 Stream.runForEach((event) => Queue.offer(events, event)),
9313 Effect.forkScoped,
9314 );
9315 const providerThread = yield* runtime.ensureThread({
9316 threadId,
9317 modelSelection,
9318 runtimePolicy,
9319 });
9320 const now = yield* DateTime.now;
9321 yield* runtime.startTurn(
9322 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
9323 );
9324 yield* Deferred.await(promptWireReturned);
9325 yield* Stream.fromQueue(protocolEvents).pipe(
9326 Stream.filter(
9327 (event) =>
9328 event.direction === "incoming" &&
9329 event.stage === "raw" &&
9330 typeof event.payload === "string" &&
9331 event.payload.includes('"stopReason"'),
9332 ),
9333 Stream.runHead,
9334 );
9335 assert.deepEqual(promptPhases, ["prompt-start", "wire-returned"]);
9336
9337 const firstProviderTurnId = idAllocator.derive.providerTurn({
9338 driver: ACP_TEST_DRIVER,
9339 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
9340 });
9341 // Fork interrupt while the adapter-side return is still gated. Release so
9342 // promptWireSettled completes (Effect.tap) before/while the completion
9343 // callback contends for runtimeCallbackPermit. Holding the gate forever
9344 // would also block the wire signal (same Effect resolution), so release
9345 // is required; the assertion is cancel skipped after that race window.
9346 const interruptFiber = yield* runtime
9347 .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId })
9348 .pipe(Effect.forkScoped);
9349 yield* Deferred.succeed(releasePromptCompletion, undefined);
9350 for (let attempt = 0; attempt < 20; attempt += 1) {
9351 if (promptPhases.includes("completion-released")) break;
9352 yield* Effect.yieldNow;
9353 }
9354 yield* TestClock.adjust("10 seconds");
9355 yield* Fiber.join(interruptFiber);
9356
9357 assert.includeMembers(promptPhases, [
9358 "prompt-start",
9359 "wire-returned",
9360 "completion-released",
9361 ]);
9362 assert.isFalse(
9363 cancelCalled,
9364 "settled soft steer must skip session/cancel once the prompt wire has settled",
9365 );
9366
9367 let firstTerminalStatus: string | null = null;
9368 while (firstTerminalStatus === null) {
9369 const event = yield* Queue.take(events);
9370 if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) {
9371 firstTerminalStatus = event.status;
9372 }
9373 }
9374 assert.equal(firstTerminalStatus, "interrupted");
9375 assert.equal(subagentPhase, "spawn");
9376 }).pipe(Effect.provide(layerTest), Effect.scoped),
9377 );
9378
9379 it.effect(
9380 "does not pin hasPendingBackgroundWork when a late TaskOutput re-reports an in-turn-handled task",
9381 () =>
9382 Effect.gen(function* () {
9383 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
9384 const idAllocator = yield* IdAllocator.IdAllocatorV2;
9385 const path = yield* Path.Path;
9386 const selfInvocation = yield* resolveSelfInvocation();
9387 const mockAgentPath = yield* path.fromFileUrl(
9388 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
9389 );
9390 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
9391 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
9392 [];
9393 const instanceId = ProviderInstanceId.make("acp-test");
9394 const adapter = yield* makeAcpAdapterV2({
9395 instanceId,
9396 flavor: {
9397 driver: ACP_TEST_DRIVER,
9398 capabilities: AcpProviderCapabilitiesV2,
9399 enablePostSettleContinuation: true,
9400 extractBackgroundTaskId: (toolCall) =>
9401 toolCall.toolCallId === "tool-call-monitor-1" ? "task-monitor-1" : undefined,
9402 extractBackgroundTaskCompletion: (toolCall) =>
9403 toolCall.toolCallId === "tool-call-fetch-1"
9404 ? [
9405 {
9406 taskId: "task-monitor-1",
9407 status: toolCall.status === "completed" ? "completed" : "running",
9408 appendOutput: toolCall.status === "completed" ? "MONITOR_LISTING_TOKEN" : "",
9409 },
9410 ]
9411 : [],
9412 makeRuntime: makeMockRuntime({
9413 childProcessSpawner,
9414 mockAgentPath,
9415 environment: {
9416 T3_ACP_EMIT_IN_TURN_TASKOUTPUT_THEN_LATE_DUPLICATE: "1",
9417 },
9418 protocolEvents,
9419 }),
9420 },
9421 selfInvocation,
9422 continuationRequests: {
9423 offer: (request) =>
9424 Effect.sync(() => {
9425 continuationRequests.push(request);
9426 }),
9427 },
9428 });
9429 const threadId = ThreadId.make("thread-acp-already-handled-wake-pin");
9430 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
9431 runtimeMode: "full-access",
9432 interactionMode: "default",
9433 cwd: process.cwd(),
9434 });
9435 const modelSelection = { instanceId, model: "default" } as const;
9436 const runtime = yield* adapter.openSession({
9437 threadId,
9438 providerSessionId: ProviderSessionId.make(
9439 "provider-session-acp-already-handled-wake-pin",
9440 ),
9441 modelSelection,
9442 runtimePolicy,
9443 });
9444 if (runtime.hasPendingBackgroundWork === undefined) {
9445 return yield* Effect.die(
9446 "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.",
9447 );
9448 }
9449 const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork;
9450 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
9451 yield* runtime.events.pipe(
9452 Stream.runForEach((event) => Queue.offer(events, event)),
9453 Effect.forkScoped,
9454 );
9455 const providerThread = yield* runtime.ensureThread({
9456 threadId,
9457 modelSelection,
9458 runtimePolicy,
9459 });
9460 const now = yield* DateTime.now;
9461 yield* runtime.startTurn(
9462 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
9463 );
9464 const providerTurnId = idAllocator.derive.providerTurn({
9465 driver: ACP_TEST_DRIVER,
9466 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
9467 });
9468
9469 let terminalStatus: string | null = null;
9470 while (terminalStatus === null) {
9471 const event = yield* Queue.take(events);
9472 if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) {
9473 terminalStatus = event.status;
9474 }
9475 }
9476 assert.equal(terminalStatus, "completed");
9477
9478 // Wait for the late post-finalize duplicate TaskOutput frame.
9479 yield* Stream.fromQueue(protocolEvents).pipe(
9480 Stream.filter(
9481 (event) =>
9482 event.direction === "incoming" &&
9483 event.stage === "raw" &&
9484 typeof event.payload === "string" &&
9485 event.payload.includes("MONITOR_LISTING_TOKEN_LATE"),
9486 ),
9487 Stream.runHead,
9488 );
9489 yield* Effect.yieldNow;
9490 yield* Effect.yieldNow;
9491 yield* Effect.yieldNow;
9492
9493 assert.lengthOf(
9494 continuationRequests,
9495 0,
9496 "already-handled late TaskOutput must not open a continuation run",
9497 );
9498 assert.isFalse(
9499 yield* hasPendingBackgroundWork,
9500 "wake buffer must not stay non-empty and pin idle release after an already-handled re-report",
9501 );
9502 }).pipe(Effect.provide(layerTest), Effect.scoped),
9503 );
9504
9505 it.effect(
9506 "keeps a dispatched continuation offer sticky until a turn starts or the worker drops it",
9507 () =>
9508 Effect.gen(function* () {
9509 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
9510 const idAllocator = yield* IdAllocator.IdAllocatorV2;
9511 const path = yield* Path.Path;
9512 const selfInvocation = yield* resolveSelfInvocation();
9513 const mockAgentPath = yield* path.fromFileUrl(
9514 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
9515 );
9516 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
9517 [];
9518 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
9519 let sessionUpdateHandler: Parameters<RuntimeService["handleSessionUpdate"]>[0] | undefined;
9520 const instanceId = ProviderInstanceId.make("acp-test");
9521 const adapter = yield* makeAcpAdapterV2({
9522 instanceId,
9523 flavor: {
9524 driver: ACP_TEST_DRIVER,
9525 capabilities: AcpProviderCapabilitiesV2,
9526 enablePostSettleContinuation: true,
9527 makeRuntime: makeMockRuntime({
9528 childProcessSpawner,
9529 mockAgentPath,
9530 wrapRuntime: (runtime) => ({
9531 ...runtime,
9532 handleSessionUpdate: (handler) =>
9533 Effect.sync(() => {
9534 sessionUpdateHandler = handler;
9535 }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))),
9536 }),
9537 }),
9538 },
9539 selfInvocation,
9540 continuationRequests: {
9541 offer: (request) =>
9542 Effect.sync(() => {
9543 continuationRequests.push(request);
9544 }),
9545 },
9546 });
9547 const threadId = ThreadId.make("thread-acp-sticky-continuation-dispatch");
9548 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
9549 runtimeMode: "full-access",
9550 interactionMode: "default",
9551 cwd: process.cwd(),
9552 });
9553 const modelSelection = { instanceId, model: "default" } as const;
9554 const runtime = yield* adapter.openSession({
9555 threadId,
9556 providerSessionId: ProviderSessionId.make(
9557 "provider-session-acp-sticky-continuation-dispatch",
9558 ),
9559 modelSelection,
9560 runtimePolicy,
9561 });
9562 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
9563 yield* runtime.events.pipe(
9564 Stream.runForEach((event) => Queue.offer(events, event)),
9565 Effect.forkScoped,
9566 );
9567 const providerThread = yield* runtime.ensureThread({
9568 threadId,
9569 modelSelection,
9570 runtimePolicy,
9571 });
9572 const now = yield* DateTime.now;
9573 yield* runtime.startTurn(
9574 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
9575 );
9576 const providerTurnId = idAllocator.derive.providerTurn({
9577 driver: ACP_TEST_DRIVER,
9578 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
9579 });
9580 let terminalStatus: string | null = null;
9581 while (terminalStatus === null) {
9582 const event = yield* Queue.take(events);
9583 if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) {
9584 terminalStatus = event.status;
9585 }
9586 }
9587 assert.equal(terminalStatus, "completed");
9588 assert.isDefined(sessionUpdateHandler, "session update handler must be wired");
9589
9590 const lateTool = (toolCallId: string) =>
9591 sessionUpdateHandler!({
9592 sessionId: "mock-session-1",
9593 update: {
9594 sessionUpdate: "tool_call_update",
9595 toolCallId,
9596 title: "Late tool result",
9597 kind: "other",
9598 status: "completed",
9599 rawOutput: { output: toolCallId },
9600 },
9601 });
9602 yield* lateTool("first-late-result");
9603 assert.lengthOf(continuationRequests, 1);
9604 const first = continuationRequests[0]!;
9605 assert.isDefined(first.dispatchIfCurrent);
9606 assert.isTrue(Option.isSome(yield* first.dispatchIfCurrent!(Effect.void)));
9607
9608 yield* lateTool("second-frame-before-dispatched-turn-starts");
9609 assert.lengthOf(
9610 continuationRequests,
9611 1,
9612 "late frames must not enqueue duplicate continuations during dispatch-to-start",
9613 );
9614
9615 assert.isDefined(first.clearIfCurrent);
9616 yield* first.clearIfCurrent!();
9617 yield* lateTool("new-result-after-worker-drop");
9618 assert.lengthOf(continuationRequests, 2);
9619 }).pipe(Effect.provide(layerTest), Effect.scoped),
9620 );
9621
9622 it.effect("keeps a buffered continuation current when a user turn starts before dispatch", () =>
9623 Effect.gen(function* () {
9624 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
9625 const idAllocator = yield* IdAllocator.IdAllocatorV2;
9626 const path = yield* Path.Path;
9627 const selfInvocation = yield* resolveSelfInvocation();
9628 const mockAgentPath = yield* path.fromFileUrl(
9629 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
9630 );
9631 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
9632 [];
9633 const userPromptStarted = yield* Deferred.make<void>();
9634 const releaseUserPrompt = yield* Deferred.make<void>();
9635 const bufferedAssistantText = "BUFFERED_WAKE_QUEUED_AFTER_USER";
9636 let promptOrdinal = 0;
9637 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
9638 let sessionUpdateHandler: Parameters<RuntimeService["handleSessionUpdate"]>[0] | undefined;
9639 const instanceId = ProviderInstanceId.make("acp-test");
9640 const adapter = yield* makeAcpAdapterV2({
9641 instanceId,
9642 flavor: {
9643 driver: ACP_TEST_DRIVER,
9644 capabilities: AcpProviderCapabilitiesV2,
9645 deferFinalizeForBackgroundWork: true,
9646 enablePostSettleContinuation: true,
9647 makeRuntime: makeMockRuntime({
9648 childProcessSpawner,
9649 mockAgentPath,
9650 wrapRuntime: (runtime) => ({
9651 ...runtime,
9652 handleSessionUpdate: (handler) =>
9653 Effect.sync(() => {
9654 sessionUpdateHandler = handler;
9655 }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))),
9656 prompt: (payload) =>
9657 Effect.gen(function* () {
9658 promptOrdinal += 1;
9659 const currentPromptOrdinal = promptOrdinal;
9660 const result = yield* runtime.prompt(payload);
9661 if (currentPromptOrdinal === 2) {
9662 yield* Deferred.succeed(userPromptStarted, undefined);
9663 yield* Deferred.await(releaseUserPrompt);
9664 }
9665 return result;
9666 }),
9667 }),
9668 }),
9669 },
9670 selfInvocation,
9671 continuationRequests: {
9672 offer: (request) =>
9673 Effect.sync(() => {
9674 continuationRequests.push(request);
9675 }),
9676 },
9677 });
9678 const threadId = ThreadId.make("thread-acp-buffered-continuation-user-race");
9679 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
9680 runtimeMode: "full-access",
9681 interactionMode: "default",
9682 cwd: process.cwd(),
9683 });
9684 const modelSelection = { instanceId, model: "default" } as const;
9685 const runtime = yield* adapter.openSession({
9686 threadId,
9687 providerSessionId: ProviderSessionId.make(
9688 "provider-session-acp-buffered-continuation-user-race",
9689 ),
9690 modelSelection,
9691 runtimePolicy,
9692 });
9693 if (runtime.hasPendingBackgroundWork === undefined) {
9694 return yield* Effect.die(
9695 "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.",
9696 );
9697 }
9698 const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork;
9699 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
9700 yield* runtime.events.pipe(
9701 Stream.runForEach((event) => Queue.offer(events, event)),
9702 Effect.forkScoped,
9703 );
9704 const providerThread = yield* runtime.ensureThread({
9705 threadId,
9706 modelSelection,
9707 runtimePolicy,
9708 });
9709 yield* runtime.startTurn(
9710 makeTurnInput({
9711 threadId,
9712 providerThread,
9713 instanceId,
9714 runtimePolicy,
9715 now: yield* DateTime.now,
9716 }),
9717 );
9718 const firstProviderTurnId = idAllocator.derive.providerTurn({
9719 driver: ACP_TEST_DRIVER,
9720 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
9721 });
9722 let firstTerminalStatus: string | null = null;
9723 while (firstTerminalStatus === null) {
9724 const event = yield* Queue.take(events);
9725 if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) {
9726 firstTerminalStatus = event.status;
9727 }
9728 }
9729 assert.equal(firstTerminalStatus, "completed");
9730 assert.isDefined(sessionUpdateHandler, "session update handler must be wired");
9731
9732 yield* sessionUpdateHandler!({
9733 sessionId: "mock-session-1",
9734 update: {
9735 sessionUpdate: "agent_message_chunk",
9736 content: { type: "text", text: bufferedAssistantText },
9737 },
9738 });
9739 assert.lengthOf(continuationRequests, 1);
9740 const continuationRequest = continuationRequests[0]!;
9741 assert.isDefined(continuationRequest.dispatchIfCurrent);
9742
9743 yield* runtime.startTurn(
9744 makeTurnInput({
9745 threadId,
9746 providerThread,
9747 instanceId,
9748 runtimePolicy,
9749 now: yield* DateTime.now,
9750 ordinal: 2,
9751 messageText: "User turn won the continuation dispatch race.",
9752 }),
9753 );
9754 yield* Deferred.await(userPromptStarted);
9755 let continuationDispatched = false;
9756 const dispatchOutcome = yield* continuationRequest.dispatchIfCurrent!(
9757 Effect.sync(() => {
9758 continuationDispatched = true;
9759 }),
9760 );
9761 assert.isTrue(
9762 Option.isSome(dispatchOutcome),
9763 "queue_after_active dispatch must remain current while the user run is active",
9764 );
9765 assert.isTrue(continuationDispatched);
9766 yield* Deferred.succeed(releaseUserPrompt, undefined);
9767
9768 const userProviderTurnId = idAllocator.derive.providerTurn({
9769 driver: ACP_TEST_DRIVER,
9770 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"),
9771 });
9772 let userTerminalStatus: string | null = null;
9773 while (userTerminalStatus === null) {
9774 const event = yield* Queue.take(events);
9775 if (event.type === "turn.terminal" && event.providerTurnId === userProviderTurnId) {
9776 userTerminalStatus = event.status;
9777 }
9778 }
9779 assert.equal(userTerminalStatus, "completed");
9780 assert.isTrue(
9781 yield* hasPendingBackgroundWork,
9782 "the queued continuation must retain ownership of its buffered wake traffic",
9783 );
9784
9785 yield* runtime.startTurn(
9786 makeTurnInput({
9787 threadId,
9788 providerThread,
9789 instanceId,
9790 runtimePolicy,
9791 now: yield* DateTime.now,
9792 ordinal: 3,
9793 messageCreatedBy: "agent",
9794 messageCreationSource: "provider",
9795 messageText: "Background task completed.",
9796 }),
9797 );
9798 yield* TestClock.adjust("3 seconds");
9799 const continuationProviderTurnId = idAllocator.derive.providerTurn({
9800 driver: ACP_TEST_DRIVER,
9801 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:3"),
9802 });
9803 let continuationTerminalStatus: string | null = null;
9804 let bufferedTextSeen = false;
9805 while (continuationTerminalStatus === null) {
9806 const event = yield* Queue.take(events);
9807 if (
9808 event.type === "message.updated" &&
9809 event.message.role === "assistant" &&
9810 event.message.text.includes(bufferedAssistantText)
9811 ) {
9812 bufferedTextSeen = true;
9813 }
9814 if (event.type === "turn.terminal" && event.providerTurnId === continuationProviderTurnId) {
9815 continuationTerminalStatus = event.status;
9816 }
9817 }
9818 assert.equal(continuationTerminalStatus, "completed");
9819 assert.isTrue(bufferedTextSeen, "continuation must drain the wake buffer after the user run");
9820 assert.isFalse(yield* hasPendingBackgroundWork);
9821 }).pipe(Effect.provide(layerTest), Effect.scoped),
9822 );
9823
9824 it.effect(
9825 "holds a settled turn until the injected monitor report streams instead of finalizing into it",
9826 () =>
9827 Effect.gen(function* () {
9828 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
9829 const fileSystem = yield* FileSystem.FileSystem;
9830 const idAllocator = yield* IdAllocator.IdAllocatorV2;
9831 const path = yield* Path.Path;
9832 const selfInvocation = yield* resolveSelfInvocation();
9833 const mockAgentPath = yield* path.fromFileUrl(
9834 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
9835 );
9836 const triggerDir = yield* fileSystem.makeTempDirectoryScoped();
9837 const triggerPath = path.join(triggerDir, "report-trigger");
9838 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
9839 const instanceId = ProviderInstanceId.make("acp-test");
9840 const adapter = yield* makeAcpAdapterV2({
9841 instanceId,
9842 flavor: {
9843 driver: ACP_TEST_DRIVER,
9844 capabilities: AcpProviderCapabilitiesV2,
9845 deferFinalizeForBackgroundWork: true,
9846 extractBackgroundTaskId: (toolCall) =>
9847 toolCall.toolCallId === "tool-call-monitor-1" ? "task-monitor-1" : undefined,
9848 extractBackgroundToolMutation: (text) =>
9849 text.includes('Monitor "task-monitor-1" ended')
9850 ? [{ taskId: "task-monitor-1", status: "completed", appendOutput: "" }]
9851 : [],
9852 extractBackgroundTaskCompletion: (toolCall) =>
9853 toolCall.toolCallId === "tool-call-fetch-1"
9854 ? [
9855 {
9856 taskId: "task-monitor-1",
9857 status: toolCall.status === "completed" ? "completed" : "running",
9858 appendOutput: toolCall.status === "completed" ? "MONITOR_LISTING_TOKEN" : "",
9859 },
9860 ]
9861 : [],
9862 makeRuntime: makeMockRuntime({
9863 childProcessSpawner,
9864 mockAgentPath,
9865 environment: {
9866 T3_ACP_EMIT_POST_SETTLE_MONITOR_FLOW: "1",
9867 T3_ACP_INJECTED_REPORT_TRIGGER_PATH: triggerPath,
9868 },
9869 protocolEvents,
9870 }),
9871 },
9872 selfInvocation,
9873 });
9874 const threadId = ThreadId.make("thread-acp-injected-report-hold");
9875 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
9876 runtimeMode: "full-access",
9877 interactionMode: "default",
9878 cwd: process.cwd(),
9879 });
9880 const modelSelection = { instanceId, model: "default" } as const;
9881 const runtime = yield* adapter.openSession({
9882 threadId,
9883 providerSessionId: ProviderSessionId.make("provider-session-acp-injected-report"),
9884 modelSelection,
9885 runtimePolicy,
9886 });
9887 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
9888 yield* runtime.events.pipe(
9889 Stream.runForEach((event) => Queue.offer(events, event)),
9890 Effect.forkScoped,
9891 );
9892 const providerThread = yield* runtime.ensureThread({
9893 threadId,
9894 modelSelection,
9895 runtimePolicy,
9896 });
9897 const now = yield* DateTime.now;
9898 yield* runtime.startTurn(
9899 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
9900 );
9901 const providerTurnId = idAllocator.derive.providerTurn({
9902 driver: ACP_TEST_DRIVER,
9903 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
9904 });
9905
9906 // Wait (real time) until the post-settle end notice and TaskOutput
9907 // hydration are ingested: the hydrated monitor card carries the
9908 // fetched listing.
9909 let reportSeen = false;
9910 const trackReport = (event: ProviderAdapter.ProviderAdapterV2Event): void => {
9911 if (
9912 event.type === "turn_item.updated" &&
9913 event.turnItem.type === "assistant_message" &&
9914 event.turnItem.text.includes("MONITOR_REPORT_TOKEN")
9915 ) {
9916 reportSeen = true;
9917 }
9918 };
9919 let hydrated = false;
9920 while (!hydrated) {
9921 const event = yield* Queue.take(events);
9922 if (
9923 event.type === "turn_item.updated" &&
9924 event.turnItem.type === "command_execution" &&
9925 event.turnItem.status === "completed" &&
9926 (event.turnItem.output ?? "").includes("MONITOR_LISTING_TOKEN")
9927 ) {
9928 hydrated = true;
9929 }
9930 }
9931 yield* Effect.yieldNow;
9932 yield* Effect.yieldNow;
9933 yield* Effect.yieldNow;
9934
9935 // Pre-fix the 2s deferred-finalize debounce fires here and the report
9936 // streamed by the injected turn afterwards is dropped on the floor.
9937 yield* TestClock.adjust("3 seconds");
9938 yield* Effect.yieldNow;
9939 yield* Effect.yieldNow;
9940 yield* Effect.yieldNow;
9941 let drained = yield* Queue.poll(events);
9942 while (Option.isSome(drained)) {
9943 trackReport(drained.value);
9944 assert.notEqual(
9945 drained.value.type,
9946 "turn.terminal",
9947 "deferred finalize must hold while the injected-turn report is owed",
9948 );
9949 drained = yield* Queue.poll(events);
9950 }
9951
9952 // Release the report, then the normal debounce finalizes the turn.
9953 yield* fileSystem.writeFileString(triggerPath, "go");
9954 yield* Stream.fromQueue(protocolEvents).pipe(
9955 Stream.filter(
9956 (event) =>
9957 event.direction === "incoming" &&
9958 event.stage === "raw" &&
9959 typeof event.payload === "string" &&
9960 event.payload.includes("MONITOR_REPORT_TOKEN"),
9961 ),
9962 Stream.runHead,
9963 );
9964 yield* Effect.yieldNow;
9965 yield* Effect.yieldNow;
9966 yield* Effect.yieldNow;
9967 yield* TestClock.adjust("3 seconds");
9968
9969 let terminalStatus: string | null = null;
9970 while (terminalStatus === null) {
9971 const event = yield* Queue.take(events);
9972 trackReport(event);
9973 if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) {
9974 terminalStatus = event.status;
9975 }
9976 }
9977 assert.equal(terminalStatus, "completed");
9978 assert.isTrue(
9979 reportSeen,
9980 "the injected-turn report must project before the turn finalizes",
9981 );
9982 }).pipe(Effect.provide(layerTest), Effect.scoped),
9983 );
9984
9985 it.effect(
9986 "settle-without-report held turn with mid-hold ext completion does not open a wake after the injected report",
9987 () =>
9988 Effect.gen(function* () {
9989 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
9990 const fileSystem = yield* FileSystem.FileSystem;
9991 const idAllocator = yield* IdAllocator.IdAllocatorV2;
9992 const path = yield* Path.Path;
9993 const selfInvocation = yield* resolveSelfInvocation();
9994 const mockAgentPath = yield* path.fromFileUrl(
9995 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
9996 );
9997 const triggerDir = yield* fileSystem.makeTempDirectoryScoped();
9998 const triggerPath = path.join(triggerDir, "report-trigger");
9999 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
10000 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
10001 [];
10002 const capturedMutation: {
10003 current:
10004 | ((mutation: {
10005 readonly sessionId: string;
10006 readonly taskId: string;
10007 readonly status: "running" | "completed" | "failed";
10008 }) => Effect.Effect<void>)
10009 | null;
10010 } = { current: null };
10011 const instanceId = ProviderInstanceId.make("acp-test");
10012 const adapter = yield* makeAcpAdapterV2({
10013 instanceId,
10014 flavor: {
10015 driver: ACP_TEST_DRIVER,
10016 capabilities: AcpProviderCapabilitiesV2,
10017 deferFinalizeForBackgroundWork: true,
10018 enablePostSettleContinuation: true,
10019 extractBackgroundTaskId: (toolCall) =>
10020 toolCall.toolCallId === "tool-call-monitor-1" ? "task-monitor-1" : undefined,
10021 extractBackgroundToolMutation: (text) =>
10022 text.includes('Monitor "task-monitor-1" ended')
10023 ? [{ taskId: "task-monitor-1", status: "completed", appendOutput: "" }]
10024 : [],
10025 extractBackgroundTaskCompletion: (toolCall) =>
10026 toolCall.toolCallId === "tool-call-fetch-1"
10027 ? [
10028 {
10029 taskId: "task-monitor-1",
10030 status: toolCall.status === "completed" ? "completed" : "running",
10031 appendOutput: toolCall.status === "completed" ? "MONITOR_LISTING_TOKEN" : "",
10032 },
10033 ]
10034 : [],
10035 registerExtensions: (context) =>
10036 Effect.sync(() => {
10037 capturedMutation.current = context.applyBackgroundTaskMutation;
10038 }),
10039 makeRuntime: makeMockRuntime({
10040 childProcessSpawner,
10041 mockAgentPath,
10042 environment: {
10043 T3_ACP_EMIT_POST_SETTLE_MONITOR_FLOW: "1",
10044 T3_ACP_INJECTED_REPORT_TRIGGER_PATH: triggerPath,
10045 },
10046 protocolEvents,
10047 }),
10048 },
10049 selfInvocation,
10050 continuationRequests: {
10051 offer: (request) =>
10052 Effect.sync(() => {
10053 continuationRequests.push(request);
10054 }),
10055 },
10056 });
10057 const threadId = ThreadId.make("thread-acp-settle-hold-ext-complete-no-wake");
10058 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
10059 runtimeMode: "full-access",
10060 interactionMode: "default",
10061 cwd: process.cwd(),
10062 });
10063 const modelSelection = { instanceId, model: "default" } as const;
10064 const runtime = yield* adapter.openSession({
10065 threadId,
10066 providerSessionId: ProviderSessionId.make(
10067 "provider-session-acp-settle-hold-ext-complete-no-wake",
10068 ),
10069 modelSelection,
10070 runtimePolicy,
10071 });
10072 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
10073 yield* runtime.events.pipe(
10074 Stream.runForEach((event) => Queue.offer(events, event)),
10075 Effect.forkScoped,
10076 );
10077 const providerThread = yield* runtime.ensureThread({
10078 threadId,
10079 modelSelection,
10080 runtimePolicy,
10081 });
10082 const now = yield* DateTime.now;
10083 yield* runtime.startTurn(
10084 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
10085 );
10086 const providerTurnId = idAllocator.derive.providerTurn({
10087 driver: ACP_TEST_DRIVER,
10088 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
10089 });
10090
10091 let reportSeen = false;
10092 const trackReport = (event: ProviderAdapter.ProviderAdapterV2Event): void => {
10093 if (
10094 event.type === "turn_item.updated" &&
10095 event.turnItem.type === "assistant_message" &&
10096 event.turnItem.text.includes("MONITOR_REPORT_TOKEN")
10097 ) {
10098 reportSeen = true;
10099 }
10100 };
10101 let hydrated = false;
10102 while (!hydrated) {
10103 const event = yield* Queue.take(events);
10104 if (
10105 event.type === "turn_item.updated" &&
10106 event.turnItem.type === "command_execution" &&
10107 event.turnItem.status === "completed" &&
10108 (event.turnItem.output ?? "").includes("MONITOR_LISTING_TOKEN")
10109 ) {
10110 hydrated = true;
10111 }
10112 }
10113 // Settled-held window: prompt returned, deferred finalize is holding for
10114 // the injected report. An x.ai/task_completed ext mutation must not arm
10115 // midTurnUnreported and open a wake after the report streams.
10116 const applyMutation = capturedMutation.current;
10117 if (applyMutation === null) {
10118 return yield* Effect.die("registerExtensions must capture applyBackgroundTaskMutation");
10119 }
10120 yield* applyMutation({
10121 sessionId: "mock-session-1",
10122 taskId: "task-monitor-1",
10123 status: "completed",
10124 });
10125 assert.lengthOf(
10126 continuationRequests,
10127 0,
10128 "ext completion during the settled-held window must not offer mid-hold",
10129 );
10130
10131 yield* TestClock.adjust("3 seconds");
10132 yield* Effect.yieldNow;
10133 yield* Effect.yieldNow;
10134 yield* Effect.yieldNow;
10135 let drained = yield* Queue.poll(events);
10136 while (Option.isSome(drained)) {
10137 trackReport(drained.value);
10138 assert.notEqual(
10139 drained.value.type,
10140 "turn.terminal",
10141 "deferred finalize must hold while the injected-turn report is owed",
10142 );
10143 drained = yield* Queue.poll(events);
10144 }
10145
10146 yield* fileSystem.writeFileString(triggerPath, "go");
10147 yield* Stream.fromQueue(protocolEvents).pipe(
10148 Stream.filter(
10149 (event) =>
10150 event.direction === "incoming" &&
10151 event.stage === "raw" &&
10152 typeof event.payload === "string" &&
10153 event.payload.includes("MONITOR_REPORT_TOKEN"),
10154 ),
10155 Stream.runHead,
10156 );
10157 yield* Effect.yieldNow;
10158 yield* Effect.yieldNow;
10159 yield* Effect.yieldNow;
10160 yield* TestClock.adjust("3 seconds");
10161
10162 let terminalStatus: string | null = null;
10163 while (terminalStatus === null) {
10164 const event = yield* Queue.take(events);
10165 trackReport(event);
10166 if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) {
10167 terminalStatus = event.status;
10168 }
10169 }
10170 assert.equal(terminalStatus, "completed");
10171 assert.isTrue(
10172 reportSeen,
10173 "the injected-turn report must project before the turn finalizes",
10174 );
10175 assert.lengthOf(
10176 continuationRequests,
10177 0,
10178 "settle-without-report with mid-hold ext completion must not open a wake after the report streams",
10179 );
10180 }).pipe(Effect.provide(layerTest), Effect.scoped),
10181 );
10182
10183 it.effect(
10184 "pre-settle ext completion arm is cleared when the report precedes its end notice",
10185 () =>
10186 Effect.gen(function* () {
10187 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
10188 const idAllocator = yield* IdAllocator.IdAllocatorV2;
10189 const path = yield* Path.Path;
10190 const selfInvocation = yield* resolveSelfInvocation();
10191 const mockAgentPath = yield* path.fromFileUrl(
10192 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
10193 );
10194 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
10195 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
10196 [];
10197 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
10198 let sessionUpdateHandler: Parameters<RuntimeService["handleSessionUpdate"]>[0] | undefined;
10199 const promptGate = yield* Deferred.make<EffectAcpSchema.PromptResponse>();
10200 const capturedMutation: {
10201 current:
10202 | ((mutation: {
10203 readonly sessionId: string;
10204 readonly taskId: string;
10205 readonly status: "running" | "completed" | "failed";
10206 }) => Effect.Effect<void>)
10207 | null;
10208 } = { current: null };
10209 const instanceId = ProviderInstanceId.make("acp-test");
10210 const adapter = yield* makeAcpAdapterV2({
10211 instanceId,
10212 flavor: {
10213 driver: ACP_TEST_DRIVER,
10214 capabilities: AcpProviderCapabilitiesV2,
10215 deferFinalizeForBackgroundWork: true,
10216 enablePostSettleContinuation: true,
10217 extractBackgroundTaskId: (toolCall) =>
10218 toolCall.toolCallId === "tool-call-monitor-1" ? "task-monitor-1" : undefined,
10219 extractBackgroundToolMutation: (text) =>
10220 text.includes('Monitor "task-monitor-1" ended')
10221 ? [{ taskId: "task-monitor-1", status: "completed", appendOutput: "" }]
10222 : [],
10223 registerExtensions: (context) =>
10224 Effect.sync(() => {
10225 capturedMutation.current = context.applyBackgroundTaskMutation;
10226 }),
10227 makeRuntime: makeMockRuntime({
10228 childProcessSpawner,
10229 mockAgentPath,
10230 environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" },
10231 protocolEvents,
10232 wrapRuntime: (runtime) => ({
10233 ...runtime,
10234 handleSessionUpdate: (handler) =>
10235 Effect.sync(() => {
10236 sessionUpdateHandler = handler;
10237 }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))),
10238 prompt: () => Deferred.await(promptGate),
10239 }),
10240 }),
10241 },
10242 selfInvocation,
10243 continuationRequests: {
10244 offer: (request) =>
10245 Effect.sync(() => {
10246 continuationRequests.push(request);
10247 }),
10248 },
10249 });
10250 const threadId = ThreadId.make("thread-acp-pre-settle-arm-cleared-by-report");
10251 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
10252 runtimeMode: "full-access",
10253 interactionMode: "default",
10254 cwd: process.cwd(),
10255 });
10256 const modelSelection = { instanceId, model: "default" } as const;
10257 const runtime = yield* adapter.openSession({
10258 threadId,
10259 providerSessionId: ProviderSessionId.make(
10260 "provider-session-acp-pre-settle-arm-cleared-by-report",
10261 ),
10262 modelSelection,
10263 runtimePolicy,
10264 });
10265 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
10266 yield* runtime.events.pipe(
10267 Stream.runForEach((event) => Queue.offer(events, event)),
10268 Effect.forkScoped,
10269 );
10270 const providerThread = yield* runtime.ensureThread({
10271 threadId,
10272 modelSelection,
10273 runtimePolicy,
10274 });
10275 const now = yield* DateTime.now;
10276 yield* runtime
10277 .startTurn(
10278 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }),
10279 )
10280 .pipe(Effect.forkScoped);
10281 yield* Effect.yieldNow;
10282 yield* Effect.yieldNow;
10283 assert.isDefined(sessionUpdateHandler);
10284 const applyMutation = capturedMutation.current;
10285 if (applyMutation === null) {
10286 return yield* Effect.die("registerExtensions must capture applyBackgroundTaskMutation");
10287 }
10288
10289 // Start a monitor mid-turn; leave it unhandled (no get_command).
10290 yield* sessionUpdateHandler!({
10291 sessionId: "mock-session-1",
10292 update: {
10293 sessionUpdate: "tool_call",
10294 toolCallId: "tool-call-monitor-1",
10295 title: "Monitor: pre-settle complete",
10296 kind: "execute",
10297 status: "pending",
10298 rawInput: {},
10299 },
10300 });
10301 yield* sessionUpdateHandler!({
10302 sessionId: "mock-session-1",
10303 update: {
10304 sessionUpdate: "tool_call_update",
10305 toolCallId: "tool-call-monitor-1",
10306 status: "in_progress",
10307 },
10308 });
10309 yield* applyMutation({
10310 sessionId: "mock-session-1",
10311 taskId: "task-monitor-1",
10312 status: "running",
10313 });
10314 // Completion ext PRE-settle: arms midTurnUnreportedCompletedTaskIds.
10315 yield* applyMutation({
10316 sessionId: "mock-session-1",
10317 taskId: "task-monitor-1",
10318 status: "completed",
10319 });
10320 assert.lengthOf(
10321 continuationRequests,
10322 0,
10323 "pre-settle unhandled completion must not offer while the turn is active",
10324 );
10325
10326 // Settle without reporting. Tool stays in_progress so deferred finalize
10327 // holds the turn open for the injected report.
10328 yield* Deferred.succeed(promptGate, { stopReason: "end_turn" });
10329 yield* Effect.yieldNow;
10330 yield* Effect.yieldNow;
10331 yield* Effect.yieldNow;
10332 assert.lengthOf(
10333 continuationRequests,
10334 0,
10335 "settle must hold for deferred background work; midTurn alone must not offer mid-hold",
10336 );
10337
10338 // The injected report can race ahead of the end notice. It must be
10339 // remembered so the later notice consumes the pre-settle arm.
10340 yield* sessionUpdateHandler!({
10341 sessionId: "mock-session-1",
10342 update: {
10343 sessionUpdate: "agent_message_chunk",
10344 content: { type: "text", text: "Monitor finished. MONITOR_REPORT_TOKEN" },
10345 },
10346 });
10347 yield* sessionUpdateHandler!({
10348 sessionId: "mock-session-1",
10349 update: {
10350 sessionUpdate: "user_message_chunk",
10351 content: {
10352 type: "text",
10353 text: 'Monitor "task-monitor-1" ended: [monitor ended: exit 0]',
10354 },
10355 },
10356 });
10357 yield* Effect.yieldNow;
10358 yield* Effect.yieldNow;
10359 yield* Effect.yieldNow;
10360
10361 let reportSeen = false;
10362 const trackReport = (event: ProviderAdapter.ProviderAdapterV2Event): void => {
10363 if (
10364 event.type === "turn_item.updated" &&
10365 event.turnItem.type === "assistant_message" &&
10366 event.turnItem.text.includes("MONITOR_REPORT_TOKEN")
10367 ) {
10368 reportSeen = true;
10369 }
10370 if (
10371 event.type === "message.updated" &&
10372 event.message.text.includes("MONITOR_REPORT_TOKEN")
10373 ) {
10374 reportSeen = true;
10375 }
10376 };
10377 let drained = yield* Queue.poll(events);
10378 while (Option.isSome(drained)) {
10379 trackReport(drained.value);
10380 assert.notEqual(
10381 drained.value.type,
10382 "turn.terminal",
10383 "turn must stay open while hydration hold / quiet window remain",
10384 );
10385 drained = yield* Queue.poll(events);
10386 }
10387 assert.isTrue(reportSeen, "injected report must project into the held turn");
10388
10389 // Hydration safety (60s) force-completes the monitor tool, then the 3s
10390 // quiet window finalizes. No TaskOutput path: that would also clear
10391 // midTurn and hide the agent_message_chunk consumption site under test.
10392 yield* TestClock.adjust("60 seconds");
10393 yield* Effect.yieldNow;
10394 yield* Effect.yieldNow;
10395 yield* TestClock.adjust("3 seconds");
10396
10397 const providerTurnId = idAllocator.derive.providerTurn({
10398 driver: ACP_TEST_DRIVER,
10399 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
10400 });
10401 let terminalStatus: string | null = null;
10402 while (terminalStatus === null) {
10403 const event = yield* Queue.take(events);
10404 trackReport(event);
10405 if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) {
10406 terminalStatus = event.status;
10407 }
10408 }
10409 assert.equal(terminalStatus, "completed");
10410 assert.lengthOf(
10411 continuationRequests,
10412 0,
10413 "pre-settle arm must be cleared when the injected report streams; no duplicate wake",
10414 );
10415 }).pipe(Effect.provide(layerTest), Effect.scoped),
10416 );
10417
10418 it.effect(
10419 "staggered pre-settle completion keeps midTurn marks until last background task ends post-finalize",
10420 () =>
10421 Effect.gen(function* () {
10422 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
10423 const idAllocator = yield* IdAllocator.IdAllocatorV2;
10424 const path = yield* Path.Path;
10425 const selfInvocation = yield* resolveSelfInvocation();
10426 const mockAgentPath = yield* path.fromFileUrl(
10427 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
10428 );
10429 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
10430 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
10431 [];
10432 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
10433 let sessionUpdateHandler: Parameters<RuntimeService["handleSessionUpdate"]>[0] | undefined;
10434 const promptGate = yield* Deferred.make<EffectAcpSchema.PromptResponse>();
10435 const capturedMutation: {
10436 current:
10437 | ((mutation: {
10438 readonly sessionId: string;
10439 readonly taskId: string;
10440 readonly status: "running" | "completed" | "failed";
10441 }) => Effect.Effect<void>)
10442 | null;
10443 } = { current: null };
10444 const instanceId = ProviderInstanceId.make("acp-test");
10445 const adapter = yield* makeAcpAdapterV2({
10446 instanceId,
10447 flavor: {
10448 driver: ACP_TEST_DRIVER,
10449 capabilities: AcpProviderCapabilitiesV2,
10450 enablePostSettleContinuation: true,
10451 extractBackgroundTaskId: (toolCall) => {
10452 if (toolCall.toolCallId === "tool-call-monitor-a") return "task-monitor-a";
10453 if (toolCall.toolCallId === "tool-call-monitor-b") return "task-monitor-b";
10454 return undefined;
10455 },
10456 extractBackgroundToolMutation: (text) => {
10457 if (text.includes('Monitor "task-monitor-a" ended')) {
10458 return [{ taskId: "task-monitor-a", status: "completed", appendOutput: "" }];
10459 }
10460 if (text.includes('Monitor "task-monitor-b" ended')) {
10461 return [{ taskId: "task-monitor-b", status: "completed", appendOutput: "" }];
10462 }
10463 return [];
10464 },
10465 registerExtensions: (context) =>
10466 Effect.sync(() => {
10467 capturedMutation.current = context.applyBackgroundTaskMutation;
10468 }),
10469 makeRuntime: makeMockRuntime({
10470 childProcessSpawner,
10471 mockAgentPath,
10472 environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" },
10473 protocolEvents,
10474 wrapRuntime: (runtime) => ({
10475 ...runtime,
10476 handleSessionUpdate: (handler) =>
10477 Effect.sync(() => {
10478 sessionUpdateHandler = handler;
10479 }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))),
10480 prompt: () => Deferred.await(promptGate),
10481 }),
10482 }),
10483 },
10484 selfInvocation,
10485 continuationRequests: {
10486 offer: (request) =>
10487 Effect.sync(() => {
10488 continuationRequests.push(request);
10489 }),
10490 },
10491 });
10492 const threadId = ThreadId.make("thread-acp-staggered-midturn-keep-until-last");
10493 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
10494 runtimeMode: "full-access",
10495 interactionMode: "default",
10496 cwd: process.cwd(),
10497 });
10498 const modelSelection = { instanceId, model: "default" } as const;
10499 const runtime = yield* adapter.openSession({
10500 threadId,
10501 providerSessionId: ProviderSessionId.make(
10502 "provider-session-acp-staggered-midturn-keep-until-last",
10503 ),
10504 modelSelection,
10505 runtimePolicy,
10506 });
10507 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
10508 yield* runtime.events.pipe(
10509 Stream.runForEach((event) => Queue.offer(events, event)),
10510 Effect.forkScoped,
10511 );
10512 const providerThread = yield* runtime.ensureThread({
10513 threadId,
10514 modelSelection,
10515 runtimePolicy,
10516 });
10517 const now = yield* DateTime.now;
10518 yield* runtime
10519 .startTurn(
10520 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }),
10521 )
10522 .pipe(Effect.forkScoped);
10523 yield* Effect.yieldNow;
10524 yield* Effect.yieldNow;
10525 assert.isDefined(sessionUpdateHandler);
10526 const applyMutation = capturedMutation.current;
10527 if (applyMutation === null) {
10528 return yield* Effect.die("registerExtensions must capture applyBackgroundTaskMutation");
10529 }
10530
10531 // Register monitors A and B in-turn; leave both unhandled.
10532 for (const [toolCallId, title] of [
10533 ["tool-call-monitor-a", "Monitor: staggered A"],
10534 ["tool-call-monitor-b", "Monitor: staggered B"],
10535 ] as const) {
10536 yield* sessionUpdateHandler!({
10537 sessionId: "mock-session-1",
10538 update: {
10539 sessionUpdate: "tool_call",
10540 toolCallId,
10541 title,
10542 kind: "execute",
10543 status: "pending",
10544 rawInput: {},
10545 },
10546 });
10547 yield* sessionUpdateHandler!({
10548 sessionId: "mock-session-1",
10549 update: {
10550 sessionUpdate: "tool_call_update",
10551 toolCallId,
10552 status: "in_progress",
10553 },
10554 });
10555 }
10556 yield* applyMutation({
10557 sessionId: "mock-session-1",
10558 taskId: "task-monitor-a",
10559 status: "running",
10560 });
10561 yield* applyMutation({
10562 sessionId: "mock-session-1",
10563 taskId: "task-monitor-b",
10564 status: "running",
10565 });
10566 // A completes pre-settle while unhandled: arms midTurnUnreportedCompletedTaskIds.
10567 yield* applyMutation({
10568 sessionId: "mock-session-1",
10569 taskId: "task-monitor-a",
10570 status: "completed",
10571 });
10572 assert.lengthOf(
10573 continuationRequests,
10574 0,
10575 "pre-settle unhandled completion must not offer while the turn is active",
10576 );
10577
10578 // Turn settles and finalizes with B still running: no offer at finalize
10579 // (running set non-empty), but marks must be kept for the later end.
10580 yield* Deferred.succeed(promptGate, { stopReason: "end_turn" });
10581 const providerTurnId = idAllocator.derive.providerTurn({
10582 driver: ACP_TEST_DRIVER,
10583 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
10584 });
10585 let terminalStatus: string | null = null;
10586 while (terminalStatus === null) {
10587 const event = yield* Queue.take(events);
10588 if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) {
10589 terminalStatus = event.status;
10590 }
10591 }
10592 assert.equal(terminalStatus, "completed");
10593 assert.lengthOf(
10594 continuationRequests,
10595 0,
10596 "finalize must not offer while B is still running",
10597 );
10598
10599 // B ends post-finalize via mutation-only end-notice (fails
10600 // acpPostSettleWakeEvidence: extractBackgroundToolMutation matches).
10601 // Without kept midTurn marks this path would neither buffer nor offer.
10602 const bEndNotice = {
10603 sessionId: "mock-session-1",
10604 update: {
10605 sessionUpdate: "user_message_chunk" as const,
10606 content: {
10607 type: "text" as const,
10608 text: 'Monitor "task-monitor-b" ended: [monitor ended: exit 0]',
10609 },
10610 },
10611 };
10612 assert.isFalse(
10613 acpPostSettleWakeEvidence(bEndNotice, {
10614 extractBackgroundToolMutation: (text) =>
10615 text.includes('Monitor "task-monitor-b" ended')
10616 ? [{ taskId: "task-monitor-b", status: "completed", appendOutput: "" }]
10617 : [],
10618 }),
10619 "mutation-only end-notice must not count as post-settle wake evidence",
10620 );
10621 yield* sessionUpdateHandler!(bEndNotice);
10622 yield* Effect.yieldNow;
10623 yield* Effect.yieldNow;
10624 assert.lengthOf(
10625 continuationRequests,
10626 1,
10627 "exactly one continuation when the last running task ends post-finalize with kept midTurn marks",
10628 );
10629 }).pipe(Effect.provide(layerTest), Effect.scoped),
10630 );
10631
10632 it.effect(
10633 "staggered pre-settle completion does not keep midTurn marks when the turn is interrupted",
10634 () =>
10635 Effect.gen(function* () {
10636 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
10637 const idAllocator = yield* IdAllocator.IdAllocatorV2;
10638 const path = yield* Path.Path;
10639 const selfInvocation = yield* resolveSelfInvocation();
10640 const mockAgentPath = yield* path.fromFileUrl(
10641 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
10642 );
10643 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
10644 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
10645 [];
10646 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
10647 let sessionUpdateHandler: Parameters<RuntimeService["handleSessionUpdate"]>[0] | undefined;
10648 const promptGate = yield* Deferred.make<EffectAcpSchema.PromptResponse>();
10649 const capturedMutation: {
10650 current:
10651 | ((mutation: {
10652 readonly sessionId: string;
10653 readonly taskId: string;
10654 readonly status: "running" | "completed" | "failed";
10655 }) => Effect.Effect<void>)
10656 | null;
10657 } = { current: null };
10658 const instanceId = ProviderInstanceId.make("acp-test");
10659 const adapter = yield* makeAcpAdapterV2({
10660 instanceId,
10661 flavor: {
10662 driver: ACP_TEST_DRIVER,
10663 capabilities: AcpProviderCapabilitiesV2,
10664 // Hold finalize after prompt settle while monitor tools stay open so
10665 // interrupt lands on the settled-soft path (cancel need not race the
10666 // hang prompt).
10667 deferFinalizeForBackgroundWork: true,
10668 enablePostSettleContinuation: true,
10669 extractBackgroundTaskId: (toolCall) => {
10670 if (toolCall.toolCallId === "tool-call-monitor-a") return "task-monitor-a";
10671 if (toolCall.toolCallId === "tool-call-monitor-b") return "task-monitor-b";
10672 return undefined;
10673 },
10674 extractBackgroundToolMutation: (text) => {
10675 if (text.includes('Monitor "task-monitor-a" ended')) {
10676 return [{ taskId: "task-monitor-a", status: "completed", appendOutput: "" }];
10677 }
10678 if (text.includes('Monitor "task-monitor-b" ended')) {
10679 return [{ taskId: "task-monitor-b", status: "completed", appendOutput: "" }];
10680 }
10681 return [];
10682 },
10683 registerExtensions: (context) =>
10684 Effect.sync(() => {
10685 capturedMutation.current = context.applyBackgroundTaskMutation;
10686 }),
10687 makeRuntime: makeMockRuntime({
10688 childProcessSpawner,
10689 mockAgentPath,
10690 environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" },
10691 protocolEvents,
10692 wrapRuntime: (runtime) => ({
10693 ...runtime,
10694 handleSessionUpdate: (handler) =>
10695 Effect.sync(() => {
10696 sessionUpdateHandler = handler;
10697 }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))),
10698 prompt: () => Deferred.await(promptGate),
10699 }),
10700 }),
10701 },
10702 selfInvocation,
10703 continuationRequests: {
10704 offer: (request) =>
10705 Effect.sync(() => {
10706 continuationRequests.push(request);
10707 }),
10708 },
10709 });
10710 const threadId = ThreadId.make("thread-acp-staggered-midturn-clear-on-interrupt");
10711 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
10712 runtimeMode: "full-access",
10713 interactionMode: "default",
10714 cwd: process.cwd(),
10715 });
10716 const modelSelection = { instanceId, model: "default" } as const;
10717 const runtime = yield* adapter.openSession({
10718 threadId,
10719 providerSessionId: ProviderSessionId.make(
10720 "provider-session-acp-staggered-midturn-clear-on-interrupt",
10721 ),
10722 modelSelection,
10723 runtimePolicy,
10724 });
10725 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
10726 yield* runtime.events.pipe(
10727 Stream.runForEach((event) => Queue.offer(events, event)),
10728 Effect.forkScoped,
10729 );
10730 const providerThread = yield* runtime.ensureThread({
10731 threadId,
10732 modelSelection,
10733 runtimePolicy,
10734 });
10735 const now = yield* DateTime.now;
10736 yield* runtime
10737 .startTurn(
10738 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }),
10739 )
10740 .pipe(Effect.forkScoped);
10741 yield* Effect.yieldNow;
10742 yield* Effect.yieldNow;
10743 assert.isDefined(sessionUpdateHandler);
10744 const applyMutation = capturedMutation.current;
10745 if (applyMutation === null) {
10746 return yield* Effect.die("registerExtensions must capture applyBackgroundTaskMutation");
10747 }
10748
10749 for (const [toolCallId, title] of [
10750 ["tool-call-monitor-a", "Monitor: staggered interrupt A"],
10751 ["tool-call-monitor-b", "Monitor: staggered interrupt B"],
10752 ] as const) {
10753 yield* sessionUpdateHandler!({
10754 sessionId: "mock-session-1",
10755 update: {
10756 sessionUpdate: "tool_call",
10757 toolCallId,
10758 title,
10759 kind: "execute",
10760 status: "pending",
10761 rawInput: {},
10762 },
10763 });
10764 yield* sessionUpdateHandler!({
10765 sessionId: "mock-session-1",
10766 update: {
10767 sessionUpdate: "tool_call_update",
10768 toolCallId,
10769 status: "in_progress",
10770 },
10771 });
10772 }
10773 yield* applyMutation({
10774 sessionId: "mock-session-1",
10775 taskId: "task-monitor-a",
10776 status: "running",
10777 });
10778 yield* applyMutation({
10779 sessionId: "mock-session-1",
10780 taskId: "task-monitor-b",
10781 status: "running",
10782 });
10783 yield* applyMutation({
10784 sessionId: "mock-session-1",
10785 taskId: "task-monitor-a",
10786 status: "completed",
10787 });
10788 assert.lengthOf(continuationRequests, 0);
10789
10790 // Settle while B's tool row still open: deferred finalize holds the
10791 // turn. Interrupt then takes the settled-soft path.
10792 yield* Deferred.succeed(promptGate, { stopReason: "end_turn" });
10793 yield* Effect.yieldNow;
10794 yield* Effect.yieldNow;
10795 yield* Effect.yieldNow;
10796 assert.lengthOf(
10797 continuationRequests,
10798 0,
10799 "settle must hold for deferred background tools; no mid-hold offer",
10800 );
10801
10802 const providerTurnId = idAllocator.derive.providerTurn({
10803 driver: ACP_TEST_DRIVER,
10804 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
10805 });
10806 yield* runtime.interruptTurn({ providerThread, providerTurnId });
10807
10808 let terminalStatus: string | null = null;
10809 while (terminalStatus === null) {
10810 const event = yield* Queue.take(events);
10811 if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) {
10812 terminalStatus = event.status;
10813 }
10814 }
10815 assert.equal(terminalStatus, "interrupted");
10816 assert.lengthOf(
10817 continuationRequests,
10818 0,
10819 "interrupted finalize must not offer a continuation",
10820 );
10821
10822 yield* sessionUpdateHandler!({
10823 sessionId: "mock-session-1",
10824 update: {
10825 sessionUpdate: "user_message_chunk",
10826 content: {
10827 type: "text",
10828 text: 'Monitor "task-monitor-b" ended: [monitor ended: exit 0]',
10829 },
10830 },
10831 });
10832 yield* Effect.yieldNow;
10833 yield* Effect.yieldNow;
10834 assert.lengthOf(
10835 continuationRequests,
10836 0,
10837 "interrupted turns must clear midTurn marks; B ending post-finalize must not offer",
10838 );
10839 }).pipe(Effect.provide(layerTest), Effect.scoped),
10840 );
10841
10842 it.effect(
10843 "two-turn in-turn monitors never open a wake run or retain turn-1 injected-turn ack chatter",
10844 () =>
10845 Effect.gen(function* () {
10846 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
10847 const idAllocator = yield* IdAllocator.IdAllocatorV2;
10848 const path = yield* Path.Path;
10849 const selfInvocation = yield* resolveSelfInvocation();
10850 const mockAgentPath = yield* path.fromFileUrl(
10851 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
10852 );
10853 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
10854 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
10855 [];
10856 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
10857 let sessionUpdateHandler: Parameters<RuntimeService["handleSessionUpdate"]>[0] | undefined;
10858 const promptGates: Array<Deferred.Deferred<EffectAcpSchema.PromptResponse, never>> = [];
10859 const capturedMutation: {
10860 current:
10861 | ((mutation: {
10862 readonly sessionId: string;
10863 readonly taskId: string;
10864 readonly status: "running" | "completed" | "failed";
10865 }) => Effect.Effect<void>)
10866 | null;
10867 } = { current: null };
10868 const instanceId = ProviderInstanceId.make("acp-test");
10869 const adapter = yield* makeAcpAdapterV2({
10870 instanceId,
10871 flavor: {
10872 driver: ACP_TEST_DRIVER,
10873 capabilities: AcpProviderCapabilitiesV2,
10874 enablePostSettleContinuation: true,
10875 extractBackgroundTaskId: (toolCall) => {
10876 if (toolCall.toolCallId === "tool-call-monitor-1") return "task-monitor-1";
10877 if (toolCall.toolCallId === "tool-call-monitor-2") return "task-monitor-2";
10878 return undefined;
10879 },
10880 extractBackgroundToolMutation: (text) => {
10881 if (text.includes('Monitor "task-monitor-1" ended')) {
10882 return [{ taskId: "task-monitor-1", status: "completed", appendOutput: "" }];
10883 }
10884 if (text.includes('Monitor "task-monitor-2" ended')) {
10885 return [{ taskId: "task-monitor-2", status: "completed", appendOutput: "" }];
10886 }
10887 return [];
10888 },
10889 extractBackgroundTaskCompletion: (toolCall) => {
10890 if (toolCall.toolCallId === "tool-call-fetch-1") {
10891 return [
10892 {
10893 taskId: "task-monitor-1",
10894 status: toolCall.status === "completed" ? "completed" : "running",
10895 appendOutput: toolCall.status === "completed" ? "MONITOR_LISTING_TOKEN" : "",
10896 },
10897 ];
10898 }
10899 if (toolCall.toolCallId === "tool-call-fetch-2") {
10900 return [
10901 {
10902 taskId: "task-monitor-2",
10903 status: toolCall.status === "completed" ? "completed" : "running",
10904 appendOutput: toolCall.status === "completed" ? "MONITOR_LISTING_TOKEN_2" : "",
10905 },
10906 ];
10907 }
10908 return [];
10909 },
10910 registerExtensions: (context) =>
10911 Effect.sync(() => {
10912 capturedMutation.current = context.applyBackgroundTaskMutation;
10913 }),
10914 makeRuntime: makeMockRuntime({
10915 childProcessSpawner,
10916 mockAgentPath,
10917 environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" },
10918 protocolEvents,
10919 wrapRuntime: (runtime) => ({
10920 ...runtime,
10921 handleSessionUpdate: (handler) =>
10922 Effect.sync(() => {
10923 sessionUpdateHandler = handler;
10924 }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))),
10925 prompt: () =>
10926 Effect.gen(function* () {
10927 const gate = yield* Deferred.make<EffectAcpSchema.PromptResponse>();
10928 promptGates.push(gate);
10929 return yield* Deferred.await(gate);
10930 }),
10931 }),
10932 }),
10933 },
10934 selfInvocation,
10935 continuationRequests: {
10936 offer: (request) =>
10937 Effect.sync(() => {
10938 continuationRequests.push(request);
10939 }),
10940 },
10941 });
10942 const threadId = ThreadId.make("thread-acp-multiturn-in-turn-monitor-no-wake");
10943 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
10944 runtimeMode: "full-access",
10945 interactionMode: "default",
10946 cwd: process.cwd(),
10947 });
10948 const modelSelection = { instanceId, model: "default" } as const;
10949 const runtime = yield* adapter.openSession({
10950 threadId,
10951 providerSessionId: ProviderSessionId.make(
10952 "provider-session-acp-multiturn-in-turn-monitor-no-wake",
10953 ),
10954 modelSelection,
10955 runtimePolicy,
10956 });
10957 if (runtime.hasPendingBackgroundWork === undefined) {
10958 return yield* Effect.die(
10959 "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.",
10960 );
10961 }
10962 const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork;
10963 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
10964 yield* runtime.events.pipe(
10965 Stream.runForEach((event) => Queue.offer(events, event)),
10966 Effect.forkScoped,
10967 );
10968 const providerThread = yield* runtime.ensureThread({
10969 threadId,
10970 modelSelection,
10971 runtimePolicy,
10972 });
10973 const now = yield* DateTime.now;
10974 yield* runtime
10975 .startTurn(
10976 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }),
10977 )
10978 .pipe(Effect.forkScoped);
10979 while (promptGates.length < 1) {
10980 yield* Effect.yieldNow;
10981 }
10982 assert.isDefined(sessionUpdateHandler, "session update handler must be wired");
10983 const applyMutation = capturedMutation.current;
10984 if (applyMutation === null) {
10985 return yield* Effect.die("registerExtensions must capture applyBackgroundTaskMutation");
10986 }
10987
10988 // Turn 1: in-turn monitor + get_command hydrate + report.
10989 yield* sessionUpdateHandler!({
10990 sessionId: "mock-session-1",
10991 update: {
10992 sessionUpdate: "tool_call",
10993 toolCallId: "tool-call-monitor-1",
10994 title: "Monitor: first turn",
10995 kind: "execute",
10996 status: "pending",
10997 rawInput: {},
10998 },
10999 });
11000 yield* sessionUpdateHandler!({
11001 sessionId: "mock-session-1",
11002 update: {
11003 sessionUpdate: "tool_call_update",
11004 toolCallId: "tool-call-monitor-1",
11005 status: "in_progress",
11006 },
11007 });
11008 yield* applyMutation({
11009 sessionId: "mock-session-1",
11010 taskId: "task-monitor-1",
11011 status: "running",
11012 });
11013 yield* sessionUpdateHandler!({
11014 sessionId: "mock-session-1",
11015 update: {
11016 sessionUpdate: "tool_call",
11017 toolCallId: "tool-call-fetch-1",
11018 title: "get_command_or_subagent_output",
11019 kind: "other",
11020 status: "pending",
11021 rawInput: { task_id: "task-monitor-1" },
11022 },
11023 });
11024 yield* sessionUpdateHandler!({
11025 sessionId: "mock-session-1",
11026 update: {
11027 sessionUpdate: "tool_call_update",
11028 toolCallId: "tool-call-fetch-1",
11029 status: "completed",
11030 rawOutput: { output: "MONITOR_LISTING_TOKEN" },
11031 },
11032 });
11033 yield* sessionUpdateHandler!({
11034 sessionId: "mock-session-1",
11035 update: {
11036 sessionUpdate: "agent_message_chunk",
11037 content: { type: "text", text: "Monitor listing ready in-turn." },
11038 },
11039 });
11040 yield* applyMutation({
11041 sessionId: "mock-session-1",
11042 taskId: "task-monitor-1",
11043 status: "completed",
11044 });
11045 assert.lengthOf(continuationRequests, 0);
11046
11047 yield* Deferred.succeed(promptGates[0]!, { stopReason: "end_turn" });
11048 const firstProviderTurnId = idAllocator.derive.providerTurn({
11049 driver: ACP_TEST_DRIVER,
11050 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
11051 });
11052 let firstTerminal: string | null = null;
11053 while (firstTerminal === null) {
11054 const event = yield* Queue.take(events);
11055 if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) {
11056 firstTerminal = event.status;
11057 }
11058 }
11059 assert.equal(firstTerminal, "completed");
11060
11061 // Post-finalize injected monitor-event + ack chatter (live Grok CLI path).
11062 yield* sessionUpdateHandler!({
11063 sessionId: "mock-session-1",
11064 update: {
11065 sessionUpdate: "user_message_chunk",
11066 content: {
11067 type: "text",
11068 text: '<monitor-event taskId="task-monitor-1">Monitor "task-monitor-1" ended</monitor-event>',
11069 },
11070 },
11071 });
11072 yield* sessionUpdateHandler!({
11073 sessionId: "mock-session-1",
11074 update: {
11075 sessionUpdate: "agent_thought_chunk",
11076 content: {
11077 type: "text",
11078 text: "That monitor event is the same run I already summarized.",
11079 },
11080 },
11081 });
11082 yield* sessionUpdateHandler!({
11083 sessionId: "mock-session-1",
11084 update: {
11085 sessionUpdate: "agent_message_chunk",
11086 content: {
11087 type: "text",
11088 text: "That monitor event is the same run I already summarized above.",
11089 },
11090 },
11091 });
11092 yield* Effect.yieldNow;
11093 yield* Effect.yieldNow;
11094 assert.lengthOf(
11095 continuationRequests,
11096 0,
11097 "turn-1 injected-turn acks must not open a continuation",
11098 );
11099 assert.isFalse(
11100 yield* hasPendingBackgroundWork,
11101 "turn-1 injected-turn ack chatter must not remain as wake buffer evidence",
11102 );
11103
11104 // Turn 2: second in-turn monitor; mid-turn lifecycle must not wake.
11105 yield* runtime
11106 .startTurn(
11107 makeTurnInput({
11108 threadId,
11109 providerThread,
11110 instanceId,
11111 runtimePolicy,
11112 now: yield* DateTime.now,
11113 ordinal: 2,
11114 }),
11115 )
11116 .pipe(Effect.forkScoped);
11117 while (promptGates.length < 2) {
11118 yield* Effect.yieldNow;
11119 }
11120 if (sessionUpdateHandler === undefined || capturedMutation.current === null) {
11121 return yield* Effect.die("extensions and session handler must be wired for turn 2");
11122 }
11123
11124 yield* sessionUpdateHandler({
11125 sessionId: "mock-session-1",
11126 update: {
11127 sessionUpdate: "tool_call",
11128 toolCallId: "tool-call-monitor-2",
11129 title: "Monitor: second turn",
11130 kind: "execute",
11131 status: "pending",
11132 rawInput: {},
11133 },
11134 });
11135 yield* sessionUpdateHandler({
11136 sessionId: "mock-session-1",
11137 update: {
11138 sessionUpdate: "tool_call_update",
11139 toolCallId: "tool-call-monitor-2",
11140 status: "in_progress",
11141 },
11142 });
11143 yield* capturedMutation.current({
11144 sessionId: "mock-session-1",
11145 taskId: "task-monitor-2",
11146 status: "running",
11147 });
11148 yield* sessionUpdateHandler({
11149 sessionId: "mock-session-1",
11150 update: {
11151 sessionUpdate: "tool_call",
11152 toolCallId: "tool-call-fetch-2",
11153 title: "get_command_or_subagent_output",
11154 kind: "other",
11155 status: "pending",
11156 rawInput: { task_id: "task-monitor-2" },
11157 },
11158 });
11159 yield* sessionUpdateHandler({
11160 sessionId: "mock-session-1",
11161 update: {
11162 sessionUpdate: "tool_call_update",
11163 toolCallId: "tool-call-fetch-2",
11164 status: "completed",
11165 rawOutput: { output: "MONITOR_LISTING_TOKEN_2" },
11166 },
11167 });
11168 yield* sessionUpdateHandler({
11169 sessionId: "mock-session-1",
11170 update: {
11171 sessionUpdate: "agent_message_chunk",
11172 content: { type: "text", text: "Second monitor listing ready in-turn." },
11173 },
11174 });
11175 yield* capturedMutation.current({
11176 sessionId: "mock-session-1",
11177 taskId: "task-monitor-2",
11178 status: "completed",
11179 });
11180 assert.lengthOf(
11181 continuationRequests,
11182 0,
11183 "mid-turn task_completed for an in-turn-handled monitor must not open a wake run",
11184 );
11185
11186 yield* Deferred.succeed(promptGates[1]!, { stopReason: "end_turn" });
11187 const secondProviderTurnId = idAllocator.derive.providerTurn({
11188 driver: ACP_TEST_DRIVER,
11189 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"),
11190 });
11191 let secondTerminal: string | null = null;
11192 while (secondTerminal === null) {
11193 const event = yield* Queue.take(events);
11194 if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) {
11195 secondTerminal = event.status;
11196 }
11197 }
11198 assert.equal(secondTerminal, "completed");
11199 assert.lengthOf(
11200 continuationRequests,
11201 0,
11202 "no continuation must be offered across the multiturn in-turn monitor sequence",
11203 );
11204 assert.isFalse(
11205 yield* hasPendingBackgroundWork,
11206 "wake buffer must not retain turn-1 or turn-2 in-turn-handled ack residue",
11207 );
11208 }).pipe(Effect.provide(layerTest), Effect.scoped),
11209 );
11210
11211 it.effect("a wake names work that ended while the previous wake was queued", () =>
11212 Effect.gen(function* () {
11213 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
11214 const idAllocator = yield* IdAllocator.IdAllocatorV2;
11215 const path = yield* Path.Path;
11216 const selfInvocation = yield* resolveSelfInvocation();
11217 const mockAgentPath = yield* path.fromFileUrl(
11218 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
11219 );
11220 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
11221 [];
11222 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
11223 let sessionUpdateHandler: Parameters<RuntimeService["handleSessionUpdate"]>[0] | undefined;
11224 let applyMutation: AcpAdapterV2ExtensionContext["applyBackgroundTaskMutation"] | undefined;
11225 const promptGate = yield* Deferred.make<EffectAcpSchema.PromptResponse>();
11226 const instanceId = ProviderInstanceId.make("acp-test");
11227 const adapter = yield* makeAcpAdapterV2({
11228 instanceId,
11229 flavor: {
11230 driver: ACP_TEST_DRIVER,
11231 capabilities: AcpProviderCapabilitiesV2,
11232 enablePostSettleContinuation: true,
11233 deferFinalizeForBackgroundWork: true,
11234 registerExtensions: (context) =>
11235 Effect.sync(() => {
11236 applyMutation = context.applyBackgroundTaskMutation;
11237 }),
11238 makeRuntime: makeMockRuntime({
11239 childProcessSpawner,
11240 mockAgentPath,
11241 environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" },
11242 wrapRuntime: (runtime) => ({
11243 ...runtime,
11244 handleSessionUpdate: (handler) =>
11245 Effect.sync(() => {
11246 sessionUpdateHandler = handler;
11247 }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))),
11248 prompt: () => Deferred.await(promptGate),
11249 }),
11250 }),
11251 },
11252 selfInvocation,
11253 continuationRequests: {
11254 offer: (request) =>
11255 Effect.sync(() => {
11256 continuationRequests.push(request);
11257 }),
11258 },
11259 });
11260 const threadId = ThreadId.make("thread-acp-wake-report-while-queued");
11261 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
11262 runtimeMode: "full-access",
11263 interactionMode: "default",
11264 cwd: process.cwd(),
11265 });
11266 const modelSelection = { instanceId, model: "default" } as const;
11267 const runtime = yield* adapter.openSession({
11268 threadId,
11269 providerSessionId: ProviderSessionId.make("provider-session-acp-wake-report-while-queued"),
11270 modelSelection,
11271 runtimePolicy,
11272 });
11273 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
11274 yield* runtime.events.pipe(
11275 Stream.runForEach((event) => Queue.offer(events, event)),
11276 Effect.forkScoped,
11277 );
11278 const providerThread = yield* runtime.ensureThread({
11279 threadId,
11280 modelSelection,
11281 runtimePolicy,
11282 });
11283 const awaitTerminal = (ordinal: number) =>
11284 Effect.gen(function* () {
11285 const providerTurnId = idAllocator.derive.providerTurn({
11286 driver: ACP_TEST_DRIVER,
11287 nativeTurnId: acpScopedNativeId(instanceId, `mock-session-1:turn:${ordinal}`),
11288 });
11289 while (true) {
11290 const event = yield* Queue.take(events);
11291 if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) {
11292 return event.status;
11293 }
11294 }
11295 });
11296 yield* runtime
11297 .startTurn(
11298 makeTurnInput({
11299 threadId,
11300 providerThread,
11301 instanceId,
11302 runtimePolicy,
11303 now: yield* DateTime.now,
11304 }),
11305 )
11306 .pipe(Effect.forkScoped);
11307 yield* Deferred.succeed(promptGate, { stopReason: "end_turn" });
11308 assert.equal(yield* awaitTerminal(1), "completed");
11309 if (applyMutation === undefined || sessionUpdateHandler === undefined) {
11310 return yield* Effect.die("extensions and session handler must be wired");
11311 }
11312 const endCommand = (taskId: string, label: string) =>
11313 applyMutation!({
11314 sessionId: "mock-session-1",
11315 taskId,
11316 status: "completed",
11317 report: { kind: "command", label },
11318 });
11319 const agentText = (text: string) =>
11320 sessionUpdateHandler!({
11321 sessionId: "mock-session-1",
11322 update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } },
11323 });
11324
11325 yield* endCommand("task-a", "sleep a");
11326 yield* agentText("A_DONE");
11327 assert.lengthOf(continuationRequests, 1);
11328 assert.equal(continuationRequests[0]?.notification?.summary, 'Command "sleep a" finished');
11329 // B ends while A's wake waits for its turn; that wake is already named.
11330 yield* endCommand("task-b", "sleep b");
11331 assert.lengthOf(continuationRequests, 1);
11332
11333 yield* runtime.startTurn(
11334 makeTurnInput({
11335 threadId,
11336 providerThread,
11337 instanceId,
11338 runtimePolicy,
11339 now: yield* DateTime.now,
11340 ordinal: 2,
11341 messageCreatedBy: "agent",
11342 messageCreationSource: "provider",
11343 messageText: "Background task completed.",
11344 }),
11345 );
11346 yield* TestClock.adjust("3 seconds");
11347 assert.equal(yield* awaitTerminal(2), "completed");
11348
11349 // B's own wake names it.
11350 yield* agentText("B_DONE");
11351 assert.lengthOf(continuationRequests, 2);
11352 assert.deepEqual(continuationRequests[1]?.notification, {
11353 source: { kind: "command" },
11354 outcome: "completed",
11355 summary: 'Command "sleep b" finished',
11356 });
11357 }).pipe(Effect.provide(layerTest), Effect.scoped),
11358 );
11359
11360 it.effect("mid-turn completed mutation defers offer until finalize only when unhandled", () =>
11361 Effect.gen(function* () {
11362 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
11363 const idAllocator = yield* IdAllocator.IdAllocatorV2;
11364 const path = yield* Path.Path;
11365 const selfInvocation = yield* resolveSelfInvocation();
11366 const mockAgentPath = yield* path.fromFileUrl(
11367 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
11368 );
11369 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
11370 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
11371 [];
11372 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
11373 let sessionUpdateHandler: Parameters<RuntimeService["handleSessionUpdate"]>[0] | undefined;
11374 const promptGates: Array<Deferred.Deferred<EffectAcpSchema.PromptResponse, never>> = [];
11375 const capturedMutation: {
11376 current:
11377 | ((mutation: {
11378 readonly sessionId: string;
11379 readonly taskId: string;
11380 readonly status: "running" | "completed" | "failed";
11381 }) => Effect.Effect<void>)
11382 | null;
11383 } = { current: null };
11384 const instanceId = ProviderInstanceId.make("acp-test");
11385 const adapter = yield* makeAcpAdapterV2({
11386 instanceId,
11387 flavor: {
11388 driver: ACP_TEST_DRIVER,
11389 capabilities: AcpProviderCapabilitiesV2,
11390 enablePostSettleContinuation: true,
11391 extractBackgroundTaskId: (toolCall) =>
11392 toolCall.toolCallId === "tool-call-monitor-unhandled"
11393 ? "task-monitor-unhandled"
11394 : toolCall.toolCallId === "tool-call-monitor-handled"
11395 ? "task-monitor-handled"
11396 : undefined,
11397 extractBackgroundTaskCompletion: (toolCall) =>
11398 toolCall.toolCallId === "tool-call-fetch-handled"
11399 ? [
11400 {
11401 taskId: "task-monitor-handled",
11402 status: toolCall.status === "completed" ? "completed" : "running",
11403 appendOutput: toolCall.status === "completed" ? "HANDLED_LISTING" : "",
11404 },
11405 ]
11406 : [],
11407 registerExtensions: (context) =>
11408 Effect.sync(() => {
11409 capturedMutation.current = context.applyBackgroundTaskMutation;
11410 }),
11411 makeRuntime: makeMockRuntime({
11412 childProcessSpawner,
11413 mockAgentPath,
11414 environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" },
11415 protocolEvents,
11416 wrapRuntime: (runtime) => ({
11417 ...runtime,
11418 handleSessionUpdate: (handler) =>
11419 Effect.sync(() => {
11420 sessionUpdateHandler = handler;
11421 }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))),
11422 prompt: () =>
11423 Effect.gen(function* () {
11424 const gate = yield* Deferred.make<EffectAcpSchema.PromptResponse>();
11425 promptGates.push(gate);
11426 return yield* Deferred.await(gate);
11427 }),
11428 }),
11429 }),
11430 },
11431 selfInvocation,
11432 continuationRequests: {
11433 offer: (request) =>
11434 Effect.sync(() => {
11435 continuationRequests.push(request);
11436 }),
11437 },
11438 });
11439 const threadId = ThreadId.make("thread-acp-mid-turn-dirty-wake-buffer");
11440 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
11441 runtimeMode: "full-access",
11442 interactionMode: "default",
11443 cwd: process.cwd(),
11444 });
11445 const modelSelection = { instanceId, model: "default" } as const;
11446 const runtime = yield* adapter.openSession({
11447 threadId,
11448 providerSessionId: ProviderSessionId.make(
11449 "provider-session-acp-mid-turn-dirty-wake-buffer",
11450 ),
11451 modelSelection,
11452 runtimePolicy,
11453 });
11454 if (runtime.hasPendingBackgroundWork === undefined) {
11455 return yield* Effect.die(
11456 "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.",
11457 );
11458 }
11459 const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork;
11460 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
11461 yield* runtime.events.pipe(
11462 Stream.runForEach((event) => Queue.offer(events, event)),
11463 Effect.forkScoped,
11464 );
11465 const providerThread = yield* runtime.ensureThread({
11466 threadId,
11467 modelSelection,
11468 runtimePolicy,
11469 });
11470 const now = yield* DateTime.now;
11471 // Seed turn: complete promptly, then dirty the wake buffer with a
11472 // thought-only frame (buffers, no offer).
11473 yield* runtime
11474 .startTurn(
11475 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }),
11476 )
11477 .pipe(Effect.forkScoped);
11478 while (promptGates.length < 1) {
11479 yield* Effect.yieldNow;
11480 }
11481 yield* Deferred.succeed(promptGates[0]!, { stopReason: "end_turn" });
11482 const firstProviderTurnId = idAllocator.derive.providerTurn({
11483 driver: ACP_TEST_DRIVER,
11484 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
11485 });
11486 let firstTerminal: string | null = null;
11487 while (firstTerminal === null) {
11488 const event = yield* Queue.take(events);
11489 if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) {
11490 firstTerminal = event.status;
11491 }
11492 }
11493 assert.equal(firstTerminal, "completed");
11494 assert.isDefined(sessionUpdateHandler);
11495 yield* sessionUpdateHandler!({
11496 sessionId: "mock-session-1",
11497 update: {
11498 sessionUpdate: "agent_thought_chunk",
11499 content: { type: "text", text: "stale wake residue from prior turn" },
11500 },
11501 });
11502 assert.isTrue(yield* hasPendingBackgroundWork, "thought residue must dirty the wake buffer");
11503 assert.lengthOf(continuationRequests, 0);
11504
11505 // Active un-finalized turn 2: user-turn start clears prior residue.
11506 yield* runtime
11507 .startTurn(
11508 makeTurnInput({
11509 threadId,
11510 providerThread,
11511 instanceId,
11512 runtimePolicy,
11513 now: yield* DateTime.now,
11514 ordinal: 2,
11515 }),
11516 )
11517 .pipe(Effect.forkScoped);
11518 while (promptGates.length < 2) {
11519 yield* Effect.yieldNow;
11520 }
11521 assert.isFalse(
11522 yield* hasPendingBackgroundWork,
11523 "user-turn start must drop prior-turn wake residue",
11524 );
11525 const applyMutation = capturedMutation.current;
11526 if (applyMutation === null || sessionUpdateHandler === undefined) {
11527 return yield* Effect.die("extensions and session handler must be wired for turn 2");
11528 }
11529
11530 // Unhandled monitor: mid-turn complete must not offer while active.
11531 yield* sessionUpdateHandler({
11532 sessionId: "mock-session-1",
11533 update: {
11534 sessionUpdate: "tool_call",
11535 toolCallId: "tool-call-monitor-unhandled",
11536 title: "Monitor: unhandled",
11537 kind: "execute",
11538 status: "pending",
11539 rawInput: {},
11540 },
11541 });
11542 yield* sessionUpdateHandler({
11543 sessionId: "mock-session-1",
11544 update: {
11545 sessionUpdate: "tool_call_update",
11546 toolCallId: "tool-call-monitor-unhandled",
11547 status: "in_progress",
11548 },
11549 });
11550 yield* applyMutation({
11551 sessionId: "mock-session-1",
11552 taskId: "task-monitor-unhandled",
11553 status: "running",
11554 });
11555 // Re-seed dirty wakeBuffer while the turn is still active by routing a
11556 // post-settle-style frame is impossible (active context). Defect B is
11557 // covered by the mid-turn unreported set: completion lands mid-turn
11558 // with empty buffer and must still not offer until finalize.
11559 yield* applyMutation({
11560 sessionId: "mock-session-1",
11561 taskId: "task-monitor-unhandled",
11562 status: "completed",
11563 });
11564 assert.lengthOf(
11565 continuationRequests,
11566 0,
11567 "mid-turn completed mutation must not offer while the root turn is active",
11568 );
11569
11570 yield* Deferred.succeed(promptGates[1]!, { stopReason: "end_turn" });
11571 const secondProviderTurnId = idAllocator.derive.providerTurn({
11572 driver: ACP_TEST_DRIVER,
11573 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"),
11574 });
11575 let secondTerminal: string | null = null;
11576 while (secondTerminal === null) {
11577 const event = yield* Queue.take(events);
11578 if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) {
11579 secondTerminal = event.status;
11580 }
11581 }
11582 assert.equal(secondTerminal, "completed");
11583 assert.lengthOf(
11584 continuationRequests,
11585 1,
11586 "post-finalize must offer when mid-turn completion was never handled in-turn",
11587 );
11588
11589 // Third turn: hydrate in-turn before lifecycle complete; no offer.
11590 const firstOffer = continuationRequests[0]!;
11591 if (firstOffer.clearIfCurrent !== undefined) {
11592 yield* firstOffer.clearIfCurrent();
11593 }
11594 continuationRequests.length = 0;
11595 yield* runtime
11596 .startTurn(
11597 makeTurnInput({
11598 threadId,
11599 providerThread,
11600 instanceId,
11601 runtimePolicy,
11602 now: yield* DateTime.now,
11603 ordinal: 3,
11604 }),
11605 )
11606 .pipe(Effect.forkScoped);
11607 while (promptGates.length < 3) {
11608 yield* Effect.yieldNow;
11609 }
11610 if (sessionUpdateHandler === undefined || capturedMutation.current === null) {
11611 return yield* Effect.die("handler must remain wired for turn 3");
11612 }
11613 yield* sessionUpdateHandler({
11614 sessionId: "mock-session-1",
11615 update: {
11616 sessionUpdate: "tool_call",
11617 toolCallId: "tool-call-monitor-handled",
11618 title: "Monitor: handled",
11619 kind: "execute",
11620 status: "pending",
11621 rawInput: {},
11622 },
11623 });
11624 yield* sessionUpdateHandler({
11625 sessionId: "mock-session-1",
11626 update: {
11627 sessionUpdate: "tool_call_update",
11628 toolCallId: "tool-call-monitor-handled",
11629 status: "in_progress",
11630 },
11631 });
11632 yield* capturedMutation.current({
11633 sessionId: "mock-session-1",
11634 taskId: "task-monitor-handled",
11635 status: "running",
11636 });
11637 yield* sessionUpdateHandler({
11638 sessionId: "mock-session-1",
11639 update: {
11640 sessionUpdate: "tool_call",
11641 toolCallId: "tool-call-fetch-handled",
11642 title: "get_command_or_subagent_output",
11643 kind: "other",
11644 status: "pending",
11645 rawInput: {},
11646 },
11647 });
11648 yield* sessionUpdateHandler({
11649 sessionId: "mock-session-1",
11650 update: {
11651 sessionUpdate: "tool_call_update",
11652 toolCallId: "tool-call-fetch-handled",
11653 status: "completed",
11654 rawOutput: { output: "HANDLED_LISTING" },
11655 },
11656 });
11657 yield* capturedMutation.current({
11658 sessionId: "mock-session-1",
11659 taskId: "task-monitor-handled",
11660 status: "completed",
11661 });
11662 assert.lengthOf(continuationRequests, 0, "handled mid-turn completion must not offer");
11663 yield* Deferred.succeed(promptGates[2]!, { stopReason: "end_turn" });
11664 const thirdProviderTurnId = idAllocator.derive.providerTurn({
11665 driver: ACP_TEST_DRIVER,
11666 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:3"),
11667 });
11668 let thirdTerminal: string | null = null;
11669 while (thirdTerminal === null) {
11670 const event = yield* Queue.take(events);
11671 if (event.type === "turn.terminal" && event.providerTurnId === thirdProviderTurnId) {
11672 thirdTerminal = event.status;
11673 }
11674 }
11675 assert.equal(thirdTerminal, "completed");
11676 assert.lengthOf(
11677 continuationRequests,
11678 0,
11679 "post-finalize must not offer when the mid-turn completion was handled in-turn",
11680 );
11681 }).pipe(Effect.provide(layerTest), Effect.scoped),
11682 );
11683
11684 it.effect(
11685 "mid-turn unhandled background completion still offers exactly one continuation after finalize",
11686 () =>
11687 Effect.gen(function* () {
11688 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
11689 const idAllocator = yield* IdAllocator.IdAllocatorV2;
11690 const path = yield* Path.Path;
11691 const selfInvocation = yield* resolveSelfInvocation();
11692 const mockAgentPath = yield* path.fromFileUrl(
11693 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
11694 );
11695 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
11696 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
11697 [];
11698 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
11699 let sessionUpdateHandler: Parameters<RuntimeService["handleSessionUpdate"]>[0] | undefined;
11700 const promptGate = yield* Deferred.make<EffectAcpSchema.PromptResponse>();
11701 const capturedMutation: {
11702 current:
11703 | ((mutation: {
11704 readonly sessionId: string;
11705 readonly taskId: string;
11706 readonly status: "running" | "completed" | "failed";
11707 }) => Effect.Effect<void>)
11708 | null;
11709 } = { current: null };
11710 const instanceId = ProviderInstanceId.make("acp-test");
11711 const adapter = yield* makeAcpAdapterV2({
11712 instanceId,
11713 flavor: {
11714 driver: ACP_TEST_DRIVER,
11715 capabilities: AcpProviderCapabilitiesV2,
11716 enablePostSettleContinuation: true,
11717 extractBackgroundTaskId: (toolCall) =>
11718 toolCall.toolCallId === "tool-call-monitor-late" ? "task-monitor-late" : undefined,
11719 registerExtensions: (context) =>
11720 Effect.sync(() => {
11721 capturedMutation.current = context.applyBackgroundTaskMutation;
11722 }),
11723 makeRuntime: makeMockRuntime({
11724 childProcessSpawner,
11725 mockAgentPath,
11726 environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" },
11727 protocolEvents,
11728 wrapRuntime: (runtime) => ({
11729 ...runtime,
11730 handleSessionUpdate: (handler) =>
11731 Effect.sync(() => {
11732 sessionUpdateHandler = handler;
11733 }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))),
11734 prompt: () => Deferred.await(promptGate),
11735 }),
11736 }),
11737 },
11738 selfInvocation,
11739 continuationRequests: {
11740 offer: (request) =>
11741 Effect.sync(() => {
11742 continuationRequests.push(request);
11743 }),
11744 },
11745 });
11746 const threadId = ThreadId.make("thread-acp-mid-turn-unhandled-offers-after-finalize");
11747 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
11748 runtimeMode: "full-access",
11749 interactionMode: "default",
11750 cwd: process.cwd(),
11751 });
11752 const modelSelection = { instanceId, model: "default" } as const;
11753 const runtime = yield* adapter.openSession({
11754 threadId,
11755 providerSessionId: ProviderSessionId.make(
11756 "provider-session-acp-mid-turn-unhandled-offers-after-finalize",
11757 ),
11758 modelSelection,
11759 runtimePolicy,
11760 });
11761 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
11762 yield* runtime.events.pipe(
11763 Stream.runForEach((event) => Queue.offer(events, event)),
11764 Effect.forkScoped,
11765 );
11766 const providerThread = yield* runtime.ensureThread({
11767 threadId,
11768 modelSelection,
11769 runtimePolicy,
11770 });
11771 const now = yield* DateTime.now;
11772 yield* runtime
11773 .startTurn(
11774 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }),
11775 )
11776 .pipe(Effect.forkScoped);
11777 // Wait until the wrapped prompt is awaiting the gate.
11778 yield* Effect.yieldNow;
11779 yield* Effect.yieldNow;
11780 assert.isDefined(sessionUpdateHandler);
11781 const applyMutation = capturedMutation.current;
11782 if (applyMutation === null) {
11783 return yield* Effect.die("registerExtensions must capture applyBackgroundTaskMutation");
11784 }
11785
11786 // Start a monitor mid-turn; do not hydrate via get_command (unhandled).
11787 yield* sessionUpdateHandler!({
11788 sessionId: "mock-session-1",
11789 update: {
11790 sessionUpdate: "tool_call",
11791 toolCallId: "tool-call-monitor-late",
11792 title: "Monitor: late complete",
11793 kind: "execute",
11794 status: "pending",
11795 rawInput: {},
11796 },
11797 });
11798 yield* sessionUpdateHandler!({
11799 sessionId: "mock-session-1",
11800 update: {
11801 sessionUpdate: "tool_call_update",
11802 toolCallId: "tool-call-monitor-late",
11803 status: "in_progress",
11804 },
11805 });
11806 yield* applyMutation({
11807 sessionId: "mock-session-1",
11808 taskId: "task-monitor-late",
11809 status: "running",
11810 });
11811 yield* applyMutation({
11812 sessionId: "mock-session-1",
11813 taskId: "task-monitor-late",
11814 status: "completed",
11815 });
11816 assert.lengthOf(
11817 continuationRequests,
11818 0,
11819 "mid-turn unhandled completion must not offer while the turn is active",
11820 );
11821
11822 yield* Deferred.succeed(promptGate, { stopReason: "end_turn" });
11823 const providerTurnId = idAllocator.derive.providerTurn({
11824 driver: ACP_TEST_DRIVER,
11825 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
11826 });
11827 let terminalStatus: string | null = null;
11828 while (terminalStatus === null) {
11829 const event = yield* Queue.take(events);
11830 if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) {
11831 terminalStatus = event.status;
11832 }
11833 }
11834 assert.equal(terminalStatus, "completed");
11835 assert.lengthOf(
11836 continuationRequests,
11837 1,
11838 "exactly one continuation must be offered after finalize for mid-turn unhandled completion",
11839 );
11840 const beforeWhitespace = yield* runtime.readThreadSnapshot({ providerThread });
11841 yield* Queue.clear(events);
11842 yield* sessionUpdateHandler!({
11843 sessionId: "mock-session-1",
11844 update: {
11845 sessionUpdate: "agent_message_chunk",
11846 content: { type: "text", text: " \n\t " },
11847 },
11848 });
11849 const lateEvents: Array<ProviderAdapter.ProviderAdapterV2Event> = [];
11850 let lateEvent = yield* Queue.poll(events);
11851 while (Option.isSome(lateEvent)) {
11852 lateEvents.push(lateEvent.value);
11853 lateEvent = yield* Queue.poll(events);
11854 }
11855 assert.isFalse(
11856 lateEvents.some((event) => event.type === "message.updated"),
11857 "whitespace-only late chunks must not append assistant history",
11858 );
11859 const afterWhitespace = yield* runtime.readThreadSnapshot({ providerThread });
11860 assert.deepEqual(
11861 afterWhitespace.messages,
11862 beforeWhitespace.messages,
11863 "whitespace-only late chunks must not mutate the loaded history snapshot",
11864 );
11865 }).pipe(Effect.provide(layerTest), Effect.scoped),
11866 );
11867
11868 it.effect("empty-drain continuation turn waits the quiet window so late frames can attach", () =>
11869 Effect.gen(function* () {
11870 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
11871 const idAllocator = yield* IdAllocator.IdAllocatorV2;
11872 const path = yield* Path.Path;
11873 const selfInvocation = yield* resolveSelfInvocation();
11874 const mockAgentPath = yield* path.fromFileUrl(
11875 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
11876 );
11877 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
11878 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
11879 [];
11880 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
11881 let sessionUpdateHandler: Parameters<RuntimeService["handleSessionUpdate"]>[0] | undefined;
11882 const promptGate = yield* Deferred.make<EffectAcpSchema.PromptResponse>();
11883 const capturedMutation: {
11884 current:
11885 | ((mutation: {
11886 readonly sessionId: string;
11887 readonly taskId: string;
11888 readonly status: "running" | "completed" | "failed";
11889 }) => Effect.Effect<void>)
11890 | null;
11891 } = { current: null };
11892 const instanceId = ProviderInstanceId.make("acp-test");
11893 const adapter = yield* makeAcpAdapterV2({
11894 instanceId,
11895 flavor: {
11896 driver: ACP_TEST_DRIVER,
11897 capabilities: AcpProviderCapabilitiesV2,
11898 // Quiet window on empty-drain continuation; persistent so the root
11899 // monitor does not pin deferred finalize after mid-turn complete.
11900 deferFinalizeForBackgroundWork: true,
11901 enablePostSettleContinuation: true,
11902 extractBackgroundTaskId: (toolCall) =>
11903 toolCall.toolCallId === "tool-call-monitor-late" ? "task-monitor-late" : undefined,
11904 isPersistentBackgroundTool: () => true,
11905 registerExtensions: (context) =>
11906 Effect.sync(() => {
11907 capturedMutation.current = context.applyBackgroundTaskMutation;
11908 }),
11909 makeRuntime: makeMockRuntime({
11910 childProcessSpawner,
11911 mockAgentPath,
11912 environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" },
11913 protocolEvents,
11914 wrapRuntime: (runtime) => ({
11915 ...runtime,
11916 handleSessionUpdate: (handler) =>
11917 Effect.sync(() => {
11918 sessionUpdateHandler = handler;
11919 }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))),
11920 prompt: () => Deferred.await(promptGate),
11921 }),
11922 }),
11923 },
11924 selfInvocation,
11925 continuationRequests: {
11926 offer: (request) =>
11927 Effect.sync(() => {
11928 continuationRequests.push(request);
11929 }),
11930 },
11931 });
11932 const threadId = ThreadId.make("thread-acp-empty-drain-continuation-quiet");
11933 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
11934 runtimeMode: "full-access",
11935 interactionMode: "default",
11936 cwd: process.cwd(),
11937 });
11938 const modelSelection = { instanceId, model: "default" } as const;
11939 const runtime = yield* adapter.openSession({
11940 threadId,
11941 providerSessionId: ProviderSessionId.make(
11942 "provider-session-acp-empty-drain-continuation-quiet",
11943 ),
11944 modelSelection,
11945 runtimePolicy,
11946 });
11947 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
11948 yield* runtime.events.pipe(
11949 Stream.runForEach((event) => Queue.offer(events, event)),
11950 Effect.forkScoped,
11951 );
11952 const providerThread = yield* runtime.ensureThread({
11953 threadId,
11954 modelSelection,
11955 runtimePolicy,
11956 });
11957 const now = yield* DateTime.now;
11958 yield* runtime
11959 .startTurn(
11960 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }),
11961 )
11962 .pipe(Effect.forkScoped);
11963 yield* Effect.yieldNow;
11964 yield* Effect.yieldNow;
11965 assert.isDefined(sessionUpdateHandler);
11966 const applyMutation = capturedMutation.current;
11967 if (applyMutation === null) {
11968 return yield* Effect.die("registerExtensions must capture applyBackgroundTaskMutation");
11969 }
11970
11971 // Mid-turn unhandled completion arms midTurn and offers after finalize
11972 // with an empty wakeBuffer (midTurn-only offer).
11973 yield* sessionUpdateHandler!({
11974 sessionId: "mock-session-1",
11975 update: {
11976 sessionUpdate: "tool_call",
11977 toolCallId: "tool-call-monitor-late",
11978 title: "Monitor: late complete",
11979 kind: "execute",
11980 status: "pending",
11981 rawInput: {},
11982 },
11983 });
11984 yield* sessionUpdateHandler!({
11985 sessionId: "mock-session-1",
11986 update: {
11987 sessionUpdate: "tool_call_update",
11988 toolCallId: "tool-call-monitor-late",
11989 status: "in_progress",
11990 },
11991 });
11992 yield* applyMutation({
11993 sessionId: "mock-session-1",
11994 taskId: "task-monitor-late",
11995 status: "running",
11996 });
11997 yield* applyMutation({
11998 sessionId: "mock-session-1",
11999 taskId: "task-monitor-late",
12000 status: "completed",
12001 });
12002 yield* Deferred.succeed(promptGate, { stopReason: "end_turn" });
12003 const rootProviderTurnId = idAllocator.derive.providerTurn({
12004 driver: ACP_TEST_DRIVER,
12005 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
12006 });
12007 let rootTerminal: string | null = null;
12008 while (rootTerminal === null) {
12009 const event = yield* Queue.take(events);
12010 if (event.type === "turn.terminal" && event.providerTurnId === rootProviderTurnId) {
12011 rootTerminal = event.status;
12012 }
12013 }
12014 assert.equal(rootTerminal, "completed");
12015 assert.lengthOf(continuationRequests, 1, "midTurn-only evidence must offer one continuation");
12016
12017 // Continuation attach with empty wakeBuffer: must schedule the quiet
12018 // window rather than finalizing immediately blank.
12019 const rootInput = makeTurnInput({
12020 threadId,
12021 providerThread,
12022 instanceId,
12023 runtimePolicy,
12024 now: yield* DateTime.now,
12025 ordinal: 2,
12026 });
12027 yield* runtime
12028 .startTurn({
12029 ...rootInput,
12030 message: {
12031 ...rootInput.message,
12032 createdBy: "agent",
12033 creationSource: "provider",
12034 text: "Background task completed.",
12035 },
12036 })
12037 .pipe(Effect.forkScoped);
12038 yield* Effect.yieldNow;
12039 yield* Effect.yieldNow;
12040 yield* Effect.yieldNow;
12041
12042 const continuationProviderTurnId = idAllocator.derive.providerTurn({
12043 driver: ACP_TEST_DRIVER,
12044 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"),
12045 });
12046 // Still open: no terminal yet (quiet window).
12047 let polled = yield* Queue.poll(events);
12048 while (Option.isSome(polled)) {
12049 assert.notEqual(
12050 polled.value.type === "turn.terminal" &&
12051 polled.value.providerTurnId === continuationProviderTurnId
12052 ? "turn.terminal"
12053 : "",
12054 "turn.terminal",
12055 "empty-drain continuation must not finalize immediately",
12056 );
12057 polled = yield* Queue.poll(events);
12058 }
12059
12060 // Late wake frame during the quiet window attaches to this run.
12061 assert.isDefined(sessionUpdateHandler);
12062 yield* sessionUpdateHandler!({
12063 sessionId: "mock-session-1",
12064 update: {
12065 sessionUpdate: "agent_message_chunk",
12066 content: { type: "text", text: "LATE_WAKE_FRAME_TOKEN" },
12067 },
12068 });
12069 yield* Effect.yieldNow;
12070 yield* Effect.yieldNow;
12071
12072 // Partial advance keeps the window open; full 3s after rearm finalizes.
12073 yield* TestClock.adjust("1 second");
12074 yield* Effect.yieldNow;
12075 polled = yield* Queue.poll(events);
12076 let lateFrameSeen = false;
12077 while (Option.isSome(polled)) {
12078 if (
12079 polled.value.type === "turn_item.updated" &&
12080 polled.value.turnItem.type === "assistant_message" &&
12081 polled.value.turnItem.text.includes("LATE_WAKE_FRAME_TOKEN")
12082 ) {
12083 lateFrameSeen = true;
12084 }
12085 if (
12086 polled.value.type === "turn.terminal" &&
12087 polled.value.providerTurnId === continuationProviderTurnId
12088 ) {
12089 assert.fail("continuation must not finalize before the quiet window elapses");
12090 }
12091 polled = yield* Queue.poll(events);
12092 }
12093 assert.isTrue(lateFrameSeen, "late wake frame must attach to the continuation run");
12094
12095 yield* TestClock.adjust("3 seconds");
12096 let continuationTerminal: string | null = null;
12097 while (continuationTerminal === null) {
12098 const event = yield* Queue.take(events);
12099 if (event.type === "turn.terminal" && event.providerTurnId === continuationProviderTurnId) {
12100 continuationTerminal = event.status;
12101 }
12102 }
12103 assert.equal(continuationTerminal, "completed");
12104 }).pipe(Effect.provide(layerTest), Effect.scoped),
12105 );
12106
12107 it.effect("empty-drain continuation turn finalizes after the quiet window with no frames", () =>
12108 Effect.gen(function* () {
12109 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
12110 const idAllocator = yield* IdAllocator.IdAllocatorV2;
12111 const path = yield* Path.Path;
12112 const selfInvocation = yield* resolveSelfInvocation();
12113 const mockAgentPath = yield* path.fromFileUrl(
12114 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
12115 );
12116 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
12117 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
12118 [];
12119 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
12120 let sessionUpdateHandler: Parameters<RuntimeService["handleSessionUpdate"]>[0] | undefined;
12121 const promptGate = yield* Deferred.make<EffectAcpSchema.PromptResponse>();
12122 const capturedMutation: {
12123 current:
12124 | ((mutation: {
12125 readonly sessionId: string;
12126 readonly taskId: string;
12127 readonly status: "running" | "completed" | "failed";
12128 }) => Effect.Effect<void>)
12129 | null;
12130 } = { current: null };
12131 const instanceId = ProviderInstanceId.make("acp-test");
12132 const adapter = yield* makeAcpAdapterV2({
12133 instanceId,
12134 flavor: {
12135 driver: ACP_TEST_DRIVER,
12136 capabilities: AcpProviderCapabilitiesV2,
12137 deferFinalizeForBackgroundWork: true,
12138 enablePostSettleContinuation: true,
12139 extractBackgroundTaskId: (toolCall) =>
12140 toolCall.toolCallId === "tool-call-monitor-late" ? "task-monitor-late" : undefined,
12141 isPersistentBackgroundTool: () => true,
12142 registerExtensions: (context) =>
12143 Effect.sync(() => {
12144 capturedMutation.current = context.applyBackgroundTaskMutation;
12145 }),
12146 makeRuntime: makeMockRuntime({
12147 childProcessSpawner,
12148 mockAgentPath,
12149 environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" },
12150 protocolEvents,
12151 wrapRuntime: (runtime) => ({
12152 ...runtime,
12153 handleSessionUpdate: (handler) =>
12154 Effect.sync(() => {
12155 sessionUpdateHandler = handler;
12156 }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))),
12157 prompt: () => Deferred.await(promptGate),
12158 }),
12159 }),
12160 },
12161 selfInvocation,
12162 continuationRequests: {
12163 offer: (request) =>
12164 Effect.sync(() => {
12165 continuationRequests.push(request);
12166 }),
12167 },
12168 });
12169 const threadId = ThreadId.make("thread-acp-empty-drain-continuation-no-wedge");
12170 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
12171 runtimeMode: "full-access",
12172 interactionMode: "default",
12173 cwd: process.cwd(),
12174 });
12175 const modelSelection = { instanceId, model: "default" } as const;
12176 const runtime = yield* adapter.openSession({
12177 threadId,
12178 providerSessionId: ProviderSessionId.make(
12179 "provider-session-acp-empty-drain-continuation-no-wedge",
12180 ),
12181 modelSelection,
12182 runtimePolicy,
12183 });
12184 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
12185 yield* runtime.events.pipe(
12186 Stream.runForEach((event) => Queue.offer(events, event)),
12187 Effect.forkScoped,
12188 );
12189 const providerThread = yield* runtime.ensureThread({
12190 threadId,
12191 modelSelection,
12192 runtimePolicy,
12193 });
12194 const now = yield* DateTime.now;
12195 yield* runtime
12196 .startTurn(
12197 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }),
12198 )
12199 .pipe(Effect.forkScoped);
12200 yield* Effect.yieldNow;
12201 yield* Effect.yieldNow;
12202 assert.isDefined(sessionUpdateHandler);
12203 const applyMutation = capturedMutation.current;
12204 if (applyMutation === null) {
12205 return yield* Effect.die("registerExtensions must capture applyBackgroundTaskMutation");
12206 }
12207
12208 yield* sessionUpdateHandler!({
12209 sessionId: "mock-session-1",
12210 update: {
12211 sessionUpdate: "tool_call",
12212 toolCallId: "tool-call-monitor-late",
12213 title: "Monitor: late complete",
12214 kind: "execute",
12215 status: "pending",
12216 rawInput: {},
12217 },
12218 });
12219 yield* sessionUpdateHandler!({
12220 sessionId: "mock-session-1",
12221 update: {
12222 sessionUpdate: "tool_call_update",
12223 toolCallId: "tool-call-monitor-late",
12224 status: "in_progress",
12225 },
12226 });
12227 yield* applyMutation({
12228 sessionId: "mock-session-1",
12229 taskId: "task-monitor-late",
12230 status: "running",
12231 });
12232 yield* applyMutation({
12233 sessionId: "mock-session-1",
12234 taskId: "task-monitor-late",
12235 status: "completed",
12236 });
12237 yield* Deferred.succeed(promptGate, { stopReason: "end_turn" });
12238 const rootProviderTurnId = idAllocator.derive.providerTurn({
12239 driver: ACP_TEST_DRIVER,
12240 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
12241 });
12242 let rootTerminal: string | null = null;
12243 while (rootTerminal === null) {
12244 const event = yield* Queue.take(events);
12245 if (event.type === "turn.terminal" && event.providerTurnId === rootProviderTurnId) {
12246 rootTerminal = event.status;
12247 }
12248 }
12249 assert.equal(rootTerminal, "completed");
12250 assert.lengthOf(continuationRequests, 1);
12251
12252 const rootInput = makeTurnInput({
12253 threadId,
12254 providerThread,
12255 instanceId,
12256 runtimePolicy,
12257 now: yield* DateTime.now,
12258 ordinal: 2,
12259 });
12260 yield* runtime
12261 .startTurn({
12262 ...rootInput,
12263 message: {
12264 ...rootInput.message,
12265 createdBy: "agent",
12266 creationSource: "provider",
12267 text: "Background task completed.",
12268 },
12269 })
12270 .pipe(Effect.forkScoped);
12271 yield* Effect.yieldNow;
12272 yield* Effect.yieldNow;
12273 yield* Effect.yieldNow;
12274
12275 const continuationProviderTurnId = idAllocator.derive.providerTurn({
12276 driver: ACP_TEST_DRIVER,
12277 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"),
12278 });
12279 // No frames: quiet window must still finalize (no wedge).
12280 yield* TestClock.adjust("3 seconds");
12281 let continuationTerminal: string | null = null;
12282 while (continuationTerminal === null) {
12283 const event = yield* Queue.take(events);
12284 if (event.type === "turn.terminal" && event.providerTurnId === continuationProviderTurnId) {
12285 continuationTerminal = event.status;
12286 }
12287 }
12288 assert.equal(continuationTerminal, "completed");
12289 }).pipe(Effect.provide(layerTest), Effect.scoped),
12290 );
12291
12292 it.effect("restarts the ACP child process before the next prompt after interrupt", () =>
12293 Effect.gen(function* () {
12294 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
12295 const idAllocator = yield* IdAllocator.IdAllocatorV2;
12296 const path = yield* Path.Path;
12297 const selfInvocation = yield* resolveSelfInvocation();
12298 const mockAgentPath = yield* path.fromFileUrl(
12299 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
12300 );
12301 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
12302 const instanceId = ProviderInstanceId.make("acp-test");
12303 const adapter = yield* makeAcpAdapterV2({
12304 instanceId,
12305 flavor: {
12306 driver: ACP_TEST_DRIVER,
12307 capabilities: AcpProviderCapabilitiesV2,
12308 restartRuntimeAfterInterrupt: true,
12309 makeRuntime: makeMockRuntime({
12310 childProcessSpawner,
12311 mockAgentPath,
12312 protocolEvents,
12313 }),
12314 },
12315 selfInvocation,
12316 });
12317 const threadId = ThreadId.make("thread-acp-restart-after-interrupt");
12318 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
12319 runtimeMode: "full-access",
12320 interactionMode: "default",
12321 cwd: process.cwd(),
12322 });
12323 const modelSelection = { instanceId, model: "default" } as const;
12324 const runtime = yield* adapter.openSession({
12325 threadId,
12326 providerSessionId: ProviderSessionId.make("provider-session-acp-restart-after-interrupt"),
12327 modelSelection,
12328 runtimePolicy,
12329 });
12330 const providerThread = yield* runtime.ensureThread({
12331 threadId,
12332 modelSelection,
12333 runtimePolicy,
12334 });
12335 const now = yield* DateTime.now;
12336 yield* runtime.startTurn(
12337 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }),
12338 );
12339 yield* Stream.fromQueue(protocolEvents).pipe(
12340 Stream.filter(
12341 (event) =>
12342 event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt",
12343 ),
12344 Stream.runHead,
12345 );
12346 const providerTurnId = idAllocator.derive.providerTurn({
12347 driver: ACP_TEST_DRIVER,
12348 nativeTurnId: acpScopedNativeId(
12349 instanceId,
12350 `${providerThread.nativeThreadRef?.nativeId}:turn:1`,
12351 ),
12352 });
12353 yield* runtime.interruptTurn({
12354 providerThread,
12355 providerTurnId,
12356 requestRuntimeRestart: true,
12357 });
12358 yield* Stream.fromQueue(protocolEvents).pipe(
12359 Stream.filter(
12360 (event) =>
12361 event.direction === "outgoing" && rawProtocolMethod(event) === "session/cancel",
12362 ),
12363 Stream.runHead,
12364 );
12365
12366 yield* runtime.startTurn(
12367 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }),
12368 );
12369 const loadAfterRestart = yield* Stream.fromQueue(protocolEvents).pipe(
12370 Stream.filter(
12371 (event) =>
12372 event.direction === "outgoing" && rawProtocolMethod(event) === "session/resume",
12373 ),
12374 Stream.runHead,
12375 );
12376 assert.isTrue(
12377 Option.isSome(loadAfterRestart),
12378 "post-interrupt startTurn should respawn the runtime and replay session/resume",
12379 );
12380 }).pipe(Effect.provide(layerTest), Effect.scoped),
12381 );
12382
12383 it.effect("Windows teardown is one-shot explicitly with independent finalizer cleanup", () =>
12384 Effect.gen(function* () {
12385 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
12386 const path = yield* Path.Path;
12387 const mockAgentPath = yield* path.fromFileUrl(
12388 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
12389 );
12390 const runtimeScope = yield* Scope.make();
12391 const taskkillCommands: Array<{
12392 readonly command: string;
12393 readonly args: ReadonlyArray<string>;
12394 }> = [];
12395 const taskkillSpawner = makeTaskkillSpawner({ exitCode: 0, commands: taskkillCommands });
12396 const spawner = ChildProcessSpawner.make((command) => {
12397 const value = command as unknown as { readonly command: string };
12398 return value.command === "taskkill"
12399 ? taskkillSpawner.spawn(command)
12400 : childProcessSpawner.spawn(command);
12401 });
12402 const context = yield* Layer.build(
12403 AcpSessionRuntime.layer({
12404 spawn: {
12405 command: process.execPath,
12406 args: [mockAgentPath],
12407 cwd: process.cwd(),
12408 env: { T3_ACP_SESSION_LIFECYCLE: "1" },
12409 },
12410 cwd: process.cwd(),
12411 clientInfo: { name: "t3-acp-test", version: "0.0.0" },
12412 ownDetachedProcessGroup: true,
12413 processGroupPlatform: "win32",
12414 }).pipe(Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner))),
12415 ).pipe(Effect.provideService(Scope.Scope, runtimeScope));
12416 const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(
12417 Effect.provide(context),
12418 );
12419 assert.isDefined(runtime.terminateProcessGroup);
12420 yield* Effect.all([runtime.terminateProcessGroup!, runtime.terminateProcessGroup!], {
12421 concurrency: "unbounded",
12422 });
12423 assert.equal(taskkillCommands.length, 1);
12424 yield* Scope.close(runtimeScope, Exit.void);
12425 // The scope finalizer picks its kill path from the host, not processGroupPlatform,
12426 // so only a Windows host sends it through taskkill a second time.
12427 const finalizerTaskkills = (yield* HostProcess.Platform) === "win32" ? 1 : 0;
12428 assert.equal(taskkillCommands.length, 1 + finalizerTaskkills);
12429 assert.deepEqual(taskkillCommands.at(-1), taskkillCommands[0]);
12430 }).pipe(Effect.provide(layerTest)),
12431 );
12432
12433 it("accepts only taskkill exit code zero as successful tree termination", () => {
12434 assert.isTrue(AcpSessionRuntime.windowsTaskkillResultIsSuccess(0, ""));
12435 assert.isFalse(
12436 AcpSessionRuntime.windowsTaskkillResultIsSuccess(
12437 128,
12438 "FEHLER: Der Prozess wurde nicht gefunden.",
12439 ),
12440 );
12441 assert.isFalse(AcpSessionRuntime.windowsTaskkillResultIsSuccess(128, ""));
12442 assert.isFalse(AcpSessionRuntime.windowsTaskkillResultIsSuccess(1, "localized failure"));
12443 assert.isFalse(AcpSessionRuntime.windowsTaskkillResultIsSuccess(255, ""));
12444 });
12445
12446 it.effect("runs the default taskkill path and preserves every failure mode", () =>
12447 Effect.gen(function* () {
12448 const commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }> =
12449 [];
12450 yield* AcpSessionRuntime.terminateWindowsProcessTreeWithTaskkill(4321).pipe(
12451 Effect.provideService(
12452 ChildProcessSpawner.ChildProcessSpawner,
12453 makeTaskkillSpawner({ exitCode: 0, commands }),
12454 ),
12455 );
12456 assert.deepEqual(commands, [{ command: "taskkill", args: ["/PID", "4321", "/T", "/F"] }]);
12457
12458 for (const fixture of [
12459 { exitCode: 128, output: 'ERROR: The process "4321" not found.' },
12460 { exitCode: 128, output: "FEHLER: Prozess nicht gefunden." },
12461 { exitCode: 128, output: "" },
12462 { exitCode: 128, output: "ERROR: Access is denied." },
12463 { exitCode: 1, output: "generic failure" },
12464 ]) {
12465 const failed = yield* AcpSessionRuntime.terminateWindowsProcessTreeWithTaskkill(4321).pipe(
12466 Effect.provideService(
12467 ChildProcessSpawner.ChildProcessSpawner,
12468 makeTaskkillSpawner(fixture),
12469 ),
12470 Effect.exit,
12471 );
12472 if (Exit.isSuccess(failed)) assert.fail(`taskkill ${fixture.exitCode} must fail`);
12473 const error = Cause.squash(failed.cause);
12474 assert.instanceOf(error, AcpSessionRuntime.AcpProcessGroupTerminationError);
12475 const termination = error as AcpSessionRuntime.AcpProcessGroupTerminationError;
12476 assert.equal(
12477 termination.detail,
12478 `taskkill exited ${fixture.exitCode} for ACP process tree 4321`,
12479 );
12480 assert.equal(termination.pid, 4321);
12481 assert.equal(termination.exitCode, fixture.exitCode);
12482 if (fixture.output.length > 0) {
12483 assert.equal(termination.cause, fixture.output);
12484 }
12485 }
12486
12487 for (const fixture of [
12488 { spawnFailure: true },
12489 { outputFailure: true },
12490 { exitFailure: true },
12491 ]) {
12492 const failed = yield* AcpSessionRuntime.terminateWindowsProcessTreeWithTaskkill(4321).pipe(
12493 Effect.provideService(
12494 ChildProcessSpawner.ChildProcessSpawner,
12495 makeTaskkillSpawner(fixture),
12496 ),
12497 Effect.exit,
12498 );
12499 if (Exit.isSuccess(failed)) assert.fail("taskkill infrastructure failure must fail");
12500 const error = Cause.squash(failed.cause);
12501 assert.instanceOf(error, AcpSessionRuntime.AcpProcessGroupTerminationError);
12502 assert.equal(
12503 (error as AcpSessionRuntime.AcpProcessGroupTerminationError).detail,
12504 "Failed to run taskkill for ACP process tree 4321",
12505 );
12506 }
12507 }),
12508 );
12509
12510 it.effect("Windows process-tree teardown surfaces taskkill failure", () =>
12511 Effect.gen(function* () {
12512 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
12513 const path = yield* Path.Path;
12514 const mockAgentPath = yield* path.fromFileUrl(
12515 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
12516 );
12517 const context = yield* Layer.build(
12518 AcpSessionRuntime.layer({
12519 spawn: { command: process.execPath, args: [mockAgentPath], cwd: process.cwd() },
12520 cwd: process.cwd(),
12521 clientInfo: { name: "t3-acp-test", version: "0.0.0" },
12522 ownDetachedProcessGroup: true,
12523 processGroupPlatform: "win32",
12524 windowsProcessTreeTerminator: () =>
12525 Effect.fail(
12526 new AcpSessionRuntime.AcpProcessGroupTerminationError({
12527 detail: "mock taskkill failure",
12528 }),
12529 ),
12530 }).pipe(
12531 Layer.provide(
12532 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
12533 ),
12534 ),
12535 );
12536 const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(
12537 Effect.provide(context),
12538 );
12539 const error = yield* Effect.flip(runtime.terminateProcessGroup!);
12540 assert.equal(error.detail, "mock taskkill failure");
12541 }).pipe(Effect.provide(layerTest), Effect.scoped),
12542 );
12543
12544 it.live("poisons the session when hard teardown defects and blocks replacement work", () =>
12545 Effect.gen(function* () {
12546 if ((yield* HostProcess.Platform) !== "linux") return;
12547 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
12548 const fileSystem = yield* FileSystem.FileSystem;
12549 const idAllocator = yield* IdAllocator.IdAllocatorV2;
12550 const path = yield* Path.Path;
12551 const selfInvocation = yield* resolveSelfInvocation();
12552 const mockAgentPath = yield* path.fromFileUrl(
12553 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
12554 );
12555 const commandPidPath = yield* fileSystem.makeTempFileScoped({
12556 prefix: "t3-acp-failed-teardown-command-",
12557 });
12558 const residualCallbackDir = yield* fileSystem.makeTempDirectoryScoped();
12559 const residualCallbackResponseLogPath = path.join(residualCallbackDir, "responses.log");
12560 const residualCallbackTriggerPath = path.join(residualCallbackDir, "trigger");
12561 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
12562 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
12563 [];
12564 const teardownStarted = yield* Deferred.make<void>();
12565 const releaseTeardown = yield* Deferred.make<void>();
12566 let terminatorCallCount = 0;
12567 const instanceId = ProviderInstanceId.make("acp-test");
12568 const adapter = yield* makeAcpAdapterV2({
12569 instanceId,
12570 flavor: {
12571 driver: ACP_TEST_DRIVER,
12572 capabilities: AcpProviderCapabilitiesV2,
12573 enablePostSettleContinuation: true,
12574 restartRuntimeAfterInterrupt: true,
12575 terminateRuntimeProcessGroupOnInterrupt: true,
12576 makeRuntime: makeMockRuntime({
12577 childProcessSpawner,
12578 mockAgentPath,
12579 ownDetachedProcessGroup: true,
12580 processGroupPlatform: "win32",
12581 windowsProcessTreeTerminator: () =>
12582 Effect.gen(function* () {
12583 terminatorCallCount += 1;
12584 yield* Deferred.succeed(teardownStarted, undefined);
12585 yield* Deferred.await(releaseTeardown);
12586 return yield* Effect.die("mock taskkill defect");
12587 }),
12588 environment: {
12589 T3_ACP_EMIT_RUNNING_COMMAND_THEN_HANG: "1",
12590 T3_ACP_RESIDUAL_CALLBACK_RESPONSE_LOG_PATH: residualCallbackResponseLogPath,
12591 T3_ACP_RESIDUAL_CALLBACK_TRIGGER_PATH: residualCallbackTriggerPath,
12592 T3_ACP_RUNNING_COMMAND_PID_PATH: commandPidPath,
12593 },
12594 protocolEvents,
12595 }),
12596 },
12597 selfInvocation,
12598 continuationRequests: {
12599 offer: (request) =>
12600 Effect.sync(() => {
12601 continuationRequests.push(request);
12602 }),
12603 },
12604 });
12605 const threadId = ThreadId.make("thread-acp-failed-hard-teardown");
12606 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
12607 runtimeMode: "full-access",
12608 interactionMode: "default",
12609 cwd: process.cwd(),
12610 });
12611 const modelSelection = { instanceId, model: "default" } as const;
12612 const sessionScope = yield* Scope.make();
12613 const runtime = yield* adapter
12614 .openSession({
12615 threadId,
12616 providerSessionId: ProviderSessionId.make("provider-session-acp-failed-hard-teardown"),
12617 modelSelection,
12618 runtimePolicy,
12619 })
12620 .pipe(Effect.provideService(Scope.Scope, sessionScope));
12621 const adapterEvents = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
12622 yield* runtime.events.pipe(
12623 Stream.runForEach((event) => Queue.offer(adapterEvents, event)),
12624 Effect.forkIn(sessionScope),
12625 );
12626 const providerThread = yield* runtime.ensureThread({
12627 threadId,
12628 modelSelection,
12629 runtimePolicy,
12630 });
12631 const now = yield* DateTime.now;
12632 yield* runtime
12633 .startTurn(
12634 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }),
12635 )
12636 .pipe(Effect.forkScoped);
12637 while ((yield* fileSystem.readFileString(commandPidPath)).trim().length === 0) {
12638 yield* Effect.yieldNow;
12639 }
12640 const [commandRootPid, commandSleepPid] = (yield* fileSystem.readFileString(commandPidPath))
12641 .trim()
12642 .split(/\s+/)
12643 .map(Number);
12644 assert.isTrue(
12645 Option.isSome(yield* waitForProcesses([commandRootPid!, commandSleepPid!])),
12646 "declared failed-teardown Bash and sleep PIDs must both become live",
12647 );
12648 yield* pollProtocolMethods(protocolEvents);
12649
12650 const providerTurnId = idAllocator.derive.providerTurn({
12651 driver: ACP_TEST_DRIVER,
12652 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
12653 });
12654 const interruptFiber = yield* runtime
12655 .interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true })
12656 .pipe(Effect.exit, Effect.forkScoped);
12657 yield* Deferred.await(teardownStarted);
12658 const startFiber = yield* runtime
12659 .startTurn(
12660 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }),
12661 )
12662 .pipe(Effect.exit, Effect.forkScoped);
12663 yield* Effect.yieldNow;
12664 yield* Effect.yieldNow;
12665 assert.isUndefined(startFiber.pollUnsafe());
12666 const methodsDuringTeardown = yield* pollProtocolMethods(protocolEvents);
12667 yield* Deferred.succeed(releaseTeardown, undefined);
12668 assert.notInclude(methodsDuringTeardown, "session/resume");
12669 assert.notInclude(methodsDuringTeardown, "session/prompt");
12670
12671 const interruptExit = yield* Fiber.join(interruptFiber);
12672 if (Exit.isSuccess(interruptExit)) assert.fail("hard teardown failure must fail interrupt");
12673 assert.include(Cause.pretty(interruptExit.cause), "session is poisoned");
12674 assert.isTrue(
12675 Option.isSome(yield* waitForProcesses([commandRootPid!, commandSleepPid!])),
12676 "failed teardown must leave both declared Bash and sleep PIDs live",
12677 );
12678
12679 while (Option.isSome(yield* Queue.poll(adapterEvents))) {
12680 // Discard the interrupted turn's expected projection before residual traffic.
12681 }
12682 yield* fileSystem.writeFileString(residualCallbackTriggerPath, "go");
12683 yield* Stream.fromQueue(protocolEvents).pipe(
12684 Stream.filter(
12685 (event) =>
12686 event.direction === "incoming" &&
12687 event.stage === "raw" &&
12688 typeof event.payload === "string" &&
12689 event.payload.includes('"method":"session/request_permission"'),
12690 ),
12691 Stream.runHead,
12692 );
12693 assert.isTrue(
12694 Option.isNone(yield* Queue.poll(adapterEvents)),
12695 "residual callbacks must not mutate or emit adapter projection after poison",
12696 );
12697 assert.isFalse(
12698 yield* fileSystem.exists(residualCallbackResponseLogPath),
12699 "permission and elicitation callbacks must remain unresolved after poison",
12700 );
12701 assert.lengthOf(
12702 continuationRequests,
12703 0,
12704 "residual callbacks must not offer a continuation after poison",
12705 );
12706
12707 const startExit = yield* Fiber.join(startFiber);
12708 if (Exit.isSuccess(startExit)) assert.fail("poisoned session must reject startTurn");
12709 assert.include(Cause.pretty(startExit.cause), "session is poisoned");
12710 const resumeExit = yield* runtime
12711 .resumeThread({ providerThread, modelSelection, runtimePolicy })
12712 .pipe(Effect.exit);
12713 if (Exit.isSuccess(resumeExit)) assert.fail("poisoned session must reject resumeThread");
12714 assert.include(Cause.pretty(resumeExit.cause), "session is poisoned");
12715 const snapshotExit = yield* runtime.readThreadSnapshot({ providerThread }).pipe(Effect.exit);
12716 if (Exit.isSuccess(snapshotExit)) {
12717 assert.fail("poisoned session must reject readThreadSnapshot");
12718 }
12719 assert.include(Cause.pretty(snapshotExit.cause), "session is poisoned");
12720 const forkExit = yield* runtime
12721 .forkThread({
12722 sourceProviderThread: providerThread,
12723 targetThreadId: ThreadId.make("thread-acp-poisoned-fork"),
12724 })
12725 .pipe(Effect.exit);
12726 if (Exit.isSuccess(forkExit)) assert.fail("poisoned session must reject forkThread");
12727 assert.include(Cause.pretty(forkExit.cause), "session is poisoned");
12728 yield* pollProtocolMethods(protocolEvents);
12729 const retryInterruptExit = yield* runtime
12730 .interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true })
12731 .pipe(Effect.exit);
12732 if (Exit.isSuccess(retryInterruptExit)) {
12733 assert.fail("poisoned session must reject a repeated hard interrupt");
12734 }
12735 const firstInterruptError = Cause.squash(interruptExit.cause) as Error;
12736 const retryInterruptError = Cause.squash(retryInterruptExit.cause) as Error;
12737 assert.strictEqual(retryInterruptError.cause, firstInterruptError.cause);
12738 assert.equal(terminatorCallCount, 1);
12739 const methodsAfterPoison = yield* pollProtocolMethods(protocolEvents);
12740 assert.notInclude(methodsAfterPoison, "initialize");
12741 assert.notInclude(methodsAfterPoison, "session/cancel");
12742 assert.notInclude(methodsAfterPoison, "session/fork");
12743 assert.notInclude(methodsAfterPoison, "session/resume");
12744 assert.notInclude(methodsAfterPoison, "session/prompt");
12745 assert.isTrue(processExists(commandRootPid!));
12746 assert.isTrue(processExists(commandSleepPid!));
12747 yield* Scope.close(sessionScope, Exit.void);
12748 assert.isTrue(
12749 Option.isSome(yield* waitForProcessesToExit([commandRootPid!, commandSleepPid!])),
12750 "finalizer cleanup must reap both declared Bash and sleep PIDs",
12751 );
12752 assert.isFalse(processExists(commandRootPid!));
12753 assert.isFalse(processExists(commandSleepPid!));
12754 const finalizerMethods = yield* pollProtocolMethods(protocolEvents);
12755 assert.notInclude(finalizerMethods, "session/close");
12756 }).pipe(Effect.provide(layerTest), Effect.scoped),
12757 );
12758
12759 it.live("durably poisons start and resume when required hard teardown is unavailable", () =>
12760 Effect.gen(function* () {
12761 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
12762 const idAllocator = yield* IdAllocator.IdAllocatorV2;
12763 const path = yield* Path.Path;
12764 const selfInvocation = yield* resolveSelfInvocation();
12765 const mockAgentPath = yield* path.fromFileUrl(
12766 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
12767 );
12768 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
12769 const instanceId = ProviderInstanceId.make("acp-test");
12770 const adapter = yield* makeAcpAdapterV2({
12771 instanceId,
12772 flavor: {
12773 driver: ACP_TEST_DRIVER,
12774 capabilities: AcpProviderCapabilitiesV2,
12775 restartRuntimeAfterInterrupt: true,
12776 terminateRuntimeProcessGroupOnInterrupt: true,
12777 makeRuntime: makeMockRuntime({
12778 childProcessSpawner,
12779 mockAgentPath,
12780 environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" },
12781 protocolEvents,
12782 }),
12783 },
12784 selfInvocation,
12785 });
12786 const threadId = ThreadId.make("thread-acp-missing-hard-teardown");
12787 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
12788 runtimeMode: "full-access",
12789 interactionMode: "default",
12790 cwd: process.cwd(),
12791 });
12792 const modelSelection = { instanceId, model: "default" } as const;
12793 const runtime = yield* adapter.openSession({
12794 threadId,
12795 providerSessionId: ProviderSessionId.make("provider-session-acp-missing-hard-teardown"),
12796 modelSelection,
12797 runtimePolicy,
12798 });
12799 const providerThread = yield* runtime.ensureThread({
12800 threadId,
12801 modelSelection,
12802 runtimePolicy,
12803 });
12804 const now = yield* DateTime.now;
12805 yield* runtime
12806 .startTurn(
12807 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }),
12808 )
12809 .pipe(Effect.forkScoped);
12810 yield* Stream.fromQueue(protocolEvents).pipe(
12811 Stream.filter(
12812 (event) =>
12813 event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt",
12814 ),
12815 Stream.runHead,
12816 );
12817 const providerTurnId = idAllocator.derive.providerTurn({
12818 driver: ACP_TEST_DRIVER,
12819 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
12820 });
12821 const interruptExit = yield* runtime
12822 .interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true })
12823 .pipe(Effect.exit);
12824 if (Exit.isSuccess(interruptExit)) assert.fail("missing hard teardown must fail interrupt");
12825 assert.include(Cause.pretty(interruptExit.cause), "session is poisoned");
12826
12827 const startExit = yield* runtime
12828 .startTurn(
12829 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }),
12830 )
12831 .pipe(Effect.exit);
12832 if (Exit.isSuccess(startExit)) assert.fail("poisoned session must reject startTurn");
12833 assert.include(Cause.pretty(startExit.cause), "session is poisoned");
12834 const resumeExit = yield* runtime
12835 .resumeThread({ providerThread, modelSelection, runtimePolicy })
12836 .pipe(Effect.exit);
12837 if (Exit.isSuccess(resumeExit)) assert.fail("poisoned session must reject resumeThread");
12838 assert.include(Cause.pretty(resumeExit.cause), "session is poisoned");
12839 }).pipe(Effect.provide(layerTest), Effect.scoped),
12840 );
12841
12842 it.live("holds concurrent startTurn behind successful hard teardown and reloads once", () =>
12843 Effect.gen(function* () {
12844 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
12845 const idAllocator = yield* IdAllocator.IdAllocatorV2;
12846 const path = yield* Path.Path;
12847 const selfInvocation = yield* resolveSelfInvocation();
12848 const mockAgentPath = yield* path.fromFileUrl(
12849 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
12850 );
12851 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
12852 const teardownStarted = yield* Deferred.make<void>();
12853 const releaseTeardown = yield* Deferred.make<void>();
12854 let runtimeOrdinalSeen = 0;
12855 const instanceId = ProviderInstanceId.make("acp-test");
12856 const adapter = yield* makeAcpAdapterV2({
12857 instanceId,
12858 flavor: {
12859 driver: ACP_TEST_DRIVER,
12860 capabilities: AcpProviderCapabilitiesV2,
12861 interruptPromptOnCancel: true,
12862 restartRuntimeAfterInterrupt: true,
12863 terminateRuntimeProcessGroupOnInterrupt: true,
12864 makeRuntime: makeMockRuntime({
12865 childProcessSpawner,
12866 mockAgentPath,
12867 ownDetachedProcessGroup: true,
12868 processGroupPlatform: "win32",
12869 windowsProcessTreeTerminator: () =>
12870 Deferred.succeed(teardownStarted, undefined).pipe(
12871 Effect.andThen(Deferred.await(releaseTeardown)),
12872 ),
12873 environment: (runtimeOrdinal) => {
12874 runtimeOrdinalSeen = runtimeOrdinal;
12875 return runtimeOrdinal === 1 ? { T3_ACP_HANG_PROMPT_FOREVER: "1" } : {};
12876 },
12877 protocolEvents,
12878 }),
12879 },
12880 selfInvocation,
12881 });
12882 const threadId = ThreadId.make("thread-acp-concurrent-hard-teardown");
12883 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
12884 runtimeMode: "full-access",
12885 interactionMode: "default",
12886 cwd: process.cwd(),
12887 });
12888 const modelSelection = { instanceId, model: "default" } as const;
12889 const runtime = yield* adapter.openSession({
12890 threadId,
12891 providerSessionId: ProviderSessionId.make("provider-session-acp-concurrent-hard-teardown"),
12892 modelSelection,
12893 runtimePolicy,
12894 });
12895 const providerThread = yield* runtime.ensureThread({
12896 threadId,
12897 modelSelection,
12898 runtimePolicy,
12899 });
12900 const now = yield* DateTime.now;
12901 yield* runtime
12902 .startTurn(
12903 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }),
12904 )
12905 .pipe(Effect.forkScoped);
12906 yield* Effect.yieldNow;
12907 yield* Effect.yieldNow;
12908 yield* Effect.yieldNow;
12909 const providerTurnId = idAllocator.derive.providerTurn({
12910 driver: ACP_TEST_DRIVER,
12911 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
12912 });
12913 yield* pollProtocolMethods(protocolEvents);
12914 const interruptFiber = yield* runtime
12915 .interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true })
12916 .pipe(Effect.forkScoped);
12917 yield* Deferred.await(teardownStarted);
12918 const startFiber = yield* runtime
12919 .startTurn(
12920 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }),
12921 )
12922 .pipe(Effect.forkScoped);
12923 yield* Effect.yieldNow;
12924 yield* Effect.yieldNow;
12925 assert.isUndefined(startFiber.pollUnsafe());
12926 assert.equal(runtimeOrdinalSeen, 1);
12927 const methodsDuringTeardown = yield* pollProtocolMethods(protocolEvents);
12928 assert.notInclude(methodsDuringTeardown, "session/resume");
12929 assert.notInclude(methodsDuringTeardown, "session/prompt");
12930
12931 yield* Deferred.succeed(releaseTeardown, undefined);
12932 yield* Fiber.join(interruptFiber);
12933 yield* Fiber.join(startFiber);
12934 assert.equal(runtimeOrdinalSeen, 2);
12935 const replacementMethods = yield* pollProtocolMethods(protocolEvents);
12936 assert.equal(replacementMethods.filter((method) => method === "session/resume").length, 1);
12937 if (!replacementMethods.includes("session/prompt")) {
12938 yield* Stream.fromQueue(protocolEvents).pipe(
12939 Stream.filter(
12940 (event) =>
12941 event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt",
12942 ),
12943 Stream.runHead,
12944 );
12945 }
12946 }).pipe(Effect.provide(layerTest), Effect.scoped),
12947 );
12948
12949 it.live("quarantines old-runtime callbacks after successful hard teardown", () =>
12950 Effect.gen(function* () {
12951 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
12952 const idAllocator = yield* IdAllocator.IdAllocatorV2;
12953 const path = yield* Path.Path;
12954 const selfInvocation = yield* resolveSelfInvocation();
12955 const mockAgentPath = yield* path.fromFileUrl(
12956 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
12957 );
12958 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
12959 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
12960 [];
12961 const responseLifecycle: Array<string> = [];
12962 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
12963 type HandlerRecord = {
12964 sessionUpdate?: Parameters<RuntimeService["handleSessionUpdate"]>[0];
12965 permission?: Parameters<RuntimeService["handleRequestPermission"]>[0];
12966 elicitation?: Parameters<RuntimeService["handleElicitation"]>[0];
12967 requestUserInput?: AcpAdapterV2ExtensionContext["requestUserInput"];
12968 };
12969 const handlerRecords: HandlerRecord[] = [];
12970 const runtimeInputs: AcpAdapterV2RuntimeInput[] = [];
12971 const oldPromptCompletion = yield* Deferred.make<EffectAcpSchema.PromptResponse>();
12972 const transportDrained = yield* Deferred.make<void>();
12973 const releaseTransportDrain = yield* Deferred.make<void>();
12974 let extensionOrdinal = 0;
12975 let runtimeOrdinalSeen = 0;
12976 const instanceId = ProviderInstanceId.make("acp-test");
12977 const makeRuntime = makeMockRuntime({
12978 childProcessSpawner,
12979 mockAgentPath,
12980 ownDetachedProcessGroup: true,
12981 processGroupPlatform: "win32",
12982 windowsProcessTreeTerminator: () => Effect.void,
12983 environment: (runtimeOrdinal) => {
12984 runtimeOrdinalSeen = runtimeOrdinal;
12985 return { T3_ACP_HANG_PROMPT_FOREVER: "1" };
12986 },
12987 protocolEvents,
12988 wrapRuntime: (runtime, runtimeOrdinal) => {
12989 const record: HandlerRecord = {};
12990 handlerRecords[runtimeOrdinal - 1] = record;
12991 return {
12992 ...runtime,
12993 handleSessionUpdate: (handler) =>
12994 Effect.sync(() => {
12995 record.sessionUpdate = handler;
12996 }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))),
12997 handleRequestPermission: (handler) =>
12998 Effect.sync(() => {
12999 record.permission = handler;
13000 }).pipe(Effect.andThen(runtime.handleRequestPermission(handler))),
13001 handleElicitation: (handler) =>
13002 Effect.sync(() => {
13003 record.elicitation = handler;
13004 }).pipe(Effect.andThen(runtime.handleElicitation(handler))),
13005 ...(runtimeOrdinal === 1
13006 ? {
13007 prompt: (payload) =>
13008 Effect.gen(function* () {
13009 yield* runtime.prompt(payload).pipe(Effect.ignore, Effect.forkDetach);
13010 return yield* Deferred.await(oldPromptCompletion);
13011 }),
13012 }
13013 : {}),
13014 };
13015 },
13016 });
13017 const adapter = yield* makeAcpAdapterV2({
13018 instanceId,
13019 flavor: {
13020 driver: ACP_TEST_DRIVER,
13021 capabilities: AcpProviderCapabilitiesV2,
13022 enablePostSettleContinuation: true,
13023 registerExtensions: ({ requestUserInput }) =>
13024 Effect.sync(() => {
13025 handlerRecords[extensionOrdinal++]!.requestUserInput = requestUserInput;
13026 }),
13027 restartRuntimeAfterInterrupt: true,
13028 terminateRuntimeProcessGroupOnInterrupt: true,
13029 makeRuntime: (runtimeInput) =>
13030 Effect.sync(() => {
13031 runtimeInputs.push(runtimeInput);
13032 }).pipe(Effect.andThen(makeRuntime(runtimeInput))),
13033 },
13034 selfInvocation,
13035 testHooks: {
13036 afterHardTeardownTransportDrained: () =>
13037 Deferred.succeed(transportDrained, undefined).pipe(
13038 Effect.andThen(Deferred.await(releaseTransportDrain)),
13039 ),
13040 onNativeResponseLifecycle: (event) =>
13041 Effect.sync(() => {
13042 responseLifecycle.push(event.type);
13043 }),
13044 },
13045 continuationRequests: {
13046 offer: (request) =>
13047 Effect.sync(() => {
13048 continuationRequests.push(request);
13049 }),
13050 },
13051 });
13052 const threadId = ThreadId.make("thread-acp-successful-teardown-callback-quarantine");
13053 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
13054 runtimeMode: "full-access",
13055 interactionMode: "default",
13056 cwd: process.cwd(),
13057 });
13058 const modelSelection = { instanceId, model: "default" } as const;
13059 const runtime = yield* adapter.openSession({
13060 threadId,
13061 providerSessionId: ProviderSessionId.make(
13062 "provider-session-acp-successful-teardown-callback-quarantine",
13063 ),
13064 modelSelection,
13065 runtimePolicy,
13066 });
13067 const adapterEvents = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
13068 yield* runtime.events.pipe(
13069 Stream.runForEach((event) => Queue.offer(adapterEvents, event)),
13070 Effect.forkScoped,
13071 );
13072 const providerThread = yield* runtime.ensureThread({
13073 threadId,
13074 modelSelection,
13075 runtimePolicy,
13076 });
13077 const now = yield* DateTime.now;
13078 yield* runtime
13079 .startTurn(
13080 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }),
13081 )
13082 .pipe(Effect.forkScoped);
13083 yield* Stream.fromQueue(protocolEvents).pipe(
13084 Stream.filter(
13085 (event) =>
13086 event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt",
13087 ),
13088 Stream.runHead,
13089 );
13090 const providerTurnId = idAllocator.derive.providerTurn({
13091 driver: ACP_TEST_DRIVER,
13092 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
13093 });
13094 const permissionRequest = {
13095 sessionId: "mock-session-1",
13096 toolCall: {
13097 toolCallId: "stale-generation-1-permission",
13098 title: "Stale generation 1 permission",
13099 },
13100 options: [{ optionId: "allow", name: "Allow", kind: "allow_once" as const }],
13101 };
13102 const interruptFiber = yield* runtime
13103 .interruptTurn({
13104 providerThread,
13105 providerTurnId,
13106 requestRuntimeRestart: true,
13107 })
13108 .pipe(Effect.forkScoped);
13109 yield* Deferred.await(transportDrained);
13110 const oldHandlers = handlerRecords[0]!;
13111 assert.isDefined(oldHandlers.permission);
13112 const heldInboundFiber = yield* oldHandlers.permission!(permissionRequest, {
13113 requestId: "post-drain-stale-permission-id",
13114 method: "session/request_permission",
13115 }).pipe(Effect.forkScoped);
13116 yield* Effect.yieldNow;
13117 assert.isUndefined(heldInboundFiber.pollUnsafe());
13118 yield* Deferred.succeed(releaseTransportDrain, undefined);
13119 yield* Fiber.join(interruptFiber);
13120 yield* Effect.yieldNow;
13121 assert.isUndefined(heldInboundFiber.pollUnsafe());
13122 assert.notInclude(responseLifecycle, "registered");
13123 assert.notInclude(responseLifecycle, "watcher_started");
13124 assert.isFalse(
13125 (yield* Queue.takeAll(adapterEvents)).some(
13126 (event) => event.type === "runtime_request.updated",
13127 ),
13128 "post-drain inbound callback must not emit a runtime request",
13129 );
13130 assert.equal(runtimeOrdinalSeen, 1);
13131 yield* runtime.startTurn(
13132 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }),
13133 );
13134 yield* Stream.fromQueue(protocolEvents).pipe(
13135 Stream.filter(
13136 (event) =>
13137 event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt",
13138 ),
13139 Stream.runHead,
13140 );
13141 assert.equal(runtimeOrdinalSeen, 2);
13142 const replacementHandlers = handlerRecords[1]!;
13143 assert.isDefined(oldHandlers.sessionUpdate);
13144 assert.isDefined(oldHandlers.permission);
13145 assert.isDefined(oldHandlers.elicitation);
13146 assert.isDefined(oldHandlers.requestUserInput);
13147 assert.isDefined(replacementHandlers.sessionUpdate);
13148 assert.isDefined(replacementHandlers.permission);
13149 assert.isDefined(replacementHandlers.elicitation);
13150 assert.isDefined(replacementHandlers.requestUserInput);
13151 assert.lengthOf(runtimeInputs, 2);
13152 while (Option.isSome(yield* Queue.poll(adapterEvents))) {
13153 // Discard generation 1 terminal and generation 2 startup projection.
13154 }
13155
13156 yield* oldHandlers.sessionUpdate!({
13157 sessionId: "mock-session-1",
13158 update: {
13159 sessionUpdate: "agent_message_chunk",
13160 content: { type: "text", text: "stale generation 1 assistant" },
13161 },
13162 });
13163 yield* oldHandlers.sessionUpdate!({
13164 sessionId: "mock-session-1",
13165 update: {
13166 sessionUpdate: "tool_call_update",
13167 toolCallId: "stale-generation-1-tool",
13168 title: "Stale generation 1 tool",
13169 kind: "other",
13170 status: "completed",
13171 rawOutput: { output: "stale continuation evidence" },
13172 },
13173 });
13174 const oldPermissionFiber = yield* oldHandlers.permission!(permissionRequest, {
13175 requestId: "stale-generation-1-permission-id",
13176 method: "session/request_permission",
13177 }).pipe(Effect.exit, Effect.forkScoped);
13178 const oldElicitationFiber = yield* oldHandlers.elicitation!(
13179 {
13180 sessionId: "mock-session-1",
13181 message: "Stale generation 1 elicitation",
13182 mode: "form",
13183 requestedSchema: {
13184 type: "object",
13185 properties: { approved: { type: "boolean", title: "Approved" } },
13186 },
13187 },
13188 {
13189 requestId: "stale-generation-1-elicitation-id",
13190 method: "session/elicitation",
13191 },
13192 ).pipe(Effect.exit, Effect.forkScoped);
13193 const oldXAiUserInputFiber = yield* oldHandlers.requestUserInput!(
13194 {
13195 nativeItemId: "stale-generation-1-xai-item",
13196 nativeRequestId: "stale-generation-1-xai-request",
13197 questions: [
13198 {
13199 id: "approved",
13200 header: "Approve",
13201 question: "Approve stale generation 1?",
13202 options: [{ label: "yes", description: "Approve" }],
13203 },
13204 ],
13205 },
13206 {
13207 requestId: "stale-generation-1-xai-transport-id",
13208 method: "_x.ai/ask_user_question",
13209 },
13210 ).pipe(Effect.exit, Effect.forkScoped);
13211 yield* Deferred.succeed(oldPromptCompletion, { stopReason: "end_turn" });
13212 yield* Effect.sleep("100 millis");
13213 assert.isTrue(Option.isNone(yield* Queue.poll(adapterEvents)));
13214 assert.isUndefined(oldPermissionFiber.pollUnsafe());
13215 assert.isUndefined(oldElicitationFiber.pollUnsafe());
13216 assert.isUndefined(oldXAiUserInputFiber.pollUnsafe());
13217 assert.lengthOf(continuationRequests, 0);
13218
13219 yield* replacementHandlers.sessionUpdate!({
13220 sessionId: "mock-session-1",
13221 update: {
13222 sessionUpdate: "agent_message_chunk",
13223 content: { type: "text", text: "live generation 2 assistant" },
13224 },
13225 });
13226 let replacementMessageSeen = false;
13227 while (!replacementMessageSeen) {
13228 const event = yield* Queue.take(adapterEvents);
13229 replacementMessageSeen =
13230 event.type === "message.updated" && event.message.text.includes("live generation 2");
13231 }
13232 const replacementPermission = yield* replacementHandlers.permission!(permissionRequest, {
13233 requestId: "live-generation-2-permission-id",
13234 method: "session/request_permission",
13235 });
13236 assert.equal(replacementPermission.outcome.outcome, "selected");
13237 yield* runtimeInputs[1]!.onOutgoingResponse!("live-generation-2-permission-id");
13238 const urlElicitation = {
13239 elicitationId: "replacement-url-id",
13240 message: "Open replacement URL",
13241 mode: "url" as const,
13242 sessionId: "mock-session-1",
13243 url: "https://example.com/replacement",
13244 };
13245 yield* replacementHandlers.elicitation!(urlElicitation, {
13246 requestId: "live-generation-2-url-id",
13247 method: "session/elicitation",
13248 });
13249 yield* runtimeInputs[1]!.onOutgoingResponse!("live-generation-2-url-id");
13250
13251 const replacementUserInputFiber = yield* replacementHandlers.requestUserInput!(
13252 {
13253 nativeItemId: "live-generation-2-xai-item",
13254 nativeRequestId: "shared-request-id",
13255 questions: [
13256 {
13257 id: "approved",
13258 header: "Approve",
13259 question: "Approve live generation 2?",
13260 options: [{ label: "yes", description: "Approve" }],
13261 },
13262 ],
13263 },
13264 {
13265 requestId: "live-generation-2-transport-id",
13266 method: "_x.ai/ask_user_question",
13267 },
13268 ).pipe(Effect.forkScoped);
13269 let replacementRequest: ProviderAdapter.ProviderAdapterV2Event | undefined;
13270 while (replacementRequest === undefined) {
13271 const event = yield* Queue.take(adapterEvents);
13272 if (event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending") {
13273 replacementRequest = event;
13274 }
13275 }
13276 if (replacementRequest.type !== "runtime_request.updated") {
13277 return yield* Effect.die("Expected generation 2 xAI user input request");
13278 }
13279 const responseFiber = yield* runtime
13280 .respondToRuntimeRequest({
13281 requestId: replacementRequest.runtimeRequest.id,
13282 answers: { approved: ["yes"] },
13283 })
13284 .pipe(Effect.forkScoped);
13285 const replacementUserInput = yield* Fiber.join(replacementUserInputFiber);
13286 assert.deepEqual(replacementUserInput.answers, { approved: ["yes"] });
13287 yield* replacementUserInput.acknowledgeNativeResponse;
13288 yield* runtimeInputs[0]!.onOutgoingResponse!("stale-generation-1-transport-id");
13289 yield* runtimeInputs[1]!.onOutgoingResponse!("live-generation-2-wrong-method-id");
13290 yield* Effect.yieldNow;
13291 assert.isUndefined(responseFiber.pollUnsafe());
13292 yield* runtimeInputs[1]!.onOutgoingResponse!("live-generation-2-transport-id");
13293 yield* Fiber.join(responseFiber);
13294
13295 yield* runtime.interruptTurn({
13296 providerThread,
13297 providerTurnId: idAllocator.derive.providerTurn({
13298 driver: ACP_TEST_DRIVER,
13299 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"),
13300 }),
13301 requestRuntimeRestart: true,
13302 });
13303 }).pipe(Effect.provide(layerTest), Effect.scoped),
13304 );
13305
13306 it.live("keeps stale deferred cleanup inert while replacement requests remain live", () =>
13307 Effect.gen(function* () {
13308 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
13309 const path = yield* Path.Path;
13310 const selfInvocation = yield* resolveSelfInvocation();
13311 const mockAgentPath = yield* path.fromFileUrl(
13312 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
13313 );
13314 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
13315 const instanceId = ProviderInstanceId.make("acp-test");
13316 const adapter = yield* makeAcpAdapterV2({
13317 instanceId,
13318 flavor: {
13319 driver: ACP_TEST_DRIVER,
13320 capabilities: AcpProviderCapabilitiesV2,
13321 restartRuntimeAfterInterrupt: true,
13322 terminateRuntimeProcessGroupOnInterrupt: true,
13323 makeRuntime: makeMockRuntime({
13324 childProcessSpawner,
13325 mockAgentPath,
13326 ownDetachedProcessGroup: true,
13327 processGroupPlatform: "win32",
13328 windowsProcessTreeTerminator: () => Effect.void,
13329 environment: { T3_ACP_EMIT_TOOL_CALLS: "1" },
13330 protocolEvents,
13331 }),
13332 },
13333 selfInvocation,
13334 });
13335 const threadId = ThreadId.make("thread-acp-stale-deferred-cleanup");
13336 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
13337 runtimeMode: "approval-required",
13338 interactionMode: "default",
13339 cwd: process.cwd(),
13340 });
13341 const modelSelection = { instanceId, model: "default" } as const;
13342 const runtime = yield* adapter.openSession({
13343 threadId,
13344 providerSessionId: ProviderSessionId.make("provider-session-acp-stale-deferred-cleanup"),
13345 modelSelection,
13346 runtimePolicy,
13347 });
13348 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
13349 yield* runtime.events.pipe(
13350 Stream.runForEach((event) => Queue.offer(events, event)),
13351 Effect.forkScoped,
13352 );
13353 const providerThread = yield* runtime.ensureThread({
13354 threadId,
13355 modelSelection,
13356 runtimePolicy,
13357 });
13358 const now = yield* DateTime.now;
13359 yield* runtime.startTurn(
13360 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }),
13361 );
13362 const oldPending = yield* Queue.take(events).pipe(
13363 Effect.repeat({
13364 until: (event) =>
13365 event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending",
13366 }),
13367 );
13368 if (
13369 oldPending.type !== "runtime_request.updated" ||
13370 oldPending.runtimeRequest.providerTurnId === null
13371 ) {
13372 return yield* Effect.die("Expected the old runtime permission request");
13373 }
13374 yield* runtime.interruptTurn({
13375 providerThread,
13376 providerTurnId: oldPending.runtimeRequest.providerTurnId,
13377 requestRuntimeRestart: true,
13378 });
13379 const staleResponse = yield* runtime
13380 .respondToRuntimeRequest({
13381 requestId: oldPending.runtimeRequest.id,
13382 decision: "accept",
13383 })
13384 .pipe(Effect.exit);
13385 if (Exit.isSuccess(staleResponse)) {
13386 assert.fail("teardown must synchronously remove the old pending request");
13387 }
13388 while (Option.isSome(yield* Queue.poll(events))) {
13389 // Discard generation 1 cancellation and terminal projection.
13390 }
13391
13392 yield* runtime.startTurn(
13393 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }),
13394 );
13395 let replacementPending: ProviderAdapter.ProviderAdapterV2Event | undefined;
13396 while (replacementPending === undefined) {
13397 const event = yield* Queue.take(events);
13398 if (event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending") {
13399 replacementPending = event;
13400 }
13401 }
13402 if (replacementPending.type !== "runtime_request.updated") {
13403 return yield* Effect.die("Expected the replacement runtime permission request");
13404 }
13405 assert.notEqual(replacementPending.runtimeRequest.id, oldPending.runtimeRequest.id);
13406 yield* runtime.respondToRuntimeRequest({
13407 requestId: replacementPending.runtimeRequest.id,
13408 decision: "accept",
13409 });
13410 let replacementTerminal = false;
13411 while (!replacementTerminal) {
13412 const event = yield* Queue.take(events);
13413 if (
13414 event.type === "runtime_request.updated" &&
13415 event.runtimeRequest.id === oldPending.runtimeRequest.id
13416 ) {
13417 assert.fail("stale deferred cleanup must not emit into generation 2");
13418 }
13419 replacementTerminal = event.type === "turn.terminal";
13420 }
13421 }).pipe(Effect.provide(layerTest), Effect.scoped),
13422 );
13423
13424 it.live("resolves owner cancellation and concurrent resume waiters after hard teardown", () =>
13425 Effect.gen(function* () {
13426 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
13427 const idAllocator = yield* IdAllocator.IdAllocatorV2;
13428 const path = yield* Path.Path;
13429 const selfInvocation = yield* resolveSelfInvocation();
13430 const mockAgentPath = yield* path.fromFileUrl(
13431 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
13432 );
13433 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
13434 const teardownStarted = yield* Deferred.make<void>();
13435 const releaseTeardown = yield* Deferred.make<void>();
13436 let runtimeOrdinalSeen = 0;
13437 const instanceId = ProviderInstanceId.make("acp-test");
13438 const adapter = yield* makeAcpAdapterV2({
13439 instanceId,
13440 flavor: {
13441 driver: ACP_TEST_DRIVER,
13442 capabilities: AcpProviderCapabilitiesV2,
13443 interruptPromptOnCancel: true,
13444 restartRuntimeAfterInterrupt: true,
13445 terminateRuntimeProcessGroupOnInterrupt: true,
13446 makeRuntime: makeMockRuntime({
13447 childProcessSpawner,
13448 mockAgentPath,
13449 ownDetachedProcessGroup: true,
13450 processGroupPlatform: "win32",
13451 windowsProcessTreeTerminator: () =>
13452 Deferred.succeed(teardownStarted, undefined).pipe(
13453 Effect.andThen(Deferred.await(releaseTeardown)),
13454 ),
13455 environment: (runtimeOrdinal) => {
13456 runtimeOrdinalSeen = runtimeOrdinal;
13457 return runtimeOrdinal === 1 ? { T3_ACP_HANG_PROMPT_FOREVER: "1" } : {};
13458 },
13459 protocolEvents,
13460 }),
13461 },
13462 selfInvocation,
13463 });
13464 const threadId = ThreadId.make("thread-acp-concurrent-resume-teardown");
13465 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
13466 runtimeMode: "full-access",
13467 interactionMode: "default",
13468 cwd: process.cwd(),
13469 });
13470 const modelSelection = { instanceId, model: "default" } as const;
13471 const runtime = yield* adapter.openSession({
13472 threadId,
13473 providerSessionId: ProviderSessionId.make(
13474 "provider-session-acp-concurrent-resume-teardown",
13475 ),
13476 modelSelection,
13477 runtimePolicy,
13478 });
13479 const providerThread = yield* runtime.ensureThread({
13480 threadId,
13481 modelSelection,
13482 runtimePolicy,
13483 });
13484 const now = yield* DateTime.now;
13485 yield* runtime
13486 .startTurn(
13487 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }),
13488 )
13489 .pipe(Effect.forkScoped);
13490 yield* Effect.yieldNow;
13491 yield* Effect.yieldNow;
13492 yield* Effect.yieldNow;
13493 yield* pollProtocolMethods(protocolEvents);
13494 const providerTurnId = idAllocator.derive.providerTurn({
13495 driver: ACP_TEST_DRIVER,
13496 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
13497 });
13498 const interruptFiber = yield* runtime
13499 .interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true })
13500 .pipe(Effect.forkScoped);
13501 yield* Deferred.await(teardownStarted);
13502 const resumeFiber = yield* runtime
13503 .resumeThread({ providerThread, modelSelection, runtimePolicy })
13504 .pipe(Effect.forkScoped);
13505 const secondResumeFiber = yield* runtime
13506 .resumeThread({ providerThread, modelSelection, runtimePolicy })
13507 .pipe(Effect.forkScoped);
13508 const snapshotProviderThread = {
13509 ...providerThread,
13510 nativeThreadRef: {
13511 ...providerThread.nativeThreadRef!,
13512 nativeId: "mock-session-snapshot",
13513 },
13514 };
13515 const snapshotFiber = yield* runtime
13516 .readThreadSnapshot({ providerThread: snapshotProviderThread })
13517 .pipe(Effect.forkScoped);
13518 const forkFiber = yield* runtime
13519 .forkThread({
13520 sourceProviderThread: providerThread,
13521 targetThreadId: ThreadId.make("thread-acp-concurrent-fork-after-teardown"),
13522 })
13523 .pipe(Effect.forkScoped);
13524 yield* Effect.yieldNow;
13525 yield* Effect.yieldNow;
13526 assert.isUndefined(resumeFiber.pollUnsafe());
13527 assert.isUndefined(secondResumeFiber.pollUnsafe());
13528 assert.isUndefined(snapshotFiber.pollUnsafe());
13529 assert.isUndefined(forkFiber.pollUnsafe());
13530 assert.equal(runtimeOrdinalSeen, 1);
13531 const methodsDuringTeardown = yield* pollProtocolMethods(protocolEvents);
13532 assert.notInclude(methodsDuringTeardown, "session/resume");
13533 assert.notInclude(methodsDuringTeardown, "session/fork");
13534 const cancelInterruptOwner = yield* Fiber.interrupt(interruptFiber).pipe(Effect.forkScoped);
13535 yield* Effect.yieldNow;
13536 yield* Effect.yieldNow;
13537 assert.isUndefined(cancelInterruptOwner.pollUnsafe());
13538 yield* Deferred.succeed(releaseTeardown, undefined);
13539 yield* Fiber.join(cancelInterruptOwner);
13540 yield* Fiber.join(resumeFiber);
13541 yield* Fiber.join(secondResumeFiber);
13542 yield* Fiber.join(snapshotFiber);
13543 yield* Fiber.join(forkFiber);
13544 assert.equal(runtimeOrdinalSeen, 2);
13545 const methodsAfterRestart = yield* pollProtocolMethods(protocolEvents);
13546 assert.equal(methodsAfterRestart.filter((method) => method === "session/resume").length, 2);
13547 assert.equal(methodsAfterRestart.filter((method) => method === "session/fork").length, 1);
13548 }).pipe(Effect.provide(layerTest), Effect.scoped),
13549 );
13550
13551 it.live("direct Stop skips uninterruptible ACP cancel and recovers after native teardown", () =>
13552 Effect.gen(function* () {
13553 if ((yield* HostProcess.Platform) !== "linux") return;
13554 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
13555 const fileSystem = yield* FileSystem.FileSystem;
13556 const idAllocator = yield* IdAllocator.IdAllocatorV2;
13557 const path = yield* Path.Path;
13558 const selfInvocation = yield* resolveSelfInvocation();
13559 const mockAgentPath = yield* path.fromFileUrl(
13560 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
13561 );
13562 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
13563 const commandPidPath = yield* fileSystem.makeTempFileScoped({
13564 prefix: "t3-acp-direct-stop-command-",
13565 });
13566 yield* Effect.addFinalizer(() =>
13567 Effect.sync(() => cleanupPublishedDetachedFixture(commandPidPath)),
13568 );
13569 let cancelCalled = false;
13570 const instanceId = ProviderInstanceId.make("acp-test");
13571 const adapter = yield* makeAcpAdapterV2({
13572 instanceId,
13573 flavor: {
13574 driver: ACP_TEST_DRIVER,
13575 capabilities: AcpProviderCapabilitiesV2,
13576 enablePostSettleContinuation: true,
13577 extractBackgroundTaskId: (toolCall) =>
13578 toolCall.toolCallId === "tool-call-running-1" ? "task-running-1" : undefined,
13579 extractBackgroundTaskCompletion: (toolCall) =>
13580 toolCall.toolCallId === "tool-call-output-1"
13581 ? [{ taskId: "task-running-1", status: "running", appendOutput: "" }]
13582 : [],
13583 interruptPromptOnCancel: true,
13584 restartRuntimeAfterInterrupt: true,
13585 terminateRuntimeProcessGroupOnInterrupt: true,
13586 makeRuntime: makeMockRuntime({
13587 childProcessSpawner,
13588 mockAgentPath,
13589 ownDescendantProcessGroups: true,
13590 ownDetachedProcessGroup: true,
13591 environment: (runtimeOrdinal) =>
13592 runtimeOrdinal === 1
13593 ? {
13594 T3_ACP_EMIT_RUNNING_COMMAND_THEN_HANG: "1",
13595 T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL: "1",
13596 T3_ACP_RUNNING_COMMAND_PID_PATH: commandPidPath,
13597 T3_ACP_RUNNING_COMMAND_IGNORE_TERM: "1",
13598 T3_ACP_RUNNING_COMMAND_SEPARATE_SESSION: "1",
13599 }
13600 : {},
13601 protocolEvents,
13602 wrapCancel: () =>
13603 Effect.sync(() => {
13604 cancelCalled = true;
13605 }).pipe(Effect.andThen(Effect.never)),
13606 }),
13607 },
13608 selfInvocation,
13609 continuationRequests: { offer: () => Effect.void },
13610 });
13611 const threadId = ThreadId.make("thread-acp-direct-stop-running-command");
13612 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
13613 runtimeMode: "full-access",
13614 interactionMode: "default",
13615 cwd: process.cwd(),
13616 });
13617 const modelSelection = { instanceId, model: "default" } as const;
13618 const runtime = yield* adapter.openSession({
13619 threadId,
13620 providerSessionId: ProviderSessionId.make(
13621 "provider-session-acp-direct-stop-running-command",
13622 ),
13623 modelSelection,
13624 runtimePolicy,
13625 });
13626 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
13627 yield* runtime.events.pipe(
13628 Stream.runForEach((event) => Queue.offer(events, event)),
13629 Effect.forkScoped,
13630 );
13631 const providerThread = yield* runtime.ensureThread({
13632 threadId,
13633 modelSelection,
13634 runtimePolicy,
13635 });
13636 const now = yield* DateTime.now;
13637 yield* runtime
13638 .startTurn(
13639 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }),
13640 )
13641 .pipe(Effect.forkScoped);
13642
13643 let runningMonitorSeen = false;
13644 let runningTaskOutputSeen = false;
13645 while (!runningMonitorSeen || !runningTaskOutputSeen) {
13646 const event = yield* Queue.take(events);
13647 if (
13648 event.type === "turn_item.updated" &&
13649 (event.turnItem.status === "running" || event.turnItem.status === "pending")
13650 ) {
13651 if (event.turnItem.nativeItemRef?.nativeId === "tool-call-running-1") {
13652 runningMonitorSeen = true;
13653 }
13654 if (event.turnItem.nativeItemRef?.nativeId === "tool-call-output-1") {
13655 runningTaskOutputSeen = true;
13656 }
13657 }
13658 }
13659 const [commandLauncherPid, commandRootPid, commandSleepPid] = Option.getOrThrow(
13660 yield* waitForPublishedProcessIds(fileSystem, commandPidPath, 3),
13661 );
13662 assert.isTrue(
13663 Option.isSome(
13664 yield* waitForProcesses([commandLauncherPid!, commandRootPid!, commandSleepPid!]),
13665 ),
13666 "declared direct Stop launcher, Bash, and sleep PIDs must become live",
13667 );
13668
13669 const firstProviderTurnId = idAllocator.derive.providerTurn({
13670 driver: ACP_TEST_DRIVER,
13671 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
13672 });
13673 yield* Queue.takeAll(events);
13674 const interruptFiber = yield* runtime
13675 .interruptTurn({
13676 providerThread,
13677 providerTurnId: firstProviderTurnId,
13678 requestRuntimeRestart: true,
13679 })
13680 .pipe(Effect.forkScoped);
13681 const interruptCompleted = yield* Fiber.join(interruptFiber).pipe(
13682 Effect.timeoutOption("3 seconds"),
13683 );
13684 assert.isTrue(Option.isSome(interruptCompleted), "hung ACP cancel must not block teardown");
13685 assert.isFalse(cancelCalled, "hard process-group teardown must skip ACP cancel");
13686 yield* Effect.sleep("250 millis");
13687 assert.isFalse(processExists(commandLauncherPid!));
13688 assert.isFalse(processExists(commandRootPid!));
13689 assert.isFalse(processExists(commandSleepPid!));
13690
13691 let terminalStatus: string | null = null;
13692 let openToolTerminalStatus: string | null = null;
13693 let openToolInterruptedExitCode: number | undefined = 42;
13694 let lateAfterCancelSeen = false;
13695 let runningMonitorAfterInterrupt = false;
13696 while (terminalStatus === null || openToolTerminalStatus === null) {
13697 const event = yield* Queue.take(events);
13698 if (
13699 event.type === "turn_item.updated" &&
13700 event.turnItem.nativeItemRef?.nativeId === "tool-call-running-1" &&
13701 event.turnItem.status === "running"
13702 ) {
13703 runningMonitorAfterInterrupt = true;
13704 if (event.turnItem.type === "command_execution") {
13705 assert.equal(
13706 event.turnItem.exitCode,
13707 undefined,
13708 "mid-stream exit_code 0 must not project while the command is still running",
13709 );
13710 }
13711 }
13712 if (
13713 event.type === "turn_item.updated" &&
13714 event.turnItem.type === "command_execution" &&
13715 (event.turnItem.status === "failed" ||
13716 event.turnItem.status === "cancelled" ||
13717 event.turnItem.status === "interrupted" ||
13718 event.turnItem.status === "completed")
13719 ) {
13720 openToolTerminalStatus = event.turnItem.status;
13721 openToolInterruptedExitCode = event.turnItem.exitCode;
13722 }
13723 if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) {
13724 terminalStatus = event.status;
13725 }
13726 if (
13727 event.type === "message.updated" &&
13728 event.message.role === "assistant" &&
13729 event.message.text.includes("late after cancel")
13730 ) {
13731 lateAfterCancelSeen = true;
13732 }
13733 }
13734 assert.equal(terminalStatus, "interrupted");
13735 assert.equal(openToolTerminalStatus, "interrupted");
13736 assert.equal(
13737 openToolInterruptedExitCode,
13738 undefined,
13739 "interrupted commands must not retain a mid-stream exit_code 0",
13740 );
13741 assert.isFalse(runningMonitorAfterInterrupt);
13742 assert.isFalse(yield* runtime.hasPendingBackgroundWork!);
13743 assert.isFalse(
13744 lateAfterCancelSeen,
13745 "late post-Stop assistant text must not attach to the stopped run",
13746 );
13747
13748 // Give residual cancel-path updates a chance to mis-project if quarantine fails.
13749 yield* Effect.sleep("200 millis");
13750 yield* Effect.yieldNow;
13751 yield* Effect.yieldNow;
13752 let residual = yield* Queue.poll(events);
13753 while (Option.isSome(residual)) {
13754 const event = residual.value;
13755 if (
13756 event.type === "turn_item.updated" &&
13757 event.turnItem.nativeItemRef?.nativeId === "tool-call-running-1"
13758 ) {
13759 assert.notEqual(event.turnItem.status, "running");
13760 }
13761 if (
13762 event.type === "message.updated" &&
13763 event.message.role === "assistant" &&
13764 event.message.text.includes("late after cancel")
13765 ) {
13766 lateAfterCancelSeen = true;
13767 }
13768 residual = yield* Queue.poll(events);
13769 }
13770 assert.isFalse(lateAfterCancelSeen, "quarantine must drop residual stopped-run events");
13771
13772 const secondNow = yield* DateTime.now;
13773 yield* runtime.startTurn(
13774 makeTurnInput({
13775 threadId,
13776 providerThread,
13777 instanceId,
13778 runtimePolicy,
13779 now: secondNow,
13780 ordinal: 2,
13781 }),
13782 );
13783 const loadAfterRestart = yield* Stream.fromQueue(protocolEvents).pipe(
13784 Stream.filter(
13785 (event) =>
13786 event.direction === "outgoing" && rawProtocolMethod(event) === "session/resume",
13787 ),
13788 Stream.runHead,
13789 );
13790 assert.isTrue(
13791 Option.isSome(loadAfterRestart),
13792 "Direct Stop follow-up must respawn the ACP runtime",
13793 );
13794
13795 const secondProviderTurnId = idAllocator.derive.providerTurn({
13796 driver: ACP_TEST_DRIVER,
13797 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"),
13798 });
13799 let secondTerminal: string | null = null;
13800 let stoppedRunTextOnFollowUp = false;
13801 while (secondTerminal === null) {
13802 const event = yield* Queue.take(events);
13803 if (
13804 event.type === "message.updated" &&
13805 event.message.role === "assistant" &&
13806 event.message.text.includes("late after cancel")
13807 ) {
13808 stoppedRunTextOnFollowUp = true;
13809 }
13810 if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) {
13811 secondTerminal = event.status;
13812 }
13813 }
13814 assert.equal(secondTerminal, "completed");
13815 assert.isFalse(
13816 stoppedRunTextOnFollowUp,
13817 "stopped-run residual text must not attach to the follow-up run",
13818 );
13819 }).pipe(Effect.provide(layerTest), Effect.scoped),
13820 );
13821
13822 it.effect(
13823 "direct Stop on a deferred subagent hold terminalizes the subagent and does not carry it forward",
13824 () =>
13825 Effect.gen(function* () {
13826 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
13827 const idAllocator = yield* IdAllocator.IdAllocatorV2;
13828 const path = yield* Path.Path;
13829 const selfInvocation = yield* resolveSelfInvocation();
13830 const mockAgentPath = yield* path.fromFileUrl(
13831 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
13832 );
13833 const instanceId = ProviderInstanceId.make("acp-test");
13834 let subagentPhase: "spawn" | "complete" = "spawn";
13835 const promptSettled = yield* Deferred.make<void>();
13836 const adapter = yield* makeAcpAdapterV2({
13837 testHooks: {
13838 afterPromptSettledWithBackgroundWork: () =>
13839 Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid),
13840 },
13841 instanceId,
13842 flavor: {
13843 driver: ACP_TEST_DRIVER,
13844 capabilities: AcpProviderCapabilitiesV2,
13845 deferFinalizeForBackgroundWork: true,
13846 restartRuntimeAfterInterrupt: true,
13847 extractSubagentUpdate: (toolCall) =>
13848 toolCall.toolCallId !== "tool-call-generic-1"
13849 ? undefined
13850 : subagentPhase === "spawn"
13851 ? {
13852 nativeTaskId: "task-generic-1",
13853 prompt: "background subagent",
13854 title: "background subagent",
13855 model: null,
13856 status: "running",
13857 childSessionId: null,
13858 result: null,
13859 }
13860 : {
13861 nativeTaskId: "task-generic-1",
13862 prompt: "",
13863 title: null,
13864 model: null,
13865 status: "completed",
13866 childSessionId: null,
13867 result: "SUB_DONE",
13868 },
13869 makeRuntime: makeMockRuntime({
13870 childProcessSpawner,
13871 mockAgentPath,
13872 environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" },
13873 }),
13874 },
13875 selfInvocation,
13876 });
13877 const threadId = ThreadId.make("thread-acp-direct-stop-subagent-hold");
13878 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
13879 runtimeMode: "full-access",
13880 interactionMode: "default",
13881 cwd: process.cwd(),
13882 });
13883 const modelSelection = { instanceId, model: "default" } as const;
13884 const runtime = yield* adapter.openSession({
13885 threadId,
13886 providerSessionId: ProviderSessionId.make(
13887 "provider-session-acp-direct-stop-subagent-hold",
13888 ),
13889 modelSelection,
13890 runtimePolicy,
13891 });
13892 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
13893 yield* runtime.events.pipe(
13894 Stream.runForEach((event) => Queue.offer(events, event)),
13895 Effect.forkScoped,
13896 );
13897 const providerThread = yield* runtime.ensureThread({
13898 threadId,
13899 modelSelection,
13900 runtimePolicy,
13901 });
13902 const now = yield* DateTime.now;
13903 yield* runtime.startTurn(
13904 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
13905 );
13906 yield* Deferred.await(promptSettled);
13907
13908 const firstProviderTurnId = idAllocator.derive.providerTurn({
13909 driver: ACP_TEST_DRIVER,
13910 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
13911 });
13912 yield* runtime.interruptTurn({
13913 providerThread,
13914 providerTurnId: firstProviderTurnId,
13915 requestRuntimeRestart: true,
13916 });
13917
13918 let subagentStatus: string | null = null;
13919 let firstTerminalStatus: string | null = null;
13920 while (firstTerminalStatus === null) {
13921 const event = yield* Queue.take(events);
13922 if (event.type === "turn_item.updated" && event.turnItem.type === "subagent") {
13923 subagentStatus = event.turnItem.status;
13924 }
13925 if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) {
13926 firstTerminalStatus = event.status;
13927 }
13928 }
13929 assert.equal(firstTerminalStatus, "interrupted");
13930 assert.equal(subagentStatus, "interrupted");
13931
13932 subagentPhase = "complete";
13933 const secondNow = yield* DateTime.now;
13934 yield* runtime.startTurn(
13935 makeTurnInput({
13936 threadId,
13937 providerThread,
13938 instanceId,
13939 runtimePolicy,
13940 now: secondNow,
13941 ordinal: 2,
13942 }),
13943 );
13944 const secondProviderTurnId = idAllocator.derive.providerTurn({
13945 driver: ACP_TEST_DRIVER,
13946 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"),
13947 });
13948 let carriedCompleted = false;
13949 let secondTerminalStatus: string | null = null;
13950 while (secondTerminalStatus === null) {
13951 const event = yield* Queue.take(events);
13952 if (
13953 event.type === "turn_item.updated" &&
13954 event.turnItem.type === "subagent" &&
13955 event.turnItem.status === "completed"
13956 ) {
13957 carriedCompleted = true;
13958 }
13959 if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) {
13960 secondTerminalStatus = event.status;
13961 }
13962 }
13963 assert.equal(secondTerminalStatus, "completed");
13964 assert.isFalse(
13965 carriedCompleted,
13966 "Direct Stop must not carry a stopped subagent into the follow-up run",
13967 );
13968 }).pipe(Effect.provide(layerTest), Effect.scoped),
13969 );
13970
13971 it.live("restart_active terminates native work and reloads a clean runtime", () =>
13972 Effect.gen(function* () {
13973 if ((yield* HostProcess.Platform) !== "linux") return;
13974 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
13975 const fileSystem = yield* FileSystem.FileSystem;
13976 const idAllocator = yield* IdAllocator.IdAllocatorV2;
13977 const path = yield* Path.Path;
13978 const selfInvocation = yield* resolveSelfInvocation();
13979 const mockAgentPath = yield* path.fromFileUrl(
13980 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
13981 );
13982 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
13983 const commandPidPath = yield* fileSystem.makeTempFileScoped({
13984 prefix: "t3-acp-restart-active-command-",
13985 });
13986 yield* Effect.addFinalizer(() =>
13987 Effect.sync(() => cleanupPublishedDetachedFixture(commandPidPath)),
13988 );
13989 const instanceId = ProviderInstanceId.make("acp-test");
13990 const adapter = yield* makeAcpAdapterV2({
13991 instanceId,
13992 flavor: {
13993 driver: ACP_TEST_DRIVER,
13994 capabilities: AcpProviderCapabilitiesV2,
13995 interruptPromptOnCancel: true,
13996 restartRuntimeAfterInterrupt: true,
13997 restartRuntimeOnEveryInterrupt: true,
13998 terminateRuntimeProcessGroupOnInterrupt: true,
13999 makeRuntime: makeMockRuntime({
14000 childProcessSpawner,
14001 mockAgentPath,
14002 ownDescendantProcessGroups: true,
14003 ownDetachedProcessGroup: true,
14004 processGroupTerminationGrace: 0,
14005 environment: (runtimeOrdinal) =>
14006 runtimeOrdinal === 1
14007 ? {
14008 T3_ACP_EXIT_ON_CANCEL: "1",
14009 T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL: "1",
14010 T3_ACP_EMIT_RUNNING_COMMAND_THEN_HANG: "1",
14011 T3_ACP_RUNNING_COMMAND_PID_PATH: commandPidPath,
14012 T3_ACP_RUNNING_COMMAND_SEPARATE_SESSION: "1",
14013 }
14014 : {},
14015 protocolEvents,
14016 wrapCancel: (cancel) => cancel.pipe(Effect.andThen(Effect.sleep("250 millis"))),
14017 }),
14018 },
14019 selfInvocation,
14020 });
14021 const threadId = ThreadId.make("thread-acp-restart-active-in-process");
14022 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
14023 runtimeMode: "full-access",
14024 interactionMode: "default",
14025 cwd: process.cwd(),
14026 });
14027 const modelSelection = { instanceId, model: "default" } as const;
14028 const runtime = yield* adapter.openSession({
14029 threadId,
14030 providerSessionId: ProviderSessionId.make("provider-session-acp-restart-active-in-process"),
14031 modelSelection,
14032 runtimePolicy,
14033 });
14034 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
14035 yield* runtime.events.pipe(
14036 Stream.runForEach((event) => Queue.offer(events, event)),
14037 Effect.forkScoped,
14038 );
14039 const providerThread = yield* runtime.ensureThread({
14040 threadId,
14041 modelSelection,
14042 runtimePolicy,
14043 });
14044 const now = yield* DateTime.now;
14045 yield* runtime
14046 .startTurn(
14047 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }),
14048 )
14049 .pipe(Effect.forkScoped);
14050
14051 let runningToolSeen = false;
14052 while (!runningToolSeen) {
14053 const event = yield* Queue.take(events);
14054 if (
14055 event.type === "turn_item.updated" &&
14056 event.turnItem.type === "command_execution" &&
14057 (event.turnItem.status === "running" || event.turnItem.status === "pending")
14058 ) {
14059 runningToolSeen = true;
14060 }
14061 }
14062 const [commandLauncherPid, commandRootPid, commandSleepPid] = Option.getOrThrow(
14063 yield* waitForPublishedProcessIds(fileSystem, commandPidPath, 3),
14064 );
14065 assert.isTrue(
14066 Option.isSome(
14067 yield* waitForProcesses([commandLauncherPid!, commandRootPid!, commandSleepPid!]),
14068 ),
14069 "declared restart_active launcher, Bash, and sleep PIDs must become live",
14070 );
14071
14072 const firstProviderTurnId = idAllocator.derive.providerTurn({
14073 driver: ACP_TEST_DRIVER,
14074 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"),
14075 });
14076 // restart_active path: interrupt without requestRuntimeRestart.
14077 const interruptFiber = yield* runtime
14078 .interruptTurn({
14079 providerThread,
14080 providerTurnId: firstProviderTurnId,
14081 })
14082 .pipe(Effect.forkScoped);
14083 yield* Fiber.join(interruptFiber);
14084 yield* Effect.sleep("250 millis");
14085 assert.isFalse(processExists(commandLauncherPid!));
14086 assert.isFalse(processExists(commandRootPid!));
14087 assert.isFalse(processExists(commandSleepPid!));
14088
14089 let firstTerminal: string | null = null;
14090 while (firstTerminal === null) {
14091 const event = yield* Queue.take(events);
14092 if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) {
14093 firstTerminal = event.status;
14094 }
14095 }
14096 assert.equal(firstTerminal, "interrupted");
14097
14098 yield* Queue.takeAll(protocolEvents);
14099 yield* runtime
14100 .startTurn(
14101 makeTurnInput({
14102 threadId,
14103 providerThread,
14104 instanceId,
14105 runtimePolicy,
14106 now,
14107 ordinal: 2,
14108 }),
14109 )
14110 .pipe(Effect.forkScoped);
14111 let loadSeen = false;
14112 let promptSeen = false;
14113 while (!loadSeen || !promptSeen) {
14114 const event = yield* Queue.take(protocolEvents);
14115 if (event.direction !== "outgoing") continue;
14116 const method = rawProtocolMethod(event);
14117 loadSeen ||= method === "session/resume";
14118 promptSeen ||= method === "session/prompt";
14119 }
14120 assert.isTrue(loadSeen, "restart_active must replay session/resume on a new ACP process");
14121 assert.isTrue(promptSeen, "replacement prompt must start after reload");
14122 const secondProviderTurnId = idAllocator.derive.providerTurn({
14123 driver: ACP_TEST_DRIVER,
14124 nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"),
14125 });
14126 let staleEventSeen = false;
14127 let secondTerminal: string | null = null;
14128 while (secondTerminal === null) {
14129 const event = yield* Queue.take(events);
14130 if (
14131 event.type === "turn_item.updated" &&
14132 event.turnItem.nativeItemRef?.nativeId === "tool-call-running-1"
14133 ) {
14134 staleEventSeen = true;
14135 }
14136 if (
14137 event.type === "message.updated" &&
14138 event.message.role === "assistant" &&
14139 event.message.text.includes("late after cancel")
14140 ) {
14141 staleEventSeen = true;
14142 }
14143 if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) {
14144 secondTerminal = event.status;
14145 }
14146 }
14147 assert.equal(secondTerminal, "completed");
14148 assert.isFalse(staleEventSeen, "interrupted runtime events must not attach to attempt 2");
14149 }).pipe(Effect.provide(layerTest), Effect.scoped),
14150 );
14151});
14152
14153describe("acpPostSettleWakeEvidence", () => {
14154 const sessionId = "session-wake";
14155
14156 it("accepts assistant text and tool updates as wake evidence", () => {
14157 assert.isTrue(
14158 acpPostSettleWakeEvidence({
14159 sessionId,
14160 update: {
14161 sessionUpdate: "agent_message_chunk",
14162 content: { type: "text", text: "Subagent finished. SUBAGENT_DONE" },
14163 },
14164 }),
14165 );
14166 assert.isTrue(
14167 acpPostSettleWakeEvidence({
14168 sessionId,
14169 update: {
14170 sessionUpdate: "tool_call",
14171 toolCallId: "call-1",
14172 title: "get_command_or_subagent_output",
14173 status: "pending",
14174 kind: "other",
14175 content: [],
14176 locations: [],
14177 rawInput: {},
14178 },
14179 }),
14180 );
14181 });
14182
14183 it("rejects monitor end chatter and background mutations", () => {
14184 assert.isFalse(
14185 acpPostSettleWakeEvidence({
14186 sessionId,
14187 update: {
14188 sessionUpdate: "agent_message_chunk",
14189 content: {
14190 type: "text",
14191 text: 'Monitor "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" ended',
14192 },
14193 },
14194 }),
14195 );
14196 assert.isFalse(
14197 acpPostSettleWakeEvidence(
14198 {
14199 sessionId,
14200 update: {
14201 sessionUpdate: "user_message_chunk",
14202 content: { type: "text", text: "task-1 completed" },
14203 },
14204 },
14205 {
14206 extractBackgroundToolMutation: () => [
14207 {
14208 taskId: "task-1",
14209 status: "completed",
14210 appendOutput: "",
14211 },
14212 ],
14213 },
14214 ),
14215 );
14216 });
14217});
14218
14219describe("acpPostSettleContinuationOfferEvidence", () => {
14220 const sessionId = "session-wake-offer";
14221
14222 it("offers on assistant text and terminal tool status", () => {
14223 assert.isTrue(
14224 acpPostSettleContinuationOfferEvidence({
14225 sessionId,
14226 update: {
14227 sessionUpdate: "agent_message_chunk",
14228 content: { type: "text", text: "Background shell finished." },
14229 },
14230 }),
14231 );
14232 assert.isTrue(
14233 acpPostSettleContinuationOfferEvidence({
14234 sessionId,
14235 update: {
14236 sessionUpdate: "tool_call_update",
14237 toolCallId: "call-1",
14238 title: "run_terminal_command",
14239 status: "completed",
14240 kind: "other",
14241 content: [],
14242 locations: [],
14243 rawInput: {},
14244 },
14245 }),
14246 );
14247 assert.isTrue(
14248 acpPostSettleContinuationOfferEvidence({
14249 sessionId,
14250 update: {
14251 sessionUpdate: "tool_call_update",
14252 toolCallId: "call-2",
14253 title: "run_terminal_command",
14254 status: "failed",
14255 kind: "other",
14256 content: [],
14257 locations: [],
14258 rawInput: {},
14259 },
14260 }),
14261 );
14262 });
14263
14264 it("does not offer on thought-only chunks", () => {
14265 assert.isFalse(
14266 acpPostSettleContinuationOfferEvidence({
14267 sessionId,
14268 update: {
14269 sessionUpdate: "agent_thought_chunk",
14270 content: { type: "text", text: "Still reasoning about the monitor output…" },
14271 },
14272 }),
14273 );
14274 // Thoughts may still be wake evidence for buffering once a real offer opens.
14275 assert.isTrue(
14276 acpPostSettleWakeEvidence({
14277 sessionId,
14278 update: {
14279 sessionUpdate: "agent_thought_chunk",
14280 content: { type: "text", text: "Still reasoning about the monitor output…" },
14281 },
14282 }),
14283 );
14284 });
14285
14286 it("does not offer on whitespace-only assistant chunks", () => {
14287 const notification = {
14288 sessionId,
14289 update: {
14290 sessionUpdate: "agent_message_chunk" as const,
14291 content: { type: "text" as const, text: " \n\t " },
14292 },
14293 };
14294 assert.isFalse(acpPostSettleWakeEvidence(notification));
14295 assert.isFalse(acpPostSettleContinuationOfferEvidence(notification));
14296 });
14297
14298 it("buffers in-progress tool updates without offering", () => {
14299 assert.isTrue(
14300 acpPostSettleWakeEvidence({
14301 sessionId,
14302 update: {
14303 sessionUpdate: "tool_call_update",
14304 toolCallId: "call-1",
14305 title: "run_terminal_command",
14306 status: "in_progress",
14307 kind: "other",
14308 content: [],
14309 locations: [],
14310 rawInput: {},
14311 },
14312 }),
14313 );
14314 assert.isFalse(
14315 acpPostSettleContinuationOfferEvidence({
14316 sessionId,
14317 update: {
14318 sessionUpdate: "tool_call_update",
14319 toolCallId: "call-1",
14320 title: "run_terminal_command",
14321 status: "in_progress",
14322 kind: "other",
14323 content: [],
14324 locations: [],
14325 rawInput: {},
14326 },
14327 }),
14328 );
14329 assert.isFalse(
14330 acpPostSettleContinuationOfferEvidence({
14331 sessionId,
14332 update: {
14333 sessionUpdate: "tool_call",
14334 toolCallId: "call-1",
14335 title: "run_terminal_command",
14336 status: "pending",
14337 kind: "other",
14338 content: [],
14339 locations: [],
14340 rawInput: {},
14341 },
14342 }),
14343 );
14344 });
14345
14346 it("does not offer filtered monitor chatter", () => {
14347 assert.isFalse(
14348 acpPostSettleContinuationOfferEvidence({
14349 sessionId,
14350 update: {
14351 sessionUpdate: "agent_message_chunk",
14352 content: {
14353 type: "text",
14354 text: 'Monitor "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" ended',
14355 },
14356 },
14357 }),
14358 );
14359 });
14360
14361 it("does not offer on a normalized monitor start ACK despite raw completed status", () => {
14362 const monitorStartAck = {
14363 sessionId,
14364 update: {
14365 sessionUpdate: "tool_call_update",
14366 toolCallId: "call-monitor",
14367 title: "Tool",
14368 status: "completed",
14369 kind: "other",
14370 content: [],
14371 locations: [],
14372 rawInput: { variant: "Monitor", description: "stream test" },
14373 rawOutput: {
14374 type: "Monitor",
14375 taskId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
14376 timeoutMs: 60000,
14377 },
14378 },
14379 } as const;
14380 // Raw frame looks terminal; the Grok flavor knows it is a running monitor.
14381 assert.isTrue(acpPostSettleContinuationOfferEvidence(monitorStartAck));
14382 assert.isFalse(
14383 acpPostSettleContinuationOfferEvidence(monitorStartAck, {
14384 normalizeToolCall: normalizeXAiAcpToolCallState,
14385 }),
14386 );
14387 });
14388});
14389
14390describe("acpPostSettleWakeShouldBuffer", () => {
14391 const sessionId = "session-wake-buffer";
14392
14393 it("drops agent progress chatter while background work is running", () => {
14394 for (const sessionUpdate of ["agent_message_chunk", "agent_thought_chunk"] as const) {
14395 assert.isFalse(
14396 acpPostSettleWakeShouldBuffer(
14397 {
14398 sessionId,
14399 update: {
14400 sessionUpdate,
14401 content: { type: "text", text: "Still running." },
14402 },
14403 },
14404 true,
14405 ),
14406 );
14407 }
14408 });
14409
14410 it("retains tool state while running and agent output after completion", () => {
14411 const agentMessage = {
14412 sessionId,
14413 update: {
14414 sessionUpdate: "agent_message_chunk",
14415 content: { type: "text", text: "Monitor finished successfully." },
14416 },
14417 } as const;
14418 const toolUpdate = {
14419 sessionId,
14420 update: {
14421 sessionUpdate: "tool_call_update",
14422 toolCallId: "call-monitor",
14423 title: "monitor",
14424 status: "in_progress",
14425 kind: "other",
14426 content: [],
14427 locations: [],
14428 rawInput: {},
14429 },
14430 } as const;
14431
14432 assert.isTrue(acpPostSettleWakeShouldBuffer(toolUpdate, true));
14433 assert.isTrue(acpPostSettleWakeShouldBuffer(agentMessage, false));
14434 });
14435});
14436
14437describe("acpIsAppOwnedWakeTurn", () => {
14438 it("recognizes only the orchestrator-injected app-owned wake", () => {
14439 // Delegated-child wake: must preserve this session's own wake frames.
14440 assert.isTrue(acpIsAppOwnedWakeTurn({ createdBy: "agent", creationSource: "server" }));
14441 // Provider-native continuation: handled by the continuation branch instead.
14442 assert.isFalse(acpIsAppOwnedWakeTurn({ createdBy: "agent", creationSource: "provider" }));
14443 // Real user turns still clear stale wake residue.
14444 assert.isFalse(acpIsAppOwnedWakeTurn({ createdBy: "user", creationSource: "web" }));
14445 assert.isFalse(acpIsAppOwnedWakeTurn({ createdBy: "user", creationSource: "mcp" }));
14446 // A user-authored message never counts, whatever its surface.
14447 assert.isFalse(acpIsAppOwnedWakeTurn({ createdBy: "user", creationSource: "server" }));
14448 });
14449});
14450
14451describe("acpPostSettleMonitorPromptShouldSuppress", () => {
14452 it("suppresses running monitor prompts but not terminal notices", () => {
14453 assert.isTrue(
14454 acpPostSettleMonitorPromptShouldSuppress({ taskId: "task-active", status: "running" }),
14455 );
14456 assert.isFalse(
14457 acpPostSettleMonitorPromptShouldSuppress({ taskId: "task-ended", status: "completed" }),
14458 );
14459 assert.isFalse(
14460 acpPostSettleMonitorPromptShouldSuppress({ taskId: "task-failed", status: "failed" }),
14461 );
14462 });
14463});