mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 03:32:55 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21a25c39aa | ||
|
|
c644da16d1 | ||
|
|
dbf41b059e | ||
|
|
b4bf345a30 | ||
|
|
fe9b3d693e | ||
|
|
7624140141 | ||
|
|
59009a2c2a |
+123
-1
@@ -2,7 +2,7 @@ import { describe, expect, test, vi } from "vitest";
|
|||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { createServer } from "node:http";
|
import { createServer, type ServerResponse } from "node:http";
|
||||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { once } from "node:events";
|
import { once } from "node:events";
|
||||||
|
|
||||||
@@ -171,6 +171,62 @@ async function startMockResponsesServer(sequence: string[]): Promise<{
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function startHeldFirstResponseServer(): Promise<{
|
||||||
|
url: string;
|
||||||
|
requestBodies: string[];
|
||||||
|
releaseFirstResponse: () => void;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
}> {
|
||||||
|
const requestBodies: string[] = [];
|
||||||
|
let heldResponse: ServerResponse | null = null;
|
||||||
|
const reply = (res: ServerResponse) => {
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("content-type", "text/event-stream");
|
||||||
|
res.end(assistantMessageSse("done"));
|
||||||
|
};
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
||||||
|
req.on("end", () => {
|
||||||
|
if (req.method !== "POST" || req.url !== "/v1/responses") {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end("not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestBodies.push(Buffer.concat(chunks).toString("utf8"));
|
||||||
|
if (requestBodies.length === 1) {
|
||||||
|
heldResponse = res;
|
||||||
|
} else {
|
||||||
|
reply(res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(0, "127.0.0.1");
|
||||||
|
await once(server, "listening");
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
throw new Error("Expected TCP address for mock responses server");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
url: `http://127.0.0.1:${address.port}`,
|
||||||
|
requestBodies,
|
||||||
|
releaseFirstResponse: () => {
|
||||||
|
if (!heldResponse) throw new Error("No held response to release");
|
||||||
|
reply(heldResponse);
|
||||||
|
heldResponse = null;
|
||||||
|
},
|
||||||
|
close: () =>
|
||||||
|
new Promise<void>((resolve, reject) => {
|
||||||
|
server.closeAllConnections();
|
||||||
|
server.close((error) => {
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function writeMockCodexConfig(codexHome: string, serverUrl: string): void {
|
function writeMockCodexConfig(codexHome: string, serverUrl: string): void {
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
path.join(codexHome, "config.toml"),
|
path.join(codexHome, "config.toml"),
|
||||||
@@ -217,6 +273,72 @@ function waitForEvent<TEvent extends AgentStreamEvent>(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("Codex app-server provider (local e2e)", () => {
|
describe("Codex app-server provider (local e2e)", () => {
|
||||||
|
test.runIf(isCodexInstalled())(
|
||||||
|
"rewinding a steer message keeps the earlier prompt of its turn in model context",
|
||||||
|
async () => {
|
||||||
|
const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-steer-rewind-cwd-"));
|
||||||
|
const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-steer-rewind-home-"));
|
||||||
|
const mockServer = await startHeldFirstResponseServer();
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeMockCodexConfig(codexHome, mockServer.url);
|
||||||
|
const client = new CodexAppServerAgentClient(createTestLogger());
|
||||||
|
const session = await client.createSession(
|
||||||
|
{ provider: "codex", cwd, modeId: "auto", model: "mock-model" },
|
||||||
|
{ env: { CODEX_HOME: codexHome } },
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userMessageIds = new Map<string, string>();
|
||||||
|
session.subscribe((event) => {
|
||||||
|
if (
|
||||||
|
event.type === "timeline" &&
|
||||||
|
event.item.type === "user_message" &&
|
||||||
|
event.item.messageId
|
||||||
|
) {
|
||||||
|
userMessageIds.set(event.item.text, event.item.messageId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const turnFinished = waitForEvent({
|
||||||
|
session,
|
||||||
|
timeoutMs: 15_000,
|
||||||
|
label: "turn completion",
|
||||||
|
predicate: (event): event is Extract<AgentStreamEvent, { type: "turn_completed" }> =>
|
||||||
|
event.type === "turn_completed",
|
||||||
|
});
|
||||||
|
|
||||||
|
const started = await session.startTurn("ORIGINAL_PROMPT");
|
||||||
|
await vi.waitFor(() => expect(mockServer.requestBodies).toHaveLength(1), {
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
session.steerActiveTurn!("STEER_PROMPT", { expectedTurnId: started.turnId }),
|
||||||
|
).resolves.toEqual({ status: "accepted" });
|
||||||
|
mockServer.releaseFirstResponse();
|
||||||
|
await turnFinished;
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(userMessageIds.get("STEER_PROMPT")).toBeDefined(), {
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
session.revertConversation!({ messageId: userMessageIds.get("STEER_PROMPT")! }),
|
||||||
|
).rejects.toThrow("Select the first message in the turn instead");
|
||||||
|
|
||||||
|
await session.run("AFTER_REWIND");
|
||||||
|
expect(mockServer.requestBodies.at(-1)).toContain("AFTER_REWIND");
|
||||||
|
expect(mockServer.requestBodies.at(-1)).toContain("ORIGINAL_PROMPT");
|
||||||
|
} finally {
|
||||||
|
await session.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await mockServer.close();
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
rmSync(codexHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
45_000,
|
||||||
|
);
|
||||||
|
|
||||||
test.runIf(isCodexInstalled())(
|
test.runIf(isCodexInstalled())(
|
||||||
"reloads a persisted idle thread repeatedly without overlapping writers",
|
"reloads a persisted idle thread repeatedly without overlapping writers",
|
||||||
async () => {
|
async () => {
|
||||||
|
|||||||
@@ -105,10 +105,7 @@ interface CollaborationModeRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface CodexSessionTestAccess {
|
interface CodexSessionTestAccess {
|
||||||
codexUserMessageTurns(): {
|
codexUserMessages(): { messageId: string; turnId: string | null }[];
|
||||||
resolve(messageId: string): { index: number; turnId: string | null } | null;
|
|
||||||
count(): number;
|
|
||||||
};
|
|
||||||
ensureThreadLoaded(): Promise<void>;
|
ensureThreadLoaded(): Promise<void>;
|
||||||
handleToolApprovalRequest(params: unknown): Promise<unknown>;
|
handleToolApprovalRequest(params: unknown): Promise<unknown>;
|
||||||
handleNotification(method: string, params: unknown): void;
|
handleNotification(method: string, params: unknown): void;
|
||||||
@@ -2203,6 +2200,60 @@ describe("Codex app-server provider", () => {
|
|||||||
await session.close();
|
await session.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.each(["legacy", "paginated"] as const)(
|
||||||
|
"rejects rewinding a steer message in a %s thread before forking",
|
||||||
|
async (historyMode) => {
|
||||||
|
const appServer = createFakeCodexAppServer({
|
||||||
|
"thread/read": () => ({ thread: { id: "thread-1", historyMode, turns: [] } }),
|
||||||
|
"turn/steer": () => ({ turnId: "turn-first" }),
|
||||||
|
});
|
||||||
|
const session = new CodexAppServerAgentSession(
|
||||||
|
createConfig({ cwd: "/workspace/project" }),
|
||||||
|
null,
|
||||||
|
createTestLogger(),
|
||||||
|
async () => appServer.child,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const started = await session.startTurn("original prompt");
|
||||||
|
appServer.startsTurn({ threadId: "thread-1", turnId: "turn-first" });
|
||||||
|
const originalMessage = waitForNextTimelineItem(session, "user_message");
|
||||||
|
emitCodexUserMessage(appServer, {
|
||||||
|
id: "codex-first",
|
||||||
|
text: "original prompt",
|
||||||
|
turnId: "turn-first",
|
||||||
|
});
|
||||||
|
await originalMessage;
|
||||||
|
await expect(
|
||||||
|
session.steerActiveTurn("steer prompt", { expectedTurnId: started.turnId }),
|
||||||
|
).resolves.toEqual({ status: "accepted" });
|
||||||
|
const steerMessage = waitForNextTimelineItem(session, "user_message");
|
||||||
|
emitCodexUserMessage(appServer, {
|
||||||
|
id: "codex-steer",
|
||||||
|
text: "steer prompt",
|
||||||
|
turnId: "turn-first",
|
||||||
|
});
|
||||||
|
await steerMessage;
|
||||||
|
appServer.completeTurn();
|
||||||
|
|
||||||
|
await expect(session.revertConversation({ messageId: "codex-steer" })).rejects.toThrow(
|
||||||
|
"Select the first message in the turn instead",
|
||||||
|
);
|
||||||
|
expect(appServer.requests().filter((request) => request.method === "thread/fork")).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
expect(appServer.recordedRollbacks).toEqual([]);
|
||||||
|
expect(session.id).toBe("thread-1");
|
||||||
|
|
||||||
|
await session.revertConversation({ messageId: "codex-first" });
|
||||||
|
expect(session.id).toBe("forked-thread");
|
||||||
|
appServer.assertNoErrors();
|
||||||
|
} finally {
|
||||||
|
await session.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test.each(["legacy", "paginated"] as const)(
|
test.each(["legacy", "paginated"] as const)(
|
||||||
"rewinds a %s thread onto a fork that keeps the custom provider and runtime MCP servers",
|
"rewinds a %s thread onto a fork that keeps the custom provider and runtime MCP servers",
|
||||||
async (historyMode) => {
|
async (historyMode) => {
|
||||||
@@ -4638,10 +4689,9 @@ describe("Codex app-server provider", () => {
|
|||||||
|
|
||||||
await asInternals(session).loadPersistedHistory(session.client);
|
await asInternals(session).loadPersistedHistory(session.client);
|
||||||
|
|
||||||
expect(asInternals(session).codexUserMessageTurns().resolve("message-history")).toEqual({
|
expect(asInternals(session).codexUserMessages()).toEqual([
|
||||||
index: 0,
|
{ messageId: "message-history", turnId: "native-turn-1" },
|
||||||
turnId: "native-turn-1",
|
]);
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("loads mixed legacy and MultiAgentV2 sub-agent history", async () => {
|
test("loads mixed legacy and MultiAgentV2 sub-agent history", async () => {
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ import {
|
|||||||
type CodexThreadRollbackResponse,
|
type CodexThreadRollbackResponse,
|
||||||
type CodexAppServerTraceContext,
|
type CodexAppServerTraceContext,
|
||||||
} from "./codex/app-server-transport.js";
|
} from "./codex/app-server-transport.js";
|
||||||
import { type CodexUserMessageTurnIndex, revertCodexConversation } from "./codex/rewind.js";
|
import { type CodexUserMessage, revertCodexConversation } from "./codex/rewind.js";
|
||||||
import {
|
import {
|
||||||
materializeProviderImage,
|
materializeProviderImage,
|
||||||
renderProviderImageOutputAsAssistantMarkdown,
|
renderProviderImageOutputAsAssistantMarkdown,
|
||||||
@@ -4529,16 +4529,11 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private codexUserMessageTurns(): CodexUserMessageTurnIndex {
|
private codexUserMessages(): CodexUserMessage[] {
|
||||||
return {
|
return this.userMessageTurnIds.map((messageId) => ({
|
||||||
resolve: (messageId) => {
|
messageId,
|
||||||
const index = this.userMessageTurnIndexes.get(messageId);
|
turnId: this.userMessageProviderTurnIds.get(messageId) ?? null,
|
||||||
return index === undefined
|
}));
|
||||||
? null
|
|
||||||
: { index, turnId: this.userMessageProviderTurnIds.get(messageId) ?? null };
|
|
||||||
},
|
|
||||||
count: () => this.userMessageTurnIds.length,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
||||||
@@ -4955,7 +4950,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
model: this.config.model ?? null,
|
model: this.config.model ?? null,
|
||||||
serviceTier: this.serviceTier,
|
serviceTier: this.serviceTier,
|
||||||
config: this.buildCodexInnerConfig(),
|
config: this.buildCodexInnerConfig(),
|
||||||
userMessageTurns: this.codexUserMessageTurns(),
|
userMessages: this.codexUserMessages(),
|
||||||
threadRollbackAvailable: this.threadRollbackAvailable,
|
threadRollbackAvailable: this.threadRollbackAvailable,
|
||||||
setThreadId: async (threadId) => {
|
setThreadId: async (threadId) => {
|
||||||
this.currentThreadId = threadId;
|
this.currentThreadId = threadId;
|
||||||
|
|||||||
@@ -6,11 +6,7 @@ import type {
|
|||||||
CodexThreadRollbackParams,
|
CodexThreadRollbackParams,
|
||||||
CodexThreadRollbackResponse,
|
CodexThreadRollbackResponse,
|
||||||
} from "./app-server-transport.js";
|
} from "./app-server-transport.js";
|
||||||
import {
|
import { type CodexRewindClient, revertCodexConversation } from "./rewind.js";
|
||||||
type CodexUserMessageTurnIndex,
|
|
||||||
type CodexRewindClient,
|
|
||||||
revertCodexConversation,
|
|
||||||
} from "./rewind.js";
|
|
||||||
|
|
||||||
class FakeCodex implements CodexRewindClient {
|
class FakeCodex implements CodexRewindClient {
|
||||||
readonly recordedForks: CodexThreadForkParams[] = [];
|
readonly recordedForks: CodexThreadForkParams[] = [];
|
||||||
@@ -59,33 +55,13 @@ class FakeCodex implements CodexRewindClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class CodexMessageTurns implements CodexUserMessageTurnIndex {
|
|
||||||
constructor(
|
|
||||||
private readonly indexesByMessageId: Map<string, number>,
|
|
||||||
private readonly turnIdsByMessageId: Map<string, string> = new Map(),
|
|
||||||
) {}
|
|
||||||
|
|
||||||
resolve(messageId: string): { index: number; turnId: string | null } | null {
|
|
||||||
const index = this.indexesByMessageId.get(messageId);
|
|
||||||
return index === undefined
|
|
||||||
? null
|
|
||||||
: { index, turnId: this.turnIdsByMessageId.get(messageId) ?? null };
|
|
||||||
}
|
|
||||||
|
|
||||||
count(): number {
|
|
||||||
return this.indexesByMessageId.size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Codex Rewind", () => {
|
describe("Codex Rewind", () => {
|
||||||
test("rewinds the conversation by forking the thread and rolling back past the native user message", async () => {
|
test("rewinds the conversation by forking the thread and rolling back past the native user message", async () => {
|
||||||
const codex = new FakeCodex();
|
const codex = new FakeCodex();
|
||||||
const userMessageTurns = new CodexMessageTurns(
|
const userMessages = [
|
||||||
new Map([
|
{ messageId: "codex-first", turnId: null },
|
||||||
["codex-first", 0],
|
{ messageId: "codex-second", turnId: null },
|
||||||
["codex-second", 1],
|
];
|
||||||
]),
|
|
||||||
);
|
|
||||||
let reboundThreadId: string | null = null;
|
let reboundThreadId: string | null = null;
|
||||||
|
|
||||||
await revertCodexConversation({
|
await revertCodexConversation({
|
||||||
@@ -95,7 +71,7 @@ describe("Codex Rewind", () => {
|
|||||||
cwd: "/workspace/project",
|
cwd: "/workspace/project",
|
||||||
model: "gpt-5.4-mini",
|
model: "gpt-5.4-mini",
|
||||||
serviceTier: null,
|
serviceTier: null,
|
||||||
userMessageTurns,
|
userMessages,
|
||||||
threadRollbackAvailable: true,
|
threadRollbackAvailable: true,
|
||||||
setThreadId: (threadId) => {
|
setThreadId: (threadId) => {
|
||||||
reboundThreadId = threadId;
|
reboundThreadId = threadId;
|
||||||
@@ -118,20 +94,18 @@ describe("Codex Rewind", () => {
|
|||||||
|
|
||||||
test("rewinds the conversation using native user message ids hydrated from app-server history", async () => {
|
test("rewinds the conversation using native user message ids hydrated from app-server history", async () => {
|
||||||
const codex = new FakeCodex();
|
const codex = new FakeCodex();
|
||||||
const userMessageTurns = new CodexMessageTurns(
|
const userMessages = [
|
||||||
new Map([
|
{ messageId: "codex-first", turnId: null },
|
||||||
["codex-first", 0],
|
{ messageId: "codex-second", turnId: null },
|
||||||
["codex-second", 1],
|
{ messageId: "codex-third", turnId: null },
|
||||||
["codex-third", 2],
|
];
|
||||||
]),
|
|
||||||
);
|
|
||||||
let reboundThreadId: string | null = null;
|
let reboundThreadId: string | null = null;
|
||||||
|
|
||||||
await revertCodexConversation({
|
await revertCodexConversation({
|
||||||
client: codex,
|
client: codex,
|
||||||
threadId: "source-thread",
|
threadId: "source-thread",
|
||||||
messageId: "codex-second",
|
messageId: "codex-second",
|
||||||
userMessageTurns,
|
userMessages,
|
||||||
threadRollbackAvailable: true,
|
threadRollbackAvailable: true,
|
||||||
setThreadId: (threadId) => {
|
setThreadId: (threadId) => {
|
||||||
reboundThreadId = threadId;
|
reboundThreadId = threadId;
|
||||||
@@ -157,16 +131,10 @@ describe("Codex Rewind", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const codex = new PaginatedCodex();
|
const codex = new PaginatedCodex();
|
||||||
const userMessageTurns = new CodexMessageTurns(
|
const userMessages = [
|
||||||
new Map([
|
{ messageId: "codex-first", turnId: "turn-first" },
|
||||||
["codex-first", 0],
|
{ messageId: "codex-second", turnId: "turn-second" },
|
||||||
["codex-second", 1],
|
];
|
||||||
]),
|
|
||||||
new Map([
|
|
||||||
["codex-first", "turn-first"],
|
|
||||||
["codex-second", "turn-second"],
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
let reboundThreadId: string | null = null;
|
let reboundThreadId: string | null = null;
|
||||||
|
|
||||||
await revertCodexConversation({
|
await revertCodexConversation({
|
||||||
@@ -176,7 +144,7 @@ describe("Codex Rewind", () => {
|
|||||||
cwd: "/workspace/project",
|
cwd: "/workspace/project",
|
||||||
model: "gpt-5.4-mini",
|
model: "gpt-5.4-mini",
|
||||||
serviceTier: null,
|
serviceTier: null,
|
||||||
userMessageTurns,
|
userMessages,
|
||||||
threadRollbackAvailable: true,
|
threadRollbackAvailable: true,
|
||||||
setThreadId: (threadId) => {
|
setThreadId: (threadId) => {
|
||||||
reboundThreadId = threadId;
|
reboundThreadId = threadId;
|
||||||
@@ -209,7 +177,7 @@ describe("Codex Rewind", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const codex = new PaginatedCodex();
|
const codex = new PaginatedCodex();
|
||||||
const userMessageTurns = new CodexMessageTurns(new Map([["codex-first", 0]]));
|
const userMessages = [{ messageId: "codex-first", turnId: null }];
|
||||||
let reboundThreadId: string | null = null;
|
let reboundThreadId: string | null = null;
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -220,7 +188,7 @@ describe("Codex Rewind", () => {
|
|||||||
cwd: "/workspace/project",
|
cwd: "/workspace/project",
|
||||||
model: "gpt-5.4-mini",
|
model: "gpt-5.4-mini",
|
||||||
serviceTier: null,
|
serviceTier: null,
|
||||||
userMessageTurns,
|
userMessages,
|
||||||
threadRollbackAvailable: true,
|
threadRollbackAvailable: true,
|
||||||
setThreadId: (threadId) => {
|
setThreadId: (threadId) => {
|
||||||
reboundThreadId = threadId;
|
reboundThreadId = threadId;
|
||||||
@@ -235,14 +203,14 @@ describe("Codex Rewind", () => {
|
|||||||
|
|
||||||
test("declines to rewind when the user message is not in the Codex thread", async () => {
|
test("declines to rewind when the user message is not in the Codex thread", async () => {
|
||||||
const codex = new FakeCodex();
|
const codex = new FakeCodex();
|
||||||
const userMessageTurns = new CodexMessageTurns(new Map([["codex-first", 0]]));
|
const userMessages = [{ messageId: "codex-first", turnId: null }];
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
revertCodexConversation({
|
revertCodexConversation({
|
||||||
client: codex,
|
client: codex,
|
||||||
threadId: "source-thread",
|
threadId: "source-thread",
|
||||||
messageId: "missing-message",
|
messageId: "missing-message",
|
||||||
userMessageTurns,
|
userMessages,
|
||||||
threadRollbackAvailable: true,
|
threadRollbackAvailable: true,
|
||||||
setThreadId: () => undefined,
|
setThreadId: () => undefined,
|
||||||
}),
|
}),
|
||||||
@@ -250,4 +218,47 @@ describe("Codex Rewind", () => {
|
|||||||
expect(codex.recordedForks).toEqual([]);
|
expect(codex.recordedForks).toEqual([]);
|
||||||
expect(codex.recordedRollbacks).toEqual([]);
|
expect(codex.recordedRollbacks).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("rolls back whole turns when a later turn holds a steer message", async () => {
|
||||||
|
const codex = new FakeCodex();
|
||||||
|
|
||||||
|
await revertCodexConversation({
|
||||||
|
client: codex,
|
||||||
|
threadId: "source-thread",
|
||||||
|
messageId: "codex-second",
|
||||||
|
userMessages: [
|
||||||
|
{ messageId: "codex-first", turnId: "turn-first" },
|
||||||
|
{ messageId: "codex-second", turnId: "turn-second" },
|
||||||
|
{ messageId: "codex-steer", turnId: "turn-second" },
|
||||||
|
],
|
||||||
|
threadRollbackAvailable: true,
|
||||||
|
setThreadId: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(codex.recordedRollbacks).toEqual([{ threadId: "forked-thread", numTurns: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("declines to rewind a steer message without forking", async () => {
|
||||||
|
const codex = new FakeCodex();
|
||||||
|
let reboundThreadId: string | null = null;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
revertCodexConversation({
|
||||||
|
client: codex,
|
||||||
|
threadId: "source-thread",
|
||||||
|
messageId: "codex-steer",
|
||||||
|
userMessages: [
|
||||||
|
{ messageId: "codex-first", turnId: "turn-first" },
|
||||||
|
{ messageId: "codex-steer", turnId: "turn-first" },
|
||||||
|
],
|
||||||
|
threadRollbackAvailable: true,
|
||||||
|
setThreadId: (threadId) => {
|
||||||
|
reboundThreadId = threadId;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Select the first message in the turn instead");
|
||||||
|
expect(codex.recordedForks).toEqual([]);
|
||||||
|
expect(codex.recordedRollbacks).toEqual([]);
|
||||||
|
expect(reboundThreadId).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ export interface CodexRewindClient {
|
|||||||
request(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;
|
request(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CodexUserMessageTurnIndex {
|
export interface CodexUserMessage {
|
||||||
resolve(messageId: string): { index: number; turnId: string | null } | null;
|
messageId: string;
|
||||||
count(): number;
|
turnId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CodexThreadHistoryMode = "legacy" | "paginated";
|
type CodexThreadHistoryMode = "legacy" | "paginated";
|
||||||
@@ -60,6 +60,12 @@ async function rollbackCodexThread(
|
|||||||
return parseCodexThreadRollbackResponse(await client.request("thread/rollback", params));
|
return parseCodexThreadRollbackResponse(await client.request("thread/rollback", params));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function countTurns(userMessages: readonly CodexUserMessage[]): number {
|
||||||
|
return userMessages.filter(
|
||||||
|
(message, index) => !message.turnId || message.turnId !== userMessages[index - 1]?.turnId,
|
||||||
|
).length;
|
||||||
|
}
|
||||||
|
|
||||||
export async function revertCodexConversation(input: {
|
export async function revertCodexConversation(input: {
|
||||||
client: CodexRewindClient;
|
client: CodexRewindClient;
|
||||||
threadId: string | null;
|
threadId: string | null;
|
||||||
@@ -68,7 +74,7 @@ export async function revertCodexConversation(input: {
|
|||||||
model?: string | null;
|
model?: string | null;
|
||||||
serviceTier?: string | null;
|
serviceTier?: string | null;
|
||||||
config?: Record<string, unknown> | null;
|
config?: Record<string, unknown> | null;
|
||||||
userMessageTurns: CodexUserMessageTurnIndex;
|
userMessages: readonly CodexUserMessage[];
|
||||||
threadRollbackAvailable: boolean;
|
threadRollbackAvailable: boolean;
|
||||||
setThreadId: (threadId: string) => void | Promise<void>;
|
setThreadId: (threadId: string) => void | Promise<void>;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
@@ -76,16 +82,21 @@ export async function revertCodexConversation(input: {
|
|||||||
throw new Error("Codex thread is not ready for rewind");
|
throw new Error("Codex thread is not ready for rewind");
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetTurn = input.userMessageTurns.resolve(input.messageId);
|
const targetIndex = input.userMessages.findIndex(
|
||||||
if (targetTurn === null) {
|
(message) => message.messageId === input.messageId,
|
||||||
|
);
|
||||||
|
if (targetIndex === -1) {
|
||||||
throw new Error(`Codex could not find user message ${input.messageId} in the current thread`);
|
throw new Error(`Codex could not find user message ${input.messageId} in the current thread`);
|
||||||
}
|
}
|
||||||
|
const targetTurn = input.userMessages[targetIndex];
|
||||||
const currentUserTurnCount = input.userMessageTurns.count();
|
// Codex rewinds whole turns; a steer cannot be removed without the earlier
|
||||||
const numTurns = currentUserTurnCount - targetTurn.index;
|
// messages of its turn.
|
||||||
if (numTurns < 0) {
|
if (targetTurn.turnId && input.userMessages[targetIndex - 1]?.turnId === targetTurn.turnId) {
|
||||||
throw new Error(`Codex user message ${input.messageId} is outside the current thread`);
|
throw new Error(
|
||||||
|
"Codex cannot rewind a message sent during a turn without removing earlier messages. Select the first message in the turn instead.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
const numTurns = countTurns(input.userMessages.slice(targetIndex));
|
||||||
|
|
||||||
// Codex does not carry the parent thread's config into a fork; without it the
|
// Codex does not carry the parent thread's config into a fork; without it the
|
||||||
// forked thread falls back to the default model provider.
|
// forked thread falls back to the default model provider.
|
||||||
|
|||||||
Reference in New Issue
Block a user