**A hand-declared route must not offer a reasoning effort.** The earlier
commit read the create card's missing control as drift and added one. It
is the other way round: such a model has no reasoning capability — pi-ai's
installed catalog is what supplies one, and it ships nothing under the
route — so `resolveModel` throws UNSUPPORTED_REASONING_EFFORT for every
model on it and the whole provider drops out of the picker. Verified
against the adapter, not inferred. The create card no longer offers it and
the editor withholds it on the directory's `declared` bit, which is the
real bug: that control has always been wrong for these routes.
**A blocked composer locked the way out of the block.** Reusing the
no-workspace inert posture disabled the model seat along with everything
else, so the bar asked for a model while preventing the one control that
picks one. A block now rides its own `blocked` owner prop: the textarea,
send, commands, plan seat, and access chip all lock, and the model seat
alone stays live.
**A Provider ID could derive an illegal credential reference.** The card
accepted a digit-leading id, whose derived `123_API_KEY` then failed at
the credential seam with a raw regular expression the user cannot act on.
The id must now start with a letter, and a test pins the relation between
the two rules rather than the regex.
A default naming a route the Models page has since removed left the
composer saying 选择模型 while the input still accepted a message, which
then failed inside the adapter mid-turn.
`session.prompt` now refuses with `model-unavailable` before opening a
turn. That is the enforcement boundary: the method stays callable no
matter what a client disables. `session.models` reports the same fact as
`routable`, and ui-model pushes a block through the new
`ctx.conversation.blocks` registry so the bar renders the disabled
textarea it already renders without a workspace, carrying the blocker's
own reason. The push direction is forced — ui-model already depends on
ui-conversation, so ui-conversation cannot read it back.
The gate is `routable`, not "matches no advertised group": catalog
membership is advisory, so a route serving a model it stopped advertising
is missing from the groups yet perfectly usable, and `null` before the
first load never blocks so a slow Host cannot lock a working composer.
The scaffold gains a route-only adapter for fixture-less keyless
scenarios. Registering zero providers is a test artifact — every product
composition mounts one — and the goldens that froze the seat's fallback
label now show the model those scenarios actually route to.
ChatView's bottom-follow recognized only wheel gestures as reader input,
so touch panning, scrollbar dragging, and keyboard paging could not leave
the bottom of a streaming transcript. Replace the wheel listener with
device-agnostic attribution: a scroll position deviating from the
observed-top ledger of the last delivered or written scrollTop is reader
input. Adds keyboard-paging and touch-style fling e2e scenarios (red under
the old implementation) and the bilingual Agent Note triplet.
The branch control on user and consumed-steering bubbles could enable
only when a completed turn ended with no node after the message at all,
so readers saw a permanently unavailable control whose tooltip promised
a state it could not reach. An enabled one would mislead anyway: a fork
at a message seq cuts at the containing turn/end and takes the answer
along, the opposite of the branch-to-re-ask reading a control on one's
own bubble suggests.
MessageItem loses its fork props, PendingSteeringBubble loses the
showBranch special case, and messageBranchSeqs narrows to
assistantBranchSeqs: only a completed turn's transcript tail that is
the turn's own content-text assistant may fork. A steered turn keeps
its fork point under the settled answer, because fork is a log-prefix
cut and the steer is model-visible history the child inherits.
Web aria goldens drop the user-bubble disabled-branch row and its
hidden explanation text; the nested-subagent golden also loses the one
enabled user-tail fork handle, a loss the decision note accepts.
Correct the Agent Note's consequence: a running turn withholds the footer
below its own trigger bubble, while every earlier completed turn keeps its
seat — which the package test already asserts.
Give the running-phase barrier an explicit budget: it is armed before the
park and awaited after the stop click, so the 30s replay default left no
headroom for the marker poll, the UI polls, and two aria captures.
Number the running-turn test's boundary seqs like the log does, with each
turn/end strictly after its own nodes.
`assistantActionsSeqs` picked the last content-text assistant of each turn
from the finalized transcript alone. That quantity is stable only once the
turn closes: while a turn is still producing steps, the narration written
before a tool call is the last content assistant so far, so copy, branch,
and the clock appeared under an intermediate sentence for as long as the
tool ran and then moved down to the next step's text.
Pass `ConversationSnapshot.turnEnds` into the derivation and grant the row
only inside a turn that has a durable `turn/end`. This is the same
completion fact the branch control and the `Ran for` label already read, so
the three parts of one row now agree; mid-turn narration owns nothing, and
the seat appears once under the settled answer.
`hasContentText` moves to chat-flow.ts so the ownership gate and
AssistantMarkdown's mount gate cannot drift apart.
apps/web/tests/turn-tail-actions.e2e.ts pins both states through the
assembled application: a hang sidecar on the second model call parks a turn
whose first step narrated before calling bash, and the two goldens hold the
parked flow and the flow after stopping.
ed30088adb deleted packages/ui/tui, so the two present-tense clauses naming
it in notes this branch already rewrites no longer describe HEAD. The
operative claim — the surfaces beyond the web row needed no change — stands
without naming the package. Pair hashes re-recorded.
Also anchor tool-row-styles.spec.ts's rule lookup at a rule boundary, so a
compound selector landing above a base rule fails loud instead of reading
the wrong declaration block.
`flex: none` stops the `+n` box shrinking but not its text wrapping, so a
row too narrow for title + separator + suffix broke the one-line summary in
the exact case the slot exists for. Add `white-space: nowrap` and pin the
declaration as CSS text, since jsdom has no layout.
Also align the README's row illustration with the English rendering (the
same sentence's plan-strip clause already used it), and record the two
deferred review findings in the Agent Note so they survive merge.
planSummary treated whitespace-only content as nameable, so a rejected call
whose args survive verbatim rendered a blank active clause beside a live +N.
The tool's own rule is trimmed non-empty; the row now uses it.
Also hoists the duplicated DSH_SNAPSHOT refresh flag out of the two assembled
snapshot files into their shared assembled-boot scaffolding.
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`).
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 hero's backdrop ellipse is sized 1051/776 of the hero box so its blur
scales with the input card, which means it reaches past the column whenever
the column is narrower than the glow. `[data-conversation-scroll]` declared
only `overflow-y: auto`, and a box that scrolls in one axis computes the
other axis's initial `visible` to `auto` — so that bleed came back as a real
horizontal scrollbar, 24–95px of range across ordinary laptop widths.
Declare `overflow-x: hidden` on the column instead of leaving the second axis
to be derived. Clipping is unchanged (the box already clipped both axes); the
declaration withdraws only the bar and the user gesture.
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 merge staged the `--ours` consistency records before the merged prose was
re-recorded, so the committed hashes still describe the pre-merge content and
`verify-translation-pairing` rejects the pair. Re-record all three against
what actually merged.
- `relay` resolves its sender in `contextBody` like every other form. It was
the one shape whose marker could claim a form the body did not render: an
unreadable sender fell back inside the body while the row still said relay,
contradicting the contract this PR's own note states.
- `recall` requires the retained, omitted, and truncated fields. Completeness
is what the card exists to report, so a reference that cannot state it is
not a readable recall — showing the label alone presents a confident card
over unknown loss.
- The snapshot body states the supersession its producer framing line carries.
That line is the one part of the model-facing text no section contains, and
unlike an instruction context's `<system-reminder>` it states the form's own
semantics rather than wrapping content.
- `GoalMessageSource` is a discriminated pair, so `{ form: 'notice' }` without
its account no longer compiles. The guarantee this PR claims now holds at
that seam too, not only through `ContextFormed` on plugin sources.
- Goal and tool-goal summaries are bounded by a shared `boundContextSummary`,
which tool-tasks now uses as well. A goal objective is unbounded caller text
in exactly the way a task label is.
- The runtime snapshot interpolates once per request: agent-loop renders the
sections and joins them through `joinContextSections`.
- Every form's fallback branch is pinned, not only the notice one.
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.
- Content blocks render in the order the model received them. Partitioning
hoisted every unknown block past the text around it, so an interleaved log
read back in an order the model never saw.
- A delta distinguishes a newly reconciled file from a rewritten one; `set`
and `replace` already separate them at the producer, and collapsing both to
"updated" misread a new file.
- The superseded note states current fact in its consequences and testing
rather than keeping claims the implementation now contradicts, per
implemented/AGENTS.md; the decision itself stays as the record of that
change, with the supersession pointer above it.