Fix Codex persisted timeline parsing and session persistence
This commit is contained in:
@@ -494,4 +494,125 @@ describe("AgentManager", () => {
|
|||||||
const updatedAgent = manager.getAgent(snapshot.id);
|
const updatedAgent = manager.getAgent(snapshot.id);
|
||||||
expect(updatedAgent?.currentModeId).toBe("acceptEdits");
|
expect(updatedAgent?.currentModeId).toBe("acceptEdits");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("close during in-flight stream does not clear persistence sessionId", async () => {
|
||||||
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
|
const storagePath = join(workdir, "agents");
|
||||||
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
|
|
||||||
|
class CloseRaceSession implements AgentSession {
|
||||||
|
readonly provider = "codex" as const;
|
||||||
|
readonly capabilities = TEST_CAPABILITIES;
|
||||||
|
readonly id = randomUUID();
|
||||||
|
private threadId: string | null = this.id;
|
||||||
|
private releaseStream: (() => void) | null = null;
|
||||||
|
private closed = false;
|
||||||
|
|
||||||
|
async run(): Promise<AgentRunResult> {
|
||||||
|
return { sessionId: this.id, finalText: "", timeline: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async *stream(): AsyncGenerator<AgentStreamEvent> {
|
||||||
|
yield { type: "turn_started", provider: this.provider };
|
||||||
|
if (!this.closed) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
this.releaseStream = resolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
yield { type: "turn_canceled", provider: this.provider, reason: "closed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
|
||||||
|
|
||||||
|
async getRuntimeInfo() {
|
||||||
|
return {
|
||||||
|
provider: this.provider,
|
||||||
|
sessionId: this.threadId,
|
||||||
|
model: null,
|
||||||
|
modeId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAvailableModes() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCurrentMode() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setMode(): Promise<void> {}
|
||||||
|
|
||||||
|
getPendingPermissions() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async respondToPermission(): Promise<void> {}
|
||||||
|
|
||||||
|
describePersistence() {
|
||||||
|
if (!this.threadId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { provider: this.provider, sessionId: this.threadId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async interrupt(): Promise<void> {}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
this.closed = true;
|
||||||
|
this.threadId = null;
|
||||||
|
this.releaseStream?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CloseRaceClient implements AgentClient {
|
||||||
|
readonly provider = "codex" as const;
|
||||||
|
readonly capabilities = TEST_CAPABILITIES;
|
||||||
|
|
||||||
|
async isAvailable(): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSession(): Promise<AgentSession> {
|
||||||
|
return new CloseRaceSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
async resumeSession(): Promise<AgentSession> {
|
||||||
|
return new CloseRaceSession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = new AgentManager({
|
||||||
|
clients: {
|
||||||
|
codex: new CloseRaceClient(),
|
||||||
|
},
|
||||||
|
registry: storage,
|
||||||
|
logger,
|
||||||
|
idFactory: () => "close-race-agent",
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = await manager.createAgent({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: workdir,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = manager.streamAgent(snapshot.id, "hello");
|
||||||
|
await stream.next();
|
||||||
|
|
||||||
|
await manager.closeAgent(snapshot.id);
|
||||||
|
|
||||||
|
// Drain stream finalizer path after close().
|
||||||
|
while (true) {
|
||||||
|
const next = await stream.next();
|
||||||
|
if (next.done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await manager.flush();
|
||||||
|
await storage.flush();
|
||||||
|
|
||||||
|
const persisted = await storage.get(snapshot.id);
|
||||||
|
expect(persisted?.persistence?.sessionId).toBe(snapshot.persistence?.sessionId);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -601,13 +601,17 @@ export class AgentManager {
|
|||||||
mutableAgent.pendingRun = null;
|
mutableAgent.pendingRun = null;
|
||||||
mutableAgent.lifecycle = error ? "error" : "idle";
|
mutableAgent.lifecycle = error ? "error" : "idle";
|
||||||
mutableAgent.lastError = error;
|
mutableAgent.lastError = error;
|
||||||
mutableAgent.persistence = attachPersistenceCwd(
|
const persistenceHandle =
|
||||||
mutableAgent.session.describePersistence() ??
|
mutableAgent.session.describePersistence() ??
|
||||||
(mutableAgent.runtimeInfo?.sessionId
|
(mutableAgent.runtimeInfo?.sessionId
|
||||||
? { provider: mutableAgent.provider, sessionId: mutableAgent.runtimeInfo.sessionId }
|
? { provider: mutableAgent.provider, sessionId: mutableAgent.runtimeInfo.sessionId }
|
||||||
: null),
|
: null);
|
||||||
mutableAgent.cwd
|
if (persistenceHandle) {
|
||||||
);
|
mutableAgent.persistence = attachPersistenceCwd(
|
||||||
|
persistenceHandle,
|
||||||
|
mutableAgent.cwd
|
||||||
|
);
|
||||||
|
}
|
||||||
this.emitState(mutableAgent);
|
this.emitState(mutableAgent);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1113,7 +1117,12 @@ export class AgentManager {
|
|||||||
case "thread_started":
|
case "thread_started":
|
||||||
// Update persistence with the new session ID from the provider.
|
// Update persistence with the new session ID from the provider.
|
||||||
// persistence.sessionId is the single source of truth for session identity.
|
// persistence.sessionId is the single source of truth for session identity.
|
||||||
agent.persistence = attachPersistenceCwd(agent.session.describePersistence(), agent.cwd);
|
{
|
||||||
|
const handle = agent.session.describePersistence();
|
||||||
|
if (handle) {
|
||||||
|
agent.persistence = attachPersistenceCwd(handle, agent.cwd);
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "timeline":
|
case "timeline":
|
||||||
this.recordTimeline(agent, event.item);
|
this.recordTimeline(agent, event.item);
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { Dirent } from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import readline from "node:readline";
|
import readline from "node:readline";
|
||||||
|
import { loadCodexPersistedTimeline } from "./codex-mcp-agent.js";
|
||||||
|
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
|
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
|
||||||
@@ -1086,22 +1087,40 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private async loadPersistedHistory(): Promise<void> {
|
private async loadPersistedHistory(): Promise<void> {
|
||||||
if (!this.client || !this.currentThreadId) return;
|
if (!this.client || !this.currentThreadId) return;
|
||||||
try {
|
try {
|
||||||
|
let rolloutTimeline: AgentTimelineItem[] = [];
|
||||||
|
try {
|
||||||
|
rolloutTimeline = await loadCodexPersistedTimeline(
|
||||||
|
this.currentThreadId,
|
||||||
|
undefined,
|
||||||
|
this.logger
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
rolloutTimeline = [];
|
||||||
|
}
|
||||||
|
|
||||||
const response = (await this.client.request("thread/read", {
|
const response = (await this.client.request("thread/read", {
|
||||||
threadId: this.currentThreadId,
|
threadId: this.currentThreadId,
|
||||||
includeTurns: true,
|
includeTurns: true,
|
||||||
})) as { thread?: { turns?: Array<{ items?: any[] }> } };
|
})) as { thread?: { turns?: Array<{ items?: any[] }> } };
|
||||||
const thread = response?.thread;
|
const thread = response?.thread;
|
||||||
if (!thread || !Array.isArray(thread.turns)) return;
|
const threadTimeline: AgentTimelineItem[] = [];
|
||||||
const timeline: AgentTimelineItem[] = [];
|
if (thread && Array.isArray(thread.turns)) {
|
||||||
for (const turn of thread.turns) {
|
for (const turn of thread.turns) {
|
||||||
const items = Array.isArray(turn.items) ? turn.items : [];
|
const items = Array.isArray(turn.items) ? turn.items : [];
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const timelineItem = threadItemToTimeline(item, { cwd: this.config.cwd ?? null });
|
const timelineItem = threadItemToTimeline(item, {
|
||||||
if (timelineItem) {
|
cwd: this.config.cwd ?? null,
|
||||||
timeline.push(timelineItem);
|
});
|
||||||
|
if (timelineItem) {
|
||||||
|
threadTimeline.push(timelineItem);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const timeline =
|
||||||
|
rolloutTimeline.length > 0 ? rolloutTimeline : threadTimeline;
|
||||||
|
|
||||||
if (timeline.length > 0) {
|
if (timeline.length > 0) {
|
||||||
this.persistedHistory = timeline;
|
this.persistedHistory = timeline;
|
||||||
this.historyPending = true;
|
this.historyPending = true;
|
||||||
@@ -1775,19 +1794,27 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
const title = thread.preview ?? null;
|
const title = thread.preview ?? null;
|
||||||
let timeline: AgentTimelineItem[] = [];
|
let timeline: AgentTimelineItem[] = [];
|
||||||
try {
|
try {
|
||||||
|
const rolloutTimeline = await loadCodexPersistedTimeline(
|
||||||
|
threadId,
|
||||||
|
undefined,
|
||||||
|
this.logger
|
||||||
|
);
|
||||||
const read = (await client.request("thread/read", {
|
const read = (await client.request("thread/read", {
|
||||||
threadId,
|
threadId,
|
||||||
includeTurns: true,
|
includeTurns: true,
|
||||||
})) as { thread?: { turns?: Array<{ items?: any[] }> } };
|
})) as { thread?: { turns?: Array<{ items?: any[] }> } };
|
||||||
const turns = read.thread?.turns ?? [];
|
const turns = read.thread?.turns ?? [];
|
||||||
const items: AgentTimelineItem[] = [];
|
const itemsFromThreadRead: AgentTimelineItem[] = [];
|
||||||
for (const turn of turns) {
|
for (const turn of turns) {
|
||||||
for (const item of turn.items ?? []) {
|
for (const item of turn.items ?? []) {
|
||||||
const timelineItem = threadItemToTimeline(item, { cwd });
|
const timelineItem = threadItemToTimeline(item, { cwd });
|
||||||
if (timelineItem) items.push(timelineItem);
|
if (timelineItem) itemsFromThreadRead.push(timelineItem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
timeline = items;
|
timeline =
|
||||||
|
rolloutTimeline.length > 0
|
||||||
|
? rolloutTimeline
|
||||||
|
: itemsFromThreadRead;
|
||||||
} catch {
|
} catch {
|
||||||
timeline = [];
|
timeline = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5097,10 +5097,20 @@ async function findRolloutFile(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RolloutContentItemSchema = z.object({
|
const RolloutContentItemSchema = z
|
||||||
type: z.string(),
|
.union([
|
||||||
text: z.string(),
|
z.object({ type: z.literal("input_text"), text: z.string() }),
|
||||||
}).passthrough();
|
z.object({ type: z.literal("output_text"), text: z.string() }),
|
||||||
|
z.object({ type: z.literal("reasoning_text"), text: z.string() }),
|
||||||
|
z.object({ type: z.literal("text"), text: z.string() }),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
type: z.string(),
|
||||||
|
text: z.string().optional(),
|
||||||
|
message: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough(),
|
||||||
|
]);
|
||||||
|
|
||||||
const RolloutContentArraySchema = z.array(RolloutContentItemSchema);
|
const RolloutContentArraySchema = z.array(RolloutContentItemSchema);
|
||||||
|
|
||||||
@@ -5116,77 +5126,131 @@ function extractContentTextByType(content: unknown, itemType: string): string {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
const RolloutResponsePayloadSchema = z.object({
|
const RolloutResponseMessagePayloadSchema = z.object({
|
||||||
type: z.string().optional(),
|
type: z.literal("message"),
|
||||||
role: z.string().optional(),
|
role: z.enum(["user", "assistant"]).optional(),
|
||||||
|
content: z.unknown().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const RolloutResponseReasoningPayloadSchema = z.object({
|
||||||
|
type: z.literal("reasoning"),
|
||||||
content: z.unknown().optional(),
|
content: z.unknown().optional(),
|
||||||
name: z.string().optional(),
|
|
||||||
call_id: z.string().optional(),
|
|
||||||
arguments: z.string().optional(),
|
|
||||||
output: z.string().optional(),
|
|
||||||
summary: z.array(z.object({ text: z.string().optional() })).optional(),
|
summary: z.array(z.object({ text: z.string().optional() })).optional(),
|
||||||
text: z.string().optional(),
|
text: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const RolloutEventPayloadSchema = z.object({
|
const RolloutResponseFunctionCallPayloadSchema = z.object({
|
||||||
type: z.string().optional(),
|
type: z.literal("function_call"),
|
||||||
|
name: z.string().optional(),
|
||||||
|
call_id: z.string().optional(),
|
||||||
|
arguments: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const RolloutResponseCustomToolCallPayloadSchema = z.object({
|
||||||
|
type: z.literal("custom_tool_call"),
|
||||||
|
name: z.string().optional(),
|
||||||
|
call_id: z.string().optional(),
|
||||||
|
arguments: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const RolloutResponseFunctionCallOutputPayloadSchema = z.object({
|
||||||
|
type: z.literal("function_call_output"),
|
||||||
|
call_id: z.string().optional(),
|
||||||
|
output: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const RolloutEventAgentReasoningPayloadSchema = z.object({
|
||||||
|
type: z.literal("agent_reasoning"),
|
||||||
text: z.string().optional(),
|
text: z.string().optional(),
|
||||||
message: z.string().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const RolloutEntrySchema = z.object({
|
const RolloutEventAgentMessagePayloadSchema = z.object({
|
||||||
type: z.enum(["response_item", "event_msg"]),
|
type: z.literal("agent_message"),
|
||||||
payload: z.unknown().optional(),
|
message: z
|
||||||
|
.union([
|
||||||
|
z.string(),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
role: z.string().optional(),
|
||||||
|
message: z.string().optional(),
|
||||||
|
text: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough(),
|
||||||
|
])
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type RolloutEntry = z.infer<typeof RolloutEntrySchema>;
|
const RolloutEventUserMessagePayloadSchema = z.object({
|
||||||
type RolloutResponsePayload = z.infer<typeof RolloutResponsePayloadSchema>;
|
type: z.literal("user_message"),
|
||||||
|
message: z
|
||||||
|
.union([
|
||||||
|
z.string(),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
role: z.string().optional(),
|
||||||
|
message: z.string().optional(),
|
||||||
|
text: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough(),
|
||||||
|
])
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
function parseRolloutEntryFromLine(line: string): RolloutEntry | null {
|
type RolloutResponseReasoningPayload = z.infer<
|
||||||
if (!line) {
|
typeof RolloutResponseReasoningPayloadSchema
|
||||||
return null;
|
>;
|
||||||
}
|
type ParsedRolloutRecord =
|
||||||
try {
|
| { kind: "timeline"; item: AgentTimelineItem }
|
||||||
const parsed = JSON.parse(line);
|
| { kind: "call"; name: string; callId?: string; input?: unknown }
|
||||||
const result = RolloutEntrySchema.safeParse(parsed);
|
| { kind: "output"; callId: string; output: string }
|
||||||
if (result.success) {
|
| { kind: "ignore" };
|
||||||
return result.data;
|
|
||||||
}
|
const RolloutMessageContentSchema = z
|
||||||
if (
|
.union([
|
||||||
parsed &&
|
z.string().transform((content) => content.trim()),
|
||||||
typeof parsed === "object" &&
|
z.array(
|
||||||
typeof (parsed as { output?: unknown }).output === "string"
|
z
|
||||||
) {
|
.object({
|
||||||
return parseRolloutEntryFromLine((parsed as { output: string }).output);
|
text: z.string().optional(),
|
||||||
}
|
message: z.string().optional(),
|
||||||
} catch {
|
})
|
||||||
return null;
|
.passthrough()
|
||||||
}
|
).transform((content) =>
|
||||||
return null;
|
content
|
||||||
}
|
.map((block) => block.text ?? block.message ?? "")
|
||||||
|
.map((text) => text.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")
|
||||||
|
.trim()
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
function extractMessageText(content: unknown): string {
|
function extractMessageText(content: unknown): string {
|
||||||
if (!Array.isArray(content)) {
|
const parsed = RolloutMessageContentSchema.safeParse(content);
|
||||||
|
if (!parsed.success) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
const parts: string[] = [];
|
return parsed.data;
|
||||||
for (const block of content) {
|
}
|
||||||
if (!block || typeof block !== "object") {
|
|
||||||
continue;
|
const RolloutEventMessageTextSchema = z
|
||||||
}
|
.union([
|
||||||
const record = block as Record<string, unknown>;
|
z.string(),
|
||||||
const text = typeof record.text === "string" ? record.text : undefined;
|
z
|
||||||
if (text && text.trim()) {
|
.object({
|
||||||
parts.push(text.trim());
|
message: z.string().optional(),
|
||||||
continue;
|
text: z.string().optional(),
|
||||||
}
|
})
|
||||||
const message =
|
.passthrough()
|
||||||
typeof record.message === "string" ? record.message : undefined;
|
.transform((message) => message.message ?? message.text ?? ""),
|
||||||
if (message && message.trim()) {
|
]);
|
||||||
parts.push(message.trim());
|
|
||||||
}
|
function extractEventMessageText(message: unknown): string {
|
||||||
|
const parsed = RolloutEventMessageTextSchema.safeParse(message);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
return parts.join("\n").trim();
|
return parsed.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSyntheticRolloutUserMessage(text: string): boolean {
|
function isSyntheticRolloutUserMessage(text: string): boolean {
|
||||||
@@ -5207,10 +5271,10 @@ function isSyntheticRolloutUserMessage(text: string): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractReasoningText(payload: RolloutResponsePayload): string {
|
function extractReasoningText(payload: RolloutResponseReasoningPayload): string {
|
||||||
if (Array.isArray(payload?.summary)) {
|
if (Array.isArray(payload.summary)) {
|
||||||
const text = payload.summary
|
const text = payload.summary
|
||||||
.map((item) => (item && typeof item.text === "string" ? item.text : ""))
|
.map((item) => item.text ?? "")
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n")
|
.join("\n")
|
||||||
.trim();
|
.trim();
|
||||||
@@ -5218,17 +5282,117 @@ function extractReasoningText(payload: RolloutResponsePayload): string {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Handle content array with reasoning_text items
|
|
||||||
const contentText = extractContentTextByType(payload.content, "reasoning_text");
|
const contentText = extractContentTextByType(payload.content, "reasoning_text");
|
||||||
if (contentText) {
|
if (contentText) {
|
||||||
return contentText;
|
return contentText;
|
||||||
}
|
}
|
||||||
if (typeof payload?.text === "string") {
|
if (typeof payload.text === "string") {
|
||||||
return payload.text;
|
return payload.text;
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseJsonLikeString(value: string): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FunctionCallInputNormalizationSchema = z
|
||||||
|
.union([
|
||||||
|
z.object({ cmd: z.string() }).transform((input) => ({ name: "Bash", input: { command: input.cmd } })),
|
||||||
|
z
|
||||||
|
.object({ command: z.array(z.string()) })
|
||||||
|
.transform((input) => ({ name: "Bash", input: { command: input.command[2] ?? "" } })),
|
||||||
|
z.unknown().transform((input) => ({ name: "unknown", input })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const RolloutResponseRecordSchema = z
|
||||||
|
.union([
|
||||||
|
RolloutResponseMessagePayloadSchema.transform((payload): ParsedRolloutRecord => {
|
||||||
|
const text = extractMessageText(payload.content);
|
||||||
|
const itemType = payload.role === "assistant" ? "assistant_message" : "user_message";
|
||||||
|
const shouldEmit = text.length > 0 && (itemType !== "user_message" || !isSyntheticRolloutUserMessage(text));
|
||||||
|
return shouldEmit
|
||||||
|
? { kind: "timeline", item: { type: itemType, text } }
|
||||||
|
: { kind: "ignore" };
|
||||||
|
}),
|
||||||
|
RolloutResponseReasoningPayloadSchema.transform((payload): ParsedRolloutRecord => {
|
||||||
|
const text = extractReasoningText(payload);
|
||||||
|
return text.length > 0
|
||||||
|
? { kind: "timeline", item: { type: "reasoning", text } }
|
||||||
|
: { kind: "ignore" };
|
||||||
|
}),
|
||||||
|
z
|
||||||
|
.union([
|
||||||
|
RolloutResponseFunctionCallPayloadSchema,
|
||||||
|
RolloutResponseCustomToolCallPayloadSchema,
|
||||||
|
])
|
||||||
|
.transform((payload): ParsedRolloutRecord => {
|
||||||
|
const rawName = payload.name ?? "unknown";
|
||||||
|
const parsedArguments = payload.arguments ? parseJsonLikeString(payload.arguments) : undefined;
|
||||||
|
const normalized =
|
||||||
|
rawName === "exec_command" || rawName === "shell"
|
||||||
|
? FunctionCallInputNormalizationSchema.parse(parsedArguments)
|
||||||
|
: { name: rawName, input: parsedArguments };
|
||||||
|
const skip = rawName === "write_stdin";
|
||||||
|
return skip
|
||||||
|
? { kind: "ignore" }
|
||||||
|
: {
|
||||||
|
kind: "call",
|
||||||
|
name: normalized.name,
|
||||||
|
callId: payload.call_id,
|
||||||
|
input: normalized.input,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
RolloutResponseFunctionCallOutputPayloadSchema.transform(
|
||||||
|
(payload): ParsedRolloutRecord =>
|
||||||
|
payload.call_id && payload.output
|
||||||
|
? { kind: "output", callId: payload.call_id, output: payload.output }
|
||||||
|
: { kind: "ignore" }
|
||||||
|
),
|
||||||
|
z.unknown().transform((): ParsedRolloutRecord => ({ kind: "ignore" })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const RolloutEventRecordSchema = z
|
||||||
|
.union([
|
||||||
|
RolloutEventAgentReasoningPayloadSchema.transform(
|
||||||
|
(payload): ParsedRolloutRecord =>
|
||||||
|
payload.text
|
||||||
|
? { kind: "timeline", item: { type: "reasoning", text: payload.text } }
|
||||||
|
: { kind: "ignore" }
|
||||||
|
),
|
||||||
|
RolloutEventAgentMessagePayloadSchema.transform((payload): ParsedRolloutRecord => {
|
||||||
|
const text = extractEventMessageText(payload.message);
|
||||||
|
return text.length > 0
|
||||||
|
? { kind: "timeline", item: { type: "assistant_message", text } }
|
||||||
|
: { kind: "ignore" };
|
||||||
|
}),
|
||||||
|
RolloutEventUserMessagePayloadSchema.transform((payload): ParsedRolloutRecord => {
|
||||||
|
const text = extractEventMessageText(payload.message);
|
||||||
|
const shouldEmit = text.length > 0 && !isSyntheticRolloutUserMessage(text);
|
||||||
|
return shouldEmit
|
||||||
|
? { kind: "timeline", item: { type: "user_message", text } }
|
||||||
|
: { kind: "ignore" };
|
||||||
|
}),
|
||||||
|
z.unknown().transform((): ParsedRolloutRecord => ({ kind: "ignore" })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const RolloutRecordSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.enum(["response_item", "event_msg"]),
|
||||||
|
payload: z.unknown().optional(),
|
||||||
|
item: z.unknown().optional(),
|
||||||
|
msg: z.unknown().optional(),
|
||||||
|
})
|
||||||
|
.transform((entry) =>
|
||||||
|
entry.type === "response_item"
|
||||||
|
? RolloutResponseRecordSchema.parse(entry.payload ?? entry.item)
|
||||||
|
: RolloutEventRecordSchema.parse(entry.payload ?? entry.msg)
|
||||||
|
);
|
||||||
|
|
||||||
function parseJsonRolloutTimeline(
|
function parseJsonRolloutTimeline(
|
||||||
parsed: unknown
|
parsed: unknown
|
||||||
): AgentTimelineItem[] | null {
|
): AgentTimelineItem[] | null {
|
||||||
@@ -5244,25 +5408,28 @@ function parseJsonRolloutTimeline(
|
|||||||
if (!entry || typeof entry !== "object") {
|
if (!entry || typeof entry !== "object") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const record = entry as Record<string, unknown>;
|
const messagePayloadResult =
|
||||||
const type = record.type;
|
RolloutResponseMessagePayloadSchema.safeParse(entry);
|
||||||
if (type === "message") {
|
if (messagePayloadResult.success) {
|
||||||
const role = record.role;
|
const payload = messagePayloadResult.data;
|
||||||
const text = extractMessageText(record.content);
|
const text = extractMessageText(payload.content);
|
||||||
if (!text || typeof role !== "string") {
|
if (!text) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (role === "assistant") {
|
if (payload.role === "assistant") {
|
||||||
timeline.push({ type: "assistant_message", text });
|
timeline.push({ type: "assistant_message", text });
|
||||||
} else if (role === "user") {
|
} else if (payload.role === "user") {
|
||||||
if (!isSyntheticRolloutUserMessage(text)) {
|
if (!isSyntheticRolloutUserMessage(text)) {
|
||||||
timeline.push({ type: "user_message", text });
|
timeline.push({ type: "user_message", text });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (type === "reasoning") {
|
|
||||||
const text = extractReasoningText(record as RolloutResponsePayload);
|
const reasoningPayloadResult =
|
||||||
|
RolloutResponseReasoningPayloadSchema.safeParse(entry);
|
||||||
|
if (reasoningPayloadResult.success) {
|
||||||
|
const text = extractReasoningText(reasoningPayloadResult.data);
|
||||||
if (text) {
|
if (text) {
|
||||||
timeline.push({ type: "reasoning", text });
|
timeline.push({ type: "reasoning", text });
|
||||||
}
|
}
|
||||||
@@ -5294,126 +5461,39 @@ export async function parseRolloutFile(
|
|||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
// First pass: collect function_call_output entries by call_id
|
const parsedRecords = lines
|
||||||
const outputsByCallId = new Map<string, string>();
|
.map((line) => {
|
||||||
for (const line of lines) {
|
try {
|
||||||
const entry = parseRolloutEntryFromLine(line);
|
return JSON.parse(line);
|
||||||
if (!entry || entry.type !== "response_item") continue;
|
} catch {
|
||||||
const payloadResult = RolloutResponsePayloadSchema.safeParse(entry.payload);
|
return null;
|
||||||
if (!payloadResult.success) continue;
|
|
||||||
const payload = payloadResult.data;
|
|
||||||
if (payload.type === "function_call_output" && payload.call_id && payload.output) {
|
|
||||||
outputsByCallId.set(payload.call_id, payload.output);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Second pass: build timeline
|
|
||||||
const timeline: AgentTimelineItem[] = [];
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
const entry = parseRolloutEntryFromLine(line);
|
|
||||||
if (!entry) continue;
|
|
||||||
|
|
||||||
if (entry.type === "response_item") {
|
|
||||||
const payloadResult = RolloutResponsePayloadSchema.safeParse(entry.payload);
|
|
||||||
if (!payloadResult.success) continue;
|
|
||||||
const payload = payloadResult.data;
|
|
||||||
|
|
||||||
switch (payload.type) {
|
|
||||||
case "message": {
|
|
||||||
const text = extractMessageText(payload.content);
|
|
||||||
if (text) {
|
|
||||||
if (payload.role === "assistant") {
|
|
||||||
timeline.push({ type: "assistant_message", text });
|
|
||||||
} else if (payload.role === "user") {
|
|
||||||
if (!isSyntheticRolloutUserMessage(text)) {
|
|
||||||
timeline.push({ type: "user_message", text });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "reasoning": {
|
|
||||||
const text = extractReasoningText(payload);
|
|
||||||
if (text) {
|
|
||||||
timeline.push({ type: "reasoning", text });
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "function_call":
|
|
||||||
case "custom_tool_call": {
|
|
||||||
const rawName = payload.name ?? "unknown";
|
|
||||||
const callId = payload.call_id;
|
|
||||||
|
|
||||||
// Skip internal polling calls
|
|
||||||
if (rawName === "write_stdin") {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let input: unknown;
|
|
||||||
if (payload.arguments) {
|
|
||||||
try {
|
|
||||||
input = JSON.parse(payload.arguments);
|
|
||||||
} catch {
|
|
||||||
input = payload.arguments;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map exec_command and shell to Bash with normalized input
|
|
||||||
let name = rawName;
|
|
||||||
if (rawName === "exec_command" && input && typeof input === "object") {
|
|
||||||
const execInput = input as { cmd?: string };
|
|
||||||
if (execInput.cmd) {
|
|
||||||
name = "Bash";
|
|
||||||
input = { command: execInput.cmd };
|
|
||||||
}
|
|
||||||
} else if (rawName === "shell" && input && typeof input === "object") {
|
|
||||||
// Older format: { command: ["bash", "-lc", "actual cmd"], workdir: "..." }
|
|
||||||
const shellInput = input as { command?: string[] };
|
|
||||||
if (Array.isArray(shellInput.command) && shellInput.command.length >= 3) {
|
|
||||||
name = "Bash";
|
|
||||||
// command[2] is the actual shell command after "bash -lc"
|
|
||||||
input = { command: shellInput.command[2] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attach output if available
|
|
||||||
const output = callId ? outputsByCallId.get(callId) : undefined;
|
|
||||||
|
|
||||||
timeline.push({
|
|
||||||
type: "tool_call",
|
|
||||||
name,
|
|
||||||
callId,
|
|
||||||
status: "completed",
|
|
||||||
input,
|
|
||||||
...(output ? { output } : {}),
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "function_call_output":
|
|
||||||
// Already processed in first pass
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
} else if (entry.type === "event_msg") {
|
})
|
||||||
const payloadResult = RolloutEventPayloadSchema.safeParse(entry.payload);
|
.filter((record): record is unknown => record !== null)
|
||||||
if (!payloadResult.success) continue;
|
.map((record) => RolloutRecordSchema.safeParse(record))
|
||||||
const payload = payloadResult.data;
|
.filter((result): result is { success: true; data: ParsedRolloutRecord } => result.success)
|
||||||
|
.map((result) => result.data);
|
||||||
|
|
||||||
if (payload.type === "agent_reasoning" && payload.text) {
|
const outputsByCallId = parsedRecords
|
||||||
timeline.push({ type: "reasoning", text: payload.text });
|
.filter((record): record is Extract<ParsedRolloutRecord, { kind: "output" }> => record.kind === "output")
|
||||||
} else if (payload.type === "agent_message" && payload.message) {
|
.reduce((map, record) => map.set(record.callId, record.output), new Map<string, string>());
|
||||||
timeline.push({ type: "assistant_message", text: payload.message });
|
|
||||||
} else if (payload.type === "user_message" && payload.message) {
|
|
||||||
if (!isSyntheticRolloutUserMessage(payload.message)) {
|
|
||||||
timeline.push({ type: "user_message", text: payload.message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return timeline;
|
return parsedRecords.flatMap((record): AgentTimelineItem[] =>
|
||||||
|
record.kind === "timeline"
|
||||||
|
? [record.item]
|
||||||
|
: record.kind === "call"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: "tool_call",
|
||||||
|
name: record.name,
|
||||||
|
callId: record.callId,
|
||||||
|
status: "completed",
|
||||||
|
input: record.input,
|
||||||
|
output: record.callId ? outputsByCallId.get(record.callId) : undefined,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type CodexPersistedTimelineOptions = {
|
type CodexPersistedTimelineOptions = {
|
||||||
|
|||||||
@@ -216,6 +216,79 @@ describe("codex rollout parsing", () => {
|
|||||||
text: "Let me think about this.",
|
text: "Let me think about this.",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("parses legacy response_item shape using item", async () => {
|
||||||
|
const rolloutPath = join(tmpDir, "rollout.jsonl");
|
||||||
|
const lines = [
|
||||||
|
JSON.stringify({
|
||||||
|
timestamp: "2026-01-22T07:08:54.378Z",
|
||||||
|
type: "response_item",
|
||||||
|
item: {
|
||||||
|
type: "function_call",
|
||||||
|
name: "exec_command",
|
||||||
|
arguments: '{"cmd":"echo hello"}',
|
||||||
|
call_id: "call_legacy_1",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
JSON.stringify({
|
||||||
|
timestamp: "2026-01-22T07:09:01.785Z",
|
||||||
|
type: "response_item",
|
||||||
|
item: {
|
||||||
|
type: "function_call_output",
|
||||||
|
call_id: "call_legacy_1",
|
||||||
|
output: "hello",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
writeFileSync(rolloutPath, lines.join("\n") + "\n");
|
||||||
|
|
||||||
|
const timeline = await parseRolloutFile(rolloutPath);
|
||||||
|
const toolCalls = timeline.filter((i) => i.type === "tool_call");
|
||||||
|
expect(toolCalls.length).toBe(1);
|
||||||
|
expect(toolCalls[0]).toMatchObject({
|
||||||
|
type: "tool_call",
|
||||||
|
name: "Bash",
|
||||||
|
callId: "call_legacy_1",
|
||||||
|
input: { command: "echo hello" },
|
||||||
|
output: "hello",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses legacy event_msg shape using msg", async () => {
|
||||||
|
const rolloutPath = join(tmpDir, "rollout.jsonl");
|
||||||
|
const lines = [
|
||||||
|
JSON.stringify({
|
||||||
|
timestamp: "2026-01-22T07:08:54.378Z",
|
||||||
|
type: "event_msg",
|
||||||
|
msg: {
|
||||||
|
type: "agent_reasoning",
|
||||||
|
text: "thinking",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
JSON.stringify({
|
||||||
|
timestamp: "2026-01-22T07:08:54.378Z",
|
||||||
|
type: "event_msg",
|
||||||
|
msg: {
|
||||||
|
type: "agent_message",
|
||||||
|
message: { role: "assistant", message: "done" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
JSON.stringify({
|
||||||
|
timestamp: "2026-01-22T07:08:54.378Z",
|
||||||
|
type: "event_msg",
|
||||||
|
msg: {
|
||||||
|
type: "user_message",
|
||||||
|
message: { role: "user", message: "question" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
writeFileSync(rolloutPath, lines.join("\n") + "\n");
|
||||||
|
|
||||||
|
const timeline = await parseRolloutFile(rolloutPath);
|
||||||
|
expect(timeline).toContainEqual({ type: "reasoning", text: "thinking" });
|
||||||
|
expect(timeline).toContainEqual({ type: "assistant_message", text: "done" });
|
||||||
|
expect(timeline).toContainEqual({ type: "user_message", text: "question" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("complex conversation", () => {
|
describe("complex conversation", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user