diff --git a/packages/host/agent-adapter/AGENTS.md b/packages/host/agent-adapter/AGENTS.md index 0558eda7..2d310933 100644 --- a/packages/host/agent-adapter/AGENTS.md +++ b/packages/host/agent-adapter/AGENTS.md @@ -84,7 +84,7 @@ Every new adapter MUST honor these (`base.ts`); downstream relies on them, they - **Session-ref is DEFERRED for fresh threads** until the first turn is accepted — announcing at `thread/start` triggers the client's transcript seed against an empty rollout and the seed's uptoSeq cut swallows the first prompt. Resumed threads announce immediately. - **Auth (CODE-174)**: the app-server caches credentials for its whole process lifetime — an `auth.json` written after spawn is invisible to `getAuthStatus` AND the request path (verified live on 0.144.1). A signed-out turn 401s through a ~27 s retry storm (5× websocket then 5× https); the structured status rides only the mid-retry `error` notifications (`codexErrorInfo.responseStreamDisconnected.httpStatusCode`), while the final no-retry error degrades to `codexErrorInfo:"other"` with the 401 left in prose — `isCodexAuthError` matches both. The adapter latches the FIRST 401 into one non-recoverable `authentication_failed` error (the code the daemon's login re-probe keys on), quietly retires the server (deliberate `close()` suppresses the exit alarm) and arms `resumeFrom`, so the next prompt respawns + `thread/resume`s with fresh on-disk credentials — retry-after-login works via respawn, never in-place. - **Usage**: `thread/tokenUsage/updated` fires once per model call; emit the thread-cumulative `total`, not `last` (consumers replace usage wholesale). No cost data on any codex surface. -- **History** stays on direct rollout-JSONL reads (`sessions/` + `archived_sessions/` + `session_index.jsonl`, filtered by cwd), skipping corrupt lines, independent of the live process. History reads carry the project cwd so `CODEX_HOME` resolves through the same login-shell/`direnv` environment as the live session; project-scoped list lookups preserve their resolving cwd for subsequent import reads. Machine-injected user-role rows are filtered from replay and title previews per content part — codex 0.144 dropped the `` wrapper and glues a `# AGENTS.md instructions …` prose part and the `` part into ONE user row (marker list in `history.ts`, all verbatim in the 0.144.1 binary; CODE-235). A marker-matched row is rescued only when every marker-bearing part is echoed by an `event_msg`/`user_message` row — real prompts always are (TUI- and app-server-written alike), injected rows never; rollouts without event_msg rows degrade to marker-only; `turn_context.summary` is a reasoning-summary mode, NOT a title. Read pages are cut by aggregate embedded-attachment payload as well as event count (`sliceHistoryEventPage`, budget `MAX_ATTACHMENT_TOTAL_BASE64_LENGTH`) — one `history.read.result` is a single logical transport message and the tunnel silently drops what its reassembly buffer can't hold, so image-heavy transcripts fan across cursor pages. Reasoning cannot replay from rollouts (`encrypted_content` only). +- **History** stays on direct rollout-JSONL reads (`sessions/` + `archived_sessions/` + `session_index.jsonl`, filtered by cwd), skipping corrupt lines, independent of the live process. History reads carry the project cwd so `CODEX_HOME` resolves through the same login-shell/`direnv` environment as the live session; project-scoped list lookups preserve their resolving cwd for subsequent import reads. Machine-injected user-role rows are filtered from replay and title previews per content part — codex 0.144 dropped the `` wrapper and glues a `# AGENTS.md instructions …` prose part and the `` part into ONE user row (marker list in `history.ts`, all verbatim in the 0.144.1 binary; CODE-235). 0.144.6 additionally injects a `` row (the invoked SKILL.md) beside the typed `$name args` prompt, plus `` / ` { ]); }); + it('replays code-mode MCP calls from their mcp_tool_call_end event rows (CODE-576)', () => { + // Real 0.144.6 shapes: nested code-mode MCP calls persist ONLY as this event; its call_id is + // the live mcpToolCall item id, and `result` is a serialized Rust Result (`Ok`/`Err`). + const events = mapCodexHistoryEvents(HID, [ + { + type: 'event_msg', + payload: { + type: 'mcp_tool_call_end', + call_id: 'exec-957cc4b0', + invocation: { + server: 'codex_apps', + tool: 'linear.list_issues', + arguments: { limit: 50, orderBy: 'updatedAt' }, + }, + result: { Ok: { content: [{ type: 'text', text: 'reauth required' }], isError: true } }, + }, + }, + { + type: 'event_msg', + payload: { + type: 'mcp_tool_call_end', + call_id: 'exec-06f8d6de', + invocation: { server: 'node_repl', tool: 'js', arguments: { code: '1 + 1' } }, + result: { Ok: { content: [{ type: 'text', text: '2' }] } }, + }, + }, + { + type: 'event_msg', + payload: { + type: 'mcp_tool_call_end', + call_id: 'call_err1', + invocation: { server: 'github', tool: 'search' }, + result: { Err: 'connection reset' }, + }, + }, + ]); + + const tools = toolCalls(events); + expect(tools).toHaveLength(3); + expect(tools[0]).toMatchObject({ + toolCallId: 'exec-957cc4b0', + title: 'mcp__linear__list_issues', + kind: 'other', + status: 'failed', + rawInput: { limit: 50, orderBy: 'updatedAt' }, + }); + expect(tools[1]).toMatchObject({ + toolCallId: 'exec-06f8d6de', + title: 'mcp__node_repl__js', + status: 'completed', + }); + expect(tools[2]).toMatchObject({ + toolCallId: 'call_err1', + title: 'mcp__github__search', + status: 'failed', + rawOutput: 'connection reset', + }); + }); + + it('reconciles a response-backed MCP call with its end row instead of a third card (CODE-576)', () => { + // Legacy direct-MCP rollouts persist all three rows for one call, in this observed order; + // the announce/settle pair is the replay, but the end row alone carries the structured + // failure verdict (`isError`/`Err`) — it must win over the settle's output-text heuristic. + const events = mapCodexHistoryEvents(HID, [ + responseItem({ + type: 'function_call', + namespace: 'mcp__codex_apps__github', + name: '_search_issues', + arguments: '{"query":"is:open"}', + call_id: 'call_dual1', + }), + { + type: 'event_msg', + payload: { + type: 'mcp_tool_call_end', + call_id: 'call_dual1', + invocation: { server: 'codex_apps', tool: 'github.search_issues' }, + result: { Ok: { content: [], isError: true } }, + }, + }, + responseItem({ type: 'function_call_output', call_id: 'call_dual1', output: 'no results' }), + responseItem({ + type: 'function_call', + namespace: 'mcp__node_repl', + name: 'js', + arguments: '{"code":"1"}', + call_id: 'call_dual2', + }), + { + type: 'event_msg', + payload: { + type: 'mcp_tool_call_end', + call_id: 'call_dual2', + invocation: { server: 'node_repl', tool: 'js' }, + result: { Ok: { content: [{ type: 'text', text: '1' }] } }, + }, + }, + responseItem({ type: 'function_call_output', call_id: 'call_dual2', output: '1' }), + ]); + + const tools = toolCalls(events); + expect(tools.map((tool) => [tool.toolCallId, tool.title, tool.status])).toEqual([ + ['call_dual1', 'mcp__github__search_issues', 'in_progress'], + ['call_dual1', 'mcp__github__search_issues', 'failed'], + ['call_dual2', 'mcp__node_repl__js', 'in_progress'], + ['call_dual2', 'mcp__node_repl__js', 'completed'], + ]); + }); + + it('replays an oversized MCP result status-only (CODE-576)', () => { + const events = mapCodexHistoryEvents(HID, [ + { + type: 'event_msg', + payload: { + type: 'mcp_tool_call_end', + call_id: 'exec-huge', + invocation: { server: 'github', tool: 'fetch_file' }, + result: { + Ok: { content: [{ type: 'text', text: 'A'.repeat(256 * 1024 + 1) }], isError: true }, + }, + }, + }, + { + type: 'event_msg', + payload: { + type: 'mcp_tool_call_end', + call_id: 'exec-cjk', + invocation: { server: 'github', tool: 'fetch_file' }, + // Under the cap in UTF-16 code units but over it in UTF-8 bytes (3 bytes per CJK char) + // — the transport frames bytes, so this must also drop. + result: { Ok: { content: [{ type: 'text', text: '猫'.repeat(100 * 1024) }] } }, + }, + }, + ]); + + const tools = toolCalls(events); + expect(tools).toHaveLength(2); + expect(tools[0]).toMatchObject({ + toolCallId: 'exec-huge', + title: 'mcp__github__fetch_file', + status: 'failed', + }); + expect(tools[0].rawOutput).toBeUndefined(); + expect(tools[1]).toMatchObject({ toolCallId: 'exec-cjk', status: 'completed' }); + expect(tools[1].rawOutput).toBeUndefined(); + }); + + it('unwraps the code-mode Script envelope and fails a failed script (CODE-576)', () => { + const events = mapCodexHistoryEvents(HID, [ + responseItem({ + type: 'custom_tool_call', + name: 'exec', + input: 'const r = await tools.exec_command({cmd: "ls"});', + call_id: 'call_script1', + }), + responseItem({ + type: 'custom_tool_call_output', + call_id: 'call_script1', + // Code-mode outputs are arrays of input_text parts; the envelope is the first part. + output: [ + { type: 'input_text', text: 'Script completed\nWall time 0.3 seconds\nOutput:\n' }, + { type: 'input_text', text: 'file-a\nfile-b' }, + ], + }), + responseItem({ + type: 'custom_tool_call', + name: 'exec', + input: 'throw new Error("boom");', + call_id: 'call_script2', + }), + responseItem({ + type: 'custom_tool_call_output', + call_id: 'call_script2', + output: 'Script failed\nWall time 0.0 seconds\nOutput:\nError: boom', + }), + responseItem({ + type: 'custom_tool_call', + name: 'exec', + input: 'await tools.exec_command({cmd: "sleep 60"});', + call_id: 'call_script3', + }), + responseItem({ + type: 'custom_tool_call_output', + call_id: 'call_script3', + output: 'Script running with cell ID 3\nWall time 10.0 seconds\nOutput:\npartial', + }), + ]); + + const settled = toolCalls(events).filter((tool) => tool.status !== 'in_progress'); + expect(settled[0]).toMatchObject({ + toolCallId: 'call_script1', + title: 'exec', + kind: 'execute', + status: 'completed', + }); + expect(settled[0].content).toEqual([ + { type: 'content', content: { type: 'text', text: 'file-a\nfile-b' } }, + ]); + expect(settled[1]).toMatchObject({ status: 'failed' }); + expect(settled[1].content).toEqual([ + { type: 'content', content: { type: 'text', text: 'Error: boom' } }, + ]); + // A yield-timeout receipt is an intermediate snapshot of a still-running script, not a failure. + expect(settled[2]).toMatchObject({ status: 'completed' }); + expect(settled[2].content).toEqual([ + { type: 'content', content: { type: 'text', text: 'partial' } }, + ]); + }); + + it('fails an apply_patch whose settle is a verification-failure receipt (CODE-576)', () => { + const patch = '*** Begin Patch\n*** Update File: a.ts\n@@\n-old\n+new\n*** End Patch'; + const receipt = 'apply_patch verification failed: Failed to find expected lines in a.ts'; + const events = mapCodexHistoryEvents(HID, [ + responseItem({ + type: 'custom_tool_call', + name: 'apply_patch', + input: patch, + call_id: 'call_patch1', + }), + responseItem({ type: 'custom_tool_call_output', call_id: 'call_patch1', output: receipt }), + ]); + + const settled = toolCalls(events).at(-1); + expect(settled).toMatchObject({ status: 'failed' }); + expect(settled?.content.at(-1)).toEqual({ + type: 'content', + content: { type: 'text', text: receipt }, + }); + }); + + it('drops the 0.144.6 skill-expansion and recommended-plugins rows beside the typed prompt (CODE-576)', () => { + const typed = '$linear:linear list our issues'; + const events = mapCodexHistoryEvents(HID, [ + { type: 'event_msg', payload: { type: 'user_message', message: typed } }, + responseItem({ + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: '\nlinear\n' }, + ], + }), + responseItem({ + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: typed }], + }), + responseItem({ + type: 'message', + role: 'user', + content: [ + { + type: 'input_text', + text: '\nlinear:linear\n/tmp/SKILL.md\n---\nname: linear\n---\nbody', + }, + ], + }), + ]); + + const users = events.filter((entry) => entry.event.type === 'user-message'); + expect(users).toHaveLength(1); + expect(users[0].event).toMatchObject({ content: [{ type: 'text', text: typed }] }); + }); + it('replays apply_patch like the live fileChange item: diff blocks kept through settle', () => { const patch = '*** Begin Patch\n*** Update File: greet.py\n@@\n- print("hello")\n+ print("goodbye")\n*** End Patch\n'; diff --git a/packages/host/agent-adapter/src/__tests__/history-util.test.ts b/packages/host/agent-adapter/src/__tests__/history-util.test.ts index 2eafe0f8..8989b806 100644 --- a/packages/host/agent-adapter/src/__tests__/history-util.test.ts +++ b/packages/host/agent-adapter/src/__tests__/history-util.test.ts @@ -29,6 +29,44 @@ function imageEvent(id: string, base64Length: number): AgentHistoryEvent { }; } +function toolEvent(id: string, rawOutputLength: number): AgentHistoryEvent { + return { + historyId: HID, + itemId: id, + event: { + type: 'tool-call', + toolCall: { + toolCallId: id, + title: id, + kind: 'other', + status: 'completed', + content: [], + rawOutput: 'A'.repeat(rawOutputLength), + }, + }, + }; +} + +/** The freeform-exec settle shape: the whole command output in `content`, an exit code in + * `rawOutput` — the payload the budget must not undercount. */ +function execEvent(id: string, outputLength: number): AgentHistoryEvent { + return { + historyId: HID, + itemId: id, + event: { + type: 'tool-call', + toolCall: { + toolCallId: id, + title: id, + kind: 'execute', + status: 'completed', + content: [{ type: 'content', content: { type: 'text', text: 'A'.repeat(outputLength) } }], + rawOutput: 0, + }, + }, + }; +} + function itemIds(page: { events: AgentHistoryEvent[] }): Array { return page.events.map((event) => event.itemId); } @@ -80,4 +118,26 @@ describe('sliceHistoryEventPage', () => { expect(page.events).toEqual([]); expect(page.cursor).toBeUndefined(); }); + + it('counts tool raw results toward the page budget (CODE-576)', () => { + const large = Math.ceil(MAX_ATTACHMENT_TOTAL_BASE64_LENGTH * 0.6); + const events = [toolEvent('tool-1', large), textEvent('text-1'), toolEvent('tool-2', large)]; + const first = sliceHistoryEventPage(events, 0, 1000); + expect(itemIds(first)).toEqual(['tool-1', 'text-1']); + expect(first.cursor).toBe('2'); + const rest = sliceHistoryEventPage(events, 2, 1000); + expect(itemIds(rest)).toEqual(['tool-2']); + expect(rest.cursor).toBeUndefined(); + }); + + it('counts tool content toward the page budget — exec bodies ride content, not rawOutput (CODE-576)', () => { + const large = Math.ceil(MAX_ATTACHMENT_TOTAL_BASE64_LENGTH * 0.6); + const events = [execEvent('exec-1', large), textEvent('text-1'), execEvent('exec-2', large)]; + const first = sliceHistoryEventPage(events, 0, 1000); + expect(itemIds(first)).toEqual(['exec-1', 'text-1']); + expect(first.cursor).toBe('2'); + const rest = sliceHistoryEventPage(events, 2, 1000); + expect(itemIds(rest)).toEqual(['exec-2']); + expect(rest.cursor).toBeUndefined(); + }); }); diff --git a/packages/host/agent-adapter/src/history-util.ts b/packages/host/agent-adapter/src/history-util.ts index 139963ea..ec907c85 100644 --- a/packages/host/agent-adapter/src/history-util.ts +++ b/packages/host/agent-adapter/src/history-util.ts @@ -1,3 +1,4 @@ +import { Buffer } from 'node:buffer'; import type { AgentHistoryEvent, AgentHistoryId, MessageId, Timestamp } from '@linkcode/schema'; import { MAX_ATTACHMENT_TOTAL_BASE64_LENGTH, textBlock } from '@linkcode/schema'; import { clamp } from 'foxts/clamp'; @@ -37,8 +38,14 @@ export function cursorFromTotal( return offset + limit < totalCount ? String(offset + limit) : undefined; } -/** Base64 payload one replayed event embeds — user messages are where attachments ride. */ -function eventAttachmentLength(event: AgentHistoryEvent): number { +/** Payload one replayed event embeds beyond its fixed structure: user-message attachments + * (base64 — ASCII, so string length is byte length) and a tool call's whole serialized payload — + * command output and diffs ride `content`, MCP results `rawOutput`, and none are bounded by the + * event's shape. Measured in UTF-8 bytes, which is what the transport frames. */ +function eventPayloadLength(event: AgentHistoryEvent): number { + if (event.event.type === 'tool-call') { + return Buffer.byteLength(JSON.stringify(event.event.toolCall), 'utf8'); + } if (event.event.type !== 'user-message') return 0; let total = 0; for (const block of event.event.content) { @@ -50,13 +57,13 @@ function eventAttachmentLength(event: AgentHistoryEvent): number { return total; } -/** Page slice bounded by event count AND aggregate embedded-attachment payload. One - * `history.read.result` travels as a single logical transport message, and the tunnel silently - * drops any message its reassembly buffer cannot hold — so image-heavy transcripts must fan - * across cursor pages instead of concentrating attachments into one reply. The budget is +/** Page slice bounded by event count AND aggregate embedded payload (attachments + tool + * payloads). One `history.read.result` travels as a single logical transport message, and the + * tunnel silently drops any message its reassembly buffer cannot hold — so payload-heavy + * transcripts must fan across cursor pages instead of concentrating into one reply. The budget is * `MAX_ATTACHMENT_TOTAL_BASE64_LENGTH` (what transports already size a maximal prompt's frame - * for); a page's first event always ships, since per-prompt caps keep any single event within - * that budget on its own. */ + * for); a page's first event always ships — per-prompt attachment caps, provider-side tool-output + * truncation, and the codex per-MCP-result cap keep single events within budget in practice. */ export function sliceHistoryEventPage( events: readonly AgentHistoryEvent[], offset: number, @@ -65,11 +72,11 @@ export function sliceHistoryEventPage( const page: AgentHistoryEvent[] = []; let payloadLength = 0; for (let index = offset; index < events.length && page.length < limit; index += 1) { - const attachmentLength = eventAttachmentLength(events[index]); - if (page.length > 0 && payloadLength + attachmentLength > MAX_ATTACHMENT_TOTAL_BASE64_LENGTH) { + const eventLength = eventPayloadLength(events[index]); + if (page.length > 0 && payloadLength + eventLength > MAX_ATTACHMENT_TOTAL_BASE64_LENGTH) { break; } - payloadLength += attachmentLength; + payloadLength += eventLength; page.push(events[index]); } const next = offset + page.length; diff --git a/packages/host/agent-adapter/src/native/codex/history-tools.ts b/packages/host/agent-adapter/src/native/codex/history-tools.ts index e5989754..4d90481e 100644 --- a/packages/host/agent-adapter/src/native/codex/history-tools.ts +++ b/packages/host/agent-adapter/src/native/codex/history-tools.ts @@ -1,5 +1,6 @@ +import { Buffer } from 'node:buffer'; import type { Plan, ToolCall, ToolCallContent, ToolCallLocation } from '@linkcode/schema'; -import { isRecord, stringField, textFromUnknown } from '../../history-util'; +import { isRecord, recordField, stringField, textFromUnknown } from '../../history-util'; import { toolKindFromName } from '../../util'; import { CODEX_PLAN_ID, @@ -152,6 +153,50 @@ function codexMcpToolName( return server.length > 0 && tool.length > 0 ? { server, tool } : undefined; } +/** One replayed event must fit a history page's transport budget on its own + * (`sliceHistoryEventPage`); MCP results are provider-unbounded (real rollouts carry multi-MB + * ones), so oversized results replay status-only. */ +const MCP_RESULT_MAX_JSON_BYTES = 256 * 1024; + +/** Whether an `mcp_tool_call_end` row records a failure. `result` is a serialized Rust Result — + * `{Ok: CallToolResult}` / `{Err: string}` — and an Ok carrying `isError` is still a failed call + * (mirrors McpToolCallEndEvent::is_success). */ +export function codexMcpEndFailed(payload: Record): boolean { + const result = recordField(payload, 'result'); + const ok = result?.Ok; + return result?.Err !== undefined || (isRecord(ok) && ok.isError === true); +} + +/** + * An `event_msg mcp_tool_call_end` row settled into the live `mcpToolCall` item shape. Codex + * persists nested code-mode MCP calls ONLY as this event (no response_item), and its `call_id` IS + * the live item id, so replayed and live cards converge by id — codex's own ThreadHistoryBuilder + * rebuilds thread items from this same row. (Verified against codex-rs rust-v0.144.6.) + */ +export function codexMcpEndToolCall(payload: Record): ToolCall | undefined { + const callId = stringField(payload, 'call_id'); + const invocation = recordField(payload, 'invocation'); + if (!callId || !invocation) return undefined; + const server = stringField(invocation, 'server'); + const tool = stringField(invocation, 'tool'); + if (!server || !tool) return undefined; + const result = recordField(payload, 'result'); + const raw = result?.Ok ?? result?.Err; + return { + toolCallId: callId, + title: codexMcpSlug(server, tool), + kind: 'other', + status: codexMcpEndFailed(payload) ? 'failed' : 'completed', + content: [], + rawInput: invocation.arguments, + rawOutput: + raw !== undefined && + Buffer.byteLength(JSON.stringify(raw), 'utf8') <= MCP_RESULT_MAX_JSON_BYTES + ? raw + : undefined, + }; +} + /** Settle an output row into the final snapshot, keeping the announce's diff content for edits and * unwrapping the freeform-exec output envelope for everything else. */ export function codexToolSettle( @@ -213,11 +258,17 @@ const OUTPUT_MARKER = '\nOutput:\n'; /** Declined runs persist ` failed for \`cmd\`: reason` — anchored so a command whose own * output happens to contain the phrase is not misread as a decline. */ const DECLINED_OUTPUT_RE = /^\w+ failed for `/; +/** The code-mode script envelope (`prepend_script_status` in codex-rs): a status line, a wall-time + * line, then the body. Array-part outputs join with an artifact newline — consumed by the `\n?`. */ +const SCRIPT_ENVELOPE_RE = + /^Script (completed|failed|terminated|running with cell ID [^\n]*)\nWall time [^\n]*\nOutput:\n\n?/; /** * Unwrap the freeform-exec output envelope (`Chunk ID: … / Process exited with code N / - * Output:\n`; apply_patch uses `Exit code: N`). Cancelled runs persist `aborted by user - * after Ns` and declined ones ` failed for \`…\`: …` — both settle as failed with the raw + * Output:\n`; apply_patch uses `Exit code: N`) and the code-mode script envelope + * (`Script / Wall time Ns / Output:\n`, failed on a failed/terminated script). + * Cancelled runs persist `aborted by user after Ns`, declined ones ` failed for \`…\`: …`, + * and unapplied patches `apply_patch verification failed: …` — all settle as failed with the raw * text as the record. */ function parseCodexToolOutput(output: string): { @@ -225,9 +276,22 @@ function parseCodexToolOutput(output: string): { exitCode?: number; failed: boolean; } { - if (output.startsWith('aborted by user') || DECLINED_OUTPUT_RE.test(output)) { + if ( + output.startsWith('aborted by user') || + output.startsWith('apply_patch verification failed') || + DECLINED_OUTPUT_RE.test(output) + ) { return { body: output, failed: true }; } + if (output.startsWith('Script ')) { + const envelope = SCRIPT_ENVELOPE_RE.exec(output); + if (envelope) { + return { + body: output.slice(envelope[0].length), + failed: envelope[1] === 'failed' || envelope[1] === 'terminated', + }; + } + } if (output.startsWith('Chunk ID:') || output.startsWith('Exit code:')) { const exitMatch = EXIT_CODE_RE.exec(output); const exitCode = exitMatch ? Number.parseInt(exitMatch[1], 10) : undefined; diff --git a/packages/host/agent-adapter/src/native/codex/history.ts b/packages/host/agent-adapter/src/native/codex/history.ts index 17e3b7df..9fd52453 100644 --- a/packages/host/agent-adapter/src/native/codex/history.ts +++ b/packages/host/agent-adapter/src/native/codex/history.ts @@ -31,7 +31,12 @@ import { textHistoryEvent, timestampMs, } from '../../history-util'; -import { codexToolAnnounce, codexToolSettle } from './history-tools'; +import { + codexMcpEndFailed, + codexMcpEndToolCall, + codexToolAnnounce, + codexToolSettle, +} from './history-tools'; const WHITESPACE_RUN_RE = /\s+/g; const DATA_IMAGE_RE = /^data:([^;,]+);base64,(.*)$/; @@ -66,13 +71,18 @@ function base64ByteLength(data: string): number { /** Codex persists machine-injected context as ordinary user-role messages recognizable only by * their leading marker; they must not replay as user bubbles or become a title preview. The XML * wrappers are the pre-0.14x shapes; codex 0.144 heads the AGENTS.md part with the prose markers - * instead (all verbatim in the 0.144.1 binary). Match markers exactly — a real user message could - * begin with `<` or `#` too. */ + * instead (all verbatim in the 0.144.1 binary). 0.144.6 injects each skill invocation's SKILL.md + * as a `` row beside the typed `$name args` prompt, plus ``; + * ``. Match markers exactly — + * a real user message could begin with `<` or `#` too. */ const SYNTHETIC_USER_MARKERS = [ '', '', '', '', + '', + '', + ' { + const callIds = new Set(); + for (const row of rows) { + if (stringField(row, 'type') !== 'response_item') continue; + const payload = recordField(row, 'payload'); + if (!payload) continue; + const payloadType = stringField(payload, 'type'); + if ( + payloadType === undefined || + (!CODEX_TOOL_ANNOUNCE_TYPES.has(payloadType) && !CODEX_TOOL_OUTPUT_TYPES.has(payloadType)) + ) { + continue; + } + const callId = stringField(payload, 'call_id'); + if (callId) callIds.add(callId); + } + return callIds; +} + /** * Replays the rollout as the event stream the live turn emitted: text plus tool announce/settle * pairs correlated by `call_id`, mapped to the live presentation shapes by `history-tools.ts`. - * History ids are NOT converged with the live app-server item ids (the rollout persists only - * `call_id`; message rows carry no id) — the seed relies on the `uptoSeq` cut. Known lossiness - * (CODE-97): reasoning stays unreplayable (`encrypted_content` only), and replayed edit diffs are - * reconstructed from the `*** Begin Patch` envelope, not the app-server's richer live unified diff. + * MCP calls replay from `event_msg mcp_tool_call_end` rows — for nested code-mode calls the only + * persisted record — whose `call_id` IS the live item id, so those cards converge with live + * events; response_item tool rows and message rows still do NOT converge (the rollout persists + * only `call_id`; message rows carry no id) and rely on the `uptoSeq` cut. Known lossiness + * (CODE-97): reasoning stays unreplayable (`encrypted_content` only), nested code-mode + * `exec_command` runs leave no durable record beyond the script output envelope, and replayed + * edit diffs are reconstructed from the `*** Begin Patch` envelope, not the live unified diff. */ export function mapCodexHistoryEvents( historyId: AgentHistoryId, @@ -468,6 +504,10 @@ export function mapCodexHistoryEvents( const events: AgentHistoryEvent[] = []; const announced = new Map(); const promptTexts = collectCodexPromptTexts(rows); + const respondedCallIds = collectRespondedToolCallIds(rows); + /** Response-backed calls whose end row recorded a failure: the response output settle cannot + * see `isError`/`Err`, so the end row's verdict must win over the settle's text heuristic. */ + const mcpEndFailures = new Set(); /** update_plan call_ids, so their `Plan updated` receipts don't settle a phantom tool row. */ const planCalls = new Set(); let currentTurnId: string | null = null; @@ -507,6 +547,29 @@ export function mapCodexHistoryEvents( }); return; } + if (stringField(row, 'type') === 'event_msg') { + const payload = recordField(row, 'payload'); + if (payload && stringField(payload, 'type') === 'mcp_tool_call_end') { + const callId = stringField(payload, 'call_id'); + if (callId === undefined) return; + if (respondedCallIds.has(callId)) { + // Response-backed calls already replay through the announce/settle pair — only the end + // row's failure verdict carries over (observed order announce → end → output, but a + // late end row corrects an already-settled card too). + if (codexMcpEndFailed(payload)) { + mcpEndFailures.add(callId); + const existing = announced.get(callId); + if (existing && existing.status !== 'in_progress') { + events.push(recordToolEvent({ ...existing, status: 'failed' })); + } + } + return; + } + const toolCall = codexMcpEndToolCall(payload); + if (toolCall) events.push(recordToolEvent(toolCall)); + } + return; + } if (stringField(row, 'type') !== 'response_item') return; const payload = recordField(row, 'payload'); if (!payload) return; @@ -526,7 +589,10 @@ export function mapCodexHistoryEvents( } if (CODEX_TOOL_OUTPUT_TYPES.has(payloadType)) { if (planCalls.has(callId)) return; - events.push(recordToolEvent(codexToolSettle(callId, payload, announced.get(callId)))); + const settled = codexToolSettle(callId, payload, announced.get(callId)); + events.push( + recordToolEvent(mcpEndFailures.has(callId) ? { ...settled, status: 'failed' } : settled), + ); return; } }