mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-29 20:52:51 +00:00
fix(server): read Codex history in pages instead of one whole-thread reply
Opening a Codex agent whose timeline the daemon doesn't hold asked Codex for the whole thread in one thread/read reply. Past the 128 MiB provider line cap that reply killed the session; before the cap it crashed the daemon. Codex 0.145 and newer reject that read on paginated threads, so importing them failed. Paginated threads now load through thread/items/list pages. A legacy thread keeps its single read. A reply over the line cap fails only its own request: for a legacy read, a warning row names the archive, `codex migrate-rollouts`, unarchive steps; for a page, a warning row ends the history there. Items are capped as they are projected, and rewind's fork no longer returns the thread's turns. Co-authored-by: hunterricks <hr@savagestrategic.io> Co-authored-by: dundunHa <lixinpengde@gmail.com>
This commit is contained in:
co-authored by
hunterricks
dundunHa
parent
ac6e4e9f96
commit
4c4bb45761
@@ -0,0 +1,108 @@
|
||||
// Opt-in diagnostic for #3905. Run from the repository root:
|
||||
// node --import tsx packages/server/scripts/reproduce-codex-history-string-limit.mts 520 [legacy|paginated]
|
||||
// Requires Codex 0.148+ on PATH and approximately 2 GiB of available memory.
|
||||
// `paginated` migrates the synthetic rollout with `codex migrate-rollouts` first.
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, mkdir, open, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { CodexAppServerAgentSession } from "../src/server/agent/providers/codex-app-server-agent.js";
|
||||
|
||||
const megabytes = Number(process.argv[2] ?? "520");
|
||||
if (!Number.isSafeInteger(megabytes) || megabytes < 1 || megabytes > 600) {
|
||||
throw new Error("Expected an integer size between 1 and 600 MiB");
|
||||
}
|
||||
const historyMode = process.argv[3] ?? "legacy";
|
||||
if (historyMode !== "legacy" && historyMode !== "paginated") {
|
||||
throw new Error("Expected history mode legacy or paginated");
|
||||
}
|
||||
const root = await mkdtemp(path.join(process.cwd(), ".codex-history-repro-"));
|
||||
const home = path.join(root, "codex-home");
|
||||
const now = new Date();
|
||||
const timestamp = now.toISOString();
|
||||
const threadId = randomUUID();
|
||||
const turnId = randomUUID();
|
||||
const sessions = path.join(home, "sessions", ...timestamp.slice(0, 10).split("-"));
|
||||
await mkdir(sessions, { recursive: true });
|
||||
const rollout = await open(
|
||||
path.join(sessions, `rollout-${timestamp.slice(0, 19).replaceAll(":", "-")}-${threadId}.jsonl`),
|
||||
"w",
|
||||
);
|
||||
async function write(type: string, payload: unknown) {
|
||||
await rollout.write(`${JSON.stringify({ timestamp, type, payload })}\n`);
|
||||
}
|
||||
await write("session_meta", {
|
||||
id: threadId,
|
||||
timestamp,
|
||||
cwd: root,
|
||||
originator: "codex_cli_rs",
|
||||
cli_version: "0.159.0",
|
||||
source: "cli",
|
||||
model_provider: "openai",
|
||||
history_mode: "legacy",
|
||||
});
|
||||
await write("event_msg", { type: "task_started", turn_id: turnId, model_context_window: 200000 });
|
||||
await write("event_msg", {
|
||||
type: "user_message",
|
||||
message: "Synthetic oversized history",
|
||||
images: [],
|
||||
local_images: [],
|
||||
text_elements: [],
|
||||
});
|
||||
for (let index = 0; index < megabytes; index++) {
|
||||
await write("event_msg", {
|
||||
type: "agent_message",
|
||||
message: "x".repeat(1024 * 1024),
|
||||
phase: "commentary",
|
||||
});
|
||||
}
|
||||
await write("event_msg", { type: "task_complete", turn_id: turnId, last_agent_message: "done" });
|
||||
await rollout.close();
|
||||
if (historyMode === "paginated") {
|
||||
execFileSync("codex", ["migrate-rollouts", "--apply", "--thread", threadId], {
|
||||
env: { ...process.env, CODEX_HOME: home },
|
||||
stdio: "ignore",
|
||||
});
|
||||
}
|
||||
console.log(
|
||||
`Opening synthetic ${megabytes} MiB ${historyMode} history through the real Codex app-server`,
|
||||
);
|
||||
const session = new CodexAppServerAgentSession(
|
||||
{ provider: "codex", cwd: root },
|
||||
{ sessionId: threadId },
|
||||
pino({ level: "silent" }),
|
||||
async () =>
|
||||
spawn(
|
||||
"codex",
|
||||
["app-server", "--disable", "plugins", "--disable", "apps", "--disable", "remote_plugin"],
|
||||
{
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: { ...process.env, CODEX_HOME: home },
|
||||
},
|
||||
),
|
||||
{ codexHome: home },
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
undefined,
|
||||
"history",
|
||||
);
|
||||
try {
|
||||
await session.connect();
|
||||
let count = 0;
|
||||
for await (const event of session.streamHistory()) {
|
||||
if (event.type !== "timeline") continue;
|
||||
count++;
|
||||
if (event.item.type === "notification") console.log(event.item.message);
|
||||
}
|
||||
console.log(`History loaded: ${count} timeline rows`);
|
||||
} catch (error) {
|
||||
// A recoverable session error is acceptable for the crash-isolation assertion.
|
||||
console.log(
|
||||
`History rejected without crashing: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
}
|
||||
@@ -88,6 +88,7 @@ describe("Codex executable discovery", () => {
|
||||
import { CodexAppServerClient } from "./codex/app-server-transport.js";
|
||||
import {
|
||||
createFakeCodexAppServer,
|
||||
fakeCodexThreadHistory,
|
||||
type FakeCodexAppServer,
|
||||
waitForNextPermission,
|
||||
waitForNextTimelineItem,
|
||||
@@ -1991,7 +1992,7 @@ describe("Codex app-server provider", () => {
|
||||
purpose: "history",
|
||||
});
|
||||
|
||||
expect(threadRequests).toEqual(["thread/read"]);
|
||||
expect(threadRequests).toEqual(["thread/read", "thread/read"]);
|
||||
await session.close();
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
@@ -2068,6 +2069,7 @@ describe("Codex app-server provider", () => {
|
||||
"thread/loaded/list",
|
||||
"thread/resume",
|
||||
"thread/read",
|
||||
"thread/read",
|
||||
]);
|
||||
expect(codex.threadMethods(1)).toEqual(["thread/loaded/list", "thread/resume"]);
|
||||
expect(codex.manager.getTimeline(codex.agentId)).toEqual(projected);
|
||||
@@ -2427,7 +2429,7 @@ describe("Codex app-server provider", () => {
|
||||
cwd: "/workspace/project",
|
||||
model: "gpt-5.4",
|
||||
serviceTier: null,
|
||||
excludeTurns: false,
|
||||
excludeTurns: true,
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
]);
|
||||
@@ -2441,8 +2443,9 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
test("rewinds a paginated conversation through the public session capability", async () => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"thread/read": () => ({
|
||||
thread: { id: "thread-1", historyMode: "paginated", turns: [] },
|
||||
...fakeCodexThreadHistory({
|
||||
"thread-1": { historyMode: "paginated", turns: [] },
|
||||
"forked-thread": { historyMode: "paginated", turns: [] },
|
||||
}),
|
||||
"thread/rollback": () => {
|
||||
throw new Error("paginated threads do not support thread/rollback");
|
||||
@@ -2487,7 +2490,7 @@ describe("Codex app-server provider", () => {
|
||||
cwd: "/workspace/project",
|
||||
model: "gpt-5.4",
|
||||
serviceTier: null,
|
||||
excludeTurns: false,
|
||||
excludeTurns: true,
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
]);
|
||||
@@ -2504,11 +2507,10 @@ describe("Codex app-server provider", () => {
|
||||
async (historyMode) => {
|
||||
const appServer = createFakeCodexAppServer(
|
||||
historyMode === "paginated"
|
||||
? {
|
||||
"thread/read": () => ({
|
||||
thread: { id: "thread-1", historyMode: "paginated", turns: [] },
|
||||
}),
|
||||
}
|
||||
? fakeCodexThreadHistory({
|
||||
"thread-1": { historyMode: "paginated", turns: [] },
|
||||
"forked-thread": { historyMode: "paginated", turns: [] },
|
||||
})
|
||||
: undefined,
|
||||
);
|
||||
const customCodexConfig = {
|
||||
@@ -4947,6 +4949,7 @@ describe("Codex app-server provider", () => {
|
||||
}
|
||||
|
||||
expect(requests.map((request) => [request.method, request.params])).toEqual([
|
||||
["thread/read", { threadId: "test-thread", includeTurns: false }],
|
||||
["thread/read", { threadId: "test-thread", includeTurns: true }],
|
||||
]);
|
||||
expect(history).toEqual([
|
||||
|
||||
@@ -92,12 +92,18 @@ import {
|
||||
type CodexAppServerTraceContext,
|
||||
} from "./codex/app-server-transport.js";
|
||||
import { type CodexUserMessageTurnIndex, revertCodexConversation } from "./codex/rewind.js";
|
||||
import {
|
||||
type CodexHistoryRow,
|
||||
CodexThreadHistoryTooLargeError,
|
||||
readCodexThreadHistory,
|
||||
} from "./codex/thread-history.js";
|
||||
import {
|
||||
materializeProviderImage,
|
||||
renderProviderImageOutputAsAssistantMarkdown,
|
||||
type ProviderImageOutput,
|
||||
} from "./provider-image-output.js";
|
||||
import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js";
|
||||
import { limitAgentTimelineItemContent } from "../agent-timeline-content.js";
|
||||
import {
|
||||
formatProviderDiagnostic,
|
||||
formatProviderDiagnosticError,
|
||||
@@ -1804,22 +1810,13 @@ function readCodexHistoryTimestamp(item: unknown): string | null {
|
||||
);
|
||||
}
|
||||
|
||||
function readCodexTurnHistoryTimestamp(
|
||||
turn: unknown,
|
||||
/** Falls back to the row's start time for a user message and its end time otherwise. */
|
||||
function readCodexHistoryRowTimestamp(
|
||||
row: CodexHistoryRow,
|
||||
timelineItem: AgentTimelineItem,
|
||||
): string | null {
|
||||
const record = toObjectRecord(turn);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startedAt =
|
||||
normalizeProviderReplayTimestamp(record.startedAt) ??
|
||||
normalizeProviderReplayTimestamp(record.started_at);
|
||||
const completedAt =
|
||||
normalizeProviderReplayTimestamp(record.completedAt) ??
|
||||
normalizeProviderReplayTimestamp(record.completed_at);
|
||||
|
||||
const startedAt = normalizeProviderReplayTimestamp(row.startedAt);
|
||||
const completedAt = normalizeProviderReplayTimestamp(row.completedAt);
|
||||
if (timelineItem.type === "user_message") {
|
||||
return startedAt ?? completedAt;
|
||||
}
|
||||
@@ -2063,84 +2060,19 @@ async function threadItemToTimelineEntries(
|
||||
return [timelineItem, ...(await mcpToolResultImagesToTimeline(item))];
|
||||
}
|
||||
|
||||
const CodexThreadReadResponseSchema = z
|
||||
.object({
|
||||
thread: z
|
||||
.object({
|
||||
turns: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
items: z.array(z.unknown()).default([]),
|
||||
})
|
||||
.passthrough(),
|
||||
)
|
||||
.default([]),
|
||||
})
|
||||
.passthrough()
|
||||
.default({ turns: [] }),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
type CodexThreadReadResponse = z.infer<typeof CodexThreadReadResponseSchema>;
|
||||
type CodexThreadReadRequest = (threadId: string) => Promise<unknown>;
|
||||
|
||||
async function requestCodexThreadHistory(
|
||||
requestThread: CodexThreadReadRequest,
|
||||
threadId: string,
|
||||
): Promise<CodexThreadReadResponse> {
|
||||
const response = await requestThread(threadId);
|
||||
return CodexThreadReadResponseSchema.parse(response);
|
||||
}
|
||||
|
||||
async function loadCodexThreadHistoryTimeline(params: {
|
||||
client: CodexAppServerClientLike;
|
||||
threadId: string;
|
||||
cwd: string | null;
|
||||
requestThread: CodexThreadReadRequest;
|
||||
}): Promise<CodexThreadHistoryProjection> {
|
||||
const response = await requestCodexThreadHistory(params.requestThread, params.threadId);
|
||||
const timeline: PersistedTimelineEntry[] = [];
|
||||
const subAgentTimelineIndexByThreadId = new Map<string, number>();
|
||||
for (const turn of response.thread.turns) {
|
||||
for (const item of turn.items) {
|
||||
const historicalSubAgentActivity = readCodexSubAgentActivity(item);
|
||||
if (historicalSubAgentActivity) {
|
||||
const existingIndex = subAgentTimelineIndexByThreadId.get(
|
||||
historicalSubAgentActivity.agentThreadId,
|
||||
);
|
||||
if (existingIndex !== undefined) {
|
||||
const activityTimelineItem = await threadItemToTimeline(item, { cwd: params.cwd });
|
||||
updateHistoricalSubAgentActivity(
|
||||
timeline,
|
||||
existingIndex,
|
||||
historicalSubAgentActivity.kind,
|
||||
activityTimelineItem?.type === "tool_call" &&
|
||||
activityTimelineItem.detail.type === "sub_agent"
|
||||
? activityTimelineItem.detail.subAgentType
|
||||
: undefined,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for (const timelineItem of await threadItemToTimelineEntries(item, { cwd: params.cwd })) {
|
||||
const timestamp =
|
||||
readCodexHistoryTimestamp(item) ?? readCodexTurnHistoryTimestamp(turn, timelineItem);
|
||||
const settledTimelineItem =
|
||||
historicalSubAgentActivity && timelineItem.type === "tool_call"
|
||||
? settleHistoricalSubAgentActivity(timelineItem, historicalSubAgentActivity.kind)
|
||||
: timelineItem;
|
||||
timeline.push({
|
||||
item: settledTimelineItem,
|
||||
timestamp: timestamp ?? undefined,
|
||||
...(timelineItem.type === "user_message" && typeof turn.id === "string"
|
||||
? { providerTurnId: turn.id }
|
||||
: {}),
|
||||
});
|
||||
for (const childThreadId of readCodexHistoricalSubAgentThreadIds(item)) {
|
||||
subAgentTimelineIndexByThreadId.set(childThreadId, timeline.length - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await projectCodexThreadHistory(params, { timeline, subAgentTimelineIndexByThreadId });
|
||||
} catch (error) {
|
||||
if (!(error instanceof CodexThreadHistoryTooLargeError)) throw error;
|
||||
// The session stays usable; the row says why the history stops here.
|
||||
timeline.push({ item: { type: "notification", level: "warning", message: error.message } });
|
||||
}
|
||||
const subAgentRoutes = Array.from(subAgentTimelineIndexByThreadId.entries()).flatMap(
|
||||
([childThreadId, timelineIndex]): PersistedSubAgentRoute[] => {
|
||||
@@ -2153,11 +2085,55 @@ async function loadCodexThreadHistoryTimeline(params: {
|
||||
return { timeline, subAgentRoutes };
|
||||
}
|
||||
|
||||
function readCodexThread(client: CodexAppServerClientLike, threadId: string): Promise<unknown> {
|
||||
return client.request("thread/read", {
|
||||
threadId,
|
||||
includeTurns: true,
|
||||
});
|
||||
async function projectCodexThreadHistory(
|
||||
params: { client: CodexAppServerClientLike; threadId: string; cwd: string | null },
|
||||
projection: {
|
||||
timeline: PersistedTimelineEntry[];
|
||||
subAgentTimelineIndexByThreadId: Map<string, number>;
|
||||
},
|
||||
): Promise<void> {
|
||||
const { timeline, subAgentTimelineIndexByThreadId } = projection;
|
||||
for await (const row of readCodexThreadHistory(params.client, params.threadId)) {
|
||||
const { item } = row;
|
||||
const historicalSubAgentActivity = readCodexSubAgentActivity(item);
|
||||
if (historicalSubAgentActivity) {
|
||||
const existingIndex = subAgentTimelineIndexByThreadId.get(
|
||||
historicalSubAgentActivity.agentThreadId,
|
||||
);
|
||||
if (existingIndex !== undefined) {
|
||||
const activityTimelineItem = await threadItemToTimeline(item, { cwd: params.cwd });
|
||||
updateHistoricalSubAgentActivity(
|
||||
timeline,
|
||||
existingIndex,
|
||||
historicalSubAgentActivity.kind,
|
||||
activityTimelineItem?.type === "tool_call" &&
|
||||
activityTimelineItem.detail.type === "sub_agent"
|
||||
? activityTimelineItem.detail.subAgentType
|
||||
: undefined,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for (const timelineItem of await threadItemToTimelineEntries(item, { cwd: params.cwd })) {
|
||||
const timestamp =
|
||||
readCodexHistoryTimestamp(item) ?? readCodexHistoryRowTimestamp(row, timelineItem);
|
||||
const settledTimelineItem =
|
||||
historicalSubAgentActivity && timelineItem.type === "tool_call"
|
||||
? settleHistoricalSubAgentActivity(timelineItem, historicalSubAgentActivity.kind)
|
||||
: timelineItem;
|
||||
timeline.push({
|
||||
// Cap each item as it is projected so a large history is never held uncapped.
|
||||
item: limitAgentTimelineItemContent(settledTimelineItem),
|
||||
timestamp: timestamp ?? undefined,
|
||||
...(timelineItem.type === "user_message" && row.turnId !== undefined
|
||||
? { providerTurnId: row.turnId }
|
||||
: {}),
|
||||
});
|
||||
for (const childThreadId of readCodexHistoricalSubAgentThreadIds(item)) {
|
||||
subAgentTimelineIndexByThreadId.set(childThreadId, timeline.length - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function forkCodexThread(
|
||||
@@ -3997,9 +3973,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
return { timeline: [], providerSubagentEvents: [] };
|
||||
}
|
||||
const { timeline, subAgentRoutes } = await loadCodexThreadHistoryTimeline({
|
||||
client,
|
||||
threadId: this.currentThreadId,
|
||||
cwd: this.config.cwd ?? null,
|
||||
requestThread: (threadIdToRead) => readCodexThread(client, threadIdToRead),
|
||||
});
|
||||
this.subAgentCallsByCallId.clear();
|
||||
this.subAgentCallIdByChildThreadId.clear();
|
||||
@@ -4055,9 +4031,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
});
|
||||
try {
|
||||
const childHistory = await loadCodexThreadHistoryTimeline({
|
||||
client,
|
||||
threadId: next.route.childThreadId,
|
||||
cwd: this.config.cwd ?? null,
|
||||
requestThread: (childThreadId) => readCodexThread(client, childThreadId),
|
||||
});
|
||||
for (const entry of childHistory.timeline) {
|
||||
this.emitProviderSubagentTimeline(next.route.childThreadId, entry.item, entry.timestamp);
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
// Regression for #3905. Real Codex, synthetic on-disk history, no model turns.
|
||||
// Opt in because the oversized cases need approximately 2 GiB of memory and over
|
||||
// 1 GiB of temporary storage.
|
||||
const enabled = process.env.PASEO_CODEX_HISTORY_STRING_LIMIT_REPRO === "1";
|
||||
const script = fileURLToPath(
|
||||
new URL("../../../../scripts/reproduce-codex-history-string-limit.mts", import.meta.url),
|
||||
);
|
||||
|
||||
test.runIf(enabled).each([
|
||||
{ megabytes: 2, historyMode: "legacy", expected: "History loaded: 3 timeline rows" },
|
||||
{ megabytes: 520, historyMode: "legacy", expected: "codex migrate-rollouts --apply --thread" },
|
||||
{ megabytes: 520, historyMode: "paginated", expected: "History loaded: 521 timeline rows" },
|
||||
])(
|
||||
"opening $megabytes MiB of $historyMode Codex history does not crash its hosting process",
|
||||
async ({ megabytes, historyMode, expected }) => {
|
||||
const root = await mkdtemp(path.join(process.cwd(), ".codex-history-test-"));
|
||||
try {
|
||||
const result = await new Promise<{ code: number; stdout: string; stderr: string }>(
|
||||
(resolve) => {
|
||||
const child = execFile(
|
||||
process.execPath,
|
||||
["--import", "tsx", script, String(megabytes), historyMode],
|
||||
{
|
||||
cwd: root,
|
||||
detached: true,
|
||||
timeout: 120_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
try {
|
||||
if (child.pid) process.kill(-child.pid, "SIGKILL");
|
||||
} catch (killError) {
|
||||
if ((killError as NodeJS.ErrnoException).code !== "ESRCH") throw killError;
|
||||
}
|
||||
resolve({ code: error ? Number(error.code ?? 1) : 0, stdout, stderr });
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(result.code, result.stderr).toBe(0);
|
||||
expect(result.stdout).toContain(expected);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
}
|
||||
},
|
||||
180_000,
|
||||
);
|
||||
@@ -185,14 +185,41 @@ describe("Codex app-server transport", () => {
|
||||
});
|
||||
|
||||
describe("Codex stdout byte admission", () => {
|
||||
test("fails pending requests and the session before joining an oversized line", async () => {
|
||||
test("an oversized response fails only its own request", async () => {
|
||||
const child = createCodexAppServerChildProcess();
|
||||
const client = new CodexAppServerClient(child, createTestLogger());
|
||||
const terminations: Error[] = [];
|
||||
client.setUnexpectedTerminationHandler((error) => terminations.push(error));
|
||||
const oversized = client.request("thread/items/list", {}).catch((error: Error) => error);
|
||||
const other = client.request("model/list", {});
|
||||
const chunk = Buffer.alloc(64 * 1024, "x");
|
||||
child.stdout.write('{"id":1,"result":"');
|
||||
for (let index = 0; index < 2049; index++) child.stdout.write(chunk);
|
||||
child.stdout.write('"}\n{"id":2,"result":{"data":[]}}\n');
|
||||
|
||||
expect(await oversized).toMatchObject({
|
||||
name: "CodexAppServerResponseTooLargeError",
|
||||
method: "thread/items/list",
|
||||
limitBytes: 128 * 1024 * 1024,
|
||||
});
|
||||
await expect(other).resolves.toEqual({ data: [] });
|
||||
const next = client.request("model/list", {});
|
||||
child.stdout.write('{"id":3,"result":{"data":["next"]}}\n');
|
||||
await expect(next).resolves.toEqual({ data: ["next"] });
|
||||
expect(terminations).toEqual([]);
|
||||
await client.dispose();
|
||||
child.stdout.end();
|
||||
child.stderr.end();
|
||||
});
|
||||
|
||||
test("an oversized notification fails pending requests and the session", async () => {
|
||||
const child = createCodexAppServerChildProcess();
|
||||
const client = new CodexAppServerClient(child, createTestLogger());
|
||||
const terminations: Error[] = [];
|
||||
client.setUnexpectedTerminationHandler((error) => terminations.push(error));
|
||||
const request = client.request("thread/resume", {}, 1000).catch((error: Error) => error);
|
||||
const chunk = Buffer.alloc(64 * 1024, "x");
|
||||
child.stdout.write('{"id":1,"result":"');
|
||||
child.stdout.write('{"method":"item/completed","params":"');
|
||||
for (let index = 0; index < 2048; index++) child.stdout.write(chunk);
|
||||
const error = await request;
|
||||
expect(error).toMatchObject({
|
||||
|
||||
@@ -33,11 +33,26 @@ interface JsonRpcNotification {
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
method: string;
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
/** A response past the provider line cap. Only its request fails; the session stays up. */
|
||||
export class CodexAppServerResponseTooLargeError extends Error {
|
||||
constructor(
|
||||
readonly method: string,
|
||||
readonly limitBytes: number,
|
||||
) {
|
||||
super(`Codex app-server response to ${method} exceeds ${limitBytes} bytes`);
|
||||
this.name = "CodexAppServerResponseTooLargeError";
|
||||
}
|
||||
}
|
||||
|
||||
// Codex serializes a response with `id` first, so its head names the request.
|
||||
const OVERSIZED_RESPONSE_HEAD = /^\{"id":(\d+),"result":/;
|
||||
|
||||
export class CodexAppServerRpcError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -258,7 +273,7 @@ export class CodexAppServerClient {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`Codex app-server request timed out for ${method}`));
|
||||
}, timeoutMs);
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
this.pending.set(id, { method, resolve, reject, timer });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -304,8 +319,21 @@ export class CodexAppServerClient {
|
||||
}
|
||||
|
||||
private handleStreamLimit(failure: ProviderStreamLimitError): void {
|
||||
// A rejected response/notification cannot be replayed safely. Fail the session,
|
||||
const responseId = OVERSIZED_RESPONSE_HEAD.exec(failure.lineHead)?.[1];
|
||||
const pending = responseId === undefined ? undefined : this.pending.get(Number(responseId));
|
||||
if (pending) {
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(Number(responseId));
|
||||
this.logger.warn(
|
||||
{ method: pending.method, limitBytes: failure.limitBytes },
|
||||
"Codex app-server response exceeded the line cap",
|
||||
);
|
||||
pending.reject(new CodexAppServerResponseTooLargeError(pending.method, failure.limitBytes));
|
||||
return;
|
||||
}
|
||||
// A lost notification or server request cannot be replayed. Fail the session,
|
||||
// including live turns with no pending RPC, rather than dropping a stream unit.
|
||||
this.stdoutLineReader.close();
|
||||
this.handleUnexpectedTermination(failure);
|
||||
void this.dispose().catch((err) => {
|
||||
this.logger.warn({ err }, "Failed to terminate oversized Codex stdout producer");
|
||||
|
||||
@@ -108,7 +108,7 @@ describe("Codex Rewind", () => {
|
||||
cwd: "/workspace/project",
|
||||
model: "gpt-5.4-mini",
|
||||
serviceTier: null,
|
||||
excludeTurns: false,
|
||||
excludeTurns: true,
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
]);
|
||||
@@ -190,7 +190,7 @@ describe("Codex Rewind", () => {
|
||||
cwd: "/workspace/project",
|
||||
model: "gpt-5.4-mini",
|
||||
serviceTier: null,
|
||||
excludeTurns: false,
|
||||
excludeTurns: true,
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
parseCodexThreadForkResponse,
|
||||
parseCodexThreadRollbackResponse,
|
||||
} from "./app-server-transport.js";
|
||||
import { readCodexThreadHistoryMode } from "./thread-history.js";
|
||||
|
||||
export interface CodexRewindClient {
|
||||
forkThread?(params: CodexThreadForkParams): Promise<CodexThreadForkResponse>;
|
||||
@@ -20,26 +21,6 @@ export interface CodexUserMessageTurnIndex {
|
||||
count(): number;
|
||||
}
|
||||
|
||||
type CodexThreadHistoryMode = "legacy" | "paginated";
|
||||
|
||||
async function readCodexThreadHistoryMode(
|
||||
client: CodexRewindClient,
|
||||
threadId: string,
|
||||
): Promise<CodexThreadHistoryMode> {
|
||||
const response = await client.request("thread/read", { threadId, includeTurns: false });
|
||||
if (typeof response !== "object" || response === null || !("thread" in response)) {
|
||||
throw new Error("Codex thread/read did not return thread metadata");
|
||||
}
|
||||
const thread = response.thread;
|
||||
if (typeof thread !== "object" || thread === null || !("historyMode" in thread)) {
|
||||
return "legacy";
|
||||
}
|
||||
if (thread.historyMode === "legacy" || thread.historyMode === "paginated") {
|
||||
return thread.historyMode;
|
||||
}
|
||||
throw new Error(`Codex thread/read returned unknown history mode ${String(thread.historyMode)}`);
|
||||
}
|
||||
|
||||
async function forkCodexThread(
|
||||
client: CodexRewindClient,
|
||||
params: CodexThreadForkParams,
|
||||
@@ -95,7 +76,8 @@ export async function revertCodexConversation(input: {
|
||||
model: input.model ?? null,
|
||||
serviceTier: input.serviceTier ?? null,
|
||||
...(input.config ? { config: input.config } : {}),
|
||||
excludeTurns: false,
|
||||
// Paseo re-reads the rewound thread through paged history, not the fork reply.
|
||||
excludeTurns: true,
|
||||
persistExtendedHistory: true,
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@ import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
|
||||
import type { AgentSession, AgentStreamEvent } from "../../../agent-sdk-types.js";
|
||||
import { CODEX_PROVIDER_LINE_BYTES } from "../app-server-transport.js";
|
||||
|
||||
/** A handler result that the fake writes as a response line past the provider line cap. */
|
||||
export const OVERSIZED_RESPONSE = Symbol("oversized Codex response");
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
type FakeCodexAppServerHandler = (params: unknown) => unknown;
|
||||
@@ -229,8 +233,18 @@ export function createFakeCodexAppServer(
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.resolve(handler(message.params))
|
||||
let response: unknown;
|
||||
try {
|
||||
response = handler(message.params);
|
||||
} catch (error) {
|
||||
response = Promise.reject(error);
|
||||
}
|
||||
Promise.resolve(response)
|
||||
.then((result) => {
|
||||
if (result === OVERSIZED_RESPONSE) {
|
||||
writeOversizedResponse(message.id as number);
|
||||
return undefined;
|
||||
}
|
||||
const rpcError = toJsonObject(result).__jsonRpcError;
|
||||
if (rpcError) {
|
||||
child.stdout.write(`${JSON.stringify({ id: message.id, error: rpcError })}\n`);
|
||||
@@ -313,6 +327,16 @@ export function createFakeCodexAppServer(
|
||||
});
|
||||
}
|
||||
|
||||
// Codex writes `id` first, then a result past the provider line cap.
|
||||
function writeOversizedResponse(id: number): void {
|
||||
child.stdout.write(`{"id":${id},"result":{"data":[{"item":{"text":"`);
|
||||
const chunk = Buffer.alloc(1024 * 1024, "x");
|
||||
for (let written = 0; written <= CODEX_PROVIDER_LINE_BYTES; written += chunk.byteLength) {
|
||||
child.stdout.write(chunk);
|
||||
}
|
||||
child.stdout.write('"}}]}}\n');
|
||||
}
|
||||
|
||||
function writeNotification(method: string, params: JsonObject): void {
|
||||
child.stdout.write(`${JSON.stringify({ method, params })}\n`);
|
||||
}
|
||||
@@ -696,3 +720,81 @@ export function waitForProviderSubagent(
|
||||
): Promise<ProviderSubagentEvent> {
|
||||
return waitForNextEvent(session, "provider_subagent", (event) => event.event.id === id);
|
||||
}
|
||||
|
||||
export interface FakeCodexHistoryTurn {
|
||||
id: string;
|
||||
startedAt?: number;
|
||||
completedAt?: number;
|
||||
items: JsonObject[];
|
||||
}
|
||||
|
||||
export interface FakeCodexHistoryThread {
|
||||
/** Omitted on Codex builds older than 0.143, which only have legacy history. */
|
||||
historyMode?: "legacy" | "paginated";
|
||||
turns: FakeCodexHistoryTurn[];
|
||||
}
|
||||
|
||||
export interface FakeCodexHistoryOptions {
|
||||
/** Any response carrying one of these items is written past the line cap. */
|
||||
oversizedItemIds?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex's persisted thread history: `thread/read` for metadata and legacy full
|
||||
* reads, `thread/items/list` for paginated threads. Paginated threads reject
|
||||
* the full read as Codex 0.145 and 0.146 do.
|
||||
*/
|
||||
export function fakeCodexThreadHistory(
|
||||
threads: Record<string, FakeCodexHistoryThread>,
|
||||
options: FakeCodexHistoryOptions = {},
|
||||
): Record<string, FakeCodexAppServerHandler> {
|
||||
const oversizedItemIds = new Set(options.oversizedItemIds ?? []);
|
||||
const isOversized = (items: JsonObject[]) =>
|
||||
items.some((item) => oversizedItemIds.has(String(item.id)));
|
||||
|
||||
function readThread(params: unknown): { id: string; thread: FakeCodexHistoryThread } {
|
||||
const id = String(toJsonObject(params).threadId);
|
||||
const thread = threads[id];
|
||||
if (!thread) throw new Error(`thread not found: ${id}`);
|
||||
return { id, thread };
|
||||
}
|
||||
|
||||
return {
|
||||
"thread/read": (params) => {
|
||||
const { id, thread } = readThread(params);
|
||||
if (!toJsonObject(params).includeTurns) {
|
||||
return {
|
||||
thread: { id, ...(thread.historyMode ? { historyMode: thread.historyMode } : {}) },
|
||||
};
|
||||
}
|
||||
if (thread.historyMode === "paginated") {
|
||||
throw new Error("paginated threads do not support thread/read(includeTurns=true)");
|
||||
}
|
||||
if (isOversized(thread.turns.flatMap((turn) => turn.items))) return OVERSIZED_RESPONSE;
|
||||
return { thread: { id, turns: thread.turns } };
|
||||
},
|
||||
"thread/items/list": (params) => {
|
||||
const { thread } = readThread(params);
|
||||
if (thread.historyMode !== "paginated") {
|
||||
throw new Error("thread/items/list is not supported for legacy threads");
|
||||
}
|
||||
const request = toJsonObject(params);
|
||||
// Codex records each item's own times; the fake gives every item its turn's.
|
||||
const rows = thread.turns.flatMap((turn) =>
|
||||
turn.items.map((item) => ({
|
||||
turnId: turn.id,
|
||||
item,
|
||||
startedAtMs: turn.startedAt === undefined ? null : turn.startedAt * 1000,
|
||||
completedAtMs: turn.completedAt === undefined ? null : turn.completedAt * 1000,
|
||||
})),
|
||||
);
|
||||
const limit = Math.min(typeof request.limit === "number" ? request.limit : 25, 100);
|
||||
const start =
|
||||
typeof request.cursor === "string" ? Number(request.cursor.slice("cursor-".length)) : 0;
|
||||
const end = Math.min(rows.length, start + limit);
|
||||
const data = rows.slice(start, end);
|
||||
if (isOversized(data.map((row) => row.item))) return OVERSIZED_RESPONSE;
|
||||
return { data, nextCursor: end < rows.length ? `cursor-${end}` : null };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import { CodexAppServerAgentClient } from "../codex-app-server-agent.js";
|
||||
import {
|
||||
createFakeCodexAppServer,
|
||||
type FakeCodexAppServer,
|
||||
type FakeCodexHistoryOptions,
|
||||
type FakeCodexHistoryThread,
|
||||
fakeCodexThreadHistory,
|
||||
} from "./test-utils/fake-app-server.js";
|
||||
|
||||
function userItem(id: string, text: string) {
|
||||
return { type: "userMessage", id, content: [{ type: "text", text }] };
|
||||
}
|
||||
|
||||
function agentItem(id: string, text: string) {
|
||||
return { type: "agentMessage", id, text };
|
||||
}
|
||||
|
||||
async function openCodexThread(
|
||||
thread: FakeCodexHistoryThread,
|
||||
options?: FakeCodexHistoryOptions,
|
||||
): Promise<{ appServer: FakeCodexAppServer; session: AgentSession }> {
|
||||
const appServer = createFakeCodexAppServer(
|
||||
fakeCodexThreadHistory({ "saved-thread": thread }, options),
|
||||
);
|
||||
const provider = new CodexAppServerAgentClient(createTestLogger());
|
||||
const internals = provider as unknown as {
|
||||
goalsEnabledPromise: Promise<boolean> | null;
|
||||
autoReviewEnabledPromise: Promise<boolean> | null;
|
||||
spawnAppServer: () => Promise<ChildProcessWithoutNullStreams>;
|
||||
};
|
||||
internals.goalsEnabledPromise = Promise.resolve(false);
|
||||
internals.autoReviewEnabledPromise = Promise.resolve(false);
|
||||
internals.spawnAppServer = async () => appServer.child;
|
||||
const session = await provider.resumeSession({ sessionId: "saved-thread" });
|
||||
return { appServer, session };
|
||||
}
|
||||
|
||||
async function readHistory(session: AgentSession): Promise<AgentStreamEvent[]> {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
for await (const event of session.streamHistory()) events.push(event);
|
||||
return events;
|
||||
}
|
||||
|
||||
function timelineItems(events: AgentStreamEvent[]) {
|
||||
return events.flatMap((event) => (event.type === "timeline" ? [event.item] : []));
|
||||
}
|
||||
|
||||
function historyRequests(appServer: FakeCodexAppServer) {
|
||||
return appServer
|
||||
.requests()
|
||||
.filter(
|
||||
(request) =>
|
||||
request.method === "thread/read" ||
|
||||
request.method === "thread/turns/list" ||
|
||||
request.method === "thread/items/list",
|
||||
)
|
||||
.map((request) => ({ method: request.method, params: request.params }));
|
||||
}
|
||||
|
||||
describe("Codex thread history", () => {
|
||||
test("loads a paginated thread through pages", async () => {
|
||||
const answers = Array.from({ length: 150 }, (_, index) =>
|
||||
agentItem(`agent-${index}`, `Answer ${index}`),
|
||||
);
|
||||
const { appServer, session } = await openCodexThread({
|
||||
historyMode: "paginated",
|
||||
turns: [
|
||||
{
|
||||
id: "turn-1",
|
||||
startedAt: 1_778_832_941,
|
||||
completedAt: 1_778_833_000,
|
||||
items: [userItem("user-1", "First"), ...answers.slice(0, 120)],
|
||||
},
|
||||
{
|
||||
id: "turn-2",
|
||||
startedAt: 1_778_833_094,
|
||||
completedAt: 1_778_833_100,
|
||||
items: [userItem("user-2", "Second"), ...answers.slice(120)],
|
||||
},
|
||||
],
|
||||
});
|
||||
try {
|
||||
const events = await readHistory(session);
|
||||
|
||||
expect(timelineItems(events)).toEqual([
|
||||
{ type: "user_message", text: "First", messageId: "user-1" },
|
||||
...answers.slice(0, 120).map((answer) => ({
|
||||
type: "assistant_message",
|
||||
text: answer.text,
|
||||
messageId: answer.id,
|
||||
})),
|
||||
{ type: "user_message", text: "Second", messageId: "user-2" },
|
||||
...answers.slice(120).map((answer) => ({
|
||||
type: "assistant_message",
|
||||
text: answer.text,
|
||||
messageId: answer.id,
|
||||
})),
|
||||
]);
|
||||
const timestamps = events.flatMap((event) =>
|
||||
event.type === "timeline" ? [event.timestamp] : [],
|
||||
);
|
||||
expect(timestamps[0]).toBe("2026-05-15T08:15:41.000Z");
|
||||
expect(timestamps[1]).toBe("2026-05-15T08:16:40.000Z");
|
||||
expect(timestamps[121]).toBe("2026-05-15T08:18:14.000Z");
|
||||
const requests = historyRequests(appServer);
|
||||
expect(requests).not.toContainEqual(
|
||||
expect.objectContaining({ params: expect.objectContaining({ includeTurns: true }) }),
|
||||
);
|
||||
const itemPages = requests.filter((request) => request.method === "thread/items/list");
|
||||
expect(itemPages.length).toBeGreaterThan(1);
|
||||
for (const page of itemPages) {
|
||||
expect(page.params).toMatchObject({ threadId: "saved-thread", sortDirection: "asc" });
|
||||
expect((page.params as { limit: number }).limit).toBeLessThanOrEqual(100);
|
||||
}
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("loads a paginated thread whose full read Codex 0.145 rejects", async () => {
|
||||
const { appServer, session } = await openCodexThread({
|
||||
historyMode: "paginated",
|
||||
turns: [{ id: "turn-1", items: [userItem("user-1", "Hello"), agentItem("agent-1", "Hi")] }],
|
||||
});
|
||||
try {
|
||||
expect(timelineItems(await readHistory(session))).toEqual([
|
||||
{ type: "user_message", text: "Hello", messageId: "user-1" },
|
||||
{ type: "assistant_message", text: "Hi", messageId: "agent-1" },
|
||||
]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("a page too big to send shows a warning row and stops loading", async () => {
|
||||
const answers = Array.from({ length: 120 }, (_, index) =>
|
||||
agentItem(`agent-${index}`, `Answer ${index}`),
|
||||
);
|
||||
const { appServer, session } = await openCodexThread(
|
||||
{
|
||||
historyMode: "paginated",
|
||||
turns: [
|
||||
{ id: "turn-1", items: [userItem("user-1", "One"), ...answers] },
|
||||
{ id: "turn-2", items: [userItem("user-2", "Two"), agentItem("huge", "never sent")] },
|
||||
],
|
||||
},
|
||||
{ oversizedItemIds: ["huge"] },
|
||||
);
|
||||
const loaded = [
|
||||
{ type: "user_message", text: "One", messageId: "user-1" },
|
||||
...answers.slice(0, 99).map((answer) => ({
|
||||
type: "assistant_message",
|
||||
text: answer.text,
|
||||
messageId: answer.id,
|
||||
})),
|
||||
{
|
||||
type: "notification",
|
||||
level: "warning",
|
||||
message: "The rest of this Codex thread's history is too large to load.",
|
||||
},
|
||||
];
|
||||
try {
|
||||
expect(timelineItems(await readHistory(session))).toEqual(loaded);
|
||||
// The oversized reply failed only its own request; the session still answers.
|
||||
expect(timelineItems(await readHistory(session))).toEqual(loaded);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("loads a legacy thread that fits in one read", async () => {
|
||||
const { appServer, session } = await openCodexThread({
|
||||
historyMode: "legacy",
|
||||
turns: [{ id: "turn-1", items: [userItem("user-1", "Hello"), agentItem("agent-1", "Hi")] }],
|
||||
});
|
||||
try {
|
||||
expect(timelineItems(await readHistory(session))).toEqual([
|
||||
{ type: "user_message", text: "Hello", messageId: "user-1" },
|
||||
{ type: "assistant_message", text: "Hi", messageId: "agent-1" },
|
||||
]);
|
||||
expect(historyRequests(appServer).map((request) => request.method)).not.toContain(
|
||||
"thread/items/list",
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("a legacy thread too big for one read shows the migration command", async () => {
|
||||
const { appServer, session } = await openCodexThread(
|
||||
{
|
||||
historyMode: "legacy",
|
||||
turns: [{ id: "turn-1", items: [userItem("user-1", "Hello"), agentItem("huge", "x")] }],
|
||||
},
|
||||
{ oversizedItemIds: ["huge"] },
|
||||
);
|
||||
const migrationRow = {
|
||||
type: "notification",
|
||||
level: "warning",
|
||||
message: expect.stringContaining("codex migrate-rollouts --apply --thread saved-thread"),
|
||||
};
|
||||
try {
|
||||
expect(timelineItems(await readHistory(session))).toEqual([migrationRow]);
|
||||
// The session stays up for the user to prompt until they archive it to migrate.
|
||||
expect(timelineItems(await readHistory(session))).toEqual([migrationRow]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { CodexAppServerResponseTooLargeError } from "./app-server-transport.js";
|
||||
|
||||
// Codex clamps history pages to 100 rows.
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
export interface CodexThreadHistoryClient {
|
||||
request(method: string, params?: unknown): Promise<unknown>;
|
||||
}
|
||||
|
||||
export type CodexThreadHistoryMode = "legacy" | "paginated";
|
||||
|
||||
export interface CodexHistoryRow {
|
||||
item: unknown;
|
||||
turnId: string | undefined;
|
||||
/** The item's times on a paginated thread; its turn's on a legacy thread. */
|
||||
startedAt: unknown;
|
||||
completedAt: unknown;
|
||||
}
|
||||
|
||||
/** A history reply over the line cap; the rows before it were already read. */
|
||||
export class CodexThreadHistoryTooLargeError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "CodexThreadHistoryTooLargeError";
|
||||
}
|
||||
}
|
||||
|
||||
const CodexThreadMetadataSchema = z
|
||||
.object({
|
||||
thread: z.object({ historyMode: z.enum(["legacy", "paginated"]).optional() }).passthrough(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const CodexLegacyThreadReadSchema = z
|
||||
.object({
|
||||
thread: z
|
||||
.object({
|
||||
turns: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z.string().optional(),
|
||||
startedAt: z.unknown().optional(),
|
||||
completedAt: z.unknown().optional(),
|
||||
items: z.array(z.unknown()).default([]),
|
||||
})
|
||||
.passthrough(),
|
||||
)
|
||||
.default([]),
|
||||
})
|
||||
.passthrough()
|
||||
.default({ turns: [] }),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const CodexItemsPageSchema = z
|
||||
.object({
|
||||
data: z.array(
|
||||
z
|
||||
.object({
|
||||
turnId: z.string(),
|
||||
item: z.unknown(),
|
||||
startedAtMs: z.number().nullable().optional(),
|
||||
completedAtMs: z.number().nullable().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
),
|
||||
nextCursor: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/** Codex builds before 0.143 report no history mode; their threads are legacy. */
|
||||
export async function readCodexThreadHistoryMode(
|
||||
client: CodexThreadHistoryClient,
|
||||
threadId: string,
|
||||
): Promise<CodexThreadHistoryMode> {
|
||||
const response = CodexThreadMetadataSchema.parse(
|
||||
await client.request("thread/read", { threadId, includeTurns: false }),
|
||||
);
|
||||
return response.thread.historyMode ?? "legacy";
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a thread's persisted items in order. Paginated threads are read in
|
||||
* pages; a legacy thread arrives in one `thread/read` reply, as Codex offers
|
||||
* no pages for it.
|
||||
*/
|
||||
export async function* readCodexThreadHistory(
|
||||
client: CodexThreadHistoryClient,
|
||||
threadId: string,
|
||||
): AsyncGenerator<CodexHistoryRow> {
|
||||
if ((await readCodexThreadHistoryMode(client, threadId)) === "paginated") {
|
||||
yield* readPaginatedHistory(client, threadId);
|
||||
} else {
|
||||
yield* readLegacyHistory(client, threadId);
|
||||
}
|
||||
}
|
||||
|
||||
async function* readLegacyHistory(
|
||||
client: CodexThreadHistoryClient,
|
||||
threadId: string,
|
||||
): AsyncGenerator<CodexHistoryRow> {
|
||||
const response = await requestHistory(
|
||||
client,
|
||||
"thread/read",
|
||||
{ threadId, includeTurns: true },
|
||||
`This Codex thread's history is too large to load in one read. ` +
|
||||
`To move it to paginated history, archive this agent, ` +
|
||||
`run \`codex migrate-rollouts --apply --thread ${threadId}\` (Codex 0.148 or newer), ` +
|
||||
`then unarchive the agent.`,
|
||||
);
|
||||
for (const turn of CodexLegacyThreadReadSchema.parse(response).thread.turns) {
|
||||
for (const item of turn.items) {
|
||||
yield { item, turnId: turn.id, startedAt: turn.startedAt, completedAt: turn.completedAt };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function* readPaginatedHistory(
|
||||
client: CodexThreadHistoryClient,
|
||||
threadId: string,
|
||||
): AsyncGenerator<CodexHistoryRow> {
|
||||
const seenCursors = new Set<string>();
|
||||
let cursor: string | undefined;
|
||||
for (;;) {
|
||||
const page = CodexItemsPageSchema.parse(
|
||||
await requestHistory(
|
||||
client,
|
||||
"thread/items/list",
|
||||
{ threadId, limit: PAGE_SIZE, sortDirection: "asc", cursor },
|
||||
"The rest of this Codex thread's history is too large to load.",
|
||||
),
|
||||
);
|
||||
for (const row of page.data) {
|
||||
yield {
|
||||
item: row.item,
|
||||
turnId: row.turnId,
|
||||
startedAt: row.startedAtMs,
|
||||
completedAt: row.completedAtMs,
|
||||
};
|
||||
}
|
||||
if (page.nextCursor == null) return;
|
||||
// Codex's own client guards against a cursor that does not advance.
|
||||
if (seenCursors.has(page.nextCursor)) {
|
||||
throw new Error(`Codex repeated history cursor for thread ${threadId}`);
|
||||
}
|
||||
seenCursors.add(page.nextCursor);
|
||||
cursor = page.nextCursor;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestHistory(
|
||||
client: CodexThreadHistoryClient,
|
||||
method: string,
|
||||
params: unknown,
|
||||
tooLargeMessage: string,
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
return await client.request(method, params);
|
||||
} catch (error) {
|
||||
if (error instanceof CodexAppServerResponseTooLargeError) {
|
||||
throw new CodexThreadHistoryTooLargeError(tooLargeMessage);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
// Provider-process stdout only; these are not app/daemon wire limits.
|
||||
export const JSONL_PROVIDER_LINE_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
// Enough of an oversized line to read which response it was, e.g. `{"id":12,"result":`.
|
||||
const OVERSIZED_LINE_HEAD_BYTES = 64;
|
||||
|
||||
export class ProviderStreamLimitError extends Error {
|
||||
readonly code = "PROVIDER_STREAM_LINE_TOO_LARGE";
|
||||
|
||||
constructor(readonly limitBytes: number) {
|
||||
constructor(
|
||||
readonly limitBytes: number,
|
||||
/** The first bytes of the oversized line; the rest is discarded unread. */
|
||||
readonly lineHead = "",
|
||||
) {
|
||||
super(
|
||||
`Provider stdout line exceeds ${limitBytes} bytes. Start a new thread or reduce the provider response size.`,
|
||||
);
|
||||
@@ -12,10 +19,26 @@ export class ProviderStreamLimitError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Admits raw UTF-8 bytes before retaining, decoding or joining a complete line. */
|
||||
function readLineHead(parts: Buffer[]): string {
|
||||
const head: Buffer[] = [];
|
||||
let bytes = 0;
|
||||
for (const part of parts) {
|
||||
if (bytes >= OVERSIZED_LINE_HEAD_BYTES) break;
|
||||
head.push(part.subarray(0, OVERSIZED_LINE_HEAD_BYTES - bytes));
|
||||
bytes += head[head.length - 1].byteLength;
|
||||
}
|
||||
return Buffer.concat(head).toString("utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Admits raw UTF-8 bytes before retaining, decoding or joining a complete line.
|
||||
* An oversized line is reported with its head and skipped up to its newline;
|
||||
* the owner closes the reader if the stream cannot continue without it.
|
||||
*/
|
||||
export class ProviderLineReader {
|
||||
private parts: Buffer[] = [];
|
||||
private bytes = 0;
|
||||
private skippingOversizedLine = false;
|
||||
private stopped = false;
|
||||
|
||||
constructor(
|
||||
@@ -31,11 +54,20 @@ export class ProviderLineReader {
|
||||
while (start < buffer.byteLength && !this.stopped) {
|
||||
const newline = buffer.indexOf(10, start);
|
||||
const end = newline === -1 ? buffer.byteLength : newline;
|
||||
if (this.skippingOversizedLine) {
|
||||
if (newline === -1) return;
|
||||
this.skippingOversizedLine = false;
|
||||
start = newline + 1;
|
||||
continue;
|
||||
}
|
||||
const length = end - start;
|
||||
if (length > this.limitBytes - this.bytes) {
|
||||
this.close();
|
||||
this.onLimit(new ProviderStreamLimitError(this.limitBytes));
|
||||
return;
|
||||
const head = readLineHead([...this.parts, buffer.subarray(start, end)]);
|
||||
this.parts = [];
|
||||
this.bytes = 0;
|
||||
this.skippingOversizedLine = true;
|
||||
this.onLimit(new ProviderStreamLimitError(this.limitBytes, head));
|
||||
continue;
|
||||
}
|
||||
if (length) this.parts.push(buffer.subarray(start, end));
|
||||
this.bytes += length;
|
||||
@@ -150,7 +182,10 @@ export class JsonlFrameDecoder {
|
||||
(line) => {
|
||||
if (line.trim()) this.consumeLine(line);
|
||||
},
|
||||
(error) => this.drop("line-too-large", error),
|
||||
(error) => {
|
||||
this.lines.close();
|
||||
this.drop("line-too-large", error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user