Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion apps/server/scripts/codexReplayRecordingRecords.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ it("preserves monotonic request ids across native Codex forks", () => {
},
];

const output = codexReplayRecordingOutputRecords(records);
const output = codexReplayRecordingOutputRecords(records, { workspace: "/recording" });

assert.deepEqual(output, records);
assert.deepEqual(
Expand All @@ -33,3 +33,27 @@ it("preserves monotonic request ids across native Codex forks", () => {
[1, 4, 5],
);
});

it("names the recording cwd in outbound frames only", () => {
const output = codexReplayRecordingOutputRecords(
[
{
type: "expect_outbound",
frame: { id: 3, method: "turn/start", params: { cwd: "/recording", input: [] } },
},
{
type: "emit_inbound",
frame: { method: "thread/started", params: { thread: { cwd: "/recording" } } },
},
],
{ workspace: "/recording" },
);

assert.deepEqual(
output.map((record) => record.frame),
[
{ id: 3, method: "turn/start", params: { cwd: "<workspace>", input: [] } },
{ method: "thread/started", params: { thread: { cwd: "/recording" } } },
],
);
});
28 changes: 26 additions & 2 deletions apps/server/scripts/codexReplayRecordingRecords.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,33 @@
const REPLAY_WORKSPACE_PLACEHOLDER = "<workspace>";

function withWorkspacePlaceholder(value: unknown, workspace: string): unknown {
if (value === workspace) {
return REPLAY_WORKSPACE_PLACEHOLDER;
}
if (Array.isArray(value)) {
return value.map((entry) => withWorkspacePlaceholder(entry, workspace));
}
if (typeof value !== "object" || value === null) {
return value;
}
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, withWorkspacePlaceholder(entry, workspace)]),
);
}

/**
* Codex supports multiple provider threads in one app-server session, so native
* fork recording keeps the request ids emitted by that single client.
* fork recording keeps the request ids emitted by that single client. Outbound
* frames name the recording cwd `<workspace>`, which replay swaps for its own
* checkpoint workspace.
*/
export function codexReplayRecordingOutputRecords(
records: ReadonlyArray<Record<string, unknown>>,
options: { readonly workspace: string },
): ReadonlyArray<Record<string, unknown>> {
return [...records];
return records.map((record) =>
record.type === "expect_outbound"
? { ...record, frame: withWorkspacePlaceholder(record.frame, options.workspace) }
: record,
);
}
20 changes: 18 additions & 2 deletions apps/server/scripts/record-codex-app-server-replay-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,14 @@ const CODEX_CLIENT_INFO = {
title: "T3 Code Desktop",
version: "0.1.0",
} as const;
// Match the V2 adapter's initialize and turn/start frames so recordings replay
// against it without hand edits.
const CODEX_CLIENT_CAPABILITIES = {
experimentalApi: true,
optOutNotificationMethods: ["turn/diff/updated"],
} as const;
const CODEX_REPLAY_MODEL =
readArgValue("--model") ?? process.env.T3_CODEX_REPLAY_MODEL ?? "gpt-5.4";

const SCENARIO_NAMES = [
"simple",
Expand Down Expand Up @@ -625,7 +630,7 @@ function scenarios(): ReadonlyArray<ReplayScenario> {
{
name: "rollback-one-turn",
description:
"Two completed turns, thread/rollback numTurns=1, then a post-rollback turn.",
"Two completed turns, thread/revert before the second turn, then a post-rollback turn.",
steps: [
{
type: "turn",
Expand Down Expand Up @@ -861,7 +866,9 @@ function makeRecorder({
records.push(record);
});
const flush = () => {
const outputRecords = codexReplayRecordingOutputRecords(records);
const outputRecords = codexReplayRecordingOutputRecords(records, {
workspace: process.cwd(),
});
return fs.writeFileString(
outPath,
`${[
Expand All @@ -875,6 +882,7 @@ function makeRecorder({
source: "record-codex-app-server-replay-fixture",
fileName: scenario.fileName,
description: scenario.description,
model: CODEX_REPLAY_MODEL,
},
},
...outputRecords,
Expand Down Expand Up @@ -1109,6 +1117,12 @@ function runReplaySession({
) =>
Effect.gen(function* () {
const turnParams: TurnStartParams = {
approvalPolicy: "never",
sandboxPolicy: { type: "dangerFullAccess" },
cwd: process.cwd(),
model: CODEX_REPLAY_MODEL,
summary: "detailed",
approvalsReviewer: "user",
...run.turnDefaults,
...step.turnOverrides,
input: turnInput(step.prompt),
Expand Down Expand Up @@ -1210,6 +1224,8 @@ function runReplaySession({

for (const [stepIndex, step] of run.steps.entries()) {
if (step.type === "rollback") {
// The adapter only reverts paginated history, so it reads the mode first.
yield* client.request("thread/read", { threadId: activeThreadId, includeTurns: false });
yield* revertCodexThread(client, activeThreadId, step.numTurns);
continue;
}
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/orchestration-v2/testkit/fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -814,7 +814,8 @@ export const ORCHESTRATOR_REPLAY_FIXTURES: ReadonlyArray<OrchestratorReplayFixtu
{
driver: ProviderDriverKind.make("codex"),
transcriptFile: new URL("./thread_rollback/codex_transcript.ndjson", import.meta.url),
modelSelection: CODEX_MODEL_SELECTION,
// Recorded live on Codex 0.156.1, where gpt-5.4 is no longer served.
modelSelection: { ...CODEX_MODEL_SELECTION, model: "gpt-5.6-luna" },
assertOutput: assertThreadRollbackOutput,
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export function assertThreadRollbackOutput(
"assistant_message",
"checkpoint",
"user_message",
"reasoning",
"reasoning",
"assistant_message",
"checkpoint",
]);
Expand All @@ -47,6 +49,8 @@ export function assertThreadRollbackOutput(
"assistant_message",
"checkpoint",
"user_message",
"reasoning",
"reasoning",
"assistant_message",
"checkpoint",
]);
Expand All @@ -60,6 +64,10 @@ export function assertThreadRollbackOutput(
THREAD_ROLLBACK_AFTER_PROMPT,
]);
assertVisibleUserMessagesExclude(projection, [THREAD_ROLLBACK_SECOND_PROMPT]);
// Codex's own context was rewound: the post-rollback recall omits the reverted turn.
const recall = projection.turnItems.findLast((item) => item.type === "assistant_message");
assert.include(recall?.type === "assistant_message" ? recall.text : "", "first turn complete");
assert.notInclude(recall?.type === "assistant_message" ? recall.text : "", "second turn");
assert.isAtLeast(projection.checkpoints.length, 2);
assert.isTrue(
projection.runs.some((run) => run.status === "rolled_back"),
Expand Down
Loading
Loading