fix: carry the backend's model-withhold verdict in the slots payload - #7546
Conversation
e8fcd6b to
26a0e92
Compare
UX Review (Fable 5) — ✅ PASSUX-level review of The diff's UX surface is confined to the composer's model chip label logic — no new strings, no layout, no screenshots. The change makes the chip tell the truth in cases where it previously lied ( UX-Verdict: PASS The chip now names the model a turn will actually use, unknown states fail open to prior behavior, and no new copy or flow is introduced. [UX-REVIEWED] 5f2cae1 |
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Backend-owned verdict replaces a cross-language heuristic at the right layer; the field is additive, fail-open on unknown, and self-invalidating on re-pin. The residual risks are already contained: the stronger alternative (carrying the effective model id) is named and tracked (#7575), the distributed teardown drops are pinned by a source-level ratchet test with the chokepoint deferred for a stated layering reason, and any reset site the ratchet's two-file scope misses yields only a bounded stale label that the next spawn's unconditional re-record overwrites. [DESIGN-REVIEWED] 5f2cae1 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- src/kiro_crew/dashboard/state.py:4181 -- False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All verification checks are done. The new field has two counted frontend consumers, no existing mechanism carries an effective-model answer in the slots payload, the teardown sites outside the two ratcheted files reset ephemeral non-slot sessions (hooks, refine, channels, taskrunner) rather than dashboard chat slots, and the deferred alternative is explicitly tracked. Final review: First-Principles-Verdict: PASS The backend publishes the entitlement verdict it already computes, replacing a frontend heuristic that turned every catalog filter into an entitlement signal — cause-level, every item counted. What this change shipsIntent: stop the composer chip mislabeling a slot's pinned model by carrying the backend's own withhold verdict instead of inferring it from picker-list membership — a FIX.
Watch
[FIRST-PRINCIPLES-REVIEWED] 5f2cae1 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
26a0e92 to
1b452b9
Compare
Disposition: First Principles CONCERNS on 26a0e92Watch item -- "single reset funnel" premise has counted bypasses: FIXED, in Verified the finding and then widened it: grepping
Each now drops the verdict itself. I did not route the runner's sites through Four sites is the drift risk the property-based invalidation exists to avoid, so Also updated the PR body, which carried the same over-broad "single funnel" Items 1-7 of the "What this change ships" list need no action -- they read the |
1b452b9 to
d8bbde6
Compare
d8bbde6 to
5e57ae0
Compare
Disposition: GPT 5.6 blocking findings on d8bbde6Both real, both FIXED in 5e57ae0, both mutation-verified (each new test 1. New tests: 2. The skip was justified in-comment as "unknown must fail open, and overwriting a New test: Gates after the fix: 23 in Unrelated CI note for whoever reads this next: the red |
Disposition: First Principles CONCERNS on 5e57ae0Watch 2 (deferred follow-up should be tracked): DONE -- filed as #7575, Subtraction (delete the four non-spawn drops, the The window is not symmetric. The subtraction rests on "the window's fallback is A payload field is a wire claim, not a chip input. Conceded wording. The On the two rounds of this lane. Round 1 on 26a0e92 found the funnel claim If a maintainer prefers the smaller surface, the subtraction is a one-commit Unrelated red, for the next reader: |
The composer inferred "is this pin usable?" from picker-list membership: a slot's pinned model absent from GET /api/models displayed as auto. The backend already computes the authoritative answer per slot at spawn, against the live session's advertised list, but never propagated it -- so every filter applied to that list silently became an entitlement signal, and one predicate had to stay in lockstep across two languages by convention alone. The slots payload now carries model_withheld (true / false / null = not known yet) and displayModel reads it, keeping list membership only as the fallback for a slot with no verdict. The verdict is recorded against the model id it was computed for, so any of slot.model's writers invalidates it without an explicit reset, and it is dropped at the one session-reset funnel because it describes the session that advertised the list. Closes #1819
5e57ae0 to
5f2cae1
Compare
bolichen97
left a comment
There was a problem hiding this comment.
Reviewed via parallel subagent audit: diff matches description, CI fully green, no blocking findings, no unresolved threads.
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
What is the problem?
The composer's model chip decided "is this pin usable?" by looking for the slot's
pinned model in the picker's list (
GET /api/models). If it was not on thatlist, the chip showed
auto. The backend already computes the authoritativeanswer per slot -- at spawn, against the live session's advertised list -- but
that verdict was never propagated, so the frontend re-derived it from a weaker
signal.
That conflated "not entitled" with "not listed for any reason".
api_modelsnarrows its list for reasons that have nothing to do with entitlement (it drops
deprecated ids before the entitlement narrowing), so every one of those filters
silently became an entitlement signal and a filtered-but-runnable pin displayed
as
auto.The same predicate also had three expressions that had to stay in lockstep
across two languages by convention alone: the backend narrowing
(
_entitled_kiro_models), the frontend display (displayModelplus themodelsDegradedtrust gate), and the mirrorednormalizeModelKey.Why this issue matters to the user
The chip names the model a turn will run on. When it is wrong, the user is told
their session runs on a model it does not, and the only recourse is to spot the
mismatch themselves. Today the wrong label is cosmetic and narrow. The real cost
is that it widens with no test tripping, because the heuristic cannot tell why
a row vanished from the picker: any future catalog filter (curation, dedup, a new
deprecation) mislabels every slot pinned to a model it removes.
How our fix solves it
Symptom: the chip reads
autofor a pin the account can run (or names a pin noturn will use). Cause: the frontend answers an entitlement question from list
membership, which is not an entitlement signal. Root cause: the one component
that knows the answer -- the session spawn -- never published it.
_pinned_model_verdict(chat_runner) replaces_pinned_model_withheldandreturns a tri-state:
Truewithheld,Falserunnable,Noneunknown.Unknown is now its own answer: an empty advertised set used to fold into
"runnable" through
model_is_unusable, which is correct for the wire and wrongfor a displayed verdict.
SlotProjection.to_dictcarries it as
model_withheld(true/false/null).displayModelreads it:true->auto;false-> the pin (the list'sspelling when the list has a row for it, so the picker still highlights);
null/absent -> the existing list-membership heuristic with itsdegradedgate. Unknown fails open, as the issue requires.
invalidates it with no work from any of
slot.model's writers (picker, bulkpick, pick rollback, two restore paths, canonical backfill). A verdict
outliving a re-pin would label the new model with the old model's entitlement.
that advertised the list. There is no single chokepoint for that:
_reset_slot_sessionis the funnel for the switch handlers (agent, workspace,model), but three other sites replace a slot's session directly -- the deferred
project-change reset and the deferred conversation discard in
_consume_pending_teardowns, and the post-turn agent-switch reset/discard inthe runner's
finally. Routing those through the funnel would also cancel anypending question card, which is not this change's business, so each drops the
verdict itself and a test pins the invariant: every
state.sessions.reset(/discard_conversation(site inchat_runner.pyandchat_handlers.pymust carry arecord_model_withheld(None)beside it.the pin-to-agent row still writes
slot.model, never the displayed label.Deliberately unchanged: a slot that has never spawned a session has no verdict,
so its display still falls back to list membership. The withhold is only knowable
once a session advertises a list, and collapsing unknown into "show the pin"
would re-show an unentitled pin in that window -- the symptom #1611 fixed. The
issue's named deprecated-pin instance lives in exactly that pre-first-turn
window and is separately self-correcting:
_normalize_modelrewrites such a pinto its replacement at turn start, so from the first turn on the slot carries an
id the picker does list. What the verdict fixes is the class -- wherever a
verdict exists, no
/api/modelsfilter can relabel a runnable pin, and agenuinely withheld pin reads
autoeven when the list is wider or degraded.What tests we did
Targeted only (no full suite locally).
test/test_dashboard_chat.py -k PinnedModelWithheld20 passed;test/test_chat_runner_coverage.py+test_dashboard_approval.py+test_eager_spawn.py398 passed;test_chat_slot_reset_conversation.py+test_chat_slot_project.py+test_reset_conversation_directive.py+test_mcp_core_set_project.py81 passed;test/test_ask_question_roundtrip.py(the reset funnel's own tests) 63 passed;test_dashboard_chat_handlers_coverage.py+test_agent_default_model.py+test_resume_publishes_hydrated_slot.py184 passed.vitest run src/test/model.displayModel.test.ts23 passed;tsc -bclean; eslint 0 errors on the five touched files (8 pre-existingwarnings in ChatPage.tsx, none on changed lines).
auto,claude_code, claude backend, no getter, getter raises, empty advertised set);
the payload carries both answers; the verdict is not reported after a re-pin
and is reported again when the same pin returns; a cleared pin reports nothing;
teardown forgets it; the reset funnel forgets it; the source-level ratchet over
every teardown call site; and two end-to-end
_run_chattests -- a withheldpin still gets its notice card and now reports
true, and a deprecated pin(rewritten to its replacement at turn start) reports
falsewith no notice.state.py,slot_projection.pyand
chat_handlers.pyat base, the 7 payload/identity/teardown tests fail;with
chat_runner.pyat base, both end-to-end recording tests fail; with thereset-conversation route's drop removed, the ratchet fails naming
chat_handlers:3658.dashboard modules (its 2 errors are pre-existing in
src/kiro_crew/transcribe.py).black --checkflagsapps/builtins/auto_research/handlers.py; verifiedpre-existing by running the same check on that file at base -- my change there
is one word inside a comment.
Why no screenshot: the chip's label does change, but only for a slot whose
live session withholds its pinned model (or whose pin an
/api/modelsfilterremoved while the session still advertises it). Neither state can be staged in a
pod without faking the backend's advertised list, so the delta is covered by the
displayModelunit tests and the two end-to-end_run_chattests instead.Any other suggestions on the work
model id (the issue's other option) rather than a boolean. The chip could then
name a deprecated pin's replacement instead of the deprecated spelling, and
normalizeModelKey's mirror of_normalize_model_keycould go away. It is awider change (a second field, and the write firewall has to be re-argued for an
id-shaped field), so it is not folded in here. Tracked as Carry the slot's effective model id so the last picker-membership inference can go #7575.
displayModel's membership fallback is unreachable for any slotthat has run a turn, but cannot be deleted until that unknown state is closed.
The two spellings of "can this account run it" still coexist; the verdict just
wins wherever it exists.
SessionManageris keyed by session key and knows nothing about slots, so itcannot drop slot-side state itself. A teardown callback on the manager (or
moving the deferred-teardown consume behind the funnel once the pending-question
interaction is settled) would collapse all four -- worth doing when something
else slot-side needs the same hook.
Pattern harvest
Rule candidate: a cached verdict must be keyed to the input it judged, not to
the object that holds it. The defect class is a stored judgment outliving the
value it was computed for -- here
slot.modelhas six writers, so an explicitreset at each one would eventually be missed. This repo already learned the
same lesson for session resets (
_reset_slot_sessionexists because "six callsites each having to remember an extra line is how one of them gets missed"),
so the convention is worth making mechanical: a stored verdict is spelled as the
pair
(_x, _x_for)and read only through a property that compares_x_foragainst the live input. A lint rule can check the shape -- any slot named
_<name>that is published in a payload and written by arecord_*methodshould have a sibling
_<name>_for-- which is cheaper than trying to detectstaleness itself. Where the input is NOT a field the holder owns (a live session,
here), the fallback is the source-level ratchet this PR adds: enumerate the sites
that invalidate, and fail the build when a new one appears without the drop.
Closes #1819