* fix(web): keep streamed reasoning/text block ids stable across snapshot rows
Streaming snapshots of one stream (pi/codex reasoning and text) arrive as
separate message rows, and the window store retires older rows as newer
snapshots land. The timeline derived the block id from whichever row was
first seen, so the id (and the threadMessageId built from it) churned on
every snapshot, remounting the rendered reasoning panel mid-stream and
replaying its open animation — the panel visibly flashed/re-rendered on
every snapshot tick.
Derive the block id from the stream id when present (unique per stream,
stable across snapshot rows) so the block is updated in place and the
smooth streaming keeps appending to the previous text. Row-derived ids
remain the fallback for content without a stream id.
Also rerun gen:fixtures to refresh the two golden fixtures affected by
the new id shape.
* fix(ios,android): mirror stream-stable block ids in native chat ports
The native HapiKit (Swift) and protocol (Kotlin) chat pipelines are ports
of the web reducerTimeline and are pinned by the same golden fixtures in
shared/fixtures/chat. After the web-side change to derive streamed
reasoning/text block ids from the stream id, the ports still produced
row-derived ids, so the iOS/Android fixture conformance suites went red
on the two refreshed fixtures.
Apply the same streamId-first id derivation (row-derived fallback kept)
to both ports so all three pipelines project identical block ids.
* fix(web,ios,android): reject blank stream ids as block identity
Blank ('' or whitespace-only) stream ids are not streams per the wire
semantics in shared/src/messages.ts (readReasoningStreamId trims before
accepting). The previous nullish fallback let accepted payloads carrying
blank ids through, so every such row shared one empty block id: the
merge maps collided and assistant-ui occurrence suffixes churned with
list position, reintroducing remounts.
Normalize with a trim guard in all three pipelines (web, HapiKit,
protocol) and add a web regression test covering both empty and
whitespace-only ids.
* fix(ios): use normalized stream id for block construction identity
The blank-id guard was applied to lookup and map insertion but block
construction still read the raw optional, so accepted payloads carrying
blank/whitespace ids produced blocks sharing one blank SwiftUI identity
instead of falling back to row-derived ids (web/Android already used the
normalized local). Hoist the nonBlankStreamId result and reuse it for
lookup, block identity, and insertion in both the text and reasoning
branches.
Also add native coverage for stream identity: stream-id derivation for
text/reasoning plus blank ('' and whitespace-only) fallbacks, which the
golden fixtures do not exercise.
* fix(web): pin blank stream-id identity contract in golden fixtures
Update the two stale fixture descriptions (stream-keyed blocks are now
keyed by the stream id, not the first message) and add a generated
conformance fixture covering empty and whitespace-only codex data.id
values for both reasoning and text: blank ids are not stream identities,
so each payload keeps its own row-derived block id instead of collapsing
onto a shared blank identity. Web, iOS, and Android all run this same
golden fixture.
* feat(hub): make title provider max_tokens and timeout env-tunable
Reasoning models used as title providers (e.g. GLM thinking models) need
more than 64 completion tokens and more than the hardcoded 10s timeout to
emit a title, and the only workaround was patching the compiled binary
after every install.
Expose both knobs via HAPI_TITLE_PROVIDER_MAX_TOKENS and
HAPI_TITLE_PROVIDER_TIMEOUT_MS, following the existing
HAPI_TITLE_SUGGESTION_RATE_LIMIT pattern; defaults are unchanged.
* docs(hub): document title provider max_tokens/timeout env knobs
Add the two new HAPI_TITLE_PROVIDER_* variables to the title-provider
configuration table in the installation guide, and extend the provider
test to cover the timeout abort path (the signal fires and rejects the
in-flight request).
---------
Co-authored-by: HongChenGG <HongChenGG@users.noreply.github.com>
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