From 1afc4801e7200cab5ffbe365851dcfe50d07f49c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 10:49:07 +1000 Subject: [PATCH] fix(server): fork Codex threads at the native turn boundary Retry/edit of an earlier turn in a paginated native Codex thread forked at head and then issued thread/rollback, which paginated threads reject. Resolve the selected provider turn nativeTurnRef and pass it as lastTurnId to thread/fork so later turns are excluded atomically and no rollback is needed. Fall back to fork-then-rollback only when no native reference is available, and fail honestly when that fallback hits a paginated fork. rollbackThread now probes historyMode and reports an explicit unsupported error for paginated threads instead of sending a request Codex will reject. Adapts the approach from pingdotgg/t3code#9169 by Yusuf007R. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../Adapters/CodexAdapterV2.test.ts | 558 ++++++++++++++++++ .../Adapters/CodexAdapterV2.ts | 123 +++- apps/server/src/orchestration-v2/TODO.md | 8 +- .../CodexReplayFixtures.integration.test.ts | 9 +- .../testkit/ThreadFork.integration.test.ts | 342 ++++++----- .../codex_transcript.ndjson | 4 +- .../codex_transcript.ndjson | 15 +- .../codex_transcript.ndjson | 4 +- .../codex_transcript.ndjson | 6 +- .../thread_rollback/codex_transcript.ndjson | 12 +- .../thread-lineage-and-context-transfer.md | 4 +- 11 files changed, 866 insertions(+), 219 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 3c5749fa473f..8d14abd5a2ad 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -39,7 +39,9 @@ import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts"; import { + ProviderAdapterForkThreadError, ProviderAdapterOpenSessionError, + ProviderAdapterRollbackThreadError, ProviderAdapterV2RuntimePolicy, type ProviderAdapterV2Event, type ProviderAdapterV2TurnInput, @@ -60,6 +62,7 @@ import { makeCodexAppServerProtocolLogger, makeCodexAppServerSpawnCommand, projectCodexDynamicToolItem, + resolveCodexForkBoundary, resolveCodexRollbackTurnCount, } from "./CodexAdapterV2.ts"; import { makeReplayServerConfig } from "./CodexAdapterV2.testkit.ts"; @@ -992,6 +995,159 @@ describe("CodexAdapterV2 rollback mapping", () => { ); }); +describe("CodexAdapterV2 fork boundary", () => { + const providerThreadId = ProviderThreadId.make("provider-thread-codex-fork-boundary"); + const makeProviderThread = (now: DateTime.Utc): OrchestrationV2ProviderThread => ({ + id: providerThreadId, + driver: CODEX_DRIVER_KIND, + providerInstanceId: ProviderInstanceId.make("codex"), + providerSessionId: ProviderSessionId.make("provider-session-codex-fork-boundary"), + appThreadId: ThreadId.make("thread-codex-fork-boundary"), + ownerNodeId: null, + nativeThreadRef: { + driver: CODEX_DRIVER_KIND, + nativeId: "native-thread-codex-fork-boundary", + strength: "strong", + }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: 1, + lastRunOrdinal: 2, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }); + const makeProviderTurn = ( + id: string, + ordinal: number, + nativeId: string | null, + now: DateTime.Utc, + ): OrchestrationV2ProviderTurn => ({ + id: ProviderTurnId.make(id), + providerThreadId, + nodeId: NodeId.make(`node-${id}`), + runAttemptId: RunAttemptId.make(`run-attempt-${id}`), + nativeTurnRef: + nativeId === null + ? { driver: CODEX_DRIVER_KIND, nativeId: null, strength: "none" } + : { driver: CODEX_DRIVER_KIND, nativeId, strength: "strong" }, + ordinal, + status: "completed", + startedAt: now, + completedAt: now, + }); + + it.effect("resolves the selected provider turn to an inclusive native fork boundary", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const firstTurn = makeProviderTurn("provider-turn-first", 1, "native-turn-first", now); + const secondTurn = makeProviderTurn("provider-turn-second", 2, "native-turn-second", now); + + const boundary = yield* resolveCodexForkBoundary({ + sourceProviderThread: makeProviderThread(now), + sourceProviderTurns: [firstTurn, secondTurn], + providerTurnId: firstTurn.id, + targetThreadId: ThreadId.make("thread-codex-fork-target"), + }); + + assert.deepEqual(boundary, { + lastTurnId: "native-turn-first", + rollbackTurnCount: 0, + }); + }), + ); + + it.effect("resolves the latest source turn to a native fork boundary without rollback", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const firstTurn = makeProviderTurn("provider-turn-first", 1, "native-turn-first", now); + const secondTurn = makeProviderTurn("provider-turn-second", 2, "native-turn-second", now); + + const boundary = yield* resolveCodexForkBoundary({ + sourceProviderThread: makeProviderThread(now), + sourceProviderTurns: [firstTurn, secondTurn], + providerTurnId: secondTurn.id, + targetThreadId: ThreadId.make("thread-codex-fork-target"), + }); + + assert.deepEqual(boundary, { + lastTurnId: "native-turn-second", + rollbackTurnCount: 0, + }); + }), + ); + + it.effect("keeps the rollback-count fallback when the boundary turn lacks a native id", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const firstTurn = makeProviderTurn("provider-turn-first", 1, null, now); + const secondTurn = makeProviderTurn("provider-turn-second", 2, "native-turn-second", now); + + const boundary = yield* resolveCodexForkBoundary({ + sourceProviderThread: makeProviderThread(now), + sourceProviderTurns: [firstTurn, secondTurn], + providerTurnId: firstTurn.id, + targetThreadId: ThreadId.make("thread-codex-fork-target"), + }); + + assert.deepEqual(boundary, { lastTurnId: undefined, rollbackTurnCount: 1 }); + }), + ); + + it.effect("keeps the rollback-count fallback when the boundary turn has no native ref", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const firstTurn: OrchestrationV2ProviderTurn = { + ...makeProviderTurn("provider-turn-first", 1, "native-turn-first", now), + nativeTurnRef: null, + }; + const secondTurn = makeProviderTurn("provider-turn-second", 2, "native-turn-second", now); + + const boundary = yield* resolveCodexForkBoundary({ + sourceProviderThread: makeProviderThread(now), + sourceProviderTurns: [firstTurn, secondTurn], + providerTurnId: firstTurn.id, + targetThreadId: ThreadId.make("thread-codex-fork-target"), + }); + + assert.deepEqual(boundary, { lastTurnId: undefined, rollbackTurnCount: 1 }); + }), + ); + + it.effect("forks at head without a boundary when no provider turn is selected", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + + const boundary = yield* resolveCodexForkBoundary({ + sourceProviderThread: makeProviderThread(now), + targetThreadId: ThreadId.make("thread-codex-fork-target"), + }); + + assert.deepEqual(boundary, { lastTurnId: undefined, rollbackTurnCount: 0 }); + }), + ); + + it.effect("fails with a typed error when the selected source turn is missing", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const firstTurn = makeProviderTurn("provider-turn-first", 1, "native-turn-first", now); + + const error = yield* Effect.flip( + resolveCodexForkBoundary({ + sourceProviderThread: makeProviderThread(now), + sourceProviderTurns: [firstTurn], + providerTurnId: ProviderTurnId.make("provider-turn-missing"), + targetThreadId: ThreadId.make("thread-codex-fork-target"), + }), + ); + + assert.instanceOf(error, ProviderAdapterForkThreadError); + assert.include(String(error.cause), "provider-turn-missing"); + }), + ); +}); + describe("CodexAdapterV2 background command detail", () => { it("summarizes command, exit code, and output tail", () => { assert.equal( @@ -4657,4 +4813,406 @@ describe("CodexAdapterV2 post-settle continuation", () => { }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), ), ); + + const codexReplayThreadResult = (input: { + readonly nativeThreadId: string; + readonly forkedFromId: string | null; + }) => ({ + thread: { + id: input.nativeThreadId, + sessionId: input.nativeThreadId, + forkedFromId: input.forkedFromId, + preview: "", + ephemeral: false, + modelProvider: "openai", + createdAt: 1782622440, + updatedAt: 1782622440, + status: { type: "idle" }, + path: `/tmp/${input.nativeThreadId}.jsonl`, + cwd: "/workspace", + cliVersion: "0.144.0", + source: "vscode", + threadSource: null, + agentNickname: null, + agentRole: null, + gitInfo: null, + name: null, + turns: [], + }, + model: "gpt-5.4", + modelProvider: "openai", + serviceTier: null, + cwd: "/workspace", + instructionSources: [], + approvalPolicy: "on-request", + approvalsReviewer: "user", + sandbox: { type: "workspaceWrite", writableRoots: [], networkAccess: false }, + reasoningEffort: "medium", + }); + + const errorCauseChainText = (error: unknown): string => + error instanceof Error ? `${error.message} ${errorCauseChainText(error.cause)}` : String(error); + + const codexReplaySourceTurn = (input: { + readonly id: string; + readonly ordinal: number; + readonly nativeId: string | null; + readonly providerThreadId: ProviderThreadId; + readonly now: DateTime.Utc; + }): OrchestrationV2ProviderTurn => ({ + id: ProviderTurnId.make(input.id), + providerThreadId: input.providerThreadId, + nodeId: NodeId.make(`node-${input.id}`), + runAttemptId: RunAttemptId.make(`run-attempt-${input.id}`), + nativeTurnRef: + input.nativeId === null + ? { driver: CODEX_DRIVER_KIND, nativeId: null, strength: "none" } + : { driver: CODEX_DRIVER_KIND, nativeId: input.nativeId, strength: "strong" }, + ordinal: input.ordinal, + status: "completed", + startedAt: input.now, + completedAt: input.now, + }); + + it.effect("fails honestly when rolling back a paginated Codex thread", () => + Effect.gen(function* () { + const nativeThreadId = "paginated-rollback-thread"; + const preamble = codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "paginated-rollback-turn", + prompt: "unused", + }); + const transcript = makeCodexReplayTranscript({ + scenario: "codex-paginated-rollback", + entries: [ + ...preamble.slice(0, 5), + { + type: "expect_outbound", + label: "thread/read", + frame: { + id: 3, + method: "thread/read", + params: { threadId: nativeThreadId, includeTurns: false }, + }, + }, + { + type: "emit_inbound", + label: "thread/read", + frame: { + id: 3, + result: { thread: { id: nativeThreadId, historyMode: "paginated" } }, + }, + }, + ], + }); + const outbound: Array = []; + const harness = yield* makeCodexReplayHarness( + transcript, + () => Effect.void, + (method) => + Effect.sync(() => { + outbound.push(method); + }), + ); + const now = yield* DateTime.now; + const firstTurn = codexReplaySourceTurn({ + id: "provider-turn-first", + ordinal: 1, + nativeId: "native-turn-first", + providerThreadId: harness.providerThread.id, + now, + }); + const secondTurn = codexReplaySourceTurn({ + id: "provider-turn-second", + ordinal: 2, + nativeId: "native-turn-second", + providerThreadId: harness.providerThread.id, + now, + }); + + const error = yield* Effect.flip( + harness.runtime.rollbackThread({ + providerThread: harness.providerThread, + target: { + type: "provider_turn", + checkpointId: CheckpointId.make("checkpoint-paginated-rollback"), + appRunOrdinal: 1, + providerTurn: firstTurn, + }, + providerThreadTurns: [firstTurn, secondTurn], + }), + ); + + assert.instanceOf(error, ProviderAdapterRollbackThreadError); + assert.include( + errorCauseChainText(error), + "paginated", + "paginated rollback must surface an honest unsupported-history failure", + ); + assert.notInclude( + outbound, + "thread/rollback", + "thread/rollback must not be sent to a paginated Codex thread", + ); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ); + + it.effect( + "falls back to fork-local thread/rollback on legacy history when the source turn lacks a native reference", + () => + Effect.gen(function* () { + const nativeThreadId = "fallback-source-thread"; + const forkThreadId = "fallback-fork-thread"; + const preamble = codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "fallback-source-turn", + prompt: "unused", + }); + const transcript = makeCodexReplayTranscript({ + scenario: "codex-fork-legacy-fallback", + entries: [ + ...preamble.slice(0, 5), + { + type: "expect_outbound", + label: "thread/fork", + frame: { id: 3, method: "thread/fork", params: { threadId: nativeThreadId } }, + }, + { + type: "emit_inbound", + label: "thread/fork", + frame: { + id: 3, + result: codexReplayThreadResult({ + nativeThreadId: forkThreadId, + forkedFromId: nativeThreadId, + }), + }, + }, + { + type: "expect_outbound", + label: "thread/read", + frame: { + id: 4, + method: "thread/read", + params: { threadId: forkThreadId, includeTurns: false }, + }, + }, + { + type: "emit_inbound", + label: "thread/read", + frame: { + id: 4, + result: { thread: { id: forkThreadId, historyMode: "legacy" } }, + }, + }, + { + type: "expect_outbound", + label: "thread/rollback", + frame: { + id: 5, + method: "thread/rollback", + params: { threadId: forkThreadId, numTurns: 1 }, + }, + }, + { + type: "emit_inbound", + label: "thread/rollback", + frame: { + id: 5, + result: codexReplayThreadResult({ + nativeThreadId: forkThreadId, + forkedFromId: null, + }), + }, + }, + ], + }); + const outbound: Array = []; + const harness = yield* makeCodexReplayHarness( + transcript, + () => Effect.void, + (method) => + Effect.sync(() => { + outbound.push(method); + }), + ); + const now = yield* DateTime.now; + const firstTurn = codexReplaySourceTurn({ + id: "provider-turn-first", + ordinal: 1, + nativeId: null, + providerThreadId: harness.providerThread.id, + now, + }); + const secondTurn = codexReplaySourceTurn({ + id: "provider-turn-second", + ordinal: 2, + nativeId: "native-turn-second", + providerThreadId: harness.providerThread.id, + now, + }); + + const forkedProviderThread = yield* harness.runtime.forkThread({ + sourceProviderThread: harness.providerThread, + sourceProviderTurns: [firstTurn, secondTurn], + providerTurnId: firstTurn.id, + targetThreadId: ThreadId.make("thread-fork-legacy-fallback-target"), + }); + + assert.equal(forkedProviderThread.nativeThreadRef?.nativeId, forkThreadId); + assert.notEqual(forkedProviderThread.id, harness.providerThread.id); + assert.equal(forkedProviderThread.forkedFrom?.providerTurnId, firstTurn.id); + assert.deepEqual(outbound.slice(-2), ["thread/fork", "thread/rollback"]); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ); + + it.effect( + "fails honestly when a paginated fork cannot honor a source turn without a native reference", + () => + Effect.gen(function* () { + const nativeThreadId = "paginated-fallback-source-thread"; + const forkThreadId = "paginated-fallback-fork-thread"; + const preamble = codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "paginated-fallback-source-turn", + prompt: "unused", + }); + const transcript = makeCodexReplayTranscript({ + scenario: "codex-fork-paginated-fallback", + entries: [ + ...preamble.slice(0, 5), + { + type: "expect_outbound", + label: "thread/fork", + frame: { id: 3, method: "thread/fork", params: { threadId: nativeThreadId } }, + }, + { + type: "emit_inbound", + label: "thread/fork", + frame: { + id: 3, + result: codexReplayThreadResult({ + nativeThreadId: forkThreadId, + forkedFromId: nativeThreadId, + }), + }, + }, + { + type: "expect_outbound", + label: "thread/read", + frame: { + id: 4, + method: "thread/read", + params: { threadId: forkThreadId, includeTurns: false }, + }, + }, + { + type: "emit_inbound", + label: "thread/read", + frame: { + id: 4, + result: { thread: { id: forkThreadId, historyMode: "paginated" } }, + }, + }, + ], + }); + const outbound: Array = []; + const harness = yield* makeCodexReplayHarness( + transcript, + () => Effect.void, + (method) => + Effect.sync(() => { + outbound.push(method); + }), + ); + const now = yield* DateTime.now; + const firstTurn = codexReplaySourceTurn({ + id: "provider-turn-first", + ordinal: 1, + nativeId: null, + providerThreadId: harness.providerThread.id, + now, + }); + const secondTurn = codexReplaySourceTurn({ + id: "provider-turn-second", + ordinal: 2, + nativeId: "native-turn-second", + providerThreadId: harness.providerThread.id, + now, + }); + + const error = yield* Effect.flip( + harness.runtime.forkThread({ + sourceProviderThread: harness.providerThread, + sourceProviderTurns: [firstTurn, secondTurn], + providerTurnId: firstTurn.id, + targetThreadId: ThreadId.make("thread-fork-paginated-fallback-target"), + }), + ); + + assert.instanceOf(error, ProviderAdapterForkThreadError); + assert.include( + errorCauseChainText(error), + "paginated", + "the missing-native-reference fallback must name the paginated limitation", + ); + assert.notInclude( + outbound, + "thread/rollback", + "thread/rollback must not be sent to a paginated Codex fork", + ); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ); + + it.effect("propagates native thread/fork failures as typed fork errors", () => + Effect.gen(function* () { + const nativeThreadId = "fork-failure-source-thread"; + const preamble = codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "fork-failure-source-turn", + prompt: "unused", + }); + const transcript = makeCodexReplayTranscript({ + scenario: "codex-fork-request-failure", + entries: [ + ...preamble.slice(0, 5), + { + type: "expect_outbound", + label: "thread/fork", + frame: { + id: 3, + method: "thread/fork", + params: { threadId: nativeThreadId, lastTurnId: "native-turn-first" }, + }, + }, + { + type: "emit_inbound", + label: "thread/fork", + frame: { id: 3, error: { code: -32000, message: "fork exploded" } }, + }, + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + const now = yield* DateTime.now; + const firstTurn = codexReplaySourceTurn({ + id: "provider-turn-first", + ordinal: 1, + nativeId: "native-turn-first", + providerThreadId: harness.providerThread.id, + now, + }); + + const error = yield* Effect.flip( + harness.runtime.forkThread({ + sourceProviderThread: harness.providerThread, + sourceProviderTurns: [firstTurn], + providerTurnId: firstTurn.id, + targetThreadId: ThreadId.make("thread-fork-failure-target"), + }), + ); + + assert.instanceOf(error, ProviderAdapterForkThreadError); + assert.include(errorCauseChainText(error), "fork exploded"); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ); }); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index f60a49178517..f91237cd60e2 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -43,6 +43,7 @@ import type { ThreadId, } from "@t3tools/contracts"; import * as CodexClient from "effect-codex-app-server/client"; +import * as CodexErrors from "effect-codex-app-server/errors"; import * as CodexSchema from "effect-codex-app-server/schema"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -831,6 +832,58 @@ const resolveCodexForkRollbackTurnCount = Effect.fn("CodexAdapterV2.resolveForkR }, ); +/** + * Prefer a native `thread/fork` turn boundary over the fork-then-rollback + * fallback. `lastTurnId` is inclusive on the Codex side, so a fork requested at + * the selected turn omits every later turn atomically. That matters for + * paginated threads, which reject `thread/rollback` entirely. The count + * fallback remains only for source turns that predate native turn references. + */ +export const resolveCodexForkBoundary = Effect.fn("CodexAdapterV2.resolveForkBoundary")(function* ( + input: ProviderAdapterV2ForkThreadInput, +) { + const rollbackTurnCount = yield* resolveCodexForkRollbackTurnCount(input); + if (input.providerTurnId === undefined || input.sourceProviderTurns === undefined) { + return { lastTurnId: undefined, rollbackTurnCount }; + } + + const boundaryTurn = providerTurnsForThread( + input.sourceProviderTurns, + input.sourceProviderThread, + ).find((turn) => turn.id === input.providerTurnId); + const nativeTurnId = boundaryTurn?.nativeTurnRef?.nativeId; + if (nativeTurnId === null || nativeTurnId === undefined) { + return { lastTurnId: undefined, rollbackTurnCount }; + } + + return { lastTurnId: nativeTurnId, rollbackTurnCount: 0 }; +}); + +/** + * The generated `thread/read` response schema does not surface `historyMode`, + * so the probe goes through the raw request channel with a permissive decode + * (mirrors the V1 session runtime's paginated-history detection). + */ +const CodexThreadHistoryMetadata = Schema.Struct({ + thread: Schema.Struct({ + historyMode: Schema.optionalKey(Schema.Literals(["legacy", "paginated"])), + }), +}); +const decodeCodexThreadHistoryMetadata = Schema.decodeUnknownEffect(CodexThreadHistoryMetadata); + +const readCodexThreadHistoryMode = Effect.fn("CodexAdapterV2.readThreadHistoryMode")(function* ( + raw: Pick, + threadId: string, +) { + const response = yield* raw.request("thread/read", { threadId, includeTurns: false }); + const metadata = yield* decodeCodexThreadHistoryMetadata(response).pipe( + Effect.mapError((error) => + CodexErrors.CodexAppServerRequestError.invalidPayload("thread/read", "decode-payload", error), + ), + ); + return metadata.thread.historyMode; +}); + export const resolveCodexRollbackTurnCount = Effect.fn("CodexAdapterV2.resolveRollbackTurnCount")( function* (input: ProviderAdapterV2RollbackThreadInput) { const providerTurns = input.providerThreadTurns; @@ -5583,6 +5636,19 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi runtimeRequests: [], }; } + // thread/rollback only exists for legacy-history threads; Codex + // rejects it on paginated threads and this adapter has no + // paginated rollback path yet, so surface that honestly. + const historyMode = yield* ensureInitialized.pipe( + Effect.andThen(readCodexThreadHistoryMode(client.raw, threadId)), + ); + if (historyMode === "paginated") { + return yield* new ProviderAdapterRollbackThreadError({ + driver: CODEX_PROVIDER, + providerThreadId: threadInput.providerThread.id, + cause: `Cannot roll back Codex thread ${threadId}: the thread uses paginated history, which rejects thread/rollback, and this adapter does not implement paginated conversation rollback.`, + }); + } const response = yield* ensureInitialized.pipe( Effect.andThen(client.request("thread/rollback", { threadId, numTurns })), ); @@ -5617,10 +5683,14 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi forkThread: (threadInput) => Effect.gen(function* () { const threadId = yield* getNativeThreadId(threadInput.sourceProviderThread); + const boundary = yield* resolveCodexForkBoundary(threadInput); const response = yield* ensureInitialized.pipe( Effect.andThen( client.request("thread/fork", { threadId, + ...(boundary.lastTurnId === undefined + ? {} + : { lastTurnId: boundary.lastTurnId }), ...codexThreadRuntimeParams({ threadId: threadInput.targetThreadId, ...(threadInput.modelSelection === undefined @@ -5641,26 +5711,39 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi }), ), ); - const rollbackTurnCount = yield* resolveCodexForkRollbackTurnCount(threadInput); - const forkedThread = - rollbackTurnCount === 0 - ? response.thread - : (yield* ensureInitialized.pipe( - Effect.andThen( - client.request("thread/rollback", { - threadId: response.thread.id, - numTurns: rollbackTurnCount, - }), - ), - Effect.mapError( - (cause) => - new ProviderAdapterForkThreadError({ - driver: CODEX_PROVIDER, - providerThreadId: threadInput.sourceProviderThread.id, - cause: normalizeCodexCause(cause), - }), - ), - )).thread; + let forkedThread = response.thread; + if (boundary.rollbackTurnCount > 0) { + // Reached only when the selected source turn has no native + // turn reference, so the fork had to be taken at head and then + // trimmed. thread/rollback is legacy-history only; on a + // paginated fork the boundary cannot be honored at all. + const historyMode = yield* ensureInitialized.pipe( + Effect.andThen(readCodexThreadHistoryMode(client.raw, response.thread.id)), + ); + if (historyMode === "paginated") { + return yield* new ProviderAdapterForkThreadError({ + driver: CODEX_PROVIDER, + providerThreadId: threadInput.sourceProviderThread.id, + cause: `Cannot fork Codex thread ${threadId} at provider turn ${threadInput.providerTurnId}: the source turn has no native Codex turn reference, and the forked thread uses paginated history which rejects thread/rollback.`, + }); + } + forkedThread = (yield* ensureInitialized.pipe( + Effect.andThen( + client.request("thread/rollback", { + threadId: response.thread.id, + numTurns: boundary.rollbackTurnCount, + }), + ), + Effect.mapError( + (cause) => + new ProviderAdapterForkThreadError({ + driver: CODEX_PROVIDER, + providerThreadId: threadInput.sourceProviderThread.id, + cause: normalizeCodexCause(cause), + }), + ), + )).thread; + } return providerThreadFromCodexThread({ appThreadId: threadInput.targetThreadId, idAllocator, diff --git a/apps/server/src/orchestration-v2/TODO.md b/apps/server/src/orchestration-v2/TODO.md index 8b2d93c0bf78..315a644c0d4a 100644 --- a/apps/server/src/orchestration-v2/TODO.md +++ b/apps/server/src/orchestration-v2/TODO.md @@ -12,8 +12,12 @@ implementation checklist for `apps/server/src/orchestration-v2`. - Checkpoint rollback is currently a full revert: filesystem checkpoint restore, provider thread rollback, stale checkpoint marking, and later run/node `rolled_back` projection state. - Codex same-provider fork is lazy: `thread.fork` records lineage and pending transfer, and first - dispatch resolves native Codex fork. Earlier source-point forks use native `thread/fork` followed - by fork-local `thread/rollback`. + dispatch resolves native Codex fork. Earlier source-point forks pass the source turn's native id + to `thread/fork` as `lastTurnId`; the fork-then-rollback fallback remains only for source turns + without a native turn reference and only on legacy-history threads. +- Codex provider conversation rollback supports only legacy-history threads. The adapter probes + `historyMode` and fails explicitly on paginated threads; the V1 session runtime's + `thread/turns/list` + `thread/revert` path is the reference for closing this gap. - Native Codex fork-from-earlier-run has a real replay-backed test fixture: `testkit/fixtures/thread_fork_native_prior_turn`. - Merge-back from a fork into its source thread records a `merge_back` context transfer, materializes diff --git a/apps/server/src/orchestration-v2/testkit/CodexReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/CodexReplayFixtures.integration.test.ts index 9643dabec756..86a247db3dd9 100644 --- a/apps/server/src/orchestration-v2/testkit/CodexReplayFixtures.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/CodexReplayFixtures.integration.test.ts @@ -272,7 +272,14 @@ const scenarioExpectations = { approvalRequestCount: 0, }, thread_rollback: { - outgoing: ["initialize", "initialized", "thread/start", "turn/start", "thread/rollback"], + outgoing: [ + "initialize", + "initialized", + "thread/start", + "turn/start", + "thread/read", + "thread/rollback", + ], incoming: ["turn/started", "turn/completed", "item/agentMessage/delta"], turnStartCount: 3, turnCompletedCount: 3, diff --git a/apps/server/src/orchestration-v2/testkit/ThreadFork.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ThreadFork.integration.test.ts index e616495edbcf..835ef4054b3e 100644 --- a/apps/server/src/orchestration-v2/testkit/ThreadFork.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ThreadFork.integration.test.ts @@ -714,188 +714,184 @@ describe("orchestration V2 thread fork", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect( - "rolls back a Codex native fork when forking from an earlier completed source turn", - () => - Effect.gen(function* () { - const rawTranscript = yield* readTranscript(PRIOR_TURN_TRANSCRIPT_PATH); - const cwd = yield* Effect.acquireRelease(makeCheckpointWorkspace, (directory) => - Effect.service(FileSystem.FileSystem).pipe( - Effect.flatMap((fs) => fs.remove(directory, { recursive: true, force: true })), - Effect.orDie, - ), - ); - const transcript = yield* CodexOrchestratorReplayHarness.decodeTranscript( - materializeReplayTranscriptWorkspace(rawTranscript, cwd), - ); - - const materialized = yield* Effect.gen(function* () { - const ids = yield* IdAllocatorV2; - const projectId = yield* ids.allocate.project({ - fixtureName: "thread-fork-native-prior-turn", - }); - const sourceThreadId = yield* ids.allocate.thread({ - fixtureName: "thread-fork-native-prior-turn-source", - projectId, - }); - const targetThreadId = ThreadId.make("thread-fork-native-prior-turn-target"); - const firstRunId = ids.derive.run({ threadId: sourceThreadId, ordinal: 1 }); + it.effect("forks a Codex native thread at the selected native turn boundary", () => + Effect.gen(function* () { + const rawTranscript = yield* readTranscript(PRIOR_TURN_TRANSCRIPT_PATH); + const cwd = yield* Effect.acquireRelease(makeCheckpointWorkspace, (directory) => + Effect.service(FileSystem.FileSystem).pipe( + Effect.flatMap((fs) => fs.remove(directory, { recursive: true, force: true })), + Effect.orDie, + ), + ); + const transcript = yield* CodexOrchestratorReplayHarness.decodeTranscript( + materializeReplayTranscriptWorkspace(rawTranscript, cwd), + ); - const commands = [ - { - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: yield* ids.allocate.command({ - fixtureName: "thread-fork-native-prior-turn", - commandName: "thread-create-source", - }), - threadId: sourceThreadId, - projectId, - title: "Source thread", - modelSelection: CODEX_MODEL_SELECTION, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: yield* ids.allocate.command({ - fixtureName: "thread-fork-native-prior-turn", - commandName: "source-message-alpha", - }), - threadId: sourceThreadId, - messageId: MessageId.make("message-thread-fork-native-prior-turn-alpha"), - text: THREAD_FORK_NATIVE_PRIOR_TURN_ALPHA_PROMPT, - attachments: [], - modelSelection: CODEX_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: yield* ids.allocate.command({ - fixtureName: "thread-fork-native-prior-turn", - commandName: "source-message-beta", - }), - threadId: sourceThreadId, - messageId: MessageId.make("message-thread-fork-native-prior-turn-beta"), - text: THREAD_FORK_NATIVE_PRIOR_TURN_BETA_PROMPT, - attachments: [], - modelSelection: CODEX_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }, - { - type: "thread.fork", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command-thread-fork-native-prior-turn"), - sourceThreadId, - targetThreadId, - sourcePoint: { type: "run", runId: firstRunId }, - title: "Forked from first response", - }, - { - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: yield* ids.allocate.command({ - fixtureName: "thread-fork-native-prior-turn", - commandName: "target-message-repeat", - }), - threadId: targetThreadId, - messageId: MessageId.make("message-thread-fork-native-prior-turn-repeat"), - text: THREAD_FORK_NATIVE_PRIOR_TURN_REPEAT_PROMPT, - attachments: [], - modelSelection: CODEX_MODEL_SELECTION, - dispatchMode: { type: "start_immediately" }, - }, - ] satisfies ReadonlyArray; + const materialized = yield* Effect.gen(function* () { + const ids = yield* IdAllocatorV2; + const projectId = yield* ids.allocate.project({ + fixtureName: "thread-fork-native-prior-turn", + }); + const sourceThreadId = yield* ids.allocate.thread({ + fixtureName: "thread-fork-native-prior-turn-source", + projectId, + }); + const targetThreadId = ThreadId.make("thread-fork-native-prior-turn-target"); + const firstRunId = ids.derive.run({ threadId: sourceThreadId, ordinal: 1 }); - return { + const commands = [ + { + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: yield* ids.allocate.command({ + fixtureName: "thread-fork-native-prior-turn", + commandName: "thread-create-source", + }), + threadId: sourceThreadId, + projectId, + title: "Source thread", + modelSelection: CODEX_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }, + { + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: yield* ids.allocate.command({ + fixtureName: "thread-fork-native-prior-turn", + commandName: "source-message-alpha", + }), + threadId: sourceThreadId, + messageId: MessageId.make("message-thread-fork-native-prior-turn-alpha"), + text: THREAD_FORK_NATIVE_PRIOR_TURN_ALPHA_PROMPT, + attachments: [], + modelSelection: CODEX_MODEL_SELECTION, + dispatchMode: { type: "start_immediately" }, + }, + { + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: yield* ids.allocate.command({ + fixtureName: "thread-fork-native-prior-turn", + commandName: "source-message-beta", + }), + threadId: sourceThreadId, + messageId: MessageId.make("message-thread-fork-native-prior-turn-beta"), + text: THREAD_FORK_NATIVE_PRIOR_TURN_BETA_PROMPT, + attachments: [], + modelSelection: CODEX_MODEL_SELECTION, + dispatchMode: { type: "start_immediately" }, + }, + { + type: "thread.fork", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command-thread-fork-native-prior-turn"), sourceThreadId, targetThreadId, - commands, - }; - }).pipe(Effect.provide(idAllocatorLayer), provideDeterministicTestRuntime); - - const result = yield* runOrchestratorV2ProviderReplayScenario( + sourcePoint: { type: "run", runId: firstRunId }, + title: "Forked from first response", + }, { - name: "thread_fork_native_prior_turn/codex", - transcript, - commands: materialized.commands, - steps: [ - { type: "dispatch", command: materialized.commands[0]!, await: true }, - { type: "advance_clock", duration: "1 millis" }, - { type: "dispatch", command: materialized.commands[1]!, await: true }, - { type: "await_thread_idle", threadId: materialized.sourceThreadId }, - { type: "dispatch", command: materialized.commands[2]!, await: true }, - { type: "await_thread_idle", threadId: materialized.sourceThreadId }, - { type: "dispatch", command: materialized.commands[3]!, await: true }, - { type: "dispatch", command: materialized.commands[4]!, await: true }, - { type: "await_thread_idle", threadId: materialized.targetThreadId }, - ], - projectionThreadIds: [materialized.sourceThreadId, materialized.targetThreadId], - runtimePolicyOverride: { cwd, ...CODEX_READ_ONLY_NEVER_POLICY }, + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: yield* ids.allocate.command({ + fixtureName: "thread-fork-native-prior-turn", + commandName: "target-message-repeat", + }), + threadId: targetThreadId, + messageId: MessageId.make("message-thread-fork-native-prior-turn-repeat"), + text: THREAD_FORK_NATIVE_PRIOR_TURN_REPEAT_PROMPT, + attachments: [], + modelSelection: CODEX_MODEL_SELECTION, + dispatchMode: { type: "start_immediately" }, }, - CodexOrchestratorReplayHarness, - ).pipe(provideDeterministicTestRuntime); + ] satisfies ReadonlyArray; - const targetProjection = result.projections.get(materialized.targetThreadId); - assert.isDefined(targetProjection); - const targetAssistantText = targetProjection.turnItems - .filter((item) => item.type === "assistant_message") - .map((item) => item.text) - .join("\n"); - assert.include(targetAssistantText, "fork boundary alpha"); - assert.notInclude( - targetAssistantText, - "fork boundary beta", - "forking from the first source run must not preserve later source turns in native Codex context", - ); - assert.equal(targetProjection.contextTransfers[0]?.resolution?.strategy, "native_fork"); + return { + sourceThreadId, + targetThreadId, + commands, + }; + }).pipe(Effect.provide(idAllocatorLayer), provideDeterministicTestRuntime); - const visibleItems = targetProjection.visibleTurnItems.map((row) => row.item); - assert.deepEqual( - visibleItems.slice(0, 2).map((item) => item.type), - ["user_message", "assistant_message"], - "fork target projection should expose inherited source history through the fork point", - ); - assert.equal( - visibleItems[0]?.type === "user_message" ? visibleItems[0].inputIntent : undefined, - "turn_start", - "inherited fork history should preserve source message intent", - ); - assert.equal(targetProjection.visibleTurnItems[0]?.visibility, "inherited"); - assert.equal(targetProjection.visibleTurnItems[1]?.visibility, "inherited"); - const forkMarker = targetProjection.visibleTurnItems.find( - (row) => row.item.type === "fork", - ); - assert.isDefined(forkMarker, "fork target projection should include a visible fork marker"); - assert.equal(forkMarker.visibility, "synthetic"); - const targetShell = result.shellSnapshot.threads.find( - (thread) => thread.id === materialized.targetThreadId, - ); - assert.isDefined(targetShell, "shell snapshot should include the fork target thread"); - assert.equal(targetShell.visibleItemCount, targetProjection.visibleTurnItems.length); - assert.equal(targetShell.lineage.relationshipToParent, "fork"); - assert.equal(targetShell.forkedFrom?.type, "run"); + const result = yield* runOrchestratorV2ProviderReplayScenario( + { + name: "thread_fork_native_prior_turn/codex", + transcript, + commands: materialized.commands, + steps: [ + { type: "dispatch", command: materialized.commands[0]!, await: true }, + { type: "advance_clock", duration: "1 millis" }, + { type: "dispatch", command: materialized.commands[1]!, await: true }, + { type: "await_thread_idle", threadId: materialized.sourceThreadId }, + { type: "dispatch", command: materialized.commands[2]!, await: true }, + { type: "await_thread_idle", threadId: materialized.sourceThreadId }, + { type: "dispatch", command: materialized.commands[3]!, await: true }, + { type: "dispatch", command: materialized.commands[4]!, await: true }, + { type: "await_thread_idle", threadId: materialized.targetThreadId }, + ], + projectionThreadIds: [materialized.sourceThreadId, materialized.targetThreadId], + runtimePolicyOverride: { cwd, ...CODEX_READ_ONLY_NEVER_POLICY }, + }, + CodexOrchestratorReplayHarness, + ).pipe(provideDeterministicTestRuntime); - const visibleText = visibleItems - .filter((item) => item.type === "user_message" || item.type === "assistant_message") - .map((item) => item.text) - .join("\n"); - assert.include(visibleText, "fork boundary alpha"); - assert.notInclude( - visibleText, - "fork boundary beta", - "fork target visible projection must not inherit source turns after the fork point", - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + const targetProjection = result.projections.get(materialized.targetThreadId); + assert.isDefined(targetProjection); + const targetAssistantText = targetProjection.turnItems + .filter((item) => item.type === "assistant_message") + .map((item) => item.text) + .join("\n"); + assert.include(targetAssistantText, "fork boundary alpha"); + assert.notInclude( + targetAssistantText, + "fork boundary beta", + "forking from the first source run must not preserve later source turns in native Codex context", + ); + assert.equal(targetProjection.contextTransfers[0]?.resolution?.strategy, "native_fork"); + + const visibleItems = targetProjection.visibleTurnItems.map((row) => row.item); + assert.deepEqual( + visibleItems.slice(0, 2).map((item) => item.type), + ["user_message", "assistant_message"], + "fork target projection should expose inherited source history through the fork point", + ); + assert.equal( + visibleItems[0]?.type === "user_message" ? visibleItems[0].inputIntent : undefined, + "turn_start", + "inherited fork history should preserve source message intent", + ); + assert.equal(targetProjection.visibleTurnItems[0]?.visibility, "inherited"); + assert.equal(targetProjection.visibleTurnItems[1]?.visibility, "inherited"); + const forkMarker = targetProjection.visibleTurnItems.find((row) => row.item.type === "fork"); + assert.isDefined(forkMarker, "fork target projection should include a visible fork marker"); + assert.equal(forkMarker.visibility, "synthetic"); + const targetShell = result.shellSnapshot.threads.find( + (thread) => thread.id === materialized.targetThreadId, + ); + assert.isDefined(targetShell, "shell snapshot should include the fork target thread"); + assert.equal(targetShell.visibleItemCount, targetProjection.visibleTurnItems.length); + assert.equal(targetShell.lineage.relationshipToParent, "fork"); + assert.equal(targetShell.forkedFrom?.type, "run"); + + const visibleText = visibleItems + .filter((item) => item.type === "user_message" || item.type === "assistant_message") + .map((item) => item.text) + .join("\n"); + assert.include(visibleText, "fork boundary alpha"); + assert.notInclude( + visibleText, + "fork boundary beta", + "fork target visible projection must not inherit source turns after the fork point", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); it.effect("forks a Claude native session from an earlier completed source turn", () => diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native/codex_transcript.ndjson index c3341992fa8e..4a996df37425 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native/codex_transcript.ndjson @@ -1,4 +1,4 @@ -{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"thread_fork_native","metadata":{"source":"hand-authored-replay","description":"One completed source turn, a native thread/fork, and the first target fork turn."}} +{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"thread_fork_native","metadata":{"source":"hand-authored-replay","description":"One completed source turn, a native thread/fork, and the first target fork turn.","normalization":"The thread/fork expected request gains lastTurnId (inclusive native boundary) to match the V2 adapter's native-boundary fork."}} {"type":"expect_outbound","label":"initialize/source","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} {"type":"emit_inbound","label":"initialize/source","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0","codexHome":"/tmp/codex","platformFamily":"unix","platformOs":"macos"}}} {"type":"expect_outbound","label":"initialized/source","frame":{"method":"initialized"}} @@ -10,7 +10,7 @@ {"type":"emit_inbound","label":"item/completed/source-user","frame":{"method":"item/completed","params":{"item":{"type":"userMessage","id":"native-source-user-item","content":[{"type":"text","text":"Respond with the following text: source fork seed ok","text_elements":[]}]},"threadId":"native-source-thread","turnId":"native-source-turn"}}} {"type":"emit_inbound","label":"item/completed/source-agent","frame":{"method":"item/completed","params":{"item":{"type":"agentMessage","id":"native-source-agent-item","text":"source fork seed ok","phase":"final_answer","memoryCitation":null},"threadId":"native-source-thread","turnId":"native-source-turn"}}} {"type":"emit_inbound","label":"turn/completed/source","frame":{"method":"turn/completed","params":{"threadId":"native-source-thread","turn":{"id":"native-source-turn","items":[],"status":"completed","error":null,"startedAt":1776739349,"completedAt":1776739356,"durationMs":6434}}}} -{"type":"expect_outbound","label":"thread/fork","frame":{"id":4,"method":"thread/fork","params":{"threadId":"native-source-thread"}}} +{"type":"expect_outbound","label":"thread/fork","frame":{"id":4,"method":"thread/fork","params":{"threadId":"native-source-thread","lastTurnId":"native-source-turn"}}} {"type":"emit_inbound","label":"thread/fork","frame":{"id":4,"result":{"thread":{"id":"native-fork-thread","forkedFromId":"native-source-thread","preview":"","ephemeral":false,"modelProvider":"openai","createdAt":1776739360,"updatedAt":1776739360,"status":{"type":"idle"},"path":"/tmp/fork.jsonl","cwd":"/tmp/project","cliVersion":"0.120.0","source":"vscode","agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"turns":[]},"model":"gpt-5.4","modelProvider":"openai","serviceTier":"fast","cwd":"/tmp/project","approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"readOnlyAccess":{"type":"fullAccess"},"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"reasoningEffort":"xhigh"}}} {"type":"expect_outbound","label":"turn/start/fork","frame":{"id":5,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"text":"Respond with the following text: fork native ok","type":"text"}],"model":"gpt-5.4","sandboxPolicy":{"type":"dangerFullAccess"},"threadId":"native-fork-thread","approvalsReviewer":"user"}}} {"type":"emit_inbound","label":"turn/start/fork","frame":{"id":5,"result":{"turn":{"id":"native-fork-turn","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_prior_turn/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_prior_turn/codex_transcript.ndjson index 146505fbc71a..41817f9c9ad7 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_prior_turn/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_prior_turn/codex_transcript.ndjson @@ -1,4 +1,4 @@ -{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.124.0-alpha.3","scenario":"thread_fork_native_prior_turn","metadata":{"source":"codex-app-server-live-probe-normalized-for-orchestrator-replay","description":"Two completed source turns, native thread/fork, rollback of the fork to the first source turn, and a fork turn that only sees the first turn.","sourceThreadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","forkThreadId":"019dd6ba-47b7-7092-8688-9cf7fe5f6498","firstTurnId":"019dd6ba-268b-7f82-8f4c-9fde0edcfd55","secondTurnId":"019dd6ba-416d-7902-95cf-d4179c3f4738","repeatTurnId":"019dd6ba-47eb-7041-ad45-5abe752c28c9","normalization":"Native frames are live. Initialize frames are normalized to the T3 adapter client info, and a fork-session initialize was inserted to match orchestrator provider-session lifecycle."}} +{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.124.0-alpha.3","scenario":"thread_fork_native_prior_turn","metadata":{"source":"codex-app-server-live-probe-normalized-for-orchestrator-replay","description":"Two completed source turns, a native thread/fork through the first source turn, and a fork turn that only sees the first turn.","sourceThreadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","forkThreadId":"019dd6ba-47b7-7092-8688-9cf7fe5f6498","firstTurnId":"019dd6ba-268b-7f82-8f4c-9fde0edcfd55","secondTurnId":"019dd6ba-416d-7902-95cf-d4179c3f4738","repeatTurnId":"019dd6ba-47eb-7041-ad45-5abe752c28c9","normalization":"Native frames are live. Initialize frames are normalized to the T3 adapter client info, and a fork-session initialize was inserted to match orchestrator provider-session lifecycle. The thread/fork expected request gains lastTurnId (the first source turn, inclusive), the emitted fork response now contains only turns through that boundary, and the thread/rollback pair was removed to match the V2 adapter's native-boundary fork."}} {"type":"expect_outbound","label":"initialize/source","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} {"type":"emit_inbound","label":"initialize/source","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.124.0-alpha.3","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} {"type":"expect_outbound","label":"initialized/source","frame":{"method":"initialized"}} @@ -48,21 +48,16 @@ {"type":"emit_inbound","label":"account/rateLimits/updated","frame":{"method":"account/rateLimits/updated","params":{"rateLimits":{"limitId":"codex","limitName":null,"primary":{"usedPercent":61,"windowDurationMins":300,"resetsAt":1777429830},"secondary":{"usedPercent":23,"windowDurationMins":10080,"resetsAt":1777959590},"credits":null,"planType":"pro","rateLimitReachedType":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","status":{"type":"idle"}}}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","turn":{"id":"019dd6ba-416d-7902-95cf-d4179c3f4738","items":[],"status":"completed","error":null,"startedAt":1777424023,"completedAt":1777424025,"durationMs":1596}}}} -{"type":"expect_outbound","label":"thread/fork","frame":{"id":5,"method":"thread/fork","params":{"threadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27"}}} +{"type":"expect_outbound","label":"thread/fork","frame":{"id":5,"method":"thread/fork","params":{"threadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","lastTurnId":"019dd6ba-268b-7f82-8f4c-9fde0edcfd55"}}} {"type":"emit_inbound","label":"deprecationNotice","frame":{"method":"deprecationNotice","params":{"summary":"`[features].collab` is deprecated. Use `[features].multi_agent` instead.","details":"Enable it with `--enable multi_agent` or `[features].multi_agent` in config.toml. See https://developers.openai.com/codex/config-basic#feature-flags for details."}}} {"type":"emit_inbound","label":"warning","frame":{"method":"warning","params":{"threadId":"019dd6ba-47b7-7092-8688-9cf7fe5f6498","message":"Under-development features enabled: apply_patch_freeform. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` in /Users/julius/.codex/config.toml."}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"name":"uidotsh","status":"starting","error":null}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"name":"computer-use","status":"starting","error":null}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"name":"codex_apps","status":"starting","error":null}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"name":"computer-use","status":"ready","error":null}}} -{"type":"emit_inbound","label":"thread/fork","frame":{"id":5,"result":{"thread":{"id":"019dd6ba-47b7-7092-8688-9cf7fe5f6498","forkedFromId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","preview":"For this fork-boundary fixture, respond with exactly: fork boundary alpha","ephemeral":false,"modelProvider":"openai","createdAt":1777424025,"updatedAt":1777424025,"status":{"type":"idle"},"path":"/Users/julius/.codex/sessions/2026/04/28/rollout-2026-04-28T17-53-45-019dd6ba-47b7-7092-8688-9cf7fe5f6498.jsonl","cwd":"/Users/julius/.t3/worktrees/codething-mvp/t3code-c1e5e1d1","cliVersion":"0.124.0-alpha.3","source":"vscode","agentNickname":null,"agentRole":null,"gitInfo":{"sha":"969e8ea4fac1f2066ed50dd4f82b5fd0014d8a4d","branch":"t3code/codex-turn-mapping","originUrl":"git@github.com:pingdotgg/t3code.git"},"name":null,"turns":[{"id":"019dd6ba-268b-7f82-8f4c-9fde0edcfd55","items":[{"type":"userMessage","id":"item-1","content":[{"type":"text","text":"For this fork-boundary fixture, respond with exactly: fork boundary alpha","text_elements":[]}]},{"type":"agentMessage","id":"item-2","text":"fork boundary alpha","phase":"final_answer","memoryCitation":null}],"status":"completed","error":null,"startedAt":1777424017,"completedAt":1777424023,"durationMs":6880},{"id":"019dd6ba-416d-7902-95cf-d4179c3f4738","items":[{"type":"userMessage","id":"item-3","content":[{"type":"text","text":"For this fork-boundary fixture, respond with exactly: fork boundary beta","text_elements":[]}]},{"type":"agentMessage","id":"item-4","text":"fork boundary beta","phase":"final_answer","memoryCitation":null}],"status":"completed","error":null,"startedAt":1777424023,"completedAt":1777424025,"durationMs":1596}]},"model":"gpt-5.5","modelProvider":"openai","serviceTier":"fast","cwd":"/Users/julius/.t3/worktrees/codething-mvp/t3code-c1e5e1d1","instructionSources":["/Users/julius/.codex/AGENTS.md","/Users/julius/.t3/worktrees/codething-mvp/t3code-c1e5e1d1/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":["/Users/julius/.codex/memories"],"readOnlyAccess":{"type":"fullAccess"},"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"permissionProfile":{"network":{"enabled":false},"fileSystem":{"entries":[{"path":{"type":"special","value":{"kind":"root"}},"access":"read"},{"path":{"type":"special","value":{"kind":"current_working_directory"}},"access":"write"},{"path":{"type":"special","value":{"kind":"slash_tmp"}},"access":"write"},{"path":{"type":"special","value":{"kind":"tmpdir"}},"access":"write"},{"path":{"type":"path","path":"/Users/julius/.codex/memories"},"access":"write"},{"path":{"type":"path","path":"/Users/julius/Development/Work/codething-mvp/.git/worktrees/t3code-c1e5e1d1"},"access":"read"},{"path":{"type":"path","path":"/Users/julius/.t3/worktrees/codething-mvp/t3code-c1e5e1d1/.git"},"access":"read"},{"path":{"type":"path","path":"/Users/julius/.t3/worktrees/codething-mvp/t3code-c1e5e1d1/.codex"},"access":"read"}]}},"reasoningEffort":"medium"}}} -{"type":"expect_outbound","label":"thread/rollback","frame":{"id":6,"method":"thread/rollback","params":{"threadId":"019dd6ba-47b7-7092-8688-9cf7fe5f6498","numTurns":1}}} -{"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"019dd6ba-47b7-7092-8688-9cf7fe5f6498","turnId":"019dd6ba-416d-7902-95cf-d4179c3f4738","tokenUsage":{"total":{"totalTokens":53852,"inputTokens":53827,"cachedInputTokens":29952,"outputTokens":25,"reasoningOutputTokens":9},"last":{"totalTokens":26934,"inputTokens":26927,"cachedInputTokens":26496,"outputTokens":7,"reasoningOutputTokens":0},"modelContextWindow":258400}}}} -{"type":"emit_inbound","label":"thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"019dd6ba-47b7-7092-8688-9cf7fe5f6498","forkedFromId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","preview":"For this fork-boundary fixture, respond with exactly: fork boundary alpha","ephemeral":false,"modelProvider":"openai","createdAt":1777424025,"updatedAt":1777424025,"status":{"type":"idle"},"path":"/Users/julius/.codex/sessions/2026/04/28/rollout-2026-04-28T17-53-45-019dd6ba-47b7-7092-8688-9cf7fe5f6498.jsonl","cwd":"/Users/julius/.t3/worktrees/codething-mvp/t3code-c1e5e1d1","cliVersion":"0.124.0-alpha.3","source":"vscode","agentNickname":null,"agentRole":null,"gitInfo":{"sha":"969e8ea4fac1f2066ed50dd4f82b5fd0014d8a4d","branch":"t3code/codex-turn-mapping","originUrl":"git@github.com:pingdotgg/t3code.git"},"name":null,"turns":[{"id":"019dd6ba-268b-7f82-8f4c-9fde0edcfd55","items":[{"type":"userMessage","id":"item-1","content":[{"type":"text","text":"For this fork-boundary fixture, respond with exactly: fork boundary alpha","text_elements":[]}]},{"type":"agentMessage","id":"item-2","text":"fork boundary alpha","phase":"final_answer","memoryCitation":null}],"status":"completed","error":null,"startedAt":1777424017,"completedAt":1777424023,"durationMs":6880},{"id":"019dd6ba-416d-7902-95cf-d4179c3f4738","items":[{"type":"userMessage","id":"item-3","content":[{"type":"text","text":"For this fork-boundary fixture, respond with exactly: fork boundary beta","text_elements":[]}]},{"type":"agentMessage","id":"item-4","text":"fork boundary beta","phase":"final_answer","memoryCitation":null}],"status":"completed","error":null,"startedAt":1777424023,"completedAt":1777424025,"durationMs":1596}]}}}} -{"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"019dd6ba-47b7-7092-8688-9cf7fe5f6498","turnId":"019dd6ba-47e9-7ed2-ba44-374e8b74cf27","tokenUsage":{"total":{"totalTokens":53852,"inputTokens":53827,"cachedInputTokens":29952,"outputTokens":25,"reasoningOutputTokens":9},"last":{"totalTokens":10877,"inputTokens":0,"cachedInputTokens":0,"outputTokens":0,"reasoningOutputTokens":0},"modelContextWindow":258400}}}} -{"type":"emit_inbound","label":"thread/rollback","frame":{"id":6,"result":{"thread":{"id":"019dd6ba-47b7-7092-8688-9cf7fe5f6498","forkedFromId":null,"preview":"For this fork-boundary fixture, respond with exactly: fork boundary alpha","ephemeral":false,"modelProvider":"openai","createdAt":1777424025,"updatedAt":1777424025,"status":{"type":"idle"},"path":"/Users/julius/.codex/sessions/2026/04/28/rollout-2026-04-28T17-53-45-019dd6ba-47b7-7092-8688-9cf7fe5f6498.jsonl","cwd":"/Users/julius/.t3/worktrees/codething-mvp/t3code-c1e5e1d1","cliVersion":"0.124.0-alpha.3","source":"vscode","agentNickname":null,"agentRole":null,"gitInfo":{"sha":"969e8ea4fac1f2066ed50dd4f82b5fd0014d8a4d","branch":"t3code/codex-turn-mapping","originUrl":"git@github.com:pingdotgg/t3code.git"},"name":null,"turns":[{"id":"019dd6ba-268b-7f82-8f4c-9fde0edcfd55","items":[{"type":"userMessage","id":"item-1","content":[{"type":"text","text":"For this fork-boundary fixture, respond with exactly: fork boundary alpha","text_elements":[]}]},{"type":"agentMessage","id":"item-2","text":"fork boundary alpha","phase":"final_answer","memoryCitation":null}],"status":"completed","error":null,"startedAt":1777424017,"completedAt":1777424023,"durationMs":6880}]}}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":7,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"type":"text","text":"Repeat the user-visible conversation so far verbatim. Include only user and assistant messages. Do not include hidden system/developer content."}],"model":"gpt-5.4","sandboxPolicy":{"type":"readOnly","networkAccess":false},"threadId":"019dd6ba-47b7-7092-8688-9cf7fe5f6498","approvalsReviewer":"user"}}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":7,"result":{"turn":{"id":"019dd6ba-47eb-7041-ad45-5abe752c28c9","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"thread/fork","frame":{"id":5,"result":{"thread":{"id":"019dd6ba-47b7-7092-8688-9cf7fe5f6498","forkedFromId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","preview":"For this fork-boundary fixture, respond with exactly: fork boundary alpha","ephemeral":false,"modelProvider":"openai","createdAt":1777424025,"updatedAt":1777424025,"status":{"type":"idle"},"path":"/Users/julius/.codex/sessions/2026/04/28/rollout-2026-04-28T17-53-45-019dd6ba-47b7-7092-8688-9cf7fe5f6498.jsonl","cwd":"/Users/julius/.t3/worktrees/codething-mvp/t3code-c1e5e1d1","cliVersion":"0.124.0-alpha.3","source":"vscode","agentNickname":null,"agentRole":null,"gitInfo":{"sha":"969e8ea4fac1f2066ed50dd4f82b5fd0014d8a4d","branch":"t3code/codex-turn-mapping","originUrl":"git@github.com:pingdotgg/t3code.git"},"name":null,"turns":[{"id":"019dd6ba-268b-7f82-8f4c-9fde0edcfd55","items":[{"type":"userMessage","id":"item-1","content":[{"type":"text","text":"For this fork-boundary fixture, respond with exactly: fork boundary alpha","text_elements":[]}]},{"type":"agentMessage","id":"item-2","text":"fork boundary alpha","phase":"final_answer","memoryCitation":null}],"status":"completed","error":null,"startedAt":1777424017,"completedAt":1777424023,"durationMs":6880}]},"model":"gpt-5.5","modelProvider":"openai","serviceTier":"fast","cwd":"/Users/julius/.t3/worktrees/codething-mvp/t3code-c1e5e1d1","instructionSources":["/Users/julius/.codex/AGENTS.md","/Users/julius/.t3/worktrees/codething-mvp/t3code-c1e5e1d1/AGENTS.md"],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":["/Users/julius/.codex/memories"],"readOnlyAccess":{"type":"fullAccess"},"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"permissionProfile":{"network":{"enabled":false},"fileSystem":{"entries":[{"path":{"type":"special","value":{"kind":"root"}},"access":"read"},{"path":{"type":"special","value":{"kind":"current_working_directory"}},"access":"write"},{"path":{"type":"special","value":{"kind":"slash_tmp"}},"access":"write"},{"path":{"type":"special","value":{"kind":"tmpdir"}},"access":"write"},{"path":{"type":"path","path":"/Users/julius/.codex/memories"},"access":"write"},{"path":{"type":"path","path":"/Users/julius/Development/Work/codething-mvp/.git/worktrees/t3code-c1e5e1d1"},"access":"read"},{"path":{"type":"path","path":"/Users/julius/.t3/worktrees/codething-mvp/t3code-c1e5e1d1/.git"},"access":"read"},{"path":{"type":"path","path":"/Users/julius/.t3/worktrees/codething-mvp/t3code-c1e5e1d1/.codex"},"access":"read"}]}},"reasoningEffort":"medium"}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":6,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"type":"text","text":"Repeat the user-visible conversation so far verbatim. Include only user and assistant messages. Do not include hidden system/developer content."}],"model":"gpt-5.4","sandboxPolicy":{"type":"readOnly","networkAccess":false},"threadId":"019dd6ba-47b7-7092-8688-9cf7fe5f6498","approvalsReviewer":"user"}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":6,"result":{"turn":{"id":"019dd6ba-47eb-7041-ad45-5abe752c28c9","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019dd6ba-47b7-7092-8688-9cf7fe5f6498","status":{"type":"active","activeFlags":[]}}}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"019dd6ba-47b7-7092-8688-9cf7fe5f6498","turn":{"id":"019dd6ba-47eb-7041-ad45-5abe752c28c9","items":[],"status":"inProgress","error":null,"startedAt":1777424025,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"name":"uidotsh","status":"ready","error":null}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_continue/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_continue/codex_transcript.ndjson index dc3983cfb438..06978c150131 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_continue/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_continue/codex_transcript.ndjson @@ -1,4 +1,4 @@ -{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.137.0","scenario":"thread_merge_back_continue","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_merge_back_continue.ndjson","description":"A source thread consumes one fork-delta handoff and later recalls source and transferred context."}} +{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.137.0","scenario":"thread_merge_back_continue","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_merge_back_continue.ndjson","description":"A source thread consumes one fork-delta handoff and later recalls source and transferred context.","normalization":"The thread/fork expected request gains lastTurnId (inclusive native boundary) to match the V2 adapter's native-boundary fork."}} {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.137.0 (Mac OS 26.5.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} @@ -34,7 +34,7 @@ {"type":"emit_inbound","label":"account/rateLimits/updated","frame":{"method":"account/rateLimits/updated","params":{"rateLimits":{"limitId":"codex","limitName":null,"primary":{"usedPercent":0,"windowDurationMins":300,"resetsAt":1780968999},"secondary":{"usedPercent":3,"windowDurationMins":10080,"resetsAt":1781296757},"credits":null,"individualLimit":null,"planType":"pro","rateLimitReachedType":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019ea9a7-ac5e-74f0-a17c-75426d4c40c1","status":{"type":"idle"}}}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"019ea9a7-ac5e-74f0-a17c-75426d4c40c1","turn":{"id":"019ea9a7-adb0-77a2-85a6-3d3b6ff5ef67","items":[],"itemsView":"notLoaded","status":"completed","error":null,"startedAt":1780962799,"completedAt":1780962804,"durationMs":5102}}}} -{"type":"expect_outbound","label":"thread/fork","frame":{"id":4,"method":"thread/fork","params":{"threadId":"019ea9a7-ac5e-74f0-a17c-75426d4c40c1"}}} +{"type":"expect_outbound","label":"thread/fork","frame":{"id":4,"method":"thread/fork","params":{"threadId":"019ea9a7-ac5e-74f0-a17c-75426d4c40c1","lastTurnId":"019ea9a7-adb0-77a2-85a6-3d3b6ff5ef67"}}} {"type":"emit_inbound","label":"deprecationNotice","frame":{"method":"deprecationNotice","params":{"summary":"`[features].collab` is deprecated. Use `[features].multi_agent` instead.","details":"Enable it with `--enable multi_agent` or `[features].multi_agent` in config.toml. See https://developers.openai.com/codex/config-basic#feature-flags for details."}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"name":"uidotsh","status":"starting","error":null}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"name":"xcodebuildmcp","status":"starting","error":null}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_siblings/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_siblings/codex_transcript.ndjson index 9ff75cf978ca..b071c17089e2 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_siblings/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_siblings/codex_transcript.ndjson @@ -1,4 +1,4 @@ -{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.137.0","scenario":"thread_merge_back_siblings","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_merge_back_siblings.ndjson","description":"Two sibling fork deltas are merged sequentially into one source provider thread and recalled together."}} +{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.137.0","scenario":"thread_merge_back_siblings","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_merge_back_siblings.ndjson","description":"Two sibling fork deltas are merged sequentially into one source provider thread and recalled together.","normalization":"The thread/fork expected requests gain lastTurnId (inclusive native boundary) to match the V2 adapter's native-boundary fork."}} {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.137.0 (Mac OS 26.5.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} @@ -35,7 +35,7 @@ {"type":"emit_inbound","label":"account/rateLimits/updated","frame":{"method":"account/rateLimits/updated","params":{"rateLimits":{"limitId":"codex","limitName":null,"primary":{"usedPercent":0,"windowDurationMins":300,"resetsAt":1780968999},"secondary":{"usedPercent":3,"windowDurationMins":10080,"resetsAt":1781296757},"credits":null,"individualLimit":null,"planType":"pro","rateLimitReachedType":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019ea9a7-e28f-7c72-96e8-9ca581f225ce","status":{"type":"idle"}}}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"019ea9a7-e28f-7c72-96e8-9ca581f225ce","turn":{"id":"019ea9a7-e43a-74f2-b94e-99f168de38fd","items":[],"itemsView":"notLoaded","status":"completed","error":null,"startedAt":1780962813,"completedAt":1780962817,"durationMs":4111}}}} -{"type":"expect_outbound","label":"thread/fork","frame":{"id":4,"method":"thread/fork","params":{"threadId":"019ea9a7-e28f-7c72-96e8-9ca581f225ce"}}} +{"type":"expect_outbound","label":"thread/fork","frame":{"id":4,"method":"thread/fork","params":{"threadId":"019ea9a7-e28f-7c72-96e8-9ca581f225ce","lastTurnId":"019ea9a7-e43a-74f2-b94e-99f168de38fd"}}} {"type":"emit_inbound","label":"deprecationNotice","frame":{"method":"deprecationNotice","params":{"summary":"`[features].collab` is deprecated. Use `[features].multi_agent` instead.","details":"Enable it with `--enable multi_agent` or `[features].multi_agent` in config.toml. See https://developers.openai.com/codex/config-basic#feature-flags for details."}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"name":"computer-use","status":"starting","error":null}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"name":"codex_apps","status":"starting","error":null}}} @@ -68,7 +68,7 @@ {"type":"emit_inbound","label":"account/rateLimits/updated","frame":{"method":"account/rateLimits/updated","params":{"rateLimits":{"limitId":"codex","limitName":null,"primary":{"usedPercent":0,"windowDurationMins":300,"resetsAt":1780968999},"secondary":{"usedPercent":3,"windowDurationMins":10080,"resetsAt":1781296757},"credits":null,"individualLimit":null,"planType":"pro","rateLimitReachedType":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019ea9a7-f48f-74e2-b87e-4a7e9313b57a","status":{"type":"idle"}}}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"019ea9a7-f48f-74e2-b87e-4a7e9313b57a","turn":{"id":"019ea9a7-f5bd-7c22-a906-5625e1d8564d","items":[],"itemsView":"notLoaded","status":"completed","error":null,"startedAt":1780962817,"completedAt":1780962822,"durationMs":4566}}}} -{"type":"expect_outbound","label":"thread/fork","frame":{"id":6,"method":"thread/fork","params":{"threadId":"019ea9a7-e28f-7c72-96e8-9ca581f225ce"}}} +{"type":"expect_outbound","label":"thread/fork","frame":{"id":6,"method":"thread/fork","params":{"threadId":"019ea9a7-e28f-7c72-96e8-9ca581f225ce","lastTurnId":"019ea9a7-e43a-74f2-b94e-99f168de38fd"}}} {"type":"emit_inbound","label":"deprecationNotice","frame":{"method":"deprecationNotice","params":{"summary":"`[features].collab` is deprecated. Use `[features].multi_agent` instead.","details":"Enable it with `--enable multi_agent` or `[features].multi_agent` in config.toml. See https://developers.openai.com/codex/config-basic#feature-flags for details."}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"name":"node_repl","status":"starting","error":null}}} {"type":"emit_inbound","label":"mcpServer/startupStatus/updated","frame":{"method":"mcpServer/startupStatus/updated","params":{"name":"uidotsh","status":"starting","error":null}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback/codex_transcript.ndjson index 6d39a229136d..27f61b18e1cc 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback/codex_transcript.ndjson @@ -1,4 +1,4 @@ -{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"thread_rollback","metadata":{"source":"codex-app-server-probe","fileName":"thread_rollback.ndjson","description":"One thread completes two turns, rolls back the most recent turn, then starts another turn."}} +{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"thread_rollback","metadata":{"source":"codex-app-server-probe","fileName":"thread_rollback.ndjson","description":"One thread completes two turns, rolls back the most recent turn, then starts another turn.","normalization":" A synthesized thread/read history-mode probe was inserted before thread/rollback to match the V2 adapter paginated-history check."}} {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}} {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}} {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} @@ -49,12 +49,14 @@ {"type":"emit_inbound","label":"account/rateLimits/updated","frame":{"method":"account/rateLimits/updated","params":{"rateLimits":{"limitId":"codex","limitName":null,"primary":{"usedPercent":0,"windowDurationMins":300,"resetsAt":1776748632},"secondary":{"usedPercent":0,"windowDurationMins":10080,"resetsAt":1777335432},"credits":null,"planType":"pro"}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019daded-23e0-7100-87d7-12089c71339a","status":{"type":"idle"}}}} {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"019daded-23e0-7100-87d7-12089c71339a","turn":{"id":"019daded-454a-70e2-acce-09a54f6192d0","items":[],"status":"completed","error":null,"startedAt":1776739501,"completedAt":1776739504,"durationMs":3091}}}} -{"type":"expect_outbound","label":"thread/rollback","frame":{"id":5,"method":"thread/rollback","params":{"numTurns":1,"threadId":"019daded-23e0-7100-87d7-12089c71339a"}}} +{"type":"expect_outbound","label":"thread/read","frame":{"id":5,"method":"thread/read","params":{"threadId":"019daded-23e0-7100-87d7-12089c71339a","includeTurns":false}}} +{"type":"emit_inbound","label":"thread/read","frame":{"id":5,"result":{"thread":{"id":"019daded-23e0-7100-87d7-12089c71339a","forkedFromId":null,"preview":"","ephemeral":false,"modelProvider":"openai","createdAt":1776739492,"updatedAt":1776739492,"status":{"type":"idle"},"path":"/Users/julius/.codex/sessions/2026/04/20/rollout-2026-04-20T19-44-52-019daded-23e0-7100-87d7-12089c71339a.jsonl","cwd":"/Users/julius/.t3/worktrees/codething-mvp/t3code-c1e5e1d1/packages/effect-codex-app-server","cliVersion":"0.120.0","source":"vscode","agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"turns":[],"historyMode":"legacy"}}}} +{"type":"expect_outbound","label":"thread/rollback","frame":{"id":6,"method":"thread/rollback","params":{"numTurns":1,"threadId":"019daded-23e0-7100-87d7-12089c71339a"}}} {"type":"emit_inbound","label":"thread/tokenUsage/updated","frame":{"method":"thread/tokenUsage/updated","params":{"threadId":"019daded-23e0-7100-87d7-12089c71339a","turnId":"019daded-515f-7642-b0db-bd62c3f7a473","tokenUsage":{"total":{"totalTokens":56503,"inputTokens":56470,"cachedInputTokens":56064,"outputTokens":33,"reasoningOutputTokens":13},"last":{"totalTokens":12317,"inputTokens":0,"cachedInputTokens":0,"outputTokens":0,"reasoningOutputTokens":0},"modelContextWindow":258400}}}} {"type":"emit_inbound","label":"account/rateLimits/updated","frame":{"method":"account/rateLimits/updated","params":{"rateLimits":{"limitId":"codex","limitName":null,"primary":{"usedPercent":0,"windowDurationMins":300,"resetsAt":1776748632},"secondary":{"usedPercent":0,"windowDurationMins":10080,"resetsAt":1777335432},"credits":null,"planType":"pro"}}}} -{"type":"emit_inbound","label":"thread/rollback","frame":{"id":5,"result":{"thread":{"id":"019daded-23e0-7100-87d7-12089c71339a","forkedFromId":null,"preview":"Respond with exactly: rollback fixture first turn complete","ephemeral":false,"modelProvider":"openai","createdAt":1776739492,"updatedAt":1776739504,"status":{"type":"idle"},"path":"/Users/julius/.codex/sessions/2026/04/20/rollout-2026-04-20T19-44-52-019daded-23e0-7100-87d7-12089c71339a.jsonl","cwd":"/Users/julius/.t3/worktrees/codething-mvp/t3code-c1e5e1d1/packages/effect-codex-app-server","cliVersion":"0.120.0","source":"vscode","agentNickname":null,"agentRole":null,"gitInfo":{"sha":"7eefb2eb1d98f6b814c9fd7f43652727059c3326","branch":"t3code/codex-turn-mapping","originUrl":"git@github.com:pingdotgg/t3code.git"},"name":null,"turns":[{"id":"019daded-23e9-7100-8f16-28223024be18","items":[{"type":"userMessage","id":"item-1","content":[{"type":"text","text":"Respond with exactly: rollback fixture first turn complete","text_elements":[]}]},{"type":"agentMessage","id":"item-2","text":"rollback fixture first turn complete","phase":"final_answer","memoryCitation":null}],"status":"completed","error":null,"startedAt":1776739492,"completedAt":1776739501,"durationMs":8544}]}}}} -{"type":"expect_outbound","label":"turn/start","frame":{"id":6,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"text":"Repeat the conversation verbatim.","type":"text"}],"model":"gpt-5.4","sandboxPolicy":{"type":"dangerFullAccess"},"threadId":"019daded-23e0-7100-87d7-12089c71339a","approvalsReviewer":"user"}}} -{"type":"emit_inbound","label":"turn/start","frame":{"id":6,"result":{"turn":{"id":"019daded-5165-7f73-a6bc-3cf4386b542c","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} +{"type":"emit_inbound","label":"thread/rollback","frame":{"id":6,"result":{"thread":{"id":"019daded-23e0-7100-87d7-12089c71339a","forkedFromId":null,"preview":"Respond with exactly: rollback fixture first turn complete","ephemeral":false,"modelProvider":"openai","createdAt":1776739492,"updatedAt":1776739504,"status":{"type":"idle"},"path":"/Users/julius/.codex/sessions/2026/04/20/rollout-2026-04-20T19-44-52-019daded-23e0-7100-87d7-12089c71339a.jsonl","cwd":"/Users/julius/.t3/worktrees/codething-mvp/t3code-c1e5e1d1/packages/effect-codex-app-server","cliVersion":"0.120.0","source":"vscode","agentNickname":null,"agentRole":null,"gitInfo":{"sha":"7eefb2eb1d98f6b814c9fd7f43652727059c3326","branch":"t3code/codex-turn-mapping","originUrl":"git@github.com:pingdotgg/t3code.git"},"name":null,"turns":[{"id":"019daded-23e9-7100-8f16-28223024be18","items":[{"type":"userMessage","id":"item-1","content":[{"type":"text","text":"Respond with exactly: rollback fixture first turn complete","text_elements":[]}]},{"type":"agentMessage","id":"item-2","text":"rollback fixture first turn complete","phase":"final_answer","memoryCitation":null}],"status":"completed","error":null,"startedAt":1776739492,"completedAt":1776739501,"durationMs":8544}]}}}} +{"type":"expect_outbound","label":"turn/start","frame":{"id":7,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"text":"Repeat the conversation verbatim.","type":"text"}],"model":"gpt-5.4","sandboxPolicy":{"type":"dangerFullAccess"},"threadId":"019daded-23e0-7100-87d7-12089c71339a","approvalsReviewer":"user"}}} +{"type":"emit_inbound","label":"turn/start","frame":{"id":7,"result":{"turn":{"id":"019daded-5165-7f73-a6bc-3cf4386b542c","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019daded-23e0-7100-87d7-12089c71339a","status":{"type":"active","activeFlags":[]}}}} {"type":"emit_inbound","label":"turn/started","frame":{"method":"turn/started","params":{"threadId":"019daded-23e0-7100-87d7-12089c71339a","turn":{"id":"019daded-5165-7f73-a6bc-3cf4386b542c","items":[],"status":"inProgress","error":null,"startedAt":1776739504,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"item/started","frame":{"method":"item/started","params":{"item":{"type":"userMessage","id":"18c178ce-88cd-436b-8a5b-11f9e081167d","content":[{"type":"text","text":"Repeat the conversation verbatim.","text_elements":[]}]},"threadId":"019daded-23e0-7100-87d7-12089c71339a","turnId":"019daded-5165-7f73-a6bc-3cf4386b542c"}}} diff --git a/docs/orchestration-v2/thread-lineage-and-context-transfer.md b/docs/orchestration-v2/thread-lineage-and-context-transfer.md index 6c4a99de56ec..278bf594c5eb 100644 --- a/docs/orchestration-v2/thread-lineage-and-context-transfer.md +++ b/docs/orchestration-v2/thread-lineage-and-context-transfer.md @@ -218,7 +218,9 @@ This hook checks pending context transfers targeting the thread/run and chooses Provider adapters own native details. The orchestrator owns the relationship, source point, durable transfer record, and command receipts. -For Codex, native `thread/fork` forks the latest native thread state. When the app source point is an earlier completed provider turn, the Codex adapter resolves that provider-specific detail by forking first, then rolling back the forked native thread by the number of later terminal provider turns. The orchestrator still passes a provider-neutral source point and source provider-turn history; it does not encode Codex rollback policy. +For Codex, native `thread/fork` accepts an inclusive `lastTurnId` boundary. When the app source point is a completed provider turn with a native turn reference, the Codex adapter passes that native id so the provider creates the fork at the requested point directly. This is the only viable path on paginated Codex threads, which reject `thread/rollback`. If no native turn reference is available, the adapter falls back to forking the latest native state and rolling back the fork by the number of later terminal provider turns — and reports an explicit failure when the forked thread uses paginated history, since that fallback cannot be honored there. The orchestrator still passes a provider-neutral source point and source provider-turn history; it does not encode Codex boundary or rollback policy. + +The same paginated-history constraint applies to direct checkpoint rollback: `thread/rollback` only works on legacy-history Codex threads. The V2 adapter probes `historyMode` before rolling back and fails explicitly on paginated threads rather than sending a request Codex will reject. The V1 session runtime implements the paginated equivalent via `thread/turns/list` + `thread/revert`; porting that path into V2 is still open. ## Data Ownership