Skip to content

fix(tui): a sibling slash command no longer cancels /model's reply - #838

Merged
ericleepi314 merged 1 commit into
mainfrom
fix/slash-flight-per-command
Aug 14, 2026
Merged

fix(tui): a sibling slash command no longer cancels /model's reply#838
ericleepi314 merged 1 commit into
mainfrom
fix/slash-flight-per-command

Conversation

@ericleepi314

Copy link
Copy Markdown
Collaborator

The bug

Picking a model in /model switched the session but left the stats line under the composer reading the old provider and old modelanthropic · claude-opus-5 sitting next to answers that were coming from DeepSeek.

Reported against a session whose own record (~/.clawcodex/sessions/ds_….json) held model: 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.

createSlashHandler kept ONE flight counter shared by every dispatch (++slashFlightRef.current), and guarded/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 onModelSelect now dispatches /model … and /effort … back-to-back in a single tick. The second bumped the counter before the first's RPC returned, so the /model reply — the one that folds provider+model into ui.info via infoAfterModelSwitch and prints the model → … 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 sid check.

Unregistered commands keep one shared lane on purpose: on the slash.exec path the output is the whole product, so a newer exec slash should still suppress an older one's late output. The pre-existing drops stale slash.exec output after a newer slash test 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 /effort for auto, so an auto session dispatches one command and cannot reproduce it. The reported session was on max.

That same trap made my first e2e attempt vacuous — it sent current: "", landed on auto, and passed against the reintroduced bug. Only mutation-testing caught it.

Verification

  • Both unit tests mutation-checked. Reinstating the shared counter fails still applies a /model switch when another command is dispatched in the same tick; keying on the full command string fails still supersedes an older dispatch of the SAME command. The key choice is pinned from both directions.
  • New screen-level e2e (tests/test_tui_model_picker_stats_e2e.py) drives the real ui-tui/dist/entry.js in 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: stale anthropic · claude-opus-5 and no model → line.
  • ui-tui: baseline on this base is 7 failed | 1859 passed; with the fix 7 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 typecheck clean; no new lint (the one remaining warning in the test file pre-exists at line 367).
  • Backend test_model_provider_picker.py + test_effort_options.py: 62 passed.

Note: the header box keeping claude-opus-5 · anthropic is 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

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>
@github-actions

Copy link
Copy Markdown

Test Results

     4 files     966 suites   27m 43s ⏱️
14 636 tests 14 617 ✅ 19 💤 0 ❌
29 948 runs  29 891 ✅ 57 💤 0 ❌

Results for commit b43b495.

@ericleepi314
ericleepi314 merged commit 4dc7f11 into main Aug 14, 2026
7 checks passed
@ericleepi314
ericleepi314 deleted the fix/slash-flight-per-command branch August 14, 2026 07:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant