From 767b0e970b50e3a3dfccbfbc75bac5e86d5cea7f Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:59:00 +0200 Subject: [PATCH] Rewind legacy Codex threads on Codex versions without thread/rollback (#5711) Codex 0.156 removed thread/rollback. Rewinding a legacy-history thread forked it and then called rollback on the fork, so the rewind failed and left an unused fork behind. Read the Codex version from the initialize userAgent and, when rollback is gone, fork before the target turn the same way paginated threads already do. Older servers keep fork plus rollback. Closes #5616 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: Bae Seongmin <83870598+3ae3ae@users.noreply.github.com> --- .../providers/codex-app-server-agent.test.ts | 60 +++++++++++++++++++ .../agent/providers/codex-app-server-agent.ts | 19 +++++- .../agent/providers/codex/rewind.test.ts | 5 ++ .../server/agent/providers/codex/rewind.ts | 10 +++- 4 files changed, 90 insertions(+), 4 deletions(-) diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index f5fb059255..e0e7fa0379 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -1965,6 +1965,66 @@ describe("Codex app-server provider", () => { await session.close(); }); + test("rewinds a legacy conversation with a bounded fork on Codex without thread/rollback", async () => { + const appServer = createFakeCodexAppServer({ + initialize: () => ({ userAgent: "paseo/0.159.0 (Ubuntu 26.4.0; x86_64) (paseo; 0)" }), + "thread/read": () => ({ + thread: { id: "thread-1", historyMode: "legacy", turns: [] }, + }), + "thread/rollback": () => ({ + __jsonRpcError: { + code: -32600, + message: "Invalid request: unknown variant `thread/rollback`", + }, + }), + }); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + null, + createTestLogger(), + async () => appServer.child, + ); + + await session.startTurn("remember first"); + emitCodexUserMessage(appServer, { + id: "codex-first", + text: "remember first", + turnId: "turn-first", + }); + appServer.completeTurn(); + await session.startTurn("remember second"); + emitCodexUserMessage(appServer, { + id: "codex-second", + text: "remember second", + turnId: "turn-second", + }); + appServer.completeTurn(); + + await session.revertConversation({ messageId: "codex-second" }); + + const forkRequests = appServer + .requests() + .filter((request) => request.method === "thread/fork") + .map((request) => request.params); + expect(forkRequests).toEqual([ + { + threadId: "thread-1", + beforeTurnId: "turn-second", + cwd: "/workspace/project", + model: "gpt-5.4", + serviceTier: null, + excludeTurns: false, + persistExtendedHistory: true, + }, + ]); + expect(appServer.recordedRollbacks).toEqual([]); + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ + sessionId: "forked-thread", + }); + appServer.assertNoErrors(); + await session.close(); + }); + test("rewinds a paginated conversation through the public session capability", async () => { const appServer = createFakeCodexAppServer({ "thread/read": () => ({ diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 1fbe22ec30..cee50fbf4c 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -174,6 +174,9 @@ const CODEX_PLAN_IMPLEMENTATION_PROMPT_PREFIX = // (and the /goal slash command) when the binary is too old. const CODEX_GOALS_MIN_VERSION: readonly [number, number, number] = [0, 128, 0]; const CODEX_AUTO_REVIEW_MIN_VERSION: readonly [number, number, number] = [0, 115, 0]; +// Codex removed `thread/rollback` in 0.156.0, so rewinding a legacy thread there +// has to fork before the target turn instead. +const CODEX_THREAD_ROLLBACK_REMOVED_VERSION: readonly [number, number, number] = [0, 156, 0]; function parseCodexVersion(versionOutput: string): [number, number, number] | null { const match = versionOutput.match(/(\d+)\.(\d+)\.(\d+)/); @@ -194,6 +197,15 @@ function codexVersionAtLeast( return true; } +// The initialize userAgent starts with `/`. An agent we +// cannot parse is treated as an older server that still has rollback. +function codexServerHasThreadRollback(userAgent: unknown): boolean { + return ( + typeof userAgent !== "string" || + !codexVersionAtLeast(userAgent, CODEX_THREAD_ROLLBACK_REMOVED_VERSION) + ); +} + type GoalSubcommand = | { kind: "set"; objective: string } | { kind: "pause" } @@ -3428,6 +3440,7 @@ export class CodexAppServerAgentSession implements AgentSession { cancelRequested: boolean; } | null = null; private client: CodexAppServerClient | null = null; + private threadRollbackAvailable = true; private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private nextTurnOrdinal = 0; private activeForegroundTurnId: string | null = null; @@ -3607,7 +3620,10 @@ export class CodexAppServerAgentSession implements AgentSession { this.registerRequestHandlers(); try { - await client.request("initialize", buildCodexAppServerInitializeParams()); + const initialized = toObjectRecord( + await client.request("initialize", buildCodexAppServerInitializeParams()), + ); + this.threadRollbackAvailable = codexServerHasThreadRollback(initialized?.userAgent); client.notify("initialized", {}); this.speedModels = @@ -4927,6 +4943,7 @@ export class CodexAppServerAgentSession implements AgentSession { serviceTier: this.serviceTier, config: this.buildCodexInnerConfig(), userMessageTurns: this.codexUserMessageTurns(), + threadRollbackAvailable: this.threadRollbackAvailable, setThreadId: async (threadId) => { this.currentThreadId = threadId; this.cachedRuntimeInfo = null; diff --git a/packages/server/src/server/agent/providers/codex/rewind.test.ts b/packages/server/src/server/agent/providers/codex/rewind.test.ts index 923b8074bb..da7fe6a8f5 100644 --- a/packages/server/src/server/agent/providers/codex/rewind.test.ts +++ b/packages/server/src/server/agent/providers/codex/rewind.test.ts @@ -96,6 +96,7 @@ describe("Codex Rewind", () => { model: "gpt-5.4-mini", serviceTier: null, userMessageTurns, + threadRollbackAvailable: true, setThreadId: (threadId) => { reboundThreadId = threadId; }, @@ -131,6 +132,7 @@ describe("Codex Rewind", () => { threadId: "source-thread", messageId: "codex-second", userMessageTurns, + threadRollbackAvailable: true, setThreadId: (threadId) => { reboundThreadId = threadId; }, @@ -175,6 +177,7 @@ describe("Codex Rewind", () => { model: "gpt-5.4-mini", serviceTier: null, userMessageTurns, + threadRollbackAvailable: true, setThreadId: (threadId) => { reboundThreadId = threadId; }, @@ -218,6 +221,7 @@ describe("Codex Rewind", () => { model: "gpt-5.4-mini", serviceTier: null, userMessageTurns, + threadRollbackAvailable: true, setThreadId: (threadId) => { reboundThreadId = threadId; }, @@ -239,6 +243,7 @@ describe("Codex Rewind", () => { threadId: "source-thread", messageId: "missing-message", userMessageTurns, + threadRollbackAvailable: true, setThreadId: () => undefined, }), ).rejects.toThrow("Codex could not find user message missing-message"); diff --git a/packages/server/src/server/agent/providers/codex/rewind.ts b/packages/server/src/server/agent/providers/codex/rewind.ts index 1c3d3c471c..021d57645c 100644 --- a/packages/server/src/server/agent/providers/codex/rewind.ts +++ b/packages/server/src/server/agent/providers/codex/rewind.ts @@ -69,6 +69,7 @@ export async function revertCodexConversation(input: { serviceTier?: string | null; config?: Record | null; userMessageTurns: CodexUserMessageTurnIndex; + threadRollbackAvailable: boolean; setThreadId: (threadId: string) => void | Promise; }): Promise { if (!input.threadId) { @@ -98,8 +99,10 @@ export async function revertCodexConversation(input: { persistExtendedHistory: true, }; - const historyMode = await readCodexThreadHistoryMode(input.client, input.threadId); - if (historyMode === "paginated") { + if ( + !input.threadRollbackAvailable || + (await readCodexThreadHistoryMode(input.client, input.threadId)) === "paginated" + ) { if (!targetTurn.turnId) { throw new Error(`Codex could not find the turn containing user message ${input.messageId}`); } @@ -111,7 +114,8 @@ export async function revertCodexConversation(input: { return; } - // Fork is non-destructive: the old thread file stays on disk and remains + // Legacy threads on Codex before 0.156 fork and then roll back. Fork is + // non-destructive: the old thread file stays on disk and remains // recoverable with `codex resume ` if the rewind target was wrong. const forked = await forkCodexThread(input.client, forkParams); const forkedThreadId = forked.thread.id;