Group ordinary Codex commands with default tools across web, iOS and Android while preserving exploration and user-shell boundaries. Add regression tests, generated protocol fixtures and shared-command coverage in the iOS transcript UI suite.
Golden protocol fixtures
Machine-generated conformance fixtures for the HAPI client protocol. The
web implementation is the source of truth: every expected* value in
every file is produced by running the real web implementation over
hand-authored wire inputs, then applying the normative projection each suite
documents below. Native clients (iOS HapiProtocol, Android
:core:protocol) port the same logic and must reproduce the expectations
from the inputs exactly.
Three suites:
| Suite | Pins | Web source of truth |
|---|---|---|
chat/ |
message decode/render pipeline (normalize → reduce → group) | web/src/chat/ |
sse/ |
session-updated versioned-patch application |
applySessionDetailPatch in web/src/lib/sessionPatch.ts |
pagination/ |
message window: paging cursors, epoch resets, optimistic sends, trims | web/src/lib/message-window-store.ts + web/src/lib/messages.ts |
Never edit the JSON by hand — edit the case definitions in
web/scripts/fixtures/ (cases/, sse/cases.ts, pagination/cases.ts) and
regenerate.
Layout
shared/fixtures/
├── VERSION # current fixtureVersion (single integer + \n)
├── chat/<name>.json # one fixture per case
├── sse/<name>.json
├── pagination/<name>.json
├── catalogs/modes.json # reference tables (see Catalogs below)
└── README.md
How to consume (all suites)
- iOS (
ios/, SPM test target): resolve the repo checkout from the test file's own location and load every<suite>/*.json, e.g.URL(fileURLWithPath: #filePath)→ walk up to the package root →../../shared/fixtures. Decode the inputs, run the ported implementation, project, and compare against the stored expectations. - Android (
android/,:core:protocolJVM tests): pass the directory via Gradle —tasks.withType<Test> { systemProperty("hapi.fixtures.dir", rootDir.resolve("../shared/fixtures")) }— and read it withSystem.getProperty("hapi.fixtures.dir")in the test. - Iterate all files in a suite directory (fail on zero files) so newly added fixtures are picked up without native-side changes.
Acceptance bar
Exact match on the normative projection: serialize your computed output and
the fixture's stored expectation to canonical JSON (recursively sorted
object keys; numbers as JSON numbers; no undefined/absent-key differences)
and compare for equality. Field order in the files is already canonical
(sorted keys, 4-space indent, LF, trailing newline), so a key-sorted
structural deep-compare is equivalent.
fixtureVersion policy
fixtureVersion (mirrored in VERSION, shared by all suites) is bumped when
a document schema or a normative projection changes shape. Native suites must
fail loudly when the on-disk version is newer than the version they
support (do not silently skip), and should tolerate older versions only if
they explicitly implement them. Additive new fixture files and new wire
cases do not bump the version.
Chat suite (chat/)
Runs the real web pipeline (normalizeDecryptedMessage → reduceChatBlocks
→ buildVisibleChatBlocks) over wire messages, then projects.
Document schema (fixtureVersion: 1)
{
"fixtureVersion": 1,
"name": "claude-assistant-text", // equals the file name
"description": "…",
"input": {
"messages": [ /* DecryptedMessage[] exactly as GET /sessions/:id/messages returns them */ ],
"agentState": null, // session.agentState (permission requests) or null
"options": { "hasMoreMessages": false } // older history exists beyond `messages`
},
"expected": {
"blocks": [ /* projected ChatBlock[] (pre tool-grouping) */ ],
"hasReadyEvent": false,
"latestUsage": null, // or { inputTokens, outputTokens, contextSize, contextWindow }
"visibleBlocks": [ /* projected blocks after tool-grouping */ ]
}
}
Per fixture, the acceptance comparison covers blocks, hasReadyEvent,
latestUsage, and visibleBlocks.
Normative field contract
The projection keeps structure + semantics and drops web-presentation and
advisory detail. Whatever is absent below is intentionally NOT part of the
contract — natives may derive their own equivalents but must not expect it in
fixtures. Implementation: web/scripts/fixtures/projection.ts (keep in sync
with this list).
Optional fields are present only when they carry a value; invokedAt is
omitted when null. localId is always present (nullable) on the block kinds
that carry it.
Every block
kind, id, createdAt, invokedAt?
Per kind
| kind | normative fields |
|---|---|
user-text |
localId, text, attachments? — each { id, filename, mimeType, size, path } |
agent-text |
localId, text |
agent-reasoning |
localId, text |
cli-output |
localId, text, source ('user' | 'assistant') |
codex-review |
localId, review (verbatim normalized review object) |
generated-image |
localId, imageId, fileName, mimeType |
agent-event |
event — the normalized AgentEvent object verbatim (type + typed payload fields) |
tool-call |
localId, tool, children? (recursively projected; omitted when empty) |
tool-group (visibleBlocks only) |
firstToolId, lastToolId, tools (projected tool-call blocks in order; membership + order + count) |
tool object
{ id, name, state, input?, result?, permission? }
state:'pending' | 'running' | 'completed' | 'error'input: verbatim wire value (may benullwhen only the result was seen)result: verbatim wire value, present once a result/progress landed — including hub truncation markers (…[hapi: truncated N chars]…) byte-for-bytepermission?:{ status, mode?, decision?, allowedTools?, answers?, reason? }—status:'pending' | 'approved' | 'denied' | 'canceled'
Top level
hasReadyEvent: boolean (areadyevent is consumed, never a block)latestUsage:nullor{ inputTokens, outputTokens, contextSize, contextWindow }(contextWindownullable;contextSizealready folds cache tokens in)
Dropped (web-presentation / advisory — not in fixtures)
Block level: meta, usage (per-block), model, durationMs, status
(optimistic-send state), originalText, streamId, uuid/parentUUID,
agentTimestamp. Tool level: createdAt/startedAt/completedAt/
execStartedAt/execCompletedAt (timing), description, nativeTitle,
nativeKind, progress. Permission level: id, date, createdAt,
completedAt. Attachments: previewUrl. Generated image: source.
Tool group: defaultOpen, historyState, needsOlderHistory,
activityTitle, presentationMode, summary. Top level: latestGoal,
latestUsage.cacheCreation/cacheRead/model/timestamp.
SSE session-patch suite (sse/)
Pins the versioned-patch algorithm for session-updated events carrying a
SessionPatch (contract: docs/api/client-contract/sse.md, "Versioned patch
algorithm"). Expectations are computed by the real web fold —
applySessionDetailPatch in web/src/lib/sessionPatch.ts.
Document schema (fixtureVersion: 1)
{
"fixtureVersion": 1,
"name": "metadata-newer-version-applied", // equals the file name
"description": "…",
"initialSession": { /* full Session as cached before the first patch */ },
"patches": [ /* SessionPatch payloads in arrival order */ ],
"expectedPatchResults": [ "applied" | "unchanged", … ], // aligned with patches
"expectedSession": { /* Session after folding all patches */ }
}
Replay contract
Fold over patches in order:
next = applySessionDetailPatch(session, patch) // your port
results[i] = next == null ? "unchanged" : "applied"
session = next ?? session
Compare results against expectedPatchResults and the final session
against expectedSession (canonical-JSON equality). unchanged is
normative: it means the call reported "nothing changed — keep the previous
object identity" (version-gated wrapper, sub-minute activeAt keep-alive,
no-op assignment). A port that applies a stale wrapper, or that treats a
keep-alive as a change, fails on the verdict even when the final session
happens to match.
Both inputs are stored schema-normalized: initialSession is the parse
output of SessionSchema and every patch is the parse output of the strict
SessionPatchSchema (zod defaults applied, e.g. TodoItem.priority), so
consumers decode the JSON verbatim — no schema re-run needed. The generator
validates every authored patch against the wire schema, and the web
self-check re-validates on every test run, so fixtures cannot drift from
shared/src/schemas.ts.
Behavior deliberately pinned (see the individual descriptions):
- versioned wrappers (
metadata/agentState/todos/teamState) apply only whenversionis strictly greater than the cached watermark; equal and older versions are dropped regardless of value; absenttodosUpdatedAt/teamStateUpdatedAtwatermarks count as 0 teamStatewrapper withvalue: nullclears the field (absent inexpectedSession) and still stores the watermarkupdatedAtis max-monotonic; an olderupdatedAtalone isunchanged- flat fields are last-write-wins; a
serviceTierkey explicitly present asnullclears it activeTurnStartedAtis not applied by the patch path (web behavior: no assignment branch) — a patch carrying only it isunchanged- a patch whose only effective change is an
activeAtdelta < 60s isunchangedand the cachedactiveAtdoes not move scratchlistUpdatedAtis a refetch trigger only:unchanged, no session mutation
Pagination suite (pagination/)
Pins the message-window store: paging requests it must issue, epoch-reset
handling, optimistic-send reconciliation, queued-row lifecycle, and window
trimming (contract: docs/api/client-contract/pagination.md). Expectations
are recorded from the real web store (web/src/lib/message-window-store.ts)
driven by a scripted ApiClient.
Document schema (fixtureVersion: 1)
{
"fixtureVersion": 1,
"name": "older-page-epoch-mismatch-resets", // equals the file name
"description": "…",
"ops": [ /* operation script, in order; see below */ ],
"expectedState": { /* final window projection, see below */ }
}
Operations
Replay each op strictly sequentially against your ported store. Ops that hit
the server carry responses — the scripted GET /messages replies, consumed
FIFO — and the machine-recorded expectedRequests: the exact query objects
the web store sent (limit only for a latest page; beforeAt+beforeSeq+
limit for older pages; afterAt+afterSeq+untilAt+untilSeq+epoch+
limit for tail catch-up, where untilAt/untilSeq are null on the first
loop request). Your port must issue the same requests in the same order and
consume every scripted response.
op |
Store operation (web name) | Notes |
|---|---|---|
sync-tail |
syncTailMessages |
Full tail sync; latest page when no usable cursor, else the after-cursor loop. |
fetch-older |
fetchOlderMessages |
One older page. expectedOutcome is {kind:'applied', hasMore, addedRenderableCount} or {kind:'stopped', reason}. On an epoch mismatch the store runs an internal tail sync — its request/response belong to this same op and the outcome is stopped/epoch-reset. |
sse-messages |
ingestIncomingMessages |
SSE message-received delivery (also advances the newest cursor when an epoch is cached — even past rows the pipeline hides). |
append-optimistic |
appendOptimisticMessage |
Local optimistic row (id === localId). Does not advance the newest cursor. |
update-status |
updateMessageStatus |
Client send-state transition by localId. |
messages-consumed |
markMessagesConsumed |
SSE event: stamp invokedAt, flip status to sent (skip failed), re-sort by position; never advances the newest cursor. |
message-cancelled |
removeOptimisticMessage |
SSE event / optimistic DELETE removal; matches localId or id; idempotent. |
cancel-invoked |
remove + append | DELETE answered {"status":"invoked"}: remove by localId, then ingest the returned message with client status sent (the harness adds the status). |
set-view-mode |
setMessageViewMode |
tail re-entry trims to the visible window and, after a history overflow, forces a latest reset. |
queued-state |
reconcile round trip | Collect candidates (user rows with invokedAt === null, pinned in expectedCandidates, window order), apply invoked entries like messages-consumed, then drop candidates in neither queuedLocalIds nor invoked. |
expectedState projection (normative)
{
"messages": [ { "id", "localId", "seq", "createdAt",
"invokedAt"?, // wire tri-state: absent / null / number
"scheduledAt"?, // wire tri-state
"status"?, // client send state when present
"queued": bool, // user row ∧ invokedAt === null ∧ status ≠ 'failed'
"optimistic": bool } ], // localId ∧ id === localId
"hasMore": bool, // older history exists (server flag ∨ trim)
"epoch": number | null,
"viewMode": "tail" | "history",
"olderCursor": { "at", "seq" } | null, // next before-request position
"newestCursor": { "at", "seq" } | null // next after-request position
}
messages order is normative (position order: at = invokedAt ?? createdAt,
ties by seq). The cursors are the store's compound paging positions (web
internals oldestPosition* / newestPosition*); everything else that is
web-internal (render/version counters, notification throttling, persistence)
is intentionally NOT part of the contract.
Determinism notes: the web store's only wall-clock reads gate notification
throttling and never touch this projection, so no time injection is needed —
replays are exact. Fixtures keep every position pair distinct (no (at, seq)
ties), so the web's id tie-break (localeCompare) never decides an order;
native ports should still break full ties with an ASCII id comparison.
Catalogs
catalogs/ holds reference tables generated from shared/src modules (not
from the chat pipeline) by the same generator, with the same canonical
serialization and drift gate. Never edit them by hand.
-
catalogs/modes.json— generated fromshared/src/modes.ts(web/scripts/fixtures/modesCatalog.tsimports the module directly, like the chat pipeline):permissionModesByFlavor: for everyAGENT_FLAVORSentry, the permission modes offered for that flavor in offer order, each as{ mode, label, tone }(tone:'neutral' | 'info' | 'warning' | 'danger'). An empty array means the flavor exposes no runtime permission switching (e.g.pi).codexCollaborationModes: the codex-only collaboration axis as{ mode, label }pairs.
Natives port this table (mode ids, order, labels, tones) and should compare their port against the file in tests the same way as the chat fixtures: canonical-JSON equality.
Regeneration & drift gate
bun run gen:fixtures # from the repo root (runs web/scripts/generate-fixtures.ts)
Output is byte-deterministic (canonical serialization), so git status after
a regeneration is the drift signal: when web/src/chat/**,
web/src/lib/sessionPatch.ts, web/src/lib/message-window-store.ts or
web/src/lib/messages.ts change behavior, regenerated fixtures differ, the
diff gets committed, and the native conformance suites go red until the ports
catch up. The web-side self-checks (all in bun run test:web) re-run every
stored input against the live implementation and fail on any divergence from
the stored expectations or from canonical serialization:
web/src/chat/fixtures.test.ts— chat suite (also rebuildscatalogs/modes.jsonfromshared/src/modes.tsand compares)web/src/lib/sessionPatch.fixtures.test.ts— sse suiteweb/src/lib/message-window-store.fixtures.test.ts— pagination suite