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;