fix(tui): a sibling slash command no longer cancels /model's reply - #838
Merged
Conversation
Picking a model in /model switched the session but left the stats line under the composer reading the OLD provider AND model — `anthropic · claude-opus-5` next to answers coming from DeepSeek. #836 is what armed it. createSlashHandler kept ONE flight counter shared by every dispatch, and guarded/stale() dropped any reply that was no longer the newest — a guard meant for "the user moved on from this command" that could not tell that apart from "the user ran a different command". #836 gave the picker a third step, so onModelSelect now dispatches `/model …` and `/effort …` back-to-back in a single tick: the second bumped the counter before the first's RPC returned, and the `/model` reply — the one that folds provider+model into ui.info via infoAfterModelSwitch and prints `model → …` — was discarded. The backend had already committed the switch, so only the label was wrong, and both halves went stale rather than just the provider #836 threaded. Count dispatches per resolved command name instead, so aliases of one command still supersede each other while an unrelated command leaves it alone. Unregistered commands keep one shared lane on purpose: there the output IS the product, so a newer exec slash should still suppress an older one's late output (the existing slash.exec test pins that). Only fires when step 3 lands on a real effort level — the picker preselects the session's live level and emits no `/effort` for `auto`, so an auto session dispatches one command and never saw this. Both unit tests are mutation-checked: reinstating the shared counter fails the same-tick test, and keying on the full command string fails the supersede test. The e2e drives the real TUI binary in a PTY through all three picker steps, and reproduces the exact reported row before the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Test Results 4 files 966 suites 27m 43s ⏱️ Results for commit b43b495. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
Picking a model in
/modelswitched the session but left the stats line under the composer reading the old provider and old model —anthropic · claude-opus-5sitting next to answers that were coming from DeepSeek.Reported against a session whose own record (
~/.clawcodex/sessions/ds_….json) heldmodel: deepseek-v4-flash,provider: deepseek— so the backend was right and only the label was stale.Root cause
Not the provider-threading #836 fixed. #836 is what armed this one.
createSlashHandlerkept ONE flight counter shared by every dispatch (++slashFlightRef.current), andguarded/stale()discarded any reply that was no longer the newest. That guard means "the user moved on from this command" — but a single counter could not tell that apart from "the user ran a different command".#836 gave the picker a third step, so
onModelSelectnow dispatches/model …and/effort …back-to-back in a single tick. The second bumped the counter before the first's RPC returned, so the/modelreply — the one that folds provider+model intoui.infoviainfoAfterModelSwitchand prints themodel → …line — was thrown away. That both halves went stale, rather than just the provider #836 threaded, is what ruled #836 out.The fix
Count dispatches per resolved command name, so aliases of one command still supersede each other while an unrelated command leaves it alone. A session change stays guarded by the independent
sidcheck.Unregistered commands keep one shared lane on purpose: on the
slash.execpath the output is the whole product, so a newer exec slash should still suppress an older one's late output. The pre-existingdrops stale slash.exec output after a newer slashtest pins that and still passes.Why this survived review
It only fires when step 3 lands on a real effort level. The picker preselects the session's live level and emits no
/effortforauto, so anautosession dispatches one command and cannot reproduce it. The reported session was onmax.That same trap made my first e2e attempt vacuous — it sent
current: "", landed onauto, and passed against the reintroduced bug. Only mutation-testing caught it.Verification
still applies a /model switch when another command is dispatched in the same tick; keying on the full command string failsstill supersedes an older dispatch of the SAME command. The key choice is pinned from both directions.tests/test_tui_model_picker_stats_e2e.py) drives the realui-tui/dist/entry.jsin a PTY against a fake NDJSON agent-server, walks all three picker steps, and reads the row back with pyte. Under the reintroduced bug it reproduces the report exactly: staleanthropic · claude-opus-5and nomodel →line.ui-tui: baseline on this base is7 failed | 1859 passed; with the fix7 failed | 1861 passed— same pre-existing failures (virtualHeights,statusRule,useConfigSync,createGatewayEventHandler, all failing identically with the change stashed), plus the 2 new tests.npm run typecheckclean; no new lint (the one remaining warning in the test file pre-exists at line 367).test_model_provider_picker.py+test_effort_options.py: 62 passed.Note: the header box keeping
claude-opus-5 · anthropicis by design — it's a committed transcript row that picks up the new pairing on next launch, and is not part of this fix.🤖 Generated with Claude Code