The plan strip header has not emitted `<done>/<total> tasks · <n> in progress`
since #740 replaced it with `·`-joined per-status counts. Four sites still
quoted the old string: plan-summary.ts's module JSDoc, ui-conversation's
README pair, and the web-todo-display note pair.
The parallel-in-progress note's regression example mixed a four-item plan with
an eight-item row reading, and built-boot.snapshot.ts still called itself the
only test loading the built bundles.
The `+N` active count rides ToolRow's non-shrinking `summarySuffix` slot,
but only jsdom package suites covered it: the ACP snapshots render no web
surface and the built-boot smoke asserts no todo row. Add
`apps/web/tests/todo-row.snapshot.ts`, which boots the built client bundles
against the keyless fixture transport and records `summary`, `suffix`, and
the plan strip's header as separate fields, so folding the count back into
the summary string changes the expected output.
The three assembled-jsdom files now share `apps/web/tests/assembled-boot.ts`
instead of each carrying its own copy of the boot entry list, bundle map,
jsdom stubs, and mount call.
Also: name the policy branch in each `allowParallelInProgress` test title so
no case asserting `true` sits under a `false` describe, reword the stale cap
comment in todo-panel.spec.tsx, and record the plan strip's real header
format in the Agent Note (per-status counts, not `<done>/<total> tasks`).
The tool catalog boots every tool with its default config, but
allowParallelInProgress has no default, so the generator has to choose;
the page now says which branch it shows. The Agent Note's sidecar count
went stale twice inside this PR, so it records the refresh rule instead
of a point-in-time census. The SDK builtin entry pins its config literal
with satisfies like every sibling.
Rebuild the todo row's parallel summary on the shared ToolRow that master
introduced: planSummary still returns the active name and the remaining
count separately, and the row hands the count to a new non-shrinking
summarySuffix slot so a narrow row clips the summary text before the count.
An error row drops the suffix, whose collapsed summary is the failure line.
Re-record the ACP todo-write transcript for the parallel prompt, regenerate
the config catalog for the required allowParallelInProgress field, and
re-record the bilingual pairing hashes.
The TTFT and tok/s readings divide by measured wall time, so they are not
reproducible: the same replayed scenario yielded 69 and 70 tok/s on
consecutive local runs, and a 3 ms replayed stream reads 26333 tok/s. Baking
those into committed goldens made the Web lane flaky by construction, and the
goldens for the readings themselves were never refreshed.
Three fixes, then a refresh:
The footer's decorative dots are `aria-hidden`, so the readings concatenated
into one accessible string — `Ran for 13sTTFT 0.2s12 tok/s`. That is a real
defect on its own (a reader hears one run-on instead of three facts) and it
also denied `{{duration}}` the word boundary it matches on, so even the
previously-stable `Ran for` duration started leaking raw. The separators now
carry flanking spaces.
`normalizeAria` gains `{{throughput}}` beside `{{duration}}`, and its duration
alternation accepts the stats line's compact `2m42s` as well as the
message-chrome template's `2m 42s` — the compact form had no pattern at all,
which is why `LLM 382m39s` survived the first refresh.
Refreshed 17 goldens. They also record that the stats line's `LLM` group now
renders at all: it folds assistant `timing`, which the live transcript adapter
only began attaching in this branch, so the group was previously dead in Chat.
Verified by running the lane in replay three times after the refresh: 41/41
files green each time, goldens untouched. Before this change two consecutive
runs disagreed on both the values and the failure count.
The rebase carried the simple-interface block from the base branch, while
this branch turned ContextFormed into a per-form discriminated union;
ContextSnapshotSection gets its own block so the page stays self-contained.
Four values complete the vocabulary, so the opaque body is reached only by
producers that genuinely promise no shape.
`snapshot` — current state a later snapshot supersedes. system-prompt now
exposes `renderContextSections()`, the named contributions
`renderContextSnapshot()` already joins for the model, so the body attributes
each part to the subsystem that produced it instead of re-splitting joined
prose. The runtime snapshot, time-context, and tmux-context declare it.
`notice` — a one-off account of what just happened, declared by tool-tasks,
goal state changes, tool-goal wrap-up, plan-mode switches, and
repeat-tool-guard. Its `summary` rides the COLLAPSED row: these five are the
majority of shipped producers and none of them needs expanding to be read.
The task summary bounds itself because its inputs are unbounded caller text.
`relay` — a message another agent addressed to this one; both subagent
sources declare it and the body names the sender above what it said.
`recall` — material lifted from another session's log. session-reference
needed no new field: its references already record retained and omitted
counts and the truncation flag, which the body shows first, because recalled
context is bounded on the way in.
`ContextFormed` is now discriminated by `form`, so a producer cannot declare
a shape without the facts that shape is presented from — a notice without its
summary, or a snapshot without its sections, fails to compile.
Only the two hook bridges stay opaque, by design: their content is whatever
an external program printed, so no shape can be promised for it. Unknown
kinds and unreadable records land there too.
The composer ring, percentage, and `~used / capacity` header read
`contextPressure.pressureTokens`, which moves only when a request reports
usage. Compaction reports none — compact-basic summarizes through a direct
`ctx.llm.stream()` call and appends only its own `compact/*` records plus the
replacement `user/message` — so the meter was frozen across the one action
taken to change it. Driving a real `compactNow` through the agent loop:
BEFORE compact: ring=4% header=~4227/100000 rows=[18, 0, 4365]
AFTER compact: ring=4% header=~4227/100000 rows=[18, 0, 286]
The composition rows fell 93%; the ring did not move, and would not until an
entire further turn completed. The panel then contradicted itself by more than
an order of magnitude at exactly the moment a reader opens it.
`contextPressure` now also publishes `projectedTokens`: the provider sample
plus the heuristic repricing of everything the surface gained or lost since
that sample, clamped at zero, folded through the shared `surface-fold.ts`. The
sample is stamped before the same event joins the surface, so an
`assistant/message` anchors against the surface its own request carried. Only
the delta is estimated, so the figure stays provider-anchored — the estimator's
CJK and JSON-schema underpricing stays out of the occupancy number — while
reacting the moment content lands or a span is shadowed. Same run after:
BEFORE compact: ring=4% header=~4323/100000 (pressure=4227, projected=4323)
AFTER compact: ring=0% header=~ 244/100000 (pressure=4227, projected= 244)
`contextOccupancy` prefers the projected figure and falls back to the bare
sample, so a projection restored from a pre-field checkpoint degrades to the
old behavior rather than disappearing. `stateVersion` moves to 3.
- An empty replacement catalog is a real catalog: `renderCatalogUpdate()`
publishes zero entries when the last skill disappears, and falling back
would hide that every earlier name was retired.
- The opaque fallback keeps a `form` declaration this version cannot present.
It is the one place a newer or foreign log's declared shape would otherwise
vanish from the UI entirely, since the row marker is also absent there.
- An instruction change with an unrecognized `action` disqualifies the record.
The action decides the word the row shows, so an unknown one would be
presented as loaded or updated.
- The catalog list bounds itself and reports the withheld count. Entry count
is unbounded and the scrollport bounds height, not node count.
- A catalog message keeps content blocks this version does not know, instead
of dropping model-visible content the extensible union may carry.
- `core.md` defines `ContextFormed`, the interface actually carrying the
optional field, beside `ContextForm`.
- The superseded-in-part bullet states the affected clauses as one rule rather
than enumerating them; two rounds of enumeration each missed some, which is
the shape being fragile rather than the list being wrong.
- The note records the one migration case that does not self-heal: an
old-format catalog as the only one, with an empty current view, leaves a
stale catalog nothing replaces.
`contextBreakdown.messageTokens` and `measure().surfaceTokens` answer the same
question in the same heuristic vocabulary, and the panel's composition rows are
only honest while they agree. Each owner carried its own copy of the positional
fold — same pricing, same `{seq, tokens}` node list, same replace-range lookup
and guard, differing only in mutable versus immutable application — so an edit
to either one would have moved the panel away from `measure()` with both sides
still green. The duplication gate caught the shared 62 tokens.
`src/surface-fold.ts` now owns `foldSurfaceTokens`: total, allocation-fresh,
returning the event's price, the next surface, and the signed total delta. The
service assigns that result where it used to prepare a commit closure, which
keeps its validate-before-mutate replay transaction intact — the fold throws
before any state is touched, so a malformed event still fails identically on
every retry. `_prepareSurfaceMutation` and `_estimateSurfaceEvent` go away with
it, and the projection's apply drops to one call.
Covers the identity with a session that appends and then compacts, asserting
the projection figure equals the service surface at each boundary; the test
fails when either side of the fold is perturbed.
The panel header concatenated a `45%` span with a `context.used` fragment, so
Chinese rendered "45% 上下文已用" against the ring's own "上下文已用 45%". The
header now renders the one localized `context.aria` sentence split around its
`{percent}` slot: each locale owns the reading's position while the reading
keeps its primary tone, and the side a locale leaves empty collapses through
`.headline:empty` instead of spending a header gap.
The bar mapped every composition row to a segment unconditionally, and
`.segment`'s 2px min-width kept each one visible, so a 0% occupancy panel
painted an ~8px filled bar over an empty context. Parts are now computed with
their widths and zero-width parts are filtered out, which also collapses the
plain and segmented branches into one map.
Summary scroll regions keep their scrollbar thumbs transparent until the
region is hovered or contains keyboard focus, without changing the
reserved scroll geometry. Updates the package README pair and the
inspection-ledger Agent Note together.
- `catalogHistory` validated its durable read. `agent.session.events` is a
JSONL/SQLite seed on resume or fork, and seed validation guarantees only a
source object with a non-empty `kind`; a `skill-catalog` record with missing
or wrongly shaped `entries` threw inside the step listener, failing every
later turn of that session. It is now skipped as an unrecognizable record,
the posture the replaced content digest had, with a regression test over six
malformed shapes.
- The headless keyless smoke still filtered catalogs by the old plugin source,
so the `built-bin-smoke` gate would not have found the catalog message.
- Entries record the published description unescaped. The pseudo-XML escaping
belongs to the `<available_skills>` frame and is applied when rendering it,
so a description containing `<` no longer reaches the card as `<`.
`escapeText` is injective, so republish semantics and the model-facing text
are unchanged.
- Adjacent text blocks join with no separator, matching how provider adapters
flatten them; the body no longer shows a line break the model never saw.
- Provenance fields are bounded like the text: an unknown producer may record
an arbitrarily large value.
- Both readers are all-or-nothing, and the row's form marker reports what
rendered rather than what was declared, so a partly unreadable record cannot
present a confident but incomplete account.
- The catalog body consumes `update` as a replacement notice; the digest
canonicalizes per entry as JSON, since every separator character is itself
legal in a description.
- `core.md` documents the form axis with a `ContextForm` type-equiv block, and
both projections assert the wiring they duplicate.
Assistant footers and the stats line gain TTFT/tok-per-second readings
folded from step timings; context occupancy moves off the stats line onto
a composer ring whose panel shows a heuristic system/tools/messages
breakdown from the new token-meter contextBreakdown session projection.
Injected context reached the transcript as one anonymous shape whose
expanded body was the whole message serialized as inline JSON, so the
escaping collapsed the only readable part — the model-facing prose —
into a single line.
`MessageSource` gains an optional producer-declared `form`, a small
semantic vocabulary of information shapes independent of `kind`: kind
says who produced the context, form says what shape it is, so several
producers may share one presentation. Two values ship.
`instructions` (workspace-context) lists the reconciled files above
their text and keeps the `<system-reminder>` framing verbatim, because
the framing is part of what the model read. `catalog` moves dsh-tool-skill
off the shared plugin kind onto a `skill-catalog` source carrying the
published name/description entries, and the body lists those instead of
re-parsing `<available_skills>` out of the prose. Catalog identity moves
with it: the republish digest now covers the durable entries, deleting
the text-slicing that recovered them from a logged message.
Everything else renders the opaque body — the model-facing text with its
real line breaks, then the remaining provenance as fields. That is the
documented default, not a leftover: a resumed, forked, or foreign log
must render whether or not its producer is mounted here, which is why
the classification lives in the durable source rather than a client-side
table keyed by producer.