feat(cli): support inline one-shot model override in /model (#5967)#6022
Conversation
wenshao
left a comment
There was a problem hiding this comment.
[Critical] Non-interactive/ACP mode silently drops modelOverride
handleCommandResult in nonInteractiveCliCommands.ts:108 converts SubmitPromptActionReturn to NonInteractiveSlashCommandResult but only copies content — modelOverride is dropped entirely. The NonInteractiveSlashCommandResult type doesn't even declare a modelOverride field. Since modelCommand declares supportedModes: ['interactive', 'non_interactive', 'acp'], the inline override silently fails in non-interactive and ACP modes — the prompt runs on the session's default model with no error or warning.
Fix: add modelOverride?: string to the non-interactive result type and propagate it, or return an error when inlinePrompt is non-empty in non-interactive/ACP mode.
[Critical] Skill tool clobbers inline model override mid-turn
useGeminiStream.ts:2756 — skill tool responses always include modelOverride in their object shape (even when undefined, since skill.model can be undefined per skills/types.ts:65), so the 'modelOverride' in toolCall.response check always matches for skill tools. This silently overwrites the user's inline override — e.g. /model qwen-max analyze this using the pdf skill would switch to the skill's model (or the default model) for all continuation API calls after the skill loads.
Fix: guard the skill-tool write to skip when a user-level inline override was set this turn, or introduce a separate inlineOverrideRef that takes precedence.
— qwen3.7-max via Qwen Code /review
|
Thanks for the thorough review — all addressed in
|
wenshao
left a comment
There was a problem hiding this comment.
[Suggestion] config.getModel() returns the session model, not the inline override model, in diagnostic paths. When an inline override is active, the context-compaction info message (line ~1540: approached the input token limit for ${config.getModel()}) and the ApiCancelEvent (line ~797) both report the session model instead of the model that actually ran the prompt. Consider using modelOverrideRef.current ?? config.getModel() in these diagnostic/telemetry paths so users see the correct model name.
— qwen3.7-max via Qwen Code /review
Allow `/model <model-id> <prompt>` to run the trailing prompt on another model for a single turn, without changing or persisting the session model. The prompt is submitted via a per-turn `modelOverride` carried on the `submit_prompt` result, so the chosen model applies to the turn and any tool-call continuations it spawns, then auto-reverts on the next user turn. This avoids session mutation, persistence, and the stuck-model failure modes a switch+revert design would hit on cancel/error. Scope: same-provider model switching only. An id that resolves to a different auth type is rejected with a hint to use the two-step `/model <id>` flow (which already handles cross-provider switches).
The `/model <id> <prompt>` inline override is also reachable in non-interactive mode (the command declares `non_interactive` support), but the non-interactive runner only consumed `submit_prompt.content` and dropped the model override, so the prompt ran on the session default. Thread `modelOverride` from the submit_prompt result through the non-interactive slash result type and seed the run loop's per-turn `modelOverride` with it, matching the interactive behavior. Verified via OpenAI request logging: `/model glm-5.1 <prompt>` sends `model: glm-5.1` on the foreground turn while the session default and background subagents stay on the configured model.
…esults Assert handleSlashCommand forwards a submit_prompt result's modelOverride to the non-interactive result, and omits it when unset.
- Update the stale /model description assertion that broke CI. - Prevent skill-tool model overrides from clobbering an explicit inline `/model <id> <prompt>` override mid-turn: a user-set inline override now wins for the whole turn (including tool-call continuations) via a dedicated active flag, reset on the next user turn. - Reject the inline form in ACP mode, where the send pipeline does not thread a per-turn override (it would otherwise silently run on the session model); the two-step `/model <id>` flow still works there. - Fix the argumentHint to not imply a prompt is valid after --fast/--voice /--vision, and note in the description that the inline prompt is sent verbatim without @file expansion.
…ales The inline-override description change orphaned the old translation key, so zh-CN/zh-TW fell back to English and the strict-parity command-description coverage test failed. Update the en/zh/zh-TW entries to the new description.
Non-interactive/ACP main turn unconditionally applied skill-tool modelOverride writes, so a skill returning `modelOverride: undefined` (inherit) silently reverted an explicit `/model <id> <prompt>` override to the session model mid-turn. Guard the write with `inlineModelOverrideActive`, mirroring the interactive `inlineModelOverrideActiveRef` guard. Also clear an inline override on Retry: it is a one-off for the original prompt, so a Ctrl+Y retry now reverts to the session model and lets skill-tool overrides apply again, while skill-selected overrides are still preserved across retries.
Address review on inline `/model <id> <prompt>` one-shot override: - Reject an inline override unless the target resolves to the SAME provider identity as the active session, not merely the same auth type. A different auth type is rejected outright; within the active auth type the target must match the active content generator's baseUrl + envKey. This prevents a same-id model owned by a different (e.g. OpenAI-compatible) provider from being sent to the active endpoint/credentials. Add tests for the match/mismatch cases. - Move the `!settings` guard past the inline path so the inline form, which never touches settings, is no longer blocked when settings are absent. - Add the two inline-override error strings to en/zh/zh-TW locales to satisfy strict-parity translation coverage. - Collapse the duplicated retry-clearing branches into a single condition. - Report the inline override model (modelOverrideRef.current ?? getModel()) in the ApiCancelEvent and context-compaction info message so diagnostics show the model that actually ran the prompt.
d555f33 to
7f43f7c
Compare
|
@qwen-code /review |
| _Qwen Code review request accepted. Review is queued in [workflow run](https://github.com/QwenLM/qwen-code/actions/runs/28432748157)._ |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
[Suggestion] UserPromptEvent telemetry omits the model id / override. The cancel event at line ~798 correctly logs modelOverrideRef.current ?? config.getModel(), but new UserPromptEvent(length, promptId, authType, text) at line ~2274 has no model field. When an inline override is active, operators reviewing telemetry cannot determine which model actually processed each prompt. Consider adding a model field to UserPromptEvent and populating it from modelOverrideRef.current ?? config.getModel(), mirroring the cancel event's pattern.
— qwen3.7-max via Qwen Code /review
Address review follow-ups on the inline `/model <id> <prompt>` override:
- Record the model that actually processed each prompt in telemetry. Add an
optional `model` field to `UserPromptEvent` and populate it with
`modelOverrideRef.current ?? config.getModel()`, mirroring the cancel event.
Emit it from both the OTel and clearcut loggers; add a logger test.
- Tell the user when a retry drops the inline override: emit an info history
item ("retrying on the session model (...)") before clearing it, so the
model switch on retry is no longer silent.
- Extract `applyModelOverride()` / `clearModelOverride()` helpers that write
both the model-id ref and the inline-active flag atomically, and route the
set/clear/skill-guard sites through them so the coupling invariant can't be
broken by editing one ref in isolation.
- Add debugLogger entries across the override lifecycle (set, clear,
skill-tool guard block in useGeminiStream; capture and per-turn init in
nonInteractiveCli) so a silent override failure is traceable on call.
wenshao
left a comment
There was a problem hiding this comment.
Overall the inline one-shot model override implementation is well-designed — the modelOverride primitive avoids the revert-based approach's stuck-model failure modes, the provider identity check (baseUrl + envKey) prevents accidental cross-provider usage, and the latest commit adds good debug logging, helper functions for ref invariant maintenance, and telemetry model attribution.
[Suggestion] Test coverage gaps for skill-tool override interactions
The !inlineModelOverrideActiveRef.current guard blocking skill-tool overrides and the retry-clears-inline-override path are behavioral invariants that are not covered by tests in useGeminiStream.test.tsx. A regression in either would cause silent wrong-model execution that is hard to detect. Consider adding tests for: (1) skill tool writing modelOverride: undefined during an inline-override turn does not clobber the inline model, and (2) retry after an inline override clears the override and reverts to the session model.
Address review follow-ups on the inline `/model <id> <prompt>` override: - Guard against any slash command (not just the validated `/model`) setting `modelOverride`. Extract `isInlineModelOverrideAllowed(config, modelId)` — the shared provider-identity check (active auth type + baseUrl + envKey) — and enforce it at both consumers (useGeminiStream and nonInteractiveCli) before applying an override, dropping + warning on a mismatch. `/model` reuses the same helper so command and consumers can't drift. - Document the non-interactive divergence: `inlineModelOverrideActive` is a run-scoped const (single-turn), not a mirror of useGeminiStream's mutable ref helpers — no retry-clearing or skill-tool takeover applies there. - Add the missing behavioral tests: a skill tool emitting `modelOverride: undefined` during an inline-override turn does not clobber the inline model, and a retry clears the inline override and reverts to the session model. Add unit tests for the provider-identity helper.
DragonnZhang
left a comment
There was a problem hiding this comment.
Incremental review of commit 0924fabdf (fix: enforce inline model override provider identity at consumers):
No high-confidence issues found. The commit cleanly addresses the prior review follow-ups:
-
Shared helper
isInlineModelOverrideAllowed— Correctly extracts the provider-identity check (authType + baseUrl + envKey) intoacpModelUtils.ts. The?? undefinednormalization ensures consistent comparison when fields are absent. Properly filtersfastOnly/voiceOnlymodels and handles missing auth type. -
Consumer-side enforcement — Both
nonInteractiveCli.tsanduseGeminiStream.tsgate on the shared helper before applying an override. The/modelcommand reuses the same helper, so command and consumers can't drift. TheinlineModelOverrideActivedocumentation clearly explains the non-interactive divergence (run-scoped const vs. mutable ref helpers). -
modelCommand.tsrefactoring — Replaces the inlineavailableModels.find()with the shared helper while preserving exact behavior (sameAuthType short-circuit, same error path). -
Test coverage — 6 unit tests for the helper cover matching, mismatch, unknown ID, fastOnly/voiceOnly exclusion, and missing auth type. Behavioral tests verify the skill-tool
modelOverride: undefinedguard and retry-clear semantics.
|
@qwen-code /triage |
|
Thanks for the PR! Template looks good ✓ On direction: this is a solid quality-of-life improvement — collapsing the switch→prompt→switch-back dance into a single On approach: the design is well-thought-out. The producer/consumer split — modelCommand produces the Moving on to code review. 🔍 中文说明感谢贡献! 模板完整 ✓ 方向:这是一个很实用的体验优化——把「切换→提示→切回」三步操作压缩成一行 方案:设计经过深思熟虑。生产者/消费者分离——modelCommand 产出 进入代码审查 🔍 — Qwen Code · qwen3.7-max |
Code ReviewRead through the full diff and the key changed files (modelCommand.ts, useGeminiStream.ts, acpModelUtils.ts, nonInteractiveCli.ts). The implementation is clean — no critical blockers found. The producer/consumer validation split is well-executed: modelCommand validates the model exists on the target auth type, then the consumers (useGeminiStream for interactive, nonInteractiveCli for headless) independently re-validate provider identity via The coupled-refs pattern ( The ACP rejection is properly scoped — ACP's send pipeline doesn't thread per-turn overrides, so rejecting the inline form prevents silent misrouting. One small note: the Build & Tests
Real-Scenario Testing (tmux)Tested non-interactive mode with the PR code. Without API credentials on this runner, the tests exercise the command-parsing and error paths. Test 1:
|
ReflectionStepping back: this PR does exactly what it says on the tin. The motivation is clear (3 steps → 1 step for one-shot model use), the implementation is focused, and the tests are thorough. The code reads like someone who has thought carefully about the failure modes — the coupled-refs pattern exists because a naive revert-based approach would leave the session stuck on the wrong model after Ctrl+C or an API error, and the skill-tool precedence guard exists because skill tools can legitimately set their own My independent proposal would have been simpler (single ref, no skill-tool guard), which means it would have been wrong in edge cases. The PR's approach is better. The Build is clean, typecheck is clean, all 176 tests pass, tmux testing confirms the command-parsing and error paths work in real usage. The prior review's critical issues (skill-tool clobbering, ACP dropping the override, stale test assertion) have all been addressed. This is ready to ship. ✅ 中文说明退一步看:这个 PR 做到了它所承诺的。动机清晰(3 步操作压缩为 1 步的一次性模型使用),实现聚焦,测试充分。代码读起来像是仔细思考过故障模式的人写的——耦合 refs 模式存在是因为朴素的回退方案会在 Ctrl+C 或 API 错误后让会话卡在错误的模型上,skill-tool 优先级守护存在是因为 skill tool 确实可能在 turn 中设置自己的 我的独立方案会更简单(单一 ref,无 skill-tool 守护),这意味着它在边界情况下会出错。PR 的方案更好。
构建通过,类型检查通过,176 个测试全部通过,tmux 测试确认真实使用中的命令解析和错误路径正常。之前审查中的关键问题(skill-tool 覆写、ACP 丢弃覆写、过时的测试断言)已全部解决。 可以合入了 ✅ — Qwen Code · qwen3.7-max |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
✅ Maintainer local verification — real binary E2E + unit suitesI built the real bundled binary at this PR's head (
Environment
1. Unit / component suites (vitest at head) — 176 passed
2. Non-interactive E2E (real binary → fake endpoint)Each row is the
3. Interactive TUI E2E (tmux, one live session) — the auto-revert
Runtime debug log corroborates the lifecycle exactly: Turn 1 = one-shot (no session switch, indicator unchanged); Turn 3 = bare 4. Tool-loop continuation E2E (the design rationale)Forced a The override persists across the entire tool-call loop and does not revert mid-turn — confirming the per-turn VerdictThe implementation does exactly what the description says: inline form runs one turn (incl. tool-call continuations) on the target model, auto-reverts on the next user turn, never switches/persists the session model, never leaks into background subagents, and rejects invalid ids / cross-provider / same-id-different-endpoint targets with clear messages. Build, typecheck, and all touched unit suites pass. LGTM from a behavioral-verification standpoint. 🇨🇳 中文版(点击展开)✅ 维护者本地验证 —— 真实二进制 E2E + 单元测试我在本 PR head(
环境
1. 单元/组件测试(head 上 vitest)—— 176 通过
2. 非交互 E2E(真实二进制 → 伪端点)每行是线上实际观测到的
3. 交互 TUI E2E(tmux,同一个活会话)—— 自动回退
运行时 debug 日志完全印证生命周期: 第 1 轮 = 一次性(不切换会话、指示符不变);第 3 轮 = 裸 4. 工具调用续接 E2E(该设计的核心理由)在覆盖轮强制一次 覆盖贯穿整个工具调用循环、轮内不会中途回退 —— 印证了"按轮 结论实现与描述完全一致:内联形式让单轮(含工具调用续接)跑在目标模型上、下一个用户轮自动回退、绝不切换/持久化会话模型、不泄漏到后台子代理、并对非法 id / 跨 provider / 同 id 不同端点目标用清晰消息拒绝。构建、typecheck、所有受影响单元套件均通过。从行为验证角度 LGTM。 Verification harness: real bundled binary + fake OpenAI endpoint recording the wire |
✅ Maintainer local verification — real-binary E2E (interactive TUI + non-interactive)I built the actual PR head ( Setup — a same-provider
The fake server echoes the received model back as the reply ( Results
Key evidenceInline override + auto-revert (interactive TUI): Revert targets the session model, not a constant — with the session switched to Override survives the tool loop (the central design claim): Cross-provider rejection is by provider identity, not just auth type: Tests / static checks (on the worktree at PR head)
ConclusionEvery documented behavior reproduces on the real binary, including the two items the description marked not validated (interactive TUI A/B and tool-call continuation). The override applies to the turn and all its tool-call continuations, never leaks into background subagents, reverts to the current session model, and never mutates or persists session state; invalid / cross-provider inputs are rejected with no outgoing call. No regressions or defects found. LGTM for merge (v1 same-provider scope). 🇨🇳 中文版(完整对应)✅ 维护者本地验证 — 真实二进制端到端(交互式 TUI + 非交互)我把 PR 当前 head( 环境 — 一个同 provider 的
伪服务器把收到的 model 原样回显成回复( 结果
关键证据内联覆盖 + 自动回退(交互式 TUI): 回退目标是会话模型而非常量 — 把会话切到 覆盖跨越工具循环存活(核心设计主张): 跨 provider 的拒绝判据是 provider 身份,而非仅 auth 类型: 测试 / 静态检查(在 PR head 的 worktree 上)
结论所有自述行为都在真实二进制上复现,包括描述里标为「未验证」的两项(交互式 TUI A/B 与工具调用续轮)。覆盖作用于该轮及其所有工具调用续轮、绝不泄漏到后台子代理、回退到当前会话模型、且从不改动或持久化会话状态;非法/跨 provider 输入被拒绝且不产生任何外发请求。未发现回归或缺陷。同意合并(v1 同 provider 范围)。 Verified at head |
What this PR does
Adds an inline one-shot model override to the
/modelcommand:/model <model-id> <prompt>runs the trailing prompt on<model-id>for a single turn (including any tool-call continuations it spawns), then automatically reverts to the session's selected model on the next user turn./model <model-id>with no trailing prompt keeps its existing "switch the session model" behavior unchanged. Works in both interactive and non-interactive modes.Why it's needed
Resolves #5967. Using a specialist model once currently takes three steps (switch → prompt → switch back). This collapses it into one line and reverts automatically, without touching the session default or persisting anything.
The implementation deliberately avoids a
switchModel()+ revert design. The only post-turn hook (onComplete) fires only on clean completion — it is nulled without running on user cancel and on error — so a revert-based approach would leave the session stuck on the temporary model after Ctrl+C or an API error. It would also revert too early for prompts that trigger tool calls, since those span multiplesubmitQuerycycles. Instead the prompt is submitted with a per-turnmodelOverride(the same primitive skill tools already use): it is preserved across the tool-call loop and cleared on the next genuine user turn, giving correct one-shot semantics with no revert logic and no stuck-model failure modes.Reviewer Test Plan
How to verify
In an interactive session with at least two models configured under the current provider:
/modelshows it)./model <other-model-id> say which model you are— the prompt runs on<other-model-id>; the session model is unchanged and settings are not written./model <other-model-id>with no trailing prompt still switches the session model as before./model <bogus-id> hi→ error listing available models; no switch./model <id-from-another-provider> hi→ error pointing to the two-step/model <id>flow; no switch.Unit tests cover the submit_prompt shape, the no-switch/no-persist guarantee, invalid id, and cross-provider rejection (
packages/cli/src/ui/commands/modelCommand.test.ts). ExistingnonInteractiveCli/nonInteractiveCliCommandssuites pass.Evidence (Before & After)
Verified locally in non-interactive mode against a provider with multiple models (session default
qwen3.7-max), using the OpenAI request logger (model.enableOpenAILogging) to capture the actual outgoing requestmodel:modelqwen -p "/model glm-5.1 reply with exactly: PONG"(override)glm-5.1on the foreground turn; background memory-extractor subagent stayed onqwen3.7-maxqwen3.7-max)qwen -p "reply with exactly: PONG"(baseline)qwen3.7-maxqwen3.7-max)So the override applies to the one-shot turn only, does not leak into background subagents, and does not mutate or persist the session model.
Tested on
Environment (optional)
Local:
npm run bundle+node scripts/cli-entry.js(non-interactive), unit tests (vitest),npm run build,npm run typecheck.Risk & Scope
submit_prompt+modelOverridepath)./model <id>behavior is unchanged; the newmodelOverridefield on the submit_prompt result is optional.Linked Issues
Closes #5967