fix(chat): disclose auto in the per-turn model footer - #4658
Conversation
The footer's model chip vanished on every Auto turn. `read_effective_model` skips the `auto` sentinel by design, and picking Auto writes that sentinel into both `_model` and `_resolved_model_id`, so both candidates were the sentinel and the reader returned "" — which the footer renders as nothing, indistinguishable from a turn that reported no measurement at all. Add `read_turn_model`, which splits that single blank into two answers: a concrete id when one is available, the bare `auto` when the turn was handed to Auto and the backend disclosed no id, and "" only when nothing is known. Auto's per-turn choice is not on the ACP wire (the `_kiro.dev/metadata` frame carries context and metering only, and `currentModelId` is session-scoped), so `auto` is the whole of what can be said truthfully; it is never presented as a model id, and pricing/window lookups keep using `read_effective_model`. Point the telemetry path at the same helper. Its `_resolve_model` already had an equivalent Auto fallback, so the row store knew a turn was Auto while the footer did not — one reader now serves both. The frontend needs no change: the existing chip renders the sentinel verbatim. Tests pin that, since a filter added there would silently restore the blank. `chat_runner.py` is not in the black baseline but was not black-clean on main, so the diff-scoped formatting gate requires the one unrelated hunk at :6703.
UX Review (Fable 5) — ✅ PASSUX-level review of UX-Verdict: PASS
[UX-REVIEWED] de853c8 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsThe discovery pass produced no candidates, and my own re-derivation of the No findings. [OPUS-REVIEWED] de853c8 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
Design Review (Fable 5) — ✅ PASSDesign-level review of Design review complete. The PR splits "no model information" from "the user handed the turn to Auto" via a new Design-Verdict: PASS Honest three-state attribution at the only layer that can know it, with the reader split correctly guarded against future misuse. [DESIGN-REVIEWED] de853c8 |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All counts are run; the picture is complete. Composing the final review. First-Principles-Verdict: PASS A reported blank-footer defect, fixed by deleting a second spelling: both readers now share one Auto-aware helper, and the wire limit is named, not guessed. What this change shipsIntent: make an Auto turn's footer say "the backend chose" instead of rendering nothing — a FIX.
The depth question is answered by a protocol limit, not preference: Auto's per-turn pick is absent from the Subtractions
[FIRST-PRINCIPLES-REVIEWED] de853c8 |
Problem
The per-turn footer's model chip disappeared on every Auto turn — the line read
0.56 credits · 19swith no model, while a pinned turn in the same session readclaude-opus-5 · 0.64 credits · 6.1s. A blank there is indistinguishable from a turn that reported no measurement at all, so it reads as a broken footer rather than as "the backend chose".Mechanically:
read_effective_modelskips theautosentinel by design, and picking Auto writes that sentinel into both_modeland_resolved_model_id(session_handle.py:1102/1122,client.py:2360/2361), clobbering the concretecurrentModelIdthatsession/newreported. Both candidates were the sentinel, so the reader returned""and the footer omitted the whole segment.This also contradicted
_attach_turn_stats's own docstring, which claimed theautopath was "the disclosure of what auto resolved to" — the one path where it disclosed nothing.Verified against live data before changing anything: five session files written minutes apart on the current build carried
turn_statswithelapsed_ms+creditsand nomodel.Change
read_turn_modelsplits the single blank into three distinct answers:global.anthropic.claude-opus-4-8[1m]auto""(key omitted)Auto's per-turn choice is not on the ACP wire — the
_kiro.dev/metadataframe carriescontextUsagePercentageandmeteringUsageonly (acp/_dispatch.py:148), andcurrentModelIdis session-scoped (session/new/session/load). Soautois the whole of what can be said truthfully. It is never presented as a model id, andread_effective_modelremains the reader for pricing and context-window lookups where the sentinel is not a usable key.The telemetry path (
_resolve_model) already had an equivalent Auto fallback, so the usage row store knew a turn was Auto while the footer did not. Both now go through one reader; the pre-existingTestModelSourceFallback::test_auto_source_fills_empty_modelcovers that the refactor is behaviour-preserving.No frontend production change. The existing chip renders the sentinel verbatim, so
autodisplays as-is and the tooltip becomes… · model: autothrough the existingturn_modelkey — zero new i18n keys.Attribution semantics (unchanged by this PR, worth stating)
The value is read at that turn's
EVENT_COMPLETEand frozen into the message'smeta.turn_stats; the frontend renders the persisted meta and never recomputes. So switching the model later does not relabel an older message, which is correct — that turn really was served by what it says.Switching mid-turn is not a quiet relabel either: the live-switch path refuses when
has_active_turn()and falls back to a session reset, which ends the turn — and a turn that never reachesEVENT_COMPLETEkeeps_turn_model = ""(chat_runner.py:4990), so nothing is attached. Background ops (titles, suggestions) are separate ACP sessions with their own handle, and_wrapper_chainvisits_runtimelast so process-level--modelcannot outrank session state.The honest limit: the value is derived from mutable session state at turn end, not recorded when the prompt was sent, so this rests on the no-mid-turn-switch invariant rather than on the data model.
Not in scope
Disclosing which model Auto picked requires the backend to report it per turn.
_dispatch.py's_log_unrecognized_metadata_fieldsalready logs novel metadata fields once each, so it will surface if kiro-cli starts sending one.Tests
test/test_usage.py::TestReadTurnModel— the three attribution states, sentinel matched case/space-insensitively, found deeper in the wrapper chain, never raises on a hostile source.test/test_turn_stats.py— the sentinel is carried like any other value; plus a binding test that the footer usesread_turn_model, since both readers exist and differ only on the Auto path, so binding the wrong one is a silent regression that every pinned-model assertion still passes.website/src/test/AssistantMessage.test.tsx— anautoturn renders the chip and the full line;fmtTurnModel('auto')passes through unchanged.Mutation-verified both layers. Neutering
_source_requests_autoin the helper failed 4 tests (3 new + the pre-existing telemetry one). Adding&& turnStats.model !== 'auto'to the renderer failed the new frontend test. Both reverted.Renamed
test_model_omitted_when_unresolved→test_model_omitted_when_unattributable: "unresolved auto" is no longer that case.Docs
docs/system-specs/features/turn-stats-footer.mdnever documented themodelfield at all. Added the meta-contract row, the three-state table, and why Auto's per-turn choice is unobtainable.Unrelated hunk, and why it is required
chat_runner.pyis not in.github/black-baseline.txtbut was not black-clean onorigin/maineither (verified against pristine main: one hunk, a manual wrap black would join at:6703). The formatting gate is diff-scoped and requires any non-baselined file in scope to be clean, so the first PR to touch this file pays that one line. Applied by hand exactly as black asked rather than letting black rewrite the file.Verification
Green locally:
pytesttargeted (1475 across every module importing the touched code),isort,flake8,mypy(999 files), the black gate at real diff scope,docs-lint,tsc -b,eslint,jscpd,i18n:checkwithI18N_BASE_REF, and the full frontend suite — 22208 passed, 0 failed.Full backend suite: 58188 passed, 43 failed, none of them this change. Not one of the 12 failing files references
handlers.usage,chat_runner,turn_stats, or either reader. 7 are self-inflicted by the dev host: its glibc is too old for Pillow 12's manylinux wheels, so Pillow had to be built from source with-C jpeg=disable(no libjpeg headers) andPIL.features.check('jpg')isFalse, which is exactly whattest_acp_prompt_blocks's jpeg/webp mime tests assert on. The other 36 are host/tooling (playwright node bootstrap, ssh multiplexing,psparsing, macOS release, GitHub workflow triage, xdist host-budget at load average 27). CI runs on a clean image and is the check that matters for these.Follow-up noticed, not fixed here
chat_handlers.py:3312asserts "The UI disables the model button while a turn runs", but no run-statedisabledguard is apparent on the model button inChatPage.tsx. It does not change behaviour (the backendhas_active_turn()guard plus the reset path covers it), but the comment may be stale and the front line of defence is the backend, not the UI.