| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; |
| 2 | import * as NodeOS from "node:os"; |
| 3 | |
| 4 | import { historyResponseItems } from "@t3tools/provider-core/server/handoffBudget"; |
| 5 | import { |
| 6 | makeProviderTextDeltaCoalescer, |
| 7 | type ProviderTextDeltaUpdate, |
| 8 | } from "./ProviderTextDeltaCoalescer.ts"; |
| 9 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 10 | import { |
| 11 | CommandId, |
| 12 | CheckpointId, |
| 13 | CodexSettings, |
| 14 | EnvironmentId, |
| 15 | MessageId, |
| 16 | type ModelSelection, |
| 17 | NodeId, |
| 18 | type OrchestrationV2AppThread, |
| 19 | type OrchestrationV2ProviderThread, |
| 20 | type OrchestrationV2ProviderTurn, |
| 21 | type OrchestrationV2TurnItem, |
| 22 | ProjectId, |
| 23 | type ProviderApprovalDecision, |
| 24 | ProviderInstanceId, |
| 25 | ProviderSessionId, |
| 26 | ProviderThreadId, |
| 27 | ProviderTurnId, |
| 28 | RunAttemptId, |
| 29 | RunId, |
| 30 | ThreadId, |
| 31 | TurnItemId, |
| 32 | } from "@t3tools/contracts"; |
| 33 | import { assert, describe, it } from "@effect/vitest"; |
| 34 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 35 | import { SpawnExecutableResolution } from "@t3tools/shared/shell"; |
| 36 | import * as CodexClient from "effect-codex-app-server/client"; |
| 37 | import * as CodexError from "effect-codex-app-server/errors"; |
| 38 | import * as CodexReplay from "effect-codex-app-server/replay"; |
| 39 | import * as DateTime from "effect/DateTime"; |
| 40 | import * as Deferred from "effect/Deferred"; |
| 41 | import * as Crypto from "effect/Crypto"; |
| 42 | import * as Effect from "effect/Effect"; |
| 43 | import * as Predicate from "effect/Predicate"; |
| 44 | import * as FileSystem from "effect/FileSystem"; |
| 45 | import { MCP_APP_OUTPUT_KEY, readMcpAppReference } from "@t3tools/shared/mcpApp"; |
| 46 | import { resolveAttachmentPathById } from "../../attachmentStore.ts"; |
| 47 | import * as Fiber from "effect/Fiber"; |
| 48 | import * as Layer from "effect/Layer"; |
| 49 | import * as Path from "effect/Path"; |
| 50 | import * as PlatformError from "effect/PlatformError"; |
| 51 | import * as Ref from "effect/Ref"; |
| 52 | import * as Schema from "effect/Schema"; |
| 53 | import * as Stream from "effect/Stream"; |
| 54 | import { TestClock } from "effect/testing"; |
| 55 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; |
| 56 | |
| 57 | import packageJson from "../../../package.json" with { type: "json" }; |
| 58 | import * as ServerConfig from "../../config.ts"; |
| 59 | import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; |
| 60 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; |
| 61 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 62 | import * as EffectWorker from "../EffectWorker.ts"; |
| 63 | import * as Orchestrator from "../Orchestrator.ts"; |
| 64 | import * as ProviderReplayHarness from "../testkit/ProviderReplayHarness.ts"; |
| 65 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; |
| 66 | import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; |
| 67 | import * as CodexAdapterV2 from "./CodexAdapterV2.ts"; |
| 68 | import { makeReplayServerConfig, withCodexReplayChildMetadata } from "./CodexAdapterV2.testkit.ts"; |
| 69 | import * as CodexAdapterV2Testkit from "./CodexAdapterV2.testkit.ts"; |
| 70 | |
| 71 | const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); |
| 72 | const replayTranscriptJson = Schema.fromJsonString(CodexReplay.CodexAppServerReplayTranscript); |
| 73 | const encodeReplayTranscriptJson = Schema.encodeEffect(replayTranscriptJson); |
| 74 | const decodeReplayTranscriptJson = Schema.decodeUnknownEffect(replayTranscriptJson); |
| 75 | const encodeStringJson = Schema.encodeEffect(Schema.fromJsonString(Schema.String)); |
| 76 | |
| 77 | describe("Codex context usage compatibility", () => { |
| 78 | const previous: ModelSelection = { |
| 79 | instanceId: ProviderInstanceId.make("codex"), |
| 80 | model: "gpt-6-astra", |
| 81 | }; |
| 82 | it("retains measured usage for reasoning-only changes in either direction", () => { |
| 83 | const low: ModelSelection = { ...previous, options: [{ id: "reasoningEffort", value: "low" }] }; |
| 84 | assert.isTrue(CodexAdapterV2.canReuseCodexContextUsage(previous, low)); |
| 85 | assert.isTrue(CodexAdapterV2.canReuseCodexContextUsage(low, previous)); |
| 86 | assert.isTrue( |
| 87 | CodexAdapterV2.canReuseCodexContextUsage(low, { |
| 88 | ...low, |
| 89 | options: [{ id: "reasoningEffort", value: "high" }], |
| 90 | }), |
| 91 | ); |
| 92 | }); |
| 93 | it("invalidates usage for model, instance, context-window and unknown option changes", () => { |
| 94 | for (const next of [ |
| 95 | { ...previous, model: "other-model" }, |
| 96 | { ...previous, instanceId: ProviderInstanceId.make("other-codex") }, |
| 97 | { ...previous, options: [{ id: "contextWindow", value: "32k" }] }, |
| 98 | { ...previous, options: [{ id: "customOption", value: "value" }] }, |
| 99 | ]) |
| 100 | assert.isFalse(CodexAdapterV2.canReuseCodexContextUsage(previous, next)); |
| 101 | }); |
| 102 | }); |
| 103 | |
| 104 | describe("CodexAdapterV2 file change approvals", () => { |
| 105 | it("uses nonblank reasons before sorted file operations and renamed paths", () => { |
| 106 | const fileChanges = { |
| 107 | "/tmp/removed.md": { type: "delete" as const, content: "gone" }, |
| 108 | "/tmp/added.ts": { type: "add" as const, content: "export {};" }, |
| 109 | "/tmp/moved.ts": { |
| 110 | type: "update" as const, |
| 111 | unified_diff: "@@", |
| 112 | move_path: "/tmp/renamed.ts", |
| 113 | }, |
| 114 | }; |
| 115 | assert.equal( |
| 116 | CodexAdapterV2.codexFileChangeApprovalPrompt({ |
| 117 | reason: " Update configuration. ", |
| 118 | fileChanges, |
| 119 | }), |
| 120 | "Update configuration.", |
| 121 | ); |
| 122 | assert.equal( |
| 123 | CodexAdapterV2.codexFileChangeApprovalPrompt({ reason: " ", fileChanges }), |
| 124 | "add /tmp/added.ts\nupdate /tmp/moved.ts -> /tmp/renamed.ts\ndelete /tmp/removed.md", |
| 125 | ); |
| 126 | }); |
| 127 | |
| 128 | it("falls back to a nonblank grant root and omits empty details", () => { |
| 129 | assert.equal( |
| 130 | CodexAdapterV2.codexFileChangeApprovalPrompt({ reason: " ", grantRoot: " /workspace " }), |
| 131 | "/workspace", |
| 132 | ); |
| 133 | assert.equal( |
| 134 | CodexAdapterV2.codexFileChangeApprovalPrompt({ fileChanges: {}, grantRoot: "/workspace" }), |
| 135 | "/workspace", |
| 136 | ); |
| 137 | assert.isUndefined( |
| 138 | CodexAdapterV2.codexFileChangeApprovalPrompt({ |
| 139 | reason: " ", |
| 140 | grantRoot: " ", |
| 141 | fileChanges: {}, |
| 142 | }), |
| 143 | ); |
| 144 | }); |
| 145 | |
| 146 | it("bounds large patch descriptions without losing the remaining count", () => { |
| 147 | const fileChanges = Object.fromEntries( |
| 148 | Array.from({ length: 25 }, (_, index) => [ |
| 149 | `/tmp/file-${String(index).padStart(2, "0")}.ts`, |
| 150 | { type: "add" as const, content: "" }, |
| 151 | ]), |
| 152 | ); |
| 153 | const detail = CodexAdapterV2.codexFileChangeApprovalPrompt({ fileChanges }); |
| 154 | assert.equal(detail?.split("\n").length, 21); |
| 155 | assert.isTrue(detail?.startsWith("add /tmp/file-00.ts") ?? false); |
| 156 | assert.isTrue(detail?.endsWith("+5 more") ?? false); |
| 157 | assert.notInclude(detail, "file-20.ts"); |
| 158 | }); |
| 159 | }); |
| 160 | |
| 161 | describe("CodexAdapterV2 context usage", () => { |
| 162 | it("uses the current context rather than cumulative processed tokens", () => { |
| 163 | const usage = CodexAdapterV2.codexProviderTurnTokenUsage( |
| 164 | { |
| 165 | total: { |
| 166 | totalTokens: 180_000, |
| 167 | inputTokens: 160_000, |
| 168 | cachedInputTokens: 20_000, |
| 169 | outputTokens: 20_000, |
| 170 | reasoningOutputTokens: 5_000, |
| 171 | }, |
| 172 | last: { |
| 173 | totalTokens: 50_000, |
| 174 | inputTokens: 45_000, |
| 175 | cachedInputTokens: 10_000, |
| 176 | outputTokens: 5_000, |
| 177 | reasoningOutputTokens: 1_000, |
| 178 | }, |
| 179 | modelContextWindow: 200_000, |
| 180 | }, |
| 181 | "2026-08-29T00:00:00.000Z", |
| 182 | ); |
| 183 | |
| 184 | assert.deepEqual(usage, { |
| 185 | usedTokens: 50_000, |
| 186 | maxTokens: 200_000, |
| 187 | inputTokens: 45_000, |
| 188 | cachedInputTokens: 10_000, |
| 189 | outputTokens: 5_000, |
| 190 | reasoningOutputTokens: 1_000, |
| 191 | updatedAt: "2026-08-29T00:00:00.000Z", |
| 192 | }); |
| 193 | }); |
| 194 | }); |
| 195 | |
| 196 | describe("CodexAdapterV2 assistant message streaming", () => { |
| 197 | it.effect("makes accumulated assistant text visible after the bounded flush interval", () => |
| 198 | Effect.gen(function* () { |
| 199 | const updates = yield* Ref.make< |
| 200 | ReadonlyArray<{ |
| 201 | readonly turnId: string; |
| 202 | readonly itemId: string; |
| 203 | readonly text: string; |
| 204 | readonly completed: boolean; |
| 205 | }> |
| 206 | >([]); |
| 207 | const coalescer = yield* makeProviderTextDeltaCoalescer({ |
| 208 | flushIntervalMs: 50, |
| 209 | emit: (update) => Ref.update(updates, (current) => [...current, update]), |
| 210 | }); |
| 211 | |
| 212 | yield* coalescer.append({ turnId: "turn-1", itemId: "message-1", delta: "partial" }); |
| 213 | assert.deepEqual(yield* Ref.get(updates), []); |
| 214 | yield* Effect.yieldNow; |
| 215 | yield* TestClock.adjust("50 millis"); |
| 216 | yield* Effect.yieldNow; |
| 217 | |
| 218 | assert.deepEqual(yield* Ref.get(updates), [ |
| 219 | { |
| 220 | turnId: "turn-1", |
| 221 | itemId: "message-1", |
| 222 | text: "partial", |
| 223 | completed: false, |
| 224 | }, |
| 225 | ]); |
| 226 | }), |
| 227 | ); |
| 228 | |
| 229 | it.effect("coalesces multiple token deltas into one assistant update per interval", () => |
| 230 | Effect.gen(function* () { |
| 231 | const updates = yield* Ref.make<ReadonlyArray<ProviderTextDeltaUpdate>>([]); |
| 232 | const coalescer = yield* makeProviderTextDeltaCoalescer({ |
| 233 | flushIntervalMs: 50, |
| 234 | emit: (update) => Ref.update(updates, (current) => [...current, update]), |
| 235 | }); |
| 236 | |
| 237 | yield* coalescer.append({ turnId: "turn-1", itemId: "message-1", delta: "one" }); |
| 238 | yield* coalescer.append({ turnId: "turn-1", itemId: "message-1", delta: " two" }); |
| 239 | yield* coalescer.append({ turnId: "turn-1", itemId: "message-1", delta: " three" }); |
| 240 | yield* Effect.yieldNow; |
| 241 | yield* TestClock.adjust("50 millis"); |
| 242 | yield* Effect.yieldNow; |
| 243 | |
| 244 | assert.deepEqual(yield* Ref.get(updates), [ |
| 245 | { |
| 246 | turnId: "turn-1", |
| 247 | itemId: "message-1", |
| 248 | text: "one two three", |
| 249 | completed: false, |
| 250 | }, |
| 251 | ]); |
| 252 | }), |
| 253 | ); |
| 254 | |
| 255 | it.effect("flushes buffered text synchronously before item and turn completion", () => |
| 256 | Effect.gen(function* () { |
| 257 | const updates = yield* Ref.make<ReadonlyArray<ProviderTextDeltaUpdate>>([]); |
| 258 | const coalescer = yield* makeProviderTextDeltaCoalescer({ |
| 259 | flushIntervalMs: 50, |
| 260 | emit: (update) => Ref.update(updates, (current) => [...current, update]), |
| 261 | }); |
| 262 | |
| 263 | yield* coalescer.append({ turnId: "turn-1", itemId: "message-1", delta: "item final" }); |
| 264 | const completedText = yield* coalescer.complete({ |
| 265 | turnId: "turn-1", |
| 266 | itemId: "message-1", |
| 267 | }); |
| 268 | yield* coalescer.append({ turnId: "turn-1", itemId: "message-2", delta: "turn final" }); |
| 269 | yield* coalescer.flushTurn("turn-1"); |
| 270 | |
| 271 | assert.equal(completedText, "item final"); |
| 272 | assert.deepEqual(yield* Ref.get(updates), [ |
| 273 | { turnId: "turn-1", itemId: "message-1", text: "item final", completed: true }, |
| 274 | { turnId: "turn-1", itemId: "message-2", text: "turn final", completed: true }, |
| 275 | ]); |
| 276 | yield* Effect.yieldNow; |
| 277 | yield* TestClock.adjust("50 millis"); |
| 278 | yield* Effect.yieldNow; |
| 279 | assert.equal((yield* Ref.get(updates)).length, 2); |
| 280 | }), |
| 281 | ); |
| 282 | |
| 283 | it.effect("retains buffered text until completion updates are emitted", () => |
| 284 | Effect.gen(function* () { |
| 285 | const updates = yield* Ref.make<ReadonlyArray<ProviderTextDeltaUpdate>>([]); |
| 286 | const failNext = yield* Ref.make(true); |
| 287 | const coalescer = yield* makeProviderTextDeltaCoalescer({ |
| 288 | flushIntervalMs: 50, |
| 289 | emit: (update) => |
| 290 | Ref.getAndSet(failNext, false).pipe( |
| 291 | Effect.flatMap((shouldFail) => |
| 292 | shouldFail |
| 293 | ? Effect.die("projection unavailable") |
| 294 | : Ref.update(updates, (current) => [...current, update]), |
| 295 | ), |
| 296 | ), |
| 297 | }); |
| 298 | |
| 299 | yield* coalescer.append({ turnId: "turn-1", itemId: "message-1", delta: "turn final" }); |
| 300 | const failedFlush = yield* coalescer.flushTurn("turn-1").pipe(Effect.exit); |
| 301 | assert.equal(failedFlush._tag, "Failure"); |
| 302 | yield* coalescer.flushTurn("turn-1"); |
| 303 | |
| 304 | yield* coalescer.append({ turnId: "turn-1", itemId: "message-2", delta: "item final" }); |
| 305 | yield* Ref.set(failNext, true); |
| 306 | const failedComplete = yield* coalescer |
| 307 | .complete({ turnId: "turn-1", itemId: "message-2" }) |
| 308 | .pipe(Effect.exit); |
| 309 | assert.equal(failedComplete._tag, "Failure"); |
| 310 | const completedText = yield* coalescer.complete({ |
| 311 | turnId: "turn-1", |
| 312 | itemId: "message-2", |
| 313 | }); |
| 314 | |
| 315 | assert.equal(completedText, "item final"); |
| 316 | assert.deepEqual(yield* Ref.get(updates), [ |
| 317 | { turnId: "turn-1", itemId: "message-1", text: "turn final", completed: true }, |
| 318 | { turnId: "turn-1", itemId: "message-2", text: "item final", completed: true }, |
| 319 | ]); |
| 320 | }), |
| 321 | ); |
| 322 | |
| 323 | it.effect("can discard an empty completion without emitting an assistant update", () => |
| 324 | Effect.gen(function* () { |
| 325 | const updates = yield* Ref.make<ReadonlyArray<ProviderTextDeltaUpdate>>([]); |
| 326 | const coalescer = yield* makeProviderTextDeltaCoalescer({ |
| 327 | flushIntervalMs: 50, |
| 328 | emit: (update) => Ref.update(updates, (current) => [...current, update]), |
| 329 | }); |
| 330 | |
| 331 | yield* coalescer.append({ turnId: "turn-1", itemId: "message-1", delta: "" }); |
| 332 | yield* Effect.yieldNow; |
| 333 | yield* TestClock.adjust("50 millis"); |
| 334 | yield* Effect.yieldNow; |
| 335 | const completedText = yield* coalescer.complete({ |
| 336 | turnId: "turn-1", |
| 337 | itemId: "message-1", |
| 338 | finalText: "", |
| 339 | emitEmpty: false, |
| 340 | }); |
| 341 | |
| 342 | assert.equal(completedText, ""); |
| 343 | assert.deepEqual(yield* Ref.get(updates), []); |
| 344 | |
| 345 | yield* coalescer.append({ turnId: "turn-1", itemId: "message-2", delta: "buffered" }); |
| 346 | assert.equal( |
| 347 | yield* coalescer.complete({ |
| 348 | turnId: "turn-1", |
| 349 | itemId: "message-2", |
| 350 | emitEmpty: false, |
| 351 | }), |
| 352 | "buffered", |
| 353 | ); |
| 354 | assert.deepEqual(yield* Ref.get(updates), [ |
| 355 | { |
| 356 | turnId: "turn-1", |
| 357 | itemId: "message-2", |
| 358 | text: "buffered", |
| 359 | completed: true, |
| 360 | }, |
| 361 | ]); |
| 362 | }), |
| 363 | ); |
| 364 | |
| 365 | it.effect("treats explicit empty final text as authoritative over buffered deltas", () => |
| 366 | Effect.gen(function* () { |
| 367 | const updates = yield* Ref.make<ReadonlyArray<ProviderTextDeltaUpdate>>([]); |
| 368 | const coalescer = yield* makeProviderTextDeltaCoalescer({ |
| 369 | flushIntervalMs: 50, |
| 370 | emit: (update) => Ref.update(updates, (current) => [...current, update]), |
| 371 | }); |
| 372 | |
| 373 | yield* coalescer.append({ |
| 374 | turnId: "turn-1", |
| 375 | itemId: "message-1", |
| 376 | delta: "stale buffered text", |
| 377 | }); |
| 378 | const completedText = yield* coalescer.complete({ |
| 379 | turnId: "turn-1", |
| 380 | itemId: "message-1", |
| 381 | finalText: "", |
| 382 | }); |
| 383 | |
| 384 | assert.equal(completedText, ""); |
| 385 | assert.deepEqual(yield* Ref.get(updates), [ |
| 386 | { |
| 387 | turnId: "turn-1", |
| 388 | itemId: "message-1", |
| 389 | text: "", |
| 390 | completed: true, |
| 391 | }, |
| 392 | ]); |
| 393 | }), |
| 394 | ); |
| 395 | }); |
| 396 | |
| 397 | describe("CodexAdapterV2 runtime policy", () => { |
| 398 | it.effect("derives concrete Codex turn policies from every T3 runtime mode", () => |
| 399 | Effect.gen(function* () { |
| 400 | const build = ( |
| 401 | runtimeMode: "approval-required" | "auto-accept-edits" | "auto" | "full-access", |
| 402 | ) => |
| 403 | CodexAdapterV2.buildCodexTurnStartParams({ |
| 404 | nativeThreadId: `native-${runtimeMode}`, |
| 405 | codexInput: [{ type: "text", text: "test" }], |
| 406 | runtimePolicy: { |
| 407 | runtimeMode, |
| 408 | interactionMode: "default", |
| 409 | cwd: null, |
| 410 | }, |
| 411 | modelSelection: { |
| 412 | instanceId: ProviderInstanceId.make("codex"), |
| 413 | model: "gpt-5.4", |
| 414 | }, |
| 415 | }); |
| 416 | |
| 417 | const approvalRequired = yield* build("approval-required"); |
| 418 | const autoAcceptEdits = yield* build("auto-accept-edits"); |
| 419 | const auto = yield* build("auto"); |
| 420 | const fullAccess = yield* build("full-access"); |
| 421 | |
| 422 | assert.equal(approvalRequired.approvalPolicy, "untrusted"); |
| 423 | assert.equal(approvalRequired.approvalsReviewer, "user"); |
| 424 | assert.equal(approvalRequired.sandboxPolicy?.type, "readOnly"); |
| 425 | assert.equal(autoAcceptEdits.approvalPolicy, "on-request"); |
| 426 | assert.equal(autoAcceptEdits.approvalsReviewer, "user"); |
| 427 | assert.equal(autoAcceptEdits.sandboxPolicy?.type, "workspaceWrite"); |
| 428 | assert.equal(auto.approvalPolicy, "on-request"); |
| 429 | assert.equal(auto.approvalsReviewer, "auto_review"); |
| 430 | assert.equal(auto.sandboxPolicy?.type, "workspaceWrite"); |
| 431 | assert.equal(fullAccess.approvalPolicy, "never"); |
| 432 | assert.equal(fullAccess.approvalsReviewer, "user"); |
| 433 | assert.equal(fullAccess.sandboxPolicy?.type, "dangerFullAccess"); |
| 434 | }), |
| 435 | ); |
| 436 | |
| 437 | it.effect("preserves explicit Codex turn policy overrides", () => |
| 438 | Effect.gen(function* () { |
| 439 | const params = yield* CodexAdapterV2.buildCodexTurnStartParams({ |
| 440 | nativeThreadId: "native-override", |
| 441 | codexInput: [{ type: "text", text: "test" }], |
| 442 | runtimePolicy: { |
| 443 | runtimeMode: "full-access", |
| 444 | interactionMode: "default", |
| 445 | cwd: null, |
| 446 | approvalPolicy: "on-request", |
| 447 | sandboxPolicy: { |
| 448 | type: "readOnly", |
| 449 | }, |
| 450 | }, |
| 451 | modelSelection: { |
| 452 | instanceId: ProviderInstanceId.make("codex"), |
| 453 | model: "gpt-5.4", |
| 454 | }, |
| 455 | }); |
| 456 | |
| 457 | assert.equal(params.approvalPolicy, "on-request"); |
| 458 | assert.equal(params.sandboxPolicy?.type, "readOnly"); |
| 459 | }), |
| 460 | ); |
| 461 | |
| 462 | it.effect("sends MCP app model context as untrusted Codex context", () => |
| 463 | Effect.gen(function* () { |
| 464 | const policy = { runtimeMode: "full-access", interactionMode: "default", cwd: null } as const; |
| 465 | const modelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }; |
| 466 | const appContext = [{ key: "mcp_app_todos_list_todos_item-1", text: "Filtered to overdue" }]; |
| 467 | const alone = yield* CodexAdapterV2.buildCodexTurnStartParams({ |
| 468 | nativeThreadId: "native-app-context", |
| 469 | codexInput: [{ type: "text", text: "what's on my list?" }], |
| 470 | runtimePolicy: policy, |
| 471 | modelSelection, |
| 472 | appContext, |
| 473 | }); |
| 474 | assert.deepEqual(alone.additionalContext, { |
| 475 | "mcp_app_todos_list_todos_item-1": { kind: "untrusted", value: "Filtered to overdue" }, |
| 476 | }); |
| 477 | // Alongside T3's own context, both are kept. |
| 478 | const withT3 = yield* CodexAdapterV2.buildCodexTurnStartParams({ |
| 479 | nativeThreadId: "native-app-context", |
| 480 | codexInput: [{ type: "text", text: "what's on my list?" }], |
| 481 | runtimePolicy: policy, |
| 482 | modelSelection, |
| 483 | hasT3Mcp: true, |
| 484 | appContext, |
| 485 | }); |
| 486 | assert.deepEqual(withT3.additionalContext?.["mcp_app_todos_list_todos_item-1"], { |
| 487 | kind: "untrusted", |
| 488 | value: "Filtered to overdue", |
| 489 | }); |
| 490 | assert.isDefined(withT3.additionalContext?.["t3_code_runtime"]); |
| 491 | }), |
| 492 | ); |
| 493 | |
| 494 | it.effect("adds default-mode developer instructions when the T3 MCP server is attached", () => |
| 495 | Effect.gen(function* () { |
| 496 | const params = yield* CodexAdapterV2.buildCodexTurnStartParams({ |
| 497 | nativeThreadId: "native-orchestration-instructions", |
| 498 | codexInput: [{ type: "text", text: "delegate this task" }], |
| 499 | runtimePolicy: { |
| 500 | runtimeMode: "full-access", |
| 501 | interactionMode: "default", |
| 502 | cwd: null, |
| 503 | }, |
| 504 | modelSelection: { |
| 505 | instanceId: ProviderInstanceId.make("codex"), |
| 506 | model: "gpt-5.4", |
| 507 | }, |
| 508 | hasT3Mcp: true, |
| 509 | }); |
| 510 | |
| 511 | assert.equal(params.collaborationMode?.mode, "default"); |
| 512 | assert.include( |
| 513 | params.additionalContext?.t3_code_orchestration?.value ?? "", |
| 514 | "Use `delegate_task`", |
| 515 | ); |
| 516 | assert.include( |
| 517 | params.additionalContext?.t3_code_orchestration?.value ?? "", |
| 518 | "structured object, never as JSON text", |
| 519 | ); |
| 520 | }), |
| 521 | ); |
| 522 | |
| 523 | it.effect("omits default-mode collaboration settings without the T3 MCP server", () => |
| 524 | Effect.gen(function* () { |
| 525 | const params = yield* CodexAdapterV2.buildCodexTurnStartParams({ |
| 526 | nativeThreadId: "native-default-without-t3-mcp", |
| 527 | codexInput: [{ type: "text", text: "implement this task" }], |
| 528 | runtimePolicy: { |
| 529 | runtimeMode: "full-access", |
| 530 | interactionMode: "default", |
| 531 | cwd: null, |
| 532 | }, |
| 533 | modelSelection: { |
| 534 | instanceId: ProviderInstanceId.make("codex"), |
| 535 | model: "gpt-5.4", |
| 536 | }, |
| 537 | hasT3Mcp: false, |
| 538 | }); |
| 539 | |
| 540 | assert.isUndefined(params.collaborationMode); |
| 541 | }), |
| 542 | ); |
| 543 | |
| 544 | it.effect("adds T3 plan-mode developer instructions when the T3 MCP server is attached", () => |
| 545 | Effect.gen(function* () { |
| 546 | const params = yield* CodexAdapterV2.buildCodexTurnStartParams({ |
| 547 | nativeThreadId: "native-plan-with-t3-mcp", |
| 548 | codexInput: [{ type: "text", text: "plan this task" }], |
| 549 | runtimePolicy: { |
| 550 | runtimeMode: "full-access", |
| 551 | interactionMode: "plan", |
| 552 | cwd: null, |
| 553 | }, |
| 554 | modelSelection: { |
| 555 | instanceId: ProviderInstanceId.make("codex"), |
| 556 | model: "gpt-5.4", |
| 557 | }, |
| 558 | hasT3Mcp: true, |
| 559 | }); |
| 560 | |
| 561 | assert.equal(params.collaborationMode?.mode, "plan"); |
| 562 | assert.include( |
| 563 | params.collaborationMode?.settings.developer_instructions ?? "", |
| 564 | "request_user_input", |
| 565 | ); |
| 566 | assert.include(params.additionalContext?.t3_code_tools?.value ?? "", "preview_status"); |
| 567 | }), |
| 568 | ); |
| 569 | |
| 570 | it.effect("keeps Codex in plan mode without referencing unavailable T3 MCP tools", () => |
| 571 | Effect.gen(function* () { |
| 572 | const params = yield* CodexAdapterV2.buildCodexTurnStartParams({ |
| 573 | nativeThreadId: "native-plan-without-t3-mcp", |
| 574 | codexInput: [{ type: "text", text: "plan this task" }], |
| 575 | runtimePolicy: { |
| 576 | runtimeMode: "full-access", |
| 577 | interactionMode: "plan", |
| 578 | cwd: null, |
| 579 | }, |
| 580 | modelSelection: { |
| 581 | instanceId: ProviderInstanceId.make("codex"), |
| 582 | model: "gpt-5.4", |
| 583 | }, |
| 584 | hasT3Mcp: false, |
| 585 | }); |
| 586 | |
| 587 | assert.equal(params.collaborationMode?.mode, "plan"); |
| 588 | assert.notProperty(params.collaborationMode?.settings, "developer_instructions"); |
| 589 | }), |
| 590 | ); |
| 591 | |
| 592 | it.effect("compiles per-turn Codex model options and cwd from their owning inputs", () => |
| 593 | Effect.gen(function* () { |
| 594 | const params = yield* CodexAdapterV2.buildCodexTurnStartParams({ |
| 595 | nativeThreadId: "native-model-options", |
| 596 | codexInput: [{ type: "text", text: "test" }], |
| 597 | runtimePolicy: { |
| 598 | runtimeMode: "full-access", |
| 599 | interactionMode: "plan", |
| 600 | cwd: "/workspace/model-options", |
| 601 | reasoningEffort: "low", |
| 602 | }, |
| 603 | modelSelection: { |
| 604 | instanceId: ProviderInstanceId.make("codex"), |
| 605 | model: "gpt-5.4", |
| 606 | options: [ |
| 607 | { id: "reasoningEffort", value: "xhigh" }, |
| 608 | { id: "serviceTier", value: "priority" }, |
| 609 | ], |
| 610 | }, |
| 611 | }); |
| 612 | |
| 613 | assert.equal(params.model, "gpt-5.4"); |
| 614 | assert.equal(params.effort, "xhigh"); |
| 615 | assert.equal(params.serviceTier, "priority"); |
| 616 | assert.equal(params.cwd, "/workspace/model-options"); |
| 617 | assert.equal(params.collaborationMode?.settings.model, "gpt-5.4"); |
| 618 | assert.equal(params.collaborationMode?.settings.reasoning_effort, "xhigh"); |
| 619 | |
| 620 | // ChatGPT token sharing rejects service tiers, so managed sessions drop a stale pick. |
| 621 | const managed = yield* CodexAdapterV2.buildCodexTurnStartParams({ |
| 622 | nativeThreadId: "native-model-options", |
| 623 | codexInput: [{ type: "text", text: "test" }], |
| 624 | runtimePolicy: { |
| 625 | runtimeMode: "full-access", |
| 626 | interactionMode: "default", |
| 627 | cwd: "/workspace/model-options", |
| 628 | }, |
| 629 | modelSelection: { |
| 630 | instanceId: ProviderInstanceId.make("codex"), |
| 631 | model: "gpt-5.4", |
| 632 | options: [{ id: "serviceTier", value: "priority" }], |
| 633 | }, |
| 634 | omitServiceTier: true, |
| 635 | }); |
| 636 | assert.equal(managed.serviceTier, undefined); |
| 637 | }), |
| 638 | ); |
| 639 | }); |
| 640 | |
| 641 | describe("CodexAdapterV2 process spawning", () => { |
| 642 | it("injects cwd, model, and MCP authorization into thread-scoped params", () => { |
| 643 | const threadId = ThreadId.make("thread-codex-mcp"); |
| 644 | const mcpSession = { |
| 645 | environmentId: EnvironmentId.make("environment-codex-mcp"), |
| 646 | threadId, |
| 647 | providerSessionId: "mcp-session-codex", |
| 648 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 649 | endpoint: "http://127.0.0.1:43123/mcp", |
| 650 | authorizationHeader: "Bearer secret-codex-token", |
| 651 | browserToolsAvailable: true, |
| 652 | }; |
| 653 | |
| 654 | assert.deepEqual( |
| 655 | CodexAdapterV2.codexThreadRuntimeParams({ |
| 656 | mcpSession, |
| 657 | modelSelection: { model: "gpt-5.4" }, |
| 658 | runtimePolicy: { |
| 659 | runtimeMode: "full-access", |
| 660 | interactionMode: "default", |
| 661 | cwd: "/workspace/thread-codex-mcp", |
| 662 | }, |
| 663 | }), |
| 664 | { |
| 665 | cwd: "/workspace/thread-codex-mcp", |
| 666 | model: "gpt-5.4", |
| 667 | config: { |
| 668 | "tools.update_plan.enabled": true, |
| 669 | // Codex waits up to 10 s, not 1 s, for MCP servers before the first request (#13437). |
| 670 | mcp_optional_startup_grace_ms: 10_000, |
| 671 | mcp_servers: { |
| 672 | "t3-code": { |
| 673 | url: "http://127.0.0.1:43123/mcp", |
| 674 | tool_timeout_sec: 3_900, |
| 675 | http_headers: { |
| 676 | Authorization: "Bearer secret-codex-token", |
| 677 | }, |
| 678 | }, |
| 679 | }, |
| 680 | }, |
| 681 | }, |
| 682 | ); |
| 683 | }); |
| 684 | |
| 685 | it.effect("resolves Windows command shims through the shared spawn policy", () => |
| 686 | Effect.gen(function* () { |
| 687 | const command = yield* CodexAdapterV2.makeCodexAppServerSpawnCommand({ |
| 688 | command: "codex", |
| 689 | args: ["app-server", "argument with spaces"], |
| 690 | cwd: "C:\\workspace", |
| 691 | env: { CUSTOM: "1" }, |
| 692 | extendEnv: true, |
| 693 | }); |
| 694 | |
| 695 | assert.isTrue(ChildProcess.isStandardCommand(command)); |
| 696 | if (!ChildProcess.isStandardCommand(command)) { |
| 697 | return; |
| 698 | } |
| 699 | assert.equal( |
| 700 | command.command, |
| 701 | '^"C:\\npm\\codex.cmd^" ^"app-server^" ^"argument^ with^ spaces^"', |
| 702 | ); |
| 703 | assert.deepEqual(command.args, []); |
| 704 | assert.equal(command.options.shell, true); |
| 705 | assert.equal(command.options.cwd, "C:\\workspace"); |
| 706 | assert.deepEqual(command.options.env, { CUSTOM: "1" }); |
| 707 | assert.equal(command.options.extendEnv, true); |
| 708 | }).pipe( |
| 709 | Effect.provideService(HostProcess.Platform, "win32"), |
| 710 | Effect.provideService(HostProcess.Environment, { |
| 711 | PATH: "C:\\Windows\\System32", |
| 712 | HOST_ONLY: "1", |
| 713 | }), |
| 714 | Effect.provideService(SpawnExecutableResolution, (_command, _platform, environment) => { |
| 715 | assert.equal(environment.HOST_ONLY, "1"); |
| 716 | assert.equal(environment.CUSTOM, "1"); |
| 717 | return "C:\\npm\\codex.cmd"; |
| 718 | }), |
| 719 | ), |
| 720 | ); |
| 721 | |
| 722 | it.effect("uses direct execution for native executables", () => |
| 723 | Effect.gen(function* () { |
| 724 | const command = yield* CodexAdapterV2.makeCodexAppServerSpawnCommand({ |
| 725 | command: "codex.exe", |
| 726 | args: ["app-server"], |
| 727 | }); |
| 728 | |
| 729 | assert.isTrue(ChildProcess.isStandardCommand(command)); |
| 730 | if (!ChildProcess.isStandardCommand(command)) { |
| 731 | return; |
| 732 | } |
| 733 | assert.equal(command.command, "C:\\bin\\codex.exe"); |
| 734 | assert.deepEqual(command.args, ["app-server"]); |
| 735 | assert.equal(command.options.shell, false); |
| 736 | }).pipe( |
| 737 | Effect.provideService(HostProcess.Platform, "win32"), |
| 738 | Effect.provideService(SpawnExecutableResolution, () => "C:\\bin\\codex.exe"), |
| 739 | ), |
| 740 | ); |
| 741 | |
| 742 | it.effect("launches the app-server with the configured launch arguments", () => |
| 743 | Effect.gen(function* () { |
| 744 | const spawnedArgs: Array<ReadonlyArray<string>> = []; |
| 745 | const spawner = ChildProcessSpawner.make((command) => { |
| 746 | if (ChildProcess.isStandardCommand(command)) spawnedArgs.push(command.args); |
| 747 | return Effect.fail( |
| 748 | PlatformError.systemError({ _tag: "NotFound", module: "ChildProcess", method: "spawn" }), |
| 749 | ); |
| 750 | }); |
| 751 | const factory = yield* CodexAdapterV2.CodexAppServerClientFactory.pipe( |
| 752 | Effect.provide(CodexAdapterV2.layerAppServerClientFactory), |
| 753 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| 754 | Effect.provideService( |
| 755 | ProviderEventLoggers.ProviderEventLoggers, |
| 756 | ProviderEventLoggers.NoOpProviderEventLoggers, |
| 757 | ), |
| 758 | ); |
| 759 | const open = (environment: NodeJS.ProcessEnv) => |
| 760 | factory |
| 761 | .open({ |
| 762 | instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, |
| 763 | threadId: ThreadId.make("thread-launch-args"), |
| 764 | providerSessionId: ProviderSessionId.make("provider-session-launch-args"), |
| 765 | runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ |
| 766 | runtimeMode: "full-access", |
| 767 | interactionMode: "default", |
| 768 | cwd: "/workspace", |
| 769 | }), |
| 770 | settings: { |
| 771 | ...DEFAULT_CODEX_SETTINGS, |
| 772 | launchArgs: " --strict-config -c model_reasoning_summary=detailed ", |
| 773 | }, |
| 774 | environment, |
| 775 | }) |
| 776 | .pipe(Effect.scoped, Effect.exit); |
| 777 | |
| 778 | yield* open({}); |
| 779 | yield* open({ T3CODE_CODEX_LAUNCH_ARGS: " --enable env-feature " }); |
| 780 | |
| 781 | assert.deepEqual(spawnedArgs, [ |
| 782 | ["app-server", "--strict-config", "-c", "model_reasoning_summary=detailed"], |
| 783 | ["app-server", "--enable", "env-feature"], |
| 784 | ]); |
| 785 | }).pipe(Effect.provideService(HostProcess.Platform, "linux")), |
| 786 | ); |
| 787 | |
| 788 | it.effect("expands ~ in the configured binary path before spawning", () => |
| 789 | Effect.gen(function* () { |
| 790 | const spawnedCommands: Array<string> = []; |
| 791 | const spawner = ChildProcessSpawner.make((command) => { |
| 792 | if (ChildProcess.isStandardCommand(command)) spawnedCommands.push(command.command); |
| 793 | return Effect.fail( |
| 794 | PlatformError.systemError({ _tag: "NotFound", module: "ChildProcess", method: "spawn" }), |
| 795 | ); |
| 796 | }); |
| 797 | const path = yield* Path.Path; |
| 798 | const adapter = yield* CodexAdapterV2.createCodexAdapterV2({ |
| 799 | instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, |
| 800 | displayName: undefined, |
| 801 | environment: [], |
| 802 | enabled: true, |
| 803 | config: { ...DEFAULT_CODEX_SETTINGS, binaryPath: "~/bin/codex" }, |
| 804 | }).pipe( |
| 805 | Effect.provide( |
| 806 | Layer.mergeAll( |
| 807 | CodexAdapterV2.layerAppServerClientFactory, |
| 808 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-codex-binary-home-" }), |
| 809 | ), |
| 810 | ), |
| 811 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| 812 | Effect.provideService( |
| 813 | ProviderEventLoggers.ProviderEventLoggers, |
| 814 | ProviderEventLoggers.NoOpProviderEventLoggers, |
| 815 | ), |
| 816 | ); |
| 817 | |
| 818 | yield* adapter |
| 819 | .openSession({ |
| 820 | threadId: ThreadId.make("thread-binary-home"), |
| 821 | providerSessionId: ProviderSessionId.make("provider-session-binary-home"), |
| 822 | modelSelection: CODEX_TEST_MODEL_SELECTION, |
| 823 | runtimePolicy: CODEX_TEST_RUNTIME_POLICY, |
| 824 | }) |
| 825 | .pipe(Effect.scoped, Effect.exit); |
| 826 | |
| 827 | assert.deepEqual(spawnedCommands, [path.join(NodeOS.homedir(), "bin", "codex")]); |
| 828 | }).pipe( |
| 829 | Effect.provide( |
| 830 | Layer.mergeAll( |
| 831 | IdAllocator.layer, |
| 832 | McpProviderSessions.layer, |
| 833 | ScriptProgress.layerUnavailable, |
| 834 | NodeServices.layer, |
| 835 | ), |
| 836 | ), |
| 837 | Effect.provideService(HostProcess.Platform, "linux"), |
| 838 | ), |
| 839 | ); |
| 840 | }); |
| 841 | |
| 842 | describe("CodexAdapterV2 dynamic tool projection", () => { |
| 843 | it.effect("keeps a failed Sky MCP call in Computer Use when its error has no metadata", () => |
| 844 | Effect.gen(function* () { |
| 845 | const projection = yield* CodexAdapterV2.projectCodexDynamicToolItem({ |
| 846 | type: "mcpToolCall", |
| 847 | id: "failed-sky-js", |
| 848 | server: "node_repl", |
| 849 | tool: "js", |
| 850 | status: "failed", |
| 851 | arguments: { |
| 852 | title: "Restrict the breakpoint to Git actions", |
| 853 | code: "await sky.type_text({app:'T3 Code (Nightly)',text:'debug(ga.run)'});", |
| 854 | }, |
| 855 | result: { content: [{ type: "text", text: "Illegal Unicode escape sequence" }] }, |
| 856 | }); |
| 857 | assert.equal(projection.title, "Restrict the breakpoint to Git actions"); |
| 858 | assert.equal(projection.toolSurface, "computer"); |
| 859 | assert.deepEqual(projection.toolSource, { |
| 860 | key: "computer-use", |
| 861 | name: "Computer Use", |
| 862 | kind: "computer", |
| 863 | }); |
| 864 | }).pipe(Effect.provide(NodeServices.layer)), |
| 865 | ); |
| 866 | |
| 867 | it.effect.each(["inProgress", "completed", "failed"] as const)( |
| 868 | "preserves a bare js action's title and computer surface when %s without result metadata", |
| 869 | (status) => |
| 870 | Effect.gen(function* () { |
| 871 | const projection = yield* CodexAdapterV2.projectCodexDynamicToolItem({ |
| 872 | type: "dynamicToolCall", |
| 873 | id: "bare-js", |
| 874 | tool: "js", |
| 875 | status, |
| 876 | arguments: { code: "await sky.get_app_state(app);", title: "Read the desktop window" }, |
| 877 | contentItems: [], |
| 878 | success: status !== "failed", |
| 879 | }); |
| 880 | assert.equal(projection.title, "Read the desktop window"); |
| 881 | assert.equal(projection.toolSurface, "computer"); |
| 882 | }).pipe(Effect.provide(NodeServices.layer)), |
| 883 | ); |
| 884 | |
| 885 | it.effect.each(["cua_repl", "node_repl"])( |
| 886 | "uses the %s call title while leaving other MCP titles as tool arguments", |
| 887 | (server) => |
| 888 | Effect.gen(function* () { |
| 889 | const call = { |
| 890 | type: "mcpToolCall" as const, |
| 891 | id: "inspect", |
| 892 | server, |
| 893 | tool: "js", |
| 894 | status: "completed" as const, |
| 895 | arguments: { |
| 896 | code: "await game.getAXStateAndScreenshot();", |
| 897 | title: "Inspect Saga music screen", |
| 898 | }, |
| 899 | result: { content: [] }, |
| 900 | }; |
| 901 | assert.equal( |
| 902 | (yield* CodexAdapterV2.projectCodexDynamicToolItem(call)).title, |
| 903 | "Inspect Saga music screen", |
| 904 | ); |
| 905 | assert.equal( |
| 906 | (yield* CodexAdapterV2.projectCodexDynamicToolItem({ |
| 907 | ...call, |
| 908 | arguments: { title: " " }, |
| 909 | })).title, |
| 910 | server === "cua_repl" ? "Computer use" : "Run JavaScript", |
| 911 | ); |
| 912 | assert.equal( |
| 913 | (yield* CodexAdapterV2.projectCodexDynamicToolItem({ ...call, server: "github" })).title, |
| 914 | "js", |
| 915 | ); |
| 916 | }).pipe(Effect.provide(NodeServices.layer)), |
| 917 | ); |
| 918 | |
| 919 | it.effect.each(["inProgress", "completed", "failed"] as const)( |
| 920 | "presents ordinary MCP calls when %s", |
| 921 | (status) => |
| 922 | Effect.gen(function* () { |
| 923 | const projection = yield* CodexAdapterV2.projectCodexDynamicToolItem({ |
| 924 | type: "mcpToolCall", |
| 925 | id: "weather-call", |
| 926 | server: "weather", |
| 927 | tool: "get_weather", |
| 928 | status, |
| 929 | arguments: { city: "Berlin" }, |
| 930 | }); |
| 931 | assert.equal(projection.title, "get weather"); |
| 932 | assert.deepEqual(projection.toolSource, { |
| 933 | key: "mcp:weather", |
| 934 | name: "weather", |
| 935 | kind: "integration", |
| 936 | }); |
| 937 | assert.deepEqual(projection.input, { city: "Berlin" }); |
| 938 | }).pipe(Effect.provide(NodeServices.layer)), |
| 939 | ); |
| 940 | |
| 941 | it.effect("uses Codex connector names without reading a display title from arguments", () => |
| 942 | Effect.gen(function* () { |
| 943 | const projection = yield* CodexAdapterV2.projectCodexDynamicToolItem({ |
| 944 | type: "mcpToolCall", |
| 945 | id: "connector-call", |
| 946 | server: "_apps", |
| 947 | tool: "connector_get_weather", |
| 948 | status: "completed", |
| 949 | arguments: { title: "Argument, not display metadata" }, |
| 950 | appContext: { |
| 951 | connectorId: "weather-app", |
| 952 | appName: "Weather", |
| 953 | actionName: "Check weather", |
| 954 | }, |
| 955 | result: { |
| 956 | content: [], |
| 957 | _meta: { source: { logoUrl: "https://example.com/weather.png" } }, |
| 958 | }, |
| 959 | }); |
| 960 | assert.equal(projection.title, "Check weather"); |
| 961 | assert.equal(projection.toolSource?.name, "Weather"); |
| 962 | assert.deepEqual(projection.toolIcon, { |
| 963 | _tag: "themed-logo", |
| 964 | logoUrl: "https://example.com/weather.png", |
| 965 | }); |
| 966 | assert.deepEqual(projection.toolSource?.icon, projection.toolIcon); |
| 967 | }).pipe(Effect.provide(NodeServices.layer)), |
| 968 | ); |
| 969 | |
| 970 | it.effect("preserves native browser and app icons alongside MCP tool output", () => |
| 971 | Effect.gen(function* () { |
| 972 | const browser = yield* CodexAdapterV2.projectCodexDynamicToolItem({ |
| 973 | type: "mcpToolCall", |
| 974 | id: "browser", |
| 975 | server: "browser", |
| 976 | tool: "open", |
| 977 | status: "completed", |
| 978 | arguments: {}, |
| 979 | result: { |
| 980 | content: [], |
| 981 | _meta: { |
| 982 | "codex/toolSurface": { |
| 983 | kind: "browserUse", |
| 984 | browserFamily: "Chrome", |
| 985 | screenshot: { |
| 986 | pageUrl: "https://example.com/docs", |
| 987 | faviconUrl: "https://example.com/icon.png", |
| 988 | }, |
| 989 | }, |
| 990 | }, |
| 991 | }, |
| 992 | }); |
| 993 | assert.equal(browser.toolSurface, "browser"); |
| 994 | assert.deepEqual(browser.toolIcon, { |
| 995 | _tag: "website", |
| 996 | pageUrl: "https://example.com/docs", |
| 997 | faviconUrl: "https://example.com/icon.png", |
| 998 | }); |
| 999 | assert.equal(browser.toolSource?.name, "Chrome"); |
| 1000 | const app = yield* CodexAdapterV2.projectCodexDynamicToolItem({ |
| 1001 | type: "mcpToolCall", |
| 1002 | id: "app", |
| 1003 | server: "computer", |
| 1004 | tool: "click", |
| 1005 | status: "completed", |
| 1006 | arguments: {}, |
| 1007 | result: { |
| 1008 | content: [], |
| 1009 | _meta: { |
| 1010 | "codex/toolSurface": { |
| 1011 | kind: "computerUse", |
| 1012 | app: { kind: "appId", appId: "com.apple.finder" }, |
| 1013 | }, |
| 1014 | }, |
| 1015 | }, |
| 1016 | }); |
| 1017 | assert.deepEqual(app.toolIcon, { |
| 1018 | _tag: "native-app", |
| 1019 | app: { _tag: "app-id", appId: "com.apple.finder" }, |
| 1020 | }); |
| 1021 | assert.equal(app.toolSource?.name, "Finder"); |
| 1022 | }).pipe(Effect.provide(NodeServices.layer)), |
| 1023 | ); |
| 1024 | |
| 1025 | it.effect("preserves MCP arguments and prefers structured output", () => |
| 1026 | Effect.gen(function* () { |
| 1027 | const projection = yield* CodexAdapterV2.projectCodexDynamicToolItem({ |
| 1028 | type: "mcpToolCall", |
| 1029 | id: "call-create-threads", |
| 1030 | server: "t3-code", |
| 1031 | tool: "create_threads", |
| 1032 | status: "completed", |
| 1033 | arguments: { |
| 1034 | threads: [{ title: "Fixture child", prompt: "fixture child prompt" }], |
| 1035 | }, |
| 1036 | result: { |
| 1037 | content: [{ type: "text", text: '{"threads":[{"threadId":"thread:mcp:fixture:0"}]}' }], |
| 1038 | structuredContent: { |
| 1039 | threads: [{ threadId: "thread:mcp:fixture:0" }], |
| 1040 | }, |
| 1041 | }, |
| 1042 | }); |
| 1043 | |
| 1044 | assert.deepEqual(projection, { |
| 1045 | toolName: "t3-code.create_threads", |
| 1046 | input: { |
| 1047 | threads: [{ title: "Fixture child", prompt: "fixture child prompt" }], |
| 1048 | }, |
| 1049 | output: { |
| 1050 | threads: [{ threadId: "thread:mcp:fixture:0" }], |
| 1051 | }, |
| 1052 | status: "completed", |
| 1053 | }); |
| 1054 | }).pipe(Effect.provide(NodeServices.layer)), |
| 1055 | ); |
| 1056 | |
| 1057 | it.effect("preserves namespaced dynamic tool output", () => |
| 1058 | Effect.gen(function* () { |
| 1059 | const projection = yield* CodexAdapterV2.projectCodexDynamicToolItem({ |
| 1060 | type: "dynamicToolCall", |
| 1061 | id: "call-dynamic", |
| 1062 | namespace: "workspace", |
| 1063 | tool: "inspect", |
| 1064 | status: "failed", |
| 1065 | arguments: { path: "package.json" }, |
| 1066 | contentItems: [{ type: "inputText", text: "inspection failed" }], |
| 1067 | success: false, |
| 1068 | }); |
| 1069 | |
| 1070 | assert.deepEqual(projection, { |
| 1071 | toolName: "workspace.inspect", |
| 1072 | input: { path: "package.json" }, |
| 1073 | output: [{ type: "inputText", text: "inspection failed" }], |
| 1074 | status: "failed", |
| 1075 | }); |
| 1076 | }).pipe(Effect.provide(NodeServices.layer)), |
| 1077 | ); |
| 1078 | }); |
| 1079 | |
| 1080 | describe("CodexAdapterV2 native protocol logging", () => { |
| 1081 | it.effect("logs decoded app-server frames once with credentials redacted", () => |
| 1082 | Effect.gen(function* () { |
| 1083 | const writes: Array<{ |
| 1084 | readonly event: unknown; |
| 1085 | readonly threadId: ThreadId | null; |
| 1086 | }> = []; |
| 1087 | const logger: ProviderEventLoggers.EventNdjsonLogger = { |
| 1088 | filePath: "/tmp/events.log", |
| 1089 | write: (event, threadId) => |
| 1090 | Effect.sync(() => { |
| 1091 | writes.push({ event, threadId }); |
| 1092 | }), |
| 1093 | close: () => Effect.void, |
| 1094 | }; |
| 1095 | const threadId = ThreadId.make("thread-1"); |
| 1096 | const providerSessionId = ProviderSessionId.make("provider-session-1"); |
| 1097 | const protocolLogger = CodexAdapterV2.makeCodexAppServerProtocolLogger({ |
| 1098 | nativeEventLogger: logger, |
| 1099 | threadId, |
| 1100 | providerSessionId, |
| 1101 | }); |
| 1102 | |
| 1103 | assert.notEqual(protocolLogger, undefined); |
| 1104 | if (protocolLogger === undefined) { |
| 1105 | return; |
| 1106 | } |
| 1107 | |
| 1108 | yield* protocolLogger({ |
| 1109 | direction: "incoming", |
| 1110 | stage: "decoded", |
| 1111 | payload: { |
| 1112 | method: "thread/event", |
| 1113 | params: { |
| 1114 | id: "evt-1", |
| 1115 | http_headers: { Authorization: "Bearer secret-codex-token" }, |
| 1116 | usage: { inputTokens: 12, outputTokens: 8, totalTokens: 20 }, |
| 1117 | }, |
| 1118 | }, |
| 1119 | }); |
| 1120 | yield* protocolLogger({ |
| 1121 | direction: "incoming", |
| 1122 | stage: "raw", |
| 1123 | payload: |
| 1124 | '{"method":"thread/event","params":{"http_headers":{"Authorization":"Bearer secret-codex-token"}}}\n', |
| 1125 | }); |
| 1126 | |
| 1127 | assert.equal(writes.length, 1); |
| 1128 | assert.equal(writes[0]?.threadId, threadId); |
| 1129 | assert.deepEqual(writes[0]?.event, { |
| 1130 | provider: "codex", |
| 1131 | protocol: "codex.app-server", |
| 1132 | kind: "protocol", |
| 1133 | providerSessionId, |
| 1134 | event: { |
| 1135 | direction: "incoming", |
| 1136 | stage: "decoded", |
| 1137 | payload: { |
| 1138 | method: "thread/event", |
| 1139 | params: { |
| 1140 | id: "evt-1", |
| 1141 | http_headers: { Authorization: "[REDACTED]" }, |
| 1142 | usage: { inputTokens: 12, outputTokens: 8, totalTokens: 20 }, |
| 1143 | }, |
| 1144 | }, |
| 1145 | }, |
| 1146 | }); |
| 1147 | }), |
| 1148 | ); |
| 1149 | |
| 1150 | it.effect("filters streaming frames before redaction without losing decode failures", () => |
| 1151 | Effect.gen(function* () { |
| 1152 | const writes: Array<unknown> = []; |
| 1153 | const protocolLogger = CodexAdapterV2.makeCodexAppServerProtocolLogger({ |
| 1154 | nativeEventLogger: { |
| 1155 | filePath: "/tmp/events.log", |
| 1156 | write: (event) => |
| 1157 | Effect.sync(() => { |
| 1158 | writes.push(event); |
| 1159 | }), |
| 1160 | close: () => Effect.void, |
| 1161 | }, |
| 1162 | threadId: ThreadId.make("thread-1"), |
| 1163 | providerSessionId: ProviderSessionId.make("provider-session-1"), |
| 1164 | }); |
| 1165 | assert.exists(protocolLogger); |
| 1166 | if (protocolLogger === undefined) return; |
| 1167 | |
| 1168 | yield* protocolLogger({ |
| 1169 | direction: "incoming", |
| 1170 | stage: "decoded", |
| 1171 | payload: { |
| 1172 | method: "item/agentMessage/delta", |
| 1173 | get params() { |
| 1174 | throw new Error("delta must not be copied"); |
| 1175 | }, |
| 1176 | }, |
| 1177 | }); |
| 1178 | yield* protocolLogger({ |
| 1179 | direction: "incoming", |
| 1180 | stage: "raw", |
| 1181 | get payload() { |
| 1182 | throw new Error("raw frame must not be parsed"); |
| 1183 | }, |
| 1184 | }); |
| 1185 | yield* protocolLogger({ |
| 1186 | direction: "incoming", |
| 1187 | stage: "decode_failed", |
| 1188 | payload: { operation: "decode", method: "turn/completed", issueCount: 1 }, |
| 1189 | }); |
| 1190 | |
| 1191 | assert.equal(writes.length, 1); |
| 1192 | assert.nestedPropertyVal(writes[0], "event.stage", "decode_failed"); |
| 1193 | assert.nestedPropertyVal(writes[0], "event.payload.method", "turn/completed"); |
| 1194 | }), |
| 1195 | ); |
| 1196 | |
| 1197 | it.effect("retains redacted failures when large payloads are summarized", () => |
| 1198 | Effect.gen(function* () { |
| 1199 | const writes: Array<unknown> = []; |
| 1200 | const protocolLogger = CodexAdapterV2.makeCodexAppServerProtocolLogger({ |
| 1201 | nativeEventLogger: { |
| 1202 | filePath: "/tmp/events.log", |
| 1203 | write: (event) => |
| 1204 | Effect.sync(() => { |
| 1205 | writes.push(event); |
| 1206 | }), |
| 1207 | close: () => Effect.void, |
| 1208 | }, |
| 1209 | threadId: ThreadId.make("thread-1"), |
| 1210 | providerSessionId: ProviderSessionId.make("provider-session-1"), |
| 1211 | }); |
| 1212 | assert.exists(protocolLogger); |
| 1213 | if (protocolLogger === undefined) return; |
| 1214 | |
| 1215 | yield* protocolLogger({ |
| 1216 | direction: "incoming", |
| 1217 | stage: "decoded", |
| 1218 | payload: { |
| 1219 | method: "error", |
| 1220 | params: { |
| 1221 | threadId: "native-thread", |
| 1222 | turnId: "native-turn", |
| 1223 | error: { |
| 1224 | code: "unauthorized", |
| 1225 | message: '{"message":"Unauthorized","Authorization":"Bearer secret-token"}', |
| 1226 | }, |
| 1227 | history: "x".repeat(128 * 1_024), |
| 1228 | }, |
| 1229 | }, |
| 1230 | }); |
| 1231 | |
| 1232 | const serialized = encodeUnknownJson(writes); |
| 1233 | assert.equal(writes.length, 1); |
| 1234 | assert.isBelow(serialized.length, 2_048); |
| 1235 | assert.notInclude(serialized, "secret-token"); |
| 1236 | assert.include(serialized, "[REDACTED]"); |
| 1237 | assert.nestedPropertyVal(writes[0], "event.payload.params.error.code", "unauthorized"); |
| 1238 | assert.nestedPropertyVal(writes[0], "event.payload.params.turnId", "native-turn"); |
| 1239 | }), |
| 1240 | ); |
| 1241 | }); |
| 1242 | |
| 1243 | describe("CodexAdapterV2 rollback mapping", () => { |
| 1244 | it.effect("derives native rollback count from durable provider turns", () => |
| 1245 | Effect.gen(function* () { |
| 1246 | const now = yield* DateTime.now; |
| 1247 | const providerThreadId = ProviderThreadId.make("provider-thread-codex-rollback"); |
| 1248 | const providerThread: OrchestrationV2ProviderThread = { |
| 1249 | id: providerThreadId, |
| 1250 | driver: CodexAdapterV2.CODEX_DRIVER_KIND, |
| 1251 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 1252 | providerSessionId: ProviderSessionId.make("provider-session-codex-rollback"), |
| 1253 | appThreadId: ThreadId.make("thread-codex-rollback"), |
| 1254 | ownerNodeId: null, |
| 1255 | nativeThreadRef: { |
| 1256 | driver: CodexAdapterV2.CODEX_DRIVER_KIND, |
| 1257 | nativeId: "native-thread-codex-rollback", |
| 1258 | strength: "strong", |
| 1259 | }, |
| 1260 | nativeConversationHeadRef: null, |
| 1261 | status: "idle", |
| 1262 | firstRunOrdinal: 1, |
| 1263 | lastRunOrdinal: 3, |
| 1264 | handoffIds: [], |
| 1265 | forkedFrom: null, |
| 1266 | createdAt: now, |
| 1267 | updatedAt: now, |
| 1268 | }; |
| 1269 | const providerTurn = ( |
| 1270 | id: string, |
| 1271 | ordinal: number, |
| 1272 | status: OrchestrationV2ProviderTurn["status"], |
| 1273 | ): OrchestrationV2ProviderTurn => ({ |
| 1274 | id: ProviderTurnId.make(id), |
| 1275 | providerThreadId, |
| 1276 | nodeId: NodeId.make(`node-${id}`), |
| 1277 | runAttemptId: RunAttemptId.make(`run-attempt-${id}`), |
| 1278 | nativeTurnRef: { |
| 1279 | driver: CodexAdapterV2.CODEX_DRIVER_KIND, |
| 1280 | nativeId: `native-${id}`, |
| 1281 | strength: "strong", |
| 1282 | }, |
| 1283 | ordinal, |
| 1284 | status, |
| 1285 | startedAt: now, |
| 1286 | completedAt: status === "running" || status === "pending" ? null : now, |
| 1287 | }); |
| 1288 | const firstTurn = providerTurn("provider-turn-first", 1, "completed"); |
| 1289 | const secondTurn = providerTurn("provider-turn-second", 2, "completed"); |
| 1290 | const runningTurn = providerTurn("provider-turn-running", 3, "running"); |
| 1291 | const interruptedTurn = providerTurn("provider-turn-interrupted", 4, "interrupted"); |
| 1292 | // `/goal` control turns never reached Codex, so revert must not count them. |
| 1293 | const goalCommandTurn = { |
| 1294 | ...providerTurn("provider-turn-goal-command", 5, "completed"), |
| 1295 | nativeTurnRef: null, |
| 1296 | }; |
| 1297 | |
| 1298 | const numTurns = yield* CodexAdapterV2.resolveCodexRollbackTurnCount({ |
| 1299 | providerThread, |
| 1300 | target: { |
| 1301 | type: "provider_turn", |
| 1302 | checkpointId: CheckpointId.make("checkpoint-first"), |
| 1303 | appRunOrdinal: 1, |
| 1304 | providerTurn: firstTurn, |
| 1305 | }, |
| 1306 | providerThreadTurns: [goalCommandTurn, interruptedTurn, runningTurn, secondTurn, firstTurn], |
| 1307 | }); |
| 1308 | |
| 1309 | assert.equal(numTurns, 2); |
| 1310 | }), |
| 1311 | ); |
| 1312 | }); |
| 1313 | |
| 1314 | describe("CodexAdapterV2 fork boundary", () => { |
| 1315 | const providerThreadId = ProviderThreadId.make("provider-thread-codex-fork-boundary"); |
| 1316 | const makeProviderThread = (now: DateTime.Utc): OrchestrationV2ProviderThread => ({ |
| 1317 | id: providerThreadId, |
| 1318 | driver: CodexAdapterV2.CODEX_DRIVER_KIND, |
| 1319 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 1320 | providerSessionId: ProviderSessionId.make("provider-session-codex-fork-boundary"), |
| 1321 | appThreadId: ThreadId.make("thread-codex-fork-boundary"), |
| 1322 | ownerNodeId: null, |
| 1323 | nativeThreadRef: { |
| 1324 | driver: CodexAdapterV2.CODEX_DRIVER_KIND, |
| 1325 | nativeId: "native-thread-codex-fork-boundary", |
| 1326 | strength: "strong", |
| 1327 | }, |
| 1328 | nativeConversationHeadRef: null, |
| 1329 | status: "idle", |
| 1330 | firstRunOrdinal: 1, |
| 1331 | lastRunOrdinal: 2, |
| 1332 | handoffIds: [], |
| 1333 | forkedFrom: null, |
| 1334 | createdAt: now, |
| 1335 | updatedAt: now, |
| 1336 | }); |
| 1337 | const makeProviderTurn = ( |
| 1338 | id: string, |
| 1339 | ordinal: number, |
| 1340 | nativeId: string | null, |
| 1341 | now: DateTime.Utc, |
| 1342 | ): OrchestrationV2ProviderTurn => ({ |
| 1343 | id: ProviderTurnId.make(id), |
| 1344 | providerThreadId, |
| 1345 | nodeId: NodeId.make(`node-${id}`), |
| 1346 | runAttemptId: RunAttemptId.make(`run-attempt-${id}`), |
| 1347 | nativeTurnRef: |
| 1348 | nativeId === null |
| 1349 | ? { driver: CodexAdapterV2.CODEX_DRIVER_KIND, nativeId: null, strength: "none" } |
| 1350 | : { driver: CodexAdapterV2.CODEX_DRIVER_KIND, nativeId, strength: "strong" }, |
| 1351 | ordinal, |
| 1352 | status: "completed", |
| 1353 | startedAt: now, |
| 1354 | completedAt: now, |
| 1355 | }); |
| 1356 | |
| 1357 | it.effect("resolves the selected provider turn to an inclusive native fork boundary", () => |
| 1358 | Effect.gen(function* () { |
| 1359 | const now = yield* DateTime.now; |
| 1360 | const firstTurn = makeProviderTurn("provider-turn-first", 1, "native-turn-first", now); |
| 1361 | const secondTurn = makeProviderTurn("provider-turn-second", 2, "native-turn-second", now); |
| 1362 | |
| 1363 | const boundary = yield* CodexAdapterV2.resolveCodexForkBoundary({ |
| 1364 | sourceProviderThread: makeProviderThread(now), |
| 1365 | sourceProviderTurns: [firstTurn, secondTurn], |
| 1366 | providerTurnId: firstTurn.id, |
| 1367 | targetThreadId: ThreadId.make("thread-codex-fork-target"), |
| 1368 | }); |
| 1369 | |
| 1370 | assert.deepEqual(boundary, { |
| 1371 | lastTurnId: "native-turn-first", |
| 1372 | rollbackTurnCount: 0, |
| 1373 | }); |
| 1374 | }), |
| 1375 | ); |
| 1376 | |
| 1377 | it.effect("resolves the latest source turn to a native fork boundary without rollback", () => |
| 1378 | Effect.gen(function* () { |
| 1379 | const now = yield* DateTime.now; |
| 1380 | const firstTurn = makeProviderTurn("provider-turn-first", 1, "native-turn-first", now); |
| 1381 | const secondTurn = makeProviderTurn("provider-turn-second", 2, "native-turn-second", now); |
| 1382 | |
| 1383 | const boundary = yield* CodexAdapterV2.resolveCodexForkBoundary({ |
| 1384 | sourceProviderThread: makeProviderThread(now), |
| 1385 | sourceProviderTurns: [firstTurn, secondTurn], |
| 1386 | providerTurnId: secondTurn.id, |
| 1387 | targetThreadId: ThreadId.make("thread-codex-fork-target"), |
| 1388 | }); |
| 1389 | |
| 1390 | assert.deepEqual(boundary, { |
| 1391 | lastTurnId: "native-turn-second", |
| 1392 | rollbackTurnCount: 0, |
| 1393 | }); |
| 1394 | }), |
| 1395 | ); |
| 1396 | |
| 1397 | it.effect("keeps the rollback-count fallback when the boundary turn lacks a native id", () => |
| 1398 | Effect.gen(function* () { |
| 1399 | const now = yield* DateTime.now; |
| 1400 | const firstTurn = makeProviderTurn("provider-turn-first", 1, null, now); |
| 1401 | const secondTurn = makeProviderTurn("provider-turn-second", 2, "native-turn-second", now); |
| 1402 | |
| 1403 | const boundary = yield* CodexAdapterV2.resolveCodexForkBoundary({ |
| 1404 | sourceProviderThread: makeProviderThread(now), |
| 1405 | sourceProviderTurns: [firstTurn, secondTurn], |
| 1406 | providerTurnId: firstTurn.id, |
| 1407 | targetThreadId: ThreadId.make("thread-codex-fork-target"), |
| 1408 | }); |
| 1409 | |
| 1410 | assert.deepEqual(boundary, { lastTurnId: undefined, rollbackTurnCount: 1 }); |
| 1411 | }), |
| 1412 | ); |
| 1413 | |
| 1414 | it.effect("keeps the rollback-count fallback when the boundary turn has no native ref", () => |
| 1415 | Effect.gen(function* () { |
| 1416 | const now = yield* DateTime.now; |
| 1417 | const firstTurn: OrchestrationV2ProviderTurn = { |
| 1418 | ...makeProviderTurn("provider-turn-first", 1, "native-turn-first", now), |
| 1419 | nativeTurnRef: null, |
| 1420 | }; |
| 1421 | const secondTurn = makeProviderTurn("provider-turn-second", 2, "native-turn-second", now); |
| 1422 | |
| 1423 | const boundary = yield* CodexAdapterV2.resolveCodexForkBoundary({ |
| 1424 | sourceProviderThread: makeProviderThread(now), |
| 1425 | sourceProviderTurns: [firstTurn, secondTurn], |
| 1426 | providerTurnId: firstTurn.id, |
| 1427 | targetThreadId: ThreadId.make("thread-codex-fork-target"), |
| 1428 | }); |
| 1429 | |
| 1430 | assert.deepEqual(boundary, { lastTurnId: undefined, rollbackTurnCount: 1 }); |
| 1431 | }), |
| 1432 | ); |
| 1433 | |
| 1434 | it.effect("forks at head without a boundary when no provider turn is selected", () => |
| 1435 | Effect.gen(function* () { |
| 1436 | const now = yield* DateTime.now; |
| 1437 | |
| 1438 | const boundary = yield* CodexAdapterV2.resolveCodexForkBoundary({ |
| 1439 | sourceProviderThread: makeProviderThread(now), |
| 1440 | targetThreadId: ThreadId.make("thread-codex-fork-target"), |
| 1441 | }); |
| 1442 | |
| 1443 | assert.deepEqual(boundary, { lastTurnId: undefined, rollbackTurnCount: 0 }); |
| 1444 | }), |
| 1445 | ); |
| 1446 | |
| 1447 | it.effect("fails with a typed error when the selected source turn is missing", () => |
| 1448 | Effect.gen(function* () { |
| 1449 | const now = yield* DateTime.now; |
| 1450 | const firstTurn = makeProviderTurn("provider-turn-first", 1, "native-turn-first", now); |
| 1451 | |
| 1452 | const error = yield* Effect.flip( |
| 1453 | CodexAdapterV2.resolveCodexForkBoundary({ |
| 1454 | sourceProviderThread: makeProviderThread(now), |
| 1455 | sourceProviderTurns: [firstTurn], |
| 1456 | providerTurnId: ProviderTurnId.make("provider-turn-missing"), |
| 1457 | targetThreadId: ThreadId.make("thread-codex-fork-target"), |
| 1458 | }), |
| 1459 | ); |
| 1460 | |
| 1461 | assert.instanceOf(error, ProviderAdapter.ProviderAdapterForkThreadError); |
| 1462 | assert.include(String(error.cause), "provider-turn-missing"); |
| 1463 | }), |
| 1464 | ); |
| 1465 | }); |
| 1466 | |
| 1467 | describe("CodexAdapterV2 skill mentions", () => { |
| 1468 | it("sends currency-sigil skill mentions as the $ mention Codex parses", () => { |
| 1469 | const cases: ReadonlyArray<readonly [string, string]> = [ |
| 1470 | ["€review do it", "$review do it"], |
| 1471 | ["£ship", "$ship"], |
| 1472 | ["please ¥review this diff", "please $review this diff"], |
| 1473 | ["first line\n₹ship it", "first line\n$ship it"], |
| 1474 | ["𑿝review then €2spec", "$review then $2spec"], |
| 1475 | ["$review", "$review"], |
| 1476 | ["costs €20", "costs €20"], |
| 1477 | ["€5k", "€5k"], |
| 1478 | ["budget €100M or €1e6", "budget €100M or €1e6"], |
| 1479 | ["5€review", "5€review"], |
| 1480 | ]; |
| 1481 | for (const [text, expected] of cases) { |
| 1482 | assert.equal(CodexAdapterV2.codexSkillMentionText(text), expected, text); |
| 1483 | } |
| 1484 | }); |
| 1485 | }); |
| 1486 | |
| 1487 | describe("CodexAdapterV2 background command detail", () => { |
| 1488 | it("summarizes command, exit code, and output tail", () => { |
| 1489 | assert.equal( |
| 1490 | CodexAdapterV2.codexBackgroundCommandDetail({ |
| 1491 | command: "sleep 20 && echo CODEX_BG_WAKE_DONE", |
| 1492 | exitCode: 0, |
| 1493 | aggregatedOutput: "CODEX_BG_WAKE_DONE\n", |
| 1494 | }), |
| 1495 | "Background command completed (exit 0): sleep 20 && echo CODEX_BG_WAKE_DONE\n\n" + |
| 1496 | "Output tail:\nCODEX_BG_WAKE_DONE", |
| 1497 | ); |
| 1498 | }); |
| 1499 | |
| 1500 | it("omits the output section and exit code when absent", () => { |
| 1501 | assert.equal( |
| 1502 | CodexAdapterV2.codexBackgroundCommandDetail({ |
| 1503 | command: "sleep 20", |
| 1504 | exitCode: null, |
| 1505 | aggregatedOutput: null, |
| 1506 | }), |
| 1507 | "Background command completed: sleep 20", |
| 1508 | ); |
| 1509 | }); |
| 1510 | |
| 1511 | it("truncates long commands and keeps only the output tail", () => { |
| 1512 | const detail = CodexAdapterV2.codexBackgroundCommandDetail({ |
| 1513 | command: "x".repeat(300), |
| 1514 | exitCode: 1, |
| 1515 | aggregatedOutput: `${"y".repeat(2000)}TAIL`, |
| 1516 | }); |
| 1517 | assert.include(detail, `(exit 1): ${"x".repeat(200)}...`); |
| 1518 | assert.include(detail, "Output tail:\n..."); |
| 1519 | assert.include(detail, "TAIL"); |
| 1520 | assert.notInclude(detail, "y".repeat(1001)); |
| 1521 | }); |
| 1522 | }); |
| 1523 | |
| 1524 | const DEFAULT_CODEX_SETTINGS = Schema.decodeSync(CodexSettings)({}); |
| 1525 | const CODEX_TEST_MODEL_SELECTION = { |
| 1526 | instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, |
| 1527 | model: "gpt-5.4", |
| 1528 | } satisfies ModelSelection; |
| 1529 | const CODEX_TEST_RUNTIME_POLICY = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ |
| 1530 | runtimeMode: "full-access", |
| 1531 | interactionMode: "default", |
| 1532 | cwd: "/workspace", |
| 1533 | }); |
| 1534 | |
| 1535 | function makeCodexTestAppThread(input: { |
| 1536 | readonly threadId: ThreadId; |
| 1537 | readonly providerThread: OrchestrationV2ProviderThread; |
| 1538 | readonly now: DateTime.Utc; |
| 1539 | }): OrchestrationV2AppThread { |
| 1540 | return { |
| 1541 | createdBy: "user", |
| 1542 | creationSource: "web", |
| 1543 | id: input.threadId, |
| 1544 | projectId: ProjectId.make(`project-${input.threadId}`), |
| 1545 | title: "Codex continuation test", |
| 1546 | providerInstanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, |
| 1547 | modelSelection: CODEX_TEST_MODEL_SELECTION, |
| 1548 | runtimeMode: "full-access", |
| 1549 | interactionMode: "default", |
| 1550 | branch: null, |
| 1551 | worktreePath: null, |
| 1552 | activeProviderThreadId: input.providerThread.id, |
| 1553 | lineage: { |
| 1554 | parentThreadId: null, |
| 1555 | relationshipToParent: null, |
| 1556 | rootThreadId: input.threadId, |
| 1557 | }, |
| 1558 | forkedFrom: null, |
| 1559 | createdAt: input.now, |
| 1560 | updatedAt: input.now, |
| 1561 | archivedAt: null, |
| 1562 | settledOverride: null, |
| 1563 | settledAt: null, |
| 1564 | lastVisitedAt: null, |
| 1565 | deletedAt: null, |
| 1566 | }; |
| 1567 | } |
| 1568 | |
| 1569 | function makeCodexTestTurnInput(input: { |
| 1570 | readonly threadId: ThreadId; |
| 1571 | readonly providerThread: OrchestrationV2ProviderThread; |
| 1572 | readonly now: DateTime.Utc; |
| 1573 | readonly attemptId: RunAttemptId; |
| 1574 | readonly text: string; |
| 1575 | }): ProviderAdapter.ProviderAdapterV2TurnInput { |
| 1576 | return { |
| 1577 | appThread: makeCodexTestAppThread(input), |
| 1578 | threadId: input.threadId, |
| 1579 | runId: RunId.make(`run-${input.attemptId}`), |
| 1580 | runOrdinal: 1, |
| 1581 | providerTurnOrdinal: 1, |
| 1582 | attemptId: input.attemptId, |
| 1583 | rootNodeId: NodeId.make(`node-${input.attemptId}`), |
| 1584 | providerThread: input.providerThread, |
| 1585 | message: { |
| 1586 | createdBy: "user", |
| 1587 | creationSource: "web", |
| 1588 | messageId: MessageId.make(`message-${input.attemptId}`), |
| 1589 | text: input.text, |
| 1590 | attachments: [], |
| 1591 | }, |
| 1592 | modelSelection: CODEX_TEST_MODEL_SELECTION, |
| 1593 | runtimePolicy: CODEX_TEST_RUNTIME_POLICY, |
| 1594 | }; |
| 1595 | } |
| 1596 | |
| 1597 | function makeCodexReplayTurn(input: { |
| 1598 | readonly id: string; |
| 1599 | readonly status: "inProgress" | "completed" | "interrupted" | "failed"; |
| 1600 | }): Record<string, unknown> { |
| 1601 | const terminal = |
| 1602 | input.status === "completed" || input.status === "interrupted" || input.status === "failed"; |
| 1603 | return { |
| 1604 | id: input.id, |
| 1605 | items: [], |
| 1606 | itemsView: "notLoaded", |
| 1607 | status: input.status, |
| 1608 | error: null, |
| 1609 | startedAt: 1782622440, |
| 1610 | completedAt: terminal ? 1782622450 : null, |
| 1611 | durationMs: null, |
| 1612 | }; |
| 1613 | } |
| 1614 | |
| 1615 | function codexReplayPreamble(input: { |
| 1616 | readonly nativeThreadId: string; |
| 1617 | readonly nativeTurnId: string; |
| 1618 | readonly prompt: string; |
| 1619 | /** Text the adapter should send, when it differs from what the user typed. */ |
| 1620 | readonly sentPrompt?: string; |
| 1621 | }): Array<CodexReplay.CodexAppServerReplayEntry> { |
| 1622 | return [ |
| 1623 | { |
| 1624 | type: "expect_outbound", |
| 1625 | label: "initialize", |
| 1626 | frame: { |
| 1627 | id: 1, |
| 1628 | method: "initialize", |
| 1629 | params: { |
| 1630 | clientInfo: { name: "T3 Code", title: "T3 Code", version: packageJson.version }, |
| 1631 | capabilities: { |
| 1632 | experimentalApi: true, |
| 1633 | optOutNotificationMethods: ["turn/diff/updated"], |
| 1634 | extensions: { |
| 1635 | "io.modelcontextprotocol/ui": { mimeTypes: ["text/html;profile=mcp-app"] }, |
| 1636 | }, |
| 1637 | }, |
| 1638 | }, |
| 1639 | }, |
| 1640 | }, |
| 1641 | { |
| 1642 | type: "emit_inbound", |
| 1643 | label: "initialize", |
| 1644 | frame: { |
| 1645 | id: 1, |
| 1646 | result: { |
| 1647 | userAgent: "T3 Code/0.156.1", |
| 1648 | codexHome: "/tmp/codex-home", |
| 1649 | platformFamily: "unix", |
| 1650 | platformOs: "macos", |
| 1651 | }, |
| 1652 | }, |
| 1653 | }, |
| 1654 | { type: "expect_outbound", label: "initialized", frame: { method: "initialized" } }, |
| 1655 | { |
| 1656 | type: "expect_outbound", |
| 1657 | label: "thread/start", |
| 1658 | frame: { |
| 1659 | id: 2, |
| 1660 | method: "thread/start", |
| 1661 | params: { config: CodexAdapterV2.CODEX_THREAD_CONFIG }, |
| 1662 | }, |
| 1663 | }, |
| 1664 | { |
| 1665 | type: "emit_inbound", |
| 1666 | label: "thread/start", |
| 1667 | frame: { |
| 1668 | id: 2, |
| 1669 | result: { |
| 1670 | thread: { |
| 1671 | id: input.nativeThreadId, |
| 1672 | sessionId: input.nativeThreadId, |
| 1673 | forkedFromId: null, |
| 1674 | preview: "", |
| 1675 | ephemeral: false, |
| 1676 | modelProvider: "openai", |
| 1677 | createdAt: 1782622440, |
| 1678 | updatedAt: 1782622440, |
| 1679 | status: { type: "idle" }, |
| 1680 | path: `/tmp/${input.nativeThreadId}.jsonl`, |
| 1681 | cwd: "/workspace", |
| 1682 | cliVersion: "0.144.0", |
| 1683 | source: "vscode", |
| 1684 | threadSource: null, |
| 1685 | agentNickname: null, |
| 1686 | agentRole: null, |
| 1687 | gitInfo: null, |
| 1688 | name: null, |
| 1689 | turns: [], |
| 1690 | }, |
| 1691 | model: "gpt-5.4", |
| 1692 | modelProvider: "openai", |
| 1693 | serviceTier: null, |
| 1694 | cwd: "/workspace", |
| 1695 | instructionSources: [], |
| 1696 | approvalPolicy: "on-request", |
| 1697 | approvalsReviewer: "user", |
| 1698 | sandbox: { type: "workspaceWrite", writableRoots: [], networkAccess: false }, |
| 1699 | reasoningEffort: "medium", |
| 1700 | }, |
| 1701 | }, |
| 1702 | }, |
| 1703 | { |
| 1704 | type: "expect_outbound", |
| 1705 | label: "turn/start", |
| 1706 | frame: { |
| 1707 | id: 3, |
| 1708 | method: "turn/start", |
| 1709 | params: { |
| 1710 | threadId: input.nativeThreadId, |
| 1711 | input: [{ type: "text", text: input.sentPrompt ?? input.prompt }], |
| 1712 | cwd: "/workspace", |
| 1713 | model: "gpt-5.4", |
| 1714 | approvalPolicy: "never", |
| 1715 | approvalsReviewer: "user", |
| 1716 | sandboxPolicy: { type: "dangerFullAccess" }, |
| 1717 | summary: "detailed", |
| 1718 | }, |
| 1719 | }, |
| 1720 | }, |
| 1721 | { |
| 1722 | type: "emit_inbound", |
| 1723 | label: "turn/start", |
| 1724 | frame: { |
| 1725 | id: 3, |
| 1726 | result: { turn: makeCodexReplayTurn({ id: input.nativeTurnId, status: "inProgress" }) }, |
| 1727 | }, |
| 1728 | }, |
| 1729 | { |
| 1730 | type: "emit_inbound", |
| 1731 | label: "turn/started", |
| 1732 | frame: { |
| 1733 | method: "turn/started", |
| 1734 | params: { |
| 1735 | threadId: input.nativeThreadId, |
| 1736 | turn: makeCodexReplayTurn({ id: input.nativeTurnId, status: "inProgress" }), |
| 1737 | }, |
| 1738 | }, |
| 1739 | }, |
| 1740 | ]; |
| 1741 | } |
| 1742 | |
| 1743 | function makeCodexReplayTranscript(input: { |
| 1744 | readonly scenario: string; |
| 1745 | readonly entries: ReadonlyArray<CodexReplay.CodexAppServerReplayEntry>; |
| 1746 | }): CodexReplay.CodexAppServerReplayTranscript { |
| 1747 | return { |
| 1748 | provider: "codex", |
| 1749 | protocol: "codex.app-server", |
| 1750 | version: "0.144.0", |
| 1751 | scenario: input.scenario, |
| 1752 | entries: input.entries, |
| 1753 | }; |
| 1754 | } |
| 1755 | |
| 1756 | function withReplayRequestId( |
| 1757 | entry: CodexReplay.CodexAppServerReplayEntry, |
| 1758 | id: number, |
| 1759 | ): CodexReplay.CodexAppServerReplayEntry { |
| 1760 | return entry.type !== "runtime_exit" && Predicate.isObject(entry.frame) |
| 1761 | ? { ...entry, frame: { ...entry.frame, id } } |
| 1762 | : entry; |
| 1763 | } |
| 1764 | |
| 1765 | describe("CodexAdapterV2 session initialize", () => { |
| 1766 | const openReplaySession = ( |
| 1767 | transcript: CodexReplay.CodexAppServerReplayTranscript, |
| 1768 | beforeEmitInbound?: CodexReplay.CodexAppServerReplayDriver["beforeEmitInbound"], |
| 1769 | ) => |
| 1770 | Effect.gen(function* () { |
| 1771 | const driver = yield* CodexReplay.makeReplayDriver( |
| 1772 | transcript, |
| 1773 | beforeEmitInbound === undefined ? {} : { beforeEmitInbound }, |
| 1774 | ); |
| 1775 | let initializeRequests = 0; |
| 1776 | const adapter = yield* CodexAdapterV2.makeCodexAdapterV2({ |
| 1777 | instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, |
| 1778 | settings: DEFAULT_CODEX_SETTINGS, |
| 1779 | environment: {}, |
| 1780 | clientFactory: { |
| 1781 | open: (openInput) => |
| 1782 | Layer.build(CodexReplay.layerReplayWithDriver(driver)).pipe( |
| 1783 | Effect.flatMap((context) => |
| 1784 | Effect.service(CodexClient.CodexAppServerClient).pipe(Effect.provide(context)), |
| 1785 | ), |
| 1786 | Effect.map( |
| 1787 | (client) => |
| 1788 | ({ |
| 1789 | ...client, |
| 1790 | request: (method, params) => |
| 1791 | Effect.sync(() => { |
| 1792 | if (method === "initialize") initializeRequests++; |
| 1793 | }).pipe(Effect.andThen(client.request(method, params))), |
| 1794 | }) satisfies CodexClient.CodexAppServerClient["Service"], |
| 1795 | ), |
| 1796 | Effect.mapError( |
| 1797 | (cause) => |
| 1798 | new ProviderAdapter.ProviderAdapterOpenSessionError({ |
| 1799 | driver: CodexAdapterV2.CODEX_DRIVER_KIND, |
| 1800 | providerSessionId: openInput.providerSessionId, |
| 1801 | cause, |
| 1802 | }), |
| 1803 | ), |
| 1804 | ), |
| 1805 | }, |
| 1806 | crypto: yield* Crypto.Crypto, |
| 1807 | fileSystem: yield* FileSystem.FileSystem, |
| 1808 | idAllocator: yield* IdAllocator.IdAllocatorV2, |
| 1809 | serverConfig: yield* makeReplayServerConfig(transcript.scenario).pipe(Effect.orDie), |
| 1810 | }); |
| 1811 | const runtime = yield* adapter.openSession({ |
| 1812 | threadId: ThreadId.make(`thread-${transcript.scenario}`), |
| 1813 | providerSessionId: ProviderSessionId.make(`provider-session-${transcript.scenario}`), |
| 1814 | modelSelection: CODEX_TEST_MODEL_SELECTION, |
| 1815 | runtimePolicy: CODEX_TEST_RUNTIME_POLICY, |
| 1816 | }); |
| 1817 | return { |
| 1818 | ensureThread: (threadId: string) => |
| 1819 | runtime.ensureThread({ |
| 1820 | threadId: ThreadId.make(threadId), |
| 1821 | modelSelection: CODEX_TEST_MODEL_SELECTION, |
| 1822 | runtimePolicy: CODEX_TEST_RUNTIME_POLICY, |
| 1823 | }), |
| 1824 | initializeRequests: () => initializeRequests, |
| 1825 | }; |
| 1826 | }); |
| 1827 | |
| 1828 | const replayPreamble = (nativeThreadId: string) => |
| 1829 | codexReplayPreamble({ nativeThreadId, nativeTurnId: "unused", prompt: "unused" }); |
| 1830 | |
| 1831 | it.effect("sends one initialize when two threads start on a fresh session at once", () => |
| 1832 | Effect.gen(function* () { |
| 1833 | const initializeAwaitingResponse = yield* Deferred.make<void>(); |
| 1834 | const releaseInitialize = yield* Deferred.make<void>(); |
| 1835 | // The transcript allows exactly one handshake: a second `initialize` |
| 1836 | // frame fails the replay, as Codex rejects it with "Already initialized". |
| 1837 | const session = yield* openReplaySession( |
| 1838 | makeCodexReplayTranscript({ |
| 1839 | scenario: "concurrent-initialize", |
| 1840 | entries: [ |
| 1841 | ...replayPreamble("concurrent-first").slice(0, 5), |
| 1842 | ...replayPreamble("concurrent-second") |
| 1843 | .slice(3, 5) |
| 1844 | .map((entry) => withReplayRequestId(entry, 3)), |
| 1845 | ], |
| 1846 | }), |
| 1847 | (entry) => |
| 1848 | entry.label === "initialize" |
| 1849 | ? Deferred.succeed(initializeAwaitingResponse, undefined).pipe( |
| 1850 | Effect.andThen(Deferred.await(releaseInitialize)), |
| 1851 | ) |
| 1852 | : Effect.void, |
| 1853 | ); |
| 1854 | |
| 1855 | const first = yield* session |
| 1856 | .ensureThread("thread-concurrent-first") |
| 1857 | .pipe(Effect.forkChild({ startImmediately: true })); |
| 1858 | yield* Deferred.await(initializeAwaitingResponse); |
| 1859 | // The second thread arrives while the handshake is still unanswered. |
| 1860 | const second = yield* session |
| 1861 | .ensureThread("thread-concurrent-second") |
| 1862 | .pipe(Effect.forkChild({ startImmediately: true })); |
| 1863 | yield* Deferred.succeed(releaseInitialize, undefined); |
| 1864 | |
| 1865 | const providerThreads = [yield* Fiber.join(first), yield* Fiber.join(second)]; |
| 1866 | assert.equal(session.initializeRequests(), 1); |
| 1867 | assert.sameMembers( |
| 1868 | providerThreads.map((providerThread) => providerThread.nativeThreadRef?.nativeId), |
| 1869 | ["concurrent-first", "concurrent-second"], |
| 1870 | ); |
| 1871 | }).pipe( |
| 1872 | Effect.scoped, |
| 1873 | Effect.provide( |
| 1874 | Layer.mergeAll( |
| 1875 | IdAllocator.layer, |
| 1876 | McpProviderSessions.layer, |
| 1877 | ScriptProgress.layerUnavailable, |
| 1878 | NodeServices.layer, |
| 1879 | ), |
| 1880 | ), |
| 1881 | ), |
| 1882 | ); |
| 1883 | |
| 1884 | it.effect("keeps progress credentials local to each Codex thread on a shared app-server", () => |
| 1885 | Effect.gen(function* () { |
| 1886 | const progressEnvironment = (threadId: string) => ({ |
| 1887 | NODE_OPTIONS: "--import=file:///private/t3-progress.mjs", |
| 1888 | T3_SCRIPT_PROGRESS_ENDPOINT: "http://127.0.0.1:1234/progress", |
| 1889 | T3_SCRIPT_PROGRESS_TOKEN: threadId, |
| 1890 | }); |
| 1891 | const entries: Array<CodexReplay.CodexAppServerReplayEntry> = replayPreamble( |
| 1892 | "progress-first", |
| 1893 | ).slice(0, 3); |
| 1894 | for (const [index, nativeId] of ["progress-first", "progress-second"].entries()) { |
| 1895 | const readId = index * 2 + 2; |
| 1896 | entries.push( |
| 1897 | { |
| 1898 | type: "expect_outbound", |
| 1899 | label: "config/read", |
| 1900 | frame: { |
| 1901 | id: readId, |
| 1902 | method: "config/read", |
| 1903 | params: { cwd: "/workspace", includeLayers: false }, |
| 1904 | }, |
| 1905 | }, |
| 1906 | { |
| 1907 | type: "emit_inbound", |
| 1908 | label: "config/read", |
| 1909 | frame: { |
| 1910 | id: readId, |
| 1911 | result: { |
| 1912 | origins: {}, |
| 1913 | config: { |
| 1914 | shell_environment_policy: { |
| 1915 | set: { KEEP_ME: "yes", NODE_OPTIONS: "--trace-warnings" }, |
| 1916 | include_only: ["PATH"], |
| 1917 | exclude: ["SECRET_*"], |
| 1918 | }, |
| 1919 | }, |
| 1920 | }, |
| 1921 | }, |
| 1922 | }, |
| 1923 | { |
| 1924 | type: "expect_outbound", |
| 1925 | label: "thread/start", |
| 1926 | frame: { |
| 1927 | id: readId + 1, |
| 1928 | method: "thread/start", |
| 1929 | params: { |
| 1930 | config: { |
| 1931 | ...CodexAdapterV2.CODEX_THREAD_CONFIG, |
| 1932 | "shell_environment_policy.set": { |
| 1933 | KEEP_ME: "yes", |
| 1934 | ...progressEnvironment(nativeId), |
| 1935 | NODE_OPTIONS: "--import=file:///private/t3-progress.mjs --trace-warnings", |
| 1936 | }, |
| 1937 | "shell_environment_policy.include_only": [ |
| 1938 | "PATH", |
| 1939 | "NODE_OPTIONS", |
| 1940 | "T3_SCRIPT_PROGRESS_ENDPOINT", |
| 1941 | "T3_SCRIPT_PROGRESS_TOKEN", |
| 1942 | ], |
| 1943 | }, |
| 1944 | }, |
| 1945 | }, |
| 1946 | }, |
| 1947 | ...replayPreamble(nativeId) |
| 1948 | .slice(4, 5) |
| 1949 | .map((entry) => withReplayRequestId(entry, readId + 1)), |
| 1950 | ); |
| 1951 | } |
| 1952 | const session = yield* openReplaySession( |
| 1953 | makeCodexReplayTranscript({ scenario: "thread-local-progress", entries }), |
| 1954 | ); |
| 1955 | const first = yield* session.ensureThread("progress-first"); |
| 1956 | const second = yield* session.ensureThread("progress-second"); |
| 1957 | assert.equal(first.nativeThreadRef?.nativeId, "progress-first"); |
| 1958 | assert.equal(second.nativeThreadRef?.nativeId, "progress-second"); |
| 1959 | assert.equal(session.initializeRequests(), 1); |
| 1960 | }).pipe( |
| 1961 | Effect.scoped, |
| 1962 | Effect.provideService(ScriptProgress.ScriptProgress, { |
| 1963 | environmentForThread: (threadId) => |
| 1964 | Effect.succeed({ |
| 1965 | NODE_OPTIONS: "--import=file:///private/t3-progress.mjs", |
| 1966 | T3_SCRIPT_PROGRESS_ENDPOINT: "http://127.0.0.1:1234/progress", |
| 1967 | T3_SCRIPT_PROGRESS_TOKEN: threadId, |
| 1968 | }), |
| 1969 | subscribe: () => Stream.empty, |
| 1970 | }), |
| 1971 | Effect.provide( |
| 1972 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), |
| 1973 | ), |
| 1974 | ), |
| 1975 | ); |
| 1976 | |
| 1977 | it.effect("retries initialize after a failed handshake", () => |
| 1978 | Effect.gen(function* () { |
| 1979 | const preamble = replayPreamble("initialize-retry"); |
| 1980 | const entries: Array<CodexReplay.CodexAppServerReplayEntry> = [ |
| 1981 | ...preamble.slice(0, 1), |
| 1982 | { |
| 1983 | type: "emit_inbound", |
| 1984 | label: "initialize", |
| 1985 | frame: { id: 1, error: { code: -32603, message: "Codex is not ready." } }, |
| 1986 | }, |
| 1987 | ...preamble.slice(0, 2).map((entry) => withReplayRequestId(entry, 2)), |
| 1988 | ...preamble.slice(2, 3), |
| 1989 | ...preamble.slice(3, 5).map((entry) => withReplayRequestId(entry, 3)), |
| 1990 | ]; |
| 1991 | const session = yield* openReplaySession( |
| 1992 | makeCodexReplayTranscript({ scenario: "initialize-retry", entries }), |
| 1993 | ); |
| 1994 | |
| 1995 | const failure = yield* session.ensureThread("thread-initialize-retry").pipe(Effect.flip); |
| 1996 | assert.equal(failure._tag, "ProviderAdapterEnsureThreadError"); |
| 1997 | const providerThread = yield* session.ensureThread("thread-initialize-retry"); |
| 1998 | assert.equal(providerThread.nativeThreadRef?.nativeId, "initialize-retry"); |
| 1999 | assert.equal(session.initializeRequests(), 2); |
| 2000 | }).pipe( |
| 2001 | Effect.scoped, |
| 2002 | Effect.provide( |
| 2003 | Layer.mergeAll( |
| 2004 | IdAllocator.layer, |
| 2005 | McpProviderSessions.layer, |
| 2006 | ScriptProgress.layerUnavailable, |
| 2007 | NodeServices.layer, |
| 2008 | ), |
| 2009 | ), |
| 2010 | ), |
| 2011 | ); |
| 2012 | |
| 2013 | it.effect("completes the handshake after a caller is interrupted mid-initialize", () => |
| 2014 | Effect.gen(function* () { |
| 2015 | const initializeAwaitingResponse = yield* Deferred.make<void>(); |
| 2016 | const releaseInitialize = yield* Deferred.make<void>(); |
| 2017 | const preamble = replayPreamble("initialize-interrupted"); |
| 2018 | const session = yield* openReplaySession( |
| 2019 | makeCodexReplayTranscript({ |
| 2020 | scenario: "initialize-interrupted", |
| 2021 | entries: [ |
| 2022 | ...preamble.slice(0, 2), |
| 2023 | // Codex handled the interrupted caller's `initialize`, so it |
| 2024 | // rejects the next one. |
| 2025 | ...preamble.slice(0, 1).map((entry) => withReplayRequestId(entry, 2)), |
| 2026 | { |
| 2027 | type: "emit_inbound", |
| 2028 | label: "initialize-rejected", |
| 2029 | frame: { id: 2, error: { code: -32600, message: "Already initialized" } }, |
| 2030 | }, |
| 2031 | ...preamble.slice(2, 3), |
| 2032 | ...preamble.slice(3, 5).map((entry) => withReplayRequestId(entry, 3)), |
| 2033 | ], |
| 2034 | }), |
| 2035 | (entry) => |
| 2036 | entry.label === "initialize" |
| 2037 | ? Deferred.succeed(initializeAwaitingResponse, undefined).pipe( |
| 2038 | Effect.andThen(Deferred.await(releaseInitialize)), |
| 2039 | ) |
| 2040 | : Effect.void, |
| 2041 | ); |
| 2042 | |
| 2043 | const interrupted = yield* session |
| 2044 | .ensureThread("thread-initialize-interrupted") |
| 2045 | .pipe(Effect.forkChild({ startImmediately: true })); |
| 2046 | yield* Deferred.await(initializeAwaitingResponse); |
| 2047 | yield* Fiber.interrupt(interrupted); |
| 2048 | yield* Deferred.succeed(releaseInitialize, undefined); |
| 2049 | |
| 2050 | const providerThread = yield* session.ensureThread("thread-initialize-interrupted"); |
| 2051 | assert.equal(providerThread.nativeThreadRef?.nativeId, "initialize-interrupted"); |
| 2052 | assert.equal(session.initializeRequests(), 2); |
| 2053 | }).pipe( |
| 2054 | Effect.scoped, |
| 2055 | Effect.provide( |
| 2056 | Layer.mergeAll( |
| 2057 | IdAllocator.layer, |
| 2058 | McpProviderSessions.layer, |
| 2059 | ScriptProgress.layerUnavailable, |
| 2060 | NodeServices.layer, |
| 2061 | ), |
| 2062 | ), |
| 2063 | ), |
| 2064 | ); |
| 2065 | }); |
| 2066 | |
| 2067 | describe("CodexAdapterV2 post-settle continuation", () => { |
| 2068 | const awaitUntil = (predicate: () => boolean, label: string): Effect.Effect<void> => |
| 2069 | Effect.gen(function* () { |
| 2070 | for (let attempt = 0; attempt < 5000; attempt++) { |
| 2071 | if (predicate()) { |
| 2072 | return; |
| 2073 | } |
| 2074 | yield* Effect.yieldNow; |
| 2075 | } |
| 2076 | return yield* Effect.die(`Timed out waiting for ${label}.`); |
| 2077 | }); |
| 2078 | |
| 2079 | const makeCodexReplayHarness = ( |
| 2080 | transcript: CodexReplay.CodexAppServerReplayTranscript, |
| 2081 | onEvent: (event: ProviderAdapter.ProviderAdapterV2Event) => Effect.Effect<unknown> = () => |
| 2082 | Effect.void, |
| 2083 | onRequest: (method: string, params: unknown) => Effect.Effect<void> = () => Effect.void, |
| 2084 | readChildMetadata?: Parameters<typeof withCodexReplayChildMetadata>[2], |
| 2085 | ) => |
| 2086 | Effect.gen(function* () { |
| 2087 | const fileSystem = yield* FileSystem.FileSystem; |
| 2088 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 2089 | const serverConfig = yield* makeReplayServerConfig(transcript.scenario).pipe(Effect.orDie); |
| 2090 | const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> = |
| 2091 | []; |
| 2092 | const clientFactory: CodexAdapterV2.CodexAppServerClientFactoryShape = { |
| 2093 | open: (openInput) => |
| 2094 | Layer.build(CodexReplay.layerReplay(transcript)).pipe( |
| 2095 | Effect.mapError( |
| 2096 | (cause) => |
| 2097 | new ProviderAdapter.ProviderAdapterOpenSessionError({ |
| 2098 | driver: CodexAdapterV2.CODEX_DRIVER_KIND, |
| 2099 | providerSessionId: openInput.providerSessionId, |
| 2100 | cause, |
| 2101 | }), |
| 2102 | ), |
| 2103 | Effect.flatMap((context) => |
| 2104 | Effect.service(CodexClient.CodexAppServerClient).pipe( |
| 2105 | Effect.map((client) => |
| 2106 | withCodexReplayChildMetadata(client, transcript, readChildMetadata), |
| 2107 | ), |
| 2108 | Effect.map( |
| 2109 | (client) => |
| 2110 | ({ |
| 2111 | ...client, |
| 2112 | request: (method, params) => |
| 2113 | onRequest(method, params).pipe( |
| 2114 | Effect.andThen(client.request(method, params)), |
| 2115 | ), |
| 2116 | }) satisfies CodexClient.CodexAppServerClient["Service"], |
| 2117 | ), |
| 2118 | Effect.provide(context), |
| 2119 | ), |
| 2120 | ), |
| 2121 | ), |
| 2122 | }; |
| 2123 | const adapter = yield* CodexAdapterV2.makeCodexAdapterV2({ |
| 2124 | instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, |
| 2125 | settings: DEFAULT_CODEX_SETTINGS, |
| 2126 | environment: {}, |
| 2127 | clientFactory, |
| 2128 | crypto: yield* Crypto.Crypto, |
| 2129 | fileSystem, |
| 2130 | idAllocator, |
| 2131 | serverConfig, |
| 2132 | continuationRequests: { |
| 2133 | offer: (request) => |
| 2134 | Effect.sync(() => { |
| 2135 | continuationRequests.push(request); |
| 2136 | }), |
| 2137 | }, |
| 2138 | }); |
| 2139 | const threadId = ThreadId.make(`thread-${transcript.scenario}`); |
| 2140 | const runtime = yield* adapter.openSession({ |
| 2141 | threadId, |
| 2142 | providerSessionId: ProviderSessionId.make(`provider-session-${transcript.scenario}`), |
| 2143 | modelSelection: CODEX_TEST_MODEL_SELECTION, |
| 2144 | runtimePolicy: CODEX_TEST_RUNTIME_POLICY, |
| 2145 | }); |
| 2146 | const providerThread = yield* runtime.ensureThread({ |
| 2147 | threadId, |
| 2148 | modelSelection: CODEX_TEST_MODEL_SELECTION, |
| 2149 | runtimePolicy: CODEX_TEST_RUNTIME_POLICY, |
| 2150 | }); |
| 2151 | const events: Array<ProviderAdapter.ProviderAdapterV2Event> = []; |
| 2152 | const firstTerminal = yield* Deferred.make<void>(); |
| 2153 | yield* runtime.events.pipe( |
| 2154 | Stream.runForEach((event) => |
| 2155 | Effect.sync(() => { |
| 2156 | events.push(event); |
| 2157 | }).pipe( |
| 2158 | Effect.andThen( |
| 2159 | event.type === "turn.terminal" |
| 2160 | ? Deferred.succeed(firstTerminal, undefined) |
| 2161 | : Effect.void, |
| 2162 | ), |
| 2163 | Effect.andThen(onEvent(event)), |
| 2164 | ), |
| 2165 | ), |
| 2166 | Effect.forkScoped, |
| 2167 | ); |
| 2168 | if (runtime.hasPendingBackgroundWork === undefined) { |
| 2169 | return yield* Effect.die("Codex adapter runtime must expose hasPendingBackgroundWork."); |
| 2170 | } |
| 2171 | const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; |
| 2172 | const terminalEvents = () => |
| 2173 | events.filter( |
| 2174 | ( |
| 2175 | event, |
| 2176 | ): event is Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "turn.terminal" }> => |
| 2177 | event.type === "turn.terminal", |
| 2178 | ); |
| 2179 | const subagentUpdates = () => |
| 2180 | events.filter( |
| 2181 | ( |
| 2182 | event, |
| 2183 | ): event is Extract< |
| 2184 | ProviderAdapter.ProviderAdapterV2Event, |
| 2185 | { type: "subagent.updated" } |
| 2186 | > => event.type === "subagent.updated", |
| 2187 | ); |
| 2188 | return { |
| 2189 | runtime, |
| 2190 | providerThread, |
| 2191 | threadId, |
| 2192 | serverConfig, |
| 2193 | events, |
| 2194 | continuationRequests, |
| 2195 | terminalEvents, |
| 2196 | subagentUpdates, |
| 2197 | hasPendingBackgroundWork, |
| 2198 | firstTerminal: Deferred.await(firstTerminal), |
| 2199 | }; |
| 2200 | }); |
| 2201 | |
| 2202 | it.effect.each(["supported", "unsupported", "invalid"] as const)( |
| 2203 | "delivers native history with %s app-server protocol", |
| 2204 | (response) => |
| 2205 | Effect.gen(function* () { |
| 2206 | const nativeThreadId = `inject-${response}`; |
| 2207 | const prompt = "Only the current request"; |
| 2208 | const history: ProviderAdapter.ProviderAdapterV2HistoricalContext = { |
| 2209 | context: "Historical conversation", |
| 2210 | messages: (["user", "assistant"] as const).map((role) => ({ |
| 2211 | role, |
| 2212 | text: |
| 2213 | role === "user" |
| 2214 | ? "Original request\n" + "界".repeat(300) |
| 2215 | : "Partial interrupted work", |
| 2216 | threadId: ThreadId.make("source"), |
| 2217 | runId: RunId.make("source-run"), |
| 2218 | itemId: TurnItemId.make(`source-${role}`), |
| 2219 | providerThreadId: null, |
| 2220 | kind: `${role}_message`, |
| 2221 | status: "interrupted", |
| 2222 | })), |
| 2223 | }; |
| 2224 | const items = historyResponseItems(history.messages, history.context); |
| 2225 | const preamble = codexReplayPreamble({ |
| 2226 | nativeThreadId, |
| 2227 | nativeTurnId: "current-turn", |
| 2228 | prompt, |
| 2229 | }); |
| 2230 | const transcript = makeCodexReplayTranscript({ |
| 2231 | scenario: `inject-${response}`, |
| 2232 | entries: [ |
| 2233 | ...preamble.slice(0, -3), |
| 2234 | { |
| 2235 | type: "expect_outbound", |
| 2236 | label: "inject", |
| 2237 | frame: { |
| 2238 | id: 3, |
| 2239 | method: "thread/inject_items", |
| 2240 | params: { threadId: nativeThreadId, items }, |
| 2241 | }, |
| 2242 | }, |
| 2243 | { |
| 2244 | type: "emit_inbound", |
| 2245 | label: "inject-result", |
| 2246 | frame: |
| 2247 | response === "supported" |
| 2248 | ? { id: 3, result: {} } |
| 2249 | : { |
| 2250 | id: 3, |
| 2251 | error: { |
| 2252 | code: response === "unsupported" ? -32601 : -32602, |
| 2253 | message: "Injection rejected", |
| 2254 | }, |
| 2255 | }, |
| 2256 | }, |
| 2257 | ...(response === "invalid" |
| 2258 | ? [] |
| 2259 | : preamble |
| 2260 | .slice(-3) |
| 2261 | .map((entry) => |
| 2262 | "frame" in entry && Predicate.isObject(entry.frame) && "id" in entry.frame |
| 2263 | ? { ...entry, frame: { ...entry.frame, id: 4 } } |
| 2264 | : entry, |
| 2265 | )), |
| 2266 | ], |
| 2267 | }); |
| 2268 | const requests: string[] = []; |
| 2269 | const harness = yield* makeCodexReplayHarness( |
| 2270 | transcript, |
| 2271 | () => Effect.void, |
| 2272 | (method) => |
| 2273 | Effect.sync(() => { |
| 2274 | requests.push(method); |
| 2275 | }), |
| 2276 | ); |
| 2277 | const injection = yield* harness.runtime.injectHistory!({ |
| 2278 | providerThread: harness.providerThread, |
| 2279 | ...history, |
| 2280 | }).pipe(Effect.result); |
| 2281 | if (response === "invalid") { |
| 2282 | assert.equal(injection._tag, "Failure"); |
| 2283 | if (injection._tag === "Failure") { |
| 2284 | assert.equal(injection.failure._tag, "ProviderAdapterProtocolError"); |
| 2285 | assert.propertyVal(injection.failure.cause, "code", -32602); |
| 2286 | assert.notProperty(injection.failure, "payload"); |
| 2287 | } |
| 2288 | assert.notInclude(requests, "turn/start"); |
| 2289 | return; |
| 2290 | } |
| 2291 | assert.equal(injection._tag, "Success"); |
| 2292 | if (injection._tag === "Success") assert.equal(injection.success, response === "supported"); |
| 2293 | yield* harness.runtime.startTurn( |
| 2294 | makeCodexTestTurnInput({ |
| 2295 | threadId: harness.threadId, |
| 2296 | providerThread: harness.providerThread, |
| 2297 | now: yield* DateTime.now, |
| 2298 | attemptId: RunAttemptId.make("inject-attempt"), |
| 2299 | text: prompt, |
| 2300 | }), |
| 2301 | ); |
| 2302 | assert.equal(requests.filter((method) => method === "turn/start").length, 1); |
| 2303 | assert.isBelow(requests.indexOf("thread/inject_items"), requests.indexOf("turn/start")); |
| 2304 | }).pipe( |
| 2305 | Effect.scoped, |
| 2306 | Effect.provide( |
| 2307 | Layer.mergeAll( |
| 2308 | IdAllocator.layer, |
| 2309 | McpProviderSessions.layer, |
| 2310 | ScriptProgress.layerUnavailable, |
| 2311 | NodeServices.layer, |
| 2312 | ), |
| 2313 | ), |
| 2314 | ), |
| 2315 | ); |
| 2316 | |
| 2317 | it.effect("identifies sessions to Codex with the same client info as main", () => |
| 2318 | Effect.gen(function* () { |
| 2319 | const transcript = makeCodexReplayTranscript({ |
| 2320 | scenario: "initialize-client-info", |
| 2321 | entries: codexReplayPreamble({ |
| 2322 | nativeThreadId: "client-info-thread", |
| 2323 | nativeTurnId: "unused", |
| 2324 | prompt: "unused", |
| 2325 | }).slice(0, 5), |
| 2326 | }); |
| 2327 | const initializeParams: Array<unknown> = []; |
| 2328 | yield* makeCodexReplayHarness( |
| 2329 | transcript, |
| 2330 | () => Effect.void, |
| 2331 | (method, params) => |
| 2332 | Effect.sync(() => { |
| 2333 | if (method === "initialize") initializeParams.push(params); |
| 2334 | }), |
| 2335 | ); |
| 2336 | // Codex uses clientInfo.name as the request originator. Replays ignore the |
| 2337 | // version, so pin the whole value here. |
| 2338 | assert.deepEqual(initializeParams, [ |
| 2339 | { |
| 2340 | clientInfo: { name: "T3 Code", title: "T3 Code", version: packageJson.version }, |
| 2341 | capabilities: { |
| 2342 | experimentalApi: true, |
| 2343 | optOutNotificationMethods: ["turn/diff/updated"], |
| 2344 | extensions: { |
| 2345 | "io.modelcontextprotocol/ui": { mimeTypes: ["text/html;profile=mcp-app"] }, |
| 2346 | }, |
| 2347 | }, |
| 2348 | }, |
| 2349 | ]); |
| 2350 | }).pipe( |
| 2351 | Effect.scoped, |
| 2352 | Effect.provide( |
| 2353 | Layer.mergeAll( |
| 2354 | IdAllocator.layer, |
| 2355 | McpProviderSessions.layer, |
| 2356 | ScriptProgress.layerUnavailable, |
| 2357 | NodeServices.layer, |
| 2358 | ), |
| 2359 | ), |
| 2360 | ), |
| 2361 | ); |
| 2362 | |
| 2363 | it.effect("unsubscribes from the native thread when it is unloaded", () => |
| 2364 | Effect.gen(function* () { |
| 2365 | const nativeThreadId = "unload-thread"; |
| 2366 | const preamble = codexReplayPreamble({ |
| 2367 | nativeThreadId, |
| 2368 | nativeTurnId: "unused", |
| 2369 | prompt: "unused", |
| 2370 | }); |
| 2371 | const transcript = makeCodexReplayTranscript({ |
| 2372 | scenario: "unload-thread", |
| 2373 | entries: [ |
| 2374 | // initialize + thread/start only; no turn runs. |
| 2375 | ...preamble.slice(0, 5), |
| 2376 | { |
| 2377 | type: "expect_outbound", |
| 2378 | label: "thread/unsubscribe", |
| 2379 | frame: { id: 3, method: "thread/unsubscribe", params: { threadId: nativeThreadId } }, |
| 2380 | }, |
| 2381 | // Response shape recorded from codex app-server 0.156.1. |
| 2382 | { |
| 2383 | type: "emit_inbound", |
| 2384 | label: "thread/unsubscribe", |
| 2385 | frame: { id: 3, result: { status: "unsubscribed" } }, |
| 2386 | }, |
| 2387 | ], |
| 2388 | }); |
| 2389 | const requests: Array<string> = []; |
| 2390 | const harness = yield* makeCodexReplayHarness( |
| 2391 | transcript, |
| 2392 | () => Effect.void, |
| 2393 | (method) => Effect.sync(() => requests.push(method)), |
| 2394 | ); |
| 2395 | assert.isDefined(harness.runtime.unloadThread); |
| 2396 | yield* harness.runtime.unloadThread!({ providerThread: harness.providerThread }); |
| 2397 | assert.deepEqual(requests, ["initialize", "thread/start", "thread/unsubscribe"]); |
| 2398 | }).pipe( |
| 2399 | Effect.scoped, |
| 2400 | Effect.provide( |
| 2401 | Layer.mergeAll( |
| 2402 | IdAllocator.layer, |
| 2403 | McpProviderSessions.layer, |
| 2404 | ScriptProgress.layerUnavailable, |
| 2405 | NodeServices.layer, |
| 2406 | ), |
| 2407 | ), |
| 2408 | ), |
| 2409 | ); |
| 2410 | |
| 2411 | it.effect("keeps the app-server failure as the cause when an unload is rejected", () => |
| 2412 | Effect.gen(function* () { |
| 2413 | const nativeThreadId = "unload-thread-rejected"; |
| 2414 | const preamble = codexReplayPreamble({ |
| 2415 | nativeThreadId, |
| 2416 | nativeTurnId: "unused", |
| 2417 | prompt: "unused", |
| 2418 | }); |
| 2419 | const transcript = makeCodexReplayTranscript({ |
| 2420 | scenario: "unload-thread-rejected", |
| 2421 | entries: [ |
| 2422 | ...preamble.slice(0, 5), |
| 2423 | { |
| 2424 | type: "expect_outbound", |
| 2425 | label: "thread/unsubscribe", |
| 2426 | frame: { id: 3, method: "thread/unsubscribe", params: { threadId: nativeThreadId } }, |
| 2427 | }, |
| 2428 | { |
| 2429 | type: "emit_inbound", |
| 2430 | label: "thread/unsubscribe", |
| 2431 | frame: { id: 3, error: { code: -32600, message: "invalid thread id" } }, |
| 2432 | }, |
| 2433 | ], |
| 2434 | }); |
| 2435 | const harness = yield* makeCodexReplayHarness(transcript); |
| 2436 | const error = yield* harness.runtime.unloadThread!({ |
| 2437 | providerThread: harness.providerThread, |
| 2438 | }).pipe(Effect.flip); |
| 2439 | assert.equal(error._tag, "ProviderAdapterProtocolError"); |
| 2440 | const cause = error._tag === "ProviderAdapterProtocolError" ? error.cause : undefined; |
| 2441 | assert.equal((cause as { _tag?: string } | undefined)?._tag, "CodexAppServerRequestError"); |
| 2442 | }).pipe( |
| 2443 | Effect.scoped, |
| 2444 | Effect.provide( |
| 2445 | Layer.mergeAll( |
| 2446 | IdAllocator.layer, |
| 2447 | McpProviderSessions.layer, |
| 2448 | ScriptProgress.layerUnavailable, |
| 2449 | NodeServices.layer, |
| 2450 | ), |
| 2451 | ), |
| 2452 | ), |
| 2453 | ); |
| 2454 | |
| 2455 | it.effect("waits for native start before interrupting an acknowledged queued turn", () => |
| 2456 | Effect.gen(function* () { |
| 2457 | const nativeThreadId = "early-stop-thread"; |
| 2458 | const nativeTurnId = "early-stop-turn"; |
| 2459 | const prompt = "Run a command."; |
| 2460 | const preamble = codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt }); |
| 2461 | const transcript = makeCodexReplayTranscript({ |
| 2462 | scenario: "early-stop-await-native-start", |
| 2463 | entries: [ |
| 2464 | ...preamble.slice(0, -2), |
| 2465 | { |
| 2466 | type: "emit_inbound", |
| 2467 | label: "turn/start/queued", |
| 2468 | frame: { |
| 2469 | id: 3, |
| 2470 | result: { |
| 2471 | turn: { |
| 2472 | ...makeCodexReplayTurn({ id: nativeTurnId, status: "inProgress" }), |
| 2473 | startedAt: null, |
| 2474 | }, |
| 2475 | }, |
| 2476 | }, |
| 2477 | }, |
| 2478 | { |
| 2479 | type: "emit_inbound", |
| 2480 | label: "turn/started", |
| 2481 | afterMs: 1000, |
| 2482 | frame: { |
| 2483 | method: "turn/started", |
| 2484 | params: { |
| 2485 | threadId: nativeThreadId, |
| 2486 | turn: makeCodexReplayTurn({ id: nativeTurnId, status: "inProgress" }), |
| 2487 | }, |
| 2488 | }, |
| 2489 | }, |
| 2490 | { |
| 2491 | type: "expect_outbound", |
| 2492 | label: "turn/interrupt", |
| 2493 | frame: { |
| 2494 | id: 4, |
| 2495 | method: "turn/interrupt", |
| 2496 | params: { threadId: nativeThreadId, turnId: nativeTurnId }, |
| 2497 | }, |
| 2498 | }, |
| 2499 | { type: "emit_inbound", label: "turn/interrupt", frame: { id: 4, result: {} } }, |
| 2500 | { |
| 2501 | type: "emit_inbound", |
| 2502 | label: "turn/completed", |
| 2503 | frame: { |
| 2504 | method: "turn/completed", |
| 2505 | params: { |
| 2506 | threadId: nativeThreadId, |
| 2507 | turn: makeCodexReplayTurn({ id: nativeTurnId, status: "interrupted" }), |
| 2508 | }, |
| 2509 | }, |
| 2510 | }, |
| 2511 | ], |
| 2512 | }); |
| 2513 | let interruptSent = false; |
| 2514 | const harness = yield* makeCodexReplayHarness( |
| 2515 | transcript, |
| 2516 | () => Effect.void, |
| 2517 | (method) => |
| 2518 | Effect.sync(() => { |
| 2519 | if (method === "turn/interrupt") interruptSent = true; |
| 2520 | }), |
| 2521 | ); |
| 2522 | yield* harness.runtime.startTurn( |
| 2523 | makeCodexTestTurnInput({ |
| 2524 | threadId: harness.threadId, |
| 2525 | providerThread: harness.providerThread, |
| 2526 | now: yield* DateTime.now, |
| 2527 | attemptId: RunAttemptId.make("early-stop-attempt"), |
| 2528 | text: prompt, |
| 2529 | }), |
| 2530 | ); |
| 2531 | const providerTurnId = (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ |
| 2532 | driver: CodexAdapterV2.CODEX_DRIVER_KIND, |
| 2533 | nativeTurnId, |
| 2534 | }); |
| 2535 | const interrupt = yield* harness.runtime |
| 2536 | .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) |
| 2537 | .pipe(Effect.forkScoped); |
| 2538 | yield* TestClock.adjust("500 millis"); |
| 2539 | assert.isFalse(interruptSent, "Stop must not reach Codex before native turn/started"); |
| 2540 | yield* TestClock.adjust("500 millis"); |
| 2541 | yield* Fiber.join(interrupt); |
| 2542 | yield* harness.firstTerminal; |
| 2543 | assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); |
| 2544 | assert.lengthOf(harness.terminalEvents(), 1); |
| 2545 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 2546 | }).pipe( |
| 2547 | Effect.scoped, |
| 2548 | Effect.provide( |
| 2549 | Layer.mergeAll( |
| 2550 | IdAllocator.layer, |
| 2551 | McpProviderSessions.layer, |
| 2552 | ScriptProgress.layerUnavailable, |
| 2553 | NodeServices.layer, |
| 2554 | ), |
| 2555 | ), |
| 2556 | ), |
| 2557 | ); |
| 2558 | |
| 2559 | it.effect("settles Stop when a queued native turn fails before starting", () => |
| 2560 | Effect.gen(function* () { |
| 2561 | const nativeThreadId = "early-stop-thread"; |
| 2562 | const nativeTurnId = "early-stop-turn"; |
| 2563 | const prompt = "Run a command."; |
| 2564 | const preamble = codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt }); |
| 2565 | const transcript = makeCodexReplayTranscript({ |
| 2566 | scenario: "early-stop-failed-before-native-start", |
| 2567 | entries: [ |
| 2568 | ...preamble.slice(0, -2), |
| 2569 | { |
| 2570 | type: "emit_inbound", |
| 2571 | label: "turn/start/queued", |
| 2572 | frame: { |
| 2573 | id: 3, |
| 2574 | result: { |
| 2575 | turn: { |
| 2576 | ...makeCodexReplayTurn({ id: nativeTurnId, status: "inProgress" }), |
| 2577 | startedAt: null, |
| 2578 | }, |
| 2579 | }, |
| 2580 | }, |
| 2581 | }, |
| 2582 | { |
| 2583 | type: "emit_inbound", |
| 2584 | label: "turn/failed", |
| 2585 | afterMs: 1000, |
| 2586 | frame: { |
| 2587 | method: "turn/completed", |
| 2588 | params: { |
| 2589 | threadId: nativeThreadId, |
| 2590 | turn: { |
| 2591 | ...makeCodexReplayTurn({ id: nativeTurnId, status: "failed" }), |
| 2592 | startedAt: null, |
| 2593 | error: { |
| 2594 | message: "Failed before native start", |
| 2595 | codexErrorInfo: null, |
| 2596 | additionalDetails: null, |
| 2597 | }, |
| 2598 | }, |
| 2599 | }, |
| 2600 | }, |
| 2601 | }, |
| 2602 | ], |
| 2603 | }); |
| 2604 | let interruptSent = false; |
| 2605 | const harness = yield* makeCodexReplayHarness( |
| 2606 | transcript, |
| 2607 | () => Effect.void, |
| 2608 | (method) => |
| 2609 | Effect.sync(() => { |
| 2610 | if (method === "turn/interrupt") interruptSent = true; |
| 2611 | }), |
| 2612 | ); |
| 2613 | yield* harness.runtime.startTurn( |
| 2614 | makeCodexTestTurnInput({ |
| 2615 | threadId: harness.threadId, |
| 2616 | providerThread: harness.providerThread, |
| 2617 | now: yield* DateTime.now, |
| 2618 | attemptId: RunAttemptId.make("early-stop-attempt"), |
| 2619 | text: prompt, |
| 2620 | }), |
| 2621 | ); |
| 2622 | const providerTurnId = (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ |
| 2623 | driver: CodexAdapterV2.CODEX_DRIVER_KIND, |
| 2624 | nativeTurnId, |
| 2625 | }); |
| 2626 | const interrupt = yield* harness.runtime |
| 2627 | .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) |
| 2628 | .pipe(Effect.forkScoped); |
| 2629 | yield* TestClock.adjust("500 millis"); |
| 2630 | assert.isFalse(interruptSent, "Stop must not reach Codex before native turn/started"); |
| 2631 | yield* TestClock.adjust("500 millis"); |
| 2632 | yield* Fiber.join(interrupt); |
| 2633 | yield* harness.firstTerminal; |
| 2634 | assert.equal(harness.terminalEvents()[0]?.status, "failed"); |
| 2635 | assert.lengthOf(harness.terminalEvents(), 1); |
| 2636 | assert.isFalse(interruptSent, "A terminal native turn must not receive turn/interrupt"); |
| 2637 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 2638 | }).pipe( |
| 2639 | Effect.scoped, |
| 2640 | Effect.provide( |
| 2641 | Layer.mergeAll( |
| 2642 | IdAllocator.layer, |
| 2643 | McpProviderSessions.layer, |
| 2644 | ScriptProgress.layerUnavailable, |
| 2645 | NodeServices.layer, |
| 2646 | ), |
| 2647 | ), |
| 2648 | ), |
| 2649 | ); |
| 2650 | |
| 2651 | it.effect("bounds Stop when a queued native turn never starts", () => |
| 2652 | Effect.gen(function* () { |
| 2653 | const nativeThreadId = "early-stop-thread"; |
| 2654 | const nativeTurnId = "early-stop-turn"; |
| 2655 | const prompt = "Run a command."; |
| 2656 | const preamble = codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt }); |
| 2657 | const transcript = makeCodexReplayTranscript({ |
| 2658 | scenario: "early-stop-never-starts", |
| 2659 | entries: [ |
| 2660 | ...preamble.slice(0, -2), |
| 2661 | { |
| 2662 | type: "emit_inbound", |
| 2663 | label: "turn/start/queued", |
| 2664 | frame: { |
| 2665 | id: 3, |
| 2666 | result: { |
| 2667 | turn: { |
| 2668 | ...makeCodexReplayTurn({ id: nativeTurnId, status: "inProgress" }), |
| 2669 | startedAt: null, |
| 2670 | }, |
| 2671 | }, |
| 2672 | }, |
| 2673 | }, |
| 2674 | ], |
| 2675 | }); |
| 2676 | let interruptSent = false; |
| 2677 | const harness = yield* makeCodexReplayHarness( |
| 2678 | transcript, |
| 2679 | () => Effect.void, |
| 2680 | (method) => |
| 2681 | Effect.sync(() => { |
| 2682 | if (method === "turn/interrupt") interruptSent = true; |
| 2683 | }), |
| 2684 | ); |
| 2685 | yield* harness.runtime.startTurn( |
| 2686 | makeCodexTestTurnInput({ |
| 2687 | threadId: harness.threadId, |
| 2688 | providerThread: harness.providerThread, |
| 2689 | now: yield* DateTime.now, |
| 2690 | attemptId: RunAttemptId.make("early-stop-attempt"), |
| 2691 | text: prompt, |
| 2692 | }), |
| 2693 | ); |
| 2694 | const providerTurnId = (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ |
| 2695 | driver: CodexAdapterV2.CODEX_DRIVER_KIND, |
| 2696 | nativeTurnId, |
| 2697 | }); |
| 2698 | const interrupt = yield* harness.runtime |
| 2699 | .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) |
| 2700 | .pipe(Effect.exit, Effect.forkScoped); |
| 2701 | yield* TestClock.adjust("10 seconds"); |
| 2702 | assert.equal((yield* Fiber.join(interrupt))._tag, "Failure"); |
| 2703 | yield* harness.firstTerminal; |
| 2704 | assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); |
| 2705 | assert.lengthOf(harness.terminalEvents(), 1); |
| 2706 | assert.isFalse(interruptSent, "An unstarted native turn must not receive turn/interrupt"); |
| 2707 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 2708 | }).pipe( |
| 2709 | Effect.scoped, |
| 2710 | Effect.provide( |
| 2711 | Layer.mergeAll( |
| 2712 | IdAllocator.layer, |
| 2713 | McpProviderSessions.layer, |
| 2714 | ScriptProgress.layerUnavailable, |
| 2715 | NodeServices.layer, |
| 2716 | ), |
| 2717 | ), |
| 2718 | ), |
| 2719 | ); |
| 2720 | |
| 2721 | it.effect("sends currency-sigil skill mentions to Codex as $ mentions", () => |
| 2722 | Effect.gen(function* () { |
| 2723 | const nativeThreadId = "skill-sigil-thread"; |
| 2724 | const nativeTurnId = "skill-sigil-turn"; |
| 2725 | const transcript = makeCodexReplayTranscript({ |
| 2726 | scenario: "skill-sigil-canonicalized", |
| 2727 | entries: [ |
| 2728 | ...codexReplayPreamble({ |
| 2729 | nativeThreadId, |
| 2730 | nativeTurnId, |
| 2731 | prompt: "€review do it", |
| 2732 | sentPrompt: "$review do it", |
| 2733 | }), |
| 2734 | { |
| 2735 | type: "expect_outbound", |
| 2736 | label: "turn/steer", |
| 2737 | frame: { |
| 2738 | id: 4, |
| 2739 | method: "turn/steer", |
| 2740 | params: { |
| 2741 | expectedTurnId: nativeTurnId, |
| 2742 | input: [{ type: "text", text: "then $ship it" }], |
| 2743 | threadId: nativeThreadId, |
| 2744 | }, |
| 2745 | }, |
| 2746 | }, |
| 2747 | { |
| 2748 | type: "emit_inbound", |
| 2749 | label: "turn/steer", |
| 2750 | frame: { id: 4, result: { turnId: nativeTurnId } }, |
| 2751 | }, |
| 2752 | ], |
| 2753 | }); |
| 2754 | const harness = yield* makeCodexReplayHarness(transcript); |
| 2755 | const turnInput = makeCodexTestTurnInput({ |
| 2756 | threadId: harness.threadId, |
| 2757 | providerThread: harness.providerThread, |
| 2758 | now: yield* DateTime.now, |
| 2759 | attemptId: RunAttemptId.make("skill-sigil-attempt"), |
| 2760 | text: "€review do it", |
| 2761 | }); |
| 2762 | yield* harness.runtime.startTurn(turnInput); |
| 2763 | yield* harness.runtime.steerTurn({ |
| 2764 | threadId: harness.threadId, |
| 2765 | runId: turnInput.runId, |
| 2766 | providerThread: harness.providerThread, |
| 2767 | providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ |
| 2768 | driver: CodexAdapterV2.CODEX_DRIVER_KIND, |
| 2769 | nativeTurnId, |
| 2770 | }), |
| 2771 | message: { |
| 2772 | ...turnInput.message, |
| 2773 | messageId: MessageId.make("message-skill-sigil-steer"), |
| 2774 | text: "then £ship it", |
| 2775 | }, |
| 2776 | }); |
| 2777 | }).pipe( |
| 2778 | Effect.scoped, |
| 2779 | Effect.provide( |
| 2780 | Layer.mergeAll( |
| 2781 | IdAllocator.layer, |
| 2782 | McpProviderSessions.layer, |
| 2783 | ScriptProgress.layerUnavailable, |
| 2784 | NodeServices.layer, |
| 2785 | ), |
| 2786 | ), |
| 2787 | ), |
| 2788 | ); |
| 2789 | |
| 2790 | const assistantMessages = (events: ReadonlyArray<ProviderAdapter.ProviderAdapterV2Event>) => |
| 2791 | events.filter( |
| 2792 | ( |
| 2793 | event, |
| 2794 | ): event is Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "message.updated" }> => |
| 2795 | event.type === "message.updated" && event.message.role === "assistant", |
| 2796 | ); |
| 2797 | const assistantTurnItems = (events: ReadonlyArray<ProviderAdapter.ProviderAdapterV2Event>) => |
| 2798 | events.flatMap((event) => |
| 2799 | event.type === "turn_item.updated" && event.turnItem.type === "assistant_message" |
| 2800 | ? [event.turnItem] |
| 2801 | : [], |
| 2802 | ); |
| 2803 | |
| 2804 | it.effect("keeps an asynchronous Codex question actionable after the turn completes", () => |
| 2805 | Effect.scoped( |
| 2806 | Effect.gen(function* () { |
| 2807 | const nativeThreadId = "async-question-thread"; |
| 2808 | const nativeTurnId = "async-question-turn"; |
| 2809 | const usage = { |
| 2810 | totalTokens: 15, |
| 2811 | inputTokens: 10, |
| 2812 | cachedInputTokens: 2, |
| 2813 | outputTokens: 5, |
| 2814 | reasoningOutputTokens: 1, |
| 2815 | }; |
| 2816 | const transcript = makeCodexReplayTranscript({ |
| 2817 | scenario: "async-question-and-billed-usage", |
| 2818 | entries: [ |
| 2819 | ...codexReplayPreamble({ |
| 2820 | nativeThreadId, |
| 2821 | nativeTurnId, |
| 2822 | prompt: "Continue while I decide.", |
| 2823 | }), |
| 2824 | { |
| 2825 | type: "emit_inbound", |
| 2826 | label: "question", |
| 2827 | frame: { |
| 2828 | method: "item/completed", |
| 2829 | params: { |
| 2830 | threadId: nativeThreadId, |
| 2831 | turnId: nativeTurnId, |
| 2832 | item: { |
| 2833 | type: "agentMessage", |
| 2834 | id: "async-question-item", |
| 2835 | text: "Which branch?", |
| 2836 | delivery: "async", |
| 2837 | questions: [{ title: "Which branch?", options: ["main", "dev"] }], |
| 2838 | }, |
| 2839 | }, |
| 2840 | }, |
| 2841 | }, |
| 2842 | { |
| 2843 | type: "emit_inbound", |
| 2844 | label: "usage", |
| 2845 | frame: { |
| 2846 | method: "thread/tokenUsage/updated", |
| 2847 | params: { |
| 2848 | threadId: nativeThreadId, |
| 2849 | turnId: nativeTurnId, |
| 2850 | tokenUsage: { total: usage, last: usage, modelContextWindow: 200_000 }, |
| 2851 | }, |
| 2852 | }, |
| 2853 | }, |
| 2854 | { |
| 2855 | type: "emit_inbound", |
| 2856 | label: "complete", |
| 2857 | frame: { |
| 2858 | method: "turn/completed", |
| 2859 | params: { |
| 2860 | threadId: nativeThreadId, |
| 2861 | turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), |
| 2862 | }, |
| 2863 | }, |
| 2864 | }, |
| 2865 | ], |
| 2866 | }); |
| 2867 | const harness = yield* makeCodexReplayHarness(transcript); |
| 2868 | yield* harness.runtime.startTurn( |
| 2869 | makeCodexTestTurnInput({ |
| 2870 | threadId: harness.threadId, |
| 2871 | providerThread: harness.providerThread, |
| 2872 | now: yield* DateTime.now, |
| 2873 | attemptId: RunAttemptId.make("async-question-attempt"), |
| 2874 | text: "Continue while I decide.", |
| 2875 | }), |
| 2876 | ); |
| 2877 | yield* harness.firstTerminal; |
| 2878 | const requests = harness.events.flatMap((event) => |
| 2879 | event.type === "runtime_request.updated" ? [event.runtimeRequest] : [], |
| 2880 | ); |
| 2881 | assert.lengthOf(requests, 1); |
| 2882 | assert.equal(requests[0]?.status, "pending"); |
| 2883 | assert.deepEqual(requests[0]?.responseCapability, { type: "message" }); |
| 2884 | const questionItem = harness.events.find( |
| 2885 | (event) => |
| 2886 | event.type === "turn_item.updated" && event.turnItem.type === "user_input_request", |
| 2887 | ); |
| 2888 | assert.equal(questionItem?.type, "turn_item.updated"); |
| 2889 | if ( |
| 2890 | questionItem?.type === "turn_item.updated" && |
| 2891 | questionItem.turnItem.type === "user_input_request" |
| 2892 | ) { |
| 2893 | assert.deepEqual( |
| 2894 | questionItem.turnItem.questions[0]?.options.map((option) => option.label), |
| 2895 | ["main", "dev"], |
| 2896 | ); |
| 2897 | assert.equal(questionItem.turnItem.responseMode, "message"); |
| 2898 | } |
| 2899 | const questionNode = harness.events.find( |
| 2900 | (event) => event.type === "node.updated" && event.node.id === requests[0]?.nodeId, |
| 2901 | ); |
| 2902 | assert.equal( |
| 2903 | questionNode?.type === "node.updated" && questionNode.node.countsForRun, |
| 2904 | false, |
| 2905 | ); |
| 2906 | const contextReport = harness.events.find( |
| 2907 | (event) => |
| 2908 | event.type === "provider_turn.updated" && event.providerTurn.tokenUsage !== undefined, |
| 2909 | ); |
| 2910 | assert.equal(contextReport?.type, "provider_turn.updated"); |
| 2911 | if (contextReport?.type === "provider_turn.updated") { |
| 2912 | assert.equal( |
| 2913 | contextReport.providerTurn.runAttemptId, |
| 2914 | RunAttemptId.make("async-question-attempt"), |
| 2915 | ); |
| 2916 | assert.equal(contextReport.providerTurn.providerThreadId, harness.providerThread.id); |
| 2917 | assert.equal(contextReport.providerTurn.tokenUsage?.usedTokens, 15); |
| 2918 | assert.equal(contextReport.providerTurn.tokenUsage?.maxTokens, 200_000); |
| 2919 | } |
| 2920 | const completed = harness.events.find( |
| 2921 | (event) => |
| 2922 | event.type === "provider_turn.updated" && event.providerTurn.status === "completed", |
| 2923 | ); |
| 2924 | assert.equal(completed?.type, "provider_turn.updated"); |
| 2925 | if (completed?.type === "provider_turn.updated") { |
| 2926 | assert.deepEqual(completed.providerTurn.turnTokenUsage, { |
| 2927 | usageStatus: "complete", |
| 2928 | usageScope: "main_agent", |
| 2929 | hasSubagents: false, |
| 2930 | inputTokens: 10, |
| 2931 | cachedInputTokens: 2, |
| 2932 | outputTokens: 5, |
| 2933 | reasoningTokens: 1, |
| 2934 | }); |
| 2935 | } |
| 2936 | assert.isEmpty(assistantMessages(harness.events)); |
| 2937 | }).pipe( |
| 2938 | Effect.provide( |
| 2939 | Layer.mergeAll( |
| 2940 | IdAllocator.layer, |
| 2941 | McpProviderSessions.layer, |
| 2942 | ScriptProgress.layerUnavailable, |
| 2943 | NodeServices.layer, |
| 2944 | ), |
| 2945 | ), |
| 2946 | ), |
| 2947 | ), |
| 2948 | ); |
| 2949 | |
| 2950 | it.effect("stops asking for commands the user allowed for the session", () => |
| 2951 | Effect.scoped( |
| 2952 | Effect.gen(function* () { |
| 2953 | const nativeThreadId = "session-approval-thread"; |
| 2954 | const turnIds = [ |
| 2955 | "session-approval-turn-1", |
| 2956 | "session-approval-turn-2", |
| 2957 | "session-approval-turn-3", |
| 2958 | ]; |
| 2959 | const turnPolicies = { |
| 2960 | "approval-required": { |
| 2961 | approvalPolicy: "untrusted", |
| 2962 | approvalsReviewer: "user", |
| 2963 | sandboxPolicy: { type: "readOnly" }, |
| 2964 | }, |
| 2965 | "full-access": { |
| 2966 | approvalPolicy: "never", |
| 2967 | approvalsReviewer: "user", |
| 2968 | sandboxPolicy: { type: "dangerFullAccess" }, |
| 2969 | }, |
| 2970 | } as const; |
| 2971 | const turnModes = ["approval-required", "full-access", "approval-required"] as const; |
| 2972 | const turnText = (index: number) => `Review round ${index + 1}.`; |
| 2973 | const startTurn = (index: number): ReadonlyArray<CodexReplay.CodexAppServerReplayEntry> => [ |
| 2974 | { |
| 2975 | type: "expect_outbound", |
| 2976 | label: `turn ${index + 1} turn/start`, |
| 2977 | frame: { |
| 2978 | id: 3 + index, |
| 2979 | method: "turn/start", |
| 2980 | params: { |
| 2981 | threadId: nativeThreadId, |
| 2982 | input: [{ type: "text", text: turnText(index) }], |
| 2983 | cwd: "/workspace", |
| 2984 | model: "gpt-5.4", |
| 2985 | ...turnPolicies[turnModes[index]!], |
| 2986 | summary: "detailed", |
| 2987 | }, |
| 2988 | }, |
| 2989 | }, |
| 2990 | { |
| 2991 | type: "emit_inbound", |
| 2992 | label: `turn ${index + 1} turn/start`, |
| 2993 | frame: { |
| 2994 | id: 3 + index, |
| 2995 | result: { turn: makeCodexReplayTurn({ id: turnIds[index]!, status: "inProgress" }) }, |
| 2996 | }, |
| 2997 | }, |
| 2998 | turnEvent("turn/started", turnIds[index]!), |
| 2999 | ]; |
| 3000 | const approval = (input: { |
| 3001 | readonly id: number; |
| 3002 | readonly turnId: string; |
| 3003 | readonly kind: "commandExecution" | "fileChange"; |
| 3004 | readonly itemId: string; |
| 3005 | readonly decision: string; |
| 3006 | }): ReadonlyArray<CodexReplay.CodexAppServerReplayEntry> => [ |
| 3007 | { |
| 3008 | type: "emit_inbound", |
| 3009 | label: `${input.itemId} approval`, |
| 3010 | frame: { |
| 3011 | id: input.id, |
| 3012 | method: `item/${input.kind}/requestApproval`, |
| 3013 | params: { |
| 3014 | threadId: nativeThreadId, |
| 3015 | turnId: input.turnId, |
| 3016 | itemId: input.itemId, |
| 3017 | startedAtMs: 1782622441000, |
| 3018 | ...(input.kind === "commandExecution" ? { command: `cat ${input.itemId}` } : {}), |
| 3019 | }, |
| 3020 | }, |
| 3021 | }, |
| 3022 | { |
| 3023 | type: "expect_outbound", |
| 3024 | label: `${input.itemId} decision`, |
| 3025 | frame: { id: input.id, result: { decision: input.decision } }, |
| 3026 | }, |
| 3027 | ]; |
| 3028 | const turnEvent = ( |
| 3029 | method: "turn/started" | "turn/completed", |
| 3030 | turnId: string, |
| 3031 | ): CodexReplay.CodexAppServerReplayEntry => ({ |
| 3032 | type: "emit_inbound", |
| 3033 | label: `${turnId} ${method}`, |
| 3034 | frame: { |
| 3035 | method, |
| 3036 | params: { |
| 3037 | threadId: nativeThreadId, |
| 3038 | turn: makeCodexReplayTurn({ |
| 3039 | id: turnId, |
| 3040 | status: method === "turn/started" ? "inProgress" : "completed", |
| 3041 | }), |
| 3042 | }, |
| 3043 | }, |
| 3044 | }); |
| 3045 | const [patchRequest, patchDecision] = approval({ |
| 3046 | id: 102, |
| 3047 | turnId: turnIds[0]!, |
| 3048 | kind: "fileChange", |
| 3049 | itemId: "first-patch", |
| 3050 | decision: "acceptForSession", |
| 3051 | }); |
| 3052 | const transcript = makeCodexReplayTranscript({ |
| 3053 | scenario: "codex-session-approvals", |
| 3054 | entries: [ |
| 3055 | ...codexReplayPreamble({ |
| 3056 | nativeThreadId, |
| 3057 | nativeTurnId: turnIds[0]!, |
| 3058 | prompt: turnText(0), |
| 3059 | }).slice(0, -3), |
| 3060 | ...startTurn(0), |
| 3061 | ...approval({ |
| 3062 | id: 100, |
| 3063 | turnId: turnIds[0]!, |
| 3064 | kind: "commandExecution", |
| 3065 | itemId: "first-command", |
| 3066 | decision: "acceptForSession", |
| 3067 | }), |
| 3068 | // Only commands were allowed, so a file change still asks. The user |
| 3069 | // answers it after the turn ended and the mode changed. |
| 3070 | patchRequest!, |
| 3071 | // Answered without asking: Codex alone would ask for every new command. |
| 3072 | ...approval({ |
| 3073 | id: 101, |
| 3074 | turnId: turnIds[0]!, |
| 3075 | kind: "commandExecution", |
| 3076 | itemId: "second-command", |
| 3077 | decision: "acceptForSession", |
| 3078 | }), |
| 3079 | turnEvent("turn/completed", turnIds[0]!), |
| 3080 | ...startTurn(1), |
| 3081 | patchDecision!, |
| 3082 | turnEvent("turn/completed", turnIds[1]!), |
| 3083 | ...startTurn(2), |
| 3084 | // Back in the first mode, neither allowance comes back. |
| 3085 | ...approval({ |
| 3086 | id: 103, |
| 3087 | turnId: turnIds[2]!, |
| 3088 | kind: "commandExecution", |
| 3089 | itemId: "third-command", |
| 3090 | decision: "accept", |
| 3091 | }), |
| 3092 | ...approval({ |
| 3093 | id: 104, |
| 3094 | turnId: turnIds[2]!, |
| 3095 | kind: "fileChange", |
| 3096 | itemId: "second-patch", |
| 3097 | decision: "accept", |
| 3098 | }), |
| 3099 | turnEvent("turn/completed", turnIds[2]!), |
| 3100 | ], |
| 3101 | }); |
| 3102 | const harness = yield* makeCodexReplayHarness(transcript); |
| 3103 | const requests = () => [ |
| 3104 | ...new Map( |
| 3105 | harness.events.flatMap((event) => |
| 3106 | event.type === "runtime_request.updated" |
| 3107 | ? [[event.runtimeRequest.id, event.runtimeRequest] as const] |
| 3108 | : [], |
| 3109 | ), |
| 3110 | ).values(), |
| 3111 | ]; |
| 3112 | const answer = ( |
| 3113 | count: number, |
| 3114 | kind: "command" | "file-change", |
| 3115 | decision: ProviderApprovalDecision, |
| 3116 | ) => |
| 3117 | Effect.gen(function* () { |
| 3118 | yield* awaitUntil(() => requests().length === count, `approval request ${count}`); |
| 3119 | const request = requests()[count - 1]!; |
| 3120 | assert.equal(request.kind, kind); |
| 3121 | yield* harness.runtime.respondToRuntimeRequest({ requestId: request.id, decision }); |
| 3122 | }); |
| 3123 | |
| 3124 | const runTurn = (index: number) => |
| 3125 | Effect.gen(function* () { |
| 3126 | yield* harness.runtime.startTurn({ |
| 3127 | ...makeCodexTestTurnInput({ |
| 3128 | threadId: harness.threadId, |
| 3129 | providerThread: harness.providerThread, |
| 3130 | now: yield* DateTime.now, |
| 3131 | attemptId: RunAttemptId.make(`session-approvals-${index + 1}`), |
| 3132 | text: turnText(index), |
| 3133 | }), |
| 3134 | runOrdinal: index + 1, |
| 3135 | providerTurnOrdinal: index + 1, |
| 3136 | runtimePolicy: { ...CODEX_TEST_RUNTIME_POLICY, runtimeMode: turnModes[index]! }, |
| 3137 | }); |
| 3138 | }); |
| 3139 | |
| 3140 | yield* runTurn(0); |
| 3141 | yield* answer(1, "command", "acceptForSession"); |
| 3142 | yield* awaitUntil(() => requests().length === 2, "file change request"); |
| 3143 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn 1"); |
| 3144 | yield* runTurn(1); |
| 3145 | yield* answer(2, "file-change", "acceptForSession"); |
| 3146 | yield* awaitUntil(() => harness.terminalEvents().length === 2, "turn 2"); |
| 3147 | yield* runTurn(2); |
| 3148 | yield* answer(3, "command", "accept"); |
| 3149 | yield* answer(4, "file-change", "accept"); |
| 3150 | yield* awaitUntil(() => harness.terminalEvents().length === 3, "turn 3"); |
| 3151 | assert.lengthOf(requests(), 4); |
| 3152 | }).pipe( |
| 3153 | Effect.provide( |
| 3154 | Layer.mergeAll( |
| 3155 | IdAllocator.layer, |
| 3156 | McpProviderSessions.layer, |
| 3157 | ScriptProgress.layerUnavailable, |
| 3158 | NodeServices.layer, |
| 3159 | ), |
| 3160 | ), |
| 3161 | ), |
| 3162 | ), |
| 3163 | ); |
| 3164 | |
| 3165 | it.effect("preserves T3 context on the wire and restores it after compaction", () => |
| 3166 | Effect.scoped( |
| 3167 | Effect.gen(function* () { |
| 3168 | const nativeThreadId = "context-thread"; |
| 3169 | const nativeTurnId = "context-turn"; |
| 3170 | const params = yield* CodexAdapterV2.buildCodexTurnStartParams({ |
| 3171 | nativeThreadId, |
| 3172 | codexInput: [{ type: "text", text: "work" }], |
| 3173 | runtimePolicy: CODEX_TEST_RUNTIME_POLICY, |
| 3174 | modelSelection: CODEX_TEST_MODEL_SELECTION, |
| 3175 | hasT3Mcp: true, |
| 3176 | }); |
| 3177 | assert.include( |
| 3178 | params.additionalContext?.t3_code_orchestration?.value ?? "", |
| 3179 | "delegate_task", |
| 3180 | ); |
| 3181 | const entries = codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "work" }); |
| 3182 | const transcript = makeCodexReplayTranscript({ |
| 3183 | scenario: "restore-context", |
| 3184 | entries: [ |
| 3185 | ...entries.slice(0, 5), |
| 3186 | { |
| 3187 | type: "expect_outbound", |
| 3188 | label: "context turn", |
| 3189 | frame: { id: 3, method: "turn/start", params }, |
| 3190 | }, |
| 3191 | ...entries.slice(6), |
| 3192 | { |
| 3193 | type: "emit_inbound", |
| 3194 | label: "compacted", |
| 3195 | frame: { |
| 3196 | method: "item/completed", |
| 3197 | params: { |
| 3198 | threadId: nativeThreadId, |
| 3199 | turnId: nativeTurnId, |
| 3200 | item: { type: "contextCompaction", id: "compact-context" }, |
| 3201 | }, |
| 3202 | }, |
| 3203 | }, |
| 3204 | { |
| 3205 | type: "expect_outbound", |
| 3206 | label: "restore context", |
| 3207 | frame: { |
| 3208 | id: 4, |
| 3209 | method: "thread/inject_items", |
| 3210 | params: { |
| 3211 | threadId: nativeThreadId, |
| 3212 | items: Object.entries(params.additionalContext ?? {}).map(([key, entry]) => ({ |
| 3213 | type: "message", |
| 3214 | role: "developer", |
| 3215 | content: [{ type: "input_text", text: `<${key}>${entry.value}</${key}>` }], |
| 3216 | })), |
| 3217 | }, |
| 3218 | }, |
| 3219 | }, |
| 3220 | { type: "emit_inbound", label: "restored", frame: { id: 4, result: {} } }, |
| 3221 | { |
| 3222 | type: "emit_inbound", |
| 3223 | label: "done", |
| 3224 | frame: { |
| 3225 | method: "turn/completed", |
| 3226 | params: { |
| 3227 | threadId: nativeThreadId, |
| 3228 | turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), |
| 3229 | }, |
| 3230 | }, |
| 3231 | }, |
| 3232 | ], |
| 3233 | }); |
| 3234 | const harness = yield* makeCodexReplayHarness(transcript); |
| 3235 | yield* (yield* McpProviderSessions.McpProviderSessions).set({ |
| 3236 | environmentId: EnvironmentId.make("test"), |
| 3237 | threadId: harness.threadId, |
| 3238 | providerSessionId: "context-session", |
| 3239 | providerInstanceId: ProviderInstanceId.make("codex"), |
| 3240 | endpoint: "http://127.0.0.1:43123/mcp", |
| 3241 | authorizationHeader: "Bearer test", |
| 3242 | browserToolsAvailable: true, |
| 3243 | }); |
| 3244 | yield* harness.runtime.startTurn( |
| 3245 | makeCodexTestTurnInput({ |
| 3246 | threadId: harness.threadId, |
| 3247 | providerThread: harness.providerThread, |
| 3248 | now: yield* DateTime.now, |
| 3249 | attemptId: RunAttemptId.make("context-attempt"), |
| 3250 | text: "work", |
| 3251 | }), |
| 3252 | ); |
| 3253 | yield* harness.firstTerminal; |
| 3254 | assert.equal(harness.terminalEvents()[0]?.status, "completed"); |
| 3255 | }).pipe( |
| 3256 | Effect.provide( |
| 3257 | Layer.mergeAll( |
| 3258 | IdAllocator.layer, |
| 3259 | McpProviderSessions.layer, |
| 3260 | ScriptProgress.layerUnavailable, |
| 3261 | NodeServices.layer, |
| 3262 | ), |
| 3263 | ), |
| 3264 | ), |
| 3265 | ), |
| 3266 | ); |
| 3267 | |
| 3268 | it.effect("compacts Codex with the native RPC and completes the compaction turn", () => |
| 3269 | Effect.scoped( |
| 3270 | Effect.gen(function* () { |
| 3271 | const nativeThreadId = "compact-thread"; |
| 3272 | const nativeTurnId = "compact-turn"; |
| 3273 | const item = { type: "contextCompaction", id: "compact-item" }; |
| 3274 | const transcript = makeCodexReplayTranscript({ |
| 3275 | scenario: "native-compaction", |
| 3276 | entries: [ |
| 3277 | ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "unused" }).slice(0, 5), |
| 3278 | { |
| 3279 | type: "expect_outbound", |
| 3280 | label: "compact", |
| 3281 | frame: { |
| 3282 | id: 3, |
| 3283 | method: "thread/compact/start", |
| 3284 | params: { threadId: nativeThreadId }, |
| 3285 | }, |
| 3286 | }, |
| 3287 | { type: "emit_inbound", label: "compact", frame: { id: 3, result: {} } }, |
| 3288 | { |
| 3289 | type: "emit_inbound", |
| 3290 | label: "start", |
| 3291 | frame: { |
| 3292 | method: "turn/started", |
| 3293 | params: { |
| 3294 | threadId: nativeThreadId, |
| 3295 | turn: makeCodexReplayTurn({ id: nativeTurnId, status: "inProgress" }), |
| 3296 | }, |
| 3297 | }, |
| 3298 | }, |
| 3299 | ...(["item/started", "item/completed"] as const).map((method) => ({ |
| 3300 | type: "emit_inbound" as const, |
| 3301 | label: method, |
| 3302 | frame: { method, params: { threadId: nativeThreadId, turnId: nativeTurnId, item } }, |
| 3303 | })), |
| 3304 | { |
| 3305 | type: "emit_inbound", |
| 3306 | label: "complete", |
| 3307 | frame: { |
| 3308 | method: "turn/completed", |
| 3309 | params: { |
| 3310 | threadId: nativeThreadId, |
| 3311 | turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), |
| 3312 | }, |
| 3313 | }, |
| 3314 | }, |
| 3315 | ], |
| 3316 | }); |
| 3317 | const harness = yield* makeCodexReplayHarness(transcript); |
| 3318 | assert.isDefined(harness.runtime.compactThread); |
| 3319 | yield* harness.runtime.compactThread!( |
| 3320 | makeCodexTestTurnInput({ |
| 3321 | threadId: harness.threadId, |
| 3322 | providerThread: harness.providerThread, |
| 3323 | now: yield* DateTime.now, |
| 3324 | attemptId: RunAttemptId.make("compact-attempt"), |
| 3325 | text: "/compact", |
| 3326 | }), |
| 3327 | ); |
| 3328 | yield* harness.firstTerminal; |
| 3329 | const items = harness.events.flatMap((event) => |
| 3330 | event.type === "turn_item.updated" && event.turnItem.type === "compaction" |
| 3331 | ? [event.turnItem] |
| 3332 | : [], |
| 3333 | ); |
| 3334 | assert.deepEqual( |
| 3335 | items.map((entry) => entry.status), |
| 3336 | ["running", "completed"], |
| 3337 | ); |
| 3338 | assert.equal(items[0]?.id, items[1]?.id); |
| 3339 | assert.equal(harness.terminalEvents()[0]?.status, "completed"); |
| 3340 | }).pipe( |
| 3341 | Effect.provide( |
| 3342 | Layer.mergeAll( |
| 3343 | IdAllocator.layer, |
| 3344 | McpProviderSessions.layer, |
| 3345 | ScriptProgress.layerUnavailable, |
| 3346 | NodeServices.layer, |
| 3347 | ), |
| 3348 | ), |
| 3349 | ), |
| 3350 | ), |
| 3351 | ); |
| 3352 | |
| 3353 | it.effect("resumes a provider thread without requesting or decoding its history", () => |
| 3354 | Effect.scoped( |
| 3355 | Effect.gen(function* () { |
| 3356 | const scenario = "codex-resume-metadata"; |
| 3357 | const nativeThreadId = `native-${scenario}-thread`; |
| 3358 | const preamble = codexReplayPreamble({ |
| 3359 | nativeThreadId, |
| 3360 | nativeTurnId: "unused-turn", |
| 3361 | prompt: "unused-prompt", |
| 3362 | }).slice(0, 5); |
| 3363 | const transcript = makeCodexReplayTranscript({ |
| 3364 | scenario, |
| 3365 | entries: [ |
| 3366 | ...preamble, |
| 3367 | { |
| 3368 | type: "expect_outbound", |
| 3369 | label: "thread/resume", |
| 3370 | frame: { |
| 3371 | id: 3, |
| 3372 | method: "thread/resume", |
| 3373 | params: { |
| 3374 | threadId: nativeThreadId, |
| 3375 | excludeTurns: true, |
| 3376 | config: CodexAdapterV2.CODEX_THREAD_CONFIG, |
| 3377 | }, |
| 3378 | }, |
| 3379 | }, |
| 3380 | { |
| 3381 | type: "emit_inbound", |
| 3382 | label: "thread/resume", |
| 3383 | frame: { id: 3, result: { thread: { id: nativeThreadId, updatedAt: 1782622450 } } }, |
| 3384 | }, |
| 3385 | ], |
| 3386 | }); |
| 3387 | const harness = yield* makeCodexReplayHarness(transcript); |
| 3388 | const resumed = yield* harness.runtime.resumeThread({ |
| 3389 | providerThread: harness.providerThread, |
| 3390 | modelSelection: CODEX_TEST_MODEL_SELECTION, |
| 3391 | runtimePolicy: CODEX_TEST_RUNTIME_POLICY, |
| 3392 | }); |
| 3393 | |
| 3394 | assert.equal(resumed.nativeThreadRef?.nativeId, nativeThreadId); |
| 3395 | assert.equal(resumed.status, "idle"); |
| 3396 | assert.equal(DateTime.toEpochMillis(resumed.updatedAt), 1782622450000); |
| 3397 | }).pipe( |
| 3398 | Effect.provide( |
| 3399 | Layer.mergeAll( |
| 3400 | IdAllocator.layer, |
| 3401 | McpProviderSessions.layer, |
| 3402 | ScriptProgress.layerUnavailable, |
| 3403 | NodeServices.layer, |
| 3404 | ), |
| 3405 | ), |
| 3406 | ), |
| 3407 | ), |
| 3408 | ); |
| 3409 | |
| 3410 | it.effect.each( |
| 3411 | ( |
| 3412 | [ |
| 3413 | ["recovers archived-session errors", "session saved-thread is archived", 0, ""], |
| 3414 | [ |
| 3415 | "recovers unarchive hints", |
| 3416 | "Run `codex unarchive saved-thread` to unarchive it first.", |
| 3417 | 0, |
| 3418 | "", |
| 3419 | ], |
| 3420 | ["preserves missing-thread errors", "thread not found", 3, "thread not found"], |
| 3421 | ["preserves missing-rollout errors", "no rollout found", 3, "no rollout found"], |
| 3422 | ["preserves authentication errors", "authentication failed", 3, "authentication failed"], |
| 3423 | [ |
| 3424 | "preserves archived-workspace errors", |
| 3425 | "workspace is archived", |
| 3426 | 3, |
| 3427 | "workspace is archived", |
| 3428 | ], |
| 3429 | [ |
| 3430 | "preserves unrelated archive-path errors", |
| 3431 | "permission denied reading archived_sessions/saved-thread", |
| 3432 | 3, |
| 3433 | "permission denied reading archived_sessions/saved-thread", |
| 3434 | ], |
| 3435 | [ |
| 3436 | "propagates unarchive missing-thread errors", |
| 3437 | "session saved-thread is archived", |
| 3438 | 4, |
| 3439 | "thread not found", |
| 3440 | ], |
| 3441 | [ |
| 3442 | "propagates unarchive archived errors", |
| 3443 | "session saved-thread is archived", |
| 3444 | 4, |
| 3445 | "session saved-thread is archived", |
| 3446 | ], |
| 3447 | [ |
| 3448 | "propagates retry missing-thread errors", |
| 3449 | "session saved-thread is archived", |
| 3450 | 5, |
| 3451 | "thread not found", |
| 3452 | ], |
| 3453 | [ |
| 3454 | "does not retry archived errors twice", |
| 3455 | "session saved-thread is archived", |
| 3456 | 5, |
| 3457 | "session saved-thread is archived", |
| 3458 | ], |
| 3459 | ] as const |
| 3460 | ).map(([name, resumeError, failAt, finalError]) => ({ name, resumeError, failAt, finalError })), |
| 3461 | )("$name", ({ name, resumeError, failAt, finalError }) => |
| 3462 | Effect.scoped( |
| 3463 | Effect.gen(function* () { |
| 3464 | const nativeThreadId = "saved-thread"; |
| 3465 | const params = { |
| 3466 | threadId: nativeThreadId, |
| 3467 | excludeTurns: true, |
| 3468 | cwd: CODEX_TEST_RUNTIME_POLICY.cwd, |
| 3469 | model: CODEX_TEST_MODEL_SELECTION.model, |
| 3470 | config: CodexAdapterV2.CODEX_THREAD_CONFIG, |
| 3471 | }; |
| 3472 | const entries: Array<CodexReplay.CodexAppServerReplayEntry> = [ |
| 3473 | ...codexReplayPreamble({ |
| 3474 | nativeThreadId, |
| 3475 | nativeTurnId: "unused-turn", |
| 3476 | prompt: "unused-prompt", |
| 3477 | }).slice(0, 5), |
| 3478 | { |
| 3479 | type: "expect_outbound", |
| 3480 | label: "resume archived thread", |
| 3481 | frame: { id: 3, method: "thread/resume", params }, |
| 3482 | }, |
| 3483 | { |
| 3484 | type: "emit_inbound", |
| 3485 | label: "resume error", |
| 3486 | frame: { id: 3, error: { code: -32600, message: resumeError } }, |
| 3487 | }, |
| 3488 | ]; |
| 3489 | if (failAt !== 3) { |
| 3490 | entries.push( |
| 3491 | { |
| 3492 | type: "expect_outbound", |
| 3493 | label: "unarchive same thread", |
| 3494 | frame: { id: 4, method: "thread/unarchive", params: { threadId: nativeThreadId } }, |
| 3495 | }, |
| 3496 | { |
| 3497 | type: "emit_inbound", |
| 3498 | label: "unarchive result", |
| 3499 | frame: |
| 3500 | failAt === 4 |
| 3501 | ? { id: 4, error: { code: -32600, message: finalError } } |
| 3502 | : { id: 4, result: { thread: { turns: [{ type: "unknown-history-item" }] } } }, |
| 3503 | }, |
| 3504 | ); |
| 3505 | } |
| 3506 | if (failAt === 0 || failAt === 5) { |
| 3507 | entries.push( |
| 3508 | { |
| 3509 | type: "expect_outbound", |
| 3510 | label: "retry identical resume", |
| 3511 | frame: { id: 5, method: "thread/resume", params }, |
| 3512 | }, |
| 3513 | { |
| 3514 | type: "emit_inbound", |
| 3515 | label: "retry result", |
| 3516 | frame: |
| 3517 | failAt === 5 |
| 3518 | ? { id: 5, error: { code: -32600, message: finalError } } |
| 3519 | : { |
| 3520 | id: 5, |
| 3521 | result: { |
| 3522 | thread: { |
| 3523 | id: nativeThreadId, |
| 3524 | updatedAt: 1782622450, |
| 3525 | turns: [{ type: "unknown-history-item" }], |
| 3526 | }, |
| 3527 | }, |
| 3528 | }, |
| 3529 | }, |
| 3530 | ); |
| 3531 | } |
| 3532 | const harness = yield* makeCodexReplayHarness( |
| 3533 | makeCodexReplayTranscript({ scenario: name, entries }), |
| 3534 | ); |
| 3535 | const resume = harness.runtime.resumeThread({ |
| 3536 | providerThread: harness.providerThread, |
| 3537 | modelSelection: CODEX_TEST_MODEL_SELECTION, |
| 3538 | runtimePolicy: CODEX_TEST_RUNTIME_POLICY, |
| 3539 | }); |
| 3540 | if (failAt !== 0) { |
| 3541 | const error = yield* Effect.flip(resume); |
| 3542 | assert.equal(error._tag, "ProviderAdapterResumeThreadError"); |
| 3543 | assert.nestedPropertyVal(error, "cause.errorMessage", finalError); |
| 3544 | assert.nestedPropertyVal( |
| 3545 | error, |
| 3546 | "cause.method", |
| 3547 | failAt === 4 ? "thread/unarchive" : "thread/resume", |
| 3548 | ); |
| 3549 | assert.nestedPropertyVal(error, "cause.requestId", String(failAt)); |
| 3550 | return; |
| 3551 | } |
| 3552 | const resumed = yield* resume; |
| 3553 | assert.equal(resumed.id, harness.providerThread.id); |
| 3554 | assert.equal(resumed.nativeThreadRef?.nativeId, nativeThreadId); |
| 3555 | assert.deepEqual( |
| 3556 | resumed.nativeConversationHeadRef, |
| 3557 | harness.providerThread.nativeConversationHeadRef, |
| 3558 | ); |
| 3559 | assert.equal(resumed.status, "idle"); |
| 3560 | assert.equal(DateTime.toEpochMillis(resumed.updatedAt), 1782622450000); |
| 3561 | }).pipe( |
| 3562 | Effect.provide( |
| 3563 | Layer.mergeAll( |
| 3564 | IdAllocator.layer, |
| 3565 | McpProviderSessions.layer, |
| 3566 | ScriptProgress.layerUnavailable, |
| 3567 | NodeServices.layer, |
| 3568 | ), |
| 3569 | ), |
| 3570 | ), |
| 3571 | ), |
| 3572 | ); |
| 3573 | |
| 3574 | it.effect("continues an interrupted native thread with empty input and reasoning summaries", () => |
| 3575 | Effect.scoped( |
| 3576 | Effect.gen(function* () { |
| 3577 | const scenario = "codex-restart-promptless"; |
| 3578 | const nativeThreadId = "native-restart-promptless"; |
| 3579 | const nativeTurnId = "turn-restart-promptless"; |
| 3580 | const preamble = codexReplayPreamble({ |
| 3581 | nativeThreadId, |
| 3582 | nativeTurnId, |
| 3583 | prompt: "unused", |
| 3584 | }).slice(0, 5); |
| 3585 | const transcript = makeCodexReplayTranscript({ |
| 3586 | scenario, |
| 3587 | entries: [ |
| 3588 | ...preamble, |
| 3589 | { |
| 3590 | type: "expect_outbound", |
| 3591 | label: "resume", |
| 3592 | frame: { |
| 3593 | id: 3, |
| 3594 | method: "thread/resume", |
| 3595 | params: { |
| 3596 | threadId: nativeThreadId, |
| 3597 | excludeTurns: true, |
| 3598 | config: CodexAdapterV2.CODEX_THREAD_CONFIG, |
| 3599 | }, |
| 3600 | }, |
| 3601 | }, |
| 3602 | { |
| 3603 | type: "emit_inbound", |
| 3604 | label: "resume", |
| 3605 | frame: { id: 3, result: { thread: { id: nativeThreadId, updatedAt: 1782622450 } } }, |
| 3606 | }, |
| 3607 | { |
| 3608 | type: "expect_outbound", |
| 3609 | label: "continue", |
| 3610 | frame: { |
| 3611 | id: 4, |
| 3612 | method: "turn/start", |
| 3613 | params: { |
| 3614 | threadId: nativeThreadId, |
| 3615 | input: [], |
| 3616 | cwd: "/workspace", |
| 3617 | model: "gpt-5.4", |
| 3618 | approvalPolicy: "never", |
| 3619 | approvalsReviewer: "user", |
| 3620 | sandboxPolicy: { type: "dangerFullAccess" }, |
| 3621 | summary: "detailed", |
| 3622 | }, |
| 3623 | }, |
| 3624 | }, |
| 3625 | { |
| 3626 | type: "emit_inbound", |
| 3627 | label: "continue", |
| 3628 | frame: { |
| 3629 | id: 4, |
| 3630 | result: { turn: makeCodexReplayTurn({ id: nativeTurnId, status: "inProgress" }) }, |
| 3631 | }, |
| 3632 | }, |
| 3633 | ], |
| 3634 | }); |
| 3635 | const harness = yield* makeCodexReplayHarness(transcript); |
| 3636 | const resumed = yield* harness.runtime.resumeThread({ |
| 3637 | providerThread: harness.providerThread, |
| 3638 | modelSelection: CODEX_TEST_MODEL_SELECTION, |
| 3639 | runtimePolicy: CODEX_TEST_RUNTIME_POLICY, |
| 3640 | }); |
| 3641 | yield* harness.runtime.startTurn({ |
| 3642 | ...makeCodexTestTurnInput({ |
| 3643 | threadId: harness.threadId, |
| 3644 | providerThread: resumed, |
| 3645 | now: yield* DateTime.now, |
| 3646 | attemptId: RunAttemptId.make("attempt-restart-promptless"), |
| 3647 | text: "Continue where you left off.", |
| 3648 | }), |
| 3649 | restartContinuationOfRunId: RunId.make("run-before-restart"), |
| 3650 | }); |
| 3651 | assert.equal(resumed.nativeThreadRef?.nativeId, nativeThreadId); |
| 3652 | }).pipe( |
| 3653 | Effect.provide( |
| 3654 | Layer.mergeAll( |
| 3655 | IdAllocator.layer, |
| 3656 | McpProviderSessions.layer, |
| 3657 | ScriptProgress.layerUnavailable, |
| 3658 | NodeServices.layer, |
| 3659 | ), |
| 3660 | ), |
| 3661 | ), |
| 3662 | ), |
| 3663 | ); |
| 3664 | |
| 3665 | it.effect("resolves retryable app-server errors on resumed provider activity", () => |
| 3666 | Effect.scoped( |
| 3667 | Effect.gen(function* () { |
| 3668 | const scenario = "codex-provider-api-retry"; |
| 3669 | const nativeThreadId = `native-${scenario}-thread`; |
| 3670 | const nativeTurnId = `native-${scenario}-turn`; |
| 3671 | const transcript = makeCodexReplayTranscript({ |
| 3672 | scenario, |
| 3673 | entries: [ |
| 3674 | ...codexReplayPreamble({ |
| 3675 | nativeThreadId, |
| 3676 | nativeTurnId, |
| 3677 | prompt: "Open github.com.", |
| 3678 | }), |
| 3679 | { |
| 3680 | type: "emit_inbound", |
| 3681 | label: "error/retry", |
| 3682 | frame: { |
| 3683 | method: "error", |
| 3684 | params: { |
| 3685 | threadId: nativeThreadId, |
| 3686 | turnId: nativeTurnId, |
| 3687 | willRetry: true, |
| 3688 | error: { |
| 3689 | message: "Reconnecting... 2/5", |
| 3690 | additionalDetails: "The response stream disconnected.", |
| 3691 | codexErrorInfo: { |
| 3692 | responseStreamDisconnected: { httpStatusCode: 529 }, |
| 3693 | }, |
| 3694 | }, |
| 3695 | }, |
| 3696 | }, |
| 3697 | }, |
| 3698 | { |
| 3699 | type: "emit_inbound", |
| 3700 | label: "item/started/after-retry", |
| 3701 | frame: { |
| 3702 | method: "item/started", |
| 3703 | params: { |
| 3704 | threadId: nativeThreadId, |
| 3705 | turnId: nativeTurnId, |
| 3706 | startedAtMs: 1782622445000, |
| 3707 | item: { |
| 3708 | type: "commandExecution", |
| 3709 | id: "command-after-provider-retry", |
| 3710 | command: "pwd", |
| 3711 | cwd: "/workspace", |
| 3712 | processId: "42", |
| 3713 | source: "unifiedExecStartup", |
| 3714 | status: "inProgress", |
| 3715 | commandActions: [{ type: "unknown", command: "pwd" }], |
| 3716 | aggregatedOutput: null, |
| 3717 | exitCode: null, |
| 3718 | durationMs: null, |
| 3719 | }, |
| 3720 | }, |
| 3721 | }, |
| 3722 | }, |
| 3723 | { |
| 3724 | type: "emit_inbound", |
| 3725 | label: "item/completed/after-retry", |
| 3726 | frame: { |
| 3727 | method: "item/completed", |
| 3728 | params: { |
| 3729 | threadId: nativeThreadId, |
| 3730 | turnId: nativeTurnId, |
| 3731 | completedAtMs: 1782622445010, |
| 3732 | item: { |
| 3733 | type: "commandExecution", |
| 3734 | id: "command-after-provider-retry", |
| 3735 | command: "pwd", |
| 3736 | cwd: "/workspace", |
| 3737 | processId: "42", |
| 3738 | source: "unifiedExecStartup", |
| 3739 | status: "completed", |
| 3740 | commandActions: [{ type: "unknown", command: "pwd" }], |
| 3741 | aggregatedOutput: "/workspace\n", |
| 3742 | exitCode: 0, |
| 3743 | durationMs: 10, |
| 3744 | }, |
| 3745 | }, |
| 3746 | }, |
| 3747 | }, |
| 3748 | { |
| 3749 | type: "emit_inbound", |
| 3750 | label: "turn/completed", |
| 3751 | frame: { |
| 3752 | method: "turn/completed", |
| 3753 | params: { |
| 3754 | threadId: nativeThreadId, |
| 3755 | turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), |
| 3756 | }, |
| 3757 | }, |
| 3758 | }, |
| 3759 | ], |
| 3760 | }); |
| 3761 | const harness = yield* makeCodexReplayHarness(transcript); |
| 3762 | const now = yield* DateTime.now; |
| 3763 | yield* harness.runtime.startTurn( |
| 3764 | makeCodexTestTurnInput({ |
| 3765 | threadId: harness.threadId, |
| 3766 | providerThread: harness.providerThread, |
| 3767 | now, |
| 3768 | attemptId: RunAttemptId.make("attempt-codex-provider-api-retry"), |
| 3769 | text: "Open github.com.", |
| 3770 | }), |
| 3771 | ); |
| 3772 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "Codex retry recovery"); |
| 3773 | |
| 3774 | const retryItems = harness.events.flatMap((event) => |
| 3775 | event.type === "turn_item.updated" && |
| 3776 | event.turnItem.type === "error" && |
| 3777 | event.turnItem.retry !== undefined |
| 3778 | ? [event.turnItem] |
| 3779 | : [], |
| 3780 | ); |
| 3781 | assert.lengthOf(retryItems, 2); |
| 3782 | assert.equal(retryItems[0]?.status, "running"); |
| 3783 | assert.equal(retryItems[0]?.failure.code, "responseStreamDisconnected"); |
| 3784 | assert.deepEqual(retryItems[0]?.retry, { |
| 3785 | attempt: 2, |
| 3786 | maxAttempts: 5, |
| 3787 | retryDelayMs: null, |
| 3788 | }); |
| 3789 | assert.equal(retryItems[1]?.id, retryItems[0]?.id); |
| 3790 | assert.equal(retryItems[1]?.status, "completed"); |
| 3791 | assert.equal(retryItems[1]?.title, "Provider recovered"); |
| 3792 | |
| 3793 | const recoveredIndex = harness.events.findIndex( |
| 3794 | (event) => |
| 3795 | event.type === "turn_item.updated" && |
| 3796 | event.turnItem.type === "error" && |
| 3797 | event.turnItem.status === "completed", |
| 3798 | ); |
| 3799 | const resumedCommandIndex = harness.events.findIndex( |
| 3800 | (event) => |
| 3801 | event.type === "turn_item.updated" && |
| 3802 | event.turnItem.type === "command_execution" && |
| 3803 | event.turnItem.input === "pwd", |
| 3804 | ); |
| 3805 | const terminalIndex = harness.events.findIndex((event) => event.type === "turn.terminal"); |
| 3806 | assert.isAtLeast(recoveredIndex, 0); |
| 3807 | assert.isAbove(resumedCommandIndex, recoveredIndex); |
| 3808 | assert.isAbove(terminalIndex, resumedCommandIndex); |
| 3809 | }).pipe( |
| 3810 | Effect.provide( |
| 3811 | Layer.mergeAll( |
| 3812 | IdAllocator.layer, |
| 3813 | McpProviderSessions.layer, |
| 3814 | ScriptProgress.layerUnavailable, |
| 3815 | NodeServices.layer, |
| 3816 | ), |
| 3817 | ), |
| 3818 | ), |
| 3819 | ), |
| 3820 | ); |
| 3821 | |
| 3822 | it.effect("stamps Codex items with their own start time, not the turn's", () => |
| 3823 | Effect.scoped( |
| 3824 | Effect.gen(function* () { |
| 3825 | const scenario = "codex-item-start-times"; |
| 3826 | const nativeThreadId = `native-${scenario}-thread`; |
| 3827 | const nativeTurnId = `native-${scenario}-turn`; |
| 3828 | const commandLifecycle = (id: string, startedAtMs: number) => |
| 3829 | (["started", "completed"] as const).map((phase) => ({ |
| 3830 | type: "emit_inbound" as const, |
| 3831 | label: `item/${phase}/${id}`, |
| 3832 | frame: { |
| 3833 | method: `item/${phase}`, |
| 3834 | params: { |
| 3835 | threadId: nativeThreadId, |
| 3836 | turnId: nativeTurnId, |
| 3837 | ...(phase === "started" ? { startedAtMs } : { completedAtMs: startedAtMs + 10 }), |
| 3838 | item: { |
| 3839 | type: "commandExecution", |
| 3840 | id, |
| 3841 | command: "pwd", |
| 3842 | cwd: "/workspace", |
| 3843 | processId: "42", |
| 3844 | source: "unifiedExecStartup", |
| 3845 | status: phase === "started" ? "inProgress" : "completed", |
| 3846 | commandActions: [{ type: "unknown", command: "pwd" }], |
| 3847 | aggregatedOutput: phase === "started" ? null : "/workspace\n", |
| 3848 | exitCode: phase === "started" ? null : 0, |
| 3849 | durationMs: phase === "started" ? null : 10, |
| 3850 | }, |
| 3851 | }, |
| 3852 | }, |
| 3853 | })); |
| 3854 | const transcript = makeCodexReplayTranscript({ |
| 3855 | scenario, |
| 3856 | entries: [ |
| 3857 | ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Run two commands." }), |
| 3858 | ...commandLifecycle("first-command", 1782622445000), |
| 3859 | ...commandLifecycle("second-command", 1782622505000), |
| 3860 | ...(["started", "completed"] as const).map((phase) => ({ |
| 3861 | type: "emit_inbound" as const, |
| 3862 | label: `item/${phase}/compaction`, |
| 3863 | frame: { |
| 3864 | method: `item/${phase}`, |
| 3865 | params: { |
| 3866 | threadId: nativeThreadId, |
| 3867 | turnId: nativeTurnId, |
| 3868 | ...(phase === "started" |
| 3869 | ? { startedAtMs: 1782622565000 } |
| 3870 | : { completedAtMs: 1782622575000 }), |
| 3871 | item: { type: "contextCompaction", id: "compaction" }, |
| 3872 | }, |
| 3873 | }, |
| 3874 | })), |
| 3875 | { |
| 3876 | type: "emit_inbound", |
| 3877 | label: "turn/completed", |
| 3878 | frame: { |
| 3879 | method: "turn/completed", |
| 3880 | params: { |
| 3881 | threadId: nativeThreadId, |
| 3882 | turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), |
| 3883 | }, |
| 3884 | }, |
| 3885 | }, |
| 3886 | ], |
| 3887 | }); |
| 3888 | const harness = yield* makeCodexReplayHarness(transcript); |
| 3889 | yield* harness.runtime.startTurn( |
| 3890 | makeCodexTestTurnInput({ |
| 3891 | threadId: harness.threadId, |
| 3892 | providerThread: harness.providerThread, |
| 3893 | now: yield* DateTime.now, |
| 3894 | attemptId: RunAttemptId.make("attempt-codex-item-start-times"), |
| 3895 | text: "Run two commands.", |
| 3896 | }), |
| 3897 | ); |
| 3898 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "Codex item start times"); |
| 3899 | |
| 3900 | const startedAtByItem = harness.events.flatMap((event) => |
| 3901 | event.type === "turn_item.updated" && |
| 3902 | (event.turnItem.type === "command_execution" || event.turnItem.type === "compaction") |
| 3903 | ? [[event.turnItem.nativeItemRef?.nativeId, event.turnItem.startedAt] as const] |
| 3904 | : [], |
| 3905 | ); |
| 3906 | assert.deepEqual( |
| 3907 | startedAtByItem.map(([id, startedAt]) => [id, startedAt?.epochMilliseconds]), |
| 3908 | [ |
| 3909 | ["first-command", 1782622445000], |
| 3910 | ["first-command", 1782622445000], |
| 3911 | ["second-command", 1782622505000], |
| 3912 | ["second-command", 1782622505000], |
| 3913 | ["compaction", 1782622565000], |
| 3914 | ["compaction", 1782622565000], |
| 3915 | ], |
| 3916 | ); |
| 3917 | }).pipe( |
| 3918 | Effect.provide( |
| 3919 | Layer.mergeAll( |
| 3920 | IdAllocator.layer, |
| 3921 | McpProviderSessions.layer, |
| 3922 | ScriptProgress.layerUnavailable, |
| 3923 | NodeServices.layer, |
| 3924 | ), |
| 3925 | ), |
| 3926 | ), |
| 3927 | ), |
| 3928 | ); |
| 3929 | |
| 3930 | it.effect.each(["completed", "interrupted"] as const)( |
| 3931 | "retains Codex reasoning parts when the turn is %s", |
| 3932 | (terminalStatus) => |
| 3933 | Effect.scoped( |
| 3934 | Effect.gen(function* () { |
| 3935 | const scenario = `codex-reasoning-${terminalStatus}`; |
| 3936 | const nativeThreadId = `native-${scenario}-thread`; |
| 3937 | const nativeTurnId = `native-${scenario}-turn`; |
| 3938 | const prompt = "Explain the check."; |
| 3939 | const transcript = makeCodexReplayTranscript({ |
| 3940 | scenario, |
| 3941 | entries: [ |
| 3942 | ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt }), |
| 3943 | ...[ |
| 3944 | { |
| 3945 | method: "item/reasoning/summaryTextDelta", |
| 3946 | params: { itemId: "thought", summaryIndex: 0, delta: "Summary " }, |
| 3947 | }, |
| 3948 | { |
| 3949 | method: "item/reasoning/summaryTextDelta", |
| 3950 | params: { itemId: "thought", summaryIndex: 0, delta: "one" }, |
| 3951 | }, |
| 3952 | { |
| 3953 | method: "item/reasoning/summaryTextDelta", |
| 3954 | params: { itemId: "thought", summaryIndex: 1, delta: "Summary two" }, |
| 3955 | }, |
| 3956 | { |
| 3957 | method: "item/reasoning/textDelta", |
| 3958 | params: { itemId: "thought", contentIndex: 0, delta: "Raw trace" }, |
| 3959 | }, |
| 3960 | { |
| 3961 | method: "item/completed", |
| 3962 | params: { |
| 3963 | item: { |
| 3964 | type: "commandExecution", |
| 3965 | id: "after-thought", |
| 3966 | command: "pwd", |
| 3967 | cwd: "/workspace", |
| 3968 | processId: "42", |
| 3969 | source: "unifiedExecStartup", |
| 3970 | status: "completed", |
| 3971 | commandActions: [{ type: "unknown", command: "pwd" }], |
| 3972 | aggregatedOutput: "/workspace", |
| 3973 | exitCode: 0, |
| 3974 | durationMs: 1, |
| 3975 | }, |
| 3976 | }, |
| 3977 | }, |
| 3978 | ...(terminalStatus === "completed" |
| 3979 | ? [ |
| 3980 | { |
| 3981 | method: "item/completed", |
| 3982 | params: { |
| 3983 | item: { |
| 3984 | type: "reasoning", |
| 3985 | id: "thought", |
| 3986 | summary: ["Final summary one", "Summary two"], |
| 3987 | content: ["Raw trace"], |
| 3988 | }, |
| 3989 | }, |
| 3990 | }, |
| 3991 | { |
| 3992 | method: "item/completed", |
| 3993 | params: { |
| 3994 | item: { |
| 3995 | type: "reasoning", |
| 3996 | id: "completion-only", |
| 3997 | summary: ["Completion without deltas"], |
| 3998 | content: [], |
| 3999 | }, |
| 4000 | }, |
| 4001 | }, |
| 4002 | { |
| 4003 | method: "item/reasoning/textDelta", |
| 4004 | params: { |
| 4005 | itemId: "delta-only", |
| 4006 | contentIndex: 0, |
| 4007 | delta: "Retained when completion omits content", |
| 4008 | }, |
| 4009 | }, |
| 4010 | { |
| 4011 | method: "item/completed", |
| 4012 | params: { |
| 4013 | item: { type: "reasoning", id: "delta-only", summary: [], content: [] }, |
| 4014 | }, |
| 4015 | }, |
| 4016 | ] |
| 4017 | : []), |
| 4018 | ].map((event, index) => ({ |
| 4019 | type: "emit_inbound" as const, |
| 4020 | label: `reasoning-${index}`, |
| 4021 | frame: { |
| 4022 | method: event.method, |
| 4023 | params: { threadId: nativeThreadId, turnId: nativeTurnId, ...event.params }, |
| 4024 | }, |
| 4025 | })), |
| 4026 | { |
| 4027 | type: "emit_inbound", |
| 4028 | label: "turn/completed", |
| 4029 | frame: { |
| 4030 | method: "turn/completed", |
| 4031 | params: { |
| 4032 | threadId: nativeThreadId, |
| 4033 | turn: makeCodexReplayTurn({ id: nativeTurnId, status: terminalStatus }), |
| 4034 | }, |
| 4035 | }, |
| 4036 | }, |
| 4037 | ], |
| 4038 | }); |
| 4039 | const harness = yield* makeCodexReplayHarness(transcript); |
| 4040 | yield* harness.runtime.startTurn( |
| 4041 | makeCodexTestTurnInput({ |
| 4042 | threadId: harness.threadId, |
| 4043 | providerThread: harness.providerThread, |
| 4044 | now: yield* DateTime.now, |
| 4045 | attemptId: RunAttemptId.make(`attempt-${scenario}`), |
| 4046 | text: prompt, |
| 4047 | }), |
| 4048 | ); |
| 4049 | yield* harness.firstTerminal; |
| 4050 | const latest = new Map( |
| 4051 | harness.events.flatMap((event) => |
| 4052 | event.type === "turn_item.updated" && event.turnItem.type === "reasoning" |
| 4053 | ? [[event.turnItem.id, event.turnItem] as const] |
| 4054 | : [], |
| 4055 | ), |
| 4056 | ); |
| 4057 | assert.deepEqual( |
| 4058 | [...latest.values()].map((item) => item.text), |
| 4059 | terminalStatus === "completed" |
| 4060 | ? [ |
| 4061 | "Final summary one", |
| 4062 | "Summary two", |
| 4063 | "Raw trace", |
| 4064 | "Completion without deltas", |
| 4065 | "Retained when completion omits content", |
| 4066 | ] |
| 4067 | : ["Summary one", "Summary two", "Raw trace"], |
| 4068 | ); |
| 4069 | assert.isTrue([...latest.values()].every((item) => item.status === terminalStatus)); |
| 4070 | assert.isTrue([...latest.values()].every((item) => item.streaming === false)); |
| 4071 | assert.isTrue( |
| 4072 | [...latest.values()].every( |
| 4073 | (item) => item.runId !== null && item.providerTurnId !== null, |
| 4074 | ), |
| 4075 | ); |
| 4076 | assert.equal(new Set([...latest.values()].map((item) => item.ordinal)).size, latest.size); |
| 4077 | const command = harness.events.find( |
| 4078 | (event) => |
| 4079 | event.type === "turn_item.updated" && event.turnItem.type === "command_execution", |
| 4080 | ); |
| 4081 | assert.isDefined(command); |
| 4082 | if (command?.type === "turn_item.updated") { |
| 4083 | assert.isTrue( |
| 4084 | [...latest.values()] |
| 4085 | .slice(0, 3) |
| 4086 | .every((item) => item.ordinal < command.turnItem.ordinal), |
| 4087 | ); |
| 4088 | } |
| 4089 | assert.deepEqual(assistantMessages(harness.events), []); |
| 4090 | }).pipe( |
| 4091 | Effect.provide( |
| 4092 | Layer.mergeAll( |
| 4093 | IdAllocator.layer, |
| 4094 | McpProviderSessions.layer, |
| 4095 | ScriptProgress.layerUnavailable, |
| 4096 | NodeServices.layer, |
| 4097 | ), |
| 4098 | ), |
| 4099 | ), |
| 4100 | ), |
| 4101 | ); |
| 4102 | |
| 4103 | it.effect("streams a proposed plan a few times a second instead of once per delta", () => |
| 4104 | Effect.scoped( |
| 4105 | Effect.gen(function* () { |
| 4106 | const scenario = "codex-plan-delta-coalescing"; |
| 4107 | const nativeThreadId = `native-${scenario}-thread`; |
| 4108 | const nativeTurnId = `native-${scenario}-turn`; |
| 4109 | const prompt = "Plan the change."; |
| 4110 | // 100 deltas, 20 ms apart: a two second stream. |
| 4111 | const deltas = Array.from({ length: 100 }, (_, index) => `step ${index}\n`); |
| 4112 | const transcript = makeCodexReplayTranscript({ |
| 4113 | scenario, |
| 4114 | entries: [ |
| 4115 | ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt }), |
| 4116 | ...deltas.map((delta, index): CodexReplay.CodexAppServerReplayEntry => ({ |
| 4117 | type: "emit_inbound", |
| 4118 | label: `item/plan/delta/${index}`, |
| 4119 | afterMs: 20, |
| 4120 | frame: { |
| 4121 | method: "item/plan/delta", |
| 4122 | params: { threadId: nativeThreadId, turnId: nativeTurnId, itemId: "plan", delta }, |
| 4123 | }, |
| 4124 | })), |
| 4125 | { |
| 4126 | type: "emit_inbound", |
| 4127 | label: "item/completed/plan", |
| 4128 | frame: { |
| 4129 | method: "item/completed", |
| 4130 | params: { |
| 4131 | threadId: nativeThreadId, |
| 4132 | turnId: nativeTurnId, |
| 4133 | item: { type: "plan", id: "plan", text: "" }, |
| 4134 | }, |
| 4135 | }, |
| 4136 | }, |
| 4137 | { |
| 4138 | type: "emit_inbound", |
| 4139 | label: "turn/completed", |
| 4140 | frame: { |
| 4141 | method: "turn/completed", |
| 4142 | params: { |
| 4143 | threadId: nativeThreadId, |
| 4144 | turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), |
| 4145 | }, |
| 4146 | }, |
| 4147 | }, |
| 4148 | ], |
| 4149 | }); |
| 4150 | const harness = yield* makeCodexReplayHarness(transcript); |
| 4151 | yield* harness.runtime.startTurn( |
| 4152 | makeCodexTestTurnInput({ |
| 4153 | threadId: harness.threadId, |
| 4154 | providerThread: harness.providerThread, |
| 4155 | now: yield* DateTime.now, |
| 4156 | attemptId: RunAttemptId.make(`attempt-${scenario}`), |
| 4157 | text: prompt, |
| 4158 | }), |
| 4159 | ); |
| 4160 | for (let step = 0; step < 200 && harness.terminalEvents().length === 0; step += 1) { |
| 4161 | yield* Effect.yieldNow; |
| 4162 | yield* TestClock.adjust("20 millis"); |
| 4163 | } |
| 4164 | yield* harness.firstTerminal; |
| 4165 | |
| 4166 | const plans = harness.events.flatMap((event) => |
| 4167 | event.type === "plan.updated" && event.plan.kind === "proposed_plan" ? [event.plan] : [], |
| 4168 | ); |
| 4169 | const planItems = harness.events.flatMap((event) => |
| 4170 | event.type === "turn_item.updated" && event.turnItem.type === "proposed_plan" |
| 4171 | ? [event.turnItem] |
| 4172 | : [], |
| 4173 | ); |
| 4174 | // One update per 400 ms of the stream, plus the completion. |
| 4175 | assert.isAtLeast(plans.length, 2); |
| 4176 | assert.isAtMost(plans.length, 7); |
| 4177 | assert.equal(planItems.length, plans.length); |
| 4178 | assert.equal(plans.at(-1)?.markdown, deltas.join("")); |
| 4179 | assert.deepEqual( |
| 4180 | planItems.map((item) => item.streaming), |
| 4181 | [...Array.from({ length: planItems.length - 1 }, () => true), false], |
| 4182 | ); |
| 4183 | }).pipe( |
| 4184 | Effect.provide( |
| 4185 | Layer.mergeAll( |
| 4186 | IdAllocator.layer, |
| 4187 | McpProviderSessions.layer, |
| 4188 | ScriptProgress.layerUnavailable, |
| 4189 | NodeServices.layer, |
| 4190 | ), |
| 4191 | ), |
| 4192 | ), |
| 4193 | ), |
| 4194 | ); |
| 4195 | |
| 4196 | const finalAnswerTranscript = ( |
| 4197 | scenario: string, |
| 4198 | answers: ReadonlyArray<{ |
| 4199 | readonly id: string; |
| 4200 | readonly text: string; |
| 4201 | readonly phase?: "commentary" | "final_answer" | null; |
| 4202 | readonly omitPhase?: boolean; |
| 4203 | readonly streamed?: boolean; |
| 4204 | readonly completionDelayMs?: number; |
| 4205 | }>, |
| 4206 | ) => { |
| 4207 | const nativeThreadId = `native-${scenario}-thread`; |
| 4208 | const nativeTurnId = `native-${scenario}-turn`; |
| 4209 | const prompt = "Reply with the requested recovery marker."; |
| 4210 | return makeCodexReplayTranscript({ |
| 4211 | scenario, |
| 4212 | entries: [ |
| 4213 | ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt }), |
| 4214 | ...answers.flatMap( |
| 4215 | (answer, index): ReadonlyArray<CodexReplay.CodexAppServerReplayEntry> => { |
| 4216 | const phase = answer.omitPhase |
| 4217 | ? {} |
| 4218 | : { phase: answer.phase === undefined ? ("final_answer" as const) : answer.phase }; |
| 4219 | const completed: CodexReplay.CodexAppServerReplayEntry = { |
| 4220 | type: "emit_inbound", |
| 4221 | label: `item/completed/${answer.id}`, |
| 4222 | ...(answer.completionDelayMs === undefined |
| 4223 | ? {} |
| 4224 | : { afterMs: answer.completionDelayMs }), |
| 4225 | frame: { |
| 4226 | method: "item/completed", |
| 4227 | params: { |
| 4228 | item: { |
| 4229 | type: "agentMessage", |
| 4230 | id: answer.id, |
| 4231 | text: answer.text, |
| 4232 | ...phase, |
| 4233 | memoryCitation: null, |
| 4234 | }, |
| 4235 | threadId: nativeThreadId, |
| 4236 | turnId: nativeTurnId, |
| 4237 | completedAtMs: 1782622441000 + index, |
| 4238 | }, |
| 4239 | }, |
| 4240 | }; |
| 4241 | if (!answer.streamed) { |
| 4242 | return [completed]; |
| 4243 | } |
| 4244 | return [ |
| 4245 | { |
| 4246 | type: "emit_inbound", |
| 4247 | label: `item/started/${answer.id}`, |
| 4248 | frame: { |
| 4249 | method: "item/started", |
| 4250 | params: { |
| 4251 | item: { |
| 4252 | type: "agentMessage", |
| 4253 | id: answer.id, |
| 4254 | text: "", |
| 4255 | ...phase, |
| 4256 | memoryCitation: null, |
| 4257 | }, |
| 4258 | threadId: nativeThreadId, |
| 4259 | turnId: nativeTurnId, |
| 4260 | startedAtMs: 1782622440500 + index, |
| 4261 | }, |
| 4262 | }, |
| 4263 | }, |
| 4264 | { |
| 4265 | type: "emit_inbound", |
| 4266 | label: `item/agentMessage/delta/${answer.id}`, |
| 4267 | frame: { |
| 4268 | method: "item/agentMessage/delta", |
| 4269 | params: { |
| 4270 | threadId: nativeThreadId, |
| 4271 | turnId: nativeTurnId, |
| 4272 | itemId: answer.id, |
| 4273 | delta: answer.text, |
| 4274 | }, |
| 4275 | }, |
| 4276 | }, |
| 4277 | completed, |
| 4278 | ]; |
| 4279 | }, |
| 4280 | ), |
| 4281 | { |
| 4282 | type: "emit_inbound", |
| 4283 | label: "turn/completed", |
| 4284 | frame: { |
| 4285 | method: "turn/completed", |
| 4286 | params: { |
| 4287 | threadId: nativeThreadId, |
| 4288 | turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), |
| 4289 | }, |
| 4290 | }, |
| 4291 | }, |
| 4292 | ], |
| 4293 | }); |
| 4294 | }; |
| 4295 | |
| 4296 | it.effect("suppresses a trailing empty final answer after a non-empty final answer", () => |
| 4297 | Effect.scoped( |
| 4298 | Effect.gen(function* () { |
| 4299 | const transcript = finalAnswerTranscript("codex-redundant-empty-final", [ |
| 4300 | { id: "answer-non-empty", text: "CODEX_RECOVERY_OK" }, |
| 4301 | { id: "answer-empty", text: "" }, |
| 4302 | ]); |
| 4303 | const harness = yield* makeCodexReplayHarness(transcript); |
| 4304 | const now = yield* DateTime.now; |
| 4305 | |
| 4306 | yield* harness.runtime.startTurn( |
| 4307 | makeCodexTestTurnInput({ |
| 4308 | threadId: harness.threadId, |
| 4309 | providerThread: harness.providerThread, |
| 4310 | now, |
| 4311 | attemptId: RunAttemptId.make("attempt-codex-redundant-empty-final"), |
| 4312 | text: "Reply with the requested recovery marker.", |
| 4313 | }), |
| 4314 | ); |
| 4315 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); |
| 4316 | |
| 4317 | assert.deepEqual( |
| 4318 | assistantMessages(harness.events).map((event) => event.message.text), |
| 4319 | ["CODEX_RECOVERY_OK"], |
| 4320 | ); |
| 4321 | }).pipe( |
| 4322 | Effect.provide( |
| 4323 | Layer.mergeAll( |
| 4324 | IdAllocator.layer, |
| 4325 | McpProviderSessions.layer, |
| 4326 | ScriptProgress.layerUnavailable, |
| 4327 | NodeServices.layer, |
| 4328 | ), |
| 4329 | ), |
| 4330 | ), |
| 4331 | ), |
| 4332 | ); |
| 4333 | |
| 4334 | it.effect("streams text on the turn item and sends the message once, when it completes", () => |
| 4335 | Effect.scoped( |
| 4336 | Effect.gen(function* () { |
| 4337 | const transcript = finalAnswerTranscript("codex-streamed-message-once", [ |
| 4338 | { id: "answer", text: "CODEX_RECOVERY_OK", streamed: true, completionDelayMs: 100 }, |
| 4339 | ]); |
| 4340 | const harness = yield* makeCodexReplayHarness(transcript); |
| 4341 | const now = yield* DateTime.now; |
| 4342 | |
| 4343 | yield* harness.runtime.startTurn( |
| 4344 | makeCodexTestTurnInput({ |
| 4345 | threadId: harness.threadId, |
| 4346 | providerThread: harness.providerThread, |
| 4347 | now, |
| 4348 | attemptId: RunAttemptId.make("attempt-codex-streamed-message-once"), |
| 4349 | text: "Reply with the requested recovery marker.", |
| 4350 | }), |
| 4351 | ); |
| 4352 | yield* Effect.yieldNow; |
| 4353 | yield* TestClock.adjust("50 millis"); |
| 4354 | yield* awaitUntil( |
| 4355 | () => assistantTurnItems(harness.events).length === 1, |
| 4356 | "streamed turn item", |
| 4357 | ); |
| 4358 | assert.deepEqual( |
| 4359 | assistantTurnItems(harness.events).map(({ text, streaming }) => ({ text, streaming })), |
| 4360 | [{ text: "CODEX_RECOVERY_OK", streaming: true }], |
| 4361 | ); |
| 4362 | assert.deepEqual(assistantMessages(harness.events), []); |
| 4363 | |
| 4364 | yield* TestClock.adjust("50 millis"); |
| 4365 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); |
| 4366 | assert.deepEqual( |
| 4367 | assistantMessages(harness.events).map(({ message }) => ({ |
| 4368 | text: message.text, |
| 4369 | streaming: message.streaming, |
| 4370 | })), |
| 4371 | [{ text: "CODEX_RECOVERY_OK", streaming: false }], |
| 4372 | ); |
| 4373 | }).pipe( |
| 4374 | Effect.provide( |
| 4375 | Layer.mergeAll( |
| 4376 | IdAllocator.layer, |
| 4377 | McpProviderSessions.layer, |
| 4378 | ScriptProgress.layerUnavailable, |
| 4379 | NodeServices.layer, |
| 4380 | ), |
| 4381 | ), |
| 4382 | ), |
| 4383 | ), |
| 4384 | ); |
| 4385 | |
| 4386 | it.effect("suppresses a later streamed duplicate final answer", () => |
| 4387 | Effect.scoped( |
| 4388 | Effect.gen(function* () { |
| 4389 | const transcript = finalAnswerTranscript("codex-streamed-duplicate-final", [ |
| 4390 | { id: "answer-original", text: "CODEX_RECOVERY_OK" }, |
| 4391 | { |
| 4392 | id: "answer-duplicate", |
| 4393 | text: "CODEX_RECOVERY_OK", |
| 4394 | streamed: true, |
| 4395 | completionDelayMs: 100, |
| 4396 | }, |
| 4397 | ]); |
| 4398 | const harness = yield* makeCodexReplayHarness(transcript); |
| 4399 | const now = yield* DateTime.now; |
| 4400 | |
| 4401 | yield* harness.runtime.startTurn( |
| 4402 | makeCodexTestTurnInput({ |
| 4403 | threadId: harness.threadId, |
| 4404 | providerThread: harness.providerThread, |
| 4405 | now, |
| 4406 | attemptId: RunAttemptId.make("attempt-codex-streamed-duplicate-final"), |
| 4407 | text: "Reply with the requested recovery marker.", |
| 4408 | }), |
| 4409 | ); |
| 4410 | yield* awaitUntil(() => assistantMessages(harness.events).length === 1, "original answer"); |
| 4411 | yield* Effect.yieldNow; |
| 4412 | yield* TestClock.adjust("50 millis"); |
| 4413 | yield* Effect.yieldNow; |
| 4414 | |
| 4415 | assert.deepEqual( |
| 4416 | assistantMessages(harness.events).map((event) => event.message.text), |
| 4417 | ["CODEX_RECOVERY_OK"], |
| 4418 | ); |
| 4419 | |
| 4420 | yield* TestClock.adjust("50 millis"); |
| 4421 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); |
| 4422 | assert.deepEqual( |
| 4423 | assistantMessages(harness.events).map((event) => event.message.text), |
| 4424 | ["CODEX_RECOVERY_OK"], |
| 4425 | ); |
| 4426 | }).pipe( |
| 4427 | Effect.provide( |
| 4428 | Layer.mergeAll( |
| 4429 | IdAllocator.layer, |
| 4430 | McpProviderSessions.layer, |
| 4431 | ScriptProgress.layerUnavailable, |
| 4432 | NodeServices.layer, |
| 4433 | ), |
| 4434 | ), |
| 4435 | ), |
| 4436 | ), |
| 4437 | ); |
| 4438 | |
| 4439 | it.effect("buffers an overlapping later final stream until duplicate detection", () => |
| 4440 | Effect.scoped( |
| 4441 | Effect.gen(function* () { |
| 4442 | const scenario = "codex-overlapping-duplicate-final"; |
| 4443 | const nativeThreadId = `native-${scenario}-thread`; |
| 4444 | const nativeTurnId = `native-${scenario}-turn`; |
| 4445 | const answerItem = (id: string, text: string) => ({ |
| 4446 | type: "agentMessage" as const, |
| 4447 | id, |
| 4448 | text, |
| 4449 | phase: "final_answer" as const, |
| 4450 | memoryCitation: null, |
| 4451 | }); |
| 4452 | const transcript = makeCodexReplayTranscript({ |
| 4453 | scenario, |
| 4454 | entries: [ |
| 4455 | ...codexReplayPreamble({ |
| 4456 | nativeThreadId, |
| 4457 | nativeTurnId, |
| 4458 | prompt: "Reply with the requested recovery marker.", |
| 4459 | }), |
| 4460 | ...["answer-overlap-original", "answer-overlap-duplicate"].flatMap( |
| 4461 | (itemId, index): ReadonlyArray<CodexReplay.CodexAppServerReplayEntry> => [ |
| 4462 | { |
| 4463 | type: "emit_inbound", |
| 4464 | label: `item/started/${itemId}`, |
| 4465 | frame: { |
| 4466 | method: "item/started", |
| 4467 | params: { |
| 4468 | item: answerItem(itemId, ""), |
| 4469 | threadId: nativeThreadId, |
| 4470 | turnId: nativeTurnId, |
| 4471 | startedAtMs: 1782622440500 + index, |
| 4472 | }, |
| 4473 | }, |
| 4474 | }, |
| 4475 | { |
| 4476 | type: "emit_inbound", |
| 4477 | label: `item/agentMessage/delta/${itemId}`, |
| 4478 | frame: { |
| 4479 | method: "item/agentMessage/delta", |
| 4480 | params: { |
| 4481 | threadId: nativeThreadId, |
| 4482 | turnId: nativeTurnId, |
| 4483 | itemId, |
| 4484 | delta: "CODEX_RECOVERY_OK", |
| 4485 | }, |
| 4486 | }, |
| 4487 | }, |
| 4488 | ], |
| 4489 | ), |
| 4490 | { |
| 4491 | type: "emit_inbound", |
| 4492 | label: "item/completed/answer-overlap-original", |
| 4493 | afterMs: 100, |
| 4494 | frame: { |
| 4495 | method: "item/completed", |
| 4496 | params: { |
| 4497 | item: answerItem("answer-overlap-original", "CODEX_RECOVERY_OK"), |
| 4498 | threadId: nativeThreadId, |
| 4499 | turnId: nativeTurnId, |
| 4500 | completedAtMs: 1782622441000, |
| 4501 | }, |
| 4502 | }, |
| 4503 | }, |
| 4504 | { |
| 4505 | type: "emit_inbound", |
| 4506 | label: "item/completed/answer-overlap-duplicate", |
| 4507 | frame: { |
| 4508 | method: "item/completed", |
| 4509 | params: { |
| 4510 | item: answerItem("answer-overlap-duplicate", "CODEX_RECOVERY_OK"), |
| 4511 | threadId: nativeThreadId, |
| 4512 | turnId: nativeTurnId, |
| 4513 | completedAtMs: 1782622441001, |
| 4514 | }, |
| 4515 | }, |
| 4516 | }, |
| 4517 | { |
| 4518 | type: "emit_inbound", |
| 4519 | label: "turn/completed", |
| 4520 | frame: { |
| 4521 | method: "turn/completed", |
| 4522 | params: { |
| 4523 | threadId: nativeThreadId, |
| 4524 | turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), |
| 4525 | }, |
| 4526 | }, |
| 4527 | }, |
| 4528 | ], |
| 4529 | }); |
| 4530 | const harness = yield* makeCodexReplayHarness(transcript); |
| 4531 | const now = yield* DateTime.now; |
| 4532 | |
| 4533 | yield* harness.runtime.startTurn( |
| 4534 | makeCodexTestTurnInput({ |
| 4535 | threadId: harness.threadId, |
| 4536 | providerThread: harness.providerThread, |
| 4537 | now, |
| 4538 | attemptId: RunAttemptId.make("attempt-codex-overlapping-duplicate-final"), |
| 4539 | text: "Reply with the requested recovery marker.", |
| 4540 | }), |
| 4541 | ); |
| 4542 | yield* Effect.yieldNow; |
| 4543 | yield* TestClock.adjust("50 millis"); |
| 4544 | yield* Effect.yieldNow; |
| 4545 | |
| 4546 | assert.equal(new Set(assistantTurnItems(harness.events).map((item) => item.id)).size, 1); |
| 4547 | |
| 4548 | yield* TestClock.adjust("50 millis"); |
| 4549 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); |
| 4550 | assert.equal( |
| 4551 | new Set(assistantMessages(harness.events).map((event) => event.message.id)).size, |
| 4552 | 1, |
| 4553 | ); |
| 4554 | }).pipe( |
| 4555 | Effect.provide( |
| 4556 | Layer.mergeAll( |
| 4557 | IdAllocator.layer, |
| 4558 | McpProviderSessions.layer, |
| 4559 | ScriptProgress.layerUnavailable, |
| 4560 | NodeServices.layer, |
| 4561 | ), |
| 4562 | ), |
| 4563 | ), |
| 4564 | ), |
| 4565 | ); |
| 4566 | |
| 4567 | it.effect("preserves a sole empty final answer", () => |
| 4568 | Effect.scoped( |
| 4569 | Effect.gen(function* () { |
| 4570 | const transcript = finalAnswerTranscript("codex-sole-empty-final", [ |
| 4571 | { id: "answer-empty", text: "" }, |
| 4572 | ]); |
| 4573 | const harness = yield* makeCodexReplayHarness(transcript); |
| 4574 | const now = yield* DateTime.now; |
| 4575 | |
| 4576 | yield* harness.runtime.startTurn( |
| 4577 | makeCodexTestTurnInput({ |
| 4578 | threadId: harness.threadId, |
| 4579 | providerThread: harness.providerThread, |
| 4580 | now, |
| 4581 | attemptId: RunAttemptId.make("attempt-codex-sole-empty-final"), |
| 4582 | text: "Reply with the requested recovery marker.", |
| 4583 | }), |
| 4584 | ); |
| 4585 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); |
| 4586 | |
| 4587 | assert.deepEqual( |
| 4588 | assistantMessages(harness.events).map((event) => event.message.text), |
| 4589 | [""], |
| 4590 | ); |
| 4591 | }).pipe( |
| 4592 | Effect.provide( |
| 4593 | Layer.mergeAll( |
| 4594 | IdAllocator.layer, |
| 4595 | McpProviderSessions.layer, |
| 4596 | ScriptProgress.layerUnavailable, |
| 4597 | NodeServices.layer, |
| 4598 | ), |
| 4599 | ), |
| 4600 | ), |
| 4601 | ), |
| 4602 | ); |
| 4603 | |
| 4604 | it.effect("suppresses a second empty final answer", () => |
| 4605 | Effect.scoped( |
| 4606 | Effect.gen(function* () { |
| 4607 | const transcript = finalAnswerTranscript("codex-duplicate-empty-final", [ |
| 4608 | { id: "answer-empty-original", text: "" }, |
| 4609 | { id: "answer-empty-duplicate", text: "" }, |
| 4610 | ]); |
| 4611 | const harness = yield* makeCodexReplayHarness(transcript); |
| 4612 | const now = yield* DateTime.now; |
| 4613 | |
| 4614 | yield* harness.runtime.startTurn( |
| 4615 | makeCodexTestTurnInput({ |
| 4616 | threadId: harness.threadId, |
| 4617 | providerThread: harness.providerThread, |
| 4618 | now, |
| 4619 | attemptId: RunAttemptId.make("attempt-codex-duplicate-empty-final"), |
| 4620 | text: "Reply with the requested recovery marker.", |
| 4621 | }), |
| 4622 | ); |
| 4623 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); |
| 4624 | |
| 4625 | assert.deepEqual( |
| 4626 | assistantMessages(harness.events).map((event) => event.message.text), |
| 4627 | [""], |
| 4628 | ); |
| 4629 | }).pipe( |
| 4630 | Effect.provide( |
| 4631 | Layer.mergeAll( |
| 4632 | IdAllocator.layer, |
| 4633 | McpProviderSessions.layer, |
| 4634 | ScriptProgress.layerUnavailable, |
| 4635 | NodeServices.layer, |
| 4636 | ), |
| 4637 | ), |
| 4638 | ), |
| 4639 | ), |
| 4640 | ); |
| 4641 | |
| 4642 | it.effect("preserves an empty final answer when only commentary preceded it", () => |
| 4643 | Effect.scoped( |
| 4644 | Effect.gen(function* () { |
| 4645 | const transcript = finalAnswerTranscript("codex-commentary-then-empty-final", [ |
| 4646 | { id: "answer-commentary", text: "Working on it.", phase: "commentary" }, |
| 4647 | { id: "answer-empty", text: "" }, |
| 4648 | ]); |
| 4649 | const harness = yield* makeCodexReplayHarness(transcript); |
| 4650 | const now = yield* DateTime.now; |
| 4651 | |
| 4652 | yield* harness.runtime.startTurn( |
| 4653 | makeCodexTestTurnInput({ |
| 4654 | threadId: harness.threadId, |
| 4655 | providerThread: harness.providerThread, |
| 4656 | now, |
| 4657 | attemptId: RunAttemptId.make("attempt-codex-commentary-then-empty-final"), |
| 4658 | text: "Reply with the requested recovery marker.", |
| 4659 | }), |
| 4660 | ); |
| 4661 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); |
| 4662 | |
| 4663 | assert.deepEqual( |
| 4664 | assistantMessages(harness.events).map((event) => event.message.text), |
| 4665 | ["Working on it.", ""], |
| 4666 | ); |
| 4667 | }).pipe( |
| 4668 | Effect.provide( |
| 4669 | Layer.mergeAll( |
| 4670 | IdAllocator.layer, |
| 4671 | McpProviderSessions.layer, |
| 4672 | ScriptProgress.layerUnavailable, |
| 4673 | NodeServices.layer, |
| 4674 | ), |
| 4675 | ), |
| 4676 | ), |
| 4677 | ), |
| 4678 | ); |
| 4679 | |
| 4680 | it.effect("suppresses an empty final answer after a non-empty unknown-phase answer", () => |
| 4681 | Effect.scoped( |
| 4682 | Effect.gen(function* () { |
| 4683 | const transcript = finalAnswerTranscript("codex-unknown-non-empty-then-empty-final", [ |
| 4684 | { id: "answer-non-empty", text: "CODEX_RECOVERY_OK", phase: null }, |
| 4685 | { id: "answer-empty", text: "" }, |
| 4686 | ]); |
| 4687 | const harness = yield* makeCodexReplayHarness(transcript); |
| 4688 | const now = yield* DateTime.now; |
| 4689 | |
| 4690 | yield* harness.runtime.startTurn( |
| 4691 | makeCodexTestTurnInput({ |
| 4692 | threadId: harness.threadId, |
| 4693 | providerThread: harness.providerThread, |
| 4694 | now, |
| 4695 | attemptId: RunAttemptId.make("attempt-codex-unknown-non-empty-then-empty-final"), |
| 4696 | text: "Reply with the requested recovery marker.", |
| 4697 | }), |
| 4698 | ); |
| 4699 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); |
| 4700 | |
| 4701 | assert.deepEqual( |
| 4702 | assistantMessages(harness.events).map((event) => event.message.text), |
| 4703 | ["CODEX_RECOVERY_OK"], |
| 4704 | ); |
| 4705 | }).pipe( |
| 4706 | Effect.provide( |
| 4707 | Layer.mergeAll( |
| 4708 | IdAllocator.layer, |
| 4709 | McpProviderSessions.layer, |
| 4710 | ScriptProgress.layerUnavailable, |
| 4711 | NodeServices.layer, |
| 4712 | ), |
| 4713 | ), |
| 4714 | ), |
| 4715 | ), |
| 4716 | ); |
| 4717 | |
| 4718 | it.effect("suppresses a trailing empty answer with an omitted phase", () => |
| 4719 | Effect.scoped( |
| 4720 | Effect.gen(function* () { |
| 4721 | const transcript = finalAnswerTranscript("codex-final-then-empty-unknown", [ |
| 4722 | { id: "answer-non-empty", text: "CODEX_RECOVERY_OK" }, |
| 4723 | { id: "answer-empty", text: "", omitPhase: true }, |
| 4724 | ]); |
| 4725 | const harness = yield* makeCodexReplayHarness(transcript); |
| 4726 | const now = yield* DateTime.now; |
| 4727 | |
| 4728 | yield* harness.runtime.startTurn( |
| 4729 | makeCodexTestTurnInput({ |
| 4730 | threadId: harness.threadId, |
| 4731 | providerThread: harness.providerThread, |
| 4732 | now, |
| 4733 | attemptId: RunAttemptId.make("attempt-codex-final-then-empty-unknown"), |
| 4734 | text: "Reply with the requested recovery marker.", |
| 4735 | }), |
| 4736 | ); |
| 4737 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); |
| 4738 | |
| 4739 | assert.deepEqual( |
| 4740 | assistantMessages(harness.events).map((event) => event.message.text), |
| 4741 | ["CODEX_RECOVERY_OK"], |
| 4742 | ); |
| 4743 | }).pipe( |
| 4744 | Effect.provide( |
| 4745 | Layer.mergeAll( |
| 4746 | IdAllocator.layer, |
| 4747 | McpProviderSessions.layer, |
| 4748 | ScriptProgress.layerUnavailable, |
| 4749 | NodeServices.layer, |
| 4750 | ), |
| 4751 | ), |
| 4752 | ), |
| 4753 | ), |
| 4754 | ); |
| 4755 | |
| 4756 | it.effect("keeps a later non-empty final answer after an initial empty final answer", () => |
| 4757 | Effect.scoped( |
| 4758 | Effect.gen(function* () { |
| 4759 | const transcript = finalAnswerTranscript("codex-empty-then-non-empty-final", [ |
| 4760 | { id: "answer-empty", text: "" }, |
| 4761 | { id: "answer-non-empty", text: "CODEX_RECOVERY_OK" }, |
| 4762 | ]); |
| 4763 | const harness = yield* makeCodexReplayHarness(transcript); |
| 4764 | const now = yield* DateTime.now; |
| 4765 | |
| 4766 | yield* harness.runtime.startTurn( |
| 4767 | makeCodexTestTurnInput({ |
| 4768 | threadId: harness.threadId, |
| 4769 | providerThread: harness.providerThread, |
| 4770 | now, |
| 4771 | attemptId: RunAttemptId.make("attempt-codex-empty-then-non-empty-final"), |
| 4772 | text: "Reply with the requested recovery marker.", |
| 4773 | }), |
| 4774 | ); |
| 4775 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); |
| 4776 | |
| 4777 | assert.deepEqual( |
| 4778 | assistantMessages(harness.events).map((event) => event.message.text), |
| 4779 | ["", "CODEX_RECOVERY_OK"], |
| 4780 | ); |
| 4781 | }).pipe( |
| 4782 | Effect.provide( |
| 4783 | Layer.mergeAll( |
| 4784 | IdAllocator.layer, |
| 4785 | McpProviderSessions.layer, |
| 4786 | ScriptProgress.layerUnavailable, |
| 4787 | NodeServices.layer, |
| 4788 | ), |
| 4789 | ), |
| 4790 | ), |
| 4791 | ), |
| 4792 | ); |
| 4793 | |
| 4794 | const BG_SCENARIO = "codex-bg-exec-wake"; |
| 4795 | const BG_NATIVE_THREAD = "native-codex-bg-thread"; |
| 4796 | const BG_NATIVE_TURN = "native-codex-bg-turn"; |
| 4797 | const BG_COMMAND_ITEM = "call-codex-bg-command"; |
| 4798 | const BG_COMMAND = "sleep 20 && echo CODEX_BG_WAKE_DONE"; |
| 4799 | const BG_PROMPT = "Start the sleep in the background and reply STARTED."; |
| 4800 | |
| 4801 | const backgroundCommandItem = (status: "inProgress" | "completed"): Record<string, unknown> => ({ |
| 4802 | type: "commandExecution", |
| 4803 | id: BG_COMMAND_ITEM, |
| 4804 | command: BG_COMMAND, |
| 4805 | cwd: "/workspace", |
| 4806 | processId: "4242", |
| 4807 | source: "unifiedExecStartup", |
| 4808 | status, |
| 4809 | commandActions: [{ type: "unknown", command: BG_COMMAND }], |
| 4810 | aggregatedOutput: status === "completed" ? "CODEX_BG_WAKE_DONE\n" : null, |
| 4811 | exitCode: status === "completed" ? 0 : null, |
| 4812 | durationMs: status === "completed" ? 25_000 : null, |
| 4813 | }); |
| 4814 | |
| 4815 | const backgroundExecTranscript = makeCodexReplayTranscript({ |
| 4816 | scenario: BG_SCENARIO, |
| 4817 | entries: [ |
| 4818 | ...codexReplayPreamble({ |
| 4819 | nativeThreadId: BG_NATIVE_THREAD, |
| 4820 | nativeTurnId: BG_NATIVE_TURN, |
| 4821 | prompt: BG_PROMPT, |
| 4822 | }), |
| 4823 | { |
| 4824 | type: "emit_inbound", |
| 4825 | label: "item/started/command", |
| 4826 | frame: { |
| 4827 | method: "item/started", |
| 4828 | params: { |
| 4829 | item: backgroundCommandItem("inProgress"), |
| 4830 | threadId: BG_NATIVE_THREAD, |
| 4831 | turnId: BG_NATIVE_TURN, |
| 4832 | startedAtMs: 1782622440500, |
| 4833 | }, |
| 4834 | }, |
| 4835 | }, |
| 4836 | { |
| 4837 | type: "emit_inbound", |
| 4838 | label: "item/completed/root-answer", |
| 4839 | frame: { |
| 4840 | method: "item/completed", |
| 4841 | params: { |
| 4842 | item: { |
| 4843 | type: "agentMessage", |
| 4844 | id: "root-answer-bg", |
| 4845 | text: "STARTED", |
| 4846 | phase: "final_answer", |
| 4847 | memoryCitation: null, |
| 4848 | }, |
| 4849 | threadId: BG_NATIVE_THREAD, |
| 4850 | turnId: BG_NATIVE_TURN, |
| 4851 | completedAtMs: 1782622441000, |
| 4852 | }, |
| 4853 | }, |
| 4854 | }, |
| 4855 | { |
| 4856 | type: "emit_inbound", |
| 4857 | label: "turn/completed", |
| 4858 | frame: { |
| 4859 | method: "turn/completed", |
| 4860 | params: { |
| 4861 | threadId: BG_NATIVE_THREAD, |
| 4862 | turn: makeCodexReplayTurn({ id: BG_NATIVE_TURN, status: "completed" }), |
| 4863 | }, |
| 4864 | }, |
| 4865 | }, |
| 4866 | { |
| 4867 | type: "emit_inbound", |
| 4868 | label: "item/completed/command-late", |
| 4869 | afterMs: 30_000, |
| 4870 | frame: { |
| 4871 | method: "item/completed", |
| 4872 | params: { |
| 4873 | item: backgroundCommandItem("completed"), |
| 4874 | threadId: BG_NATIVE_THREAD, |
| 4875 | turnId: BG_NATIVE_TURN, |
| 4876 | completedAtMs: 1782622465500, |
| 4877 | }, |
| 4878 | }, |
| 4879 | }, |
| 4880 | ], |
| 4881 | }); |
| 4882 | |
| 4883 | it.effect( |
| 4884 | "projects a post-settle background command completion and requests a continuation", |
| 4885 | () => |
| 4886 | Effect.scoped( |
| 4887 | Effect.gen(function* () { |
| 4888 | const harness = yield* makeCodexReplayHarness(backgroundExecTranscript); |
| 4889 | const now = yield* DateTime.now; |
| 4890 | |
| 4891 | yield* harness.runtime.startTurn( |
| 4892 | makeCodexTestTurnInput({ |
| 4893 | threadId: harness.threadId, |
| 4894 | providerThread: harness.providerThread, |
| 4895 | now, |
| 4896 | attemptId: RunAttemptId.make("attempt-codex-bg-wake"), |
| 4897 | text: BG_PROMPT, |
| 4898 | }), |
| 4899 | ); |
| 4900 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); |
| 4901 | assert.equal(harness.terminalEvents()[0]?.status, "completed"); |
| 4902 | assert.isTrue(yield* harness.hasPendingBackgroundWork); |
| 4903 | assert.isTrue( |
| 4904 | yield* harness.runtime.hasPendingBackgroundWorkForThread!(harness.providerThread), |
| 4905 | ); |
| 4906 | assert.lengthOf(harness.continuationRequests, 0); |
| 4907 | const terminalIndex = harness.events.findIndex((event) => event.type === "turn.terminal"); |
| 4908 | |
| 4909 | yield* TestClock.adjust("30 seconds"); |
| 4910 | yield* awaitUntil( |
| 4911 | () => harness.continuationRequests.length === 1, |
| 4912 | "continuation request", |
| 4913 | ); |
| 4914 | const request = harness.continuationRequests[0]; |
| 4915 | assert.equal(request?.threadId, harness.threadId); |
| 4916 | assert.equal(request?.providerThreadId, harness.providerThread.id); |
| 4917 | assert.equal(request?.driver, CodexAdapterV2.CODEX_DRIVER_KIND); |
| 4918 | assert.deepEqual(request?.notification, { |
| 4919 | source: { kind: "command" }, |
| 4920 | outcome: "completed", |
| 4921 | summary: `Command "${BG_COMMAND}" finished (exit 0)`, |
| 4922 | detail: BG_COMMAND, |
| 4923 | }); |
| 4924 | assert.equal( |
| 4925 | request?.detail, |
| 4926 | `Background command completed (exit 0): ${BG_COMMAND}\n\n` + |
| 4927 | "Output tail:\nCODEX_BG_WAKE_DONE", |
| 4928 | ); |
| 4929 | |
| 4930 | const lateCommandUpdateIndex = () => |
| 4931 | harness.events.findIndex( |
| 4932 | (event, index) => |
| 4933 | index > terminalIndex && |
| 4934 | event.type === "turn_item.updated" && |
| 4935 | event.turnItem.type === "command_execution" && |
| 4936 | event.turnItem.status === "completed" && |
| 4937 | event.turnItem.output === "CODEX_BG_WAKE_DONE\n" && |
| 4938 | event.turnItem.exitCode === 0, |
| 4939 | ); |
| 4940 | yield* awaitUntil( |
| 4941 | () => lateCommandUpdateIndex() > terminalIndex, |
| 4942 | "post-settle command projection", |
| 4943 | ); |
| 4944 | assert.lengthOf(harness.terminalEvents(), 1); |
| 4945 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 4946 | }).pipe( |
| 4947 | Effect.provide( |
| 4948 | Layer.mergeAll( |
| 4949 | IdAllocator.layer, |
| 4950 | McpProviderSessions.layer, |
| 4951 | ScriptProgress.layerUnavailable, |
| 4952 | NodeServices.layer, |
| 4953 | ), |
| 4954 | ), |
| 4955 | ), |
| 4956 | ), |
| 4957 | ); |
| 4958 | |
| 4959 | // "thread_unloaded": the thread was settled, so T3 unsubscribed and Codex |
| 4960 | // unloaded it (killing its terminals) before Stop arrived. |
| 4961 | const backgroundStopCases = [true, false, "still_running", "thread_unloaded"] as const; |
| 4962 | const makeBackgroundStopTranscript = (terminated: (typeof backgroundStopCases)[number]) => { |
| 4963 | const stillRunning = terminated === "still_running"; |
| 4964 | return makeCodexReplayTranscript({ |
| 4965 | scenario: `codex-bg-stop-${terminated}`, |
| 4966 | entries: [ |
| 4967 | ...backgroundExecTranscript.entries.slice(0, -1), |
| 4968 | { |
| 4969 | type: "expect_outbound", |
| 4970 | label: "terminate-background-command", |
| 4971 | frame: { |
| 4972 | id: 4, |
| 4973 | method: "thread/backgroundTerminals/terminate", |
| 4974 | params: { threadId: BG_NATIVE_THREAD, processId: "4242" }, |
| 4975 | }, |
| 4976 | }, |
| 4977 | { |
| 4978 | type: "emit_inbound", |
| 4979 | label: "terminate-background-command", |
| 4980 | frame: |
| 4981 | terminated === "thread_unloaded" |
| 4982 | ? { |
| 4983 | id: 4, |
| 4984 | error: { code: -32600, message: `thread not found: ${BG_NATIVE_THREAD}` }, |
| 4985 | } |
| 4986 | : { id: 4, result: { terminated: terminated === true } }, |
| 4987 | }, |
| 4988 | ...(terminated === false || stillRunning |
| 4989 | ? [ |
| 4990 | { |
| 4991 | type: "expect_outbound" as const, |
| 4992 | frame: { |
| 4993 | id: 5, |
| 4994 | method: "thread/backgroundTerminals/list", |
| 4995 | params: { threadId: BG_NATIVE_THREAD }, |
| 4996 | }, |
| 4997 | }, |
| 4998 | { |
| 4999 | type: "emit_inbound" as const, |
| 5000 | frame: { |
| 5001 | id: 5, |
| 5002 | result: { data: stillRunning ? [{ processId: "4242" }] : [], nextCursor: null }, |
| 5003 | }, |
| 5004 | }, |
| 5005 | ] |
| 5006 | : []), |
| 5007 | ...(stillRunning |
| 5008 | ? [ |
| 5009 | { |
| 5010 | type: "expect_outbound" as const, |
| 5011 | frame: { |
| 5012 | id: 6, |
| 5013 | method: "thread/backgroundTerminals/terminate", |
| 5014 | params: { threadId: BG_NATIVE_THREAD, processId: "4242" }, |
| 5015 | }, |
| 5016 | }, |
| 5017 | { |
| 5018 | type: "emit_inbound" as const, |
| 5019 | frame: { id: 6, result: { terminated: false } }, |
| 5020 | }, |
| 5021 | { |
| 5022 | type: "expect_outbound" as const, |
| 5023 | frame: { |
| 5024 | id: 7, |
| 5025 | method: "thread/backgroundTerminals/list", |
| 5026 | params: { threadId: BG_NATIVE_THREAD }, |
| 5027 | }, |
| 5028 | }, |
| 5029 | { |
| 5030 | type: "emit_inbound" as const, |
| 5031 | frame: { id: 7, result: { data: [{ processId: "4242" }], nextCursor: null } }, |
| 5032 | }, |
| 5033 | { |
| 5034 | type: "expect_outbound" as const, |
| 5035 | frame: { |
| 5036 | id: 8, |
| 5037 | method: "thread/backgroundTerminals/terminate", |
| 5038 | params: { threadId: BG_NATIVE_THREAD, processId: "4242" }, |
| 5039 | }, |
| 5040 | }, |
| 5041 | { |
| 5042 | type: "emit_inbound" as const, |
| 5043 | frame: { id: 8, result: { terminated: true } }, |
| 5044 | }, |
| 5045 | ] |
| 5046 | : []), |
| 5047 | backgroundExecTranscript.entries.at(-1)!, |
| 5048 | ], |
| 5049 | }); |
| 5050 | }; |
| 5051 | |
| 5052 | it.effect.each(backgroundStopCases)( |
| 5053 | "stops a command after root completion when termination returns %s", |
| 5054 | (terminated) => { |
| 5055 | const stillRunning = terminated === "still_running"; |
| 5056 | const transcript = makeBackgroundStopTranscript(terminated); |
| 5057 | return Effect.scoped( |
| 5058 | Effect.gen(function* () { |
| 5059 | const stopped = yield* Deferred.make<void>(); |
| 5060 | const harness = yield* makeCodexReplayHarness(transcript, (event) => |
| 5061 | event.type === "turn_item.updated" && |
| 5062 | event.turnItem.type === "command_execution" && |
| 5063 | event.turnItem.status === "interrupted" |
| 5064 | ? Deferred.succeed(stopped, undefined) |
| 5065 | : Effect.void, |
| 5066 | ); |
| 5067 | const now = yield* DateTime.now; |
| 5068 | yield* harness.runtime.startTurn( |
| 5069 | makeCodexTestTurnInput({ |
| 5070 | threadId: harness.threadId, |
| 5071 | providerThread: harness.providerThread, |
| 5072 | now, |
| 5073 | attemptId: RunAttemptId.make("attempt-codex-bg-stop"), |
| 5074 | text: BG_PROMPT, |
| 5075 | }), |
| 5076 | ); |
| 5077 | yield* harness.firstTerminal; |
| 5078 | const terminal = harness.terminalEvents()[0]!; |
| 5079 | assert.equal(terminal.status, "completed"); |
| 5080 | assert.isTrue(yield* harness.hasPendingBackgroundWork); |
| 5081 | assert.isFalse( |
| 5082 | yield* harness.runtime.hasPendingBackgroundWorkForThread!({ |
| 5083 | ...harness.providerThread, |
| 5084 | id: ProviderThreadId.make("unrelated-provider-thread"), |
| 5085 | }), |
| 5086 | ); |
| 5087 | if (stillRunning) { |
| 5088 | const failed = yield* harness.runtime |
| 5089 | .interruptTurn({ |
| 5090 | providerThread: harness.providerThread, |
| 5091 | providerTurnId: terminal.providerTurnId, |
| 5092 | requestRuntimeRestart: true, |
| 5093 | }) |
| 5094 | .pipe(Effect.exit); |
| 5095 | assert.equal(failed._tag, "Failure"); |
| 5096 | assert.isTrue(yield* harness.hasPendingBackgroundWork); |
| 5097 | } |
| 5098 | yield* harness.runtime.interruptTurn({ |
| 5099 | providerThread: harness.providerThread, |
| 5100 | providerTurnId: stillRunning |
| 5101 | ? ProviderTurnId.make("later-completed-turn") |
| 5102 | : terminal.providerTurnId, |
| 5103 | requestRuntimeRestart: true, |
| 5104 | }); |
| 5105 | yield* Deferred.await(stopped); |
| 5106 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 5107 | assert.lengthOf(harness.terminalEvents(), 1); |
| 5108 | assert.equal(harness.terminalEvents()[0]?.status, "completed"); |
| 5109 | assert.lengthOf(harness.continuationRequests, 0); |
| 5110 | }).pipe( |
| 5111 | Effect.provide( |
| 5112 | Layer.mergeAll( |
| 5113 | IdAllocator.layer, |
| 5114 | McpProviderSessions.layer, |
| 5115 | ScriptProgress.layerUnavailable, |
| 5116 | NodeServices.layer, |
| 5117 | ), |
| 5118 | ), |
| 5119 | ), |
| 5120 | ); |
| 5121 | }, |
| 5122 | ); |
| 5123 | it.effect("interrupts a completed run's background command through orchestration", () => { |
| 5124 | const transcript = makeBackgroundStopTranscript(true); |
| 5125 | return Effect.scoped( |
| 5126 | Effect.gen(function* () { |
| 5127 | const fs = yield* FileSystem.FileSystem; |
| 5128 | const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bg-stop-workspace-" }); |
| 5129 | const localTranscript = yield* decodeReplayTranscriptJson( |
| 5130 | (yield* encodeReplayTranscriptJson(transcript)).replaceAll( |
| 5131 | yield* encodeStringJson("/workspace"), |
| 5132 | yield* encodeStringJson(cwd), |
| 5133 | ), |
| 5134 | ); |
| 5135 | const replayDriver = yield* CodexReplay.makeReplayDriver(localTranscript); |
| 5136 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 5137 | assert.equal( |
| 5138 | Number(yield* spawner.exitCode(ChildProcess.make("git", ["init", "--quiet"], { cwd }))), |
| 5139 | 0, |
| 5140 | ); |
| 5141 | assert.equal( |
| 5142 | Number( |
| 5143 | yield* spawner.exitCode( |
| 5144 | ChildProcess.make( |
| 5145 | "git", |
| 5146 | [ |
| 5147 | "-c", |
| 5148 | "user.name=Test", |
| 5149 | "-c", |
| 5150 | "user.email=test@example.com", |
| 5151 | "commit", |
| 5152 | "--allow-empty", |
| 5153 | "--quiet", |
| 5154 | "-m", |
| 5155 | "Initial commit", |
| 5156 | ], |
| 5157 | { cwd }, |
| 5158 | ), |
| 5159 | ), |
| 5160 | ), |
| 5161 | 0, |
| 5162 | ); |
| 5163 | yield* Effect.gen(function* () { |
| 5164 | const orchestrator = yield* Orchestrator.OrchestratorV2; |
| 5165 | const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; |
| 5166 | const threadId = ThreadId.make("thread:background-stop"); |
| 5167 | yield* orchestrator.dispatch({ |
| 5168 | type: "thread.create", |
| 5169 | commandId: CommandId.make("create-background-stop"), |
| 5170 | threadId, |
| 5171 | projectId: ProjectId.make("project:background-stop"), |
| 5172 | title: "Background stop", |
| 5173 | modelSelection: CODEX_TEST_MODEL_SELECTION, |
| 5174 | runtimeMode: "full-access", |
| 5175 | interactionMode: "default", |
| 5176 | branch: null, |
| 5177 | worktreePath: cwd, |
| 5178 | createdBy: "user", |
| 5179 | creationSource: "web", |
| 5180 | }); |
| 5181 | const waiting = yield* orchestrator.streamDomainEvents.pipe( |
| 5182 | Stream.filter( |
| 5183 | (event) => event.type === "run.updated" && event.payload.status === "waiting", |
| 5184 | ), |
| 5185 | Stream.runHead, |
| 5186 | Effect.forkChild({ startImmediately: true }), |
| 5187 | ); |
| 5188 | yield* orchestrator.dispatch({ |
| 5189 | type: "message.dispatch", |
| 5190 | commandId: CommandId.make("start-background-stop"), |
| 5191 | threadId, |
| 5192 | messageId: MessageId.make("message:background-stop"), |
| 5193 | text: BG_PROMPT, |
| 5194 | attachments: [], |
| 5195 | createdBy: "user", |
| 5196 | creationSource: "web", |
| 5197 | dispatchMode: { type: "start_immediately" }, |
| 5198 | }); |
| 5199 | yield* worker.drain(); |
| 5200 | assert.isNull((yield* Ref.get(replayDriver.state)).failure); |
| 5201 | yield* Fiber.join(waiting); |
| 5202 | yield* worker.drain(); |
| 5203 | const projection = yield* orchestrator.getThreadProjection(threadId); |
| 5204 | const run = projection.runs.at(-1)!; |
| 5205 | assert.equal(run.status, "completed"); |
| 5206 | assert.equal( |
| 5207 | (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks?.length, |
| 5208 | 1, |
| 5209 | ); |
| 5210 | const stopped = yield* orchestrator.streamDomainEvents.pipe( |
| 5211 | Stream.filter( |
| 5212 | (event) => |
| 5213 | event.type === "turn-item.updated" && |
| 5214 | event.payload.type === "command_execution" && |
| 5215 | event.payload.status === "interrupted", |
| 5216 | ), |
| 5217 | Stream.runHead, |
| 5218 | Effect.forkChild({ startImmediately: true }), |
| 5219 | ); |
| 5220 | yield* orchestrator.dispatch({ |
| 5221 | type: "run.interrupt", |
| 5222 | commandId: CommandId.make("stop-background-command"), |
| 5223 | threadId, |
| 5224 | runId: run.id, |
| 5225 | }); |
| 5226 | yield* worker.drain(); |
| 5227 | yield* Fiber.join(stopped); |
| 5228 | assert.equal( |
| 5229 | (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status, |
| 5230 | "completed", |
| 5231 | ); |
| 5232 | assert.deepEqual( |
| 5233 | (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks, |
| 5234 | [], |
| 5235 | ); |
| 5236 | }).pipe( |
| 5237 | Effect.provide( |
| 5238 | ProviderReplayHarness.layerWithRegistry( |
| 5239 | { name: "codex-background-stop", runtimePolicyOverride: { cwd } }, |
| 5240 | CodexAdapterV2Testkit.layer({ |
| 5241 | transcript: localTranscript, |
| 5242 | driver: replayDriver, |
| 5243 | }), |
| 5244 | { runEffectWorker: false }, |
| 5245 | ), |
| 5246 | ), |
| 5247 | ); |
| 5248 | }).pipe(Effect.provide(NodeServices.layer)), |
| 5249 | ); |
| 5250 | }); |
| 5251 | |
| 5252 | // The app-server exits after the root turn, before the command's own |
| 5253 | // item/completed (Codex always sends one, so only a lost notification or a |
| 5254 | // gone process leaves it running). Nothing tracks the command any more, yet |
| 5255 | // the thread still shows it, and Stop is the only way to clear it. |
| 5256 | it.effect("Stop ends a background command no Codex process tracks any more", () => |
| 5257 | Effect.scoped( |
| 5258 | Effect.gen(function* () { |
| 5259 | const fs = yield* FileSystem.FileSystem; |
| 5260 | const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bg-stale-workspace-" }); |
| 5261 | const staleTranscript = makeCodexReplayTranscript({ |
| 5262 | scenario: "codex-bg-stop-untracked", |
| 5263 | entries: [ |
| 5264 | ...backgroundExecTranscript.entries.slice(0, -1), |
| 5265 | { type: "runtime_exit", status: "success" }, |
| 5266 | ], |
| 5267 | }); |
| 5268 | const localTranscript = yield* decodeReplayTranscriptJson( |
| 5269 | (yield* encodeReplayTranscriptJson(staleTranscript)).replaceAll( |
| 5270 | yield* encodeStringJson("/workspace"), |
| 5271 | yield* encodeStringJson(cwd), |
| 5272 | ), |
| 5273 | ); |
| 5274 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 5275 | for (const args of [ |
| 5276 | ["init", "--quiet"], |
| 5277 | [ |
| 5278 | "-c", |
| 5279 | "user.name=Test", |
| 5280 | "-c", |
| 5281 | "user.email=test@example.com", |
| 5282 | "commit", |
| 5283 | "--allow-empty", |
| 5284 | "--quiet", |
| 5285 | "-m", |
| 5286 | "Initial commit", |
| 5287 | ], |
| 5288 | ]) { |
| 5289 | assert.equal(Number(yield* spawner.exitCode(ChildProcess.make("git", args, { cwd }))), 0); |
| 5290 | } |
| 5291 | yield* Effect.gen(function* () { |
| 5292 | const orchestrator = yield* Orchestrator.OrchestratorV2; |
| 5293 | const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; |
| 5294 | const threadId = ThreadId.make("thread:background-stop-untracked"); |
| 5295 | yield* orchestrator.dispatch({ |
| 5296 | type: "thread.create", |
| 5297 | commandId: CommandId.make("create-background-stop-untracked"), |
| 5298 | threadId, |
| 5299 | projectId: ProjectId.make("project:background-stop-untracked"), |
| 5300 | title: "Background stop untracked", |
| 5301 | modelSelection: CODEX_TEST_MODEL_SELECTION, |
| 5302 | runtimeMode: "full-access", |
| 5303 | interactionMode: "default", |
| 5304 | branch: null, |
| 5305 | worktreePath: cwd, |
| 5306 | createdBy: "user", |
| 5307 | creationSource: "web", |
| 5308 | }); |
| 5309 | const settled = yield* orchestrator.streamDomainEvents.pipe( |
| 5310 | Stream.filter( |
| 5311 | (event) => |
| 5312 | event.type === "run.updated" && |
| 5313 | (event.payload.status === "waiting" || event.payload.status === "completed"), |
| 5314 | ), |
| 5315 | Stream.runHead, |
| 5316 | Effect.forkChild({ startImmediately: true }), |
| 5317 | ); |
| 5318 | yield* orchestrator.dispatch({ |
| 5319 | type: "message.dispatch", |
| 5320 | commandId: CommandId.make("start-background-stop-untracked"), |
| 5321 | threadId, |
| 5322 | messageId: MessageId.make("message:background-stop-untracked"), |
| 5323 | text: BG_PROMPT, |
| 5324 | attachments: [], |
| 5325 | createdBy: "user", |
| 5326 | creationSource: "web", |
| 5327 | dispatchMode: { type: "start_immediately" }, |
| 5328 | }); |
| 5329 | yield* worker.drain(); |
| 5330 | yield* Fiber.join(settled); |
| 5331 | yield* worker.drain(); |
| 5332 | const before = yield* orchestrator.getThreadShell(threadId); |
| 5333 | assert.deepEqual( |
| 5334 | before?.pendingBackgroundTasks?.map((task) => task.kind), |
| 5335 | ["command"], |
| 5336 | "the thread still shows the command the gone process never finished", |
| 5337 | ); |
| 5338 | const run = (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)!; |
| 5339 | yield* orchestrator.dispatch({ |
| 5340 | type: "run.interrupt", |
| 5341 | commandId: CommandId.make("stop-background-untracked"), |
| 5342 | threadId, |
| 5343 | runId: run.id, |
| 5344 | holdQueue: true, |
| 5345 | }); |
| 5346 | yield* worker.drain(); |
| 5347 | const projection = yield* orchestrator.getThreadProjection(threadId); |
| 5348 | assert.deepEqual( |
| 5349 | projection.turnItems.flatMap((item) => |
| 5350 | item.type === "command_execution" ? [item.status] : [], |
| 5351 | ), |
| 5352 | ["interrupted"], |
| 5353 | ); |
| 5354 | assert.equal(projection.runs.at(-1)?.status, "completed"); |
| 5355 | assert.deepEqual( |
| 5356 | (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks, |
| 5357 | [], |
| 5358 | ); |
| 5359 | }).pipe( |
| 5360 | Effect.provide( |
| 5361 | ProviderReplayHarness.layerWithRegistry( |
| 5362 | { name: "codex-background-stop-untracked", runtimePolicyOverride: { cwd } }, |
| 5363 | CodexAdapterV2Testkit.layer({ transcript: localTranscript }), |
| 5364 | { runEffectWorker: false }, |
| 5365 | ), |
| 5366 | ), |
| 5367 | ); |
| 5368 | }).pipe(Effect.provide(NodeServices.layer)), |
| 5369 | ), |
| 5370 | ); |
| 5371 | |
| 5372 | const PRE_SETTLE_SCENARIO = "codex-bg-exec-pre-settle"; |
| 5373 | const PRE_SETTLE_NATIVE_THREAD = "native-codex-pre-settle-thread"; |
| 5374 | const PRE_SETTLE_NATIVE_TURN = "native-codex-pre-settle-turn"; |
| 5375 | |
| 5376 | const preSettleTranscript = makeCodexReplayTranscript({ |
| 5377 | scenario: PRE_SETTLE_SCENARIO, |
| 5378 | entries: [ |
| 5379 | ...codexReplayPreamble({ |
| 5380 | nativeThreadId: PRE_SETTLE_NATIVE_THREAD, |
| 5381 | nativeTurnId: PRE_SETTLE_NATIVE_TURN, |
| 5382 | prompt: BG_PROMPT, |
| 5383 | }), |
| 5384 | { |
| 5385 | type: "emit_inbound", |
| 5386 | label: "item/started/command", |
| 5387 | frame: { |
| 5388 | method: "item/started", |
| 5389 | params: { |
| 5390 | item: backgroundCommandItem("inProgress"), |
| 5391 | threadId: PRE_SETTLE_NATIVE_THREAD, |
| 5392 | turnId: PRE_SETTLE_NATIVE_TURN, |
| 5393 | startedAtMs: 1782622440500, |
| 5394 | }, |
| 5395 | }, |
| 5396 | }, |
| 5397 | { |
| 5398 | type: "emit_inbound", |
| 5399 | label: "item/completed/command-pre-settle", |
| 5400 | frame: { |
| 5401 | method: "item/completed", |
| 5402 | params: { |
| 5403 | item: backgroundCommandItem("completed"), |
| 5404 | threadId: PRE_SETTLE_NATIVE_THREAD, |
| 5405 | turnId: PRE_SETTLE_NATIVE_TURN, |
| 5406 | completedAtMs: 1782622441000, |
| 5407 | }, |
| 5408 | }, |
| 5409 | }, |
| 5410 | { |
| 5411 | type: "emit_inbound", |
| 5412 | label: "item/completed/root-answer", |
| 5413 | frame: { |
| 5414 | method: "item/completed", |
| 5415 | params: { |
| 5416 | item: { |
| 5417 | type: "agentMessage", |
| 5418 | id: "root-answer-pre-settle", |
| 5419 | text: "DONE", |
| 5420 | phase: "final_answer", |
| 5421 | memoryCitation: null, |
| 5422 | }, |
| 5423 | threadId: PRE_SETTLE_NATIVE_THREAD, |
| 5424 | turnId: PRE_SETTLE_NATIVE_TURN, |
| 5425 | completedAtMs: 1782622441500, |
| 5426 | }, |
| 5427 | }, |
| 5428 | }, |
| 5429 | { |
| 5430 | type: "emit_inbound", |
| 5431 | label: "turn/completed", |
| 5432 | frame: { |
| 5433 | method: "turn/completed", |
| 5434 | params: { |
| 5435 | threadId: PRE_SETTLE_NATIVE_THREAD, |
| 5436 | turn: makeCodexReplayTurn({ id: PRE_SETTLE_NATIVE_TURN, status: "completed" }), |
| 5437 | }, |
| 5438 | }, |
| 5439 | }, |
| 5440 | ], |
| 5441 | }); |
| 5442 | |
| 5443 | it.effect("does not request a continuation for a command that completes before settle", () => |
| 5444 | Effect.scoped( |
| 5445 | Effect.gen(function* () { |
| 5446 | const harness = yield* makeCodexReplayHarness(preSettleTranscript); |
| 5447 | const now = yield* DateTime.now; |
| 5448 | |
| 5449 | yield* harness.runtime.startTurn( |
| 5450 | makeCodexTestTurnInput({ |
| 5451 | threadId: harness.threadId, |
| 5452 | providerThread: harness.providerThread, |
| 5453 | now, |
| 5454 | attemptId: RunAttemptId.make("attempt-codex-bg-pre-settle"), |
| 5455 | text: BG_PROMPT, |
| 5456 | }), |
| 5457 | ); |
| 5458 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); |
| 5459 | assert.equal(harness.terminalEvents()[0]?.status, "completed"); |
| 5460 | yield* awaitUntil( |
| 5461 | () => |
| 5462 | harness.events.some( |
| 5463 | (event) => |
| 5464 | event.type === "turn_item.updated" && |
| 5465 | event.turnItem.type === "command_execution" && |
| 5466 | event.turnItem.status === "completed" && |
| 5467 | event.turnItem.exitCode === 0, |
| 5468 | ), |
| 5469 | "pre-settle command projection", |
| 5470 | ); |
| 5471 | |
| 5472 | yield* TestClock.adjust("30 seconds"); |
| 5473 | for (let attempt = 0; attempt < 100; attempt++) { |
| 5474 | yield* Effect.yieldNow; |
| 5475 | } |
| 5476 | assert.lengthOf(harness.continuationRequests, 0); |
| 5477 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 5478 | assert.lengthOf(harness.terminalEvents(), 1); |
| 5479 | }).pipe( |
| 5480 | Effect.provide( |
| 5481 | Layer.mergeAll( |
| 5482 | IdAllocator.layer, |
| 5483 | McpProviderSessions.layer, |
| 5484 | ScriptProgress.layerUnavailable, |
| 5485 | NodeServices.layer, |
| 5486 | ), |
| 5487 | ), |
| 5488 | ), |
| 5489 | ), |
| 5490 | ); |
| 5491 | |
| 5492 | const INTERRUPT_SCENARIO = "codex-interrupt-mid-command"; |
| 5493 | const INTERRUPT_NATIVE_THREAD = "native-codex-interrupt-thread"; |
| 5494 | const INTERRUPT_NATIVE_TURN = "native-codex-interrupt-turn"; |
| 5495 | const INTERRUPT_COMMAND_ITEM = "exec-codex-interrupt-command"; |
| 5496 | const INTERRUPT_COMMAND_ITEM_TWO = "exec-codex-interrupt-command-two"; |
| 5497 | const INTERRUPT_CHILD_COMMAND_ITEM = "exec-codex-interrupt-child-command"; |
| 5498 | const INTERRUPT_CHILD_TIMEOUT_BOUNDARY_ITEM = "exec-codex-interrupt-child-timeout-boundary"; |
| 5499 | const INTERRUPT_CHILD_NATIVE_THREAD = "native-codex-interrupt-child-thread"; |
| 5500 | const INTERRUPT_CHILD_NATIVE_TURN = "native-codex-interrupt-child-turn"; |
| 5501 | const INTERRUPT_LATE_CHILD_NATIVE_TURN = "native-codex-interrupt-late-child-turn"; |
| 5502 | const INTERRUPT_LATE_CHILD_2_NATIVE_TURN = "native-codex-interrupt-late-child-2-turn"; |
| 5503 | const INTERRUPT_TIMEOUT_BOUNDARY_ITEM = "exec-codex-interrupt-timeout-boundary"; |
| 5504 | const INTERRUPT_TIMEOUT_LATE_ITEM = "exec-codex-interrupt-timeout-late"; |
| 5505 | const INTERRUPT_COMMAND = "bash -c 'sleep 30; echo SHOULD_NOT_FINISH_CMD_INTERRUPT_FIXTURE'"; |
| 5506 | const INTERRUPT_COMMAND_TWO = "bash -c 'sleep 20; echo SECOND_COMMAND'"; |
| 5507 | const INTERRUPT_PROMPT = "Run a long foreground command and wait until interrupted."; |
| 5508 | |
| 5509 | const interruptCommandItem = (status: "inProgress" | "completed"): Record<string, unknown> => ({ |
| 5510 | type: "commandExecution", |
| 5511 | id: INTERRUPT_COMMAND_ITEM, |
| 5512 | command: INTERRUPT_COMMAND, |
| 5513 | cwd: "/workspace", |
| 5514 | processId: "57680", |
| 5515 | source: "unifiedExecStartup", |
| 5516 | status, |
| 5517 | commandActions: [{ type: "unknown", command: INTERRUPT_COMMAND }], |
| 5518 | aggregatedOutput: status === "completed" ? "SHOULD_NOT_FINISH_CMD_INTERRUPT_FIXTURE\n" : null, |
| 5519 | exitCode: status === "completed" ? 0 : null, |
| 5520 | durationMs: status === "completed" ? 30_000 : null, |
| 5521 | }); |
| 5522 | |
| 5523 | const interruptMidCommandTranscript = makeCodexReplayTranscript({ |
| 5524 | scenario: INTERRUPT_SCENARIO, |
| 5525 | entries: [ |
| 5526 | ...codexReplayPreamble({ |
| 5527 | nativeThreadId: INTERRUPT_NATIVE_THREAD, |
| 5528 | nativeTurnId: INTERRUPT_NATIVE_TURN, |
| 5529 | prompt: INTERRUPT_PROMPT, |
| 5530 | }), |
| 5531 | { |
| 5532 | type: "emit_inbound", |
| 5533 | label: "item/started/command", |
| 5534 | frame: { |
| 5535 | method: "item/started", |
| 5536 | params: { |
| 5537 | item: interruptCommandItem("inProgress"), |
| 5538 | threadId: INTERRUPT_NATIVE_THREAD, |
| 5539 | turnId: INTERRUPT_NATIVE_TURN, |
| 5540 | startedAtMs: 1782622440500, |
| 5541 | }, |
| 5542 | }, |
| 5543 | }, |
| 5544 | { |
| 5545 | type: "expect_outbound", |
| 5546 | label: "turn/interrupt", |
| 5547 | frame: { |
| 5548 | id: 4, |
| 5549 | method: "turn/interrupt", |
| 5550 | params: { |
| 5551 | threadId: INTERRUPT_NATIVE_THREAD, |
| 5552 | turnId: INTERRUPT_NATIVE_TURN, |
| 5553 | }, |
| 5554 | }, |
| 5555 | }, |
| 5556 | { |
| 5557 | type: "emit_inbound", |
| 5558 | label: "turn/interrupt", |
| 5559 | frame: { id: 4, result: {} }, |
| 5560 | }, |
| 5561 | { |
| 5562 | type: "emit_inbound", |
| 5563 | label: "item/started/command-two-after-interrupt-response", |
| 5564 | frame: { |
| 5565 | method: "item/started", |
| 5566 | params: { |
| 5567 | item: { |
| 5568 | ...interruptCommandItem("inProgress"), |
| 5569 | id: INTERRUPT_COMMAND_ITEM_TWO, |
| 5570 | command: INTERRUPT_COMMAND_TWO, |
| 5571 | processId: "57681", |
| 5572 | commandActions: [{ type: "unknown", command: INTERRUPT_COMMAND_TWO }], |
| 5573 | }, |
| 5574 | threadId: INTERRUPT_NATIVE_THREAD, |
| 5575 | turnId: INTERRUPT_NATIVE_TURN, |
| 5576 | startedAtMs: 1782622440600, |
| 5577 | }, |
| 5578 | }, |
| 5579 | }, |
| 5580 | { |
| 5581 | type: "emit_inbound", |
| 5582 | label: "turn/completed", |
| 5583 | frame: { |
| 5584 | method: "turn/completed", |
| 5585 | params: { |
| 5586 | threadId: INTERRUPT_NATIVE_THREAD, |
| 5587 | turn: makeCodexReplayTurn({ |
| 5588 | id: INTERRUPT_NATIVE_TURN, |
| 5589 | status: "interrupted", |
| 5590 | }), |
| 5591 | }, |
| 5592 | }, |
| 5593 | }, |
| 5594 | { |
| 5595 | type: "expect_outbound", |
| 5596 | label: "thread/backgroundTerminals/terminate/one", |
| 5597 | frame: { |
| 5598 | id: 5, |
| 5599 | method: "thread/backgroundTerminals/terminate", |
| 5600 | params: { threadId: INTERRUPT_NATIVE_THREAD, processId: "57680" }, |
| 5601 | }, |
| 5602 | }, |
| 5603 | { |
| 5604 | type: "emit_inbound", |
| 5605 | label: "thread/backgroundTerminals/terminate/one", |
| 5606 | frame: { id: 5, result: { terminated: false } }, |
| 5607 | }, |
| 5608 | { |
| 5609 | type: "expect_outbound", |
| 5610 | label: "thread/backgroundTerminals/list/after-false", |
| 5611 | frame: { |
| 5612 | id: 6, |
| 5613 | method: "thread/backgroundTerminals/list", |
| 5614 | params: { threadId: INTERRUPT_NATIVE_THREAD }, |
| 5615 | }, |
| 5616 | }, |
| 5617 | { |
| 5618 | type: "emit_inbound", |
| 5619 | label: "thread/backgroundTerminals/list/after-false", |
| 5620 | frame: { id: 6, result: { data: [], nextCursor: null } }, |
| 5621 | }, |
| 5622 | { |
| 5623 | type: "expect_outbound", |
| 5624 | label: "thread/backgroundTerminals/terminate/two", |
| 5625 | frame: { |
| 5626 | id: 7, |
| 5627 | method: "thread/backgroundTerminals/terminate", |
| 5628 | params: { threadId: INTERRUPT_NATIVE_THREAD, processId: "57681" }, |
| 5629 | }, |
| 5630 | }, |
| 5631 | { |
| 5632 | type: "emit_inbound", |
| 5633 | label: "thread/backgroundTerminals/terminate/two", |
| 5634 | frame: { id: 7, result: { terminated: true } }, |
| 5635 | }, |
| 5636 | { |
| 5637 | type: "emit_inbound", |
| 5638 | label: "item/completed/command-late", |
| 5639 | afterMs: 30_000, |
| 5640 | frame: { |
| 5641 | method: "item/completed", |
| 5642 | params: { |
| 5643 | item: interruptCommandItem("completed"), |
| 5644 | threadId: INTERRUPT_NATIVE_THREAD, |
| 5645 | turnId: INTERRUPT_NATIVE_TURN, |
| 5646 | completedAtMs: 1782622465500, |
| 5647 | }, |
| 5648 | }, |
| 5649 | }, |
| 5650 | ], |
| 5651 | }); |
| 5652 | |
| 5653 | it.effect("contains commands that start before and after the interrupt response", () => |
| 5654 | Effect.scoped( |
| 5655 | Effect.gen(function* () { |
| 5656 | const harness = yield* makeCodexReplayHarness(interruptMidCommandTranscript); |
| 5657 | const now = yield* DateTime.now; |
| 5658 | |
| 5659 | yield* harness.runtime.startTurn( |
| 5660 | makeCodexTestTurnInput({ |
| 5661 | threadId: harness.threadId, |
| 5662 | providerThread: harness.providerThread, |
| 5663 | now, |
| 5664 | attemptId: RunAttemptId.make("attempt-codex-interrupt-mid-command"), |
| 5665 | text: INTERRUPT_PROMPT, |
| 5666 | }), |
| 5667 | ); |
| 5668 | |
| 5669 | yield* awaitUntil( |
| 5670 | () => |
| 5671 | harness.events.some( |
| 5672 | (event) => |
| 5673 | event.type === "turn_item.updated" && |
| 5674 | event.turnItem.type === "command_execution" && |
| 5675 | event.turnItem.status === "running", |
| 5676 | ), |
| 5677 | "running command item", |
| 5678 | ); |
| 5679 | |
| 5680 | const providerTurnId = harness.events.find( |
| 5681 | ( |
| 5682 | event, |
| 5683 | ): event is Extract< |
| 5684 | ProviderAdapter.ProviderAdapterV2Event, |
| 5685 | { type: "provider_turn.updated" } |
| 5686 | > => event.type === "provider_turn.updated", |
| 5687 | )?.providerTurn.id; |
| 5688 | assert.isDefined(providerTurnId); |
| 5689 | |
| 5690 | yield* harness.runtime.interruptTurn({ |
| 5691 | providerThread: harness.providerThread, |
| 5692 | providerTurnId, |
| 5693 | }); |
| 5694 | |
| 5695 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "interrupted terminal"); |
| 5696 | assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); |
| 5697 | |
| 5698 | const terminalIndex = harness.events.findIndex((event) => event.type === "turn.terminal"); |
| 5699 | assert.isAtLeast(terminalIndex, 0); |
| 5700 | |
| 5701 | let lastCommandBeforeTerminal: |
| 5702 | | Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "turn_item.updated" }> |
| 5703 | | undefined; |
| 5704 | for (let index = 0; index < terminalIndex; index++) { |
| 5705 | const event = harness.events[index]; |
| 5706 | if (event?.type === "turn_item.updated" && event.turnItem.type === "command_execution") { |
| 5707 | lastCommandBeforeTerminal = event; |
| 5708 | } |
| 5709 | } |
| 5710 | assert.isDefined(lastCommandBeforeTerminal); |
| 5711 | assert.equal(lastCommandBeforeTerminal.turnItem.status, "interrupted"); |
| 5712 | assert.isNotNull(lastCommandBeforeTerminal.turnItem.completedAt); |
| 5713 | |
| 5714 | const interruptedCommandsBeforeTerminal = harness.events |
| 5715 | .slice(0, terminalIndex) |
| 5716 | .flatMap((event) => |
| 5717 | event.type === "turn_item.updated" && |
| 5718 | event.turnItem.type === "command_execution" && |
| 5719 | event.turnItem.status === "interrupted" |
| 5720 | ? [event.turnItem.input] |
| 5721 | : [], |
| 5722 | ) |
| 5723 | .sort(); |
| 5724 | assert.deepEqual( |
| 5725 | interruptedCommandsBeforeTerminal, |
| 5726 | [INTERRUPT_COMMAND, INTERRUPT_COMMAND_TWO].sort(), |
| 5727 | ); |
| 5728 | |
| 5729 | const interruptedCommandIndex = harness.events.findIndex( |
| 5730 | (event, index) => |
| 5731 | index < terminalIndex && |
| 5732 | event.type === "turn_item.updated" && |
| 5733 | event.turnItem.type === "command_execution" && |
| 5734 | event.turnItem.status === "interrupted", |
| 5735 | ); |
| 5736 | assert.isAbove( |
| 5737 | terminalIndex, |
| 5738 | interruptedCommandIndex, |
| 5739 | "command terminalization must precede turn.terminal", |
| 5740 | ); |
| 5741 | |
| 5742 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 5743 | assert.lengthOf(harness.continuationRequests, 0); |
| 5744 | |
| 5745 | // Late provider item/completed after interrupt must not revive the card |
| 5746 | // or request a background-command wake continuation. |
| 5747 | yield* TestClock.adjust("30 seconds"); |
| 5748 | for (let attempt = 0; attempt < 100; attempt++) { |
| 5749 | yield* Effect.yieldNow; |
| 5750 | } |
| 5751 | assert.lengthOf(harness.continuationRequests, 0); |
| 5752 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 5753 | assert.lengthOf(harness.terminalEvents(), 1); |
| 5754 | |
| 5755 | const postTerminalCommandUpdates = harness.events.filter( |
| 5756 | (event, index) => |
| 5757 | index > terminalIndex && |
| 5758 | event.type === "turn_item.updated" && |
| 5759 | event.turnItem.type === "command_execution", |
| 5760 | ); |
| 5761 | assert.lengthOf( |
| 5762 | postTerminalCommandUpdates, |
| 5763 | 0, |
| 5764 | "late item/completed after interrupt must not project", |
| 5765 | ); |
| 5766 | |
| 5767 | const commandUpdates = harness.events.filter( |
| 5768 | ( |
| 5769 | event, |
| 5770 | ): event is Extract< |
| 5771 | ProviderAdapter.ProviderAdapterV2Event, |
| 5772 | { type: "turn_item.updated" } |
| 5773 | > => event.type === "turn_item.updated" && event.turnItem.type === "command_execution", |
| 5774 | ); |
| 5775 | assert.isAtLeast(commandUpdates.length, 2, "start + interrupt terminalization"); |
| 5776 | assert.equal(commandUpdates[commandUpdates.length - 1]?.turnItem.status, "interrupted"); |
| 5777 | }).pipe( |
| 5778 | Effect.provide( |
| 5779 | Layer.mergeAll( |
| 5780 | IdAllocator.layer, |
| 5781 | McpProviderSessions.layer, |
| 5782 | ScriptProgress.layerUnavailable, |
| 5783 | NodeServices.layer, |
| 5784 | ), |
| 5785 | ), |
| 5786 | ), |
| 5787 | ), |
| 5788 | ); |
| 5789 | |
| 5790 | const interruptSubagentCommandTranscript = makeCodexReplayTranscript({ |
| 5791 | scenario: "codex-interrupt-subagent-command", |
| 5792 | entries: [ |
| 5793 | ...codexReplayPreamble({ |
| 5794 | nativeThreadId: INTERRUPT_NATIVE_THREAD, |
| 5795 | nativeTurnId: INTERRUPT_NATIVE_TURN, |
| 5796 | prompt: INTERRUPT_PROMPT, |
| 5797 | }), |
| 5798 | { |
| 5799 | type: "emit_inbound", |
| 5800 | label: "item/completed/subAgentActivity-started", |
| 5801 | frame: { |
| 5802 | method: "item/completed", |
| 5803 | params: { |
| 5804 | item: { |
| 5805 | type: "subAgentActivity", |
| 5806 | id: "call-codex-interrupt-subagent", |
| 5807 | kind: "started", |
| 5808 | agentThreadId: INTERRUPT_CHILD_NATIVE_THREAD, |
| 5809 | agentPath: "/root/stop_hold", |
| 5810 | }, |
| 5811 | threadId: INTERRUPT_NATIVE_THREAD, |
| 5812 | turnId: INTERRUPT_NATIVE_TURN, |
| 5813 | completedAtMs: 1782622441000, |
| 5814 | }, |
| 5815 | }, |
| 5816 | }, |
| 5817 | { |
| 5818 | type: "emit_inbound", |
| 5819 | label: "turn/started/child", |
| 5820 | frame: { |
| 5821 | method: "turn/started", |
| 5822 | params: { |
| 5823 | threadId: INTERRUPT_CHILD_NATIVE_THREAD, |
| 5824 | turn: makeCodexReplayTurn({ |
| 5825 | id: INTERRUPT_CHILD_NATIVE_TURN, |
| 5826 | status: "inProgress", |
| 5827 | }), |
| 5828 | }, |
| 5829 | }, |
| 5830 | }, |
| 5831 | { |
| 5832 | type: "emit_inbound", |
| 5833 | label: "item/started/child-command", |
| 5834 | frame: { |
| 5835 | method: "item/started", |
| 5836 | params: { |
| 5837 | item: { |
| 5838 | ...interruptCommandItem("inProgress"), |
| 5839 | id: INTERRUPT_CHILD_COMMAND_ITEM, |
| 5840 | processId: "57682", |
| 5841 | }, |
| 5842 | threadId: INTERRUPT_CHILD_NATIVE_THREAD, |
| 5843 | turnId: INTERRUPT_CHILD_NATIVE_TURN, |
| 5844 | startedAtMs: 1782622441500, |
| 5845 | }, |
| 5846 | }, |
| 5847 | }, |
| 5848 | { |
| 5849 | type: "expect_outbound", |
| 5850 | label: "turn/interrupt/root", |
| 5851 | frame: { |
| 5852 | id: 4, |
| 5853 | method: "turn/interrupt", |
| 5854 | params: { |
| 5855 | threadId: INTERRUPT_NATIVE_THREAD, |
| 5856 | turnId: INTERRUPT_NATIVE_TURN, |
| 5857 | }, |
| 5858 | }, |
| 5859 | }, |
| 5860 | { |
| 5861 | type: "emit_inbound", |
| 5862 | label: "turn/interrupt/root", |
| 5863 | frame: { id: 4, result: {} }, |
| 5864 | }, |
| 5865 | { |
| 5866 | type: "expect_outbound", |
| 5867 | label: "turn/interrupt/child", |
| 5868 | frame: { |
| 5869 | id: 5, |
| 5870 | method: "turn/interrupt", |
| 5871 | params: { |
| 5872 | threadId: INTERRUPT_CHILD_NATIVE_THREAD, |
| 5873 | turnId: INTERRUPT_CHILD_NATIVE_TURN, |
| 5874 | }, |
| 5875 | }, |
| 5876 | }, |
| 5877 | { |
| 5878 | type: "emit_inbound", |
| 5879 | label: "turn/interrupt/child", |
| 5880 | frame: { id: 5, result: {} }, |
| 5881 | }, |
| 5882 | { |
| 5883 | type: "expect_outbound", |
| 5884 | label: "thread/backgroundTerminals/terminate/child", |
| 5885 | frame: { |
| 5886 | id: 6, |
| 5887 | method: "thread/backgroundTerminals/terminate", |
| 5888 | params: { threadId: INTERRUPT_CHILD_NATIVE_THREAD, processId: "57682" }, |
| 5889 | }, |
| 5890 | }, |
| 5891 | { |
| 5892 | type: "emit_inbound", |
| 5893 | label: "thread/backgroundTerminals/terminate/child", |
| 5894 | frame: { id: 6, result: { terminated: true } }, |
| 5895 | }, |
| 5896 | { |
| 5897 | type: "emit_inbound", |
| 5898 | label: "turn/completed/root", |
| 5899 | frame: { |
| 5900 | method: "turn/completed", |
| 5901 | params: { |
| 5902 | threadId: INTERRUPT_NATIVE_THREAD, |
| 5903 | turn: makeCodexReplayTurn({ id: INTERRUPT_NATIVE_TURN, status: "interrupted" }), |
| 5904 | }, |
| 5905 | }, |
| 5906 | }, |
| 5907 | { |
| 5908 | type: "emit_inbound", |
| 5909 | label: "turn/completed/child-completed-race", |
| 5910 | frame: { |
| 5911 | method: "turn/completed", |
| 5912 | params: { |
| 5913 | threadId: INTERRUPT_CHILD_NATIVE_THREAD, |
| 5914 | turn: makeCodexReplayTurn({ |
| 5915 | id: INTERRUPT_CHILD_NATIVE_TURN, |
| 5916 | status: "completed", |
| 5917 | }), |
| 5918 | }, |
| 5919 | }, |
| 5920 | }, |
| 5921 | { type: "runtime_exit", status: "success" }, |
| 5922 | ], |
| 5923 | }); |
| 5924 | |
| 5925 | const assertChildProviderTerminalBeforeRoot = ( |
| 5926 | events: ReadonlyArray<ProviderAdapter.ProviderAdapterV2Event>, |
| 5927 | rootThreadId: ThreadId, |
| 5928 | ) => { |
| 5929 | const terminalIndex = events.findIndex((event) => event.type === "turn.terminal"); |
| 5930 | const childProviderTurnIndex = events.findIndex( |
| 5931 | (event) => |
| 5932 | event.type === "provider_turn.updated" && |
| 5933 | event.threadId !== rootThreadId && |
| 5934 | event.providerTurn.status === "interrupted", |
| 5935 | ); |
| 5936 | const childProviderThreadIndex = events.findIndex( |
| 5937 | (event) => |
| 5938 | event.type === "provider_thread.updated" && |
| 5939 | event.providerThread.appThreadId !== rootThreadId && |
| 5940 | event.providerThread.status === "idle", |
| 5941 | ); |
| 5942 | assert.isAtLeast(childProviderTurnIndex, 0, "child provider turn must terminalize"); |
| 5943 | assert.isAtLeast(childProviderThreadIndex, 0, "child provider thread must become idle"); |
| 5944 | assert.isAbove( |
| 5945 | terminalIndex, |
| 5946 | childProviderTurnIndex, |
| 5947 | "child provider turn must terminalize before the root run", |
| 5948 | ); |
| 5949 | assert.isAbove( |
| 5950 | terminalIndex, |
| 5951 | childProviderThreadIndex, |
| 5952 | "child provider thread must become idle before the root run", |
| 5953 | ); |
| 5954 | }; |
| 5955 | |
| 5956 | it.effect("contains descendant commands and keeps Stop authoritative", () => |
| 5957 | Effect.scoped( |
| 5958 | Effect.gen(function* () { |
| 5959 | const harness = yield* makeCodexReplayHarness(interruptSubagentCommandTranscript); |
| 5960 | const now = yield* DateTime.now; |
| 5961 | |
| 5962 | yield* harness.runtime.startTurn( |
| 5963 | makeCodexTestTurnInput({ |
| 5964 | threadId: harness.threadId, |
| 5965 | providerThread: harness.providerThread, |
| 5966 | now, |
| 5967 | attemptId: RunAttemptId.make("attempt-codex-interrupt-subagent-command"), |
| 5968 | text: INTERRUPT_PROMPT, |
| 5969 | }), |
| 5970 | ); |
| 5971 | yield* awaitUntil( |
| 5972 | () => |
| 5973 | harness.events.some( |
| 5974 | (event) => |
| 5975 | event.type === "turn_item.updated" && |
| 5976 | event.turnItem.type === "command_execution" && |
| 5977 | event.turnItem.nativeItemRef?.nativeId === INTERRUPT_CHILD_COMMAND_ITEM && |
| 5978 | event.turnItem.status === "running", |
| 5979 | ), |
| 5980 | "running child command item", |
| 5981 | ); |
| 5982 | const providerTurnId = harness.events.find( |
| 5983 | ( |
| 5984 | event, |
| 5985 | ): event is Extract< |
| 5986 | ProviderAdapter.ProviderAdapterV2Event, |
| 5987 | { type: "provider_turn.updated" } |
| 5988 | > => event.type === "provider_turn.updated" && event.threadId === harness.threadId, |
| 5989 | )?.providerTurn.id; |
| 5990 | assert.isDefined(providerTurnId); |
| 5991 | |
| 5992 | yield* harness.runtime.interruptTurn({ |
| 5993 | providerThread: harness.providerThread, |
| 5994 | providerTurnId, |
| 5995 | }); |
| 5996 | |
| 5997 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "interrupted root terminal"); |
| 5998 | assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); |
| 5999 | const childCommandUpdates = harness.events.filter( |
| 6000 | ( |
| 6001 | event, |
| 6002 | ): event is Extract< |
| 6003 | ProviderAdapter.ProviderAdapterV2Event, |
| 6004 | { type: "turn_item.updated" } |
| 6005 | > => |
| 6006 | event.type === "turn_item.updated" && |
| 6007 | event.turnItem.type === "command_execution" && |
| 6008 | event.turnItem.nativeItemRef?.nativeId === INTERRUPT_CHILD_COMMAND_ITEM, |
| 6009 | ); |
| 6010 | assert.equal(childCommandUpdates.at(-1)?.turnItem.status, "interrupted"); |
| 6011 | assert.equal(harness.subagentUpdates().at(-1)?.subagent.status, "interrupted"); |
| 6012 | assertChildProviderTerminalBeforeRoot(harness.events, harness.threadId); |
| 6013 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 6014 | }).pipe( |
| 6015 | Effect.provide( |
| 6016 | Layer.mergeAll( |
| 6017 | IdAllocator.layer, |
| 6018 | McpProviderSessions.layer, |
| 6019 | ScriptProgress.layerUnavailable, |
| 6020 | NodeServices.layer, |
| 6021 | ), |
| 6022 | ), |
| 6023 | ), |
| 6024 | ), |
| 6025 | ); |
| 6026 | |
| 6027 | const childInterruptResponseIndex = interruptSubagentCommandTranscript.entries.findIndex( |
| 6028 | (entry) => entry.type === "emit_inbound" && entry.label === "turn/interrupt/child", |
| 6029 | ); |
| 6030 | const rootInterruptResponseIndex = interruptSubagentCommandTranscript.entries.findIndex( |
| 6031 | (entry) => entry.type === "emit_inbound" && entry.label === "turn/interrupt/root", |
| 6032 | ); |
| 6033 | const interruptSubagentRequestFailureTranscript = makeCodexReplayTranscript({ |
| 6034 | scenario: "codex-interrupt-subagent-request-failure", |
| 6035 | entries: [ |
| 6036 | ...interruptSubagentCommandTranscript.entries.slice(0, rootInterruptResponseIndex + 1), |
| 6037 | { |
| 6038 | type: "emit_inbound", |
| 6039 | label: "turn/completed/root-before-child-interrupt-failure", |
| 6040 | frame: { |
| 6041 | method: "turn/completed", |
| 6042 | params: { |
| 6043 | threadId: INTERRUPT_NATIVE_THREAD, |
| 6044 | turn: makeCodexReplayTurn({ id: INTERRUPT_NATIVE_TURN, status: "interrupted" }), |
| 6045 | }, |
| 6046 | }, |
| 6047 | }, |
| 6048 | ...interruptSubagentCommandTranscript.entries.slice( |
| 6049 | rootInterruptResponseIndex + 1, |
| 6050 | childInterruptResponseIndex, |
| 6051 | ), |
| 6052 | { |
| 6053 | type: "emit_inbound", |
| 6054 | label: "turn/interrupt/child", |
| 6055 | frame: { |
| 6056 | id: 5, |
| 6057 | error: { code: -32_000, message: "child interrupt request failed" }, |
| 6058 | }, |
| 6059 | }, |
| 6060 | { type: "runtime_exit", status: "success" }, |
| 6061 | ], |
| 6062 | }); |
| 6063 | |
| 6064 | it.effect("terminalizes descendants before the root when an interrupt request fails", () => |
| 6065 | Effect.scoped( |
| 6066 | Effect.gen(function* () { |
| 6067 | const harness = yield* makeCodexReplayHarness(interruptSubagentRequestFailureTranscript); |
| 6068 | const now = yield* DateTime.now; |
| 6069 | |
| 6070 | yield* harness.runtime.startTurn( |
| 6071 | makeCodexTestTurnInput({ |
| 6072 | threadId: harness.threadId, |
| 6073 | providerThread: harness.providerThread, |
| 6074 | now, |
| 6075 | attemptId: RunAttemptId.make("attempt-codex-interrupt-subagent-request-failure"), |
| 6076 | text: INTERRUPT_PROMPT, |
| 6077 | }), |
| 6078 | ); |
| 6079 | yield* awaitUntil( |
| 6080 | () => |
| 6081 | harness.events.some( |
| 6082 | (event) => |
| 6083 | event.type === "turn_item.updated" && |
| 6084 | event.turnItem.type === "command_execution" && |
| 6085 | event.turnItem.nativeItemRef?.nativeId === INTERRUPT_CHILD_COMMAND_ITEM && |
| 6086 | event.turnItem.status === "running", |
| 6087 | ), |
| 6088 | "running child command item", |
| 6089 | ); |
| 6090 | const providerTurnId = harness.events.find( |
| 6091 | ( |
| 6092 | event, |
| 6093 | ): event is Extract< |
| 6094 | ProviderAdapter.ProviderAdapterV2Event, |
| 6095 | { type: "provider_turn.updated" } |
| 6096 | > => event.type === "provider_turn.updated" && event.threadId === harness.threadId, |
| 6097 | )?.providerTurn.id; |
| 6098 | assert.isDefined(providerTurnId); |
| 6099 | |
| 6100 | const interruptExit = yield* harness.runtime |
| 6101 | .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) |
| 6102 | .pipe(Effect.exit); |
| 6103 | |
| 6104 | assert.equal(interruptExit._tag, "Failure"); |
| 6105 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "interrupted root terminal"); |
| 6106 | assertChildProviderTerminalBeforeRoot(harness.events, harness.threadId); |
| 6107 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 6108 | }).pipe( |
| 6109 | Effect.provide( |
| 6110 | Layer.mergeAll( |
| 6111 | IdAllocator.layer, |
| 6112 | McpProviderSessions.layer, |
| 6113 | ScriptProgress.layerUnavailable, |
| 6114 | NodeServices.layer, |
| 6115 | ), |
| 6116 | ), |
| 6117 | ), |
| 6118 | ), |
| 6119 | ); |
| 6120 | |
| 6121 | const childTerminationResponseIndex = interruptSubagentCommandTranscript.entries.findIndex( |
| 6122 | (entry) => |
| 6123 | entry.type === "emit_inbound" && entry.label === "thread/backgroundTerminals/terminate/child", |
| 6124 | ); |
| 6125 | const interruptSubagentTimeoutTranscript = makeCodexReplayTranscript({ |
| 6126 | scenario: "codex-interrupt-subagent-timeout", |
| 6127 | entries: [ |
| 6128 | ...interruptSubagentCommandTranscript.entries.slice(0, childTerminationResponseIndex + 1), |
| 6129 | { |
| 6130 | type: "emit_inbound", |
| 6131 | label: "item/started/child-timeout-boundary", |
| 6132 | frame: { |
| 6133 | method: "item/started", |
| 6134 | params: { |
| 6135 | item: { |
| 6136 | ...interruptCommandItem("inProgress"), |
| 6137 | id: INTERRUPT_CHILD_TIMEOUT_BOUNDARY_ITEM, |
| 6138 | processId: null, |
| 6139 | }, |
| 6140 | threadId: INTERRUPT_CHILD_NATIVE_THREAD, |
| 6141 | turnId: INTERRUPT_CHILD_NATIVE_TURN, |
| 6142 | startedAtMs: 1782622441600, |
| 6143 | }, |
| 6144 | }, |
| 6145 | }, |
| 6146 | ], |
| 6147 | }); |
| 6148 | |
| 6149 | it.effect("terminalizes timed-out descendants before the root", () => |
| 6150 | Effect.scoped( |
| 6151 | Effect.gen(function* () { |
| 6152 | const harness = yield* makeCodexReplayHarness(interruptSubagentTimeoutTranscript); |
| 6153 | const now = yield* DateTime.now; |
| 6154 | |
| 6155 | yield* harness.runtime.startTurn( |
| 6156 | makeCodexTestTurnInput({ |
| 6157 | threadId: harness.threadId, |
| 6158 | providerThread: harness.providerThread, |
| 6159 | now, |
| 6160 | attemptId: RunAttemptId.make("attempt-codex-interrupt-subagent-timeout"), |
| 6161 | text: INTERRUPT_PROMPT, |
| 6162 | }), |
| 6163 | ); |
| 6164 | yield* awaitUntil( |
| 6165 | () => |
| 6166 | harness.events.some( |
| 6167 | (event) => |
| 6168 | event.type === "turn_item.updated" && |
| 6169 | event.turnItem.type === "command_execution" && |
| 6170 | event.turnItem.nativeItemRef?.nativeId === INTERRUPT_CHILD_COMMAND_ITEM && |
| 6171 | event.turnItem.status === "running", |
| 6172 | ), |
| 6173 | "running child command item", |
| 6174 | ); |
| 6175 | const providerTurnId = harness.events.find( |
| 6176 | ( |
| 6177 | event, |
| 6178 | ): event is Extract< |
| 6179 | ProviderAdapter.ProviderAdapterV2Event, |
| 6180 | { type: "provider_turn.updated" } |
| 6181 | > => event.type === "provider_turn.updated" && event.threadId === harness.threadId, |
| 6182 | )?.providerTurn.id; |
| 6183 | assert.isDefined(providerTurnId); |
| 6184 | |
| 6185 | const interruptFiber = yield* harness.runtime |
| 6186 | .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) |
| 6187 | .pipe(Effect.forkScoped); |
| 6188 | yield* awaitUntil( |
| 6189 | () => |
| 6190 | harness.events.some( |
| 6191 | (event) => |
| 6192 | event.type === "turn_item.updated" && |
| 6193 | event.turnItem.type === "command_execution" && |
| 6194 | event.turnItem.nativeItemRef?.nativeId === INTERRUPT_CHILD_TIMEOUT_BOUNDARY_ITEM && |
| 6195 | event.turnItem.status === "running", |
| 6196 | ), |
| 6197 | "child timeout boundary item", |
| 6198 | ); |
| 6199 | yield* TestClock.adjust("10 seconds"); |
| 6200 | yield* Fiber.join(interruptFiber); |
| 6201 | |
| 6202 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "interrupted root terminal"); |
| 6203 | assertChildProviderTerminalBeforeRoot(harness.events, harness.threadId); |
| 6204 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 6205 | }).pipe( |
| 6206 | Effect.provide( |
| 6207 | Layer.mergeAll( |
| 6208 | IdAllocator.layer, |
| 6209 | McpProviderSessions.layer, |
| 6210 | ScriptProgress.layerUnavailable, |
| 6211 | NodeServices.layer, |
| 6212 | ), |
| 6213 | ), |
| 6214 | ), |
| 6215 | ), |
| 6216 | ); |
| 6217 | |
| 6218 | const rootCompletionIndex = interruptSubagentCommandTranscript.entries.findIndex( |
| 6219 | (entry) => entry.type === "emit_inbound" && entry.label === "turn/completed/root", |
| 6220 | ); |
| 6221 | const interruptLateSubagentTurnTranscript = makeCodexReplayTranscript({ |
| 6222 | scenario: "codex-interrupt-late-subagent-turn", |
| 6223 | entries: [ |
| 6224 | ...interruptSubagentCommandTranscript.entries.slice(0, rootCompletionIndex), |
| 6225 | { |
| 6226 | type: "emit_inbound", |
| 6227 | label: "turn/started/late-child", |
| 6228 | frame: { |
| 6229 | method: "turn/started", |
| 6230 | params: { |
| 6231 | threadId: INTERRUPT_CHILD_NATIVE_THREAD, |
| 6232 | turn: makeCodexReplayTurn({ |
| 6233 | id: INTERRUPT_LATE_CHILD_NATIVE_TURN, |
| 6234 | status: "inProgress", |
| 6235 | }), |
| 6236 | }, |
| 6237 | }, |
| 6238 | }, |
| 6239 | ...interruptSubagentCommandTranscript.entries.slice(rootCompletionIndex, -1), |
| 6240 | { |
| 6241 | type: "expect_outbound", |
| 6242 | label: "turn/interrupt/late-child", |
| 6243 | frame: { |
| 6244 | id: 7, |
| 6245 | method: "turn/interrupt", |
| 6246 | params: { |
| 6247 | threadId: INTERRUPT_CHILD_NATIVE_THREAD, |
| 6248 | turnId: INTERRUPT_LATE_CHILD_NATIVE_TURN, |
| 6249 | }, |
| 6250 | }, |
| 6251 | }, |
| 6252 | { |
| 6253 | type: "emit_inbound", |
| 6254 | label: "turn/interrupt/late-child", |
| 6255 | frame: { id: 7, result: {} }, |
| 6256 | }, |
| 6257 | { type: "runtime_exit", status: "success" }, |
| 6258 | ], |
| 6259 | }); |
| 6260 | |
| 6261 | it.effect("interrupts descendants that start after the initial Stop snapshot", () => |
| 6262 | Effect.scoped( |
| 6263 | Effect.gen(function* () { |
| 6264 | const harness = yield* makeCodexReplayHarness(interruptLateSubagentTurnTranscript); |
| 6265 | const now = yield* DateTime.now; |
| 6266 | |
| 6267 | yield* harness.runtime.startTurn( |
| 6268 | makeCodexTestTurnInput({ |
| 6269 | threadId: harness.threadId, |
| 6270 | providerThread: harness.providerThread, |
| 6271 | now, |
| 6272 | attemptId: RunAttemptId.make("attempt-codex-interrupt-late-subagent-turn"), |
| 6273 | text: INTERRUPT_PROMPT, |
| 6274 | }), |
| 6275 | ); |
| 6276 | yield* awaitUntil( |
| 6277 | () => |
| 6278 | harness.events.some( |
| 6279 | (event) => |
| 6280 | event.type === "turn_item.updated" && |
| 6281 | event.turnItem.type === "command_execution" && |
| 6282 | event.turnItem.nativeItemRef?.nativeId === INTERRUPT_CHILD_COMMAND_ITEM && |
| 6283 | event.turnItem.status === "running", |
| 6284 | ), |
| 6285 | "running child command item", |
| 6286 | ); |
| 6287 | const providerTurnId = harness.events.find( |
| 6288 | ( |
| 6289 | event, |
| 6290 | ): event is Extract< |
| 6291 | ProviderAdapter.ProviderAdapterV2Event, |
| 6292 | { type: "provider_turn.updated" } |
| 6293 | > => event.type === "provider_turn.updated" && event.threadId === harness.threadId, |
| 6294 | )?.providerTurn.id; |
| 6295 | assert.isDefined(providerTurnId); |
| 6296 | |
| 6297 | const interruptFiber = yield* harness.runtime |
| 6298 | .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) |
| 6299 | .pipe(Effect.forkScoped); |
| 6300 | yield* awaitUntil( |
| 6301 | () => |
| 6302 | harness.events.some( |
| 6303 | (event) => |
| 6304 | event.type === "provider_turn.updated" && |
| 6305 | event.providerTurn.nativeTurnRef?.nativeId === INTERRUPT_LATE_CHILD_NATIVE_TURN, |
| 6306 | ), |
| 6307 | "late child provider turn", |
| 6308 | ); |
| 6309 | yield* Fiber.join(interruptFiber); |
| 6310 | |
| 6311 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "interrupted root terminal"); |
| 6312 | const lateChildUpdates = harness.events.filter( |
| 6313 | ( |
| 6314 | event, |
| 6315 | ): event is Extract< |
| 6316 | ProviderAdapter.ProviderAdapterV2Event, |
| 6317 | { type: "provider_turn.updated" } |
| 6318 | > => |
| 6319 | event.type === "provider_turn.updated" && |
| 6320 | event.providerTurn.nativeTurnRef?.nativeId === INTERRUPT_LATE_CHILD_NATIVE_TURN, |
| 6321 | ); |
| 6322 | assert.equal(lateChildUpdates.at(-1)?.providerTurn.status, "interrupted"); |
| 6323 | assertChildProviderTerminalBeforeRoot(harness.events, harness.threadId); |
| 6324 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 6325 | }).pipe( |
| 6326 | Effect.provide( |
| 6327 | Layer.mergeAll( |
| 6328 | IdAllocator.layer, |
| 6329 | McpProviderSessions.layer, |
| 6330 | ScriptProgress.layerUnavailable, |
| 6331 | NodeServices.layer, |
| 6332 | ), |
| 6333 | ), |
| 6334 | ), |
| 6335 | ), |
| 6336 | ); |
| 6337 | |
| 6338 | const interruptRescanLateSubagentTurnTranscript = makeCodexReplayTranscript({ |
| 6339 | scenario: "codex-interrupt-rescan-late-subagent-turn", |
| 6340 | entries: [ |
| 6341 | ...interruptSubagentCommandTranscript.entries.slice(0, childTerminationResponseIndex + 1), |
| 6342 | { |
| 6343 | type: "emit_inbound", |
| 6344 | label: "turn/started/late-child-1", |
| 6345 | frame: { |
| 6346 | method: "turn/started", |
| 6347 | params: { |
| 6348 | threadId: INTERRUPT_CHILD_NATIVE_THREAD, |
| 6349 | turn: makeCodexReplayTurn({ |
| 6350 | id: INTERRUPT_LATE_CHILD_NATIVE_TURN, |
| 6351 | status: "inProgress", |
| 6352 | }), |
| 6353 | }, |
| 6354 | }, |
| 6355 | }, |
| 6356 | { |
| 6357 | type: "expect_outbound", |
| 6358 | label: "turn/interrupt/late-child-1", |
| 6359 | frame: { |
| 6360 | id: 7, |
| 6361 | method: "turn/interrupt", |
| 6362 | params: { |
| 6363 | threadId: INTERRUPT_CHILD_NATIVE_THREAD, |
| 6364 | turnId: INTERRUPT_LATE_CHILD_NATIVE_TURN, |
| 6365 | }, |
| 6366 | }, |
| 6367 | }, |
| 6368 | { |
| 6369 | type: "emit_inbound", |
| 6370 | label: "turn/started/late-child-2", |
| 6371 | frame: { |
| 6372 | method: "turn/started", |
| 6373 | params: { |
| 6374 | threadId: INTERRUPT_CHILD_NATIVE_THREAD, |
| 6375 | turn: makeCodexReplayTurn({ |
| 6376 | id: INTERRUPT_LATE_CHILD_2_NATIVE_TURN, |
| 6377 | status: "inProgress", |
| 6378 | }), |
| 6379 | }, |
| 6380 | }, |
| 6381 | }, |
| 6382 | { |
| 6383 | type: "emit_inbound", |
| 6384 | label: "turn/interrupt/late-child-1", |
| 6385 | frame: { id: 7, result: {} }, |
| 6386 | }, |
| 6387 | { |
| 6388 | type: "expect_outbound", |
| 6389 | label: "turn/interrupt/late-child-2", |
| 6390 | frame: { |
| 6391 | id: 8, |
| 6392 | method: "turn/interrupt", |
| 6393 | params: { |
| 6394 | threadId: INTERRUPT_CHILD_NATIVE_THREAD, |
| 6395 | turnId: INTERRUPT_LATE_CHILD_2_NATIVE_TURN, |
| 6396 | }, |
| 6397 | }, |
| 6398 | }, |
| 6399 | { |
| 6400 | type: "emit_inbound", |
| 6401 | label: "turn/interrupt/late-child-2", |
| 6402 | frame: { id: 8, result: {} }, |
| 6403 | }, |
| 6404 | { type: "runtime_exit", status: "success" }, |
| 6405 | ], |
| 6406 | }); |
| 6407 | |
| 6408 | it.effect("interrupts descendants discovered only by the final interrupt rescan", () => |
| 6409 | Effect.scoped( |
| 6410 | Effect.gen(function* () { |
| 6411 | const harness = yield* makeCodexReplayHarness(interruptRescanLateSubagentTurnTranscript); |
| 6412 | const now = yield* DateTime.now; |
| 6413 | |
| 6414 | yield* harness.runtime.startTurn( |
| 6415 | makeCodexTestTurnInput({ |
| 6416 | threadId: harness.threadId, |
| 6417 | providerThread: harness.providerThread, |
| 6418 | now, |
| 6419 | attemptId: RunAttemptId.make("attempt-codex-interrupt-rescan-late-subagent-turn"), |
| 6420 | text: INTERRUPT_PROMPT, |
| 6421 | }), |
| 6422 | ); |
| 6423 | yield* awaitUntil( |
| 6424 | () => |
| 6425 | harness.events.some( |
| 6426 | (event) => |
| 6427 | event.type === "turn_item.updated" && |
| 6428 | event.turnItem.type === "command_execution" && |
| 6429 | event.turnItem.nativeItemRef?.nativeId === INTERRUPT_CHILD_COMMAND_ITEM && |
| 6430 | event.turnItem.status === "running", |
| 6431 | ), |
| 6432 | "running child command item", |
| 6433 | ); |
| 6434 | const providerTurnId = harness.events.find( |
| 6435 | ( |
| 6436 | event, |
| 6437 | ): event is Extract< |
| 6438 | ProviderAdapter.ProviderAdapterV2Event, |
| 6439 | { type: "provider_turn.updated" } |
| 6440 | > => event.type === "provider_turn.updated" && event.threadId === harness.threadId, |
| 6441 | )?.providerTurn.id; |
| 6442 | assert.isDefined(providerTurnId); |
| 6443 | |
| 6444 | const interruptFiber = yield* harness.runtime |
| 6445 | .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) |
| 6446 | .pipe(Effect.forkScoped); |
| 6447 | yield* awaitUntil( |
| 6448 | () => |
| 6449 | harness.events.some( |
| 6450 | (event) => |
| 6451 | event.type === "provider_turn.updated" && |
| 6452 | event.providerTurn.nativeTurnRef?.nativeId === INTERRUPT_LATE_CHILD_NATIVE_TURN, |
| 6453 | ), |
| 6454 | "late child 1 provider turn", |
| 6455 | ); |
| 6456 | yield* TestClock.adjust("10 seconds"); |
| 6457 | yield* Fiber.join(interruptFiber); |
| 6458 | |
| 6459 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "interrupted root terminal"); |
| 6460 | assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); |
| 6461 | |
| 6462 | const rootTerminalIndex = harness.events.findIndex( |
| 6463 | (event) => event.type === "turn.terminal", |
| 6464 | ); |
| 6465 | const lateChild1InterruptedIndex = harness.events.findIndex( |
| 6466 | (event) => |
| 6467 | event.type === "provider_turn.updated" && |
| 6468 | event.providerTurn.nativeTurnRef?.nativeId === INTERRUPT_LATE_CHILD_NATIVE_TURN && |
| 6469 | event.providerTurn.status === "interrupted", |
| 6470 | ); |
| 6471 | const lateChild2InterruptedIndex = harness.events.findIndex( |
| 6472 | (event) => |
| 6473 | event.type === "provider_turn.updated" && |
| 6474 | event.providerTurn.nativeTurnRef?.nativeId === INTERRUPT_LATE_CHILD_2_NATIVE_TURN && |
| 6475 | event.providerTurn.status === "interrupted", |
| 6476 | ); |
| 6477 | assert.isAtLeast( |
| 6478 | lateChild1InterruptedIndex, |
| 6479 | 0, |
| 6480 | "late child 1 must terminalize interrupted", |
| 6481 | ); |
| 6482 | assert.isAtLeast( |
| 6483 | lateChild2InterruptedIndex, |
| 6484 | 0, |
| 6485 | "late child 2 must terminalize interrupted", |
| 6486 | ); |
| 6487 | assert.isAbove( |
| 6488 | rootTerminalIndex, |
| 6489 | lateChild1InterruptedIndex, |
| 6490 | "late child 1 must terminalize before the root run", |
| 6491 | ); |
| 6492 | assert.isAbove( |
| 6493 | rootTerminalIndex, |
| 6494 | lateChild2InterruptedIndex, |
| 6495 | "late child 2 must terminalize before the root run", |
| 6496 | ); |
| 6497 | assertChildProviderTerminalBeforeRoot(harness.events, harness.threadId); |
| 6498 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 6499 | }).pipe( |
| 6500 | Effect.provide( |
| 6501 | Layer.mergeAll( |
| 6502 | IdAllocator.layer, |
| 6503 | McpProviderSessions.layer, |
| 6504 | ScriptProgress.layerUnavailable, |
| 6505 | NodeServices.layer, |
| 6506 | ), |
| 6507 | ), |
| 6508 | ), |
| 6509 | ), |
| 6510 | ); |
| 6511 | |
| 6512 | const interruptTimeoutTranscript = makeCodexReplayTranscript({ |
| 6513 | scenario: "codex-interrupt-timeout", |
| 6514 | entries: [ |
| 6515 | ...codexReplayPreamble({ |
| 6516 | nativeThreadId: INTERRUPT_NATIVE_THREAD, |
| 6517 | nativeTurnId: INTERRUPT_NATIVE_TURN, |
| 6518 | prompt: INTERRUPT_PROMPT, |
| 6519 | }), |
| 6520 | { |
| 6521 | type: "emit_inbound", |
| 6522 | label: "item/started/command", |
| 6523 | frame: { |
| 6524 | method: "item/started", |
| 6525 | params: { |
| 6526 | item: interruptCommandItem("inProgress"), |
| 6527 | threadId: INTERRUPT_NATIVE_THREAD, |
| 6528 | turnId: INTERRUPT_NATIVE_TURN, |
| 6529 | startedAtMs: 1782622440500, |
| 6530 | }, |
| 6531 | }, |
| 6532 | }, |
| 6533 | { |
| 6534 | type: "expect_outbound", |
| 6535 | label: "turn/interrupt", |
| 6536 | frame: { |
| 6537 | id: 4, |
| 6538 | method: "turn/interrupt", |
| 6539 | params: { |
| 6540 | threadId: INTERRUPT_NATIVE_THREAD, |
| 6541 | turnId: INTERRUPT_NATIVE_TURN, |
| 6542 | }, |
| 6543 | }, |
| 6544 | }, |
| 6545 | { |
| 6546 | type: "emit_inbound", |
| 6547 | label: "turn/interrupt", |
| 6548 | frame: { id: 4, result: {} }, |
| 6549 | }, |
| 6550 | { |
| 6551 | type: "emit_inbound", |
| 6552 | label: "item/started/command-two-after-interrupt-response", |
| 6553 | frame: { |
| 6554 | method: "item/started", |
| 6555 | params: { |
| 6556 | item: { |
| 6557 | ...interruptCommandItem("inProgress"), |
| 6558 | id: INTERRUPT_COMMAND_ITEM_TWO, |
| 6559 | command: INTERRUPT_COMMAND_TWO, |
| 6560 | processId: "57681", |
| 6561 | commandActions: [{ type: "unknown", command: INTERRUPT_COMMAND_TWO }], |
| 6562 | }, |
| 6563 | threadId: INTERRUPT_NATIVE_THREAD, |
| 6564 | turnId: INTERRUPT_NATIVE_TURN, |
| 6565 | startedAtMs: 1782622440600, |
| 6566 | }, |
| 6567 | }, |
| 6568 | }, |
| 6569 | { |
| 6570 | type: "expect_outbound", |
| 6571 | label: "thread/backgroundTerminals/terminate/one", |
| 6572 | frame: { |
| 6573 | id: 5, |
| 6574 | method: "thread/backgroundTerminals/terminate", |
| 6575 | params: { threadId: INTERRUPT_NATIVE_THREAD, processId: "57680" }, |
| 6576 | }, |
| 6577 | }, |
| 6578 | { |
| 6579 | type: "emit_inbound", |
| 6580 | label: "thread/backgroundTerminals/terminate/one", |
| 6581 | frame: { id: 5, result: { terminated: true } }, |
| 6582 | }, |
| 6583 | { |
| 6584 | type: "emit_inbound", |
| 6585 | label: "item/started/command-at-timeout-boundary", |
| 6586 | afterMs: 9_999, |
| 6587 | frame: { |
| 6588 | method: "item/started", |
| 6589 | params: { |
| 6590 | item: { |
| 6591 | ...interruptCommandItem("inProgress"), |
| 6592 | id: INTERRUPT_TIMEOUT_BOUNDARY_ITEM, |
| 6593 | command: "echo TIMEOUT_BOUNDARY", |
| 6594 | processId: null, |
| 6595 | commandActions: [{ type: "unknown", command: "echo TIMEOUT_BOUNDARY" }], |
| 6596 | }, |
| 6597 | threadId: INTERRUPT_NATIVE_THREAD, |
| 6598 | turnId: INTERRUPT_NATIVE_TURN, |
| 6599 | startedAtMs: 1782622450500, |
| 6600 | }, |
| 6601 | }, |
| 6602 | }, |
| 6603 | { |
| 6604 | type: "expect_outbound", |
| 6605 | label: "thread/backgroundTerminals/terminate/two", |
| 6606 | frame: { |
| 6607 | id: 6, |
| 6608 | method: "thread/backgroundTerminals/terminate", |
| 6609 | params: { threadId: INTERRUPT_NATIVE_THREAD, processId: "57681" }, |
| 6610 | }, |
| 6611 | }, |
| 6612 | { |
| 6613 | type: "emit_inbound", |
| 6614 | label: "thread/backgroundTerminals/terminate/two", |
| 6615 | frame: { id: 6, result: { terminated: true } }, |
| 6616 | }, |
| 6617 | { |
| 6618 | type: "emit_inbound", |
| 6619 | label: "turn/completed/late", |
| 6620 | afterMs: 20_000, |
| 6621 | frame: { |
| 6622 | method: "turn/completed", |
| 6623 | params: { |
| 6624 | threadId: INTERRUPT_NATIVE_THREAD, |
| 6625 | turn: makeCodexReplayTurn({ |
| 6626 | id: INTERRUPT_NATIVE_TURN, |
| 6627 | status: "interrupted", |
| 6628 | }), |
| 6629 | }, |
| 6630 | }, |
| 6631 | }, |
| 6632 | { |
| 6633 | type: "emit_inbound", |
| 6634 | label: "item/started/command-after-timeout", |
| 6635 | frame: { |
| 6636 | method: "item/started", |
| 6637 | params: { |
| 6638 | item: { |
| 6639 | ...interruptCommandItem("inProgress"), |
| 6640 | id: INTERRUPT_TIMEOUT_LATE_ITEM, |
| 6641 | command: "echo LATE_AFTER_TIMEOUT", |
| 6642 | processId: null, |
| 6643 | commandActions: [{ type: "unknown", command: "echo LATE_AFTER_TIMEOUT" }], |
| 6644 | }, |
| 6645 | threadId: INTERRUPT_NATIVE_THREAD, |
| 6646 | turnId: INTERRUPT_NATIVE_TURN, |
| 6647 | startedAtMs: 1782622470500, |
| 6648 | }, |
| 6649 | }, |
| 6650 | }, |
| 6651 | { |
| 6652 | type: "emit_inbound", |
| 6653 | label: "item/completed/command-late", |
| 6654 | frame: { |
| 6655 | method: "item/completed", |
| 6656 | params: { |
| 6657 | item: interruptCommandItem("completed"), |
| 6658 | threadId: INTERRUPT_NATIVE_THREAD, |
| 6659 | turnId: INTERRUPT_NATIVE_TURN, |
| 6660 | completedAtMs: 1782622465500, |
| 6661 | }, |
| 6662 | }, |
| 6663 | }, |
| 6664 | { type: "runtime_exit", status: "success" }, |
| 6665 | ], |
| 6666 | }); |
| 6667 | |
| 6668 | it.effect("bounds interrupt settlement and drops late completion events", () => |
| 6669 | Effect.scoped( |
| 6670 | Effect.gen(function* () { |
| 6671 | const harness = yield* makeCodexReplayHarness(interruptTimeoutTranscript); |
| 6672 | const now = yield* DateTime.now; |
| 6673 | |
| 6674 | yield* harness.runtime.startTurn( |
| 6675 | makeCodexTestTurnInput({ |
| 6676 | threadId: harness.threadId, |
| 6677 | providerThread: harness.providerThread, |
| 6678 | now, |
| 6679 | attemptId: RunAttemptId.make("attempt-codex-interrupt-timeout"), |
| 6680 | text: INTERRUPT_PROMPT, |
| 6681 | }), |
| 6682 | ); |
| 6683 | yield* awaitUntil( |
| 6684 | () => |
| 6685 | harness.events.filter( |
| 6686 | (event) => |
| 6687 | event.type === "turn_item.updated" && |
| 6688 | event.turnItem.type === "command_execution" && |
| 6689 | event.turnItem.status === "running", |
| 6690 | ).length === 1, |
| 6691 | "running command item", |
| 6692 | ); |
| 6693 | const providerTurnId = harness.events.find( |
| 6694 | ( |
| 6695 | event, |
| 6696 | ): event is Extract< |
| 6697 | ProviderAdapter.ProviderAdapterV2Event, |
| 6698 | { type: "provider_turn.updated" } |
| 6699 | > => event.type === "provider_turn.updated", |
| 6700 | )?.providerTurn.id; |
| 6701 | assert.isDefined(providerTurnId); |
| 6702 | |
| 6703 | const interruptFiber = yield* harness.runtime |
| 6704 | .interruptTurn({ |
| 6705 | providerThread: harness.providerThread, |
| 6706 | providerTurnId, |
| 6707 | }) |
| 6708 | .pipe(Effect.forkScoped); |
| 6709 | yield* awaitUntil( |
| 6710 | () => |
| 6711 | harness.events.filter( |
| 6712 | (event) => |
| 6713 | event.type === "turn_item.updated" && |
| 6714 | event.turnItem.type === "command_execution" && |
| 6715 | event.turnItem.status === "running", |
| 6716 | ).length === 2, |
| 6717 | "post-interrupt running command item", |
| 6718 | ); |
| 6719 | |
| 6720 | yield* TestClock.adjust("10 seconds"); |
| 6721 | yield* Fiber.join(interruptFiber); |
| 6722 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "timeout terminal"); |
| 6723 | assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); |
| 6724 | const terminalProviderTurnsBeforeLateEvents = harness.events.filter( |
| 6725 | (event) => |
| 6726 | event.type === "provider_turn.updated" && event.providerTurn.status === "interrupted", |
| 6727 | ); |
| 6728 | assert.lengthOf(terminalProviderTurnsBeforeLateEvents, 1); |
| 6729 | |
| 6730 | const commandUpdatesBeforeLateEvents = harness.events.filter( |
| 6731 | ( |
| 6732 | event, |
| 6733 | ): event is Extract< |
| 6734 | ProviderAdapter.ProviderAdapterV2Event, |
| 6735 | { type: "turn_item.updated" } |
| 6736 | > => event.type === "turn_item.updated" && event.turnItem.type === "command_execution", |
| 6737 | ); |
| 6738 | const terminalCommands = commandUpdatesBeforeLateEvents.filter( |
| 6739 | (event) => |
| 6740 | event.turnItem.status === "interrupted" && |
| 6741 | event.turnItem.nativeItemRef?.nativeId !== INTERRUPT_TIMEOUT_BOUNDARY_ITEM, |
| 6742 | ); |
| 6743 | assert.lengthOf(terminalCommands, 2); |
| 6744 | const boundaryUpdates = commandUpdatesBeforeLateEvents.filter( |
| 6745 | (event) => event.turnItem.nativeItemRef?.nativeId === INTERRUPT_TIMEOUT_BOUNDARY_ITEM, |
| 6746 | ); |
| 6747 | const lastBoundaryUpdate = boundaryUpdates.at(-1); |
| 6748 | assert.isDefined(lastBoundaryUpdate); |
| 6749 | assert.equal(lastBoundaryUpdate.turnItem.status, "interrupted"); |
| 6750 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 6751 | |
| 6752 | yield* TestClock.adjust("20 seconds"); |
| 6753 | for (let attempt = 0; attempt < 100; attempt++) { |
| 6754 | yield* Effect.yieldNow; |
| 6755 | } |
| 6756 | assert.lengthOf(harness.terminalEvents(), 1); |
| 6757 | assert.lengthOf( |
| 6758 | harness.events.filter( |
| 6759 | (event) => |
| 6760 | event.type === "provider_turn.updated" && event.providerTurn.status === "interrupted", |
| 6761 | ), |
| 6762 | terminalProviderTurnsBeforeLateEvents.length, |
| 6763 | "late completion must not duplicate provider-turn finalization", |
| 6764 | ); |
| 6765 | assert.lengthOf( |
| 6766 | harness.events.filter( |
| 6767 | (event) => |
| 6768 | event.type === "turn_item.updated" && event.turnItem.type === "command_execution", |
| 6769 | ), |
| 6770 | commandUpdatesBeforeLateEvents.length, |
| 6771 | "late starts and completions must not project after timeout", |
| 6772 | ); |
| 6773 | }).pipe( |
| 6774 | Effect.provide( |
| 6775 | Layer.mergeAll( |
| 6776 | IdAllocator.layer, |
| 6777 | McpProviderSessions.layer, |
| 6778 | ScriptProgress.layerUnavailable, |
| 6779 | NodeServices.layer, |
| 6780 | ), |
| 6781 | ), |
| 6782 | ), |
| 6783 | ), |
| 6784 | ); |
| 6785 | |
| 6786 | const interruptTerminationFailureTranscript = makeCodexReplayTranscript({ |
| 6787 | scenario: "codex-interrupt-termination-failure", |
| 6788 | entries: [ |
| 6789 | ...interruptMidCommandTranscript.entries |
| 6790 | .filter( |
| 6791 | (entry) => entry.type === "runtime_exit" || entry.label !== "item/completed/command-late", |
| 6792 | ) |
| 6793 | .map((entry) => |
| 6794 | entry.type === "emit_inbound" && |
| 6795 | entry.label === "thread/backgroundTerminals/list/after-false" |
| 6796 | ? { |
| 6797 | ...entry, |
| 6798 | frame: { |
| 6799 | id: 6, |
| 6800 | result: { |
| 6801 | data: [{ processId: "57680" }], |
| 6802 | nextCursor: null, |
| 6803 | }, |
| 6804 | }, |
| 6805 | } |
| 6806 | : entry, |
| 6807 | ), |
| 6808 | { |
| 6809 | type: "expect_outbound", |
| 6810 | label: "thread/backgroundTerminals/terminate/one-retry", |
| 6811 | frame: { |
| 6812 | id: 8, |
| 6813 | method: "thread/backgroundTerminals/terminate", |
| 6814 | params: { threadId: INTERRUPT_NATIVE_THREAD, processId: "57680" }, |
| 6815 | }, |
| 6816 | }, |
| 6817 | { |
| 6818 | type: "emit_inbound", |
| 6819 | label: "thread/backgroundTerminals/terminate/one-retry", |
| 6820 | frame: { id: 8, result: { terminated: false } }, |
| 6821 | }, |
| 6822 | { |
| 6823 | type: "expect_outbound", |
| 6824 | label: "thread/backgroundTerminals/list/after-false-retry", |
| 6825 | frame: { |
| 6826 | id: 9, |
| 6827 | method: "thread/backgroundTerminals/list", |
| 6828 | params: { threadId: INTERRUPT_NATIVE_THREAD }, |
| 6829 | }, |
| 6830 | }, |
| 6831 | { |
| 6832 | type: "emit_inbound", |
| 6833 | label: "thread/backgroundTerminals/list/after-false-retry", |
| 6834 | frame: { |
| 6835 | id: 9, |
| 6836 | result: { data: [{ processId: "57680" }], nextCursor: null }, |
| 6837 | }, |
| 6838 | }, |
| 6839 | { type: "runtime_exit", status: "success" }, |
| 6840 | ], |
| 6841 | }); |
| 6842 | |
| 6843 | it.effect("attempts every terminal and cleans up tracking when termination fails", () => |
| 6844 | Effect.scoped( |
| 6845 | Effect.gen(function* () { |
| 6846 | const harness = yield* makeCodexReplayHarness(interruptTerminationFailureTranscript); |
| 6847 | const now = yield* DateTime.now; |
| 6848 | |
| 6849 | yield* harness.runtime.startTurn( |
| 6850 | makeCodexTestTurnInput({ |
| 6851 | threadId: harness.threadId, |
| 6852 | providerThread: harness.providerThread, |
| 6853 | now, |
| 6854 | attemptId: RunAttemptId.make("attempt-codex-interrupt-termination-failure"), |
| 6855 | text: INTERRUPT_PROMPT, |
| 6856 | }), |
| 6857 | ); |
| 6858 | yield* awaitUntil( |
| 6859 | () => |
| 6860 | harness.events.some( |
| 6861 | (event) => |
| 6862 | event.type === "turn_item.updated" && |
| 6863 | event.turnItem.type === "command_execution" && |
| 6864 | event.turnItem.status === "running", |
| 6865 | ), |
| 6866 | "running command item", |
| 6867 | ); |
| 6868 | const providerTurnId = harness.events.find( |
| 6869 | ( |
| 6870 | event, |
| 6871 | ): event is Extract< |
| 6872 | ProviderAdapter.ProviderAdapterV2Event, |
| 6873 | { type: "provider_turn.updated" } |
| 6874 | > => event.type === "provider_turn.updated", |
| 6875 | )?.providerTurn.id; |
| 6876 | assert.isDefined(providerTurnId); |
| 6877 | |
| 6878 | const interruptExit = yield* harness.runtime |
| 6879 | .interruptTurn({ |
| 6880 | providerThread: harness.providerThread, |
| 6881 | providerTurnId, |
| 6882 | }) |
| 6883 | .pipe(Effect.exit); |
| 6884 | |
| 6885 | assert.equal(interruptExit._tag, "Failure"); |
| 6886 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "interrupted terminal"); |
| 6887 | assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); |
| 6888 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 6889 | }).pipe( |
| 6890 | Effect.provide( |
| 6891 | Layer.mergeAll( |
| 6892 | IdAllocator.layer, |
| 6893 | McpProviderSessions.layer, |
| 6894 | ScriptProgress.layerUnavailable, |
| 6895 | NodeServices.layer, |
| 6896 | ), |
| 6897 | ), |
| 6898 | ), |
| 6899 | ), |
| 6900 | ); |
| 6901 | |
| 6902 | it.effect.each([ |
| 6903 | { |
| 6904 | name: "usage", |
| 6905 | code: "usageLimitExceeded", |
| 6906 | notification: false, |
| 6907 | expectedClass: "usage_limit", |
| 6908 | }, |
| 6909 | { name: "rate", code: "rateLimitExceeded", notification: false, expectedClass: "usage_limit" }, |
| 6910 | { |
| 6911 | name: "ordinary", |
| 6912 | code: "contextWindowExceeded", |
| 6913 | notification: false, |
| 6914 | expectedClass: "provider_error", |
| 6915 | }, |
| 6916 | { |
| 6917 | name: "notification", |
| 6918 | code: "usageLimitExceeded", |
| 6919 | notification: true, |
| 6920 | expectedClass: "usage_limit", |
| 6921 | }, |
| 6922 | { |
| 6923 | name: "replacement", |
| 6924 | code: "usageLimitExceeded", |
| 6925 | notification: true, |
| 6926 | expectedClass: "provider_error", |
| 6927 | }, |
| 6928 | { |
| 6929 | name: "known-reset", |
| 6930 | code: "usageLimitExceeded", |
| 6931 | notification: false, |
| 6932 | expectedClass: "usage_limit", |
| 6933 | }, |
| 6934 | { |
| 6935 | name: "late-reset", |
| 6936 | code: "usageLimitExceeded", |
| 6937 | notification: false, |
| 6938 | expectedClass: "usage_limit", |
| 6939 | }, |
| 6940 | { |
| 6941 | name: "matching-details", |
| 6942 | code: "usageLimitExceeded", |
| 6943 | notification: true, |
| 6944 | expectedClass: "usage_limit", |
| 6945 | }, |
| 6946 | { |
| 6947 | name: "deferred-reset", |
| 6948 | code: "usageLimitExceeded", |
| 6949 | notification: false, |
| 6950 | expectedClass: "usage_limit", |
| 6951 | }, |
| 6952 | { name: "retry", code: "usageLimitExceeded", notification: true, expectedClass: "usage_limit" }, |
| 6953 | ] as const)("classifies Codex terminal failures from $name evidence", (scenario) => |
| 6954 | Effect.scoped( |
| 6955 | Effect.gen(function* () { |
| 6956 | const nativeThreadId = `native-limit-${scenario.name}`; |
| 6957 | const nativeTurnId = `turn-limit-${scenario.name}`; |
| 6958 | const message = "Provider stopped this request."; |
| 6959 | const resetAt = "2033-05-19T07:20:00.000Z"; |
| 6960 | const snapshot = { |
| 6961 | type: "emit_inbound" as const, |
| 6962 | label: "account/rateLimits/updated", |
| 6963 | frame: { |
| 6964 | method: "account/rateLimits/updated", |
| 6965 | params: { |
| 6966 | rateLimits: { |
| 6967 | limitId: "codex", |
| 6968 | primary: { usedPercent: 100, resetsAt: 2000100000, windowDurationMins: 300 }, |
| 6969 | }, |
| 6970 | }, |
| 6971 | }, |
| 6972 | }; |
| 6973 | const transcript = makeCodexReplayTranscript({ |
| 6974 | scenario: `codex-limit-${scenario.name}`, |
| 6975 | entries: [ |
| 6976 | ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }), |
| 6977 | ...(scenario.name === "known-reset" || scenario.name === "deferred-reset" |
| 6978 | ? [snapshot] |
| 6979 | : []), |
| 6980 | ...(scenario.name === "deferred-reset" |
| 6981 | ? [ |
| 6982 | { |
| 6983 | type: "emit_inbound" as const, |
| 6984 | label: "item/completed/subAgentActivity-started", |
| 6985 | frame: { |
| 6986 | method: "item/completed", |
| 6987 | params: { |
| 6988 | threadId: nativeThreadId, |
| 6989 | turnId: nativeTurnId, |
| 6990 | item: { |
| 6991 | type: "subAgentActivity", |
| 6992 | id: "limit-child-spawn", |
| 6993 | kind: "started", |
| 6994 | agentThreadId: "native-limit-child", |
| 6995 | agentPath: "/root/limit_child", |
| 6996 | }, |
| 6997 | }, |
| 6998 | }, |
| 6999 | }, |
| 7000 | { |
| 7001 | type: "emit_inbound" as const, |
| 7002 | label: "turn/started/child", |
| 7003 | frame: { |
| 7004 | method: "turn/started", |
| 7005 | params: { |
| 7006 | threadId: "native-limit-child", |
| 7007 | turn: makeCodexReplayTurn({ |
| 7008 | id: "limit-child-turn", |
| 7009 | status: "inProgress", |
| 7010 | }), |
| 7011 | }, |
| 7012 | }, |
| 7013 | }, |
| 7014 | ] |
| 7015 | : []), |
| 7016 | ...(scenario.notification |
| 7017 | ? [ |
| 7018 | { |
| 7019 | type: "emit_inbound" as const, |
| 7020 | label: "error", |
| 7021 | frame: { |
| 7022 | method: "error", |
| 7023 | params: { |
| 7024 | threadId: nativeThreadId, |
| 7025 | turnId: nativeTurnId, |
| 7026 | willRetry: scenario.name === "retry", |
| 7027 | error: { |
| 7028 | message, |
| 7029 | codexErrorInfo: scenario.code, |
| 7030 | additionalDetails: |
| 7031 | scenario.name === "matching-details" |
| 7032 | ? "Detailed provider allowance explanation." |
| 7033 | : null, |
| 7034 | }, |
| 7035 | }, |
| 7036 | }, |
| 7037 | }, |
| 7038 | ] |
| 7039 | : []), |
| 7040 | { |
| 7041 | type: "emit_inbound", |
| 7042 | label: "turn/completed", |
| 7043 | frame: { |
| 7044 | method: "turn/completed", |
| 7045 | params: { |
| 7046 | threadId: nativeThreadId, |
| 7047 | turn: { |
| 7048 | ...makeCodexReplayTurn({ id: nativeTurnId, status: "failed" }), |
| 7049 | error: { |
| 7050 | message: scenario.name === "replacement" ? "A different failure." : message, |
| 7051 | ...(scenario.notification && scenario.name !== "matching-details" |
| 7052 | ? {} |
| 7053 | : { codexErrorInfo: scenario.code }), |
| 7054 | }, |
| 7055 | }, |
| 7056 | }, |
| 7057 | }, |
| 7058 | }, |
| 7059 | ...(scenario.name === "late-reset" ? [snapshot] : []), |
| 7060 | ...(scenario.name === "deferred-reset" |
| 7061 | ? [ |
| 7062 | { |
| 7063 | ...snapshot, |
| 7064 | frame: { |
| 7065 | method: "account/rateLimits/updated", |
| 7066 | params: { |
| 7067 | rateLimits: { |
| 7068 | limitId: "codex", |
| 7069 | primary: { |
| 7070 | usedPercent: 100, |
| 7071 | resetsAt: 2000200000, |
| 7072 | windowDurationMins: 300, |
| 7073 | }, |
| 7074 | }, |
| 7075 | }, |
| 7076 | }, |
| 7077 | }, |
| 7078 | { |
| 7079 | type: "emit_inbound" as const, |
| 7080 | label: "turn/completed/child", |
| 7081 | frame: { |
| 7082 | method: "turn/completed", |
| 7083 | params: { |
| 7084 | threadId: "native-limit-child", |
| 7085 | turn: makeCodexReplayTurn({ |
| 7086 | id: "limit-child-turn", |
| 7087 | status: "completed", |
| 7088 | }), |
| 7089 | }, |
| 7090 | }, |
| 7091 | }, |
| 7092 | ] |
| 7093 | : []), |
| 7094 | ], |
| 7095 | }); |
| 7096 | const resetReceipt = yield* Deferred.make<void>(); |
| 7097 | const harness = yield* makeCodexReplayHarness(transcript, (event) => |
| 7098 | event.type === "turn_item.updated" && |
| 7099 | event.turnItem.type === "error" && |
| 7100 | event.turnItem.failure.resetAt === resetAt |
| 7101 | ? Deferred.succeed(resetReceipt, undefined) |
| 7102 | : Effect.void, |
| 7103 | ); |
| 7104 | yield* harness.runtime.startTurn( |
| 7105 | makeCodexTestTurnInput({ |
| 7106 | threadId: harness.threadId, |
| 7107 | providerThread: harness.providerThread, |
| 7108 | now: yield* DateTime.now, |
| 7109 | text: "Continue.", |
| 7110 | attemptId: RunAttemptId.make(`attempt-limit-${scenario.name}`), |
| 7111 | }), |
| 7112 | ); |
| 7113 | yield* harness.firstTerminal; |
| 7114 | const terminal = harness.terminalEvents()[0]; |
| 7115 | assert.equal(terminal?.status, "failed"); |
| 7116 | if (terminal?.status !== "failed") return; |
| 7117 | assert.equal(terminal.failure.class, scenario.expectedClass); |
| 7118 | assert.equal(terminal.threadDisposition, "reusable"); |
| 7119 | if (scenario.name === "known-reset" || scenario.name === "deferred-reset") |
| 7120 | assert.equal(terminal.failure.resetAt, resetAt); |
| 7121 | if (scenario.name === "matching-details") |
| 7122 | assert.equal(terminal.failure.message, "Detailed provider allowance explanation."); |
| 7123 | if (scenario.name === "late-reset") { |
| 7124 | yield* Deferred.await(resetReceipt); |
| 7125 | const item = harness.events.find( |
| 7126 | (event) => |
| 7127 | event.type === "turn_item.updated" && |
| 7128 | event.turnItem.type === "error" && |
| 7129 | event.turnItem.failure.resetAt === resetAt, |
| 7130 | ); |
| 7131 | assert.isDefined(item); |
| 7132 | } |
| 7133 | if (scenario.name === "retry") assert.equal(terminal.retry?.attempt, 1); |
| 7134 | }).pipe( |
| 7135 | Effect.provide( |
| 7136 | Layer.mergeAll( |
| 7137 | IdAllocator.layer, |
| 7138 | McpProviderSessions.layer, |
| 7139 | ScriptProgress.layerUnavailable, |
| 7140 | NodeServices.layer, |
| 7141 | ), |
| 7142 | ), |
| 7143 | ), |
| 7144 | ), |
| 7145 | ); |
| 7146 | |
| 7147 | const FAILED_SCENARIO = "codex-failed-mid-command"; |
| 7148 | const FAILED_NATIVE_THREAD = "native-codex-failed-thread"; |
| 7149 | const FAILED_NATIVE_TURN = "native-codex-failed-turn"; |
| 7150 | const FAILED_COMMAND_ITEM = "exec-codex-failed-command"; |
| 7151 | const FAILED_COMMAND = "sleep 30"; |
| 7152 | const FAILED_PROMPT = "Run a command that will be abandoned when the turn fails."; |
| 7153 | |
| 7154 | const failedMidCommandTranscript = makeCodexReplayTranscript({ |
| 7155 | scenario: FAILED_SCENARIO, |
| 7156 | entries: [ |
| 7157 | ...codexReplayPreamble({ |
| 7158 | nativeThreadId: FAILED_NATIVE_THREAD, |
| 7159 | nativeTurnId: FAILED_NATIVE_TURN, |
| 7160 | prompt: FAILED_PROMPT, |
| 7161 | }), |
| 7162 | { |
| 7163 | type: "emit_inbound", |
| 7164 | label: "item/started/command", |
| 7165 | frame: { |
| 7166 | method: "item/started", |
| 7167 | params: { |
| 7168 | item: { |
| 7169 | type: "commandExecution", |
| 7170 | id: FAILED_COMMAND_ITEM, |
| 7171 | command: FAILED_COMMAND, |
| 7172 | cwd: "/workspace", |
| 7173 | processId: "99", |
| 7174 | source: "unifiedExecStartup", |
| 7175 | status: "inProgress", |
| 7176 | commandActions: [{ type: "unknown", command: FAILED_COMMAND }], |
| 7177 | aggregatedOutput: null, |
| 7178 | exitCode: null, |
| 7179 | durationMs: null, |
| 7180 | }, |
| 7181 | threadId: FAILED_NATIVE_THREAD, |
| 7182 | turnId: FAILED_NATIVE_TURN, |
| 7183 | startedAtMs: 1782622440500, |
| 7184 | }, |
| 7185 | }, |
| 7186 | }, |
| 7187 | { |
| 7188 | type: "emit_inbound", |
| 7189 | label: "turn/completed", |
| 7190 | frame: { |
| 7191 | method: "turn/completed", |
| 7192 | params: { |
| 7193 | threadId: FAILED_NATIVE_THREAD, |
| 7194 | turn: { |
| 7195 | ...makeCodexReplayTurn({ |
| 7196 | id: FAILED_NATIVE_TURN, |
| 7197 | status: "failed", |
| 7198 | }), |
| 7199 | error: { message: "provider failed mid-command" }, |
| 7200 | }, |
| 7201 | }, |
| 7202 | }, |
| 7203 | }, |
| 7204 | ], |
| 7205 | }); |
| 7206 | |
| 7207 | it.effect("terminalizes running command items before turn.terminal on failed turns", () => |
| 7208 | Effect.scoped( |
| 7209 | Effect.gen(function* () { |
| 7210 | const harness = yield* makeCodexReplayHarness(failedMidCommandTranscript); |
| 7211 | const now = yield* DateTime.now; |
| 7212 | |
| 7213 | yield* harness.runtime.startTurn( |
| 7214 | makeCodexTestTurnInput({ |
| 7215 | threadId: harness.threadId, |
| 7216 | providerThread: harness.providerThread, |
| 7217 | now, |
| 7218 | attemptId: RunAttemptId.make("attempt-codex-failed-mid-command"), |
| 7219 | text: FAILED_PROMPT, |
| 7220 | }), |
| 7221 | ); |
| 7222 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "failed terminal"); |
| 7223 | assert.equal(harness.terminalEvents()[0]?.status, "failed"); |
| 7224 | |
| 7225 | const terminalIndex = harness.events.findIndex((event) => event.type === "turn.terminal"); |
| 7226 | const failedCommandIndex = harness.events.findIndex( |
| 7227 | (event, index) => |
| 7228 | index < terminalIndex && |
| 7229 | event.type === "turn_item.updated" && |
| 7230 | event.turnItem.type === "command_execution" && |
| 7231 | event.turnItem.status === "failed", |
| 7232 | ); |
| 7233 | assert.isAtLeast(failedCommandIndex, 0); |
| 7234 | assert.isAbove( |
| 7235 | terminalIndex, |
| 7236 | failedCommandIndex, |
| 7237 | "failed-turn command terminalization must precede turn.terminal", |
| 7238 | ); |
| 7239 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 7240 | assert.lengthOf(harness.continuationRequests, 0); |
| 7241 | }).pipe( |
| 7242 | Effect.provide( |
| 7243 | Layer.mergeAll( |
| 7244 | IdAllocator.layer, |
| 7245 | McpProviderSessions.layer, |
| 7246 | ScriptProgress.layerUnavailable, |
| 7247 | NodeServices.layer, |
| 7248 | ), |
| 7249 | ), |
| 7250 | ), |
| 7251 | ), |
| 7252 | ); |
| 7253 | |
| 7254 | const MCP_APP_SCENARIO = "codex-mcp-app-capture"; |
| 7255 | const MCP_APP_NATIVE_THREAD = "native-codex-mcp-app-thread"; |
| 7256 | const MCP_APP_NATIVE_TURN = "native-codex-mcp-app-turn"; |
| 7257 | const MCP_APP_ITEM = "mcp-weather-call"; |
| 7258 | const MCP_APP_PROMPT = "Show the weather in Oslo."; |
| 7259 | const MCP_APP_RESOURCE = "ui://weather/dashboard"; |
| 7260 | const MCP_APP_HTML = "<!doctype html><html><body><p>Weather</p><script>1</script></body></html>"; |
| 7261 | const mcpAppToolItem = (status: "inProgress" | "completed") => ({ |
| 7262 | type: "mcpToolCall", |
| 7263 | id: MCP_APP_ITEM, |
| 7264 | server: "weather", |
| 7265 | tool: "get_weather", |
| 7266 | status, |
| 7267 | arguments: { city: "Oslo" }, |
| 7268 | mcpAppResourceUri: MCP_APP_RESOURCE, |
| 7269 | ...(status === "completed" |
| 7270 | ? { result: { content: [{ type: "text", text: "Sunny" }], structuredContent: { temp: 21 } } } |
| 7271 | : {}), |
| 7272 | }); |
| 7273 | |
| 7274 | const mcpAppTranscript = makeCodexReplayTranscript({ |
| 7275 | scenario: MCP_APP_SCENARIO, |
| 7276 | entries: [ |
| 7277 | ...codexReplayPreamble({ |
| 7278 | nativeThreadId: MCP_APP_NATIVE_THREAD, |
| 7279 | nativeTurnId: MCP_APP_NATIVE_TURN, |
| 7280 | prompt: MCP_APP_PROMPT, |
| 7281 | }), |
| 7282 | { |
| 7283 | type: "emit_inbound", |
| 7284 | label: "item/started/app-tool", |
| 7285 | frame: { |
| 7286 | method: "item/started", |
| 7287 | params: { |
| 7288 | item: mcpAppToolItem("inProgress"), |
| 7289 | threadId: MCP_APP_NATIVE_THREAD, |
| 7290 | turnId: MCP_APP_NATIVE_TURN, |
| 7291 | startedAtMs: 1782622440500, |
| 7292 | }, |
| 7293 | }, |
| 7294 | }, |
| 7295 | { |
| 7296 | type: "emit_inbound", |
| 7297 | label: "item/completed/app-tool", |
| 7298 | frame: { |
| 7299 | method: "item/completed", |
| 7300 | params: { |
| 7301 | item: mcpAppToolItem("completed"), |
| 7302 | threadId: MCP_APP_NATIVE_THREAD, |
| 7303 | turnId: MCP_APP_NATIVE_TURN, |
| 7304 | completedAtMs: 1782622441500, |
| 7305 | }, |
| 7306 | }, |
| 7307 | }, |
| 7308 | { |
| 7309 | type: "emit_inbound", |
| 7310 | label: "turn/completed", |
| 7311 | frame: { |
| 7312 | method: "turn/completed", |
| 7313 | params: { |
| 7314 | threadId: MCP_APP_NATIVE_THREAD, |
| 7315 | turn: makeCodexReplayTurn({ id: MCP_APP_NATIVE_TURN, status: "completed" }), |
| 7316 | }, |
| 7317 | }, |
| 7318 | }, |
| 7319 | { |
| 7320 | type: "expect_outbound", |
| 7321 | label: "mcpServer/resource/read", |
| 7322 | frame: { |
| 7323 | id: 4, |
| 7324 | method: "mcpServer/resource/read", |
| 7325 | params: { threadId: MCP_APP_NATIVE_THREAD, server: "weather", uri: MCP_APP_RESOURCE }, |
| 7326 | }, |
| 7327 | }, |
| 7328 | { |
| 7329 | type: "emit_inbound", |
| 7330 | label: "mcpServer/resource/read", |
| 7331 | frame: { |
| 7332 | id: 4, |
| 7333 | result: { |
| 7334 | contents: [ |
| 7335 | { |
| 7336 | uri: MCP_APP_RESOURCE, |
| 7337 | mimeType: "text/html;profile=mcp-app", |
| 7338 | text: MCP_APP_HTML, |
| 7339 | _meta: { ui: { csp: { connectDomains: ["https://api.weather.test"] } } }, |
| 7340 | }, |
| 7341 | ], |
| 7342 | }, |
| 7343 | }, |
| 7344 | }, |
| 7345 | ], |
| 7346 | }); |
| 7347 | |
| 7348 | it.effect("captures an MCP app's resource and holds the turn open until it lands", () => |
| 7349 | Effect.scoped( |
| 7350 | Effect.gen(function* () { |
| 7351 | const appCaptured = yield* Deferred.make<OrchestrationV2TurnItem>(); |
| 7352 | const harness = yield* makeCodexReplayHarness(mcpAppTranscript, (event) => |
| 7353 | event.type === "turn_item.updated" && |
| 7354 | event.turnItem.type === "dynamic_tool" && |
| 7355 | event.turnItem.status === "completed" |
| 7356 | ? Deferred.succeed(appCaptured, event.turnItem) |
| 7357 | : Effect.void, |
| 7358 | ); |
| 7359 | const now = yield* DateTime.now; |
| 7360 | |
| 7361 | yield* harness.runtime.startTurn( |
| 7362 | makeCodexTestTurnInput({ |
| 7363 | threadId: harness.threadId, |
| 7364 | providerThread: harness.providerThread, |
| 7365 | now, |
| 7366 | attemptId: RunAttemptId.make("attempt-codex-mcp-app"), |
| 7367 | text: MCP_APP_PROMPT, |
| 7368 | }), |
| 7369 | ); |
| 7370 | const item = yield* Deferred.await(appCaptured); |
| 7371 | assert.equal(harness.terminalEvents()[0]?.status, "completed"); |
| 7372 | |
| 7373 | // The completed tool row is emitted once, with the app, never as a bare row first. |
| 7374 | const completedRows = harness.events.filter( |
| 7375 | (event) => |
| 7376 | event.type === "turn_item.updated" && |
| 7377 | event.turnItem.type === "dynamic_tool" && |
| 7378 | event.turnItem.status === "completed", |
| 7379 | ); |
| 7380 | assert.lengthOf(completedRows, 1); |
| 7381 | |
| 7382 | assert.equal(item.type, "dynamic_tool"); |
| 7383 | const output = item.type === "dynamic_tool" ? (item.output as Record<string, unknown>) : {}; |
| 7384 | const reference = readMcpAppReference(output[MCP_APP_OUTPUT_KEY]); |
| 7385 | assert.deepEqual( |
| 7386 | { ...reference, attachmentId: undefined }, |
| 7387 | { |
| 7388 | attachmentId: undefined, |
| 7389 | server: "weather", |
| 7390 | tool: "get_weather", |
| 7391 | resourceUri: MCP_APP_RESOURCE, |
| 7392 | csp: { connectDomains: ["https://api.weather.test"] }, |
| 7393 | }, |
| 7394 | ); |
| 7395 | assert.deepEqual(output.result, { |
| 7396 | content: [{ type: "text", text: "Sunny" }], |
| 7397 | structuredContent: { temp: 21 }, |
| 7398 | }); |
| 7399 | |
| 7400 | const fileSystem = yield* FileSystem.FileSystem; |
| 7401 | const stored = yield* fileSystem.readFileString( |
| 7402 | resolveAttachmentPathById({ |
| 7403 | attachmentsDir: harness.serverConfig.attachmentsDir, |
| 7404 | attachmentId: reference!.attachmentId, |
| 7405 | })!, |
| 7406 | ); |
| 7407 | // Stored with the app's declared policy ahead of its scripts. |
| 7408 | assert.include(stored, "connect-src https://api.weather.test"); |
| 7409 | assert.isBelow(stored.indexOf("Content-Security-Policy"), stored.indexOf("<script>")); |
| 7410 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 7411 | }).pipe( |
| 7412 | Effect.provide( |
| 7413 | Layer.mergeAll( |
| 7414 | IdAllocator.layer, |
| 7415 | McpProviderSessions.layer, |
| 7416 | ScriptProgress.layerUnavailable, |
| 7417 | NodeServices.layer, |
| 7418 | ), |
| 7419 | ), |
| 7420 | ), |
| 7421 | ), |
| 7422 | ); |
| 7423 | |
| 7424 | // The turn fails while the app's resource read is still outstanding (Codex |
| 7425 | // never answers it), so the capture must be cancelled and its tool row |
| 7426 | // settled before the terminal event closes ingestion. |
| 7427 | const failedMcpAppTranscript = makeCodexReplayTranscript({ |
| 7428 | scenario: "codex-mcp-app-capture-failed-turn", |
| 7429 | entries: [ |
| 7430 | ...codexReplayPreamble({ |
| 7431 | nativeThreadId: MCP_APP_NATIVE_THREAD, |
| 7432 | nativeTurnId: MCP_APP_NATIVE_TURN, |
| 7433 | prompt: MCP_APP_PROMPT, |
| 7434 | }), |
| 7435 | { |
| 7436 | type: "emit_inbound", |
| 7437 | label: "item/completed/app-tool", |
| 7438 | frame: { |
| 7439 | method: "item/completed", |
| 7440 | params: { |
| 7441 | item: mcpAppToolItem("completed"), |
| 7442 | threadId: MCP_APP_NATIVE_THREAD, |
| 7443 | turnId: MCP_APP_NATIVE_TURN, |
| 7444 | completedAtMs: 1782622441500, |
| 7445 | }, |
| 7446 | }, |
| 7447 | }, |
| 7448 | { |
| 7449 | type: "expect_outbound", |
| 7450 | label: "mcpServer/resource/read", |
| 7451 | frame: { |
| 7452 | id: 4, |
| 7453 | method: "mcpServer/resource/read", |
| 7454 | params: { threadId: MCP_APP_NATIVE_THREAD, server: "weather", uri: MCP_APP_RESOURCE }, |
| 7455 | }, |
| 7456 | }, |
| 7457 | { |
| 7458 | type: "emit_inbound", |
| 7459 | label: "turn/completed", |
| 7460 | frame: { |
| 7461 | method: "turn/completed", |
| 7462 | params: { |
| 7463 | threadId: MCP_APP_NATIVE_THREAD, |
| 7464 | turn: { |
| 7465 | ...makeCodexReplayTurn({ id: MCP_APP_NATIVE_TURN, status: "failed" }), |
| 7466 | error: { message: "provider failed mid-capture" }, |
| 7467 | }, |
| 7468 | }, |
| 7469 | }, |
| 7470 | }, |
| 7471 | ], |
| 7472 | }); |
| 7473 | |
| 7474 | it.effect("settles a pending MCP app capture before a failed turn's terminal event", () => |
| 7475 | Effect.scoped( |
| 7476 | Effect.gen(function* () { |
| 7477 | const harness = yield* makeCodexReplayHarness(failedMcpAppTranscript); |
| 7478 | const now = yield* DateTime.now; |
| 7479 | |
| 7480 | yield* harness.runtime.startTurn( |
| 7481 | makeCodexTestTurnInput({ |
| 7482 | threadId: harness.threadId, |
| 7483 | providerThread: harness.providerThread, |
| 7484 | now, |
| 7485 | attemptId: RunAttemptId.make("attempt-codex-mcp-app-failed"), |
| 7486 | text: MCP_APP_PROMPT, |
| 7487 | }), |
| 7488 | ); |
| 7489 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "failed terminal"); |
| 7490 | assert.equal(harness.terminalEvents()[0]?.status, "failed"); |
| 7491 | |
| 7492 | const terminalIndex = harness.events.findIndex((event) => event.type === "turn.terminal"); |
| 7493 | const rows = harness.events.flatMap((event, index) => |
| 7494 | event.type === "turn_item.updated" && |
| 7495 | event.turnItem.type === "dynamic_tool" && |
| 7496 | event.turnItem.status === "completed" |
| 7497 | ? [{ index, turnItem: event.turnItem }] |
| 7498 | : [], |
| 7499 | ); |
| 7500 | // Settled once, as a plain tool row, ahead of the terminal event. |
| 7501 | assert.lengthOf(rows, 1); |
| 7502 | assert.isBelow(rows[0]!.index, terminalIndex); |
| 7503 | const output = rows[0]!.turnItem.type === "dynamic_tool" ? rows[0]!.turnItem.output : null; |
| 7504 | assert.isUndefined((output as Record<string, unknown> | null)?.[MCP_APP_OUTPUT_KEY]); |
| 7505 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 7506 | }).pipe( |
| 7507 | Effect.provide( |
| 7508 | Layer.mergeAll( |
| 7509 | IdAllocator.layer, |
| 7510 | McpProviderSessions.layer, |
| 7511 | ScriptProgress.layerUnavailable, |
| 7512 | NodeServices.layer, |
| 7513 | ), |
| 7514 | ), |
| 7515 | ), |
| 7516 | ), |
| 7517 | ); |
| 7518 | |
| 7519 | const ORPHAN_WAIT_SCENARIO = "codex-orphaned-dynamic-tool"; |
| 7520 | const ORPHAN_WAIT_NATIVE_THREAD = "native-codex-orphan-wait-thread"; |
| 7521 | const ORPHAN_WAIT_NATIVE_TURN = "native-codex-orphan-wait-turn"; |
| 7522 | const ORPHAN_WAIT_ITEM = "exec-4669f3bb-78c9-4af1-b44e-daa340d2c538"; |
| 7523 | const PERSISTENT_MONITOR_ITEM = "exec-persistent-monitor"; |
| 7524 | const COMPLETED_WAIT_ITEM = "exec-completed-wait"; |
| 7525 | const ORPHAN_WAIT_PROMPT = "Wait on two nested tasks, then finish."; |
| 7526 | |
| 7527 | const orphanedDynamicToolTranscript = makeCodexReplayTranscript({ |
| 7528 | scenario: ORPHAN_WAIT_SCENARIO, |
| 7529 | entries: [ |
| 7530 | ...codexReplayPreamble({ |
| 7531 | nativeThreadId: ORPHAN_WAIT_NATIVE_THREAD, |
| 7532 | nativeTurnId: ORPHAN_WAIT_NATIVE_TURN, |
| 7533 | prompt: ORPHAN_WAIT_PROMPT, |
| 7534 | }), |
| 7535 | { |
| 7536 | type: "emit_inbound", |
| 7537 | label: "item/started/completed-wait", |
| 7538 | frame: { |
| 7539 | method: "item/started", |
| 7540 | params: { |
| 7541 | item: { |
| 7542 | type: "mcpToolCall", |
| 7543 | id: COMPLETED_WAIT_ITEM, |
| 7544 | server: "t3-code", |
| 7545 | tool: "t3_thread_wait", |
| 7546 | status: "inProgress", |
| 7547 | arguments: { threadId: "thread:completed-wait", timeoutMs: 30000 }, |
| 7548 | }, |
| 7549 | threadId: ORPHAN_WAIT_NATIVE_THREAD, |
| 7550 | turnId: ORPHAN_WAIT_NATIVE_TURN, |
| 7551 | startedAtMs: 1782622440500, |
| 7552 | }, |
| 7553 | }, |
| 7554 | }, |
| 7555 | { |
| 7556 | type: "emit_inbound", |
| 7557 | label: "item/completed/completed-wait", |
| 7558 | frame: { |
| 7559 | method: "item/completed", |
| 7560 | params: { |
| 7561 | item: { |
| 7562 | type: "mcpToolCall", |
| 7563 | id: COMPLETED_WAIT_ITEM, |
| 7564 | server: "t3-code", |
| 7565 | tool: "t3_thread_wait", |
| 7566 | status: "completed", |
| 7567 | arguments: { threadId: "thread:completed-wait", timeoutMs: 30000 }, |
| 7568 | result: { content: [{ type: "text", text: "idle" }] }, |
| 7569 | }, |
| 7570 | threadId: ORPHAN_WAIT_NATIVE_THREAD, |
| 7571 | turnId: ORPHAN_WAIT_NATIVE_TURN, |
| 7572 | completedAtMs: 1782622441500, |
| 7573 | }, |
| 7574 | }, |
| 7575 | }, |
| 7576 | { |
| 7577 | type: "emit_inbound", |
| 7578 | label: "item/started/orphan-wait", |
| 7579 | frame: { |
| 7580 | method: "item/started", |
| 7581 | params: { |
| 7582 | item: { |
| 7583 | type: "mcpToolCall", |
| 7584 | id: ORPHAN_WAIT_ITEM, |
| 7585 | server: "t3-code", |
| 7586 | tool: "t3_thread_wait", |
| 7587 | status: "inProgress", |
| 7588 | arguments: { |
| 7589 | threadId: |
| 7590 | "thread:delegated-task:command%3Amcp%3Aaafffab1-e811-458a-ae83-558e542c61ff%3Adelegate-task%3Areview-mobile-reconnect-opus-20260815", |
| 7591 | timeoutMs: 30000, |
| 7592 | }, |
| 7593 | }, |
| 7594 | threadId: ORPHAN_WAIT_NATIVE_THREAD, |
| 7595 | turnId: ORPHAN_WAIT_NATIVE_TURN, |
| 7596 | startedAtMs: 1782622442500, |
| 7597 | }, |
| 7598 | }, |
| 7599 | }, |
| 7600 | { |
| 7601 | type: "emit_inbound", |
| 7602 | label: "item/started/persistent-monitor", |
| 7603 | frame: { |
| 7604 | method: "item/started", |
| 7605 | params: { |
| 7606 | item: { |
| 7607 | type: "dynamicToolCall", |
| 7608 | id: PERSISTENT_MONITOR_ITEM, |
| 7609 | namespace: "grok", |
| 7610 | tool: "monitor", |
| 7611 | status: "inProgress", |
| 7612 | arguments: { persistent: true, command: "tail -f" }, |
| 7613 | }, |
| 7614 | threadId: ORPHAN_WAIT_NATIVE_THREAD, |
| 7615 | turnId: ORPHAN_WAIT_NATIVE_TURN, |
| 7616 | startedAtMs: 1782622443500, |
| 7617 | }, |
| 7618 | }, |
| 7619 | }, |
| 7620 | { |
| 7621 | type: "emit_inbound", |
| 7622 | label: "turn/completed", |
| 7623 | frame: { |
| 7624 | method: "turn/completed", |
| 7625 | params: { |
| 7626 | threadId: ORPHAN_WAIT_NATIVE_THREAD, |
| 7627 | turn: makeCodexReplayTurn({ |
| 7628 | id: ORPHAN_WAIT_NATIVE_TURN, |
| 7629 | status: "completed", |
| 7630 | }), |
| 7631 | }, |
| 7632 | }, |
| 7633 | }, |
| 7634 | { |
| 7635 | type: "emit_inbound", |
| 7636 | label: "item/completed/persistent-monitor", |
| 7637 | afterMs: 30_000, |
| 7638 | frame: { |
| 7639 | method: "item/completed", |
| 7640 | params: { |
| 7641 | item: { |
| 7642 | type: "dynamicToolCall", |
| 7643 | id: PERSISTENT_MONITOR_ITEM, |
| 7644 | namespace: "grok", |
| 7645 | tool: "monitor", |
| 7646 | status: "completed", |
| 7647 | arguments: { persistent: true, command: "tail -f" }, |
| 7648 | result: { content: [{ type: "text", text: "stopped" }] }, |
| 7649 | }, |
| 7650 | threadId: ORPHAN_WAIT_NATIVE_THREAD, |
| 7651 | turnId: ORPHAN_WAIT_NATIVE_TURN, |
| 7652 | completedAtMs: 1782622473500, |
| 7653 | }, |
| 7654 | }, |
| 7655 | }, |
| 7656 | ], |
| 7657 | }); |
| 7658 | |
| 7659 | it.effect( |
| 7660 | "terminalizes leftover nonpersistent dynamic tools when a completed turn never closes them", |
| 7661 | () => |
| 7662 | Effect.scoped( |
| 7663 | Effect.gen(function* () { |
| 7664 | const monitorCompleted = yield* Deferred.make<void>(); |
| 7665 | const harness = yield* makeCodexReplayHarness(orphanedDynamicToolTranscript, (event) => |
| 7666 | event.type === "turn_item.updated" && |
| 7667 | event.turnItem.type === "dynamic_tool" && |
| 7668 | event.turnItem.nativeItemRef?.nativeId === PERSISTENT_MONITOR_ITEM && |
| 7669 | event.turnItem.status === "completed" |
| 7670 | ? Deferred.succeed(monitorCompleted, undefined) |
| 7671 | : Effect.void, |
| 7672 | ); |
| 7673 | const now = yield* DateTime.now; |
| 7674 | |
| 7675 | yield* harness.runtime.startTurn( |
| 7676 | makeCodexTestTurnInput({ |
| 7677 | threadId: harness.threadId, |
| 7678 | providerThread: harness.providerThread, |
| 7679 | now, |
| 7680 | attemptId: RunAttemptId.make("attempt-codex-orphan-wait"), |
| 7681 | text: ORPHAN_WAIT_PROMPT, |
| 7682 | }), |
| 7683 | ); |
| 7684 | yield* harness.firstTerminal; |
| 7685 | assert.equal(harness.terminalEvents()[0]?.status, "completed"); |
| 7686 | |
| 7687 | const terminalIndex = harness.events.findIndex((event) => event.type === "turn.terminal"); |
| 7688 | const cancelledWait = harness.events.find( |
| 7689 | (event, index) => |
| 7690 | index < terminalIndex && |
| 7691 | event.type === "turn_item.updated" && |
| 7692 | event.turnItem.type === "dynamic_tool" && |
| 7693 | event.turnItem.nativeItemRef?.nativeId === ORPHAN_WAIT_ITEM && |
| 7694 | event.turnItem.status === "cancelled", |
| 7695 | ); |
| 7696 | assert.isDefined(cancelledWait); |
| 7697 | assert.isAbove( |
| 7698 | terminalIndex, |
| 7699 | harness.events.indexOf(cancelledWait!), |
| 7700 | "orphaned wait terminalization must precede turn.terminal", |
| 7701 | ); |
| 7702 | |
| 7703 | const completedWaitStatuses = new Set( |
| 7704 | harness.events.flatMap((event) => |
| 7705 | event.type === "turn_item.updated" && |
| 7706 | event.turnItem.type === "dynamic_tool" && |
| 7707 | event.turnItem.nativeItemRef?.nativeId === COMPLETED_WAIT_ITEM |
| 7708 | ? [event.turnItem.status] |
| 7709 | : [], |
| 7710 | ), |
| 7711 | ); |
| 7712 | assert.isTrue( |
| 7713 | completedWaitStatuses.has("completed"), |
| 7714 | "the wait that received item/completed must stay completed", |
| 7715 | ); |
| 7716 | assert.isFalse( |
| 7717 | completedWaitStatuses.has("cancelled"), |
| 7718 | "a completed wait must not be rewritten as cancelled", |
| 7719 | ); |
| 7720 | |
| 7721 | const persistentMonitorStatuses = new Set( |
| 7722 | harness.events.flatMap((event) => |
| 7723 | event.type === "turn_item.updated" && |
| 7724 | event.turnItem.type === "dynamic_tool" && |
| 7725 | event.turnItem.nativeItemRef?.nativeId === PERSISTENT_MONITOR_ITEM |
| 7726 | ? [event.turnItem.status] |
| 7727 | : [], |
| 7728 | ), |
| 7729 | ); |
| 7730 | assert.isTrue(persistentMonitorStatuses.has("running")); |
| 7731 | assert.isFalse( |
| 7732 | persistentMonitorStatuses.has("cancelled"), |
| 7733 | "persistent monitors must remain running after the root turn completes", |
| 7734 | ); |
| 7735 | assert.isTrue( |
| 7736 | yield* harness.hasPendingBackgroundWork, |
| 7737 | "persistent dynamic tools must keep the session residency pin until they complete", |
| 7738 | ); |
| 7739 | |
| 7740 | yield* TestClock.adjust("30 seconds"); |
| 7741 | yield* Deferred.await(monitorCompleted); |
| 7742 | const lateMonitorUpdateIndex = harness.events.findIndex( |
| 7743 | (event, index) => |
| 7744 | index > terminalIndex && |
| 7745 | event.type === "turn_item.updated" && |
| 7746 | event.turnItem.type === "dynamic_tool" && |
| 7747 | event.turnItem.nativeItemRef?.nativeId === PERSISTENT_MONITOR_ITEM && |
| 7748 | event.turnItem.status === "completed", |
| 7749 | ); |
| 7750 | assert.isAbove( |
| 7751 | lateMonitorUpdateIndex, |
| 7752 | terminalIndex, |
| 7753 | "persistent tool completion must follow turn.terminal", |
| 7754 | ); |
| 7755 | assert.lengthOf(harness.terminalEvents(), 1); |
| 7756 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 7757 | }).pipe( |
| 7758 | Effect.provide( |
| 7759 | Layer.mergeAll( |
| 7760 | IdAllocator.layer, |
| 7761 | McpProviderSessions.layer, |
| 7762 | ScriptProgress.layerUnavailable, |
| 7763 | NodeServices.layer, |
| 7764 | ), |
| 7765 | ), |
| 7766 | ), |
| 7767 | ), |
| 7768 | ); |
| 7769 | |
| 7770 | const RESUME_SCENARIO = "codex-resume-subagent"; |
| 7771 | const RESUME_NATIVE_THREAD = "native-codex-resume-thread"; |
| 7772 | const RESUME_NATIVE_TURN = "native-codex-resume-root-turn"; |
| 7773 | const RESUME_CHILD_THREAD = "native-codex-resume-child-thread"; |
| 7774 | const RESUME_CHILD_TURN_1 = "native-codex-resume-child-turn-1"; |
| 7775 | const RESUME_CHILD_TURN_2 = "native-codex-resume-child-turn-2"; |
| 7776 | const RESUME_PROMPT = "Spawn a sub-agent, nudge it, and reply NUDGED."; |
| 7777 | |
| 7778 | const childAgentMessage = (input: { |
| 7779 | readonly id: string; |
| 7780 | readonly text: string; |
| 7781 | readonly turnId: string; |
| 7782 | readonly completedAtMs: number; |
| 7783 | readonly afterMs?: number; |
| 7784 | readonly omitPhase?: boolean; |
| 7785 | }): CodexReplay.CodexAppServerReplayEntry => ({ |
| 7786 | type: "emit_inbound", |
| 7787 | label: `item/completed/${input.id}`, |
| 7788 | ...(input.afterMs === undefined ? {} : { afterMs: input.afterMs }), |
| 7789 | frame: { |
| 7790 | method: "item/completed", |
| 7791 | params: { |
| 7792 | item: { |
| 7793 | type: "agentMessage", |
| 7794 | id: input.id, |
| 7795 | text: input.text, |
| 7796 | ...(input.omitPhase ? {} : { phase: "final_answer" as const }), |
| 7797 | memoryCitation: null, |
| 7798 | }, |
| 7799 | threadId: RESUME_CHILD_THREAD, |
| 7800 | turnId: input.turnId, |
| 7801 | completedAtMs: input.completedAtMs, |
| 7802 | }, |
| 7803 | }, |
| 7804 | }); |
| 7805 | |
| 7806 | const childTurnStarted = ( |
| 7807 | turnId: string, |
| 7808 | afterMs?: number, |
| 7809 | ): CodexReplay.CodexAppServerReplayEntry => ({ |
| 7810 | type: "emit_inbound", |
| 7811 | label: `turn/started/${turnId}`, |
| 7812 | ...(afterMs === undefined ? {} : { afterMs }), |
| 7813 | frame: { |
| 7814 | method: "turn/started", |
| 7815 | params: { |
| 7816 | threadId: RESUME_CHILD_THREAD, |
| 7817 | turn: { |
| 7818 | ...makeCodexReplayTurn({ id: turnId, status: "inProgress" }), |
| 7819 | startedAt: turnId === RESUME_CHILD_TURN_2 ? 1782622470 : 1782622440, |
| 7820 | }, |
| 7821 | }, |
| 7822 | }, |
| 7823 | }); |
| 7824 | |
| 7825 | const childTurnCompleted = ( |
| 7826 | turnId: string, |
| 7827 | afterMs?: number, |
| 7828 | ): CodexReplay.CodexAppServerReplayEntry => ({ |
| 7829 | type: "emit_inbound", |
| 7830 | label: `turn/completed/${turnId}`, |
| 7831 | ...(afterMs === undefined ? {} : { afterMs }), |
| 7832 | frame: { |
| 7833 | method: "turn/completed", |
| 7834 | params: { |
| 7835 | threadId: RESUME_CHILD_THREAD, |
| 7836 | turn: makeCodexReplayTurn({ id: turnId, status: "completed" }), |
| 7837 | }, |
| 7838 | }, |
| 7839 | }); |
| 7840 | |
| 7841 | const resumeSubagentTranscript = makeCodexReplayTranscript({ |
| 7842 | scenario: RESUME_SCENARIO, |
| 7843 | entries: [ |
| 7844 | ...codexReplayPreamble({ |
| 7845 | nativeThreadId: RESUME_NATIVE_THREAD, |
| 7846 | nativeTurnId: RESUME_NATIVE_TURN, |
| 7847 | prompt: RESUME_PROMPT, |
| 7848 | }), |
| 7849 | { |
| 7850 | type: "emit_inbound", |
| 7851 | label: "item/completed/subAgentActivity-started", |
| 7852 | frame: { |
| 7853 | method: "item/completed", |
| 7854 | params: { |
| 7855 | item: { |
| 7856 | type: "subAgentActivity", |
| 7857 | id: "call-codex-resume-spawn", |
| 7858 | kind: "started", |
| 7859 | agentThreadId: RESUME_CHILD_THREAD, |
| 7860 | agentPath: "/root/resume_agent", |
| 7861 | }, |
| 7862 | threadId: RESUME_NATIVE_THREAD, |
| 7863 | turnId: RESUME_NATIVE_TURN, |
| 7864 | completedAtMs: 1782622441000, |
| 7865 | }, |
| 7866 | }, |
| 7867 | }, |
| 7868 | childTurnStarted(RESUME_CHILD_TURN_1), |
| 7869 | childAgentMessage({ |
| 7870 | id: "child-first-answer", |
| 7871 | text: "CODEX_FIRST_DONE", |
| 7872 | turnId: RESUME_CHILD_TURN_1, |
| 7873 | completedAtMs: 1782622442000, |
| 7874 | }), |
| 7875 | childAgentMessage({ |
| 7876 | id: "child-first-answer-empty", |
| 7877 | text: "", |
| 7878 | turnId: RESUME_CHILD_TURN_1, |
| 7879 | completedAtMs: 1782622442001, |
| 7880 | omitPhase: true, |
| 7881 | }), |
| 7882 | childAgentMessage({ |
| 7883 | id: "child-first-answer-duplicate", |
| 7884 | text: "CODEX_FIRST_DONE", |
| 7885 | turnId: RESUME_CHILD_TURN_1, |
| 7886 | completedAtMs: 1782622442002, |
| 7887 | }), |
| 7888 | childTurnCompleted(RESUME_CHILD_TURN_1, 100), |
| 7889 | { |
| 7890 | type: "emit_inbound", |
| 7891 | label: "item/completed/root-answer", |
| 7892 | frame: { |
| 7893 | method: "item/completed", |
| 7894 | params: { |
| 7895 | item: { |
| 7896 | type: "agentMessage", |
| 7897 | id: "root-answer-resume", |
| 7898 | text: "NUDGED", |
| 7899 | phase: "final_answer", |
| 7900 | memoryCitation: null, |
| 7901 | }, |
| 7902 | threadId: RESUME_NATIVE_THREAD, |
| 7903 | turnId: RESUME_NATIVE_TURN, |
| 7904 | completedAtMs: 1782622443000, |
| 7905 | }, |
| 7906 | }, |
| 7907 | }, |
| 7908 | { |
| 7909 | type: "emit_inbound", |
| 7910 | label: "turn/completed/root", |
| 7911 | frame: { |
| 7912 | method: "turn/completed", |
| 7913 | params: { |
| 7914 | threadId: RESUME_NATIVE_THREAD, |
| 7915 | turn: makeCodexReplayTurn({ id: RESUME_NATIVE_TURN, status: "completed" }), |
| 7916 | }, |
| 7917 | }, |
| 7918 | }, |
| 7919 | childTurnStarted(RESUME_CHILD_TURN_2, 30_000), |
| 7920 | childAgentMessage({ |
| 7921 | id: "child-resume-answer", |
| 7922 | text: "CODEX_RESUME_DONE", |
| 7923 | turnId: RESUME_CHILD_TURN_2, |
| 7924 | completedAtMs: 1782622480000, |
| 7925 | afterMs: 30_000, |
| 7926 | }), |
| 7927 | childTurnCompleted(RESUME_CHILD_TURN_2), |
| 7928 | ], |
| 7929 | }); |
| 7930 | |
| 7931 | it.effect.each([ |
| 7932 | { name: "current Codex Sol", model: "gpt-6-sol" }, |
| 7933 | { name: "current Codex wrong child", model: null }, |
| 7934 | { name: "Sol", model: "gpt-5.6-sol" }, |
| 7935 | { name: "Fable", model: "gpt-5.6-fable" }, |
| 7936 | { name: "Astra", model: "gpt-6-astra" }, |
| 7937 | { name: "missing", model: null }, |
| 7938 | { name: "invalid", model: null }, |
| 7939 | { name: "wrong child", model: null }, |
| 7940 | ])("reads $name child metadata without using the parent model", ({ name, model }) => |
| 7941 | Effect.scoped( |
| 7942 | Effect.gen(function* () { |
| 7943 | const metadataRead = yield* Deferred.make<void>(); |
| 7944 | const modelReported = yield* Deferred.make<void>(); |
| 7945 | let metadataRequests = 0; |
| 7946 | const harness = yield* makeCodexReplayHarness( |
| 7947 | resumeSubagentTranscript, |
| 7948 | (event) => |
| 7949 | event.type === "subagent.updated" && event.subagent.model === model |
| 7950 | ? Deferred.succeed(modelReported, undefined) |
| 7951 | : Effect.void, |
| 7952 | undefined, |
| 7953 | (threadId) => { |
| 7954 | metadataRequests++; |
| 7955 | assert.equal(threadId, RESUME_CHILD_THREAD); |
| 7956 | return Deferred.succeed(metadataRead, undefined).pipe( |
| 7957 | Effect.as( |
| 7958 | name === "invalid" |
| 7959 | ? {} |
| 7960 | : { |
| 7961 | thread: { |
| 7962 | id: name.includes("wrong child") ? "other-child" : threadId, |
| 7963 | ...(name.startsWith("current Codex") |
| 7964 | ? { model: "gpt-6-sol", reasoningEffort: "high" } |
| 7965 | : {}), |
| 7966 | }, |
| 7967 | model: name.startsWith("current Codex") |
| 7968 | ? null |
| 7969 | : name === "wrong child" |
| 7970 | ? "gpt-5.6-sol" |
| 7971 | : model, |
| 7972 | reasoningEffort: "high", |
| 7973 | serviceTier: "priority", |
| 7974 | }, |
| 7975 | ), |
| 7976 | ); |
| 7977 | }, |
| 7978 | ); |
| 7979 | yield* harness.runtime.startTurn( |
| 7980 | makeCodexTestTurnInput({ |
| 7981 | threadId: harness.threadId, |
| 7982 | providerThread: harness.providerThread, |
| 7983 | now: yield* DateTime.now, |
| 7984 | attemptId: RunAttemptId.make("attempt-child-model"), |
| 7985 | text: RESUME_PROMPT, |
| 7986 | }), |
| 7987 | ); |
| 7988 | yield* Deferred.await(metadataRead); |
| 7989 | yield* Deferred.await(modelReported); |
| 7990 | yield* TestClock.adjust("100 millis"); |
| 7991 | yield* harness.firstTerminal; |
| 7992 | assert.equal(harness.subagentUpdates().at(-1)?.subagent.model, model); |
| 7993 | if (model) { |
| 7994 | assert.deepEqual(harness.subagentUpdates().at(-1)?.subagent.modelSelection?.options, [ |
| 7995 | { id: "reasoningEffort", value: "high" }, |
| 7996 | ...(name.startsWith("current Codex") ? [] : [{ id: "serviceTier", value: "priority" }]), |
| 7997 | ]); |
| 7998 | } |
| 7999 | assert.equal(metadataRequests, name === "current Codex Sol" ? 1 : 2); |
| 8000 | }).pipe( |
| 8001 | Effect.provide( |
| 8002 | Layer.mergeAll( |
| 8003 | IdAllocator.layer, |
| 8004 | McpProviderSessions.layer, |
| 8005 | ScriptProgress.layerUnavailable, |
| 8006 | NodeServices.layer, |
| 8007 | ), |
| 8008 | ), |
| 8009 | ), |
| 8010 | ), |
| 8011 | ); |
| 8012 | |
| 8013 | it.effect.each(["failed", "malformed", "wrong child", "blank model"] as const)( |
| 8014 | "resumes child metadata after a %s read", |
| 8015 | (readResult) => |
| 8016 | Effect.scoped( |
| 8017 | Effect.gen(function* () { |
| 8018 | const modelReported = yield* Deferred.make<void>(); |
| 8019 | const model = "gpt-6-sol"; |
| 8020 | const metadataRequests: Array<string> = []; |
| 8021 | const harness = yield* makeCodexReplayHarness( |
| 8022 | resumeSubagentTranscript, |
| 8023 | (event) => |
| 8024 | event.type === "subagent.updated" && event.subagent.model === model |
| 8025 | ? Deferred.succeed(modelReported, undefined) |
| 8026 | : Effect.void, |
| 8027 | undefined, |
| 8028 | (threadId, method) => { |
| 8029 | assert.equal(threadId, RESUME_CHILD_THREAD); |
| 8030 | metadataRequests.push(method); |
| 8031 | if (method === "thread/resume") { |
| 8032 | return Effect.succeed({ thread: { id: threadId }, model }); |
| 8033 | } |
| 8034 | switch (readResult) { |
| 8035 | case "failed": |
| 8036 | return Effect.fail( |
| 8037 | new CodexError.CodexAppServerRequestError({ |
| 8038 | code: -32000, |
| 8039 | errorMessage: "Child metadata unavailable", |
| 8040 | method, |
| 8041 | }), |
| 8042 | ); |
| 8043 | case "malformed": |
| 8044 | return Effect.succeed({}); |
| 8045 | case "wrong child": |
| 8046 | return Effect.succeed({ thread: { id: "other-child", model: "gpt-6-astra" } }); |
| 8047 | case "blank model": |
| 8048 | return Effect.succeed({ thread: { id: threadId, model: " \t " } }); |
| 8049 | } |
| 8050 | }, |
| 8051 | ); |
| 8052 | yield* harness.runtime.startTurn( |
| 8053 | makeCodexTestTurnInput({ |
| 8054 | threadId: harness.threadId, |
| 8055 | providerThread: harness.providerThread, |
| 8056 | now: yield* DateTime.now, |
| 8057 | attemptId: RunAttemptId.make("attempt-child-model-fallback"), |
| 8058 | text: RESUME_PROMPT, |
| 8059 | }), |
| 8060 | ); |
| 8061 | yield* Deferred.await(modelReported); |
| 8062 | yield* TestClock.adjust("100 millis"); |
| 8063 | yield* harness.firstTerminal; |
| 8064 | assert.equal(harness.subagentUpdates().at(-1)?.subagent.model, model); |
| 8065 | assert.deepEqual(metadataRequests, ["thread/read", "thread/resume"]); |
| 8066 | }).pipe( |
| 8067 | Effect.provide( |
| 8068 | Layer.mergeAll( |
| 8069 | IdAllocator.layer, |
| 8070 | McpProviderSessions.layer, |
| 8071 | ScriptProgress.layerUnavailable, |
| 8072 | NodeServices.layer, |
| 8073 | ), |
| 8074 | ), |
| 8075 | ), |
| 8076 | ), |
| 8077 | ); |
| 8078 | |
| 8079 | it.effect.each(["thread/settings/updated", "model/rerouted"] as const)( |
| 8080 | "keeps %s child metadata when an older lookup finishes later", |
| 8081 | (method) => |
| 8082 | Effect.scoped( |
| 8083 | Effect.gen(function* () { |
| 8084 | const releaseMetadata = yield* Deferred.make<void>(); |
| 8085 | const observed = yield* Deferred.make<void>(); |
| 8086 | const model = "gpt-5.6-sol"; |
| 8087 | const notification: CodexReplay.CodexAppServerReplayEntry = { |
| 8088 | type: "emit_inbound", |
| 8089 | frame: { |
| 8090 | method, |
| 8091 | params: |
| 8092 | method === "model/rerouted" |
| 8093 | ? { |
| 8094 | threadId: RESUME_CHILD_THREAD, |
| 8095 | turnId: RESUME_CHILD_TURN_1, |
| 8096 | fromModel: "gpt-6-astra", |
| 8097 | toModel: model, |
| 8098 | reason: "highRiskCyberActivity", |
| 8099 | } |
| 8100 | : { |
| 8101 | threadId: RESUME_CHILD_THREAD, |
| 8102 | threadSettings: { |
| 8103 | model, |
| 8104 | effort: "low", |
| 8105 | serviceTier: "ultrafast", |
| 8106 | modelProvider: "openai", |
| 8107 | cwd: "/workspace", |
| 8108 | approvalPolicy: "never", |
| 8109 | approvalsReviewer: "auto_review", |
| 8110 | collaborationMode: { mode: "default", settings: { model } }, |
| 8111 | sandboxPolicy: { type: "dangerFullAccess" }, |
| 8112 | }, |
| 8113 | }, |
| 8114 | }, |
| 8115 | }; |
| 8116 | const initialSettings: CodexReplay.CodexAppServerReplayEntry = { |
| 8117 | type: "emit_inbound", |
| 8118 | frame: { |
| 8119 | method: "thread/settings/updated", |
| 8120 | params: { |
| 8121 | threadId: RESUME_CHILD_THREAD, |
| 8122 | threadSettings: { |
| 8123 | model: "gpt-6-astra", |
| 8124 | effort: "low", |
| 8125 | serviceTier: "ultrafast", |
| 8126 | modelProvider: "openai", |
| 8127 | cwd: "/workspace", |
| 8128 | approvalPolicy: "never", |
| 8129 | approvalsReviewer: "auto_review", |
| 8130 | collaborationMode: { mode: "default", settings: { model: "gpt-6-astra" } }, |
| 8131 | sandboxPolicy: { type: "dangerFullAccess" }, |
| 8132 | }, |
| 8133 | }, |
| 8134 | }, |
| 8135 | }; |
| 8136 | const harness = yield* makeCodexReplayHarness( |
| 8137 | { |
| 8138 | ...resumeSubagentTranscript, |
| 8139 | entries: resumeSubagentTranscript.entries.flatMap((entry) => |
| 8140 | entry.type === "emit_inbound" && entry.label === "turn/completed/root" |
| 8141 | ? [entry, initialSettings, notification] |
| 8142 | : [entry], |
| 8143 | ), |
| 8144 | }, |
| 8145 | (event) => |
| 8146 | event.type === "subagent.updated" && event.subagent.model === model |
| 8147 | ? Deferred.succeed(observed, undefined) |
| 8148 | : Effect.void, |
| 8149 | undefined, |
| 8150 | (threadId) => |
| 8151 | Deferred.await(releaseMetadata).pipe( |
| 8152 | Effect.as({ thread: { id: threadId }, model: "gpt-6-astra" }), |
| 8153 | ), |
| 8154 | ); |
| 8155 | yield* harness.runtime.startTurn( |
| 8156 | makeCodexTestTurnInput({ |
| 8157 | threadId: harness.threadId, |
| 8158 | providerThread: harness.providerThread, |
| 8159 | now: yield* DateTime.now, |
| 8160 | attemptId: RunAttemptId.make("attempt-child-model-update"), |
| 8161 | text: RESUME_PROMPT, |
| 8162 | }), |
| 8163 | ); |
| 8164 | yield* TestClock.adjust("100 millis"); |
| 8165 | yield* Deferred.await(observed); |
| 8166 | assert.equal(harness.subagentUpdates().at(-1)?.subagent.status, "completed"); |
| 8167 | yield* Deferred.succeed(releaseMetadata, undefined); |
| 8168 | yield* TestClock.adjust("30 seconds"); |
| 8169 | assert.equal(harness.subagentUpdates().at(-1)?.subagent.model, model); |
| 8170 | assert.deepEqual(harness.subagentUpdates().at(-1)?.subagent.modelSelection?.options, [ |
| 8171 | { id: "reasoningEffort", value: "low" }, |
| 8172 | { id: "serviceTier", value: "ultrafast" }, |
| 8173 | ]); |
| 8174 | }).pipe( |
| 8175 | Effect.provide( |
| 8176 | Layer.mergeAll( |
| 8177 | IdAllocator.layer, |
| 8178 | McpProviderSessions.layer, |
| 8179 | ScriptProgress.layerUnavailable, |
| 8180 | NodeServices.layer, |
| 8181 | ), |
| 8182 | ), |
| 8183 | ), |
| 8184 | ), |
| 8185 | ); |
| 8186 | |
| 8187 | it.effect("preserves a subagent result across a trailing empty final and resume", () => |
| 8188 | Effect.scoped( |
| 8189 | Effect.gen(function* () { |
| 8190 | const harness = yield* makeCodexReplayHarness(resumeSubagentTranscript); |
| 8191 | const now = yield* DateTime.now; |
| 8192 | |
| 8193 | yield* harness.runtime.startTurn( |
| 8194 | makeCodexTestTurnInput({ |
| 8195 | threadId: harness.threadId, |
| 8196 | providerThread: harness.providerThread, |
| 8197 | now, |
| 8198 | attemptId: RunAttemptId.make("attempt-codex-resume"), |
| 8199 | text: RESUME_PROMPT, |
| 8200 | }), |
| 8201 | ); |
| 8202 | yield* awaitUntil( |
| 8203 | () => |
| 8204 | harness.subagentUpdates().some((event) => event.subagent.result === "CODEX_FIRST_DONE"), |
| 8205 | "first subagent result", |
| 8206 | ); |
| 8207 | assert.lengthOf( |
| 8208 | harness.subagentUpdates().filter((event) => event.subagent.result === "CODEX_FIRST_DONE"), |
| 8209 | 1, |
| 8210 | ); |
| 8211 | yield* TestClock.adjust("100 millis"); |
| 8212 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); |
| 8213 | assert.equal(harness.terminalEvents()[0]?.status, "completed"); |
| 8214 | const settledUpdates = harness.subagentUpdates(); |
| 8215 | const firstCompletion = settledUpdates[settledUpdates.length - 1]; |
| 8216 | assert.equal(firstCompletion?.subagent.status, "completed"); |
| 8217 | assert.equal(firstCompletion?.subagent.result, "CODEX_FIRST_DONE"); |
| 8218 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 8219 | const settledUpdateCount = settledUpdates.length; |
| 8220 | |
| 8221 | yield* TestClock.adjust("30 seconds"); |
| 8222 | yield* awaitUntil( |
| 8223 | () => harness.subagentUpdates().length > settledUpdateCount, |
| 8224 | "subagent re-open", |
| 8225 | ); |
| 8226 | const reopened = harness.subagentUpdates()[settledUpdateCount]; |
| 8227 | assert.equal(reopened?.subagent.status, "running"); |
| 8228 | assert.equal(DateTime.toEpochMillis(reopened!.subagent.startedAt!), 1782622470000); |
| 8229 | assert.isNull(reopened!.subagent.completedAt); |
| 8230 | assert.isTrue(yield* harness.hasPendingBackgroundWork); |
| 8231 | |
| 8232 | yield* TestClock.adjust("30 seconds"); |
| 8233 | yield* awaitUntil(() => { |
| 8234 | const updates = harness.subagentUpdates(); |
| 8235 | const latest = updates[updates.length - 1]; |
| 8236 | return ( |
| 8237 | latest !== undefined && |
| 8238 | latest.subagent.status === "completed" && |
| 8239 | latest.subagent.result === "CODEX_RESUME_DONE" |
| 8240 | ); |
| 8241 | }, "resumed subagent completion"); |
| 8242 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 8243 | assert.lengthOf(harness.terminalEvents(), 1); |
| 8244 | assert.lengthOf(harness.continuationRequests, 0); |
| 8245 | }).pipe( |
| 8246 | Effect.provide( |
| 8247 | Layer.mergeAll( |
| 8248 | IdAllocator.layer, |
| 8249 | McpProviderSessions.layer, |
| 8250 | ScriptProgress.layerUnavailable, |
| 8251 | NodeServices.layer, |
| 8252 | ), |
| 8253 | ), |
| 8254 | ), |
| 8255 | ), |
| 8256 | ); |
| 8257 | |
| 8258 | it.effect("rejects duplicate child starts across parent runs", () => |
| 8259 | Effect.scoped( |
| 8260 | Effect.gen(function* () { |
| 8261 | const firstDone = yield* Deferred.make<void>(); |
| 8262 | const resumed = yield* Deferred.make<void>(); |
| 8263 | const secondTurn = "native-parent-resume-turn"; |
| 8264 | const secondPrompt = "Resume the child."; |
| 8265 | const entries = [...resumeSubagentTranscript.entries]; |
| 8266 | const resumeIndex = entries.findIndex( |
| 8267 | (e) => e.type === "emit_inbound" && e.label === `turn/started/${RESUME_CHILD_TURN_2}`, |
| 8268 | ); |
| 8269 | const suffix = entries.splice(resumeIndex); |
| 8270 | for (const entry of codexReplayPreamble({ |
| 8271 | nativeThreadId: RESUME_NATIVE_THREAD, |
| 8272 | nativeTurnId: secondTurn, |
| 8273 | prompt: secondPrompt, |
| 8274 | }).slice(-3)) { |
| 8275 | entries.push( |
| 8276 | entry.type === "expect_outbound" || entry.type === "emit_inbound" |
| 8277 | ? { |
| 8278 | ...entry, |
| 8279 | frame: |
| 8280 | Predicate.isObject(entry.frame) && "id" in entry.frame |
| 8281 | ? { ...entry.frame, id: 4 } |
| 8282 | : entry.frame, |
| 8283 | } |
| 8284 | : entry, |
| 8285 | ); |
| 8286 | } |
| 8287 | entries.push(childTurnStarted(RESUME_CHILD_TURN_1)); |
| 8288 | entries.push(...suffix); |
| 8289 | const harness = yield* makeCodexReplayHarness( |
| 8290 | makeCodexReplayTranscript({ |
| 8291 | scenario: "codex-cross-run-resume", |
| 8292 | entries, |
| 8293 | }), |
| 8294 | (event) => |
| 8295 | event.type === "turn.terminal" |
| 8296 | ? Deferred.succeed(firstDone, undefined) |
| 8297 | : event.type === "subagent.updated" && event.subagent.runId === "run-cross-run-second" |
| 8298 | ? Deferred.succeed(resumed, undefined) |
| 8299 | : Effect.void, |
| 8300 | ); |
| 8301 | const now = yield* DateTime.now; |
| 8302 | yield* harness.runtime.startTurn( |
| 8303 | makeCodexTestTurnInput({ |
| 8304 | threadId: harness.threadId, |
| 8305 | providerThread: harness.providerThread, |
| 8306 | now, |
| 8307 | attemptId: RunAttemptId.make("cross-run-first"), |
| 8308 | text: RESUME_PROMPT, |
| 8309 | }), |
| 8310 | ); |
| 8311 | yield* TestClock.adjust("100 millis"); |
| 8312 | yield* Deferred.await(firstDone); |
| 8313 | yield* harness.runtime.startTurn({ |
| 8314 | ...makeCodexTestTurnInput({ |
| 8315 | threadId: harness.threadId, |
| 8316 | providerThread: harness.providerThread, |
| 8317 | now, |
| 8318 | attemptId: RunAttemptId.make("cross-run-second"), |
| 8319 | text: secondPrompt, |
| 8320 | }), |
| 8321 | runOrdinal: 2, |
| 8322 | providerTurnOrdinal: 2, |
| 8323 | }); |
| 8324 | yield* TestClock.adjust("30 seconds"); |
| 8325 | yield* Deferred.await(resumed); |
| 8326 | const row = harness |
| 8327 | .subagentUpdates() |
| 8328 | .find((e) => e.subagent.runId === "run-cross-run-second")?.subagent; |
| 8329 | assert.equal(row?.status, "running"); |
| 8330 | assert.equal(row?.parentNodeId, "node-cross-run-second"); |
| 8331 | assert.isNull(row?.completedAt); |
| 8332 | assert.isNotNull(row?.startedAt); |
| 8333 | assert.equal(DateTime.toEpochMillis(row!.startedAt!), 1782622470000); |
| 8334 | }).pipe( |
| 8335 | Effect.provide( |
| 8336 | Layer.mergeAll( |
| 8337 | IdAllocator.layer, |
| 8338 | McpProviderSessions.layer, |
| 8339 | ScriptProgress.layerUnavailable, |
| 8340 | NodeServices.layer, |
| 8341 | ), |
| 8342 | ), |
| 8343 | ), |
| 8344 | ), |
| 8345 | ); |
| 8346 | |
| 8347 | it.effect.each([ |
| 8348 | ["pendingInit", "pending"], |
| 8349 | ["running", "running"], |
| 8350 | ["interrupted", "interrupted"], |
| 8351 | ["shutdown", "cancelled"], |
| 8352 | ["notFound", "failed"], |
| 8353 | ["errored", "failed"], |
| 8354 | ["completed", "completed"], |
| 8355 | ["activity-completed", "completed"], |
| 8356 | ["late-activity-completed", "completed"], |
| 8357 | ["stale-running", "completed"], |
| 8358 | ["duplicate-completed", "completed"], |
| 8359 | ] as const)( |
| 8360 | "normalizes subagent %s without losing its lifecycle", |
| 8361 | ([nativeStatus, expectedStatus]) => |
| 8362 | Effect.scoped( |
| 8363 | Effect.gen(function* () { |
| 8364 | const marker = yield* Deferred.make<void>(); |
| 8365 | const firstCompletion = yield* Deferred.make<void>(); |
| 8366 | const stateEntry = ( |
| 8367 | status: string, |
| 8368 | id: string, |
| 8369 | ): Extract<CodexReplay.CodexAppServerReplayEntry, { type: "emit_inbound" }> => ({ |
| 8370 | type: "emit_inbound", |
| 8371 | label: id, |
| 8372 | frame: { |
| 8373 | method: "item/completed", |
| 8374 | params: { |
| 8375 | threadId: RESUME_NATIVE_THREAD, |
| 8376 | turnId: RESUME_NATIVE_TURN, |
| 8377 | item: { |
| 8378 | type: "collabAgentToolCall", |
| 8379 | id, |
| 8380 | tool: "listAgents", |
| 8381 | status: "completed", |
| 8382 | senderThreadId: RESUME_NATIVE_THREAD, |
| 8383 | receiverThreadIds: [RESUME_CHILD_THREAD], |
| 8384 | agentsStates: { [RESUME_CHILD_THREAD]: { status, message: null } }, |
| 8385 | }, |
| 8386 | }, |
| 8387 | }, |
| 8388 | }); |
| 8389 | const entries: Array<CodexReplay.CodexAppServerReplayEntry> = [ |
| 8390 | ...codexReplayPreamble({ |
| 8391 | nativeThreadId: RESUME_NATIVE_THREAD, |
| 8392 | nativeTurnId: RESUME_NATIVE_TURN, |
| 8393 | prompt: RESUME_PROMPT, |
| 8394 | }), |
| 8395 | resumeSubagentTranscript.entries.find( |
| 8396 | (e) => |
| 8397 | e.type === "emit_inbound" && e.label === "item/completed/subAgentActivity-started", |
| 8398 | )!, |
| 8399 | ]; |
| 8400 | if (nativeStatus === "late-activity-completed") { |
| 8401 | entries.push( |
| 8402 | resumeSubagentTranscript.entries.find( |
| 8403 | (e) => e.type === "emit_inbound" && e.label === "turn/completed/root", |
| 8404 | )!, |
| 8405 | ); |
| 8406 | } |
| 8407 | if (nativeStatus === "activity-completed" || nativeStatus === "late-activity-completed") { |
| 8408 | entries.push({ |
| 8409 | type: "emit_inbound", |
| 8410 | label: "activity-done", |
| 8411 | frame: { |
| 8412 | method: "item/completed", |
| 8413 | params: { |
| 8414 | threadId: RESUME_NATIVE_THREAD, |
| 8415 | turnId: RESUME_NATIVE_TURN, |
| 8416 | item: { |
| 8417 | type: "subAgentActivity", |
| 8418 | id: "activity-done", |
| 8419 | kind: "completed", |
| 8420 | agentThreadId: RESUME_CHILD_THREAD, |
| 8421 | agentPath: "/root/resume_agent", |
| 8422 | }, |
| 8423 | }, |
| 8424 | }, |
| 8425 | }); |
| 8426 | } else if (nativeStatus === "stale-running" || nativeStatus === "duplicate-completed") { |
| 8427 | entries.push(stateEntry("completed", "child-completed"), { |
| 8428 | ...stateEntry( |
| 8429 | nativeStatus === "stale-running" ? "running" : "completed", |
| 8430 | "trailing-snapshot", |
| 8431 | ), |
| 8432 | afterMs: 100, |
| 8433 | }); |
| 8434 | } else { |
| 8435 | entries.push(stateEntry(nativeStatus, "status-update")); |
| 8436 | } |
| 8437 | // A known child's turn provides a receipt even after the parent context is released. |
| 8438 | if (nativeStatus === "late-activity-completed") { |
| 8439 | entries.push({ |
| 8440 | type: "emit_inbound", |
| 8441 | label: "late-marker", |
| 8442 | frame: { |
| 8443 | method: "turn/started", |
| 8444 | params: { |
| 8445 | threadId: RESUME_CHILD_THREAD, |
| 8446 | turn: makeCodexReplayTurn({ id: RESUME_CHILD_TURN_1, status: "inProgress" }), |
| 8447 | }, |
| 8448 | }, |
| 8449 | }); |
| 8450 | } else { |
| 8451 | entries.push({ |
| 8452 | type: "emit_inbound", |
| 8453 | label: "marker", |
| 8454 | frame: { |
| 8455 | method: "item/completed", |
| 8456 | params: { |
| 8457 | threadId: RESUME_NATIVE_THREAD, |
| 8458 | turnId: RESUME_NATIVE_TURN, |
| 8459 | item: { |
| 8460 | type: "agentMessage", |
| 8461 | id: "marker", |
| 8462 | text: "LIFECYCLE_MARKER", |
| 8463 | phase: "final_answer", |
| 8464 | memoryCitation: null, |
| 8465 | }, |
| 8466 | }, |
| 8467 | }, |
| 8468 | }); |
| 8469 | } |
| 8470 | const harness = yield* makeCodexReplayHarness( |
| 8471 | makeCodexReplayTranscript({ scenario: `subagent-${nativeStatus}`, entries }), |
| 8472 | (event) => |
| 8473 | (event.type === "message.updated" && event.message.text === "LIFECYCLE_MARKER") || |
| 8474 | (nativeStatus === "late-activity-completed" && |
| 8475 | event.type === "provider_turn.updated" && |
| 8476 | event.providerTurn.nativeTurnRef?.nativeId === RESUME_CHILD_TURN_1) |
| 8477 | ? Deferred.succeed(marker, undefined) |
| 8478 | : event.type === "subagent.updated" && event.subagent.status === "completed" |
| 8479 | ? Deferred.succeed(firstCompletion, undefined) |
| 8480 | : Effect.void, |
| 8481 | ); |
| 8482 | yield* harness.runtime.startTurn( |
| 8483 | makeCodexTestTurnInput({ |
| 8484 | threadId: harness.threadId, |
| 8485 | providerThread: harness.providerThread, |
| 8486 | now: yield* DateTime.now, |
| 8487 | attemptId: RunAttemptId.make(`subagent-${nativeStatus}`), |
| 8488 | text: RESUME_PROMPT, |
| 8489 | }), |
| 8490 | ); |
| 8491 | if (nativeStatus === "stale-running" || nativeStatus === "duplicate-completed") { |
| 8492 | yield* Deferred.await(firstCompletion); |
| 8493 | yield* TestClock.adjust("100 millis"); |
| 8494 | } |
| 8495 | yield* Deferred.await(marker); |
| 8496 | const latest = harness.subagentUpdates().at(-1)!.subagent; |
| 8497 | assert.equal(latest.status, expectedStatus); |
| 8498 | if (nativeStatus === "duplicate-completed") { |
| 8499 | const first = harness.subagentUpdates().find((e) => e.subagent.status === "completed")!; |
| 8500 | assert.equal( |
| 8501 | DateTime.toEpochMillis(latest.completedAt!), |
| 8502 | DateTime.toEpochMillis(first.subagent.completedAt!), |
| 8503 | ); |
| 8504 | } |
| 8505 | }).pipe( |
| 8506 | Effect.provide( |
| 8507 | Layer.mergeAll( |
| 8508 | IdAllocator.layer, |
| 8509 | McpProviderSessions.layer, |
| 8510 | ScriptProgress.layerUnavailable, |
| 8511 | NodeServices.layer, |
| 8512 | ), |
| 8513 | ), |
| 8514 | ), |
| 8515 | ), |
| 8516 | ); |
| 8517 | |
| 8518 | const codexReplayThreadResult = (input: { |
| 8519 | readonly nativeThreadId: string; |
| 8520 | readonly forkedFromId: string | null; |
| 8521 | }) => ({ |
| 8522 | thread: { |
| 8523 | id: input.nativeThreadId, |
| 8524 | sessionId: input.nativeThreadId, |
| 8525 | forkedFromId: input.forkedFromId, |
| 8526 | preview: "", |
| 8527 | ephemeral: false, |
| 8528 | modelProvider: "openai", |
| 8529 | createdAt: 1782622440, |
| 8530 | updatedAt: 1782622440, |
| 8531 | status: { type: "idle" }, |
| 8532 | path: `/tmp/${input.nativeThreadId}.jsonl`, |
| 8533 | cwd: "/workspace", |
| 8534 | cliVersion: "0.144.0", |
| 8535 | source: "vscode", |
| 8536 | threadSource: null, |
| 8537 | agentNickname: null, |
| 8538 | agentRole: null, |
| 8539 | gitInfo: null, |
| 8540 | name: null, |
| 8541 | turns: [], |
| 8542 | }, |
| 8543 | model: "gpt-5.4", |
| 8544 | modelProvider: "openai", |
| 8545 | serviceTier: null, |
| 8546 | cwd: "/workspace", |
| 8547 | instructionSources: [], |
| 8548 | approvalPolicy: "on-request", |
| 8549 | approvalsReviewer: "user", |
| 8550 | sandbox: { type: "workspaceWrite", writableRoots: [], networkAccess: false }, |
| 8551 | reasoningEffort: "medium", |
| 8552 | }); |
| 8553 | |
| 8554 | const errorCauseChainText = (error: unknown): string => |
| 8555 | error instanceof Error ? `${error.message} ${errorCauseChainText(error.cause)}` : String(error); |
| 8556 | |
| 8557 | const codexReplaySourceTurn = (input: { |
| 8558 | readonly id: string; |
| 8559 | readonly ordinal: number; |
| 8560 | readonly nativeId: string | null; |
| 8561 | readonly providerThreadId: ProviderThreadId; |
| 8562 | readonly now: DateTime.Utc; |
| 8563 | }): OrchestrationV2ProviderTurn => ({ |
| 8564 | id: ProviderTurnId.make(input.id), |
| 8565 | providerThreadId: input.providerThreadId, |
| 8566 | nodeId: NodeId.make(`node-${input.id}`), |
| 8567 | runAttemptId: RunAttemptId.make(`run-attempt-${input.id}`), |
| 8568 | nativeTurnRef: |
| 8569 | input.nativeId === null |
| 8570 | ? { driver: CodexAdapterV2.CODEX_DRIVER_KIND, nativeId: null, strength: "none" } |
| 8571 | : { |
| 8572 | driver: CodexAdapterV2.CODEX_DRIVER_KIND, |
| 8573 | nativeId: input.nativeId, |
| 8574 | strength: "strong", |
| 8575 | }, |
| 8576 | ordinal: input.ordinal, |
| 8577 | status: "completed", |
| 8578 | startedAt: input.now, |
| 8579 | completedAt: input.now, |
| 8580 | }); |
| 8581 | |
| 8582 | it.effect("fails honestly when rolling back a legacy Codex thread", () => |
| 8583 | Effect.gen(function* () { |
| 8584 | const nativeThreadId = "legacy-rollback-thread"; |
| 8585 | const preamble = codexReplayPreamble({ |
| 8586 | nativeThreadId, |
| 8587 | nativeTurnId: "legacy-rollback-turn", |
| 8588 | prompt: "unused", |
| 8589 | }); |
| 8590 | const transcript = makeCodexReplayTranscript({ |
| 8591 | scenario: "codex-legacy-rollback", |
| 8592 | entries: [ |
| 8593 | ...preamble.slice(0, 5), |
| 8594 | { |
| 8595 | type: "expect_outbound", |
| 8596 | label: "thread/read", |
| 8597 | frame: { |
| 8598 | id: 3, |
| 8599 | method: "thread/read", |
| 8600 | params: { threadId: nativeThreadId, includeTurns: false }, |
| 8601 | }, |
| 8602 | }, |
| 8603 | { |
| 8604 | type: "emit_inbound", |
| 8605 | label: "thread/read", |
| 8606 | frame: { |
| 8607 | id: 3, |
| 8608 | result: { thread: { id: nativeThreadId, historyMode: "legacy" } }, |
| 8609 | }, |
| 8610 | }, |
| 8611 | ], |
| 8612 | }); |
| 8613 | const outbound: Array<string> = []; |
| 8614 | const harness = yield* makeCodexReplayHarness( |
| 8615 | transcript, |
| 8616 | () => Effect.void, |
| 8617 | (method) => |
| 8618 | Effect.sync(() => { |
| 8619 | outbound.push(method); |
| 8620 | }), |
| 8621 | ); |
| 8622 | const now = yield* DateTime.now; |
| 8623 | const firstTurn = codexReplaySourceTurn({ |
| 8624 | id: "provider-turn-first", |
| 8625 | ordinal: 1, |
| 8626 | nativeId: "native-turn-first", |
| 8627 | providerThreadId: harness.providerThread.id, |
| 8628 | now, |
| 8629 | }); |
| 8630 | const secondTurn = codexReplaySourceTurn({ |
| 8631 | id: "provider-turn-second", |
| 8632 | ordinal: 2, |
| 8633 | nativeId: "native-turn-second", |
| 8634 | providerThreadId: harness.providerThread.id, |
| 8635 | now, |
| 8636 | }); |
| 8637 | |
| 8638 | const error = yield* Effect.flip( |
| 8639 | harness.runtime.rollbackThread({ |
| 8640 | providerThread: harness.providerThread, |
| 8641 | target: { |
| 8642 | type: "provider_turn", |
| 8643 | checkpointId: CheckpointId.make("checkpoint-legacy-rollback"), |
| 8644 | appRunOrdinal: 1, |
| 8645 | providerTurn: firstTurn, |
| 8646 | }, |
| 8647 | providerThreadTurns: [firstTurn, secondTurn], |
| 8648 | }), |
| 8649 | ); |
| 8650 | |
| 8651 | assert.instanceOf(error, ProviderAdapter.ProviderAdapterRollbackThreadError); |
| 8652 | assert.include( |
| 8653 | errorCauseChainText(error), |
| 8654 | "legacy", |
| 8655 | "legacy rollback must surface an honest unsupported-history failure", |
| 8656 | ); |
| 8657 | assert.notInclude( |
| 8658 | outbound, |
| 8659 | "thread/rollback", |
| 8660 | "thread/rollback must not be sent to a legacy Codex thread", |
| 8661 | ); |
| 8662 | }).pipe( |
| 8663 | Effect.scoped, |
| 8664 | Effect.provide( |
| 8665 | Layer.mergeAll( |
| 8666 | IdAllocator.layer, |
| 8667 | McpProviderSessions.layer, |
| 8668 | ScriptProgress.layerUnavailable, |
| 8669 | NodeServices.layer, |
| 8670 | ), |
| 8671 | ), |
| 8672 | ), |
| 8673 | ); |
| 8674 | |
| 8675 | it.effect( |
| 8676 | "falls back to fork-local thread/revert on paginated history when the source turn lacks a native reference", |
| 8677 | () => |
| 8678 | Effect.gen(function* () { |
| 8679 | const nativeThreadId = "fallback-source-thread"; |
| 8680 | const forkThreadId = "fallback-fork-thread"; |
| 8681 | const preamble = codexReplayPreamble({ |
| 8682 | nativeThreadId, |
| 8683 | nativeTurnId: "fallback-source-turn", |
| 8684 | prompt: "unused", |
| 8685 | }); |
| 8686 | const transcript = makeCodexReplayTranscript({ |
| 8687 | scenario: "codex-fork-paginated-fallback", |
| 8688 | entries: [ |
| 8689 | ...preamble.slice(0, 5), |
| 8690 | { |
| 8691 | type: "expect_outbound", |
| 8692 | label: "thread/fork", |
| 8693 | frame: { |
| 8694 | id: 3, |
| 8695 | method: "thread/fork", |
| 8696 | params: { threadId: nativeThreadId, config: CodexAdapterV2.CODEX_THREAD_CONFIG }, |
| 8697 | }, |
| 8698 | }, |
| 8699 | { |
| 8700 | type: "emit_inbound", |
| 8701 | label: "thread/fork", |
| 8702 | frame: { |
| 8703 | id: 3, |
| 8704 | result: codexReplayThreadResult({ |
| 8705 | nativeThreadId: forkThreadId, |
| 8706 | forkedFromId: nativeThreadId, |
| 8707 | }), |
| 8708 | }, |
| 8709 | }, |
| 8710 | { |
| 8711 | type: "expect_outbound", |
| 8712 | label: "thread/read", |
| 8713 | frame: { |
| 8714 | id: 4, |
| 8715 | method: "thread/read", |
| 8716 | params: { threadId: forkThreadId, includeTurns: false }, |
| 8717 | }, |
| 8718 | }, |
| 8719 | { |
| 8720 | type: "emit_inbound", |
| 8721 | label: "thread/read", |
| 8722 | frame: { |
| 8723 | id: 4, |
| 8724 | result: { thread: { id: forkThreadId, historyMode: "paginated" } }, |
| 8725 | }, |
| 8726 | }, |
| 8727 | { |
| 8728 | type: "expect_outbound", |
| 8729 | label: "thread/turns/list", |
| 8730 | frame: { |
| 8731 | id: 5, |
| 8732 | method: "thread/turns/list", |
| 8733 | params: { |
| 8734 | threadId: forkThreadId, |
| 8735 | cursor: null, |
| 8736 | limit: 1, |
| 8737 | sortDirection: "desc", |
| 8738 | itemsView: "summary", |
| 8739 | }, |
| 8740 | }, |
| 8741 | }, |
| 8742 | { |
| 8743 | type: "emit_inbound", |
| 8744 | label: "thread/turns/list", |
| 8745 | frame: { |
| 8746 | id: 5, |
| 8747 | result: { |
| 8748 | data: [{ id: "native-turn-second", items: [], status: "completed", error: null }], |
| 8749 | nextCursor: null, |
| 8750 | }, |
| 8751 | }, |
| 8752 | }, |
| 8753 | { |
| 8754 | type: "expect_outbound", |
| 8755 | label: "thread/revert", |
| 8756 | frame: { |
| 8757 | id: 6, |
| 8758 | method: "thread/revert", |
| 8759 | params: { threadId: forkThreadId, beforeTurnId: "native-turn-second" }, |
| 8760 | }, |
| 8761 | }, |
| 8762 | { |
| 8763 | type: "emit_inbound", |
| 8764 | label: "thread/revert", |
| 8765 | frame: { |
| 8766 | id: 6, |
| 8767 | result: codexReplayThreadResult({ |
| 8768 | nativeThreadId: forkThreadId, |
| 8769 | forkedFromId: null, |
| 8770 | }), |
| 8771 | }, |
| 8772 | }, |
| 8773 | ], |
| 8774 | }); |
| 8775 | const outbound: Array<string> = []; |
| 8776 | const harness = yield* makeCodexReplayHarness( |
| 8777 | transcript, |
| 8778 | () => Effect.void, |
| 8779 | (method) => |
| 8780 | Effect.sync(() => { |
| 8781 | outbound.push(method); |
| 8782 | }), |
| 8783 | ); |
| 8784 | const now = yield* DateTime.now; |
| 8785 | const firstTurn = codexReplaySourceTurn({ |
| 8786 | id: "provider-turn-first", |
| 8787 | ordinal: 1, |
| 8788 | nativeId: null, |
| 8789 | providerThreadId: harness.providerThread.id, |
| 8790 | now, |
| 8791 | }); |
| 8792 | const secondTurn = codexReplaySourceTurn({ |
| 8793 | id: "provider-turn-second", |
| 8794 | ordinal: 2, |
| 8795 | nativeId: "native-turn-second", |
| 8796 | providerThreadId: harness.providerThread.id, |
| 8797 | now, |
| 8798 | }); |
| 8799 | |
| 8800 | const forkedProviderThread = yield* harness.runtime.forkThread({ |
| 8801 | sourceProviderThread: harness.providerThread, |
| 8802 | sourceProviderTurns: [firstTurn, secondTurn], |
| 8803 | providerTurnId: firstTurn.id, |
| 8804 | targetThreadId: ThreadId.make("thread-fork-paginated-fallback-target"), |
| 8805 | }); |
| 8806 | |
| 8807 | assert.equal(forkedProviderThread.nativeThreadRef?.nativeId, forkThreadId); |
| 8808 | assert.notEqual(forkedProviderThread.id, harness.providerThread.id); |
| 8809 | assert.equal(forkedProviderThread.forkedFrom?.providerTurnId, firstTurn.id); |
| 8810 | assert.deepEqual(outbound.slice(-2), ["thread/turns/list", "thread/revert"]); |
| 8811 | }).pipe( |
| 8812 | Effect.scoped, |
| 8813 | Effect.provide( |
| 8814 | Layer.mergeAll( |
| 8815 | IdAllocator.layer, |
| 8816 | McpProviderSessions.layer, |
| 8817 | ScriptProgress.layerUnavailable, |
| 8818 | NodeServices.layer, |
| 8819 | ), |
| 8820 | ), |
| 8821 | ), |
| 8822 | ); |
| 8823 | |
| 8824 | it.effect( |
| 8825 | "fails honestly when a legacy fork cannot honor a source turn without a native reference", |
| 8826 | () => |
| 8827 | Effect.gen(function* () { |
| 8828 | const nativeThreadId = "legacy-fallback-source-thread"; |
| 8829 | const forkThreadId = "legacy-fallback-fork-thread"; |
| 8830 | const preamble = codexReplayPreamble({ |
| 8831 | nativeThreadId, |
| 8832 | nativeTurnId: "legacy-fallback-source-turn", |
| 8833 | prompt: "unused", |
| 8834 | }); |
| 8835 | const transcript = makeCodexReplayTranscript({ |
| 8836 | scenario: "codex-fork-legacy-fallback", |
| 8837 | entries: [ |
| 8838 | ...preamble.slice(0, 5), |
| 8839 | { |
| 8840 | type: "expect_outbound", |
| 8841 | label: "thread/fork", |
| 8842 | frame: { |
| 8843 | id: 3, |
| 8844 | method: "thread/fork", |
| 8845 | params: { threadId: nativeThreadId, config: CodexAdapterV2.CODEX_THREAD_CONFIG }, |
| 8846 | }, |
| 8847 | }, |
| 8848 | { |
| 8849 | type: "emit_inbound", |
| 8850 | label: "thread/fork", |
| 8851 | frame: { |
| 8852 | id: 3, |
| 8853 | result: codexReplayThreadResult({ |
| 8854 | nativeThreadId: forkThreadId, |
| 8855 | forkedFromId: nativeThreadId, |
| 8856 | }), |
| 8857 | }, |
| 8858 | }, |
| 8859 | { |
| 8860 | type: "expect_outbound", |
| 8861 | label: "thread/read", |
| 8862 | frame: { |
| 8863 | id: 4, |
| 8864 | method: "thread/read", |
| 8865 | params: { threadId: forkThreadId, includeTurns: false }, |
| 8866 | }, |
| 8867 | }, |
| 8868 | { |
| 8869 | type: "emit_inbound", |
| 8870 | label: "thread/read", |
| 8871 | frame: { |
| 8872 | id: 4, |
| 8873 | result: { thread: { id: forkThreadId, historyMode: "legacy" } }, |
| 8874 | }, |
| 8875 | }, |
| 8876 | ], |
| 8877 | }); |
| 8878 | const outbound: Array<string> = []; |
| 8879 | const harness = yield* makeCodexReplayHarness( |
| 8880 | transcript, |
| 8881 | () => Effect.void, |
| 8882 | (method) => |
| 8883 | Effect.sync(() => { |
| 8884 | outbound.push(method); |
| 8885 | }), |
| 8886 | ); |
| 8887 | const now = yield* DateTime.now; |
| 8888 | const firstTurn = codexReplaySourceTurn({ |
| 8889 | id: "provider-turn-first", |
| 8890 | ordinal: 1, |
| 8891 | nativeId: null, |
| 8892 | providerThreadId: harness.providerThread.id, |
| 8893 | now, |
| 8894 | }); |
| 8895 | const secondTurn = codexReplaySourceTurn({ |
| 8896 | id: "provider-turn-second", |
| 8897 | ordinal: 2, |
| 8898 | nativeId: "native-turn-second", |
| 8899 | providerThreadId: harness.providerThread.id, |
| 8900 | now, |
| 8901 | }); |
| 8902 | |
| 8903 | const error = yield* Effect.flip( |
| 8904 | harness.runtime.forkThread({ |
| 8905 | sourceProviderThread: harness.providerThread, |
| 8906 | sourceProviderTurns: [firstTurn, secondTurn], |
| 8907 | providerTurnId: firstTurn.id, |
| 8908 | targetThreadId: ThreadId.make("thread-fork-legacy-fallback-target"), |
| 8909 | }), |
| 8910 | ); |
| 8911 | |
| 8912 | assert.instanceOf(error, ProviderAdapter.ProviderAdapterForkThreadError); |
| 8913 | assert.include( |
| 8914 | errorCauseChainText(error), |
| 8915 | "legacy", |
| 8916 | "the missing-native-reference fallback must name the legacy limitation", |
| 8917 | ); |
| 8918 | assert.notInclude( |
| 8919 | outbound, |
| 8920 | "thread/rollback", |
| 8921 | "thread/rollback must not be sent to a legacy Codex fork", |
| 8922 | ); |
| 8923 | }).pipe( |
| 8924 | Effect.scoped, |
| 8925 | Effect.provide( |
| 8926 | Layer.mergeAll( |
| 8927 | IdAllocator.layer, |
| 8928 | McpProviderSessions.layer, |
| 8929 | ScriptProgress.layerUnavailable, |
| 8930 | NodeServices.layer, |
| 8931 | ), |
| 8932 | ), |
| 8933 | ), |
| 8934 | ); |
| 8935 | |
| 8936 | it.effect("propagates native thread/fork failures as typed fork errors", () => |
| 8937 | Effect.gen(function* () { |
| 8938 | const nativeThreadId = "fork-failure-source-thread"; |
| 8939 | const preamble = codexReplayPreamble({ |
| 8940 | nativeThreadId, |
| 8941 | nativeTurnId: "fork-failure-source-turn", |
| 8942 | prompt: "unused", |
| 8943 | }); |
| 8944 | const transcript = makeCodexReplayTranscript({ |
| 8945 | scenario: "codex-fork-request-failure", |
| 8946 | entries: [ |
| 8947 | ...preamble.slice(0, 5), |
| 8948 | { |
| 8949 | type: "expect_outbound", |
| 8950 | label: "thread/fork", |
| 8951 | frame: { |
| 8952 | id: 3, |
| 8953 | method: "thread/fork", |
| 8954 | params: { |
| 8955 | threadId: nativeThreadId, |
| 8956 | lastTurnId: "native-turn-first", |
| 8957 | config: CodexAdapterV2.CODEX_THREAD_CONFIG, |
| 8958 | }, |
| 8959 | }, |
| 8960 | }, |
| 8961 | { |
| 8962 | type: "emit_inbound", |
| 8963 | label: "thread/fork", |
| 8964 | frame: { id: 3, error: { code: -32000, message: "fork exploded" } }, |
| 8965 | }, |
| 8966 | ], |
| 8967 | }); |
| 8968 | const harness = yield* makeCodexReplayHarness(transcript); |
| 8969 | const now = yield* DateTime.now; |
| 8970 | const firstTurn = codexReplaySourceTurn({ |
| 8971 | id: "provider-turn-first", |
| 8972 | ordinal: 1, |
| 8973 | nativeId: "native-turn-first", |
| 8974 | providerThreadId: harness.providerThread.id, |
| 8975 | now, |
| 8976 | }); |
| 8977 | |
| 8978 | const error = yield* Effect.flip( |
| 8979 | harness.runtime.forkThread({ |
| 8980 | sourceProviderThread: harness.providerThread, |
| 8981 | sourceProviderTurns: [firstTurn], |
| 8982 | providerTurnId: firstTurn.id, |
| 8983 | targetThreadId: ThreadId.make("thread-fork-failure-target"), |
| 8984 | }), |
| 8985 | ); |
| 8986 | |
| 8987 | assert.instanceOf(error, ProviderAdapter.ProviderAdapterForkThreadError); |
| 8988 | assert.include(errorCauseChainText(error), "fork exploded"); |
| 8989 | }).pipe( |
| 8990 | Effect.scoped, |
| 8991 | Effect.provide( |
| 8992 | Layer.mergeAll( |
| 8993 | IdAllocator.layer, |
| 8994 | McpProviderSessions.layer, |
| 8995 | ScriptProgress.layerUnavailable, |
| 8996 | NodeServices.layer, |
| 8997 | ), |
| 8998 | ), |
| 8999 | ), |
| 9000 | ); |
| 9001 | |
| 9002 | describe("native goals", () => { |
| 9003 | const nativeThreadId = "goal-thread"; |
| 9004 | const objective = "Ship the feature"; |
| 9005 | const codexGoal = (status: string, tokensUsed: number) => ({ |
| 9006 | threadId: nativeThreadId, |
| 9007 | objective, |
| 9008 | status, |
| 9009 | tokenBudget: null, |
| 9010 | tokensUsed, |
| 9011 | timeUsedSeconds: 3, |
| 9012 | createdAt: 1782622440, |
| 9013 | updatedAt: 1782622450, |
| 9014 | }); |
| 9015 | const notification = ( |
| 9016 | label: string, |
| 9017 | method: string, |
| 9018 | params: unknown, |
| 9019 | ): CodexReplay.CodexAppServerReplayEntry => ({ |
| 9020 | type: "emit_inbound", |
| 9021 | label, |
| 9022 | frame: { method, params }, |
| 9023 | }); |
| 9024 | const request = (id: number, method: string, params: unknown, result: unknown) => |
| 9025 | [ |
| 9026 | { type: "expect_outbound", label: method, frame: { id, method, params } }, |
| 9027 | { type: "emit_inbound", label: method, frame: { id, result } }, |
| 9028 | ] satisfies Array<CodexReplay.CodexAppServerReplayEntry>; |
| 9029 | const withReplayRequestId = ( |
| 9030 | entries: ReadonlyArray<CodexReplay.CodexAppServerReplayEntry>, |
| 9031 | id: number, |
| 9032 | ) => |
| 9033 | entries.map((entry) => |
| 9034 | "frame" in entry && Predicate.isObject(entry.frame) && "id" in entry.frame |
| 9035 | ? { ...entry, frame: { ...entry.frame, id } } |
| 9036 | : entry, |
| 9037 | ); |
| 9038 | const sessionStart = codexReplayPreamble({ |
| 9039 | nativeThreadId, |
| 9040 | nativeTurnId: "unused", |
| 9041 | prompt: "unused", |
| 9042 | }).slice(0, 5); |
| 9043 | const goalTurnInput = ( |
| 9044 | harness: { |
| 9045 | readonly threadId: ThreadId; |
| 9046 | readonly providerThread: OrchestrationV2ProviderThread; |
| 9047 | }, |
| 9048 | text: string, |
| 9049 | ) => |
| 9050 | DateTime.now.pipe( |
| 9051 | Effect.map((now) => |
| 9052 | makeCodexTestTurnInput({ |
| 9053 | threadId: harness.threadId, |
| 9054 | providerThread: harness.providerThread, |
| 9055 | now, |
| 9056 | attemptId: RunAttemptId.make("goal-attempt"), |
| 9057 | text, |
| 9058 | }), |
| 9059 | ), |
| 9060 | ); |
| 9061 | const providerGoals = (events: ReadonlyArray<ProviderAdapter.ProviderAdapterV2Event>) => |
| 9062 | events.flatMap((event) => |
| 9063 | event.type === "provider_thread.updated" ? [event.providerThread.goal?.status ?? null] : [], |
| 9064 | ); |
| 9065 | |
| 9066 | it("parses /goal like the Codex TUI", () => { |
| 9067 | assert.deepEqual(CodexAdapterV2.parseCodexGoalCommand("/goal"), { type: "show" }); |
| 9068 | assert.deepEqual(CodexAdapterV2.parseCodexGoalCommand(" /goal Pause "), { type: "pause" }); |
| 9069 | assert.deepEqual(CodexAdapterV2.parseCodexGoalCommand("/goal resume"), { type: "resume" }); |
| 9070 | assert.deepEqual(CodexAdapterV2.parseCodexGoalCommand("/goal clear"), { type: "clear" }); |
| 9071 | assert.deepEqual(CodexAdapterV2.parseCodexGoalCommand("/goal fix the\nflaky tests"), { |
| 9072 | type: "set", |
| 9073 | objective: "fix the\nflaky tests", |
| 9074 | }); |
| 9075 | assert.isNull(CodexAdapterV2.parseCodexGoalCommand("/goals")); |
| 9076 | assert.isNull(CodexAdapterV2.parseCodexGoalCommand("set a /goal")); |
| 9077 | }); |
| 9078 | |
| 9079 | it.effect("keeps a /goal run open across the turns Codex continues on its own", () => |
| 9080 | Effect.gen(function* () { |
| 9081 | const transcript = makeCodexReplayTranscript({ |
| 9082 | scenario: "goal-continuation", |
| 9083 | entries: [ |
| 9084 | ...sessionStart, |
| 9085 | ...request(3, "thread/goal/get", { threadId: nativeThreadId }, { goal: null }), |
| 9086 | ...request( |
| 9087 | 4, |
| 9088 | "thread/goal/set", |
| 9089 | { threadId: nativeThreadId, objective, status: "paused" }, |
| 9090 | { goal: codexGoal("paused", 0) }, |
| 9091 | ), |
| 9092 | // The first goal turn carries this run's turn configuration. |
| 9093 | ...withReplayRequestId( |
| 9094 | codexReplayPreamble({ |
| 9095 | nativeThreadId, |
| 9096 | nativeTurnId: "goal-turn-1", |
| 9097 | prompt: objective, |
| 9098 | }).slice(5), |
| 9099 | 5, |
| 9100 | ), |
| 9101 | ...request( |
| 9102 | 6, |
| 9103 | "thread/goal/set", |
| 9104 | { threadId: nativeThreadId, status: "active" }, |
| 9105 | { goal: codexGoal("active", 0) }, |
| 9106 | ), |
| 9107 | notification("goal active", "thread/goal/updated", { |
| 9108 | threadId: nativeThreadId, |
| 9109 | turnId: "goal-turn-1", |
| 9110 | goal: codexGoal("active", 0), |
| 9111 | }), |
| 9112 | notification("first turn done", "turn/completed", { |
| 9113 | threadId: nativeThreadId, |
| 9114 | turn: makeCodexReplayTurn({ id: "goal-turn-1", status: "completed" }), |
| 9115 | }), |
| 9116 | notification("continuation", "turn/started", { |
| 9117 | threadId: nativeThreadId, |
| 9118 | turn: makeCodexReplayTurn({ id: "goal-turn-2", status: "inProgress" }), |
| 9119 | }), |
| 9120 | notification("goal complete", "thread/goal/updated", { |
| 9121 | threadId: nativeThreadId, |
| 9122 | turnId: "goal-turn-2", |
| 9123 | goal: codexGoal("complete", 1200), |
| 9124 | }), |
| 9125 | notification("continuation done", "turn/completed", { |
| 9126 | threadId: nativeThreadId, |
| 9127 | turn: makeCodexReplayTurn({ id: "goal-turn-2", status: "completed" }), |
| 9128 | }), |
| 9129 | ], |
| 9130 | }); |
| 9131 | const harness = yield* makeCodexReplayHarness(transcript); |
| 9132 | yield* harness.runtime.startTurn(yield* goalTurnInput(harness, `/goal ${objective}`)); |
| 9133 | yield* harness.firstTerminal; |
| 9134 | |
| 9135 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 9136 | const providerTurnId = (nativeTurnId: string) => |
| 9137 | idAllocator.derive.providerTurn({ |
| 9138 | driver: CodexAdapterV2.CODEX_DRIVER_KIND, |
| 9139 | nativeTurnId, |
| 9140 | }); |
| 9141 | assert.deepEqual( |
| 9142 | harness.terminalEvents().map((event) => [event.providerTurnId, event.status]), |
| 9143 | [[providerTurnId("goal-turn-2"), "completed"]], |
| 9144 | ); |
| 9145 | assert.deepEqual( |
| 9146 | harness.events.flatMap((event) => |
| 9147 | event.type === "provider_turn.updated" && event.providerTurn.status === "running" |
| 9148 | ? [ |
| 9149 | [ |
| 9150 | event.providerTurn.id, |
| 9151 | event.providerTurn.runAttemptId, |
| 9152 | event.providerTurn.ordinal, |
| 9153 | ], |
| 9154 | ] |
| 9155 | : [], |
| 9156 | ), |
| 9157 | [ |
| 9158 | [providerTurnId("goal-turn-1"), RunAttemptId.make("goal-attempt"), 1], |
| 9159 | [providerTurnId("goal-turn-2"), RunAttemptId.make("goal-attempt"), 2], |
| 9160 | ], |
| 9161 | ); |
| 9162 | assert.equal(providerGoals(harness.events).at(-1), "complete"); |
| 9163 | }).pipe( |
| 9164 | Effect.scoped, |
| 9165 | Effect.provide( |
| 9166 | Layer.mergeAll( |
| 9167 | IdAllocator.layer, |
| 9168 | McpProviderSessions.layer, |
| 9169 | ScriptProgress.layerUnavailable, |
| 9170 | NodeServices.layer, |
| 9171 | ), |
| 9172 | ), |
| 9173 | ), |
| 9174 | ); |
| 9175 | |
| 9176 | it.effect("settles /goal pause with a reply instead of a native turn", () => |
| 9177 | Effect.gen(function* () { |
| 9178 | const transcript = makeCodexReplayTranscript({ |
| 9179 | scenario: "goal-pause", |
| 9180 | entries: [ |
| 9181 | ...sessionStart, |
| 9182 | ...request( |
| 9183 | 3, |
| 9184 | "thread/goal/get", |
| 9185 | { threadId: nativeThreadId }, |
| 9186 | { goal: codexGoal("active", 10) }, |
| 9187 | ), |
| 9188 | ...request( |
| 9189 | 4, |
| 9190 | "thread/goal/set", |
| 9191 | { threadId: nativeThreadId, status: "paused" }, |
| 9192 | { goal: codexGoal("paused", 10) }, |
| 9193 | ), |
| 9194 | ], |
| 9195 | }); |
| 9196 | const harness = yield* makeCodexReplayHarness(transcript); |
| 9197 | yield* harness.runtime.startTurn(yield* goalTurnInput(harness, "/goal pause")); |
| 9198 | yield* harness.firstTerminal; |
| 9199 | |
| 9200 | assert.deepEqual( |
| 9201 | harness.terminalEvents().map((event) => event.status), |
| 9202 | ["completed"], |
| 9203 | ); |
| 9204 | const turns = harness.events.flatMap((event) => |
| 9205 | event.type === "provider_turn.updated" ? [event.providerTurn] : [], |
| 9206 | ); |
| 9207 | assert.isTrue(turns.every((turn) => turn.nativeTurnRef === null)); |
| 9208 | assert.include( |
| 9209 | harness.events.flatMap((event) => |
| 9210 | event.type === "message.updated" && event.message.role === "assistant" |
| 9211 | ? [event.message.text] |
| 9212 | : [], |
| 9213 | ), |
| 9214 | "Goal paused. Send /goal resume to continue.", |
| 9215 | ); |
| 9216 | assert.equal(providerGoals(harness.events).at(-1), "paused"); |
| 9217 | }).pipe( |
| 9218 | Effect.scoped, |
| 9219 | Effect.provide( |
| 9220 | Layer.mergeAll( |
| 9221 | IdAllocator.layer, |
| 9222 | McpProviderSessions.layer, |
| 9223 | ScriptProgress.layerUnavailable, |
| 9224 | NodeServices.layer, |
| 9225 | ), |
| 9226 | ), |
| 9227 | ), |
| 9228 | ); |
| 9229 | |
| 9230 | it.effect("settles a run held for the next goal turn when Stop arrives in between", () => |
| 9231 | Effect.gen(function* () { |
| 9232 | const prompt = "Keep going"; |
| 9233 | const transcript = makeCodexReplayTranscript({ |
| 9234 | scenario: "goal-stop-between-turns", |
| 9235 | entries: [ |
| 9236 | ...codexReplayPreamble({ nativeThreadId, nativeTurnId: "goal-turn", prompt }), |
| 9237 | notification("goal active", "thread/goal/updated", { |
| 9238 | threadId: nativeThreadId, |
| 9239 | turnId: "goal-turn", |
| 9240 | goal: codexGoal("active", 10), |
| 9241 | }), |
| 9242 | notification("turn done", "turn/completed", { |
| 9243 | threadId: nativeThreadId, |
| 9244 | turn: makeCodexReplayTurn({ id: "goal-turn", status: "completed" }), |
| 9245 | }), |
| 9246 | // Notifications run in order, so seeing this one proves the turn settled. |
| 9247 | notification("goal accounted", "thread/goal/updated", { |
| 9248 | threadId: nativeThreadId, |
| 9249 | turnId: null, |
| 9250 | goal: codexGoal("active", 11), |
| 9251 | }), |
| 9252 | ...request( |
| 9253 | 4, |
| 9254 | "thread/goal/set", |
| 9255 | { threadId: nativeThreadId, status: "paused" }, |
| 9256 | { goal: codexGoal("paused", 10) }, |
| 9257 | ), |
| 9258 | notification("goal paused", "thread/goal/updated", { |
| 9259 | threadId: nativeThreadId, |
| 9260 | turnId: null, |
| 9261 | goal: codexGoal("paused", 10), |
| 9262 | }), |
| 9263 | ], |
| 9264 | }); |
| 9265 | const requests: Array<string> = []; |
| 9266 | const harness = yield* makeCodexReplayHarness( |
| 9267 | transcript, |
| 9268 | () => Effect.void, |
| 9269 | (method) => |
| 9270 | Effect.sync(() => { |
| 9271 | requests.push(method); |
| 9272 | }), |
| 9273 | ); |
| 9274 | yield* harness.runtime.startTurn( |
| 9275 | makeCodexTestTurnInput({ |
| 9276 | threadId: harness.threadId, |
| 9277 | providerThread: harness.providerThread, |
| 9278 | now: yield* DateTime.now, |
| 9279 | attemptId: RunAttemptId.make("goal-hold-attempt"), |
| 9280 | text: prompt, |
| 9281 | }), |
| 9282 | ); |
| 9283 | const providerTurnId = (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ |
| 9284 | driver: CodexAdapterV2.CODEX_DRIVER_KIND, |
| 9285 | nativeTurnId: "goal-turn", |
| 9286 | }); |
| 9287 | const turnStatuses = () => |
| 9288 | harness.events.flatMap((event) => |
| 9289 | event.type === "provider_turn.updated" && event.providerTurn.id === providerTurnId |
| 9290 | ? [event.providerTurn.status] |
| 9291 | : [], |
| 9292 | ); |
| 9293 | yield* awaitUntil( |
| 9294 | () => |
| 9295 | harness.events.some( |
| 9296 | (event) => |
| 9297 | event.type === "provider_thread.updated" && |
| 9298 | event.providerThread.goal?.tokensUsed === 11, |
| 9299 | ), |
| 9300 | "the completed turn to be held", |
| 9301 | ); |
| 9302 | // The held turn keeps reading as running, so Stop still reaches the adapter. |
| 9303 | assert.deepEqual(turnStatuses(), ["running"]); |
| 9304 | assert.lengthOf(harness.terminalEvents(), 0); |
| 9305 | |
| 9306 | yield* harness.runtime.interruptTurn({ |
| 9307 | providerThread: harness.providerThread, |
| 9308 | providerTurnId, |
| 9309 | }); |
| 9310 | yield* harness.firstTerminal; |
| 9311 | |
| 9312 | assert.notInclude(requests, "turn/interrupt"); |
| 9313 | assert.deepEqual(turnStatuses(), ["running", "completed"]); |
| 9314 | assert.deepEqual( |
| 9315 | harness.terminalEvents().map((event) => event.status), |
| 9316 | ["interrupted"], |
| 9317 | ); |
| 9318 | }).pipe( |
| 9319 | Effect.scoped, |
| 9320 | Effect.provide( |
| 9321 | Layer.mergeAll( |
| 9322 | IdAllocator.layer, |
| 9323 | McpProviderSessions.layer, |
| 9324 | ScriptProgress.layerUnavailable, |
| 9325 | NodeServices.layer, |
| 9326 | ), |
| 9327 | ), |
| 9328 | ), |
| 9329 | ); |
| 9330 | |
| 9331 | it.effect("moves a Stop that names an earlier goal turn to the turn Codex continued with", () => |
| 9332 | Effect.gen(function* () { |
| 9333 | const prompt = "Keep going"; |
| 9334 | const transcript = makeCodexReplayTranscript({ |
| 9335 | scenario: "goal-stop-after-continuation", |
| 9336 | entries: [ |
| 9337 | ...codexReplayPreamble({ nativeThreadId, nativeTurnId: "goal-turn-a", prompt }), |
| 9338 | notification("goal active", "thread/goal/updated", { |
| 9339 | threadId: nativeThreadId, |
| 9340 | turnId: "goal-turn-a", |
| 9341 | goal: codexGoal("active", 10), |
| 9342 | }), |
| 9343 | notification("first turn done", "turn/completed", { |
| 9344 | threadId: nativeThreadId, |
| 9345 | turn: makeCodexReplayTurn({ id: "goal-turn-a", status: "completed" }), |
| 9346 | }), |
| 9347 | notification("continuation", "turn/started", { |
| 9348 | threadId: nativeThreadId, |
| 9349 | turn: makeCodexReplayTurn({ id: "goal-turn-b", status: "inProgress" }), |
| 9350 | }), |
| 9351 | notification("goal accounted", "thread/goal/updated", { |
| 9352 | threadId: nativeThreadId, |
| 9353 | turnId: "goal-turn-b", |
| 9354 | goal: codexGoal("active", 11), |
| 9355 | }), |
| 9356 | ...request( |
| 9357 | 4, |
| 9358 | "thread/goal/set", |
| 9359 | { threadId: nativeThreadId, status: "paused" }, |
| 9360 | { goal: codexGoal("paused", 11) }, |
| 9361 | ), |
| 9362 | ...request( |
| 9363 | 5, |
| 9364 | "turn/interrupt", |
| 9365 | { threadId: nativeThreadId, turnId: "goal-turn-b" }, |
| 9366 | {}, |
| 9367 | ), |
| 9368 | notification("continuation interrupted", "turn/completed", { |
| 9369 | threadId: nativeThreadId, |
| 9370 | turn: makeCodexReplayTurn({ id: "goal-turn-b", status: "interrupted" }), |
| 9371 | }), |
| 9372 | ], |
| 9373 | }); |
| 9374 | const harness = yield* makeCodexReplayHarness(transcript); |
| 9375 | yield* harness.runtime.startTurn( |
| 9376 | makeCodexTestTurnInput({ |
| 9377 | threadId: harness.threadId, |
| 9378 | providerThread: harness.providerThread, |
| 9379 | now: yield* DateTime.now, |
| 9380 | attemptId: RunAttemptId.make("goal-successor-attempt"), |
| 9381 | text: prompt, |
| 9382 | }), |
| 9383 | ); |
| 9384 | yield* awaitUntil( |
| 9385 | () => |
| 9386 | harness.events.some( |
| 9387 | (event) => |
| 9388 | event.type === "provider_thread.updated" && |
| 9389 | event.providerThread.goal?.tokensUsed === 11, |
| 9390 | ), |
| 9391 | "the continuation to be adopted", |
| 9392 | ); |
| 9393 | // The projection may still name the first turn when Stop is dispatched. |
| 9394 | yield* harness.runtime.interruptTurn({ |
| 9395 | providerThread: harness.providerThread, |
| 9396 | providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ |
| 9397 | driver: CodexAdapterV2.CODEX_DRIVER_KIND, |
| 9398 | nativeTurnId: "goal-turn-a", |
| 9399 | }), |
| 9400 | requestRuntimeRestart: true, |
| 9401 | }); |
| 9402 | yield* harness.firstTerminal; |
| 9403 | assert.deepEqual( |
| 9404 | harness.terminalEvents().map((event) => event.status), |
| 9405 | ["interrupted"], |
| 9406 | ); |
| 9407 | }).pipe( |
| 9408 | Effect.scoped, |
| 9409 | Effect.provide( |
| 9410 | Layer.mergeAll( |
| 9411 | IdAllocator.layer, |
| 9412 | McpProviderSessions.layer, |
| 9413 | ScriptProgress.layerUnavailable, |
| 9414 | NodeServices.layer, |
| 9415 | ), |
| 9416 | ), |
| 9417 | ), |
| 9418 | ); |
| 9419 | |
| 9420 | it.effect("stops a held goal run's background command when Stop arrives between turns", () => |
| 9421 | Effect.gen(function* () { |
| 9422 | const prompt = "Start the server"; |
| 9423 | const command = "node server.js"; |
| 9424 | const commandItem = (status: "inProgress" | "completed") => ({ |
| 9425 | type: "commandExecution", |
| 9426 | id: "goal-bg-command", |
| 9427 | command, |
| 9428 | cwd: "/workspace", |
| 9429 | processId: "4242", |
| 9430 | source: "unifiedExecStartup", |
| 9431 | status, |
| 9432 | commandActions: [{ type: "unknown", command }], |
| 9433 | aggregatedOutput: null, |
| 9434 | exitCode: null, |
| 9435 | durationMs: null, |
| 9436 | }); |
| 9437 | const transcript = makeCodexReplayTranscript({ |
| 9438 | scenario: "goal-stop-held-background", |
| 9439 | entries: [ |
| 9440 | ...codexReplayPreamble({ nativeThreadId, nativeTurnId: "goal-turn", prompt }), |
| 9441 | notification("command started", "item/started", { |
| 9442 | item: commandItem("inProgress"), |
| 9443 | threadId: nativeThreadId, |
| 9444 | turnId: "goal-turn", |
| 9445 | startedAtMs: 1782622440500, |
| 9446 | }), |
| 9447 | notification("goal active", "thread/goal/updated", { |
| 9448 | threadId: nativeThreadId, |
| 9449 | turnId: "goal-turn", |
| 9450 | goal: codexGoal("active", 10), |
| 9451 | }), |
| 9452 | notification("turn done", "turn/completed", { |
| 9453 | threadId: nativeThreadId, |
| 9454 | turn: makeCodexReplayTurn({ id: "goal-turn", status: "completed" }), |
| 9455 | }), |
| 9456 | notification("goal accounted", "thread/goal/updated", { |
| 9457 | threadId: nativeThreadId, |
| 9458 | turnId: null, |
| 9459 | goal: codexGoal("active", 11), |
| 9460 | }), |
| 9461 | ...request( |
| 9462 | 4, |
| 9463 | "thread/goal/set", |
| 9464 | { threadId: nativeThreadId, status: "paused" }, |
| 9465 | { goal: codexGoal("paused", 11) }, |
| 9466 | ), |
| 9467 | ...request( |
| 9468 | 5, |
| 9469 | "thread/backgroundTerminals/terminate", |
| 9470 | { threadId: nativeThreadId, processId: "4242" }, |
| 9471 | { terminated: true }, |
| 9472 | ), |
| 9473 | ], |
| 9474 | }); |
| 9475 | const harness = yield* makeCodexReplayHarness(transcript); |
| 9476 | yield* harness.runtime.startTurn( |
| 9477 | makeCodexTestTurnInput({ |
| 9478 | threadId: harness.threadId, |
| 9479 | providerThread: harness.providerThread, |
| 9480 | now: yield* DateTime.now, |
| 9481 | attemptId: RunAttemptId.make("goal-held-background-attempt"), |
| 9482 | text: prompt, |
| 9483 | }), |
| 9484 | ); |
| 9485 | yield* awaitUntil( |
| 9486 | () => |
| 9487 | harness.events.some( |
| 9488 | (event) => |
| 9489 | event.type === "provider_thread.updated" && |
| 9490 | event.providerThread.goal?.tokensUsed === 11, |
| 9491 | ), |
| 9492 | "the completed turn to be held", |
| 9493 | ); |
| 9494 | yield* harness.runtime.interruptTurn({ |
| 9495 | providerThread: harness.providerThread, |
| 9496 | providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ |
| 9497 | driver: CodexAdapterV2.CODEX_DRIVER_KIND, |
| 9498 | nativeTurnId: "goal-turn", |
| 9499 | }), |
| 9500 | requestRuntimeRestart: true, |
| 9501 | }); |
| 9502 | yield* harness.firstTerminal; |
| 9503 | // The settled-turn path terminates the process (the replay expects that |
| 9504 | // request) before it marks the command interrupted. |
| 9505 | assert.deepEqual( |
| 9506 | harness.terminalEvents().map((event) => event.status), |
| 9507 | ["interrupted"], |
| 9508 | ); |
| 9509 | yield* awaitUntil( |
| 9510 | () => |
| 9511 | harness.events.some( |
| 9512 | (event) => |
| 9513 | event.type === "turn_item.updated" && |
| 9514 | event.turnItem.type === "command_execution" && |
| 9515 | event.turnItem.status === "interrupted", |
| 9516 | ), |
| 9517 | "the background command to be interrupted", |
| 9518 | ); |
| 9519 | }).pipe( |
| 9520 | Effect.scoped, |
| 9521 | Effect.provide( |
| 9522 | Layer.mergeAll( |
| 9523 | IdAllocator.layer, |
| 9524 | McpProviderSessions.layer, |
| 9525 | ScriptProgress.layerUnavailable, |
| 9526 | NodeServices.layer, |
| 9527 | ), |
| 9528 | ), |
| 9529 | ), |
| 9530 | ); |
| 9531 | |
| 9532 | it.effect("moves Stop to a continuation Codex starts while the goal pause is in flight", () => |
| 9533 | Effect.gen(function* () { |
| 9534 | const prompt = "Keep going"; |
| 9535 | const transcript = makeCodexReplayTranscript({ |
| 9536 | scenario: "goal-stop-races-continuation", |
| 9537 | entries: [ |
| 9538 | ...codexReplayPreamble({ nativeThreadId, nativeTurnId: "goal-turn-a", prompt }), |
| 9539 | notification("goal active", "thread/goal/updated", { |
| 9540 | threadId: nativeThreadId, |
| 9541 | turnId: "goal-turn-a", |
| 9542 | goal: codexGoal("active", 10), |
| 9543 | }), |
| 9544 | { |
| 9545 | type: "expect_outbound", |
| 9546 | label: "thread/goal/set", |
| 9547 | frame: { |
| 9548 | id: 4, |
| 9549 | method: "thread/goal/set", |
| 9550 | params: { threadId: nativeThreadId, status: "paused" }, |
| 9551 | }, |
| 9552 | }, |
| 9553 | notification("first turn done", "turn/completed", { |
| 9554 | threadId: nativeThreadId, |
| 9555 | turn: makeCodexReplayTurn({ id: "goal-turn-a", status: "completed" }), |
| 9556 | }), |
| 9557 | notification("continuation", "turn/started", { |
| 9558 | threadId: nativeThreadId, |
| 9559 | turn: makeCodexReplayTurn({ id: "goal-turn-b", status: "inProgress" }), |
| 9560 | }), |
| 9561 | { |
| 9562 | type: "emit_inbound", |
| 9563 | label: "thread/goal/set", |
| 9564 | frame: { id: 4, result: { goal: codexGoal("paused", 11) } }, |
| 9565 | }, |
| 9566 | ...request( |
| 9567 | 5, |
| 9568 | "turn/interrupt", |
| 9569 | { threadId: nativeThreadId, turnId: "goal-turn-b" }, |
| 9570 | {}, |
| 9571 | ), |
| 9572 | notification("continuation interrupted", "turn/completed", { |
| 9573 | threadId: nativeThreadId, |
| 9574 | turn: makeCodexReplayTurn({ id: "goal-turn-b", status: "interrupted" }), |
| 9575 | }), |
| 9576 | ], |
| 9577 | }); |
| 9578 | const harness = yield* makeCodexReplayHarness(transcript); |
| 9579 | yield* harness.runtime.startTurn( |
| 9580 | makeCodexTestTurnInput({ |
| 9581 | threadId: harness.threadId, |
| 9582 | providerThread: harness.providerThread, |
| 9583 | now: yield* DateTime.now, |
| 9584 | attemptId: RunAttemptId.make("goal-stop-race-attempt"), |
| 9585 | text: prompt, |
| 9586 | }), |
| 9587 | ); |
| 9588 | yield* awaitUntil( |
| 9589 | () => |
| 9590 | harness.events.some( |
| 9591 | (event) => |
| 9592 | event.type === "provider_thread.updated" && |
| 9593 | event.providerThread.goal?.status === "active", |
| 9594 | ), |
| 9595 | "the goal to be active", |
| 9596 | ); |
| 9597 | // Stop names the first turn while it still runs. |
| 9598 | yield* harness.runtime.interruptTurn({ |
| 9599 | providerThread: harness.providerThread, |
| 9600 | providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ |
| 9601 | driver: CodexAdapterV2.CODEX_DRIVER_KIND, |
| 9602 | nativeTurnId: "goal-turn-a", |
| 9603 | }), |
| 9604 | requestRuntimeRestart: true, |
| 9605 | }); |
| 9606 | yield* harness.firstTerminal; |
| 9607 | assert.deepEqual( |
| 9608 | harness.terminalEvents().map((event) => event.status), |
| 9609 | ["interrupted"], |
| 9610 | ); |
| 9611 | }).pipe( |
| 9612 | Effect.scoped, |
| 9613 | Effect.provide( |
| 9614 | Layer.mergeAll( |
| 9615 | IdAllocator.layer, |
| 9616 | McpProviderSessions.layer, |
| 9617 | ScriptProgress.layerUnavailable, |
| 9618 | NodeServices.layer, |
| 9619 | ), |
| 9620 | ), |
| 9621 | ), |
| 9622 | ); |
| 9623 | |
| 9624 | it.effect("stops a subagent an earlier goal turn left running when Stop settles the run", () => |
| 9625 | Effect.gen(function* () { |
| 9626 | const prompt = "Delegate the audit"; |
| 9627 | const childThreadId = "goal-child-thread"; |
| 9628 | const childTurnId = "goal-child-turn"; |
| 9629 | const transcript = makeCodexReplayTranscript({ |
| 9630 | scenario: "goal-stop-held-subagent", |
| 9631 | entries: [ |
| 9632 | ...codexReplayPreamble({ nativeThreadId, nativeTurnId: "goal-turn", prompt }), |
| 9633 | notification("subagent started", "item/completed", { |
| 9634 | item: { |
| 9635 | type: "subAgentActivity", |
| 9636 | id: "goal-subagent-call", |
| 9637 | kind: "started", |
| 9638 | agentThreadId: childThreadId, |
| 9639 | agentPath: "/root/audit", |
| 9640 | }, |
| 9641 | threadId: nativeThreadId, |
| 9642 | turnId: "goal-turn", |
| 9643 | completedAtMs: 1782622441000, |
| 9644 | }), |
| 9645 | notification("child turn", "turn/started", { |
| 9646 | threadId: childThreadId, |
| 9647 | turn: makeCodexReplayTurn({ id: childTurnId, status: "inProgress" }), |
| 9648 | }), |
| 9649 | notification("goal active", "thread/goal/updated", { |
| 9650 | threadId: nativeThreadId, |
| 9651 | turnId: "goal-turn", |
| 9652 | goal: codexGoal("active", 10), |
| 9653 | }), |
| 9654 | notification("turn done", "turn/completed", { |
| 9655 | threadId: nativeThreadId, |
| 9656 | turn: makeCodexReplayTurn({ id: "goal-turn", status: "completed" }), |
| 9657 | }), |
| 9658 | notification("goal accounted", "thread/goal/updated", { |
| 9659 | threadId: nativeThreadId, |
| 9660 | turnId: null, |
| 9661 | goal: codexGoal("active", 11), |
| 9662 | }), |
| 9663 | ...request( |
| 9664 | 4, |
| 9665 | "thread/goal/set", |
| 9666 | { threadId: nativeThreadId, status: "paused" }, |
| 9667 | { goal: codexGoal("paused", 11) }, |
| 9668 | ), |
| 9669 | ...request(5, "turn/interrupt", { threadId: childThreadId, turnId: childTurnId }, {}), |
| 9670 | notification("child interrupted", "turn/completed", { |
| 9671 | threadId: childThreadId, |
| 9672 | turn: makeCodexReplayTurn({ id: childTurnId, status: "interrupted" }), |
| 9673 | }), |
| 9674 | ], |
| 9675 | }); |
| 9676 | const harness = yield* makeCodexReplayHarness(transcript); |
| 9677 | yield* harness.runtime.startTurn( |
| 9678 | makeCodexTestTurnInput({ |
| 9679 | threadId: harness.threadId, |
| 9680 | providerThread: harness.providerThread, |
| 9681 | now: yield* DateTime.now, |
| 9682 | attemptId: RunAttemptId.make("goal-held-subagent-attempt"), |
| 9683 | text: prompt, |
| 9684 | }), |
| 9685 | ); |
| 9686 | yield* awaitUntil( |
| 9687 | () => |
| 9688 | harness.events.some( |
| 9689 | (event) => |
| 9690 | event.type === "provider_thread.updated" && |
| 9691 | event.providerThread.goal?.tokensUsed === 11, |
| 9692 | ), |
| 9693 | "the completed turn to be held", |
| 9694 | ); |
| 9695 | yield* harness.runtime.interruptTurn({ |
| 9696 | providerThread: harness.providerThread, |
| 9697 | providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ |
| 9698 | driver: CodexAdapterV2.CODEX_DRIVER_KIND, |
| 9699 | nativeTurnId: "goal-turn", |
| 9700 | }), |
| 9701 | requestRuntimeRestart: true, |
| 9702 | }); |
| 9703 | yield* harness.firstTerminal; |
| 9704 | assert.deepEqual( |
| 9705 | harness.terminalEvents().map((event) => event.status), |
| 9706 | ["interrupted"], |
| 9707 | ); |
| 9708 | yield* awaitUntil( |
| 9709 | () => |
| 9710 | harness.events.some( |
| 9711 | (event) => |
| 9712 | event.type === "provider_turn.updated" && |
| 9713 | event.providerTurn.nativeTurnRef?.nativeId === childTurnId && |
| 9714 | event.providerTurn.status === "interrupted", |
| 9715 | ), |
| 9716 | "the subagent turn to be interrupted", |
| 9717 | ); |
| 9718 | }).pipe( |
| 9719 | Effect.scoped, |
| 9720 | Effect.provide( |
| 9721 | Layer.mergeAll( |
| 9722 | IdAllocator.layer, |
| 9723 | McpProviderSessions.layer, |
| 9724 | ScriptProgress.layerUnavailable, |
| 9725 | NodeServices.layer, |
| 9726 | ), |
| 9727 | ), |
| 9728 | ), |
| 9729 | ); |
| 9730 | |
| 9731 | it.effect("stops an earlier goal turn's subagent when the run settles during the pause", () => |
| 9732 | Effect.gen(function* () { |
| 9733 | const prompt = "Delegate the audit"; |
| 9734 | const childThreadId = "goal-race-child-thread"; |
| 9735 | const childTurnId = "goal-race-child-turn"; |
| 9736 | const transcript = makeCodexReplayTranscript({ |
| 9737 | scenario: "goal-stop-run-settles-during-pause", |
| 9738 | entries: [ |
| 9739 | ...codexReplayPreamble({ nativeThreadId, nativeTurnId: "goal-turn-a", prompt }), |
| 9740 | notification("subagent started", "item/completed", { |
| 9741 | item: { |
| 9742 | type: "subAgentActivity", |
| 9743 | id: "goal-race-subagent-call", |
| 9744 | kind: "started", |
| 9745 | agentThreadId: childThreadId, |
| 9746 | agentPath: "/root/audit", |
| 9747 | }, |
| 9748 | threadId: nativeThreadId, |
| 9749 | turnId: "goal-turn-a", |
| 9750 | completedAtMs: 1782622441000, |
| 9751 | }), |
| 9752 | notification("child turn", "turn/started", { |
| 9753 | threadId: childThreadId, |
| 9754 | turn: makeCodexReplayTurn({ id: childTurnId, status: "inProgress" }), |
| 9755 | }), |
| 9756 | notification("goal active", "thread/goal/updated", { |
| 9757 | threadId: nativeThreadId, |
| 9758 | turnId: "goal-turn-a", |
| 9759 | goal: codexGoal("active", 10), |
| 9760 | }), |
| 9761 | notification("first turn done", "turn/completed", { |
| 9762 | threadId: nativeThreadId, |
| 9763 | turn: makeCodexReplayTurn({ id: "goal-turn-a", status: "completed" }), |
| 9764 | }), |
| 9765 | notification("continuation", "turn/started", { |
| 9766 | threadId: nativeThreadId, |
| 9767 | turn: makeCodexReplayTurn({ id: "goal-turn-b", status: "inProgress" }), |
| 9768 | }), |
| 9769 | notification("goal accounted", "thread/goal/updated", { |
| 9770 | threadId: nativeThreadId, |
| 9771 | turnId: "goal-turn-b", |
| 9772 | goal: codexGoal("active", 11), |
| 9773 | }), |
| 9774 | { |
| 9775 | type: "expect_outbound", |
| 9776 | label: "thread/goal/set", |
| 9777 | frame: { |
| 9778 | id: 4, |
| 9779 | method: "thread/goal/set", |
| 9780 | params: { threadId: nativeThreadId, status: "paused" }, |
| 9781 | }, |
| 9782 | }, |
| 9783 | notification("goal paused", "thread/goal/updated", { |
| 9784 | threadId: nativeThreadId, |
| 9785 | turnId: "goal-turn-b", |
| 9786 | goal: codexGoal("paused", 11), |
| 9787 | }), |
| 9788 | notification("continuation done", "turn/completed", { |
| 9789 | threadId: nativeThreadId, |
| 9790 | turn: makeCodexReplayTurn({ id: "goal-turn-b", status: "completed" }), |
| 9791 | }), |
| 9792 | { |
| 9793 | type: "emit_inbound", |
| 9794 | label: "thread/goal/set", |
| 9795 | frame: { id: 4, result: { goal: codexGoal("paused", 11) } }, |
| 9796 | }, |
| 9797 | ...request(5, "turn/interrupt", { threadId: childThreadId, turnId: childTurnId }, {}), |
| 9798 | notification("child interrupted", "turn/completed", { |
| 9799 | threadId: childThreadId, |
| 9800 | turn: makeCodexReplayTurn({ id: childTurnId, status: "interrupted" }), |
| 9801 | }), |
| 9802 | ], |
| 9803 | }); |
| 9804 | const harness = yield* makeCodexReplayHarness(transcript); |
| 9805 | yield* harness.runtime.startTurn( |
| 9806 | makeCodexTestTurnInput({ |
| 9807 | threadId: harness.threadId, |
| 9808 | providerThread: harness.providerThread, |
| 9809 | now: yield* DateTime.now, |
| 9810 | attemptId: RunAttemptId.make("goal-settles-during-pause-attempt"), |
| 9811 | text: prompt, |
| 9812 | }), |
| 9813 | ); |
| 9814 | yield* awaitUntil( |
| 9815 | () => |
| 9816 | harness.events.some( |
| 9817 | (event) => |
| 9818 | event.type === "provider_thread.updated" && |
| 9819 | event.providerThread.goal?.tokensUsed === 11, |
| 9820 | ), |
| 9821 | "the continuation to be adopted", |
| 9822 | ); |
| 9823 | yield* harness.runtime.interruptTurn({ |
| 9824 | providerThread: harness.providerThread, |
| 9825 | providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ |
| 9826 | driver: CodexAdapterV2.CODEX_DRIVER_KIND, |
| 9827 | nativeTurnId: "goal-turn-b", |
| 9828 | }), |
| 9829 | requestRuntimeRestart: true, |
| 9830 | }); |
| 9831 | yield* awaitUntil( |
| 9832 | () => |
| 9833 | harness.events.some( |
| 9834 | (event) => |
| 9835 | event.type === "provider_turn.updated" && |
| 9836 | event.providerTurn.nativeTurnRef?.nativeId === childTurnId && |
| 9837 | event.providerTurn.status === "interrupted", |
| 9838 | ), |
| 9839 | "the subagent turn to be interrupted", |
| 9840 | ); |
| 9841 | }).pipe( |
| 9842 | Effect.scoped, |
| 9843 | Effect.provide( |
| 9844 | Layer.mergeAll( |
| 9845 | IdAllocator.layer, |
| 9846 | McpProviderSessions.layer, |
| 9847 | ScriptProgress.layerUnavailable, |
| 9848 | NodeServices.layer, |
| 9849 | ), |
| 9850 | ), |
| 9851 | ), |
| 9852 | ); |
| 9853 | |
| 9854 | it.effect("pauses an active goal before Stop interrupts its turn", () => |
| 9855 | Effect.gen(function* () { |
| 9856 | const prompt = "Keep going"; |
| 9857 | const transcript = makeCodexReplayTranscript({ |
| 9858 | scenario: "goal-stop", |
| 9859 | entries: [ |
| 9860 | ...codexReplayPreamble({ nativeThreadId, nativeTurnId: "goal-turn", prompt }), |
| 9861 | notification("goal active", "thread/goal/updated", { |
| 9862 | threadId: nativeThreadId, |
| 9863 | turnId: "goal-turn", |
| 9864 | goal: codexGoal("active", 10), |
| 9865 | }), |
| 9866 | ...request( |
| 9867 | 4, |
| 9868 | "thread/goal/set", |
| 9869 | { threadId: nativeThreadId, status: "paused" }, |
| 9870 | { goal: codexGoal("paused", 10) }, |
| 9871 | ), |
| 9872 | ...request(5, "turn/interrupt", { threadId: nativeThreadId, turnId: "goal-turn" }, {}), |
| 9873 | notification("interrupted", "turn/completed", { |
| 9874 | threadId: nativeThreadId, |
| 9875 | turn: makeCodexReplayTurn({ id: "goal-turn", status: "interrupted" }), |
| 9876 | }), |
| 9877 | ], |
| 9878 | }); |
| 9879 | const requests: Array<string> = []; |
| 9880 | const harness = yield* makeCodexReplayHarness( |
| 9881 | transcript, |
| 9882 | () => Effect.void, |
| 9883 | (method) => |
| 9884 | Effect.sync(() => { |
| 9885 | requests.push(method); |
| 9886 | }), |
| 9887 | ); |
| 9888 | yield* harness.runtime.startTurn( |
| 9889 | makeCodexTestTurnInput({ |
| 9890 | threadId: harness.threadId, |
| 9891 | providerThread: harness.providerThread, |
| 9892 | now: yield* DateTime.now, |
| 9893 | attemptId: RunAttemptId.make("goal-stop-attempt"), |
| 9894 | text: prompt, |
| 9895 | }), |
| 9896 | ); |
| 9897 | yield* awaitUntil( |
| 9898 | () => providerGoals(harness.events).includes("active"), |
| 9899 | "the active goal to reach the provider thread", |
| 9900 | ); |
| 9901 | yield* harness.runtime.interruptTurn({ |
| 9902 | providerThread: harness.providerThread, |
| 9903 | providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ |
| 9904 | driver: CodexAdapterV2.CODEX_DRIVER_KIND, |
| 9905 | nativeTurnId: "goal-turn", |
| 9906 | }), |
| 9907 | }); |
| 9908 | yield* harness.firstTerminal; |
| 9909 | |
| 9910 | assert.deepEqual(requests.slice(-2), ["thread/goal/set", "turn/interrupt"]); |
| 9911 | assert.deepEqual( |
| 9912 | harness.terminalEvents().map((event) => event.status), |
| 9913 | ["interrupted"], |
| 9914 | ); |
| 9915 | }).pipe( |
| 9916 | Effect.scoped, |
| 9917 | Effect.provide( |
| 9918 | Layer.mergeAll( |
| 9919 | IdAllocator.layer, |
| 9920 | McpProviderSessions.layer, |
| 9921 | ScriptProgress.layerUnavailable, |
| 9922 | NodeServices.layer, |
| 9923 | ), |
| 9924 | ), |
| 9925 | ), |
| 9926 | ); |
| 9927 | }); |
| 9928 | }); |