Skip to content

feat(cli): support inline one-shot model override in /model (#5967)#6022

Merged
wenshao merged 10 commits into
QwenLM:mainfrom
TianYuan1024:feat/inline-model-override
Jun 30, 2026
Merged

feat(cli): support inline one-shot model override in /model (#5967)#6022
wenshao merged 10 commits into
QwenLM:mainfrom
TianYuan1024:feat/inline-model-override

Conversation

@TianYuan1024

@TianYuan1024 TianYuan1024 commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Adds an inline one-shot model override to the /model command: /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 multiple submitQuery cycles. Instead the prompt is submitted with a per-turn modelOverride (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:

  1. Note the active model (/model shows it).
  2. Run /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.
  3. Send any normal prompt next — it runs on the original model (override auto-reverted).
  4. /model <other-model-id> with no trailing prompt still switches the session model as before.
  5. /model <bogus-id> hi → error listing available models; no switch.
  6. /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). Existing nonInteractiveCli / nonInteractiveCliCommands suites 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 request model:

Run Outgoing request model Persisted default after run
qwen -p "/model glm-5.1 reply with exactly: PONG" (override) glm-5.1 on the foreground turn; background memory-extractor subagent stayed on qwen3.7-max unchanged (qwen3.7-max)
qwen -p "reply with exactly: PONG" (baseline) qwen3.7-max unchanged (qwen3.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

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

Local: npm run bundle + node scripts/cli-entry.js (non-interactive), unit tests (vitest), npm run build, npm run typecheck.

Risk & Scope

  • Main risk or tradeoff: inline override applies only to the current provider; it changes the model id sent to the active content generator but does not rebuild credentials/endpoint.
  • Not validated / out of scope: cross-provider inline override (rejected with a clear hint in v1); interactive TUI before/after capture (the non-interactive request-log evidence above exercises the same submit_prompt + modelOverride path).
  • Breaking changes / migration notes: none. No-prompt /model <id> behavior is unchanged; the new modelOverride field on the submit_prompt result is optional.

Linked Issues

Closes #5967

Draft: opening to align with maintainers on the v1 scope decision (same-provider only) before finalizing. See the issue thread for the rationale.

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Non-interactive/ACP mode silently drops modelOverride

handleCommandResult in nonInteractiveCliCommands.ts:108 converts SubmitPromptActionReturn to NonInteractiveSlashCommandResult but only copies contentmodelOverride 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

Comment thread packages/cli/src/ui/commands/modelCommand.test.ts
Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts
Comment thread packages/cli/src/ui/commands/modelCommand.ts
@TianYuan1024

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — all addressed in fa384c78d:

  1. [Critical] Skill tool clobbers inline override — Fixed. A turn whose model override came from an explicit /model <id> <prompt> now sets an inlineModelOverrideActiveRef that takes precedence for the whole turn; the skill-tool override loop (including the undefined-clears case) is skipped while it's active, and the flag resets on the next user turn alongside modelOverrideRef.
  2. [Critical] Non-interactive/ACP drops modelOverride — Non-interactive was wired in an earlier commit (the result type now carries modelOverride and the run loop seeds its per-turn override from it; verified via OpenAI request logging). For ACP, the send pipeline doesn't thread a per-turn override, so rather than silently run on the session model the inline form is now rejected in ACP mode with a hint to use the two-step /model <id> flow.
  3. [Critical] Stale description assertion — Fixed; the test now asserts the updated description (this was the CI failure).
  4. [Suggestion] @file expansion bypass — Documented in the command description for now (the inline prompt is sent verbatim without @file expansion); this is shared by all submit_prompt commands, so routing inline prompts through the full preprocessing pipeline is noted as a follow-up.
  5. [Suggestion] Misleading argumentHint — Updated to [--fast|--voice|--vision] [<model-id>] | <model-id> <prompt>.

@TianYuan1024
TianYuan1024 marked this pull request as ready for review June 30, 2026 05:25
Comment thread packages/cli/src/nonInteractiveCli.ts
Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts Outdated

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Comment thread packages/cli/src/ui/commands/modelCommand.ts
Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts Outdated
Comment thread packages/cli/src/ui/commands/modelCommand.ts
Comment thread packages/cli/src/ui/commands/modelCommand.ts
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.
@TianYuan1024
TianYuan1024 force-pushed the feat/inline-model-override branch from d555f33 to 7f43f7c Compare June 30, 2026 08:12
@TianYuan1024
TianYuan1024 requested a review from wenshao June 30, 2026 08:47
@wenshao

wenshao commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /review

@github-actions

Copy link
Copy Markdown
Contributor
_Qwen Code review request accepted. Review is queued in [workflow run](https://github.com/QwenLM/qwen-code/actions/runs/28432748157)._

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts
Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts Outdated
Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts Outdated
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 wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/cli/src/nonInteractiveCli.ts
Comment thread packages/cli/src/ui/commands/types.ts
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 DragonnZhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Shared helper isInlineModelOverrideAllowed — Correctly extracts the provider-identity check (authType + baseUrl + envKey) into acpModelUtils.ts. The ?? undefined normalization ensures consistent comparison when fields are absent. Properly filters fastOnly/voiceOnly models and handles missing auth type.

  2. Consumer-side enforcement — Both nonInteractiveCli.ts and useGeminiStream.ts gate on the shared helper before applying an override. The /model command reuses the same helper, so command and consumers can't drift. The inlineModelOverrideActive documentation clearly explains the non-interactive divergence (run-scoped const vs. mutable ref helpers).

  3. modelCommand.ts refactoring — Replaces the inline availableModels.find() with the shared helper while preserving exact behavior (sameAuthType short-circuit, same error path).

  4. 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: undefined guard and retry-clear semantics.

@TianYuan1024
TianYuan1024 requested a review from wenshao June 30, 2026 14:52
@wenshao

wenshao commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot added category/cli Command line interface and interaction scope/commands Command implementation scope/model-switching Model selection and switching type/feature-request New feature or enhancement request labels Jun 30, 2026
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

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 /model <id> <prompt> line is exactly the kind of CLI shortcut users expect. Closes #5967 cleanly. No direct Claude Code equivalent found in their CHANGELOG, but the model-management area is clearly in scope. The scope decision to reject cross-provider inline overrides (pointing users to the two-step /model <id> flow) is pragmatic for v1.

On approach: the design is well-thought-out. The producer/consumer split — modelCommand produces the modelOverride, the consumer (useGeminiStream / nonInteractiveCli) validates provider identity before applying — is good defensive engineering. The ACP rejection is the right call since ACP's send pipeline doesn't thread per-turn overrides. The coupled-refs pattern (modelOverrideRef + inlineModelOverrideActiveRef) with applyModelOverride/clearModelOverride helpers adds some ceremony but correctly guards the tricky skill-tool precedence case. The diff is ~870 lines but the bulk is test coverage (~550 lines across 5 test files), which is proportional. The implementation itself is focused — every change serves the stated goal, no drive-by refactors or scope creep.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

方向:这是一个很实用的体验优化——把「切换→提示→切回」三步操作压缩成一行 /model <id> <prompt>,正是 CLI 工具用户期望的快捷方式。干净地关闭了 #5967。Claude Code 的 CHANGELOG 中没有找到直接对应的功能,但模型管理方向显然在项目范围内。v1 拒绝跨 provider 的内联覆写(引导用户使用两步 /model <id> 流程)是务实的范围决策。

方案:设计经过深思熟虑。生产者/消费者分离——modelCommand 产出 modelOverride,消费者(useGeminiStream / nonInteractiveCli)在应用前验证 provider 身份——是好的防御性工程。ACP 模式的拒绝是正确的选择,因为 ACP 的发送管道不支持逐 turn 的覆写。耦合 refs 模式(modelOverrideRef + inlineModelOverrideActiveRef)配合 applyModelOverride/clearModelOverride 辅助函数增加了一些代码量,但正确地守护了棘手的 skill-tool 优先级问题。diff 约 870 行,但大部分是测试覆盖(5 个测试文件约 550 行),比例合理。实现本身聚焦——每处改动都服务于声明的目标,没有顺手重构或范围蔓延。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Read 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 isInlineModelOverrideAllowed before applying the override. This defense-in-depth means a buggy or malicious slash command can't silently redirect API calls to a different provider.

The coupled-refs pattern (modelOverrideRef + inlineModelOverrideActiveRef) with applyModelOverride/clearModelOverride helpers is the right level of ceremony for the skill-tool precedence problem. The retry-clearing logic is also correct: inline overrides drop on retry (one-shot is done), skill-tool overrides survive (continuation semantics). The info message on retry-clear is a nice touch — makes the silent model switch visible.

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 isInlineModelOverrideAllowed function duplicates some of the provider-identity logic that conceptually lives in modelCommand's existing availableModels check. Not a blocker — the consumer-side re-validation is a deliberate design choice and the function is well-named and tested.

Build & Tests

  • npm run build — ✅ clean (only pre-existing warnings)
  • npm run typecheck — ✅ clean
  • modelCommand.test.ts — 53/53 ✅
  • acpModelUtils.test.ts — 27/27 ✅
  • nonInteractiveCliCommands.test.ts — 37/37 ✅
  • loggers.test.ts — 57/57 ✅
  • useGeminiStream.test.tsx (inline model override) — 2/2 ✅

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: /model nonexistent-model say hello — invalid model with trailing prompt

github-runner@iZt4neqpisqczs6hsm7xn2Z:~$ npm run dev -- -p '/model nonexistent-model say hello' 2>&1

> @qwen-code/[email protected] dev
> node scripts/dev.js -p /model nonexistent-model say hello

Model 'nonexistent-model' is not available for auth type 'openai'.
No models are configured for auth type 'openai'.
Configure models in settings.modelProviders and ensure the required environment variables are set. In interactive mode, run /auth to configure or switch providers, or run /model without arguments to choose from configured models.

✅ Model name correctly parsed from nonexistent-model say hello (first word = model id, rest = prompt). Error path fires before any API call. Error message includes the configuration hint.

Test 2: /model (no args) — unchanged baseline

github-runner@iZt4neqpisqczs6hsm7xn2Z:~$ npm run dev -- -p '/model' 2>&1

> @qwen-code/[email protected] dev
> node scripts/dev.js -p /model

Current model: qwen3.7-max
Use "/model <model-id>" to switch models or "/model --fast <model-id>" to set the fast model.

✅ No-args path is unchanged — still shows current model and usage hint. The inline prompt feature doesn't affect existing behavior.

Note: The actual override behavior (running a prompt on a different model) requires live API credentials which aren't available in this CI environment. The unit tests thoroughly cover the override application, skill-tool precedence, retry clearing, and cross-provider rejection paths.

中文说明

代码审查

通读了完整 diff 和关键改动文件(modelCommand.ts、useGeminiStream.ts、acpModelUtils.ts、nonInteractiveCli.ts)。实现干净——未发现关键阻断性问题。

生产者/消费者验证分离执行到位:modelCommand 验证模型在目标 auth type 上存在,然后消费者(交互式的 useGeminiStream、非交互式的 nonInteractiveCli)在应用覆写前通过 isInlineModelOverrideAllowed 独立重新验证 provider 身份。这种纵深防御意味着有 bug 或恶意的 slash command 无法静默地将 API 调用重定向到不同的 provider。

耦合 refs 模式(modelOverrideRef + inlineModelOverrideActiveRef)配合 applyModelOverride/clearModelOverride 辅助函数,对 skill-tool 优先级问题来说是恰到好处的复杂度。retry 清除逻辑也正确:内联覆写在重试时丢弃(一次性已完成),skill-tool 覆写保留(延续语义)。重试清除时的 info 提示是贴心的细节——让静默的模型切换变得可见。

ACP 拒绝的范围恰当——ACP 的发送管道不支持逐 turn 的覆写,拒绝内联形式防止了静默误路由。

一个小注:isInlineModelOverrideAllowed 函数重复了一些概念上已在 modelCommand 的 availableModels 检查中的 provider 身份逻辑。不是阻断性问题——消费者端重新验证是刻意的设计选择,函数命名清晰且有测试覆盖。

构建与测试

  • npm run build — ✅ 通过
  • npm run typecheck — ✅ 通过
  • 全部 176 个测试通过(5 个测试文件)

真实场景测试 (tmux)

  • /model nonexistent-model say hello — 模型名正确解析,错误路径正常触发 ✅
  • /model(无参数)— 原有行为未变 ✅

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Reflection

Stepping 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 modelOverride mid-turn.

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 isInlineModelOverrideAllowed function is a small, well-named, well-tested piece of provider-identity validation that gets reused in all three consumers (modelCommand, useGeminiStream, nonInteractiveCli). No unnecessary abstractions, no speculative features.

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 中设置自己的 modelOverride

我的独立方案会更简单(单一 ref,无 skill-tool 守护),这意味着它在边界情况下会出错。PR 的方案更好。

isInlineModelOverrideAllowed 是一个小型、命名清晰、测试充分的 provider 身份验证函数,在所有三个消费者(modelCommand、useGeminiStream、nonInteractiveCli)中复用。没有不必要的抽象,没有投机性功能。

构建通过,类型检查通过,176 个测试全部通过,tmux 测试确认真实使用中的命令解析和错误路径正常。之前审查中的关键问题(skill-tool 覆写、ACP 丢弃覆写、过时的测试断言)已全部解决。

可以合入了 ✅

Qwen Code · qwen3.7-max

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@wenshao

wenshao commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

✅ Maintainer local verification — real binary E2E + unit suites

I built the real bundled binary at this PR's head (3ec156f3f) and verified the inline one-shot /model <id> <prompt> override end-to-end against a fake OpenAI endpoint that records the model field actually sent on the wire (client.tsoptions?.modelOverride ?? config.getModel()). Every headline claim in the PR description reproduces. No behavioral discrepancies found.

Proof the binary ran this PR's code (not main): the runtime debug log emitted model override set to override-model (inline=true) — the (inline=${isInline}) template exists only in this PR's applyModelOverride and is absent on origin/main.

Environment

  • Worktree at PR head 3ec156f3f (merge of origin/main into feat/inline-model-override), Node v22.22.2, macOS.
  • npm ci + npm run bundle → real dist/cli.js (v0.19.3). npm run typecheck ✅ (all workspaces).
  • Isolated HOME with a modelProviders.openai catalog; two models (session-model default + override-model) share one baseUrl+envKey (same provider identity), plus decoys for the rejection cases.

1. Unit / component suites (vitest at head) — 176 passed

Suite Result Covers
modelCommand.test.ts 53 ✅ inline parse, no-switch/no-persist, invalid id, cross-provider, same-endpoint allow, ACP reject
acpModelUtils.test.ts 27 ✅ isInlineModelOverrideAllowed identity gate (baseUrl/envKey/fast-voice/unknown)
nonInteractiveCliCommands.test.ts 37 ✅ submit_prompt modelOverride passthrough / omit
useGeminiStream.test.tsx (inline block) 2 ✅ skill-tool undefined cannot clobber active inline; retry clears inline → session model
loggers.test.ts 57 ✅ UserPromptEvent.model telemetry attribute

2. Non-interactive E2E (real binary → fake endpoint)

Each row is the model observed on the wire:

Case Command Wire result Verdict
A override applies /model override-model … MARK_A foreground=override-model; background memory-extractor=session-model ✅ applies to the one-shot turn only; no leak to background subagents
A no persistence (after A) settings.model.name still session-model ✅ session default unchanged
C baseline plain prompt … (no slash) foreground=session-model
D invalid id /model totally-bogus-xyz hi no request reaches endpoint; stderr: Model 'totally-bogus-xyz' is not available … ✅ rejected, prompt not submitted
E cross-provider (suffix) /model claude-test(anthropic) hi no request; stderr: … can't switch providers … belongs to a different provider … ✅ rejected
F same-authType, diff endpoint /model other-endpoint-model hi (different baseUrl) no request; stderr: … can't switch providers … provider-identity gate holds (the security-relevant case)

3. Interactive TUI E2E (tmux, one live session) — the auto-revert

Turn Input Foreground wire model TUI rendered Footer indicator
1 /model override-model … MARK_T1 override-model ✦ REPLY_FROM[override-model] stayed · Session
2 plain … MARK_T2 session-model (auto-reverted) ✦ REPLY_FROM[session-model] · Session
3 /model override-model (no prompt) — (switch) ● Model: override-model flipped to · Override, model.name persisted

Runtime debug log corroborates the lifecycle exactly:

model override set to override-model (inline=true)   ← turn 1 (inline command)
model override cleared (inline=false)                ← turn 2 (next user turn → auto-revert)

Turn 1 = one-shot (no session switch, indicator unchanged); Turn 3 = bare /model <id> still switches & persists ⇒ the if (inlinePrompt) guard preserves existing behavior.

4. Tool-loop continuation E2E (the design rationale)

Forced a list_directory tool call on the override turn, then text on the continuation:

#0 stage=initial       model=override-model   (returns tool_call)
#1 stage=continuation   model=override-model   (after tool result fed back)  ✅
final: DONE_AFTER_TOOL[override-model]

The override persists across the entire tool-call loop and does not revert mid-turn — confirming the per-turn modelOverride design (vs switchModel()+revert) behaves as claimed.

Verdict

The 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(3ec156f3f构建了真实打包二进制,针对一个记录"真正发到线上的 model 字段"的伪 OpenAI 端点(client.tsoptions?.modelOverride ?? config.getModel()),端到端验证了 /model <id> <prompt> 内联一次性覆盖。PR 描述里的每条核心声明都复现成功,未发现行为偏差

证明跑的是本 PR 的代码(而非 main):运行时 debug 日志打出了 model override set to override-model (inline=true) —— (inline=${isInline}) 模板只存在于本 PR 的 applyModelOverrideorigin/main 上没有。

环境

  • worktree 在 PR head 3ec156f3forigin/main 合入 feat/inline-model-override),Node v22.22.2,macOS。
  • npm ci + npm run bundle → 真实 dist/cli.js(v0.19.3);npm run typecheck ✅(全 workspace)。
  • 隔离 HOME + modelProviders.openai 目录;两个模型(默认 session-model + override-model)共享同一 baseUrl+envKey(同 provider 身份),另加用于拒绝用例的诱饵模型。

1. 单元/组件测试(head 上 vitest)—— 176 通过

套件 结果 覆盖
modelCommand.test.ts 53 ✅ 内联解析、不切换/不持久化、非法 id、跨 provider、同端点放行、ACP 拒绝
acpModelUtils.test.ts 27 ✅ isInlineModelOverrideAllowed 身份门(baseUrl/envKey/fast-voice/未知)
nonInteractiveCliCommands.test.ts 37 ✅ submit_promptmodelOverride 透传/省略
useGeminiStream.test.tsx(内联块) 2 ✅ skill 工具的 undefined 不能覆盖生效中的内联;retry 清掉内联→回会话模型
loggers.test.ts 57 ✅ UserPromptEvent.model 遥测字段

2. 非交互 E2E(真实二进制 → 伪端点)

每行是线上实际观测到的 model

用例 命令 线上结果 结论
A 覆盖生效 /model override-model … MARK_A 前台=override-model;后台 memory-extractor 子代理=session-model ✅ 只作用于这一次性轮;不泄漏到后台子代理
A 不持久化 (A 之后)settings.model.name 仍是 session-model ✅ 会话默认未变
C 基线 plain prompt …(无斜杠) 前台=session-model
D 非法 id /model totally-bogus-xyz hi 没有请求到达端点;stderr:Model 'totally-bogus-xyz' is not available … ✅ 拒绝,prompt 未提交
E 跨 provider(后缀) /model claude-test(anthropic) hi 没有请求;stderr:… can't switch providers … belongs to a different provider … ✅ 拒绝
F 同 authType、不同端点 /model other-endpoint-model hi(不同 baseUrl 没有请求;stderr:… can't switch providers … provider 身份门生效(安全相关用例)

3. 交互 TUI E2E(tmux,同一个活会话)—— 自动回退

轮次 输入 前台线上 model TUI 渲染 底部指示符
1 /model override-model … MARK_T1 override-model ✦ REPLY_FROM[override-model] 保持 · Session
2 普通 … MARK_T2 session-model(自动回退) ✦ REPLY_FROM[session-model] · Session
3 /model override-model(无 prompt) —(切换) ● Model: override-model 翻成 · Overridemodel.name 已持久化

运行时 debug 日志完全印证生命周期:

model override set to override-model (inline=true)   ← 第 1 轮(内联命令)
model override cleared (inline=false)                ← 第 2 轮(下一个用户轮 → 自动回退)

第 1 轮 = 一次性(不切换会话、指示符不变);第 3 轮 = 裸 /model <id> 仍切换并持久化 ⇒ if (inlinePrompt) 守卫保住了原有行为。

4. 工具调用续接 E2E(该设计的核心理由)

在覆盖轮强制一次 list_directory 工具调用,续接轮返回文本:

#0 stage=initial       model=override-model   (返回 tool_call)
#1 stage=continuation   model=override-model   (工具结果回灌之后)  ✅
最终:DONE_AFTER_TOOL[override-model]

覆盖贯穿整个工具调用循环、轮内不会中途回退 —— 印证了"按轮 modelOverride"设计(而非 switchModel()+回退)行为符合声明。

结论

实现与描述完全一致:内联形式让单轮(含工具调用续接)跑在目标模型上、下一个用户轮自动回退、绝不切换/持久化会话模型、不泄漏到后台子代理、并对非法 id / 跨 provider / 同 id 不同端点目标用清晰消息拒绝。构建、typecheck、所有受影响单元套件均通过。从行为验证角度 LGTM。

Verification harness: real bundled binary + fake OpenAI endpoint recording the wire model + tmux-driven TUI; not affiliated with any model/vendor. Build & tooling run locally by the maintainer.

@wenshao
wenshao added this pull request to the merge queue Jun 30, 2026
@wenshao

wenshao commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

✅ Maintainer local verification — real-binary E2E (interactive TUI + non-interactive)

I built the actual PR head (3ec156f3f) into a real qwen bundle (npm ci → build core → npm run bundle, v0.19.3) and exercised it against a fake OpenAI endpoint that records the model field of every outgoing request, in both qwen -p (non-interactive) and a live interactive TUI driven through tmux. This closes the two gaps the PR flagged as “not validated”: the interactive TUI before/after capture, and the tool-call-continuation claim.

Setup — a same-provider modelProviders.openai registry with three models, all envKey: OPENAI_API_KEY:

  • model-alpha → fake server (session default)
  • model-beta → fake server (valid inline-override target)
  • model-gammadifferent baseUrl (same auth type, different provider identity → must be rejected)

The fake server echoes the received model back as the reply (REPLY_FROM_MODEL[<model>]) and independently logs each request's model server-side; both agree on every turn, so the request model is authoritative ground truth.

Results

# Behavior Expected Observed
1 Baseline -p "prompt" session model request → model-alpha
2 /model model-beta <prompt> (one-shot) override turn on beta foreground request → model-beta; reply REPLY_FROM_MODEL[model-beta]
3 Next plain turn auto-revert to session request → model-alpha
4 Override does not leak to background work session model suggestion + memory-extractor subagents stayed on model-alpha
5 No session mutation / no persistence unchanged header stays Alpha; settings.json model.name unchanged
6 Bare /model model-beta (no prompt) switch session model ● Model: model-beta, header/footer → Beta (persists)
7 Revert targets the current session model (not hardcoded) session=Beta → override alpha → revert to Beta override turn → alpha; next plain turn → model-beta
8 Tool-call continuation keeps the override both requests of the loop on override model tool-initialalpha and tool-continuationalpha (✓ Listed 1 directory, then REPLY_FROM_MODEL[model-alpha])
9 Invalid id /model bogus hi error, no switch, no call ✕ Model 'bogus' is not available … Available models: …; 0 requests
10 Cross-provider /model model-gamma hi error → two-step flow, no call ✕ Inline one-shot override can't switch providers … run '/model model-gamma' first; 0 requests

Key evidence

Inline override + auto-revert (interactive TUI):

> /model model-beta reply with exactly: OVERRIDE_TURN_AAA
✦ REPLY_FROM_MODEL[model-beta]          # request #0 model=model-beta
> reply with exactly: REVERT_TURN_BBB
✦ REPLY_FROM_MODEL[model-alpha]         # request #3 model=model-alpha (reverted)
# background subagents in between: model=model-alpha (no leak)

Revert targets the session model, not a constant — with the session switched to Beta, a one-shot /model model-alpha … ran exactly one turn on alpha, background subagents stayed on Beta, and the next plain turn reverted to Beta (request #3 model=model-beta); header remained Beta throughout.

Override survives the tool loop (the central design claim):

#0 model=model-alpha kind=tool-initial        # emitted list_directory
#1 model=model-alpha kind=tool-continuation    # tool result fed back, still alpha
#2 model=model-beta  (background suggestion)   # session model

Cross-provider rejection is by provider identity, not just auth type: model-gamma is registered under the same openai auth type but a different baseUrl; the inline form is correctly rejected (the per-turn override reuses the active endpoint/credentials and can't rebuild them), while the two-step /model <id> is still offered.

Tests / static checks (on the worktree at PR head)

  • 326 unit tests pass across the changed files: modelCommand 53, acpModelUtils 27, nonInteractiveCliCommands 37, useGeminiStream 152, loggers 57. The hardest edges are covered there: “clears the inline override on retry and reverts to the session model” and “a skill tool with modelOverride: undefined does not clobber an active inline override”.
  • tsc --noEmit clean for core and cli; eslint clean on all 11 changed source files; check-i18n passes (en.js base, zh/zh-TW in sync).
  • CI Test (ubuntu-latest) leg is green.

Conclusion

Every 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 当前 head3ec156f3f)构建成真实的 qwen 产物(npm ci → 构建 core → npm run bundle,v0.19.3),并针对一个会记录每个外发请求 model 字段的伪 OpenAI 端点,在 qwen -p(非交互)和通过 tmux 驱动的真实交互式 TUI两种模式下进行了验证。这覆盖了 PR 自述中标为「未验证」的两点:交互式 TUI 的前后对比,以及工具调用续轮(tool-call continuation)的承载。

环境 — 一个同 provider 的 modelProviders.openai 注册表,三个模型,envKey 都是 OPENAI_API_KEY

  • model-alpha → 伪服务器(会话默认模型)
  • model-beta → 伪服务器(合法的内联覆盖目标)
  • model-gamma不同 baseUrl(同 auth 类型、不同 provider 身份 → 必须被拒绝)

伪服务器把收到的 model 原样回显成回复(REPLY_FROM_MODEL[<model>]),同时在服务端独立记录每个请求的 model;两者在每一轮都一致,因此请求里的 model 就是权威依据。

结果

# 行为 期望 实测
1 基线 -p "prompt" 会话模型 请求 → model-alpha
2 /model model-beta <prompt>(一次性) 该轮跑在 beta 前台请求 → model-beta回复 REPLY_FROM_MODEL[model-beta]
3 下一条普通输入 自动回退到会话模型 请求 → model-alpha
4 覆盖泄漏到后台任务 会话模型 输入建议、记忆抽取子代理都仍用 model-alpha
5 不改会话模型 / 不落盘 不变 顶栏仍是 Alphasettings.jsonmodel.name 不变
6 /model model-beta(无 prompt) 切换会话模型 ● Model: model-beta,顶栏/底栏 → Beta(持久)
7 回退到当前会话模型(非写死) 会话=Beta → 覆盖到 alpha → 回退到 Beta 覆盖轮 → alpha;下一条普通输入 → model-beta
8 工具调用续轮保持覆盖 整个工具循环都在覆盖模型上 tool-initialalpha tool-continuationalpha✓ Listed 1 directory,随后 REPLY_FROM_MODEL[model-alpha]
9 非法 id /model bogus hi 报错、不切换、不发请求 ✕ Model 'bogus' is not available … Available models: …0 个请求
10 跨 provider /model model-gamma hi 报错并指向两步法、不发请求 ✕ Inline one-shot override can't switch providers … run '/model model-gamma' first0 个请求

关键证据

内联覆盖 + 自动回退(交互式 TUI):

> /model model-beta reply with exactly: OVERRIDE_TURN_AAA
✦ REPLY_FROM_MODEL[model-beta]          # 请求 #0 model=model-beta
> reply with exactly: REVERT_TURN_BBB
✦ REPLY_FROM_MODEL[model-alpha]         # 请求 #3 model=model-alpha(已回退)
# 中间的后台子代理:model=model-alpha(无泄漏)

回退目标是会话模型而非常量 — 把会话切到 Beta 后,一次性 /model model-alpha … 只在 alpha 上跑了一轮,后台子代理仍在 Beta,下一条普通输入回退到了 Beta(请求 #3 model=model-beta);全程顶栏保持 Beta

覆盖跨越工具循环存活(核心设计主张):

#0 model=model-alpha kind=tool-initial        # 发出 list_directory
#1 model=model-alpha kind=tool-continuation    # 工具结果回灌后仍是 alpha
#2 model=model-beta  (后台输入建议)           # 会话模型

跨 provider 的拒绝判据是 provider 身份,而非仅 auth 类型: model-gamma 注册在相同openai auth 类型下但 baseUrl 不同;内联形式被正确拒绝(每轮覆盖复用当前端点/凭据、无法为它重建),同时仍提示可用两步式 /model <id>

测试 / 静态检查(在 PR head 的 worktree 上)

  • 326 个单测通过,覆盖改动文件:modelCommand 53、acpModelUtils 27、nonInteractiveCliCommands 37、useGeminiStream 152、loggers 57。最难的边界都在其中:「retry 时清除内联覆盖并回退到会话模型」modelOverride: undefined 的 skill 工具不会顶掉正在生效的内联覆盖」
  • tsc --noEmitcorecli 均干净;eslint 在全部 11 个改动源文件上干净;check-i18n 通过(en.js 为基准,zh/zh-TW 同步)。
  • CI 的 Test (ubuntu-latest) 这一腿为绿。

结论

所有自述行为都在真实二进制上复现,包括描述里标为「未验证」的两项(交互式 TUI A/B 与工具调用续轮)。覆盖作用于该轮及其所有工具调用续轮、绝不泄漏到后台子代理、回退到当前会话模型、且从不改动或持久化会话状态;非法/跨 provider 输入被拒绝且不产生任何外发请求。未发现回归或缺陷。同意合并(v1 同 provider 范围)。

Verified at head 3ec156f3f. Methodology: real bundled qwen + a fake OpenAI server logging each request's model, driven via qwen -p and a tmux-driven interactive TUI.

@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Jun 30, 2026
@wenshao
wenshao added this pull request to the merge queue Jun 30, 2026
Merged via the queue into QwenLM:main with commit 52fdc10 Jun 30, 2026
36 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

category/cli Command line interface and interaction scope/commands Command implementation scope/model-switching Model selection and switching type/feature-request New feature or enhancement request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Support inline model switching using (/model <model-id> <prompt>) command

4 participants