Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/host/agent-adapter/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<user_instructions>` wrapper and glues a `# AGENTS.md instructions …` prose part and the `<environment_context>` 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 `<user_instructions>` wrapper and glues a `# AGENTS.md instructions …` prose part and the `<environment_context>` 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 `<skill>` row (the invoked SKILL.md) beside the typed `$name args` prompt, plus `<recommended_plugins>` / `<codex_internal_context` rows (CODE-576). 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). MCP calls replay from `event_msg mcp_tool_call_end` rows — for nested code-mode calls the ONLY persisted record — and their `call_id` IS the live item id, so replayed MCP cards converge with live events (CODE-576); code-mode `exec` script rows replay as generic execute rows with the `Script …/Wall time/Output:` envelope unwrapped (failed on a failed/terminated script), while the nested `exec_command` runs the script made leave no durable rollout record at all.
- Known provider limits (recorded on CODE-97): `turn/steer` unused (queueing is turn-boundary by design); app-server writes `trust_level = "trusted"` for every thread cwd into `~/.codex/config.toml`; enterprise `clientInfo` registration with OpenAI.

## opencode & pi
Expand Down
263 changes: 263 additions & 0 deletions packages/host/agent-adapter/src/__tests__/codex-history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,269 @@ describe('mapCodexHistoryEvents', () => {
]);
});

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: '<recommended_plugins>\nlinear\n</recommended_plugins>' },
],
}),
responseItem({
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: typed }],
}),
responseItem({
type: 'message',
role: 'user',
content: [
{
type: 'input_text',
text: '<skill>\n<name>linear:linear</name>\n<path>/tmp/SKILL.md</path>\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';
Expand Down
60 changes: 60 additions & 0 deletions packages/host/agent-adapter/src/__tests__/history-util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined> {
return page.events.map((event) => event.itemId);
}
Expand Down Expand Up @@ -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();
});
});
Loading