# Agent event normalization matrix

Source of truth for how Claude Code / Codex / OpenCode native events map to the unified amalgm event shape. Lives next to the normalizers so the contract stays close to the code.

**Files**
- Normalizers (this dir): `claude.js`, `codex.js`, `opencode.js`
- Tool contract: `tool_contract.md`
- Adapters: `../adapters/{claude,codex,opencode}.js`
- Wire framing: `../sse.js` → `toAgentStreamEvent()`
- UI consumer: `amalgm-ui/lib/agents/acpAdapter.ts`
- UI schema: `amalgm-ui/lib/agents/acpTypes.ts`

**Capturing fixtures**

```sh
CHAT_CORE_RECORD=1 <run a session>
# writes ~/.amalgm/chat-core-recordings/<date>.ndjson
```

Cell legend: ✅ handled · ⚠️ handled, with gaps · ❌ dropped · `TBD` not yet captured from a real fixture.

---

## Cursor (ACP) — added 2026-07-01

Cursor rides the generic ACP client (`../adapters/acp-client.js`) + `cursor.js` in this dir. Because amalgm's event vocabulary already mirrors ACP `session/update`, the normalizer is a thin shim rather than a per-event translation table:

| ACP | Amalgm | Notes |
|---|---|---|
| `agent_message_chunk` | `text.delta` | ✅ |
| `agent_thought_chunk` | `reasoning.delta` | ✅ (composer-2.5 emits these) |
| `tool_call` / `tool_call_update` | `tool.started/updated/completed` | ✅ ACP kinds map to amalgm kinds (`execute`→`bash`, `edit`→`file_edit`, …); shell results arrive in `rawOutput` `{exitCode, stdout, stderr}`, file edits as `content` diff items |
| `session/request_permission` | auto-answered `allow_always` (parity with bypassPermissions) | ⚠️ not yet surfaced as a first-class `permission_request` event |
| `PromptResponse.stopReason` | `done` | ✅ `end_turn`/`cancelled`/`refusal`/`max_tokens` |
| `usage_update` / `PromptResponse.usage` | `usage.final` | ⚠️ mapped but **cursor emits neither today** — no token usage exists for this harness; snapshots are `billable:false, exact:false`, prompt usage would be `exact:true chat_turn` the day Cursor ships the ACP session-usage extension |
| `session_info_update`, `available_commands_update`, `plan`, `current_mode_update`, `user_message_chunk` (session/load replay) | dropped | ❌ intentional |

Sessions: one `cursor-agent acp` process per amalgm session; resume = `session/load` (full history replay is discarded — nothing subscribes during `create`). Model selection: `session/set_model` accepts ONLY ids Cursor advertises over ACP. Amalgm keeps central ids separate (`anthropic/claude-sonnet-5-high`, `openai/gpt-5.5-medium`, `cursor/composer-2.5`) and `cliModelFor` translates them to current Cursor CLI-style ids (`claude-sonnet-5-high`, `claude-sonnet-5-thinking-high`, `claude-4.6-sonnet-medium`, `composer-2.5-fast`). `matchAcpModel` then resolves those cliModels to ACP `availableModels` ids; ACP uses base names plus bracket params, so version-first CLI names can map to family-first ACP bases (`claude-4.6-sonnet-medium` -> `claude-sonnet-4-6[...]`). Raw provider ids cannot be sent straight to Cursor; stale ids like `composer-2-fast` are aliases only. Auth: provider_auth only — the login token lives in the macOS Keychain, so the managed home gets a `Library/Keychains` alias (`syncCursorProviderAuth`).

---

## Pi (native RPC) — added 2026-07-01

Pi speaks its own JSONL RPC (`pi --mode rpc`, NOT JSON-RPC: commands `{id, type}`, responses `{type:'response'}`, un-id'd events). Adapter: `../adapters/pi.js` (`PiRpc` mirrors codex's `JsonLineRpc` pattern); normalizer: `pi.js` in this dir.

| Pi RPC | Amalgm | Notes |
|---|---|---|
| `message_update` `text_delta` / `thinking_delta` | `text.delta` / `reasoning.delta` | ✅ |
| `message_update` error (reason ≠ aborted) | `error` | ✅ from `message.errorMessage` |
| `tool_execution_start/update/end` | `tool.started/updated/completed` | ✅ update carries accumulated output, not deltas |
| `message_end` (assistant) | `usage.final` `chat_step` `exact:true` | ✅ **best usage of any harness**: input/output/cacheRead/cacheWrite + native USD `cost.total` per message |
| `get_session_stats` after `agent_end` | `usage.final` `context_snapshot` `billable:false` | ✅ context circle from `contextUsage.tokens/contextWindow` |
| `compaction_start/end` | `compaction.started/finished` | ✅ `tokensBefore` → preTokens |
| `auto_retry_start` / `extension_error` | `warning` | ✅ |
| `agent_end` | `done` | ✅ adapter-driven so the final context snapshot lands first |
| `toolcall_*` deltas, turn/queue events | dropped | ❌ intentional (steer/follow_up unused so far) |

Billing: `finalizeTurnUsage` has a pi branch — turn = sum of exact `chat_step` records, native gateway cost summed into `costUsd`/`marketCostUsd` (no catalog re-derivation). Sessions: append-only files under `<home>/.pi/agent/sessions/`; providerSessionId = session FILE PATH, resume = spawn with `--session <path>`. Models: built-in `vercel-ai-gateway` provider (`AI_GATEWAY_API_KEY`), cliModel = bare `<provider>/<model>` gateway id. Auth: all three — amalgm rides a generated extension (`.pi/amalgm-egress-extension.js`) that re-points the provider baseUrl at `/egress/<sid>/ai_gateway` (pi appends `/v1/messages` itself, which the proxy parses for usage); byok uses ai_gateway credentials; provider_auth copies `~/.pi/agent/{auth,models,settings}.json`. No MCP: pi's tools are built-in read/bash/edit/write (+ extensions) — MCP relay does not apply yet.

---

## 1. Text parts

Streamed assistant text. The user-visible response.

| | Claude Code | Codex | OpenCode |
|---|---|---|---|
| Native | `assistant.message.content[]` block `type:'text'` | `item/agentMessage/delta` | `message.part.delta` type `text` + `message.part.updated` snapshots |
| Amalgm | `agent_message_chunk` | `agent_message_chunk` | `agent_message_chunk` |
| Dedup | ❌ none | ❌ none | ✅ suffix-prefix overlap (40 / 80 char thresholds) |
| Gaps | SDK retransmit would double — untested | Same | Magic thresholds undocumented; no test |

---

## 2. Reasoning parts

Model thinking stream. Separate from text.

| | Claude Code | Codex | OpenCode |
|---|---|---|---|
| Native | blocks `type:'thinking'` or `'redacted_thinking'` | `item/reasoning/delta`, `reasoning/textDelta`, `reasoning/summaryTextDelta` | `message.part` type `reasoning` |
| Amalgm | `agent_thought_chunk` | `agent_thought_chunk` | `agent_thought_chunk` |
| Sub-types | `redacted_thinking` flattened, lost | `summaryTextDelta` vs `textDelta` flattened, lost | none |
| Done signal | implicit (assistant message ends) | implicit | implicit |
| Gaps | Sub-type info lost; UI can't differentiate redacted | Same | TBD |

---

## 3. Tool parts (the call)

Tool invocation: id, name, title.

| | Claude Code | Codex | OpenCode |
|---|---|---|---|
| Native | content block `tool_use` / `server_tool_use` / `mcp_tool_use` | `item/started` (tool item) | `message.part` type `tool`, status `pending` / `running` |
| Amalgm | `tool_call` | `tool_call` | `tool_call` |
| Tool name | canonical (`Read`, `Edit`, `Bash`, …) | semantic (`commandExecution`, `fileChange`, `webSearch`, `mcpToolCall`) | inferred from input shape via `resolveToolName()` |
| Gaps | ✅ matches UI taxonomy | ⚠️ Codex names ≠ Claude names → UI falls through to `tool-unknown` for `fileChange` etc. | ⚠️ heuristic; ambiguous inputs → `unknown` |

---

## 4. Tool input

Initial input + streaming JSON deltas while model writes args.

| | Claude Code | Codex | OpenCode |
|---|---|---|---|
| Native (initial) | `tool_use.input` object | `item.input` / `item.arguments` | `part.state.input` |
| Native (delta) | `content_block_delta` with `delta.type:'input_json_delta'` (partial JSON string) | `item/updated` (full input snapshot) | `message.part.updated` (snapshot) |
| Amalgm | `tool_call_update.inputDelta` (parsed object) | same (full snapshot) | same (full snapshot) |
| Partial-parse | `tool.partialInputJson` accumulator + `stableStringify` fingerprint dedup | none | none |
| Gaps | Never-closing JSON when cancelled mid-args — untested | No granularity (snapshot only) | Same |

---

## 5. Tool state

Lifecycle: started → in_progress → completed / failed.

| | Claude Code | Codex | OpenCode |
|---|---|---|---|
| started | `tool_use` block first seen | `item/started` | `part.state.status === 'pending' \| 'running'` |
| in_progress | `tool_progress` messages | `item/commandExecution/outputDelta`, `item/fileChange/outputDelta`, `item/mcpToolCall/progress` | `part.state.status === 'running'` |
| completed | `user.content` `tool_result` block | `item/completed` (status not failed) | `part.state.status === 'completed'` |
| failed | `tool_result.is_error: true` | `item/completed` with `status:'failed'` | `part.state.status === 'error'` |
| Amalgm | `tool_call_update` with `status ∈ { calling, output-available, output-error }` | same | same |
| Gaps | TBD | TBD | TBD |

---

## 6. Tool output

Actual result content (string, blob, structured).

| | Claude Code | Codex | OpenCode |
|---|---|---|---|
| Native field | `tool_result.content` (string or blocks) | `item.aggregatedOutput ?? output ?? result ?? diff ?? error ?? message ?? text ?? finalText` | `part.state.output ?? part.state.error` |
| Amalgm | `tool_call_update.content` + `rawOutput` | same | same |
| Image outputs | possible but unhandled | none observed | none observed |
| Gaps | Multi-block `tool_result` may not flatten correctly — TBD | Long fallback chain may pick wrong field | TBD |

---

## 7. Permissions / approvals

Tool gating: the model wants to run a tool, user must approve. Interrupts the tool lifecycle between **started** and **completed**. Distinct from tool state.

| | Claude Code | Codex | OpenCode |
|---|---|---|---|
| Native | SDK exposes `canUseTool` callback; in stream form, TBD: confirm whether a `permission_request` event is emitted | TBD: Codex has approval mode (`approval_policy`); items carry an `approval_required` flag — confirm event shape from fixture | `question.asked` event (closest analog) — partially mapped today as a tool call named `AskUserQuestion` |
| User response | resume via callback / decision RPC | resume RPC | `question.replied` (with answer) or `question.rejected` |
| Decline behavior | tool not executed; turn continues without it | TBD | tool reported as completed-with-rejection |
| Amalgm | ❌ no first-class event today; auto-approve assumed | ❌ same | ⚠️ shoehorned into `tool_call` for `AskUserQuestion` |
| Proposed | new `SessionUpdate` variant `permission_request { toolCallId, action, args, severity }` plus `permission_response { toolCallId, approved, reason? }` | same | same |
| Gaps | No standardized permission UI across CLIs. Cross-CLI parity needed. |

---

## 8. Stop / end turn / cancellation

Turn termination signal + reason.

| | Claude Code | Codex | OpenCode |
|---|---|---|---|
| End turn | `result` message → `done(stopReason: stop_reason)` | `turn/completed` → `done` | `session.idle` / `message.finish` → `done` |
| Cancel | SDK iterator close → `done(stopReason:'cancelled')` | RPC cancel → `done` | prompt abort → `done(stopReason:'cancelled')` |
| Error end | `result.is_error` → `errorEvent` + `done(stopReason:'error')` | `turn/failed` / `turn/error` → same | `session.error` → same |
| Amalgm | `complete` event with `stopReason` | same | same |
| Gaps | `stop_reason` values not enumerated/typed | TBD | TBD |

---

## 9. Usage (tokens)

Per-turn token counts.

| | Claude Code | Codex | OpenCode |
|---|---|---|---|
| Source | `result.usage` at turn end | `thread/tokenUsage/updated` (multiple per turn) | `step-finish` parts + `message.tokens` |
| input | `input_tokens` | `inputTokens` | `tokens.input` |
| output | `output_tokens` | `outputTokens` | `tokens.output` |
| cache read | `cache_read_input_tokens` | `cachedInputTokens` | `tokens.cache.read` |
| cache write | `cache_creation_input_tokens` | ❓ folded into input? | `tokens.cache.write` |
| reasoning | folded into output | `reasoningOutputTokens` → `thoughtTokens` | `tokens.reasoning` → `thoughtTokens` |
| Amalgm | `usage_update` (normalized) | same | same |
| Cadence | once per turn | per notification (multi) | per step (multi) |
| Gaps | Cache-write field semantics differ — confirm Codex | Same | TBD |

---

## 10. Cost (USD)

Dollar cost; separate concern from tokens.

| | Claude Code | Codex | OpenCode |
|---|---|---|---|
| Native | `result.total_cost_usd` once at turn end | ❌ never emitted | per-step `tokens.cost` |
| Amalgm | `usage_update.cost.amount` once | ❌ never set | `usage_update.cost.amount` per step |
| Cadence | once / turn | never | per step |
| Gaps | Three different semantics across CLIs → downstream sums are wrong. Pick one canonical place to compute. |

---

## 11. Compaction

End-to-end wiring shipped. Canonical event shape: `compaction.started` and `compaction.finished`, converted to ACP `SessionUpdate.compaction_boundary { phase: 'started' | 'finished', trigger, preTokens?, postTokens?, durationMs?, summary? }`. UI renders compaction as a ProcessChain element with the same white loading dot used by the message waiting state: `Compacting context` while active, then `Compacted context in 23s` when timing is known. Saved as a normal assistant message part so users can see prior compactions after reload.

| | Claude Code | Codex | OpenCode |
|---|---|---|---|
| Native start | `{ type:'system', subtype:'status', status:'compacting' }` (`SDKStatusMessage`) | `item/started` with `item.type:'contextCompaction'` | `message.part.updated` with `part.type:'compaction'` |
| Native finish | `{ type:'system', subtype:'compact_boundary', compact_metadata:{ trigger, pre_tokens, post_tokens, duration_ms } }` | `item/completed` with `item.type:'contextCompaction'` | `{ type:'session.compacted', properties:{ sessionID } }` |
| In-progress signal | ✅ `status:'compacting'` continues until boundary | ✅ context compaction item lifecycle | ✅ compaction part before session finish |
| Engine emit | ✅ `compactionStarted` + `compactionFinished` | ✅ `compactionStarted` + `compactionFinished` | ✅ `compactionStarted` + `compactionFinished` |
| UI render | Same ProcessChain element | Same ProcessChain element | Same ProcessChain element |
| Persistence | ✅ saved in assistant `messages.parts` as `{ type:'compaction', phase, startedAt, finishedAt, durationMs, ... }` | same | same |
| Gaps | Compaction billing needs usage decision | Compaction billing unclear; usage shows context reset | Native exposes compaction cost/tokens; usage logging decision pending |

---

## 12. Errors

Auth, model, transport — three flavors that need separate signals.

| | Claude Code | Codex | OpenCode |
|---|---|---|---|
| Auth | SDK throws with auth message | RPC error w/ auth code | session error with auth flag |
| Model (rate limit, overflow, unavailable) | `result.is_error` with `errors[]` | `turn/error` with `error.type` | `session.error` |
| Runtime / transport | adapter try-catch | RPC reject + child exit | stream / prompt reject |
| Amalgm | `error`, `auth_error`, `model_error` (inconsistent) | same | same |
| Gaps | All three often flatten to plain `error`. UI can't tell severity. `auth_error` / `model_error` exist in ACP but not consistently emitted. |

---

## 13. Session init

Fires once per session. Identifies provider session, cwd, model, tool list.

| | Claude Code | Codex | OpenCode |
|---|---|---|---|
| Native | `system` msg `subtype:'init'` with `cwd`, `model`, `tools[]` | `session/created` notification | `session.created` event |
| Amalgm | `session_init { sessionId, containerId }` | same | same |
| Extra fields (cwd / model / tools) | TBD: do they reach UI? | TBD | TBD |
| Title | `title_generated` event — when does each CLI emit one? | TBD | TBD |
| Gaps | Confirm cwd/model/tool propagation. |

---

## 14. Multimodal content (images, audio, files)

Non-text content blocks. Today only images are partially handled.

| | Claude Code | Codex | OpenCode |
|---|---|---|---|
| Image input | block `type:'image'` (`source.type:'base64'` or `'url'`) | TBD | TBD |
| Image output | block `type:'image'` (rare; vision-capable models) | none observed | none observed |
| Audio | ACP defines `content.type:'audio'`; never observed in stream | TBD | TBD |
| Files / attachments | ACP defines `content.type:'resource'` and `'resource_link'`; never observed | TBD | TBD |
| Amalgm | `agent_message_chunk` with `content.type:'image'` only — audio / resource paths exist but unused | TBD | TBD |
| Gaps | UI image-part path rarely exercised. Tool screenshot outputs (e.g. browser/preview) untested. Audio + file blocks parked in schema, no renderer. |

---

## 15. MCP tool metadata

Branding: server name, app id, icon.

| | Claude Code | Codex | OpenCode |
|---|---|---|---|
| Native marker | tool name pattern `mcp__<server>__<tool>` | `mcpToolCall` item with `serverName`, `toolName` | name pattern `mcp__<server>__<tool>` |
| Amalgm | `tool_call`; UI infers app via `resolveMcpAppMeta()` (3 strategies) | same | same |
| App branding | `mcpMappings` lookup at UI | same | same |
| Gaps | No metadata field on the wire — relies on string parsing. If server name ≠ configured app id, branding silently falls through. |

---

## 16. Dropped today

Thrown away at [acpAdapter.ts:147-153](../../../../../amalgm-ui/lib/agents/acpAdapter.ts). Listed so we know what we're losing — not necessarily wrong to drop, but worth tracking.

| | Claude Code | Codex | OpenCode |
|---|---|---|---|
| Plan | `system.subtype:'plan'` (Plan Mode) | TBD | TBD |
| Mode | `current_mode_update` | TBD | TBD |
| Available commands | `available_commands_update` | TBD | TBD |
| Config option changes | `config_option_update` | TBD | TBD |
| User message echo | `user_message_chunk` | TBD | TBD |

If any of these become product-relevant, lift the drop in the ACP adapter and add a UI renderer.

---

## How to fill this in

1. Set `CHAT_CORE_RECORD=1` and run the scenarios from the test plan against each CLI.
2. For each cell currently `TBD`, find the matching event in the recorder ndjson and replace `TBD` with the real shape.
3. For each ⚠️ / ❌ row, link the resulting fixture file so future readers can see real data.
4. When a CLI vendor ships an SDK update that adds new event types, the unmapped-event counter (proposed in critique) will surface them — add a row here and decide whether to map or drop.
