1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
2import * as NodeOS from "node:os";
3
4import { historyResponseItems } from "@t3tools/provider-core/server/handoffBudget";
5import {
6 makeProviderTextDeltaCoalescer,
7 type ProviderTextDeltaUpdate,
8} from "./ProviderTextDeltaCoalescer.ts";
9import * as NodeServices from "@effect/platform-node/NodeServices";
10import {
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";
33import { assert, describe, it } from "@effect/vitest";
34import * as HostProcess from "@t3tools/shared/HostProcess";
35import { SpawnExecutableResolution } from "@t3tools/shared/shell";
36import * as CodexClient from "effect-codex-app-server/client";
37import * as CodexError from "effect-codex-app-server/errors";
38import * as CodexReplay from "effect-codex-app-server/replay";
39import * as DateTime from "effect/DateTime";
40import * as Deferred from "effect/Deferred";
41import * as Crypto from "effect/Crypto";
42import * as Effect from "effect/Effect";
43import * as Predicate from "effect/Predicate";
44import * as FileSystem from "effect/FileSystem";
45import { MCP_APP_OUTPUT_KEY, readMcpAppReference } from "@t3tools/shared/mcpApp";
46import { resolveAttachmentPathById } from "../../attachmentStore.ts";
47import * as Fiber from "effect/Fiber";
48import * as Layer from "effect/Layer";
49import * as Path from "effect/Path";
50import * as PlatformError from "effect/PlatformError";
51import * as Ref from "effect/Ref";
52import * as Schema from "effect/Schema";
53import * as Stream from "effect/Stream";
54import { TestClock } from "effect/testing";
55import { ChildProcess, ChildProcessSpawner } from "effect/process";
56
57import packageJson from "../../../package.json" with { type: "json" };
58import * as ServerConfig from "../../config.ts";
59import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
60import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
61import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
62import * as EffectWorker from "../EffectWorker.ts";
63import * as Orchestrator from "../Orchestrator.ts";
64import * as ProviderReplayHarness from "../testkit/ProviderReplayHarness.ts";
65import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
66import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests";
67import * as CodexAdapterV2 from "./CodexAdapterV2.ts";
68import { makeReplayServerConfig, withCodexReplayChildMetadata } from "./CodexAdapterV2.testkit.ts";
69import * as CodexAdapterV2Testkit from "./CodexAdapterV2.testkit.ts";
70
71const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
72const replayTranscriptJson = Schema.fromJsonString(CodexReplay.CodexAppServerReplayTranscript);
73const encodeReplayTranscriptJson = Schema.encodeEffect(replayTranscriptJson);
74const decodeReplayTranscriptJson = Schema.decodeUnknownEffect(replayTranscriptJson);
75const encodeStringJson = Schema.encodeEffect(Schema.fromJsonString(Schema.String));
76
77describe("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
104describe("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
161describe("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
196describe("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
397describe("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
641describe("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
842describe("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
1080describe("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
1243describe("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
1314describe("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
1467describe("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
1487describe("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
1524const DEFAULT_CODEX_SETTINGS = Schema.decodeSync(CodexSettings)({});
1525const CODEX_TEST_MODEL_SELECTION = {
1526 instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID,
1527 model: "gpt-5.4",
1528} satisfies ModelSelection;
1529const CODEX_TEST_RUNTIME_POLICY = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
1530 runtimeMode: "full-access",
1531 interactionMode: "default",
1532 cwd: "/workspace",
1533});
1534
1535function 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
1569function 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
1597function 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
1615function 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
1743function 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
1756function 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
1765describe("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
2067describe("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});