1import * as NodeOS from "node:os";
2
3import { CODEX_THREAD_CONFIG } from "../src/orchestration-v2/Adapters/CodexAdapterV2.ts";
4import { revertCodexThread } from "../src/provider/CodexThreadRevert.ts";
5import { buildCodexInitializeParams } from "../src/provider/CodexProvider.ts";
6import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
7import * as NodeServices from "@effect/platform-node/NodeServices";
8import * as HostProcess from "@t3tools/shared/HostProcess";
9import { resolveSpawnCommand } from "@t3tools/shared/shell";
10import * as Console from "effect/Console";
11import * as Deferred from "effect/Deferred";
12import * as FileSystem from "effect/FileSystem";
13import * as Layer from "effect/Layer";
14import * as Path from "effect/Path";
15import * as PlatformError from "effect/PlatformError";
16import * as Effect from "effect/Effect";
17import { ChildProcess, ChildProcessSpawner } from "effect/process";
18
19import * as CodexClient from "effect-codex-app-server/client";
20import type * as CodexSchema from "effect-codex-app-server/schema";
21
22import {
23 MESSAGE_STEERING_INITIAL_PROMPT,
24 MESSAGE_STEERING_STEER_PROMPT,
25 MULTI_TURN_FIRST_PROMPT,
26 MULTI_TURN_SECOND_PROMPT,
27 PLAN_QUESTIONS_PROMPT,
28 PROPOSED_PLAN_PROMPT,
29 PROVIDER_THREAD_RESUME_FIRST_PROMPT,
30 PROVIDER_THREAD_RESUME_SECOND_PROMPT,
31 SIMPLE_PROMPT,
32 SUBAGENT_CONTINUE_PARENT_PROMPT,
33 SUBAGENT_CONTINUE_PROMPT,
34 SUBAGENT_PROMPT,
35 SUBAGENT_V2_APPROVAL_PROMPT,
36 SUBAGENT_V2_PROMPT,
37 SUBAGENT_V2_NESTED_APPROVAL_PROMPT,
38 SUBAGENT_V2_NESTED_PROMPT,
39 THREAD_ROLLBACK_AFTER_PROMPT,
40 THREAD_ROLLBACK_FIRST_PROMPT,
41 THREAD_ROLLBACK_SECOND_PROMPT,
42 THREAD_FORK_NATIVE_CONTINUE_FIRST_PROMPT,
43 THREAD_FORK_NATIVE_CONTINUE_SECOND_PROMPT,
44 THREAD_FORK_NATIVE_CONTINUE_SOURCE_PROMPT,
45 THREAD_FORK_NATIVE_SIBLINGS_FIRST_PROMPT,
46 THREAD_FORK_NATIVE_SIBLINGS_SECOND_PROMPT,
47 THREAD_FORK_NATIVE_SIBLINGS_SOURCE_PROMPT,
48 THREAD_MERGE_BACK_FORK_PROMPT,
49 THREAD_MERGE_BACK_HANDOFF_PROMPT,
50 THREAD_MERGE_BACK_RECALL_PROMPT,
51 THREAD_MERGE_BACK_SIBLINGS_FIRST_FORK_PROMPT,
52 THREAD_MERGE_BACK_SIBLINGS_FIRST_HANDOFF_PROMPT,
53 THREAD_MERGE_BACK_SIBLINGS_RECALL_PROMPT,
54 THREAD_MERGE_BACK_SIBLINGS_SECOND_FORK_PROMPT,
55 THREAD_MERGE_BACK_SIBLINGS_SECOND_HANDOFF_PROMPT,
56 THREAD_MERGE_BACK_SIBLINGS_SOURCE_PROMPT,
57 THREAD_MERGE_BACK_SOURCE_PROMPT,
58 TODO_LIST_PROMPT,
59 TOOL_CALL_WRITE_PROMPT,
60 TURN_INTERRUPT_MID_TOOL_PROMPT,
61 TURN_INTERRUPT_PROMPT,
62 WEB_SEARCH_PROMPT,
63} from "../src/orchestration-v2/testkit/fixtures/shared.ts";
64import { codexReplayRecordingOutputRecords } from "./codexReplayRecordingRecords.ts";
65import { makeReplayRecorderDeferredRegistry } from "./replayRecorderDeferredRegistry.ts";
66
67const CODEX_REPLAY_PLAN_MODE_DEVELOPER_INSTRUCTIONS =
68 process.env.T3_CODEX_REPLAY_PLAN_DEVELOPER_INSTRUCTIONS ??
69 "You are in Plan mode. Prefer request_user_input for clarifying questions. When presenting a complete plan, wrap it in <proposed_plan> and </proposed_plan>.";
70// Match the V2 adapter's initialize, thread and turn frames so recordings replay
71// against it without hand edits.
72const CODEX_CLIENT_CAPABILITIES = {
73 experimentalApi: true,
74 optOutNotificationMethods: ["turn/diff/updated"],
75 extensions: { "io.modelcontextprotocol/ui": { mimeTypes: ["text/html;profile=mcp-app"] } },
76} as const;
77const CODEX_REPLAY_DEFAULT_MODEL = "gpt-6-luna";
78const CODEX_REPLAY_MODEL_OVERRIDE = readArgValue("--model") ?? process.env.T3_CODEX_REPLAY_MODEL;
79
80const SCENARIO_NAMES = [
81 "simple",
82 "tool_call_read_only_on_request",
83 "tool_call_workspace_never",
84 "tool_call_restricted_granular",
85 "subagent",
86 "subagent_continue",
87 "subagent_v2",
88 "subagent_v2_approval",
89 "subagent_v2_nested",
90 "subagent_v2_nested_approval",
91 "multi_turn",
92 "queued_turn",
93 "provider_thread_resume",
94 "todo_list",
95 "web_search",
96 "plan_questions",
97 "proposed_plan",
98 "message_steering",
99 "turn_interrupt",
100 "turn_interrupt_mid_tool",
101 "thread_rollback",
102 "thread_rollback_after_restart",
103 "thread_rollback_to_stopped_turn",
104 "thread_fork_native_continue",
105 "thread_fork_native_siblings",
106 "thread_merge_back_continue",
107 "thread_merge_back_siblings",
108] as const;
109
110type ScenarioName = (typeof SCENARIO_NAMES)[number];
111type TurnStartParams = CodexSchema.ClientRequestParamsByMethod["turn/start"] & {
112 readonly collaborationMode?: CodexSchema.V2TurnStartParams__CollaborationMode;
113};
114type TurnStartInput = TurnStartParams["input"];
115type TurnStartResponse = CodexSchema.ClientRequestResponsesByMethod["turn/start"];
116type SandboxPolicy = NonNullable<TurnStartParams["sandboxPolicy"]>;
117type ApprovalPolicy = NonNullable<TurnStartParams["approvalPolicy"]>;
118
119interface ReplayRun {
120 readonly name: string;
121 readonly prompt?: string;
122 readonly description: string;
123 readonly steps: ReadonlyArray<ReplayStep>;
124 readonly turnDefaults?: Omit<TurnStartParams, "input" | "threadId">;
125 readonly interactionMode?: "plan";
126 /**
127 * Recorder-only `thread/start` config merged over the adapter's. Its keys go in the
128 * transcript header as `recorderThreadConfigKeys`, which replay leaves out of matching.
129 */
130 readonly threadConfig?: Readonly<Record<string, boolean | number | string>>;
131}
132
133type ReplayStep =
134 | {
135 readonly type: "turn";
136 readonly label: string;
137 readonly prompt: string;
138 readonly thread?: string;
139 readonly turnOverrides?: Omit<TurnStartParams, "input" | "threadId">;
140 }
141 | {
142 readonly type: "steeredTurn";
143 readonly label: string;
144 readonly prompt: string;
145 readonly steer: string;
146 readonly turnOverrides?: Omit<TurnStartParams, "input" | "threadId">;
147 }
148 | {
149 readonly type: "interruptedTurn";
150 readonly label: string;
151 readonly prompt: string;
152 readonly interruptAfterMs?: number;
153 readonly interruptAfterCommandExecutionStarted?: boolean;
154 readonly turnOverrides?: Omit<TurnStartParams, "input" | "threadId">;
155 }
156 | {
157 readonly type: "rollback";
158 readonly label: string;
159 readonly numTurns: number;
160 }
161 | {
162 readonly type: "fork";
163 readonly label: string;
164 readonly from?: string;
165 readonly as?: string;
166 };
167type TurnReplayStep = Exclude<
168 ReplayStep,
169 { readonly type: "rollback" } | { readonly type: "fork" }
170>;
171
172interface ReplayScenario {
173 readonly name: ScenarioName;
174 readonly fileName: `${ScenarioName}.ndjson`;
175 readonly description: string;
176 /** Model this scenario needs, e.g. one that still runs multi-agent v1. `--model` wins. */
177 readonly model?: string;
178 readonly runs: ReadonlyArray<ReplayRun>;
179}
180
181interface Recorder {
182 readonly path: string;
183 readonly setVersion: (version: string) => Effect.Effect<void>;
184 readonly writeRecord: (
185 record: Record<string, unknown>,
186 ) => Effect.Effect<void, PlatformError.PlatformError>;
187 readonly flush: () => Effect.Effect<void, PlatformError.PlatformError>;
188}
189
190function readArgValue(name: string): string | undefined {
191 const args = process.argv.slice(2);
192 const index = args.indexOf(name);
193 return index >= 0 ? args[index + 1] : undefined;
194}
195
196function readArgValues(name: string): ReadonlyArray<string> {
197 const args = process.argv.slice(2);
198 return args.flatMap((arg, index) => (arg === name && args[index + 1] ? [args[index + 1]!] : []));
199}
200
201function defaultTranscriptUrl(scenario: ReplayScenario): URL {
202 return new URL(
203 `../src/orchestration-v2/testkit/fixtures/${scenario.name}/codex_transcript.ndjson`,
204 import.meta.url,
205 );
206}
207
208function isRecord(value: unknown): value is Record<string, unknown> {
209 return typeof value === "object" && value !== null;
210}
211
212function parseScenarios(): ReadonlyArray<ScenarioName> {
213 const rawValues = [
214 ...readArgValues("--scenario"),
215 ...(process.env.T3_CODEX_REPLAY_SCENARIOS ? [process.env.T3_CODEX_REPLAY_SCENARIOS] : []),
216 ];
217 const requested = rawValues.length > 0 ? rawValues : ["simple"];
218 const names = requested.flatMap((value) =>
219 value
220 .split(",")
221 .map((part) => part.trim())
222 .filter((part) => part.length > 0),
223 );
224
225 if (names.includes("all")) {
226 return SCENARIO_NAMES;
227 }
228
229 const expandedNames = names.flatMap((name) =>
230 name === "tool_call"
231 ? [
232 "tool_call_read_only_on_request",
233 "tool_call_workspace_never",
234 "tool_call_restricted_granular",
235 ]
236 : [name],
237 );
238
239 const invalid = expandedNames.filter(
240 (name): name is string => !SCENARIO_NAMES.includes(name as ScenarioName),
241 );
242 if (invalid.length > 0) {
243 throw new Error(`Unknown scenario(s): ${invalid.join(", ")}`);
244 }
245
246 return [...new Set(expandedNames)] as ReadonlyArray<ScenarioName>;
247}
248
249function classifyJsonRpcPayload(payload: unknown): string {
250 if (!isRecord(payload)) {
251 return "unknown";
252 }
253 if (typeof payload.method === "string" && "id" in payload) {
254 return "request";
255 }
256 if (typeof payload.method === "string") {
257 return "notification";
258 }
259 if ("id" in payload && "error" in payload) {
260 return "error_response";
261 }
262 if ("id" in payload && "result" in payload) {
263 return "response";
264 }
265 return "unknown";
266}
267
268function protocolMethod(payload: unknown): string | undefined {
269 return isRecord(payload) && typeof payload.method === "string" ? payload.method : undefined;
270}
271
272function protocolId(payload: unknown): string | number | undefined {
273 if (!isRecord(payload)) {
274 return undefined;
275 }
276 return typeof payload.id === "string" || typeof payload.id === "number" ? payload.id : undefined;
277}
278
279function protocolResult(payload: unknown): unknown {
280 return isRecord(payload) && "result" in payload ? payload.result : undefined;
281}
282
283function inferCodexVersion(payload: unknown): string | undefined {
284 const result = protocolResult(payload);
285 if (!isRecord(result)) {
286 return undefined;
287 }
288
289 if (typeof result.userAgent === "string") {
290 const match = result.userAgent.match(/\/([0-9][^\s)]*)/u);
291 if (match?.[1]) {
292 return match[1];
293 }
294 }
295
296 const thread = result.thread;
297 return isRecord(thread) && typeof thread.cliVersion === "string" ? thread.cliVersion : undefined;
298}
299
300function turnInput(prompt: string): TurnStartInput {
301 return [{ type: "text", text: prompt }];
302}
303
304function getTurnId(response: TurnStartResponse): string {
305 return response.turn.id;
306}
307
308function readOnlyFullAccessSandbox(): SandboxPolicy {
309 return {
310 networkAccess: false,
311 type: "readOnly",
312 };
313}
314
315function readOnlyRestrictedSandbox(): SandboxPolicy {
316 return {
317 networkAccess: false,
318 type: "readOnly",
319 };
320}
321
322function workspaceWriteSandbox(): SandboxPolicy {
323 return {
324 networkAccess: false,
325 type: "workspaceWrite",
326 writableRoots: [],
327 };
328}
329
330function granularApprovalPolicy(): ApprovalPolicy {
331 return {
332 granular: {
333 mcp_elicitations: true,
334 request_permissions: true,
335 rules: true,
336 sandbox_approval: true,
337 skill_approval: true,
338 },
339 };
340}
341
342function scenarioModel(scenario: ReplayScenario): string {
343 return CODEX_REPLAY_MODEL_OVERRIDE ?? scenario.model ?? CODEX_REPLAY_DEFAULT_MODEL;
344}
345
346function planCollaborationMode(model: string): CodexSchema.V2TurnStartParams__CollaborationMode {
347 return {
348 mode: "plan",
349 settings: {
350 developer_instructions: CODEX_REPLAY_PLAN_MODE_DEVELOPER_INSTRUCTIONS,
351 model,
352 reasoning_effort: "medium",
353 },
354 };
355}
356
357function scenarios(): ReadonlyArray<ReplayScenario> {
358 return [
359 {
360 name: "simple",
361 fileName: "simple.ndjson",
362 description: "One thread and one turn with a deterministic text-only response.",
363 runs: [
364 {
365 name: "simple",
366 description: "Single text-only turn.",
367 prompt: SIMPLE_PROMPT,
368 steps: [{ type: "turn", label: "simple", prompt: SIMPLE_PROMPT }],
369 },
370 ],
371 },
372 {
373 name: "tool_call_read_only_on_request",
374 fileName: "tool_call_read_only_on_request.ndjson",
375 description:
376 "Write a small fixture file with read-only full filesystem visibility and on-request approvals.",
377 // gpt-6-luna declines to attempt the write under a read-only sandbox; gpt-6-sol tries it.
378 model: "gpt-6-sol",
379 runs: [
380 {
381 name: "read-only-on-request",
382 description:
383 "Write action under read-only full filesystem visibility with on-request approvals.",
384 prompt: TOOL_CALL_WRITE_PROMPT,
385 turnDefaults: {
386 approvalPolicy: "on-request",
387 sandboxPolicy: readOnlyFullAccessSandbox(),
388 },
389 steps: [{ type: "turn", label: "write-fixture-file", prompt: TOOL_CALL_WRITE_PROMPT }],
390 },
391 ],
392 },
393 {
394 name: "tool_call_workspace_never",
395 fileName: "tool_call_workspace_never.ndjson",
396 description:
397 "Write a small fixture file with workspace-write sandbox policy and never approvals.",
398 runs: [
399 {
400 name: "workspace-never",
401 description:
402 "Write action under workspace-write policy with never approvals for baseline no-prompt behavior.",
403 prompt: TOOL_CALL_WRITE_PROMPT,
404 turnDefaults: {
405 approvalPolicy: "never",
406 sandboxPolicy: workspaceWriteSandbox(),
407 },
408 steps: [{ type: "turn", label: "write-fixture-file", prompt: TOOL_CALL_WRITE_PROMPT }],
409 },
410 ],
411 },
412 {
413 name: "tool_call_restricted_granular",
414 fileName: "tool_call_restricted_granular.ndjson",
415 description:
416 "Write a small fixture file with restricted read access and granular approval flags enabled.",
417 // gpt-6 models write through the shell; gpt-5.6-terra uses apply_patch, which is what
418 // raises the file-change approval this scenario covers.
419 model: "gpt-5.6-terra",
420 runs: [
421 {
422 name: "restricted-granular",
423 description:
424 "Write action under restricted read access with granular approval flags enabled, intended to capture permission request flows when Codex escalates.",
425 prompt: TOOL_CALL_WRITE_PROMPT,
426 turnDefaults: {
427 approvalPolicy: granularApprovalPolicy(),
428 sandboxPolicy: readOnlyRestrictedSandbox(),
429 },
430 steps: [
431 {
432 type: "turn",
433 label: "write-fixture-file",
434 prompt: TOOL_CALL_WRITE_PROMPT,
435 },
436 ],
437 },
438 ],
439 },
440 {
441 name: "subagent",
442 fileName: "subagent.ndjson",
443 description: "One root turn that asks Codex to spawn two collab agents.",
444 // gpt-5.6-luna still runs multi-agent v1, the only live source of collabAgentToolCall.
445 model: "gpt-5.6-luna",
446 runs: [
447 {
448 name: "two-subagents",
449 description: "Root turn asks for two subagents reading different files.",
450 prompt: SUBAGENT_PROMPT,
451 // Without this, children read the recording user's own skills into the fixture.
452 threadConfig: { "skills.include_instructions": false },
453 turnDefaults: {
454 approvalPolicy: "on-request",
455 sandboxPolicy: readOnlyFullAccessSandbox(),
456 },
457 steps: [{ type: "turn", label: "spawn-two-subagents", prompt: SUBAGENT_PROMPT }],
458 },
459 ],
460 },
461 {
462 name: "subagent_v2",
463 fileName: "subagent_v2.ndjson",
464 description:
465 "One root turn on a multi-agent v2 model that spawns one native subagent and waits for it.",
466 runs: [
467 {
468 name: "spawn-v2-subagent",
469 description:
470 "The default model runs multi-agent v2, so Codex emits subAgentActivity items.",
471 steps: [{ type: "turn", label: "spawn-v2-subagent", prompt: SUBAGENT_V2_PROMPT }],
472 },
473 ],
474 },
475 {
476 name: "subagent_v2_approval",
477 fileName: "subagent_v2_approval.ndjson",
478 description:
479 "One root turn in approval-required mode whose multi-agent v2 subagent runs a command that needs approval.",
480 runs: [
481 {
482 name: "spawn-v2-subagent-needing-approval",
483 description:
484 "The child inherits the root's approval policy, so its write asks the client for approval on the child's native thread and turn.",
485 // The adapter's approval-required turn defaults.
486 turnDefaults: {
487 approvalPolicy: "untrusted",
488 sandboxPolicy: { type: "readOnly" },
489 },
490 steps: [
491 {
492 type: "turn",
493 label: "spawn-v2-subagent-needing-approval",
494 prompt: SUBAGENT_V2_APPROVAL_PROMPT,
495 },
496 ],
497 },
498 ],
499 },
500 {
501 name: "subagent_v2_nested",
502 fileName: "subagent_v2_nested.ndjson",
503 description:
504 "One root turn on a multi-agent v2 model whose subagent spawns a subagent that spawns a leaf.",
505 runs: [
506 {
507 name: "spawn-nested-v2-subagents",
508 description: "Multi-agent v2; depth 3 lets each child spawn again.",
509 threadConfig: { "agents.max_depth": 3 },
510 steps: [
511 {
512 type: "turn",
513 label: "spawn-nested-v2-subagents",
514 prompt: SUBAGENT_V2_NESTED_PROMPT,
515 },
516 ],
517 },
518 ],
519 },
520 {
521 name: "subagent_v2_nested_approval",
522 fileName: "subagent_v2_nested_approval.ndjson",
523 description:
524 "One root turn in approval-required mode whose multi-agent v2 subagent spawns a subagent that runs a command that needs approval.",
525 runs: [
526 {
527 name: "spawn-nested-v2-subagent-needing-approval",
528 description:
529 "Depth 2 lets the first child spawn once. The grandchild inherits the root's approval policy, so its write asks the client for approval on the grandchild's native thread and turn.",
530 threadConfig: { "agents.max_depth": 2 },
531 turnDefaults: {
532 approvalPolicy: "untrusted",
533 sandboxPolicy: { type: "readOnly" },
534 },
535 steps: [
536 {
537 type: "turn",
538 label: "spawn-nested-v2-subagent-needing-approval",
539 prompt: SUBAGENT_V2_NESTED_APPROVAL_PROMPT,
540 },
541 ],
542 },
543 ],
544 },
545 {
546 name: "subagent_continue",
547 fileName: "subagent_continue.ndjson",
548 description:
549 "A root turn spawns one native Codex subagent, then a later root turn resumes and messages the same child thread.",
550 // gpt-5.6-luna still runs multi-agent v1, the only live source of collabAgentToolCall.
551 model: "gpt-5.6-luna",
552 runs: [
553 {
554 name: "continued-subagent",
555 description:
556 "The second root turn asks Codex to continue the subagent created by the first root turn.",
557 steps: [
558 {
559 type: "turn",
560 label: "spawn-subagent",
561 prompt: SUBAGENT_CONTINUE_PROMPT,
562 },
563 {
564 type: "turn",
565 label: "continue-subagent",
566 prompt: SUBAGENT_CONTINUE_PARENT_PROMPT,
567 },
568 ],
569 },
570 ],
571 },
572 {
573 name: "multi_turn",
574 fileName: "multi_turn.ndjson",
575 description: "One thread with two sequential user turns.",
576 runs: [
577 {
578 name: "two-turns-same-thread",
579 description: "Second turn starts after the first root turn completes.",
580 steps: [
581 {
582 type: "turn",
583 label: "first",
584 prompt: MULTI_TURN_FIRST_PROMPT,
585 },
586 {
587 type: "turn",
588 label: "second",
589 prompt: MULTI_TURN_SECOND_PROMPT,
590 },
591 ],
592 },
593 ],
594 },
595 {
596 name: "queued_turn",
597 fileName: "queued_turn.ndjson",
598 description: "A second user turn queued behind an active turn starts after it completes.",
599 runs: [
600 {
601 name: "queued-second-turn",
602 description:
603 "The orchestrator holds the queued message until the first turn completes, so the provider sees two sequential turns.",
604 steps: [
605 { type: "turn", label: "first", prompt: MULTI_TURN_FIRST_PROMPT },
606 { type: "turn", label: "queued", prompt: MULTI_TURN_SECOND_PROMPT },
607 ],
608 },
609 ],
610 },
611 {
612 name: "provider_thread_resume",
613 fileName: "provider_thread_resume.ndjson",
614 description:
615 "One provider-native thread is started, completed, then resumed by thread id in a fresh app-server session.",
616 runs: [
617 {
618 name: "resume-provider-thread",
619 description:
620 "First turn completes, the app-server runtime is restarted, thread/resume loads the existing provider thread, then a second turn completes.",
621 steps: [
622 {
623 type: "turn",
624 label: "first-before-resume",
625 prompt: PROVIDER_THREAD_RESUME_FIRST_PROMPT,
626 },
627 {
628 type: "turn",
629 label: "second-after-resume",
630 prompt: PROVIDER_THREAD_RESUME_SECOND_PROMPT,
631 },
632 ],
633 },
634 ],
635 },
636 {
637 name: "todo_list",
638 fileName: "todo_list.ndjson",
639 description: "One turn that asks Codex to emit progress through update_plan.",
640 runs: [
641 {
642 name: "todo-list",
643 description: "Default-mode turn that should surface turn/plan/updated notifications.",
644 prompt: TODO_LIST_PROMPT,
645 turnDefaults: {
646 approvalPolicy: "never",
647 sandboxPolicy: readOnlyFullAccessSandbox(),
648 },
649 steps: [{ type: "turn", label: "todo-list", prompt: TODO_LIST_PROMPT }],
650 },
651 ],
652 },
653 {
654 name: "web_search",
655 fileName: "web_search.ndjson",
656 description:
657 "One turn with a native Codex webSearch item that should become normalized progress.",
658 runs: [
659 {
660 name: "web-search",
661 description: "Default-mode turn that should surface a webSearch item.",
662 steps: [{ type: "turn", label: "web-search", prompt: WEB_SEARCH_PROMPT }],
663 },
664 ],
665 },
666 {
667 name: "plan_questions",
668 fileName: "plan_questions.ndjson",
669 description: "One plan-mode turn that asks a structured clarifying question.",
670 runs: [
671 {
672 name: "plan-questions",
673 description: "Plan-mode turn intended to surface item/tool/requestUserInput.",
674 prompt: PLAN_QUESTIONS_PROMPT,
675 interactionMode: "plan",
676 turnDefaults: {
677 approvalPolicy: "never",
678 sandboxPolicy: readOnlyFullAccessSandbox(),
679 },
680 steps: [{ type: "turn", label: "plan-questions", prompt: PLAN_QUESTIONS_PROMPT }],
681 },
682 ],
683 },
684 {
685 name: "proposed_plan",
686 fileName: "proposed_plan.ndjson",
687 description: "One plan-mode turn that emits a proposed plan document.",
688 runs: [
689 {
690 name: "proposed-plan",
691 description:
692 "Plan-mode turn intended to surface item/plan/delta and completed plan item.",
693 prompt: PROPOSED_PLAN_PROMPT,
694 interactionMode: "plan",
695 turnDefaults: {
696 approvalPolicy: "never",
697 sandboxPolicy: readOnlyFullAccessSandbox(),
698 },
699 steps: [{ type: "turn", label: "proposed-plan", prompt: PROPOSED_PLAN_PROMPT }],
700 },
701 ],
702 },
703 {
704 name: "message_steering",
705 fileName: "message_steering.ndjson",
706 description: "One active turn receives an immediate turn/steer request.",
707 runs: [
708 {
709 name: "immediate-steer",
710 description: "Start a turn, then immediately steer the active root turn.",
711 steps: [
712 {
713 type: "steeredTurn",
714 label: "steered",
715 prompt: MESSAGE_STEERING_INITIAL_PROMPT,
716 steer: MESSAGE_STEERING_STEER_PROMPT,
717 },
718 ],
719 },
720 ],
721 },
722 {
723 name: "turn_interrupt",
724 fileName: "turn_interrupt.ndjson",
725 description: "One active turn is interrupted before it finishes naturally.",
726 runs: [
727 {
728 name: "interrupt-active-turn",
729 description: "Start a long-running turn, then send turn/interrupt.",
730 prompt: TURN_INTERRUPT_PROMPT,
731 turnDefaults: {
732 approvalPolicy: "never",
733 sandboxPolicy: workspaceWriteSandbox(),
734 },
735 steps: [
736 {
737 type: "interruptedTurn",
738 label: "interrupt-active-turn",
739 prompt: TURN_INTERRUPT_PROMPT,
740 interruptAfterMs: 1_500,
741 },
742 ],
743 },
744 ],
745 },
746 {
747 name: "turn_interrupt_mid_tool",
748 fileName: "turn_interrupt_mid_tool.ndjson",
749 description:
750 "One active turn is interrupted after Codex has already executed a local command.",
751 runs: [
752 {
753 name: "interrupt-after-command-execution",
754 description: "Start a turn, wait for command execution, then send turn/interrupt.",
755 prompt: TURN_INTERRUPT_MID_TOOL_PROMPT,
756 turnDefaults: {
757 approvalPolicy: "never",
758 sandboxPolicy: workspaceWriteSandbox(),
759 },
760 steps: [
761 {
762 type: "interruptedTurn",
763 label: "interrupt-after-command-execution",
764 prompt: TURN_INTERRUPT_MID_TOOL_PROMPT,
765 interruptAfterCommandExecutionStarted: true,
766 },
767 ],
768 },
769 ],
770 },
771 {
772 name: "thread_rollback",
773 fileName: "thread_rollback.ndjson",
774 description:
775 "One thread completes two turns, rolls back the most recent turn, then starts another turn.",
776 runs: [
777 {
778 name: "rollback-one-turn",
779 description:
780 "Two completed turns, thread/revert before the second turn, then a post-rollback turn.",
781 steps: [
782 {
783 type: "turn",
784 label: "first-before-rollback",
785 prompt: THREAD_ROLLBACK_FIRST_PROMPT,
786 },
787 {
788 type: "turn",
789 label: "second-before-rollback",
790 prompt: THREAD_ROLLBACK_SECOND_PROMPT,
791 },
792 {
793 type: "rollback",
794 label: "rollback-latest-turn",
795 numTurns: 1,
796 },
797 {
798 type: "turn",
799 label: "post-rollback",
800 prompt: THREAD_ROLLBACK_AFTER_PROMPT,
801 },
802 ],
803 },
804 ],
805 },
806 {
807 name: "thread_rollback_after_restart",
808 fileName: "thread_rollback_after_restart.ndjson",
809 description:
810 "One thread completes two turns, the app-server restarts, then the thread rolls back its latest turn and starts another turn.",
811 runs: [
812 {
813 name: "rollback-after-restart",
814 description:
815 "Two completed turns, a fresh app-server that has not loaded the thread, thread/revert before the second turn, then a post-rollback turn.",
816 steps: [
817 {
818 type: "turn",
819 label: "first-before-rollback",
820 prompt: THREAD_ROLLBACK_FIRST_PROMPT,
821 },
822 {
823 type: "turn",
824 label: "second-before-rollback",
825 prompt: THREAD_ROLLBACK_SECOND_PROMPT,
826 },
827 {
828 type: "rollback",
829 label: "rollback-latest-turn",
830 numTurns: 1,
831 },
832 {
833 type: "turn",
834 label: "post-rollback",
835 prompt: THREAD_ROLLBACK_AFTER_PROMPT,
836 },
837 ],
838 },
839 ],
840 },
841 {
842 name: "thread_rollback_to_stopped_turn",
843 fileName: "thread_rollback_to_stopped_turn.ndjson",
844 description:
845 "One completed turn, one turn stopped mid-tool, one completed turn, thread/revert back to the stopped turn, then a post-rollback turn.",
846 runs: [
847 {
848 name: "rollback-to-stopped-turn",
849 description:
850 "Edit the message after a stopped turn: revert the turn after it and keep the stopped turn.",
851 turnDefaults: {
852 approvalPolicy: "never",
853 sandboxPolicy: workspaceWriteSandbox(),
854 },
855 steps: [
856 {
857 type: "turn",
858 label: "first-before-rollback",
859 prompt: THREAD_ROLLBACK_FIRST_PROMPT,
860 },
861 {
862 type: "interruptedTurn",
863 label: "stopped-mid-tool",
864 prompt: TURN_INTERRUPT_MID_TOOL_PROMPT,
865 interruptAfterCommandExecutionStarted: true,
866 },
867 {
868 type: "turn",
869 label: "second-before-rollback",
870 prompt: THREAD_ROLLBACK_SECOND_PROMPT,
871 },
872 {
873 type: "rollback",
874 label: "rollback-to-stopped-turn",
875 numTurns: 1,
876 },
877 {
878 type: "turn",
879 label: "post-rollback",
880 prompt: THREAD_ROLLBACK_AFTER_PROMPT,
881 },
882 ],
883 },
884 ],
885 },
886 {
887 name: "thread_fork_native_continue",
888 fileName: "thread_fork_native_continue.ndjson",
889 description:
890 "One source marker turn, a native thread fork, a fork-local marker turn, and a recall turn that requires both contexts.",
891 runs: [
892 {
893 name: "continue-native-fork",
894 description:
895 "Proves the native fork inherits source context and retains its own first turn into a second fork-local turn.",
896 steps: [
897 {
898 type: "turn",
899 label: "source",
900 prompt: THREAD_FORK_NATIVE_CONTINUE_SOURCE_PROMPT,
901 },
902 {
903 type: "fork",
904 label: "fork-source",
905 },
906 {
907 type: "turn",
908 label: "fork-first-delta",
909 prompt: THREAD_FORK_NATIVE_CONTINUE_FIRST_PROMPT,
910 },
911 {
912 type: "turn",
913 label: "fork-second-delta",
914 prompt: THREAD_FORK_NATIVE_CONTINUE_SECOND_PROMPT,
915 },
916 ],
917 },
918 ],
919 },
920 {
921 name: "thread_fork_native_siblings",
922 fileName: "thread_fork_native_siblings.ndjson",
923 description:
924 "One source marker turn and two independent native forks that each recall the source plus only their own fork marker.",
925 // gpt-6-luna often answers the recall turn with an earlier acknowledgement instead of
926 // the markers; gpt-6-sol recalls them.
927 model: "gpt-6-sol",
928 runs: [
929 {
930 name: "native-sibling-forks",
931 description:
932 "Creates two native forks from the same source provider thread and proves their contexts remain isolated.",
933 steps: [
934 {
935 type: "turn",
936 label: "source",
937 prompt: THREAD_FORK_NATIVE_SIBLINGS_SOURCE_PROMPT,
938 thread: "source",
939 },
940 {
941 type: "fork",
942 label: "fork-first",
943 from: "source",
944 as: "first",
945 },
946 {
947 type: "turn",
948 label: "first-recall",
949 prompt: THREAD_FORK_NATIVE_SIBLINGS_FIRST_PROMPT,
950 thread: "first",
951 },
952 {
953 type: "fork",
954 label: "fork-second",
955 from: "source",
956 as: "second",
957 },
958 {
959 type: "turn",
960 label: "second-recall",
961 prompt: THREAD_FORK_NATIVE_SIBLINGS_SECOND_PROMPT,
962 thread: "second",
963 },
964 ],
965 },
966 ],
967 },
968 {
969 name: "thread_merge_back_continue",
970 fileName: "thread_merge_back_continue.ndjson",
971 description:
972 "A source thread consumes one fork-delta handoff and later recalls source and transferred context.",
973 // gpt-6-luna often answers the recall turn with an earlier acknowledgement instead of
974 // the markers; gpt-6-sol recalls them.
975 model: "gpt-6-sol",
976 runs: [
977 {
978 name: "merge-native-fork",
979 description:
980 "Proves a merge-back handoff becomes durable context on the original provider thread.",
981 steps: [
982 {
983 type: "turn",
984 label: "source",
985 prompt: THREAD_MERGE_BACK_SOURCE_PROMPT,
986 thread: "source",
987 },
988 {
989 type: "fork",
990 label: "fork",
991 from: "source",
992 as: "fork",
993 },
994 {
995 type: "turn",
996 label: "fork-delta",
997 prompt: THREAD_MERGE_BACK_FORK_PROMPT,
998 thread: "fork",
999 },
1000 {
1001 type: "turn",
1002 label: "merge-back",
1003 prompt: THREAD_MERGE_BACK_HANDOFF_PROMPT,
1004 thread: "source",
1005 },
1006 {
1007 type: "turn",
1008 label: "source-recall",
1009 prompt: THREAD_MERGE_BACK_RECALL_PROMPT,
1010 thread: "source",
1011 },
1012 ],
1013 },
1014 ],
1015 },
1016 {
1017 name: "thread_merge_back_siblings",
1018 fileName: "thread_merge_back_siblings.ndjson",
1019 description:
1020 "Two sibling fork deltas are merged sequentially into one source provider thread and recalled together.",
1021 // gpt-6-luna often answers the recall turn with an earlier acknowledgement instead of
1022 // the markers; gpt-6-sol recalls them.
1023 model: "gpt-6-sol",
1024 runs: [
1025 {
1026 name: "merge-native-sibling-forks",
1027 description:
1028 "Proves repeated merge-back handoffs accumulate on the source while native sibling forks remain independent.",
1029 steps: [
1030 {
1031 type: "turn",
1032 label: "source",
1033 prompt: THREAD_MERGE_BACK_SIBLINGS_SOURCE_PROMPT,
1034 thread: "source",
1035 },
1036 {
1037 type: "fork",
1038 label: "fork-first",
1039 from: "source",
1040 as: "first",
1041 },
1042 {
1043 type: "turn",
1044 label: "first-fork-delta",
1045 prompt: THREAD_MERGE_BACK_SIBLINGS_FIRST_FORK_PROMPT,
1046 thread: "first",
1047 },
1048 {
1049 type: "fork",
1050 label: "fork-second",
1051 from: "source",
1052 as: "second",
1053 },
1054 {
1055 type: "turn",
1056 label: "second-fork-delta",
1057 prompt: THREAD_MERGE_BACK_SIBLINGS_SECOND_FORK_PROMPT,
1058 thread: "second",
1059 },
1060 {
1061 type: "turn",
1062 label: "merge-first",
1063 prompt: THREAD_MERGE_BACK_SIBLINGS_FIRST_HANDOFF_PROMPT,
1064 thread: "source",
1065 },
1066 {
1067 type: "turn",
1068 label: "merge-second",
1069 prompt: THREAD_MERGE_BACK_SIBLINGS_SECOND_HANDOFF_PROMPT,
1070 thread: "source",
1071 },
1072 {
1073 type: "turn",
1074 label: "source-recall",
1075 prompt: THREAD_MERGE_BACK_SIBLINGS_RECALL_PROMPT,
1076 thread: "source",
1077 },
1078 ],
1079 },
1080 ],
1081 },
1082 ];
1083}
1084
1085function recorderThreadConfigMetadata(scenario: ReplayScenario) {
1086 const keys = [...new Set(scenario.runs.flatMap((run) => Object.keys(run.threadConfig ?? {})))];
1087 return keys.length === 0 ? {} : { recorderThreadConfigKeys: keys };
1088}
1089
1090function makeRecorder({
1091 outPath,
1092 scenario,
1093 checkout,
1094}: {
1095 readonly outPath: string;
1096 readonly scenario: ReplayScenario;
1097 /** Checkout root, scrubbed from recordings because it names the local worktree layout. */
1098 readonly checkout: string;
1099}): Effect.Effect<Recorder, PlatformError.PlatformError, FileSystem.FileSystem> {
1100 return Effect.gen(function* () {
1101 const fs = yield* FileSystem.FileSystem;
1102 let version = "unknown";
1103 const records: Array<Record<string, unknown>> = [];
1104 const setVersion = (nextVersion: string) =>
1105 Effect.sync(() => {
1106 version = nextVersion;
1107 });
1108 const writeRecord = (record: Record<string, unknown>) =>
1109 Effect.sync(() => {
1110 records.push(record);
1111 });
1112 const flush = () => {
1113 const outputRecords = codexReplayRecordingOutputRecords(records, {
1114 workspace: process.cwd(),
1115 machine: {
1116 home: NodeOS.homedir(),
1117 hostname: NodeOS.hostname(),
1118 checkout,
1119 },
1120 });
1121 return fs.writeFileString(
1122 outPath,
1123 `${[
1124 {
1125 type: "transcript_start",
1126 provider: "codex",
1127 protocol: "codex.app-server",
1128 version,
1129 scenario: scenario.name,
1130 metadata: {
1131 source: "record-codex-app-server-replay-fixture",
1132 fileName: scenario.fileName,
1133 description: scenario.description,
1134 model: scenarioModel(scenario),
1135 ...recorderThreadConfigMetadata(scenario),
1136 },
1137 },
1138 ...outputRecords,
1139 ]
1140 .map((record) => JSON.stringify(record))
1141 .join("\n")}\n`,
1142 );
1143 };
1144
1145 return { path: outPath, setVersion, writeRecord, flush };
1146 });
1147}
1148
1149function layerCodex({ recorder }: { readonly recorder: Recorder }) {
1150 const clientRequestMethodById = new Map<string, string>();
1151 const serverRequestMethodById = new Map<string, string>();
1152 const clientOptions: CodexClient.CodexAppServerClientOptions = {
1153 logIncoming: true,
1154 logOutgoing: true,
1155 logger: (event) => {
1156 if (event.stage === "raw") {
1157 return Effect.void;
1158 }
1159
1160 const id = protocolId(event.payload);
1161 const idKey = id === undefined ? undefined : String(id);
1162 const method = protocolMethod(event.payload);
1163 const messageKind = classifyJsonRpcPayload(event.payload);
1164 let correlatedRequestMethod: string | undefined;
1165
1166 if (messageKind === "request" && idKey && method) {
1167 if (event.direction === "outgoing") {
1168 clientRequestMethodById.set(idKey, method);
1169 } else {
1170 serverRequestMethodById.set(idKey, method);
1171 }
1172 }
1173
1174 if (messageKind === "response" || messageKind === "error_response") {
1175 if (event.direction === "incoming" && idKey) {
1176 correlatedRequestMethod = clientRequestMethodById.get(idKey);
1177 clientRequestMethodById.delete(idKey);
1178 }
1179 if (event.direction === "outgoing" && idKey) {
1180 correlatedRequestMethod = serverRequestMethodById.get(idKey);
1181 serverRequestMethodById.delete(idKey);
1182 }
1183 }
1184
1185 const version = inferCodexVersion(event.payload);
1186 const updateVersion = version ? recorder.setVersion(version) : Effect.void;
1187
1188 const label = method ?? correlatedRequestMethod;
1189 const record =
1190 event.direction === "outgoing"
1191 ? {
1192 type: "expect_outbound",
1193 ...(label ? { label } : {}),
1194 frame: event.payload,
1195 }
1196 : {
1197 type: "emit_inbound",
1198 ...(label ? { label } : {}),
1199 frame: event.payload,
1200 };
1201
1202 return Effect.gen(function* () {
1203 yield* updateVersion;
1204 yield* recorder.writeRecord(record);
1205 }).pipe(Effect.ignore);
1206 },
1207 };
1208
1209 return Layer.effect(
1210 CodexClient.CodexAppServerClient,
1211 Effect.gen(function* () {
1212 // The vp node shim marks its children with VP_TOOL_RECURSION, which makes every
1213 // `node` the agent runs through a shell fail. Codex must not inherit it.
1214 const { VP_TOOL_RECURSION: _vpToolRecursion, ...environment } =
1215 yield* HostProcess.Environment;
1216 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1217 const commandName = environment.T3_CODEX_BIN ?? environment.CODEX_BIN ?? "codex";
1218 const spawnCommand = yield* resolveSpawnCommand(commandName, ["app-server"], {
1219 env: environment,
1220 });
1221 const handle = yield* spawner.spawn(
1222 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
1223 cwd: process.cwd(),
1224 env: environment,
1225 shell: spawnCommand.shell,
1226 }),
1227 );
1228 const context = yield* Layer.build(CodexClient.layerChildProcess(handle, clientOptions));
1229 return yield* Effect.service(CodexClient.CodexAppServerClient).pipe(Effect.provide(context));
1230 }),
1231 );
1232}
1233
1234function installReplayHandlers({
1235 client,
1236 startTurn,
1237 completeTurn,
1238 startCommandExecution,
1239 beforeApprovalResponse,
1240 runningCommandProcessIds,
1241}: {
1242 readonly client: CodexClient.CodexAppServerClient["Service"];
1243 readonly startTurn: (turnId: string) => Effect.Effect<void>;
1244 readonly completeTurn: (turnId: string) => Effect.Effect<void>;
1245 readonly startCommandExecution: (turnId: string) => Effect.Effect<void>;
1246 readonly beforeApprovalResponse: () => Effect.Effect<void>;
1247 /** Turn id -> command item id -> process id, for commands still running. */
1248 readonly runningCommandProcessIds: Map<string, Map<string, string>>;
1249}) {
1250 return Effect.all(
1251 [
1252 client.handleServerRequest("item/tool/requestUserInput", (payload) =>
1253 Effect.succeed({
1254 answers: Object.fromEntries(
1255 payload.questions.map((question) => [
1256 question.id,
1257 {
1258 answers:
1259 question.options && question.options.length > 0
1260 ? [question.options[0]!.label]
1261 : ["ok"],
1262 },
1263 ]),
1264 ),
1265 }),
1266 ),
1267 client.handleServerRequest("item/commandExecution/requestApproval", () =>
1268 beforeApprovalResponse().pipe(Effect.as({ decision: "accept" })),
1269 ),
1270 client.handleServerRequest("item/fileChange/requestApproval", () =>
1271 beforeApprovalResponse().pipe(Effect.as({ decision: "accept" })),
1272 ),
1273 client.handleServerRequest("item/permissions/requestApproval", (payload) =>
1274 beforeApprovalResponse().pipe(
1275 Effect.as({
1276 permissions: payload.permissions,
1277 scope: "turn" as const,
1278 }),
1279 ),
1280 ),
1281 client.handleServerRequest("mcpServer/elicitation/request", () =>
1282 Effect.succeed({ action: "accept" }),
1283 ),
1284 client.handleServerRequest("item/tool/call", (payload) =>
1285 Effect.succeed({
1286 contentItems: [
1287 {
1288 text: `Replay dynamic tool handler did not execute external tool: ${payload.tool}`,
1289 type: "inputText" as const,
1290 },
1291 ],
1292 success: false,
1293 }),
1294 ),
1295 client.handleServerRequest("applyPatchApproval", () =>
1296 Effect.succeed({ decision: "approved" }),
1297 ),
1298 client.handleServerRequest("execCommandApproval", () =>
1299 Effect.succeed({ decision: "approved" }),
1300 ),
1301 client.handleUnknownServerRequest((method) =>
1302 Effect.die(new Error(`Unhandled Codex app-server request in replay recorder: ${method}`)),
1303 ),
1304 client.handleServerNotification("turn/started", (payload) =>
1305 startTurn(payload.turn.id).pipe(Effect.ignore),
1306 ),
1307 client.handleServerNotification("turn/completed", (payload) =>
1308 completeTurn(payload.turn.id).pipe(Effect.ignore),
1309 ),
1310 client.handleServerNotification("item/completed", (payload) => {
1311 if (payload.item.type !== "commandExecution") return Effect.void;
1312 runningCommandProcessIds.get(payload.turnId)?.delete(payload.item.id);
1313 return startCommandExecution(payload.turnId).pipe(Effect.ignore);
1314 }),
1315 client.handleServerNotification("item/started", (payload) => {
1316 if (payload.item.type !== "commandExecution") return Effect.void;
1317 const processId = payload.item.processId;
1318 if (payload.item.status === "inProgress" && typeof processId === "string") {
1319 const running = runningCommandProcessIds.get(payload.turnId) ?? new Map();
1320 running.set(payload.item.id, processId);
1321 runningCommandProcessIds.set(payload.turnId, running);
1322 }
1323 return startCommandExecution(payload.turnId).pipe(Effect.ignore);
1324 }),
1325 ],
1326 { discard: true },
1327 );
1328}
1329
1330function runReplaySession({
1331 scenario,
1332 run,
1333 recorder,
1334}: {
1335 readonly scenario: ReplayScenario;
1336 readonly run: ReplayRun;
1337 readonly recorder: Recorder;
1338}) {
1339 return Effect.gen(function* () {
1340 const startedTurns = yield* makeReplayRecorderDeferredRegistry();
1341 const completedTurns = yield* makeReplayRecorderDeferredRegistry();
1342 const startedCommandExecutions = yield* makeReplayRecorderDeferredRegistry();
1343 let approvalGate: Deferred.Deferred<void> | undefined;
1344 const getStarted = startedTurns.getOrCreate;
1345 const getCompletion = completedTurns.getOrCreate;
1346 const getCommandExecutionStarted = startedCommandExecutions.getOrCreate;
1347 const startTurn = startedTurns.succeed;
1348 const completeTurn = completedTurns.succeed;
1349 const startCommandExecution = startedCommandExecutions.succeed;
1350 const beforeApprovalResponse = () =>
1351 approvalGate ? Deferred.await(approvalGate) : Effect.void;
1352 const model = scenarioModel(scenario);
1353 // Same runtime params the adapter sends on thread/start, thread/resume and thread/fork.
1354 const threadRuntimeParams = { cwd: process.cwd(), model, config: CODEX_THREAD_CONFIG };
1355 const lastTurnIdByThread = new Map<string, string>();
1356 const runningCommandProcessIds = new Map<string, Map<string, string>>();
1357
1358 const initializeClient = Effect.gen(function* () {
1359 const client = yield* CodexClient.CodexAppServerClient;
1360
1361 yield* installReplayHandlers({
1362 client,
1363 startTurn,
1364 completeTurn,
1365 startCommandExecution,
1366 beforeApprovalResponse,
1367 runningCommandProcessIds,
1368 });
1369
1370 yield* client.request("initialize", {
1371 clientInfo: buildCodexInitializeParams().clientInfo,
1372 capabilities: CODEX_CLIENT_CAPABILITIES,
1373 });
1374
1375 yield* client.notify("initialized", undefined);
1376
1377 return client;
1378 });
1379
1380 const runTurnStep = (
1381 client: CodexClient.CodexAppServerClient["Service"],
1382 threadId: string,
1383 step: TurnReplayStep,
1384 ) =>
1385 Effect.gen(function* () {
1386 const turnParams: TurnStartParams = {
1387 approvalPolicy: "never",
1388 sandboxPolicy: { type: "dangerFullAccess" },
1389 cwd: process.cwd(),
1390 model,
1391 summary: "detailed",
1392 approvalsReviewer: "user",
1393 ...(run.interactionMode === "plan"
1394 ? { collaborationMode: planCollaborationMode(model) }
1395 : {}),
1396 ...run.turnDefaults,
1397 ...step.turnOverrides,
1398 input: turnInput(step.prompt),
1399 threadId,
1400 };
1401
1402 if (step.type === "steeredTurn") {
1403 approvalGate = yield* Deferred.make<void>();
1404 }
1405
1406 const turn = yield* client.request("turn/start", turnParams);
1407 const turnId = getTurnId(turn);
1408 const started = yield* getStarted(turnId);
1409 yield* getCompletion(turnId);
1410
1411 if (step.type === "steeredTurn") {
1412 yield* Deferred.await(started);
1413 yield* client.request("turn/steer", {
1414 expectedTurnId: turnId,
1415 input: turnInput(step.steer),
1416 threadId,
1417 });
1418 if (approvalGate) {
1419 yield* Deferred.succeed(approvalGate, void 0);
1420 approvalGate = undefined;
1421 }
1422 }
1423
1424 if (step.type === "interruptedTurn") {
1425 if (step.interruptAfterCommandExecutionStarted === true) {
1426 const commandExecutionStarted = yield* getCommandExecutionStarted(turnId);
1427 yield* Deferred.await(commandExecutionStarted);
1428 } else {
1429 yield* Effect.sleep(`${step.interruptAfterMs ?? 1_500} millis`);
1430 }
1431 yield* client.request("turn/interrupt", {
1432 threadId,
1433 turnId,
1434 });
1435 // Like the adapter, stop commands the interrupt left running.
1436 for (const processId of runningCommandProcessIds.get(turnId)?.values() ?? []) {
1437 yield* client.raw.request("thread/backgroundTerminals/terminate", {
1438 threadId,
1439 processId,
1440 });
1441 }
1442 }
1443
1444 const completed = yield* getCompletion(turnId);
1445 yield* Deferred.await(completed);
1446 lastTurnIdByThread.set(threadId, turnId);
1447 });
1448
1449 if (scenario.name === "provider_thread_resume") {
1450 const firstStep = run.steps[0];
1451 const secondStep = run.steps[1];
1452 if (
1453 !firstStep ||
1454 !secondStep ||
1455 firstStep.type === "rollback" ||
1456 firstStep.type === "fork" ||
1457 secondStep.type === "rollback" ||
1458 secondStep.type === "fork"
1459 ) {
1460 throw new Error("provider_thread_resume replay recording requires two turn steps.");
1461 }
1462
1463 const firstThread = yield* Effect.gen(function* () {
1464 const client = yield* initializeClient;
1465 const thread = yield* client.request("thread/start", threadRuntimeParams);
1466 yield* runTurnStep(client, thread.thread.id, firstStep);
1467 return thread;
1468 }).pipe(
1469 Effect.provide(
1470 layerCodex({
1471 recorder,
1472 }),
1473 ),
1474 );
1475
1476 yield* recorder.writeRecord({
1477 type: "runtime_exit",
1478 status: "success",
1479 });
1480
1481 yield* Effect.gen(function* () {
1482 const client = yield* initializeClient;
1483 const thread = yield* client.request("thread/resume", {
1484 threadId: firstThread.thread.id,
1485 excludeTurns: true,
1486 ...threadRuntimeParams,
1487 });
1488 yield* runTurnStep(client, thread.thread.id, secondStep);
1489 }).pipe(
1490 Effect.provide(
1491 layerCodex({
1492 recorder,
1493 }),
1494 ),
1495 );
1496 return;
1497 }
1498
1499 if (scenario.name === "thread_rollback_after_restart") {
1500 const [first, second, rollback, after] = run.steps;
1501 if (
1502 first === undefined ||
1503 second === undefined ||
1504 rollback?.type !== "rollback" ||
1505 after === undefined ||
1506 first.type === "rollback" ||
1507 first.type === "fork" ||
1508 second.type === "rollback" ||
1509 second.type === "fork" ||
1510 after.type === "rollback" ||
1511 after.type === "fork"
1512 ) {
1513 throw new Error(
1514 "thread_rollback_after_restart replay recording requires turn, turn, rollback, turn.",
1515 );
1516 }
1517
1518 const threadId = yield* Effect.gen(function* () {
1519 const client = yield* initializeClient;
1520 const thread = yield* client.request("thread/start", threadRuntimeParams);
1521 yield* runTurnStep(client, thread.thread.id, first);
1522 yield* runTurnStep(client, thread.thread.id, second);
1523 return thread.thread.id;
1524 }).pipe(Effect.provide(layerCodex({ recorder })));
1525
1526 yield* recorder.writeRecord({
1527 type: "runtime_exit",
1528 status: "success",
1529 });
1530
1531 yield* Effect.gen(function* () {
1532 const client = yield* initializeClient;
1533 const resumeParams = { threadId, excludeTurns: true, ...threadRuntimeParams };
1534 // Like the adapter: the fresh app-server reports the thread notLoaded,
1535 // so it is resumed before thread/revert, and the next turn resumes it again.
1536 yield* client.request("thread/read", { threadId, includeTurns: false });
1537 yield* client.request("thread/resume", resumeParams);
1538 yield* revertCodexThread(client, threadId, rollback.numTurns);
1539 yield* client.request("thread/resume", resumeParams);
1540 yield* runTurnStep(client, threadId, after);
1541 }).pipe(Effect.provide(layerCodex({ recorder })));
1542 return;
1543 }
1544
1545 yield* Effect.gen(function* () {
1546 const client = yield* initializeClient;
1547 const thread = yield* client.request("thread/start", {
1548 ...threadRuntimeParams,
1549 config: { ...threadRuntimeParams.config, ...run.threadConfig },
1550 });
1551 let activeThreadId = thread.thread.id;
1552 const threadIds = new Map<string, string>([["source", thread.thread.id]]);
1553
1554 for (const [stepIndex, step] of run.steps.entries()) {
1555 if (step.type === "rollback") {
1556 // The adapter only reverts paginated history, so it reads the mode first.
1557 yield* client.request("thread/read", { threadId: activeThreadId, includeTurns: false });
1558 yield* revertCodexThread(client, activeThreadId, step.numTurns);
1559 continue;
1560 }
1561 if (step.type === "fork") {
1562 const sourceThreadId =
1563 step.from === undefined ? activeThreadId : threadIds.get(step.from);
1564 if (sourceThreadId === undefined) {
1565 throw new Error(`Unknown replay thread alias '${step.from}'.`);
1566 }
1567 // The adapter forks at the source's latest native turn.
1568 const lastTurnId = lastTurnIdByThread.get(sourceThreadId);
1569 const forked = yield* client.request("thread/fork", {
1570 threadId: sourceThreadId,
1571 ...(lastTurnId === undefined ? {} : { lastTurnId }),
1572 ...threadRuntimeParams,
1573 });
1574 activeThreadId = forked.thread.id;
1575 if (step.as !== undefined) {
1576 threadIds.set(step.as, forked.thread.id);
1577 }
1578 continue;
1579 }
1580
1581 const threadAlias = "thread" in step ? step.thread : undefined;
1582 const turnThreadId =
1583 threadAlias === undefined ? activeThreadId : threadIds.get(threadAlias);
1584 if (turnThreadId === undefined) {
1585 throw new Error(`Unknown replay thread alias '${threadAlias}'.`);
1586 }
1587 yield* runTurnStep(client, turnThreadId, step);
1588 void stepIndex;
1589 }
1590 }).pipe(
1591 Effect.provide(
1592 layerCodex({
1593 recorder,
1594 }),
1595 ),
1596 );
1597 });
1598}
1599
1600function runScenario({
1601 scenario,
1602 outPath,
1603}: {
1604 readonly scenario: ReplayScenario;
1605 readonly outPath: string;
1606}) {
1607 return Effect.gen(function* () {
1608 const fs = yield* FileSystem.FileSystem;
1609 const path = yield* Path.Path;
1610 const checkout = path.resolve(yield* path.fromFileUrl(new URL("../../..", import.meta.url)));
1611 const recorder = yield* makeRecorder({ outPath, scenario, checkout });
1612
1613 yield* fs.makeDirectory(path.dirname(outPath), { recursive: true });
1614 yield* Console.log(`Writing ${scenario.name} Codex replay events to ${recorder.path}`);
1615
1616 yield* Effect.forEach(scenario.runs, (run) => runReplaySession({ scenario, run, recorder }), {
1617 concurrency: 1,
1618 });
1619
1620 yield* recorder.writeRecord({
1621 type: "runtime_exit",
1622 status: "success",
1623 });
1624 yield* recorder.flush();
1625 });
1626}
1627
1628const program = Effect.gen(function* () {
1629 const path = yield* Path.Path;
1630 const requestedScenarios = parseScenarios();
1631 const allScenarios = scenarios();
1632 const outDir = readArgValue("--out-dir") ?? process.env.T3_CODEX_REPLAY_OUT_DIR;
1633 const singleOutPath = readArgValue("--out") ?? process.env.T3_CODEX_REPLAY_OUT;
1634 const selected = allScenarios.filter((scenario) => requestedScenarios.includes(scenario.name));
1635
1636 if (selected.length === 0) {
1637 throw new Error("No replay scenarios selected.");
1638 }
1639 if (singleOutPath && selected.length !== 1) {
1640 throw new Error("--out / T3_CODEX_REPLAY_OUT can only be used with exactly one --scenario.");
1641 }
1642
1643 yield* Effect.forEach(
1644 selected,
1645 (scenario) =>
1646 Effect.gen(function* () {
1647 const defaultOutPath = yield* path.fromFileUrl(defaultTranscriptUrl(scenario));
1648 yield* runScenario({
1649 scenario,
1650 outPath:
1651 singleOutPath ?? (outDir ? path.join(outDir, scenario.fileName) : defaultOutPath),
1652 });
1653 }),
1654 { concurrency: 1 },
1655 );
1656});
1657
1658program.pipe(Effect.scoped, Effect.provide(NodeServices.layer), NodeRuntime.runMain);