diff --git a/packages/host/agent-adapter/AGENTS.md b/packages/host/agent-adapter/AGENTS.md index c08716f6..ea40bb8b 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', + }), + responseItem({ + type: 'function_call_output', + call_id: 'call_dual2', + output: 'aborted by user after 1.0s', + }), + { + 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' }] } }, + }, + }, + ]); + + 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('settles response-backed MCP calls from end rows when output is missing', () => { + const events = mapCodexHistoryEvents(HID, [ + responseItem({ + type: 'function_call', + namespace: 'mcp__node_repl', + name: 'js', + arguments: '{"code":"1"}', + call_id: 'call_missing_success', + }), + { + type: 'event_msg', + payload: { + type: 'mcp_tool_call_end', + call_id: 'call_missing_success', + invocation: { server: 'node_repl', tool: 'js' }, + result: { Ok: { content: [{ type: 'text', text: '1' }] } }, + }, + }, + responseItem({ + type: 'function_call', + namespace: 'mcp__github', + name: 'search', + arguments: '{}', + call_id: 'call_missing_failure', + }), + { + type: 'event_msg', + payload: { + type: 'mcp_tool_call_end', + call_id: 'call_missing_failure', + invocation: { server: 'github', tool: 'search' }, + result: { Err: 'connection reset' }, + }, + }, + ]); + + const tools = toolCalls(events); + expect(tools.map((tool) => [tool.toolCallId, tool.status])).toEqual([ + ['call_missing_success', 'in_progress'], + ['call_missing_success', 'completed'], + ['call_missing_failure', 'in_progress'], + ['call_missing_failure', 'failed'], + ]); + expect(tools[1].rawOutput).toEqual({ content: [{ type: 'text', text: '1' }] }); + expect(tools[3].rawOutput).toBe('connection reset'); + }); + + 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..7343d46d 100644 --- a/packages/host/agent-adapter/src/__tests__/history-util.test.ts +++ b/packages/host/agent-adapter/src/__tests__/history-util.test.ts @@ -5,14 +5,30 @@ import { asHistoryId, asMessageId, sliceHistoryEventPage } from '../history-util const HID = asHistoryId('history-paging'); -function textEvent(id: string): AgentHistoryEvent { +function textEvent(id: string, text = id): AgentHistoryEvent { return { historyId: HID, itemId: id, event: { type: 'user-message', messageId: asMessageId(id), - content: [{ type: 'text', text: id }], + content: [{ type: 'text', text }], + }, + }; +} + +function agentTextEvent( + id: string, + type: 'agent-message' | 'agent-thought', + text: string, +): AgentHistoryEvent { + return { + historyId: HID, + itemId: id, + event: { + type, + messageId: asMessageId(id), + content: [{ type: 'text', text }], }, }; } @@ -29,6 +45,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 +134,47 @@ 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(); + }); + + it('counts text, thought, and compaction content toward the page budget', () => { + const large = 'A'.repeat(Math.ceil(MAX_ATTACHMENT_TOTAL_BASE64_LENGTH * 0.26)); + const events: AgentHistoryEvent[] = [ + textEvent('user', large), + agentTextEvent('message', 'agent-message', large), + agentTextEvent('thought', 'agent-thought', large), + { + historyId: HID, + itemId: 'compaction', + event: { type: 'compaction', compactionId: 'compaction', summary: large }, + }, + ]; + + const first = sliceHistoryEventPage(events, 0, 1000); + expect(itemIds(first)).toEqual(['user', 'message', 'thought']); + expect(first.cursor).toBe('3'); + const rest = sliceHistoryEventPage(events, 3, 1000); + expect(itemIds(rest)).toEqual(['compaction']); + 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..48c9267c 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,39 +38,26 @@ 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 { - if (event.event.type !== 'user-message') return 0; - let total = 0; - for (const block of event.event.content) { - if (block.type === 'image' || block.type === 'audio') total += block.data.length; - else if (block.type === 'resource' && 'blob' in block.resource) { - total += block.resource.blob.length; - } - } - return total; +/** Counts the complete event because text, thoughts, summaries, tool payloads, and attachments are unbounded. */ +function eventSerializedByteLength(event: AgentHistoryEvent): number { + return Buffer.byteLength(JSON.stringify(event), 'utf8'); } -/** 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 - * `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. */ +/** Bounds aggregate serialized event bytes; the first event always ships so cursor pagination + * cannot stall on a single oversized stored record. */ export function sliceHistoryEventPage( events: readonly AgentHistoryEvent[], offset: number, limit: number, ): { events: AgentHistoryEvent[]; cursor: string | undefined } { const page: AgentHistoryEvent[] = []; - let payloadLength = 0; + let pageByteLength = 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 = eventSerializedByteLength(events[index]); + if (page.length > 0 && pageByteLength + eventLength > MAX_ATTACHMENT_TOTAL_BASE64_LENGTH) { break; } - payloadLength += attachmentLength; + pageByteLength += 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 f4d17799..d3f6ec15 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, @@ -173,6 +174,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( @@ -234,11 +279,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): { @@ -246,9 +297,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 5a05806a..90a3cd1a 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 = [ '', '', '', '', + '', + '', + '; + outputCallIds: Set; +} { + const callIds = new Set(); + const outputCallIds = 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) continue; + const isOutput = CODEX_TOOL_OUTPUT_TYPES.has(payloadType); + if (!isOutput && !CODEX_TOOL_ANNOUNCE_TYPES.has(payloadType)) continue; + const callId = stringField(payload, 'call_id'); + if (!callId) continue; + callIds.add(callId); + if (isOutput) outputCallIds.add(callId); + } + return { callIds, outputCallIds }; +} + +interface McpEndState { + status: 'completed' | 'failed'; + rawOutput?: ToolCall['rawOutput']; +} + +/** Precomputed so a response output that precedes its MCP end row still gets the structured verdict. */ +function collectMcpEndStates(rows: JsonRecord[]): Map { + const states = new Map(); + for (const row of rows) { + if (stringField(row, 'type') !== 'event_msg') continue; + const payload = recordField(row, 'payload'); + if (!payload || stringField(payload, 'type') !== 'mcp_tool_call_end') continue; + const callId = stringField(payload, 'call_id'); + if (!callId) continue; + const endToolCall = codexMcpEndToolCall(payload); + states.set(callId, { + status: codexMcpEndFailed(payload) ? 'failed' : 'completed', + ...(endToolCall?.rawOutput !== undefined && { rawOutput: endToolCall.rawOutput }), + }); + } + return states; +} + +/** Replays the rollout into live presentation shapes. MCP end-only calls retain their live + * `call_id`; message and other response rows rely on the `uptoSeq` cut because their ids diverge. */ export function mapCodexHistoryEvents( historyId: AgentHistoryId, rows: JsonRecord[], @@ -469,6 +519,10 @@ export function mapCodexHistoryEvents( const announced = new Map(); const persistedMcpIdentities = collectCodexMcpIdentities(rows); const promptTexts = collectCodexPromptTexts(rows); + const { callIds: respondedCallIds, outputCallIds: responseOutputCallIds } = + collectRespondedToolCallIds(rows); + const mcpEndStates = collectMcpEndStates(rows); + const seenMcpEndCallIds = 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; @@ -508,6 +562,25 @@ 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; + seenMcpEndCallIds.add(callId); + if (respondedCallIds.has(callId)) { + if (!responseOutputCallIds.has(callId)) { + const existing = announced.get(callId); + const endState = mcpEndStates.get(callId); + if (existing && endState) events.push(recordToolEvent({ ...existing, ...endState })); + } + 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; @@ -521,13 +594,21 @@ export function mapCodexHistoryEvents( planCalls.add(callId); events.push({ historyId, itemId: callId, event: { type: 'plan', plan: mapped.plan } }); } else { - events.push(recordToolEvent(mapped.toolCall)); + const endState = + !responseOutputCallIds.has(callId) && seenMcpEndCallIds.has(callId) + ? mcpEndStates.get(callId) + : undefined; + events.push( + recordToolEvent(endState ? { ...mapped.toolCall, ...endState } : mapped.toolCall), + ); } return; } 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)); + const status = mcpEndStates.get(callId)?.status; + events.push(recordToolEvent(status ? { ...settled, status } : settled)); return; } }