Files
hapi/web/src/lib
Junmo KimandGitHub d1c2051f28 fix(web): aggregate per-response metadata so multi-turn cards show total usage (#637)
* refactor(web): extend MessageMetadata to accept aggregated turnCount

Add an optional `turnCount` prop to MessageMetadata so the same builder
can render an aggregated response-group footer when the caller has
already summed usage and dedup-joined model ids. The label set switches
to `Models` / `Total` / `N turns` only when `turnCount >= 2`, leaving
single-turn footers byte-identical with the existing
`Invoke · Model · Usage` output.

Also expose `turnCount?: number` on `HappyChatMessageMetadata` so a
later commit can inject the aggregated metadata through the library's
ThreadMessageLike payload without widening the type at the same time.

No call site passes `turnCount` yet, so this commit is behavior-neutral
on all existing surfaces (proof-of-invariance test included).

* feat(web): aggregate per-response metadata so multi-turn cards show total usage

The `@assistant-ui/react` converter joins adjacent assistant messages
into one card but only preserves `metadata.custom` from the first
block, so multi-turn responses currently show the first turn's usage
and model only.

Compute response-group aggregates in `useHappyRuntime` and inject the
sum on each group's first visible block, where the library will keep
them. Per group: usage tokens are summed across distinct turns,
model ids are dedup-joined in first-seen order, and the invoke time
is the first turn's so the footer keeps showing when the response
started (regression-guarded by unit test). `durationMs` is explicitly
cleared on aggregated blocks because the first turn's value would
otherwise leak through the join.

Turn identity prefers the CLI-stamped `localId`. When that is null
(claude code spawn sessions today emit `localId=null` on every chunk)
the aggregator falls back to a fingerprint built from `model` plus the
shared `usage` totals — every block emitted within one Claude SDK
message carries an identical usage object, so the fingerprint dedups
those chunks without merging distinct turns whose token counts
naturally differ. Tool-result chunks with no model or usage are
skipped so they cannot inflate the turn count.

Single-turn responses get no aggregate entry, so their footers stay
byte-identical with the existing behavior.

Test plan
- `assistant-runtime.test.ts` covers the six grouping scenarios spelled
  out in the design note (localId-based + null-localId fingerprint
  fallback) plus two defensive cases for tool_result chunks and cache
  token preservation.

* fix(web): preserve explicit zero sums and count tool-group turns in response aggregator

Two correctness gaps in aggregateResponseGroups:

- addUsage folded `0 + 0` through `|| undefined`, dropping an
  explicit-zero cache token sum from the aggregated metadata.
  Replace the falsy fold with sumOptional(): undefined only when
  both operands are absent, otherwise (a ?? 0) + (b ?? 0).
- turnSourceFromBlock returned null for tool-group blocks, so a
  card whose visible-first block is a tool-group dropped its
  turn entirely. Read the first underlying tool-call instead;
  degrade to null only when the group somehow holds zero tools.

Unit tests cover both regressions: tool-group as the first visible
block in a response group, explicit-zero cache sums preserved, and
the empty-tool-group degrade-to-null path.

* fix(web): dedup response-group turns by adjacency rather than set membership

The fingerprint fallback (used when localId is null) compared each
turn key against a Set of every key seen in the group. A response
group whose first and third turns happened to carry the same
(model, usage) fingerprint would collapse the third turn into the
first, under-counting the visible turn count.

Switch to ordering-based dedup: each block's turn key only collides
with the immediately previous turn. Adjacent blocks within one SDK
message still collapse (their usage object is identical), but
non-adjacent fingerprint matches across separate turns stay
distinct. Behavior under localId-stamped flows is unchanged because
distinct turns always carry distinct localIds.

Unit test covers a three-turn group whose first and third turns
share a fingerprint with a different middle turn between them.

* fix(web): aggregate every tool-call in a tool-group and dedup by createdAt fingerprint

`buildVisibleChatBlocks` merges adjacent eligible tool-calls into a single
`tool-group` without checking that they share a turn. Reading only the
first underlying tool would drop every later tool turn from the aggregate,
so each tool-call in the group now contributes its own turn source.

The fingerprint fallback (used when the CLI does not stamp `localId`)
gains `createdAt` as a third axis. The reducer copies `msg.createdAt`
onto every derived ChatBlock, so blocks from one SDK message still
collapse to one turn, while two adjacent turns that happen to coincide
on `(model, usage)` no longer dedup against each other. Same wall-clock
millisecond collisions remain theoretically possible but are bounded by
the hub stamp resolution.

Helper layer consolidates: `turnSourceFromBlock` (single-or-null) is
gone, replaced by `turnSourcesFromBlock` returning the array directly.
Test renames clarify the contract — the existing tool-group test now
documents the same-turn collapse case — and one new test pins the
fingerprint coincidence case.

* fix(web): make tool-only response cards expose aggregate metadata

`aggregateResponseGroups` keys aggregate metadata onto a response group's
first visible block, which can be a `tool-group` when the assistant turn
starts with tools. The `toolOnly` render branch did not wire the click
toggle that the default/codex branches use, so the new Models/Total/N-turns
footer stayed unreachable for those cards.

Wrap the toolOnly content with the same cursor-pointer div used in the
sibling branches (toggleMetadata, onMetadataKeyDown, role=button,
aria-expanded). Carry `min-w-0` on the wrapper so long tool labels keep
clipping under the existing `overflow-x-hidden` on MessagePrimitive.Root.

The shared `isNestedInteractiveEvent` guard prevents the wrapper toggle
from firing when nested tool buttons or disclosures are clicked.
2026-05-18 10:40:51 +08:00
..
2026-03-16 18:29:09 +08:00