Files
hapi/shared/fixtures/README.md
T
weishu f110845be6 merge: K6 fixtures batch 2 (events, permissions, sidechains, flavors, catalogs)
# Conflicts:
#	shared/fixtures/README.md
#	web/scripts/fixtures/generate.ts
2026-08-17 12:11:22 +08:00

344 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:protocol` JVM tests): pass the directory via
Gradle — `tasks.withType<Test> { systemProperty("hapi.fixtures.dir",
rootDir.resolve("../shared/fixtures")) }` — and read it with
`System.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`)
```jsonc
{
"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 be `null` when 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-byte
- `permission?`: `{ status, mode?, decision?, allowedTools?, answers?, reason? }`
— `status`: `'pending' | 'approved' | 'denied' | 'canceled'`
### Top level
- `hasReadyEvent`: boolean (a `ready` event is consumed, never a block)
- `latestUsage`: `null` or `{ inputTokens, outputTokens, contextSize, contextWindow }`
(`contextWindow` nullable; `contextSize` already 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`)
```jsonc
{
"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
when `version` is **strictly greater** than the cached watermark; equal and
older versions are dropped regardless of value; absent `todosUpdatedAt`/
`teamStateUpdatedAt` watermarks count as 0
- `teamState` wrapper with `value: null` clears the field (absent in
`expectedSession`) and still stores the watermark
- `updatedAt` is max-monotonic; an older `updatedAt` alone is `unchanged`
- flat fields are last-write-wins; a `serviceTier` key explicitly present as
`null` clears it
- `activeTurnStartedAt` is **not applied** by the patch path (web behavior:
no assignment branch) — a patch carrying only it is `unchanged`
- a patch whose only effective change is an `activeAt` delta < 60s is
`unchanged` and the cached `activeAt` does **not** move
- `scratchlistUpdatedAt` is 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`)
```jsonc
{
"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)
```jsonc
{
"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 from `shared/src/modes.ts`
(`web/scripts/fixtures/modesCatalog.ts` imports the module directly, like
the chat pipeline):
- `permissionModesByFlavor`: for every `AGENT_FLAVORS` entry, 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
```bash
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 rebuilds
`catalogs/modes.json` from `shared/src/modes.ts` and compares)
- `web/src/lib/sessionPatch.fixtures.test.ts` — sse suite
- `web/src/lib/message-window-store.fixtures.test.ts` — pagination suite