| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; |
| 2 | import { |
| 3 | extractCopilotSubagentUpdate, |
| 4 | makeCopilotSubagentRouting, |
| 5 | normalizeDevinSessionUpdate, |
| 6 | normalizeDevinToolCall, |
| 7 | extractDevinSubagentUpdate, |
| 8 | } from "@t3tools/provider-acp-registry/testing"; |
| 9 | // @effect-diagnostics nodeBuiltinImport:off |
| 10 | import * as NodeChildProcess from "node:child_process"; |
| 11 | import * as NodeFS from "node:fs"; |
| 12 | import * as NodePath from "node:path"; |
| 13 | |
| 14 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 15 | import { assert, describe, it } from "@effect/vitest"; |
| 16 | import { |
| 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"; |
| 33 | import { GrokSettings } from "@t3tools/provider-grok/settings"; |
| 34 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 35 | import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; |
| 36 | import * as DateTime from "effect/DateTime"; |
| 37 | import * as Deferred from "effect/Deferred"; |
| 38 | import type * as Duration from "effect/Duration"; |
| 39 | import * as Cause from "effect/Cause"; |
| 40 | import * as Clock from "effect/Clock"; |
| 41 | import * as Effect from "effect/Effect"; |
| 42 | import * as Exit from "effect/Exit"; |
| 43 | import * as FileSystem from "effect/FileSystem"; |
| 44 | import * as Fiber from "effect/Fiber"; |
| 45 | import * as Layer from "effect/Layer"; |
| 46 | import * as Option from "effect/Option"; |
| 47 | import * as Path from "effect/Path"; |
| 48 | import * as PlatformError from "effect/PlatformError"; |
| 49 | import * as Queue from "effect/Queue"; |
| 50 | import * as Schema from "effect/Schema"; |
| 51 | import * as Sink from "effect/Sink"; |
| 52 | import * as Stream from "effect/Stream"; |
| 53 | import * as Scope from "effect/Scope"; |
| 54 | import * as TestClock from "effect/testing/TestClock"; |
| 55 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 56 | import * as EffectAcpErrors from "effect-acp/errors"; |
| 57 | import type * as EffectAcpProtocol from "effect-acp/protocol"; |
| 58 | import type * as EffectAcpSchema from "effect-acp/compat"; |
| 59 | |
| 60 | import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; |
| 61 | import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; |
| 62 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 63 | import { |
| 64 | ACP_EMPTY_OPTION_VALUE, |
| 65 | ACP_SESSION_MODE_OPTION_ID, |
| 66 | acpProviderOptionDescriptors, |
| 67 | } from "@t3tools/provider-acp/server/sessionConfig"; |
| 68 | import { |
| 69 | extractXAiAcpSubagentEndNotice, |
| 70 | extractXAiAcpSubagentUpdate, |
| 71 | makeXAiPromptCompletionRuntime, |
| 72 | normalizeXAiAcpToolCallState, |
| 73 | registerXAiBackgroundTaskTracking, |
| 74 | } from "@t3tools/provider-grok/testing"; |
| 75 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 76 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; |
| 77 | import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; |
| 78 | import { |
| 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 | |
| 98 | import { makeGrokAdapterV2 } from "@t3tools/provider-grok/testing"; |
| 99 | import { |
| 100 | acpRegistryPromptFailure, |
| 101 | registerMistralVibeAcpExtensions, |
| 102 | } from "@t3tools/provider-acp-registry/testing"; |
| 103 | |
| 104 | const DEFAULT_GROK_SETTINGS = Schema.decodeSync(GrokSettings)({}); |
| 105 | |
| 106 | const layerHost = TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)); |
| 107 | |
| 108 | const layerTest = Layer.mergeAll( |
| 109 | NodeServices.layer, |
| 110 | IdAllocator.layer, |
| 111 | McpProviderSessions.layer, |
| 112 | ScriptProgress.layerUnavailable, |
| 113 | layerHost, |
| 114 | ); |
| 115 | const ACP_TEST_DRIVER = ProviderDriverKind.make("acp-test"); |
| 116 | const decodeUnknownJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); |
| 117 | |
| 118 | describe("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 | |
| 136 | describe("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 | |
| 187 | describe("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 | |
| 234 | const taskkillPlatformError = (method: string) => |
| 235 | PlatformError.systemError({ _tag: "Unknown", module: "taskkill-test", method }); |
| 236 | |
| 237 | function 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 | |
| 275 | function processExists(pid: number): boolean { |
| 276 | try { |
| 277 | process.kill(pid, 0); |
| 278 | return true; |
| 279 | } catch { |
| 280 | return false; |
| 281 | } |
| 282 | } |
| 283 | |
| 284 | const 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 | |
| 291 | const 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 | |
| 298 | function 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 | |
| 313 | function 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 | |
| 361 | const 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 | |
| 380 | function 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 | |
| 483 | function 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 | |
| 497 | function 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 | |
| 512 | function 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 | |
| 520 | function 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 | |
| 535 | const 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 | |
| 549 | function 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 | |
| 613 | describe("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 | |
| 14153 | describe("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 | |
| 14219 | describe("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 | |
| 14390 | describe("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 | |
| 14437 | describe("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 | |
| 14451 | describe("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 | }); |