Skip to content
This repository was archived by the owner on Nov 7, 2026. It is now read-only.

feat(tui): show the model that actually served a routed turn - #12

Merged
brentyates-swx merged 5 commits into
swxtchfrom
feat/show-served-model
Sep 21, 2026
Merged

brentyates-swx merged 5 commits into
swxtchfrom
feat/show-served-model

Conversation

@brentyates-swx

Copy link
Copy Markdown

Follow-up to #6, which recorded responseModelIDs but never surfaced them.

The problem

The turn footer rendered only the configured model name:

▣  Build · Firerouter glm

firerouter/glm-5p3/glm-5p3-flash is 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

▣  Build · Firerouter glm (glm-5p3-flash)
▣  Build · Firerouter glm (glm-5p3-flash → glm-5p3)     multi-step turn

Verified on the wire what a served id actually looks like, rather than guessing:

requested served model
firerouter/glm-5p3/glm-5p3-flash glm-5p3-flash
accounts/fireworks/models/glm-5p3 accounts/fireworks/models/glm-5p3

A 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

site file
interactive TUI packages/tui/src/routes/session/index.tsx
markdown transcript export packages/tui/src/util/transcript.ts
opencode run replay packages/opencode/src/cli/cmd/run/turn-summary.ts
opencode run live turn footer.ts + session-data.ts + types.ts

The live path never saw the assistant message, so 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. 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

  • SDK types + openapi spec regenerated — feat(llm): record the model that actually served each turn #6 added the schema fields but left types.gen.ts and openapi.json stale, so the TUI could not read the field in a typed way. Both regens came out as exactly the two new fields.
  • The repo's one lint error fixed — a Tailwind 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 lint now reports 0 errors (was 1, pre-existing on swxtch).
  • One formatter fix to a test from feat(llm): record the model that actually served each turn #6, which landed unformatted because the fork runs only test + typecheck.

Verification

  • bun turbo typecheck — 30/30 pass
  • packages/llm — 302 pass / 0 fail
  • packages/tui — 200 pass / 0 fail
  • packages/opencode test/cli/run/ — 205 pass / 0 fail
  • bun run lint — 0 errors

The two stale-attribution guards were mutation-tested: reverting the emitter to the plausible if (info.responseModelIDs) form turns both red.

🤖 Generated with Claude Code

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
@brentyates-swx

Copy link
Copy Markdown
Author

Session stamp: c87b9b84-25b3-4804-a2ae-7e52148ee41f · launch cwd /home/byates · worktree /home/byates/swx-opencode-served-footer

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
@brentyates-swx

Copy link
Copy Markdown
Author

Follow-up: the live-turn base label is now correct too (f423c224b5)

The caveat noted at review time is fixed rather than deferred.

The bug: 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 just selected — naming a model that never saw the turn, with nothing to indicate it was wrong. Same class of silent misattribution the served ids exist to prevent, one layer up.

The fix: the assistant message records the identity the turn was actually dispatched to, so that is carried to the footer and preferred. The selection survives only as the fallback for a turn that produced no assistant message at all.

  • FooterState.served → FooterState.turnModel — identity and served ids as one field, from one event, with one reset point.
  • patch() now merges on key presence, because turn.send clears by sending an explicit undefined, which the previous value test read as "unchanged".
  • Precedence moved out of the event handler into turnSummaryModel() so the rule is directly testable; swapping the two branches back turns the test red.
  • appendServedLabel dropped — unreachable now that served ids never arrive without an identity.

Verification

  • bun turbo typecheck — 30/30
  • test/cli/run/ — 206 pass, 1 fail
  • bun run lint — 0 errors

The single failure is pre-existing and unrelated: exits nonzero promptly when the model is unknown (#27371) fails identically on untouched swxtch under the same full-directory load (~15.48s both), and passes in isolation on both. It is a wall-clock "promptly" assertion that only trips under parallel load — the flaky class tracked in #10.

…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
@brentyates-swx
brentyates-swx marked this pull request as ready for review September 21, 2026 13:36
@brentyates-swx
brentyates-swx merged commit 148fdcd into swxtch Sep 21, 2026
2 of 3 checks passed
@brentyates-swx
brentyates-swx deleted the feat/show-served-model branch September 21, 2026 13:36
@brentyates-swx

Copy link
Copy Markdown
Author

Merged with admin override (148fdcdfd8)

Merged on the captain's explicit instruction before unit/e2e reported. Recording what was and was not verified, so the gap is visible rather than implied.

Verified at merge: typecheck pass on the exact merged head 3de0102524; mergeable: MERGEABLE; HEAD..origin/swxtch = 0, so not a stale-CI merge.

Not verified at merge: unit (linux) and e2e (linux) were still pending. They run on GitHub-hosted runners, so this was not a load problem — it was a deliberate override.

Why local runs were not a substitute: this box sits at load average 65–90 (long-running crews), which makes the subprocess suite unusable. Measured at identical load on the same file:

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).

brentyates-swx added a commit that referenced this pull request Sep 22, 2026
…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
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant