mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
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>
This commit is contained in:
co-authored by
Bae Seongmin
parent
d3c902e9fa
commit
767b0e970b
@@ -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": () => ({
|
||||
|
||||
@@ -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 `<client>/<codex version>`. 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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -69,6 +69,7 @@ export async function revertCodexConversation(input: {
|
||||
serviceTier?: string | null;
|
||||
config?: Record<string, unknown> | null;
|
||||
userMessageTurns: CodexUserMessageTurnIndex;
|
||||
threadRollbackAvailable: boolean;
|
||||
setThreadId: (threadId: string) => void | Promise<void>;
|
||||
}): Promise<void> {
|
||||
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 <old-uuid>` if the rewind target was wrong.
|
||||
const forked = await forkCodexThread(input.client, forkParams);
|
||||
const forkedThreadId = forked.thread.id;
|
||||
|
||||
Reference in New Issue
Block a user