Type Codex rollout parser

This commit is contained in:
Mohamed Boudra
2025-11-15 20:24:53 +01:00
parent adda0d92d0
commit 7bdafee818
2 changed files with 176 additions and 42 deletions
@@ -100,6 +100,24 @@ type CodexOptionsOverrides = Partial<ThreadOptions> & { skipGitRepoCheck?: boole
const MAX_ROLLOUT_SEARCH_DEPTH = 5; const MAX_ROLLOUT_SEARCH_DEPTH = 5;
type ToolCallTimelineItem = Extract<AgentTimelineItem, { type: "tool_call" }>; type ToolCallTimelineItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
type ExecPermissionRequestPayload = {
call_id?: string;
command?: string | string[];
cwd?: string;
reason?: string;
risk?: { description?: string } & Record<string, unknown>;
parsed_cmd?: string[];
[key: string]: unknown;
};
type PatchPermissionRequestPayload = {
call_id?: string;
changes?: Record<string, unknown>;
grant_root?: string;
reason?: string;
[key: string]: unknown;
};
function createToolCallTimelineItem( function createToolCallTimelineItem(
data: Omit<ToolCallTimelineItem, "type"> data: Omit<ToolCallTimelineItem, "type">
): AgentTimelineItem { ): AgentTimelineItem {
@@ -612,12 +630,12 @@ class CodexAgentSession implements AgentSession {
} }
if (eventType === "exec_approval_request") { if (eventType === "exec_approval_request") {
const request = this.buildExecPermissionRequest(event as any); const request = this.buildExecPermissionRequest(event);
return this.enqueuePermissionRequest(request); return this.enqueuePermissionRequest(request);
} }
if (eventType === "apply_patch_approval_request") { if (eventType === "apply_patch_approval_request") {
const request = this.buildPatchPermissionRequest(event as any); const request = this.buildPatchPermissionRequest(event);
return this.enqueuePermissionRequest(request); return this.enqueuePermissionRequest(request);
} }
@@ -652,10 +670,11 @@ class CodexAgentSession implements AgentSession {
return events; return events;
} }
private buildExecPermissionRequest(raw: any): AgentPermissionRequest | null { private buildExecPermissionRequest(rawInput: unknown): AgentPermissionRequest | null {
if (!raw || typeof raw !== "object") { if (!rawInput || typeof rawInput !== "object") {
return null; return null;
} }
const raw = rawInput as ExecPermissionRequestPayload;
const commandParts: string[] = Array.isArray(raw.command) const commandParts: string[] = Array.isArray(raw.command)
? raw.command.filter((entry: unknown) => typeof entry === "string") ? raw.command.filter((entry: unknown) => typeof entry === "string")
@@ -705,10 +724,11 @@ class CodexAgentSession implements AgentSession {
return request; return request;
} }
private buildPatchPermissionRequest(raw: any): AgentPermissionRequest | null { private buildPatchPermissionRequest(rawInput: unknown): AgentPermissionRequest | null {
if (!raw || typeof raw !== "object") { if (!rawInput || typeof rawInput !== "object") {
return null; return null;
} }
const raw = rawInput as PatchPermissionRequestPayload;
const requestId = typeof raw.call_id === "string" && raw.call_id.length ? raw.call_id : randomUUID(); const requestId = typeof raw.call_id === "string" && raw.call_id.length ? raw.call_id : randomUUID();
const changes = raw.changes && typeof raw.changes === "object" ? raw.changes : undefined; const changes = raw.changes && typeof raw.changes === "object" ? raw.changes : undefined;
@@ -883,17 +903,17 @@ async function parseRolloutFile(filePath: string, threadId: string): Promise<Age
return events; return events;
} }
function parseRolloutEntry(line: string): { type: string; payload?: any } | null { function parseRolloutEntry(line: string): RolloutEntry | null {
if (!line) { if (!line) {
return null; return null;
} }
try { try {
const parsed = JSON.parse(line); const parsed = JSON.parse(line);
if (parsed && typeof parsed === "object" && "type" in parsed) { if (isRolloutEntry(parsed)) {
return parsed as { type: string; payload?: any }; return parsed;
} }
if (parsed && typeof parsed === "object" && typeof (parsed as any).output === "string") { if (parsed && typeof parsed === "object" && typeof (parsed as { output?: unknown }).output === "string") {
return parseRolloutEntry((parsed as any).output); return parseRolloutEntry((parsed as { output: string }).output);
} }
} catch { } catch {
return null; return null;
@@ -901,8 +921,28 @@ function parseRolloutEntry(line: string): { type: string; payload?: any } | null
return null; return null;
} }
function isRolloutEntry(value: unknown): value is RolloutEntry {
if (!value || typeof value !== "object" || !("type" in value)) {
return false;
}
const type = (value as { type?: unknown }).type;
return type === "response_item" || type === "event_msg";
}
function isSessionMetaEntry(value: unknown): value is SessionMetaEntry {
return Boolean(value && typeof value === "object" && (value as { type?: unknown }).type === "session_meta");
}
function isResponseItemEntry(value: unknown): value is ResponseItemEntry {
return Boolean(value && typeof value === "object" && (value as { type?: unknown }).type === "response_item");
}
function isRolloutMessagePayload(payload: RolloutResponsePayload | undefined): payload is RolloutMessagePayload {
return Boolean(payload && payload.type === "message");
}
function handleRolloutResponseItem( function handleRolloutResponseItem(
payload: any, payload: RolloutResponsePayload | undefined,
events: AgentStreamEvent[], events: AgentStreamEvent[],
commandCalls: Map<string, { command: string }> commandCalls: Map<string, { command: string }>
): void { ): void {
@@ -946,7 +986,7 @@ function handleRolloutResponseItem(
} }
} }
function handleRolloutEventMessage(payload: any, events: AgentStreamEvent[]): void { function handleRolloutEventMessage(payload: RolloutEventPayload | undefined, events: AgentStreamEvent[]): void {
if (!payload || typeof payload !== "object") { if (!payload || typeof payload !== "object") {
return; return;
} }
@@ -956,7 +996,7 @@ function handleRolloutEventMessage(payload: any, events: AgentStreamEvent[]): vo
} }
function handleRolloutFunctionCall( function handleRolloutFunctionCall(
payload: any, payload: RolloutFunctionCallPayload,
events: AgentStreamEvent[], events: AgentStreamEvent[],
commandCalls: Map<string, { command: string }> commandCalls: Map<string, { command: string }>
): void { ): void {
@@ -966,7 +1006,7 @@ function handleRolloutFunctionCall(
} }
if (name === "shell") { if (name === "shell") {
const args = safeJsonParse(payload.arguments); const args = safeJsonParse<Record<string, unknown>>(payload.arguments);
const command = formatCommand(args); const command = formatCommand(args);
if (command && typeof payload.call_id === "string") { if (command && typeof payload.call_id === "string") {
commandCalls.set(payload.call_id, { command }); commandCalls.set(payload.call_id, { command });
@@ -975,7 +1015,7 @@ function handleRolloutFunctionCall(
} }
if (name === "update_plan") { if (name === "update_plan") {
const args = safeJsonParse(payload.arguments); const args = safeJsonParse<{ plan?: unknown }>(payload.arguments);
const planItems = parsePlanItems(args); const planItems = parsePlanItems(args);
if (planItems.length) { if (planItems.length) {
events.push({ type: "timeline", provider: "codex", item: { type: "todo", items: planItems, raw: payload } }); events.push({ type: "timeline", provider: "codex", item: { type: "todo", items: planItems, raw: payload } });
@@ -1000,7 +1040,7 @@ function handleRolloutFunctionCall(
} }
function finalizeRolloutFunctionCall( function finalizeRolloutFunctionCall(
payload: any, payload: RolloutFunctionCallOutputPayload,
events: AgentStreamEvent[], events: AgentStreamEvent[],
commandCalls: Map<string, { command: string }> commandCalls: Map<string, { command: string }>
): void { ): void {
@@ -1011,7 +1051,7 @@ function finalizeRolloutFunctionCall(
if (!command) { if (!command) {
return; return;
} }
const result = safeJsonParse(payload.output); const result = safeJsonParse<CommandExecutionResult>(payload.output);
const exitCode = result?.metadata?.exit_code; const exitCode = result?.metadata?.exit_code;
const status = exitCode === undefined || exitCode === 0 ? "completed" : "failed"; const status = exitCode === undefined || exitCode === 0 ? "completed" : "failed";
events.push({ events.push({
@@ -1032,7 +1072,7 @@ function finalizeRolloutFunctionCall(
commandCalls.delete(payload.call_id); commandCalls.delete(payload.call_id);
} }
function handleRolloutCustomToolCall(payload: any, events: AgentStreamEvent[]): void { function handleRolloutCustomToolCall(payload: RolloutCustomToolCallPayload, events: AgentStreamEvent[]): void {
if (payload?.name === "apply_patch" && typeof payload.input === "string") { if (payload?.name === "apply_patch" && typeof payload.input === "string") {
const files = parsePatchFiles(payload.input); const files = parsePatchFiles(payload.input);
if (files.length) { if (files.length) {
@@ -1076,6 +1116,91 @@ type RolloutCandidate = {
mtime: Date; mtime: Date;
}; };
type RolloutContentBlock = {
text?: string;
message?: string;
[key: string]: unknown;
};
type RolloutMessagePayload = {
type: "message";
role?: string;
content?: RolloutContentBlock[];
};
type RolloutReasoningSummary = {
text?: string;
};
type RolloutReasoningPayload = {
type: "reasoning";
summary?: RolloutReasoningSummary[];
text?: string;
};
type RolloutFunctionCallPayload = {
type: "function_call";
name?: string;
arguments?: string;
call_id?: string;
status?: string;
};
type RolloutFunctionCallOutputPayload = {
type: "function_call_output";
call_id?: string;
output?: string;
};
type RolloutCustomToolCallPayload = {
type: "custom_tool_call";
name?: string;
status?: string;
input?: string | Record<string, unknown>;
output?: unknown;
};
type RolloutResponsePayload =
| RolloutMessagePayload
| RolloutReasoningPayload
| RolloutFunctionCallPayload
| RolloutFunctionCallOutputPayload
| RolloutCustomToolCallPayload;
type RolloutEventPayload = {
type: string;
text?: string;
[key: string]: unknown;
};
type RolloutEntry =
| { type: "response_item"; payload?: RolloutResponsePayload }
| { type: "event_msg"; payload?: RolloutEventPayload };
type CommandExecutionResult = {
metadata?: {
exit_code?: number;
[key: string]: unknown;
};
[key: string]: unknown;
};
type SessionMetaPayload = {
id?: string;
cwd?: string;
[key: string]: unknown;
};
type SessionMetaEntry = {
type: "session_meta";
payload?: SessionMetaPayload;
};
type ResponseItemEntry = {
type: "response_item";
payload?: RolloutResponsePayload;
};
async function collectRecentRolloutFiles(rootDir: string, limit: number): Promise<RolloutCandidate[]> { async function collectRecentRolloutFiles(rootDir: string, limit: number): Promise<RolloutCandidate[]> {
const candidates: RolloutCandidate[] = []; const candidates: RolloutCandidate[] = [];
async function traverse(dir: string): Promise<void> { async function traverse(dir: string): Promise<void> {
@@ -1132,18 +1257,23 @@ async function parseRolloutDescriptor(
for (const rawLine of content.split(/\r?\n/)) { for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim(); const line = rawLine.trim();
if (!line) continue; if (!line) continue;
let entry: any; let entry: unknown;
try { try {
entry = JSON.parse(line); entry = JSON.parse(line);
} catch { } catch {
continue; continue;
} }
if (!sessionId && entry?.type === "session_meta" && entry.payload) { if (!sessionId && isSessionMetaEntry(entry) && entry.payload) {
sessionId = typeof entry.payload.id === "string" ? entry.payload.id : null; sessionId = typeof entry.payload.id === "string" ? entry.payload.id : null;
if (typeof entry.payload.cwd === "string" && entry.payload.cwd.length > 0) { if (typeof entry.payload.cwd === "string" && entry.payload.cwd.length > 0) {
cwd = entry.payload.cwd; cwd = entry.payload.cwd;
} }
} else if (!title && entry?.type === "response_item" && entry.payload?.role === "user") { } else if (
!title &&
isResponseItemEntry(entry) &&
isRolloutMessagePayload(entry.payload) &&
entry.payload.role === "user"
) {
title = extractCodexUserPrompt(entry.payload); title = extractCodexUserPrompt(entry.payload);
} }
if (sessionId && cwd && title) { if (sessionId && cwd && title) {
@@ -1177,12 +1307,11 @@ async function parseRolloutDescriptor(
}; };
} }
function extractCodexUserPrompt(payload: any): string | null { function extractCodexUserPrompt(payload: RolloutMessagePayload): string | null {
const content = payload?.content; if (!Array.isArray(payload.content)) {
if (!Array.isArray(content)) {
return null; return null;
} }
for (const block of content) { for (const block of payload.content) {
if (block && typeof block === "object" && typeof block.text === "string") { if (block && typeof block === "object" && typeof block.text === "string") {
return block.text.trim(); return block.text.trim();
} }
@@ -1207,12 +1336,13 @@ function extractMessageText(content: unknown): string {
if (!block || typeof block !== "object") { if (!block || typeof block !== "object") {
continue; continue;
} }
const text = typeof (block as any).text === "string" ? (block as any).text : undefined; const record = block as Record<string, unknown>;
const text = typeof record.text === "string" ? record.text : undefined;
if (text && text.trim()) { if (text && text.trim()) {
parts.push(text.trim()); parts.push(text.trim());
continue; continue;
} }
const message = typeof (block as any).message === "string" ? (block as any).message : undefined; const message = typeof record.message === "string" ? record.message : undefined;
if (message && message.trim()) { if (message && message.trim()) {
parts.push(message.trim()); parts.push(message.trim());
} }
@@ -1220,10 +1350,10 @@ function extractMessageText(content: unknown): string {
return parts.join("\n").trim(); return parts.join("\n").trim();
} }
function extractReasoningText(payload: any): string { function extractReasoningText(payload: RolloutReasoningPayload): string {
if (Array.isArray(payload?.summary)) { if (Array.isArray(payload?.summary)) {
const text = payload.summary const text = payload.summary
.map((item: any) => (typeof item?.text === "string" ? item.text : "")) .map((item) => (item && typeof item.text === "string" ? item.text : ""))
.filter(Boolean) .filter(Boolean)
.join("\n") .join("\n")
.trim(); .trim();
@@ -1237,18 +1367,20 @@ function extractReasoningText(payload: any): string {
return ""; return "";
} }
function parsePlanItems(args: any): { text: string; completed: boolean }[] { function parsePlanItems(args: unknown): { text: string; completed: boolean }[] {
const plan = Array.isArray(args?.plan) ? args.plan : []; const planValue = typeof args === "object" && args ? (args as { plan?: unknown }).plan : undefined;
const plan = Array.isArray(planValue) ? planValue : [];
const items: { text: string; completed: boolean }[] = []; const items: { text: string; completed: boolean }[] = [];
for (const step of plan) { for (const step of plan) {
if (!step || typeof step !== "object") { if (!step || typeof step !== "object") {
continue; continue;
} }
const text = typeof step.step === "string" ? step.step : typeof step.text === "string" ? step.text : ""; const record = step as { step?: unknown; text?: unknown; status?: unknown };
const text = typeof record.step === "string" ? record.step : typeof record.text === "string" ? record.text : "";
if (!text) { if (!text) {
continue; continue;
} }
const status = typeof step.status === "string" ? step.status : ""; const status = typeof record.status === "string" ? record.status : "";
items.push({ text, completed: status === "completed" }); items.push({ text, completed: status === "completed" });
} }
return items; return items;
@@ -1280,7 +1412,7 @@ function parsePatchFiles(patch: string): { path: string; kind: string }[] {
return files; return files;
} }
function safeJsonParse<T = any>(value: unknown): T | null { function safeJsonParse<T = unknown>(value: unknown): T | null {
if (typeof value !== "string") { if (typeof value !== "string") {
return null; return null;
} }
@@ -1291,15 +1423,16 @@ function safeJsonParse<T = any>(value: unknown): T | null {
} }
} }
function formatCommand(args: any): string | null { function formatCommand(args: unknown): string | null {
if (!args) { if (!args || typeof args !== "object") {
return null; return null;
} }
if (Array.isArray(args.command)) { const record = args as { command?: unknown };
return args.command.join(" "); if (Array.isArray(record.command)) {
return record.command.join(" ");
} }
if (typeof args.command === "string") { if (typeof record.command === "string") {
return args.command; return record.command;
} }
return null; return null;
} }
+2 -1
View File
@@ -84,6 +84,7 @@
- Dropped the old status dot from `AgentStatusBar` (the permission/mode selector now stands alone) and introduced an in-stream "Working" chip inside `AgentStreamView` that renders three bouncing dots via Reanimated whenever the agent reports `status === "running"`, so busy turns surface directly in the chat timeline. - Dropped the old status dot from `AgentStatusBar` (the permission/mode selector now stands alone) and introduced an in-stream "Working" chip inside `AgentStreamView` that renders three bouncing dots via Reanimated whenever the agent reports `status === "running"`, so busy turns surface directly in the chat timeline.
- [x] npm run typecheck and fix all problems - [x] npm run typecheck and fix all problems
- Split the server build/typecheck configs so the typechecker can include the app stream helpers without breaking builds, added path aliases for `@server/*`, and tightened the shared stream types (status unions + `isAgentToolCallItem`) so the server e2e suites can import them safely. Cleaned up every failing app/server test by narrowing tool-call payloads via the helper, updated the harness + Codex/Claude specs, and re-ran `npm run typecheck` (app + server) to green. - Split the server build/typecheck configs so the typechecker can include the app stream helpers without breaking builds, added path aliases for `@server/*`, and tightened the shared stream types (status unions + `isAgentToolCallItem`) so the server e2e suites can import them safely. Cleaned up every failing app/server test by narrowing tool-call payloads via the helper, updated the harness + Codex/Claude specs, and re-ran `npm run typecheck` (app + server) to green.
- [ ] audit codebase for unecessary untyped code, hacks, casts, and `any`, type things properly - [x] audit codebase for unecessary untyped code, hacks, casts, and `any`, type things properly
- Tightened the Codex rollout parser typings so permission requests, rollout/event payloads, plan arguments, and JSON helpers all operate on well-defined TypeScript structures instead of `any`. Added explicit payload/entry guards, removed unsafe casts throughout `packages/server/src/server/agent/providers/codex-agent.ts`, and re-ran `npm run typecheck --workspace=@voice-dev/server` to validate the stricter typing.
- [ ] audit codebase for duplicated code, clean it up - [ ] audit codebase for duplicated code, clean it up
- [ ] rename the project to Paseo, including the Expo app, package names etc. - [ ] rename the project to Paseo, including the Expo app, package names etc.