Repository navigation
feat(tui): show the model that actually served a routed turn - #12
Conversation
The turn footer rendered only the CONFIGURED model name, so every turn on a router route read "Build · Firerouter glm" no matter which member model answered. The route slug is not a model, so that line was hiding both what ran and what the turn cost. Renders the configured name decorated with what served it: Build · Firerouter glm (glm-5p3-flash) Build · Firerouter glm (glm-5p3-flash → glm-5p3) multi-step turn A direct provider echoes its own id back on every response, so a served list that resolves to the configured name is dropped and every non-routed footer stays byte-identical. Covers all three places that render the line - the interactive TUI, the markdown transcript export, and `opencode run` (both session replay and the live turn). The live path never saw the assistant message, so the served ids are carried to the footer as new FooterState, reset at turn.send so one turn's route cannot decorate the next turn's summary. Also regenerates the SDK types and openapi spec, which #6 left stale, and fixes the repo's one lint error (a Tailwind CSS escape read as a deprecated octal escape) so `bun run lint` is clean. Claude-Session: https://claude.ai/code/session_017iG7xA9wRvNyFYZSXm8inU
|
Session stamp: |
The live turn summary took its model from the COMPOSER SELECTION, so switching models while a turn was in flight labelled the finished turn with the model that had just been selected - naming a model that never saw the turn, with no sign anything was wrong. The assistant message records the identity the turn was actually dispatched to, so carry that to the footer alongside the served ids and prefer it. The selection survives only as the fallback for a turn that produced no assistant message at all. FooterState.served becomes FooterState.turnModel, one field carrying the identity and its served ids from one event with one reset, and patch() now merges it on key PRESENCE so turn.send's explicit undefined actually clears it instead of being read as "unchanged". Precedence lives in turnSummaryModel() rather than inline in the event handler, so the rule this commit exists for is directly testable; the test fails if the two branches are swapped back. Drops appendServedLabel, which is unreachable now that served ids never arrive without an identity. Claude-Session: https://claude.ai/code/session_017iG7xA9wRvNyFYZSXm8inU
Follow-up: the live-turn base label is now correct too (
|
…lection Review round 1 (codex) found three real defects in the previous two commits. 1. A prompt produces one assistant message PER STEP - session/prompt.ts loops and creates a new Assistant each pass - while the run footer prints ONE summary for the whole prompt. Keeping the last message's record meant a tool-calling turn that routed A then B reported only B, silently dropping exactly the multi-model sequence this feature exists to show. Served ids now accumulate in first-seen order across every assistant message sharing a parent user message, keyed by that parentID so a new prompt starts a new record on its own evidence. 2. A turn that failed before recording any model still fell back to the composer selection, which is mutable while a turn is in flight - so a failed turn could be confidently labelled with a model chosen after it died, reproducing the bug the previous commit set out to fix. turn.send now snapshots the dispatched identity, which is the last moment the selection IS the truth, and the summary says "unknown model" rather than borrowing a selection. 3. Suppression compared resolved DISPLAY NAMES, so two different ids sharing a friendly name hid the served id - different versions, routes and prices read as identical. It compares raw ids again; only an id identical to the requested one is redundant. Accumulation lives in session-data rather than the footer's patch merge: turn.send writes a seed, so a merging patch would have folded the previous turn's models into it. Also pins the turn.send reset as a present-key-carrying-undefined (a value-based merge would drop it), and pins subagent isolation, which a reviewer suspected was broken and which nothing asserted. Truncation of the summary line at narrow widths is filed as #13. Claude-Session: https://claude.ai/code/session_017iG7xA9wRvNyFYZSXm8inU
…the clock The unknown-model regression test bounded its wall-clock duration by the SAME value it gave the harness as a kill timeout, so a slow-but-healthy run under parallel load was killed and then failed the duration assertion as if it had hung. It failed in every full-directory run of this suite and passed in isolation, on this branch and on swxtch alike. Worse, the assertion it was propping up did not do its job: the harness synthesizes exitCode -1 when it kills a run, so `not.toBe(0)` PASSED on a genuine hang. The wall clock was the only thing catching the very regression the test is named for. Asserting exitCode > 0 tests the real signal - the CLI exited with an error of its own accord - and a hang now fails on -1 with a message that says so. Verified by forcing the kill path: the assertion fails with "Expected: > 0, Received: -1". Elapsed time stays where it belongs, as the harness timeout backstop. Claude-Session: https://claude.ai/code/session_017iG7xA9wRvNyFYZSXm8inU
…footer Review round 2 (codex, corroborated by glm-5.3) found the round-1 fix was applied in one place out of three, plus a wrong turn boundary. 1. The classic TUI and persisted replay still read a SINGLE assistant message. A turn's intermediate messages finish with "tool-calls", so final() is false and only the LAST message renders a footer - which means a tool-calling turn routed A then B displayed just B. The last-message-wins defect fixed in the live footer was still live in the two surfaces a user actually looks at. Both now fold in every assistant message of the turn via servedAcrossTurn(). 2. The accumulation boundary was derived from parentID, which is not a turn boundary: auto-compaction mints a synthetic user message mid-turn, so a compaction would have discarded every model used before it. Accumulation moves to the footer, where turn.send IS the boundary, and session-data goes back to reporting one message. 3. Conversely, compaction and subtask messages share the turn's parentID while running on a DIFFERENT model, so folding by parentID alone credited their work to the user's turn. Aggregation now skips a compaction summary and anything dispatched to another model. 4. Suppressing by raw id was right, but both formatters then resolved ids back to display names, so two distinct ids sharing a name printed as "GLM-5.3 (GLM-5.3)" - true but useless. A colliding entry now carries its raw id: "GLM-5.3 (GLM-5.3 [glm-5p3])". The turn-model lifecycle is now ONE pure reducer, reduceTurnModel, so both transitions are pinned: "send" REPLACES (a new turn must never inherit the last one's models) and "observe" ACCUMULATES. Routing send through the accumulating path - the exact regression - fails the tests. Claude-Session: https://claude.ai/code/session_017iG7xA9wRvNyFYZSXm8inU
Merged with admin override (
|
run-process.test.ts |
|
|---|---|
base swxtch |
6 pass / 7 fail |
| this branch | 9 pass / 4 fail |
Strictly better than base, and the failures are load artifacts. Local suites that are not load-sensitive were green: typecheck 30/30, packages/tui 200/0, packages/llm 302/0, and the variant.shared/session-data units 38/0.
I am watching the post-merge run on swxtch and will fix forward immediately if anything goes red.
Review history
Three rounds. Round 2 found the round-1 fix had been applied in one surface out of three — the classic TUI and persisted replay still read a single assistant message, so a tool-calling turn routed A then B displayed only B. Also fixed: the accumulation boundary (parentID is not a turn boundary — auto-compaction mints a synthetic user message mid-turn), the inverse leak (compaction and subtask messages share the parent but run a different model), and a display-name collision that my own test had codified as acceptable.
Reviewers had not re-converged after round 3; that convergence step was overridden along with CI.
Deferred, tracked: #13 (summary-line truncation at narrow widths).
…il (#31) The summary rendered as one right-truncated text node: ▣ {agent} · {model} · {duration} Right-edge truncation cuts the tail first, so at narrow widths the DURATION went first, then the served-model parenthetical, then the configured model. #13 notes the resulting order is defensible but accidental - the most valuable new information is cut last - and that nothing enforced or tested it. #12 made the line longer by appending the models that actually served the turn, which is what made it reachable at common widths. The order is now explicit, and inverted where it was wrong: 1. `▣ {agent}` always survives - short, and identifies the turn. 2. The duration always survives. A handful of characters, and usually the thing being scanned for. 3. The served parenthetical survives ahead of the configured label, because it carries what the configured label cannot: the model that actually served the turn. 4. The configured label absorbs the shortfall. fitSummaryModel is pure and takes a width, so the behaviour is testable without rendering - the reason #13 was deferred out of #12 was that it needed width measurement. `truncate` stays on the text node as a backstop for a width too small for even the fitted line. 36 tests, including the rendering assertions #13 asked for at 60 and 80 columns and at 120/50/40/30 besides: the duration and agent present at every width, the whole line within the width at every width, nothing shortened when there is room, the label sacrificed before the served models, and degenerate widths of 0, 1, 5, 10 and -3 not throwing. Mutation-verified: reverting to pass-through truncation fails 6; sacrificing the served parenthetical instead of the label fails 1. Closes #13 Claude-Session: https://claude.ai/code/session_017iG7xA9wRvNyFYZSXm8inU
Follow-up to #6, which recorded
responseModelIDsbut never surfaced them.The problem
The turn footer rendered only the configured model name:
firerouter/glm-5p3/glm-5p3-flashis a route slug, not a model. Every turn on that route rendered identically no matter which member model answered — so the line hid both what ran and what the turn cost.The change
Verified on the wire what a served id actually looks like, rather than guessing:
modelfirerouter/glm-5p3/glm-5p3-flashglm-5p3-flashaccounts/fireworks/models/glm-5p3accounts/fireworks/models/glm-5p3A direct provider echoes its own id back, so a served list resolving to the configured name is dropped — every non-routed footer stays byte-identical.
All three render sites
packages/tui/src/routes/session/index.tsxpackages/tui/src/util/transcript.tsopencode runreplaypackages/opencode/src/cli/cmd/run/turn-summary.tsopencode runlive turnfooter.ts+session-data.ts+types.tsThe live path never saw the assistant message, so served ids are carried to the footer as new
FooterState, reset atturn.sendso one turn's route cannot decorate the next turn's summary.patch()rebuilds every field explicitly, so the new field had to be added there too or it would have been silently dropped.Also in here
types.gen.tsandopenapi.jsonstale, so the TUI could not read the field in a typed way. Both regens came out as exactly the two new fields.content-['\200B']in a JSX attribute read as a deprecated octal escape. Moved into a JS string; verified the emitted class is byte-identical.bun run lintnow reports 0 errors (was 1, pre-existing onswxtch).Verification
bun turbo typecheck— 30/30 passpackages/llm— 302 pass / 0 failpackages/tui— 200 pass / 0 failpackages/opencodetest/cli/run/— 205 pass / 0 failbun run lint— 0 errorsThe two stale-attribution guards were mutation-tested: reverting the emitter to the plausible
if (info.responseModelIDs)form turns both red.🤖 Generated with Claude Code