fix(cli): keep model switches session-scoped#6579
Conversation
Local verification reportI verified this PR with a real local CLI run using a temporary Real CLI behavior checkTemporary settings started with: {
"model": { "name": "gpt-4o-mini" },
"security": { "auth": { "selectedType": "openai" } }
}Session-scoped switch: $ QWEN_HOME="$tmp" OPENAI_API_KEY=dummy npm run dev -- /model gpt-4o --auth-type openai --output-format text
Model: gpt-4oResult: Explicit default switch: $ QWEN_HOME="$tmp" OPENAI_API_KEY=dummy npm run dev -- -p "/model --default gpt-4o" --auth-type openai --output-format text
Model: gpt-4o (default)Result: {
"model": { "name": "gpt-4o", "baseUrl": "" },
"security": { "auth": { "selectedType": "openai" } }
}Note: for the second command I used Automated checks$ cd packages/cli && npx vitest run src/ui/commands/modelCommand.test.ts src/ui/components/ModelDialog.test.tsx src/ui/hooks/slashCommandProcessor.test.ts
Test Files 3 passed (3)
Tests 186 passed (186)$ npm run build
# passed$ npm run typecheck
# passedScreenshot / visual evidenceThis change is command/settings behavior rather than a visual UI rendering change, so I did not include a screenshot. The CLI transcript above is the relevant before/after evidence. 中文验证报告本地验证报告我使用临时 真实 CLI 行为验证临时 settings 初始内容为: {
"model": { "name": "gpt-4o-mini" },
"security": { "auth": { "selectedType": "openai" } }
}会话级切换: $ QWEN_HOME="$tmp" OPENAI_API_KEY=dummy npm run dev -- /model gpt-4o --auth-type openai --output-format text
Model: gpt-4o结果: 显式默认值切换: $ QWEN_HOME="$tmp" OPENAI_API_KEY=dummy npm run dev -- -p "/model --default gpt-4o" --auth-type openai --output-format text
Model: gpt-4o (default)结果: {
"model": { "name": "gpt-4o", "baseUrl": "" },
"security": { "auth": { "selectedType": "openai" } }
}说明:第二条命令使用了 自动化检查$ cd packages/cli && npx vitest run src/ui/commands/modelCommand.test.ts src/ui/components/ModelDialog.test.tsx src/ui/hooks/slashCommandProcessor.test.ts
Test Files 3 passed (3)
Tests 186 passed (186)$ npm run build
# passed$ npm run typecheck
# passed截图 / 可视化证据这个改动是命令和 settings 行为变化,不是 UI 渲染变化,所以没有附截图。上面的 CLI transcript 就是相关的 before/after 证据。 |
|
Thanks for the PR! Template looks good ✓ Problem: Observed bug with clear evidence. Issue #4331 documents that Direction: Aligned. The command's own description says "switch the model for this session" — persisting the switch silently is a UX bug. Adding Size: Not applicable — all changes are in Approach: Scope feels right. The Moving on to code review. 🔍 中文说明感谢贡献! 模板完整 ✓ 问题:已观测到的 bug,有明确证据。Issue #4331 记录了 方向:对齐。命令自己的描述说"为当前会话切换模型"——默默持久化是一个 UX bug。添加 规模:不适用——所有改动都在 方案:范围合理。 进入代码审查 🔍 — Qwen Code · qwen3.7-max |
Code ReviewIndependent proposal before reading the diff: gate The PR's approach matches this exactly — No critical blockers found. No AGENTS.md violations. The code follows existing conventions — regex flag parsing mirrors Reuse check: no missed reuse opportunities. Flag parsing, state threading, and dialog props all follow established patterns in the codebase. Test ResultsFocused unit tests (PR code, worktree)CLI verification (PR code via
|
|
This is a clean, well-scoped fix for a genuine UX bug. The The implementation matches what I'd have done independently: add The focused test suite (186 tests across 3 files) covers the new flag parsing, the conditional persistence, the mutual exclusion with auxiliary model flags, and the dialog threading. The author's own CLI verification shows the behavior working correctly end-to-end. The breaking change is intentional and well-documented: users who relied on plain Approving. ✅ 中文说明这是一个干净、范围明确的修复,解决了一个真实的 UX bug。 实现方案与我独立构思的完全一致:添加 聚焦测试套件(3 个文件中的 186 个测试)覆盖了新的标志解析、条件持久化、与辅助模型标志的互斥,以及对话框传递。作者自己的 CLI 验证展示了端到端行为的正确性。 破坏性变更是有意的且有充分记录的:之前依赖普通 批准。✅ — Qwen Code · qwen3.7-max |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Two additional Critical findings could not be anchored to diff lines:
[Critical] --default silently dropped with inline prompt (modelCommand.ts:830-871): /model --default qwen-max some prompt silently ignores --default. The inline prompt path checks scopeOverride (rejecting it) but never checks persistDefault. Add a guard analogous to the scopeOverride check before the submit_prompt return.
— qwen3.7-max via Qwen Code /review
| } | ||
|
|
||
| if (!settings) { | ||
| if (persistDefault && !settings) { |
There was a problem hiding this comment.
[Critical] Voice/fast/vision model !settings guard incorrectly relaxed.
persistDefault is always false here because --default --voice is rejected by the earlier mutual-exclusion guard. This makes persistDefault && !settings dead code. But persistSetting(settings, 'voiceModel', ...) is called unconditionally downstream — when settings is undefined, this crashes with a TypeError.
The original unconditional if (!settings) was correct here since voice/fast/vision models always persist regardless of --default.
| if (persistDefault && !settings) { | |
| if (!settings) { |
— qwen3.7-max via Qwen Code /review
| settings, | ||
| authType, | ||
| modelName, | ||
| persistDefault, |
There was a problem hiding this comment.
[Critical] The caller's !settings guard at line 877 is unconditional, but switchMainModel only accesses settings inside if (persistDefault) blocks. A session-only /model <id> (no --default) in an environment where settings is unavailable will error with "Settings service not available" even though it no longer needs settings.
| persistDefault, | |
| if (persistDefault && !settings) { |
— qwen3.7-max via Qwen Code /review
|
|
||
| const result = await modelCommand.action!(mockContext, 'qwen-max'); | ||
|
|
||
| expect(switchModel).toHaveBeenCalledWith( |
There was a problem hiding this comment.
[Critical] Missing test coverage for several --default code paths:
--default --fast/--default --voice/--default --visionrejection (mutual-exclusion guard atmodelCommand.ts:468)- Bare
/model --default(no model, no scope) opening the dialog with{ persistDefault: true } --defaultwith auth-qualified model (e.g.,openai:gpt-4) which exercises thesecurity.auth.selectedTypepersistence branch
Suggested tests:
it('should reject --default combined with --fast', async () => {
const ctx = setupContext();
const result = await modelCommand.action!(ctx, '--default --fast');
expect(result).toMatchObject({
type: 'message',
messageType: 'error',
content: expect.stringContaining('--default only applies to the main model'),
});
});
it('should open the model dialog with persistDefault for /model --default', async () => {
const ctx = setupContext();
const result = await modelCommand.action!(ctx, '--default');
expect(result).toEqual({
type: 'dialog',
dialog: 'model',
persistDefault: true,
});
});— qwen3.7-max via Qwen Code /review
Suggestions — commit
|
| File | Issue | Suggested fix |
|---|---|---|
packages/cli/src/ui/commands/modelCommand.ts:465 vs ModelDialog.tsx:244-250 |
Confirmation message inconsistency across command and dialog paths. Command path shows (this project) for --default --project (missing "default"), and no suffix at all for session-only /model <id>. Dialog path shows (this project default) and (current session) respectively. Same user operations, different feedback. |
Use a shared formatPersistSuffix(scope, isDefault) helper, or align both paths to show (this project default) / (global default) / (current session) consistently. |
packages/cli/src/ui/contexts/UIActionsContext.tsx:56-60 |
openModelDialog type missing persistScope and persistDefault params that useModelCommand and slashCommandProcessor both declare. |
Add persistScope?: 'workspace' | 'user' and persistDefault?: boolean to the context type. |
packages/cli/src/ui/commands/modelCommand.ts:904 |
Non-interactive help text drops --project/--global documentation — old text listed scope flags, new text only mentions --default and --fast. |
Add "/model --default --project <id>" and "/model --default --global <id>" to the help string. |
packages/cli/src/i18n/locales/*.js |
New i18n keys untranslated: ' (this project default)', ' (global default)', ' (current session)', 'Set Default Model', --default error messages, and completion description absent from most locale files (fr, ru, de, ca, pt, ja). |
Add translations for all non-English locale files. |
packages/cli/src/ui/commands/modelCommand.ts:900 |
/model --default in non-interactive mode with no model id is a silent no-op — flag consumed with zero effect and no error. |
Return error: "--default requires a model id in non-interactive mode." |
packages/cli/src/ui/commands/modelCommand.test.ts |
Missing test: bare /model --default (no scope flag, no model id) should open dialog with persistDefault: true. |
Add test asserting { type: 'dialog', dialog: 'model', persistDefault: true }. |
packages/cli/src/ui/components/ModelDialog.test.tsx |
Missing test: persistDefault: true + persistScope: 'workspace' combination (the (this project default) suffix path). |
Add test rendering with both props and asserting suffix text. |
packages/cli/src/ui/hooks/useModelCommand.ts:33-36 |
Missing test: modelDialogPersistDefault state tracking through openModelDialog({ persistDefault: true }) and closeModelDialog(). Existing test file has zero references to persistDefault. |
Add test asserting state defaults to false, is set to true on open, and resets on close. |
— qwen3.7-max via Qwen Code /review
doudouOUC
left a comment
There was a problem hiding this comment.
[Critical] --default silently dropped with inline prompt
/model --default qwen-max hello world parses persistDefault=true and extracts inlinePrompt="hello world", but the inline prompt path (around line 831) only checks scopeOverride to reject --project/--global — it never checks persistDefault. The function returns { type: 'submit_prompt', modelOverride: 'qwen-max', content: 'hello world' }, producing a one-shot override with no persistence and no error.
A user who runs /model --default gpt-4 explain this codebase believes they have both persisted the model as default AND sent the prompt. Neither happens — only the one-shot override runs. The user discovers the persistence didn't take effect only when they start a new session.
This is the same class of silent-ignore bug that the existing scopeOverride guard was explicitly added to prevent. The fix is to add an analogous persistDefault guard inside the if (inlinePrompt) block:
if (persistDefault) {
return {
type: 'message',
messageType: 'error',
content: t(
"Cannot combine --default with an inline prompt. Run '/model --default {{model}}' first, then send your prompt.",
{ model: modelName },
),
};
}— qwen3.7-max via Qwen Code /review
Suggestions — commit
|
| File | Issue | Suggested fix |
|---|---|---|
packages/cli/src/ui/commands/modelCommand.ts:465 |
Confirmation message suffix inconsistency: defaultSuffix = scopeSuffix || t(' (default)') drops the word "default" when a scope flag is present. /model --default --project qwen-max shows "Model: qwen-max (this project)" — indistinguishable from a non-default scoped switch. Meanwhile ModelDialog.tsx:243-249 correctly produces " (this project default)" / " (global default)". |
Replace the fallback chain with explicit branches: const defaultSuffix = persistDefault ? (scopeOverride === SettingScope.Workspace ? t(' (this project default)') : scopeOverride === SettingScope.User ? t(' (global default)') : t(' (default)')) : ''; |
packages/cli/src/ui/commands/modelCommand.ts:915 |
Bare /model --default (no model id, no scope flag) dialog path untested. Tests cover --default --project and --default --global dialog returns, but not the plain --default invocation that should return { type: 'dialog', dialog: 'model', persistDefault: true }. This is the most common interactive "set default" flow. |
Add test: it('should open dialog with persistDefault for bare /model --default', ...) asserting the dialog return includes persistDefault: true. |
— qwen3.7-max via Qwen Code /review
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no new blockers beyond the three open Critical threads. Suggestion-level recommendations are in the Suggestion summary comment below.
Follow-up validation updateI pushed a small follow-up commit ( Validated locally with Node cd packages/cli
PATH="$HOME/.nvm/versions/node/v22.23.1/bin:$PATH" npx vitest run src/ui/commands/modelCommand.test.ts src/i18n/mustTranslateKeys.test.tsResult: Additional repository checks: PATH="$HOME/.nvm/versions/node/v22.23.1/bin:$PATH" npm run build
PATH="$HOME/.nvm/versions/node/v22.23.1/bin:$PATH" npm run typecheckResult: both passed. Notes from broader local testing: No screenshot is attached for this follow-up because the change is CLI command semantics plus locale coverage; the reproducible command transcript above is the relevant evidence. 中文验证报告已推送一个小的跟进提交( 本地使用 Node cd packages/cli
PATH="$HOME/.nvm/versions/node/v22.23.1/bin:$PATH" npx vitest run src/ui/commands/modelCommand.test.ts src/i18n/mustTranslateKeys.test.ts结果: 额外仓库检查: PATH="$HOME/.nvm/versions/node/v22.23.1/bin:$PATH" npm run build
PATH="$HOME/.nvm/versions/node/v22.23.1/bin:$PATH" npm run typecheck结果:均通过。 全量测试说明:也尝试运行了 本次跟进没有截图,因为改动是 CLI 命令语义和 locale 覆盖;上面的可复现命令输出是更直接的验证证据。 |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no new blockers beyond the three open Critical threads. Suggestion-level recommendations are in the Suggestion summary comment below.
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no new blockers beyond the three open Critical threads. Suggestion-level recommendations are in the Suggestion summary comment below.
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no new blockers beyond the three open Critical threads. Suggestion-level recommendations are in the Suggestion summary comment below.
✅ Maintainer local verification — merge referenceI built and exercised this PR locally against a real CLI bundle (not just unit tests) to confirm the behavior change. Everything checks out — recommend merge.
Checks
The fix, before → afterTo make sure this is a real behavioral change and not just a test-only diff, I reproduced issue #4331 on
I also confirmed the no-leak end-to-end: after a session-scoped Full scenario matrixNotable points I specifically checked:
How to reproducegit fetch origin pull/6579/head && git checkout FETCH_HEAD
npm ci && npm run bundle
export QWEN_HOME=$(mktemp -d) OPENAI_API_KEY=sk-dummy
cat > "$QWEN_HOME/settings.json" <<'JSON'
{ "security": { "auth": { "selectedType": "openai" } },
"model": { "name": "gpt-4o-mini" },
"modelProviders": { "openai": [
{ "id": "gpt-4o", "envKey": "OPENAI_API_KEY", "baseUrl": "https://api.openai.com/v1" },
{ "id": "gpt-4o-mini", "envKey": "OPENAI_API_KEY", "baseUrl": "https://api.openai.com/v1" } ] } }
JSON
node dist/cli.js -p "/model gpt-4o" # session only -> settings.json unchanged
node dist/cli.js -p "/model --default gpt-4o" # opt-in persist -> model.name = gpt-4oVerdictThe change does exactly what the description says, is well guarded, keeps auxiliary-model behavior intact, and is covered by the updated unit tests. LGTM ✅ 中文版本(点击展开)✅ 维护者本地验证 —— 合并参考我在本地对着真实的 CLI 产物(不仅仅是单元测试)构建并实际运行了这个 PR,确认行为变更符合预期。全部通过 —— 建议合并。
检查项
修复效果:修改前 → 修改后为确认这是真实的行为变更、而不只是测试层面的改动,我先在 (截图见上方英文部分)
我还端到端确认了“无泄漏”:在一次会话级 完整场景矩阵(截图见上方英文部分) 我特别检查的要点:
复现方式git fetch origin pull/6579/head && git checkout FETCH_HEAD
npm ci && npm run bundle
export QWEN_HOME=$(mktemp -d) OPENAI_API_KEY=sk-dummy
# settings.json: model.name = gpt-4o-mini + openai modelProviders (gpt-4o / gpt-4o-mini)
node dist/cli.js -p "/model gpt-4o" # 仅会话 -> settings.json 不变
node dist/cli.js -p "/model --default gpt-4o" # 显式持久化 -> model.name = gpt-4o结论改动完全符合描述,保护逻辑完善,辅助模型行为保持不变,并有更新后的单元测试覆盖。LGTM ✅ |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline.
| // undefined write is dropped from JSON and would not override on merge). | ||
| persistSetting(settings, 'model.baseUrl', '', scopeOverride); | ||
| if (persistDefault) { | ||
| if (!settings) { |
There was a problem hiding this comment.
[Suggestion] Dead code: the if (!settings) { throw new Error(...) } guards inside the if (persistDefault) blocks (lines 137-139 and 155-157) are currently unreachable. The sole caller at line 877 unconditionally checks if (!settings) before calling switchMainModel, so the function is never reached with undefined settings.
If the caller's guard is fixed to if (persistDefault && !settings) (as suggested for the open Critical at line 877/889), these internal guards become necessary — they align with the function's declared contract (settings: LoadedSettings | undefined). Consider keeping them as defensive code with a comment, or removing them and narrowing the parameter type to LoadedSettings if the caller's unconditional guard is the intended final design.
— qwen3.7-max via Qwen Code /review
| @@ -31,13 +33,16 @@ export const useModelCommand = (): UseModelCommandReturn => { | |||
| const [modelDialogPersistScope, setModelDialogPersistScope] = useState< | |||
There was a problem hiding this comment.
[Suggestion] The hook gained modelDialogPersistDefault state and a persistDefault option on openModelDialog(), plus reset logic in closeModelDialog(). The existing test file has no coverage for: (a) openModelDialog({ persistDefault: true }) setting modelDialogPersistDefault to true, (b) closeModelDialog() resetting it to false, or (c) it defaulting to false. If this state wiring breaks, the dialog would silently lose its persist-default behavior.
Similarly, ModelDialog.tsx has no test for the title changing to "Set Default Model" when persistDefault is true (line 880), or for the (current session) confirmation suffix for non-persist selections (lines 241-249).
— qwen3.7-max via Qwen Code /review
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline.
| @@ -852,7 +904,7 @@ export const modelCommand: SlashCommand = { | |||
| type: 'message', | |||
There was a problem hiding this comment.
[Suggestion] Non-interactive /model --default without a model ID silently falls through to the "Current model" info message. The --default flag is ignored with no error or guidance. In CI/CD or scripted use, a user expecting --default to take effect gets no indication it was dropped.
Consider adding a guard before the non-interactive fallback:
if (persistDefault) {
return {
type: 'message',
messageType: 'error',
content: t('--default requires a model ID in non-interactive mode. Use: /model --default <model-id>'),
};
}— qwen3.7-max via Qwen Code /review
| type: 'message', | ||
| messageType: 'info', | ||
| content: t('Model') + ': ' + effectiveModelName + scopeSuffix, | ||
| content: t('Model') + ': ' + effectiveModelName + defaultSuffix, |
There was a problem hiding this comment.
[Suggestion] The CLI confirmation for /model <id> shows Model: qwen-max with no suffix indicating the change is session-only. The model dialog correctly shows "(current session)" for the same scenario. Users migrating from the old persistent behavior get no visual feedback that their switch is ephemeral.
Consider adding a session-only suffix:
const defaultSuffix = persistDefault
? scopeSuffix || t(' (default)')
: t(' (this session)');— qwen3.7-max via Qwen Code /review
| @@ -434,6 +462,19 @@ export const modelCommand: SlashCommand = { | |||
| : scopeOverride === SettingScope.User | |||
| ? t(' (global)') | |||
There was a problem hiding this comment.
[Suggestion] Inconsistent confirmation labels between command and dialog paths. The command path shows (this project) / (global) while the dialog shows (this project default) / (global default) for the same operation with the same flags. Users see different messages depending on which UI path they take.
Consider aligning the command path labels with the dialog:
const defaultSuffix = persistDefault
? scopeOverride === SettingScope.Workspace
? t(' (this project default)')
: scopeOverride === SettingScope.User
? t(' (global default)')
: t(' (default)')
: '';— qwen3.7-max via Qwen Code /review
| : ''; | ||
| const defaultSuffix = persistDefault ? scopeSuffix || t(' (default)') : ''; | ||
| if ( | ||
| persistDefault && |
There was a problem hiding this comment.
[Suggestion] The mutual-exclusion guard rejecting --default combined with --fast, --voice, or --vision has no test coverage. If the regex or condition logic were wrong (e.g., accidentally matching --default-model or missing --vision), no test would catch it.
Consider adding tests for each rejected combination:
it('should reject --default --fast', async () => { ... });
it('should reject --default --voice', async () => { ... });
it('should reject --default --vision', async () => { ... });— qwen3.7-max via Qwen Code /review
| @@ -835,12 +886,13 @@ export const modelCommand: SlashCommand = { | |||
| settings, | |||
There was a problem hiding this comment.
[Critical] --default is silently dropped when combined with an inline prompt. /model --default qwen-max "hello" parses persistDefault=true, but the inline-prompt branch (line ~830) only rejects scopeOverride — there is no equivalent check for persistDefault. The function returns submit_prompt before reaching switchMainModel, so the persistence intent is silently discarded.
This is the same category of silent flag drop that the scopeOverride guard (line 830) was designed to prevent. Add a persistDefault check inside the if (inlinePrompt) block:
| settings, | |
| if (persistDefault) { | |
| return { | |
| type: 'message', | |
| messageType: 'error', | |
| content: t( | |
| "Cannot combine --default with an inline prompt. Run '/model --default {{model}}' first, then send your prompt.", | |
| { model: modelName }, | |
| ), | |
| }; | |
| } | |
| if (scopeOverride) { |
— qwen3.7-max via Qwen Code /review
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline.
| @@ -1419,6 +1419,8 @@ export default { | |||
| '切换此会话的模型(--fast 可设置建议模型,--voice 可设置语音转写模型,[model-id] 可立即切换)', | |||
There was a problem hiding this comment.
[Suggestion] ~10 new t() strings introduced by this PR are untranslated in both zh.js and zh-TW.js. Affected keys include: 'Set Default Model', ' (default)', ' (this project default)', ' (global default)', ' (current session)', '--default only applies to the main model. Use --fast, --voice, or --vision without --default.', 'Use --default with --project or --global when persisting the main model.', 'Settings service not available.', and the updated help text referencing --default. Chinese-speaking users will see raw English fallback strings for these keys.
— qwen3.7-max via Qwen Code /review
CI Failure AnalysisRoot CauseThe PR adds a Failing Step
FixRemove the old translation key (the one without
The new key (with SummarySimple stale-key issue: the PR correctly added the new translation but forgot to delete the old one that was replaced when 🇨🇳 中文分析CI 失败分析根本原因PR 为 失败的步骤
修复方式从两个 locale 文件中删除旧的翻译 key(不含
新的 key(包含 总结典型的残留 key 问题:PR 正确添加了新翻译,但忘记删除旧翻译——旧翻译在 |
✅ 双跑验证报告 (BASE vs PR)用 真实 TUI(tmux)对 环境:隔离 结果
A 是这个 PR 的价值证明:在 BASE 上,用户只是想临时换个模型,结果永久改掉了自己的全局默认配置。B/C/D 证明修复精确命中,且没有把持久化能力弄坏。 一处未能覆盖(如实说明)模型选择器(picker)路径未验证。 PR 描述称「ordinary model-picker selections only update the active session」,但在我的 两边行为一致,所以这不是 PR 引入的回归 —— 看起来 picker 的模型查找走的是 auth-type 注册表而非 只跑 PR 单边的话,我会把这个错误误报成回归。 这正是双跑的意义。但也意味着 picker 这半条入口没有被我的验证覆盖,建议作者或 reviewer 用一个 picker 能正常工作的配置补验一次。 复现方法# 两侧各自构建后:
HOME=<隔离home> OPENAI_API_KEY=sk-dummy node <dist>/cli.js
# TUI 内输入 /model qwen3-coder-flash
# 然后 diff <隔离home>/.qwen/settings.json验证方式:tmux 驱动真实 TUI,BASE/PR 双跑对照,观测 settings.json 实际写入。 |
… compute the verdict (QwenLM#6892) * fix(review): prove coverage on both topologies, and check the prompt survived the trip Three defects, all measured against the harness's own transcripts of real /review runs against QwenLM/qwen-code PRs QwenLM#6766 (Step 3B) and QwenLM#6579 (Step 3A). 1. Step 3A reviews were told nobody had read them. Coverage was attributed by one question: did an agent whose launch prompt says `chunk N of M` make a successful tool call? No Step 3A prompt says that — there every dimension agent walks the whole diff — so no chunk was ever attributed to anyone. Run against a real 3A review whose twelve agents each opened the diff, walked both chunks and filed findings, check-coverage returned Coverage: 0/2 chunk(s) reviewed. 16 agent(s) ran; 16 did work ERROR: 2 chunk(s) were not reviewed — 1, 2. Nobody read those lines. in one breath. compose-review runs the same computation on the way to the verdict, so a flawless small-PR review was capped away from Approve and the body it would have POSTED to the pull request said nobody had read it. Step 3A is the topology most pull requests get. The only reason it never blew up is that check-coverage lived inside Step 3B and was never reached from 3A — two bugs cancelling. Coverage is now the intersection of two things the harness wrote down: the lines each agent was pointed at (its launch prompt) and the fact that it opened the diff (a successful tool call naming the diff file). Topology-blind. It also no longer credits a chunk to an agent on the strength of any successful call — a glob for test files was enough. 2. The whole-diff agents were still launched blind. agent-prompt built the territory agents' prompts and left the other half of the fan-out to prose. All three whole-diff agents of the 3B run — cross-file tracer, test-coverage matrix, build & test — got a prompt naming no diff file at all. The test-coverage matrix was told to "Read the diff chunks" and given no path to read them from; it read the post-change source instead, which on a deletion shows it nothing. These agents own the classes a chunk agent is structurally blind to, and the gate could not see it: it only asked that question of agents whose prompt said `chunk N of M`. `agent-prompt --whole-diff` now builds their diff-reading block too. 3. The prompt the CLI built was rewritten on the way to the agent. The 3B run invoked agent-prompt correctly for all five chunks and then paraphrased what it printed: the delivered prompt dropped the rule against reciting a stock sentence, dropped the half-read warning, replaced the project's review rules with a three-sentence summary of its own, and invented an instruction that was never in the original. Nothing could see it, because a paraphrase keeps the diff path. So agent-prompt records what it emitted, at a path derived from the plan that the caller is never given; check-coverage reads it back against the launch prompt the harness recorded. Replayed against that run's real transcripts, all five chunk agents are now named. Verified end to end: a fresh /review of QwenLM#6829 (3A) calls `agent-prompt --whole-diff`, passes it verbatim, and Step 3D reports 2/2 chunks reviewed, 12 agents, 12 did work — a gate that path could not reach before, and could not have passed if it had. * fix(review): build every agent's prompt, from a roster the plan derives Two more failures, both measured against the harness's own transcripts of real /review runs. 1. Agent 0 was never launched, and nothing could tell. The skill says issue fidelity runs on every PR review. Dogfooded on QwenLM#6766, it did not run — and every check passed, because every check asks a question of an agent that RAN. An agent that does not run leaves no transcript to ask. An omission is invisible precisely because it is an omission. So `check-coverage` now derives a roster from the plan — which the caller does not write — and names every required agent that never ran, with the exact `agent-prompt` call that builds it. The plan already knew everything the roster turns on: the topology, whether the diff deletes anything, which files were rewritten heavily enough to need invariant agents, whether there is a worktree to build in and a pull request to check an issue against. `agent-prompt --role <role>` builds all of them: 0, 1a, 1b, 1c, 2, 3, 4, 5, 6a/6b/6c, 7, the test matrix, and the three invariant agents per heavy file. The briefs move out of SKILL.md and into code, because a brief the orchestrator retypes is a brief that drifts. 2. A 4 652-character prompt is not a thing an orchestrator will paste twelve times. With the briefs welded into the launch prompt, the first dogfood delivered **2 893** characters of one: it kept the head, added a preamble of its own, and cut nineteen hundred characters out of the middle. The delivery check caught it — and the run then read the check's exit-3, concluded "the agents clearly did their job", skipped `compose-review`, and filed an **Approve it had written itself**. A gate that always fails is a gate that gets talked around. So the brief goes where the diff already goes: on disk, read by the agent that needs it. The launch prompt drops to ~500-800 characters — it names the role, points at the brief file, and lists the diff reads — and whether the agent actually read its brief stops being a hope and becomes a tool call the harness wrote down (`unreadBriefs`). Verified end to end. A fresh /review of QwenLM#6847 built all twelve role prompts, and against the harness's transcripts: 12 of 12 delivered **byte-for-byte verbatim**, 12 of 12 **opened their brief**, 3-19 successful tool calls each — Agent 0 among them. Step 3D: `1/1 chunks reviewed, 12/12 agents did work`, no errors. * fix(review): the verdict is computed, not carried `compose-review` has computed the event and the body since the C/S table stopped being prose. The skill then told the orchestrator to "copy event/body verbatim into the review JSON" — a transcription, into a document the model writes, of a decision the CLI had already made. That is the exact anti-pattern `submit`'s own header repudiates, and it left two ways for a run to author its own verdict: - **The terminal.** Step 6's verdict was composed by the model, from prose rules. Dogfooded, a run read the coverage check's refusal, concluded that "the agents clearly did their job", never called `compose-review` at all, and printed `Review complete — Approve` on a review whose gate had just refused. - **The wire.** `submit` took `{event, body}` as fields. Nothing stopped a run that had skipped the computation from posting the conclusion it preferred. So `submit` composes. It takes the findings — the inline comments and the states Step 6 established — and derives everything that follows, including how many blockers there are: `criticalsInline` and `suggestionsInline` are counted off the `**[Critical]**` / `**[Suggestion]**` prefixes of the comments actually attached, not accepted as numbers beside them. (A number beside a list is a number that can disagree with the list, and one did: the breaching run posted a body reading "Suggestions are inline" next to an empty `comments` array and a summary claiming `0 Suggestion inline`.) A payload carrying `event`/`body` is refused rather than silently overruled — the caller was trying to author a verdict. Two body checks are deleted, not weakened: a body that promises inline comments it does not carry, and a body whose footer is preceded by a literal `\n`. Both were checks on a string the caller built. The caller no longer builds it. `compose-review` now prints the verdict line itself, and Step 6 prints that. There is one place a verdict exists; skipping the command does not get you a different one, it gets you none. Verified: a payload with `event: APPROVE` and an unreviewed dimension is refused at the wire; the same findings without a verdict compose to `COMMENT`. A fresh /review of QwenLM#6788 called `compose-review`, was told `Verdict: Comment`, and showed the user "Comment — downgraded from Approve (CI failing: route)" — the presubmit downgrade applied by code, on a run that did not post. * fix(review): make Step 3B carryable, and stop the delivery check crying wolf Dogfooding the Step 3B path — the one topology none of this had been run against — found two defects, and the second is the more important of the two. 1. Eighty-seven kilobytes of chunk prompts, in one response. The briefs moved onto disk for the dimension agents and not for the territory agents. Measured on PR QwenLM#6606 (5 511 diff lines, 17 chunks): 17 chunk launch prompts of ~5 149 characters each — **87 546 characters** the orchestrator was expected to paste unedited. At a twelfth of that load it had already cut nineteen hundred characters out of a single prompt. Chunk agents get the same split: the brief on disk, and a launch prompt that carries only what cannot live anywhere else — `chunk N of M`, which attributes the territory, and the `offset`/`limit`, which are the lines coverage proves were delivered. 87 546 → 14 789 characters. And `check-coverage` now asks the territory agents the same question it asks the others: did you open your brief? 2. The delivery check failed a correct run — all nine agents of it. It was a substring test: the built prompt had to appear in the launch prompt, contiguously. That is a stricter claim than the skill makes, and both of the differences it fired on were legitimate. The orchestrator had inserted **the one-sentence summary of the change that the skill explicitly tells it to add**, which breaks contiguity by construction — and it had reflowed a hard-wrapped sentence onto one line, which changes not one character of meaning. This is the failure this skill keeps re-learning, and this time it was ours: a gate that fires on a correct run is a gate that gets talked around, and there is a dogfood transcript of a model doing exactly that. The rule the check enforces is now the rule the skill states — **you may add; you may not remove, alter, or reorder** — over whitespace-collapsed lines, in order. Verified against the harness's transcripts of a real Step 3B review of QwenLM#6766: nine agents (five chunks, issue fidelity, cross-file tracer, test matrix, build & test), 9/9 delivered intact, 9/9 opened their brief, 6-22 successful tool calls each. `check-coverage`: 5/5 chunks, every list empty, exit 0. * feat(review): path-scoped rules; and move the briefs out of the skill, where they were never reaching the agents Two changes, and the second found a hole the first would not have. 1. Rules that attach to a path, not to a dimension. The nine dimensions are domain-blind by design — "find security bugs" is a lens, not a syllabus — and that holds until a file's failure modes are not guessable from reading it. A GitHub Actions workflow is the clearest case: it is YAML, so it reads as configuration, and the reviewer who treats it as configuration misses every one of its attack classes. Nothing in this review knew to ask whether a `pull_request_target` job checks out the contributor's head — which is the difference between a CI file and a remote code execution with the repository's write token. This repo runs `qwen-autofix.yml`, which posts to pull requests. `agent-prompt` now appends a checklist for such a file to the brief of every code-reviewing agent **whose territory actually contains one**. Scoped, because a rule that fires on every review is a rule that gets skimmed. `/review` runs on other people's repositories, so the calibration matters as much as the content: the blockers are the six that are unambiguously wrong; the two that shade into taste (SHA-pinning, `permissions:`) are Suggestions, exempt the conventions almost everyone keeps, and are scoped to lines the diff touches. No style rules — a linter owns those, and the Exclusion Criteria already forbid them. 2. The briefs move out of SKILL.md — and three things turned out never to have reached an agent at all. The briefs have been built in code since the roster landed, and SKILL.md still carried 38 KB of the same prose. Duplication is drift, and a 178 KB skill is ~45 000 tokens in the orchestrator's context on every review — which is itself a cause of the failure this whole line of work has been chasing. The skill now keeps what each agent is *for* (a table) and drops what it is *sent* (the command's copy is the one that arrives). Doing that surfaced what the code briefs were missing, because the deleted prose had to go somewhere: - **The Exclusion Criteria had never reached an agent.** The skill states them at the end of the document and tells the orchestrator to "apply" them. The agents do not read the document. The single largest precision control in this review has been governing nobody, in every run, since it was written. - **Nor had the anchor rules.** Agents were asked for a snippet and never told what makes one resolvable: prefer added lines, a removed line cannot be anchored at all, a bare `}` matches everywhere. `resolve-anchors` was downstream of a snippet nobody had given the rules to produce. - **Nor the severity calibration.** `SEVERITY`'s own comment warns that a chunk agent owns test coverage with nothing to calibrate it and will file "zero test coverage" as Critical — and then did not include the calibration. All three are in the briefs now. And two degradations the orchestrator used to be told to add by hand — and can no longer add, because it does not write these prompts — are applied by the builder: in cross-repo lightweight mode there is no tree, so 1b and 1c report at `Confidence: low` rather than asserting a re-establishment is missing. A false Critical blocks a merge. Step 3C (the medium-effort inline pass) now *loads* the briefs it needs rather than carrying them: same text as the high-effort agents get, read when that level actually runs instead of sitting in every review's context. SKILL.md: 171 178 → 153 656 bytes. * fix(review): scope an invariant agent to its own file, and let a blocker's blast radius be part of the blocker Two corrections, both from dogfooding the paths that had never been run. 1. An invariant agent was being handed the whole chunk plan. It owns one heavily-rewritten file. Its brief says so, and gives it that file's own slice of the diff. Its *launch prompt* listed every chunk in the review — on PR QwenLM#6457, all twenty-one reads of a 6 149-line diff, for an agent whose job is one file. The wasted reading is the smaller half. Coverage is computed from the ranges in the launch prompt, so an invariant agent was being credited with having read **every chunk in the review**. One of them could have masked twenty missing chunk agents. It now gets exactly its file's `diffRange`, and nothing else. 2. `permissions: write-all` on a job that runs untrusted code is not a Suggestion. The path rule said it was. Dogfooded against a planted vulnerability, the security agent read that and escalated anyway: "grants maximum token scope to a job that processes untrusted contributor code, amplifying the RCE above". It was right and the flat rule was too coarse. A broad token on a privileged job is not a separate recommendation — it is how far the blocker reaches, and it belongs in that finding, at Critical. On an ordinary job it stays a Suggestion. * fix(review): the six findings this skill filed against its own pull request The repository's own `/review` bot reviewed QwenLM#6892 — this change reviewing the code that changes it — and filed six Suggestions. Every one of them is real, and two are fail-open holes in the gates this pull request exists to build. They are fixed here, each with a test. - **`submit` accepted `state: null`.** `=== undefined` is not `== null`, so the structural check passed it; `compose`'s `?? {}` then collapsed it to an empty state and would have posted a review whose footer named no model and whose caps came from nowhere. - **`wasDeliveredVerbatim` was vacuously true for an empty `built` prompt.** A zero-byte record is what a partial write leaves behind — and `recordPrompt` swallows its write errors by design, so this is reachable. `readRecordedPrompts` stores it as `''`, not `undefined`, so the "no prompt was built" guard did not catch it, and the loop's body never executed. The roster would have credited a required role to whichever transcript it looked at first. It now fails closed. - **A chunk read across two pages got no credit.** The check asked for a *single* range containing the chunk, and reads of 1-200 and 201-400 are two — so it contradicted the paging instruction the same review had just given, on exactly the oversized chunks where paging is not optional. Ranges are coalesced first. - **Agent 7 was handed relative paths it could not resolve.** `worktreePath` and the plan path are repo-relative in the report, and Agent 7's working directory *is* the worktree — so `--worktree .qwen/tmp/review-pr-6457` resolved to `<worktree>/.qwen/tmp/review-pr-6457`, which does not exist. This was already visible and nobody had read it: in the 29-agent dogfood run, Agent 7 spent its time running `find … -name "*6457*fetch*"`, hunting for a plan it had been handed a path to. Absolute now. - **`removePromptRecord` was dead code with a comment claiming a caller it did not have.** `cleanup.ts` sweeps the prompt directory by prefix instead. Deleted. - **`--dry-run` omitted `cappedBy`.** The point of a dry run is to see what would be posted; `"event": "COMMENT"` with no reason leaves the reader to guess why the Approve went away. * docs(review): purge the stale event/body payload examples from Step 7 The reviewer caught a real contradiction it filed as Critical: submit.ts now refuses a payload carrying `event`/`body` (those are computed from `state` and the attached comments), but Step 7's main-path 'Build the review JSON' examples still showed `"event": "REQUEST_CHANGES"` / `"body"` and routed the verdict through a copy-it-verbatim step. An orchestrator following the unchanged instructions would have built exactly the payload submit rejects. The correct `{commit_id, comments, state}` shape existed lower in the section (the no-findings branch), added when submit took over composition — but the main-path examples and the compose-review-then-transcribe bullets above them were never reconciled. They are now: one payload shape, no verdict in it, `state` handed to submit, and the inline counts derived from the comments rather than supplied. Found by the repository's own /review on QwenLM#6892. * fix(review): the three findings from the third self-review The repository's /review passed QwenLM#6892 (no blockers) and filed three Suggestions. All three are real; two are contradictions this PR itself introduced. - **verdictLine printed a dangling colon.** When a would-be Approve was taken away by a presubmit downgrade ALONE — no cap state, `cappedBy` empty, `downgraded` true — the code joined the empty array and produced 'an Approve was NOT available: — downgraded by a presubmit check', a colon over nothing. It now collects the reasons (a cap and a downgrade are both reasons, either can be the only one) and prints the clause only when there is a reason to. The function had no test; it has six now, including this case. - **Step 3D said 'six failures' and listed seven, while check-coverage reports eight.** The count drifted as failure classes were added, and the uncoverable-chunk class had no bullet at all. Now 'eight', with the missing bullet written. - **`submit --review` help still advertised `event` / `body`** as payload fields, which the same command now refuses. Updated to `commit_id / comments / state`. Found by the repository's own /review on QwenLM#6892 — the third pass, the one that turned CHANGES_REQUESTED into no-blockers. * fix(review): a heavy file in a Step-3A diff must not demand invariant agents From a human review of QwenLM#6892 (doudouOUC). `heavy` is decided independently of topology (lib/heavy.ts): a ~300-line source file with ~120 changed lines clears the rewrite-ratio branch while srcDiffLines stays under 500 — a Step 3A review. The invariant-agent loop in requiredAgents ran in both topologies, so it added invariant-a/b/c to the roster of a 3A review that never launches them; check-coverage then reported them as missingRoles and exit-3'd, and compose-review capped the verdict — an otherwise-complete small PR, falsely blocked. Gate the loop on isTerritoryFanOut. Step 3A's dimension agents each walk the whole diff, so one already sees both ends of a rewritten file; invariant agents are a 3B mechanism for when the diff is carved into territories and no single agent holds the whole file. roster.test.ts now pins the 3A-heavy case. Also, same review: merge() in coverage.ts copied its first tuple and pushes copies, so it no longer mutates a tuple owned by rec.diffReads (harmless today, pure now). * fix(review): a downgraded Request changes must not read as a plain Comment Fifth self-review, one behavioural finding among five (the rest are test/doc). verdictLine printed 'Comment — downgraded by a presubmit check' for BOTH a Suggestion-only Comment the presubmit moved and a REQUEST_CHANGES it moved down to Comment. The second is a review with confirmed Criticals posted inline, and 'Comment — downgraded' reads to an operator as 'nothing blocking'. It could not tell them apart from baseEvent alone — a cap may already have softened the RC before the downgrade ran — so ComposeReviewResult now carries downgradedFrom, and verdictLine says 'Request changes, downgraded to Comment … (the blockers are still posted)' for that case. Six verdictLine cases now, including this one. Also from the same review, all confirmed: - agent-prompt.test.ts: the describe block named a function that was renamed (buildRolePrompt -> buildRoleBrief), and the mode-rejection it.each covered 2 of the invalid combinations, not the role-mode ones; now covers all five and drops the stale 'two modes' wording. - SKILL.md Step 7: the review-JSON example used a /* */ comment inside a ```json fence (not valid JSON); switched to ```jsonc with a // comment. * fix(review): eight review-round fixes atop the Step-3A invariant resolution Follows 7c499d1, which resolved the doudouOUC roster finding (a heavy file in a Step-3A diff must not demand invariant agents — gate the loop on the topology) and the merge() purity nit. This carries the rest of the same round: - `roster.ts` requires Agent 0 only for a positive PR number. `!== undefined` let `null`/`0`/`''` through. Note the reviewer's suggested `typeof === 'number'` is wrong for this codebase — `fetch-pr` writes the number as a *string* — so the guard accepts a numeric string too, or every real PR review would lose Agent 0. A table test pins both directions. - `transcripts.ts` matches the diff path as a whole JSON string value, so `…/diff.txt.bak` no longer counts as reading `…/diff.txt`. It also documents why FIFO is right for a chronological transcript. - `agent-prompt.ts` scopes path rules to `--file` only for invariant roles — a whole-diff reviewsCode agent passed `--file` would otherwise lose the rules for every other file — and guards each chunk element in `diffReadingBlock` like `chunkFrom`, so a corrupted chunk errors legibly instead of emitting `offset=NaN`. - `compose-review.ts` stops double-wrapping `cov.missingRoles` / `cov.rewrittenPrompts`, which coverage.ts already writes self-explanatory. - `agent-briefs.ts` JSDoc said "Two do not" read the diff; only Build & Test does not. - The agent-prompt size-bound test now covers `test-matrix`. The empty-prompt guard, the paged-read coverage, the verdictLine dangling-colon and the submit help text were all already handled by earlier commits on the branch; those threads are answered without a code change.
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
[Critical] modelCommand.ts:503 — Voice model !settings guard incorrectly relaxed (persistDefault always false here, dead code). Existing comment 3550114703 still stands.
[Critical] modelCommand.ts:811 — --default silently dropped when combined with inline prompt. Existing comment 3565595389 still stands.
[Critical] modelCommand.ts:877 — Caller !settings guard blocks session-only switches. Existing comment 3550114717 still stands.
[Critical] modelCommand.test.ts:177 — Missing test coverage for --default mutual exclusion, bare --default dialog, and auth-qualified model paths. Existing comment 3550114723 still stands.
— qwen3.7-max via Qwen Code /review
| return { | ||
| type: 'dialog', | ||
| dialog: 'model', | ||
| ...(persistDefault ? { persistDefault } : {}), |
There was a problem hiding this comment.
[Suggestion] No test for bare /model --default (no model, no scope) opening the dialog with { persistDefault: true } — Concrete cost: if the persistDefault spread is accidentally dropped or conditioned on scopeOverride being set, the dialog opens without persistence and the user's selection is session-only instead of default. No test would catch this.
| return { | |
| type: 'dialog', | |
| dialog: 'model', | |
| ...(persistDefault ? { persistDefault } : {}), | |
| return { | |
| type: 'dialog', | |
| dialog: 'model', | |
| ...(persistDefault ? { persistDefault } : {}), |
— qwen3.7-max via Qwen Code /review
| if (persistDefault && !settings) { | ||
| return { | ||
| type: 'message', | ||
| messageType: 'error', |
There was a problem hiding this comment.
[Critical] Voice model !settings guard incorrectly relaxed — persistDefault is always false in the voice path (rejected by mutual-exclusion guard at line 466), making this if (persistDefault && !settings) dead code. The unconditional persistSetting(settings, 'voiceModel', ...) at line 544 then crashes with TypeError when settings is undefined.
The fast model path (line 572) and vision model path (line 665) correctly retain if (!settings). Only voice was regressed.
Failure scenario: /model --voice some-model when context.services.settings is undefined → TypeError at settings.setValue(...) instead of a clean error message.
| if (persistDefault && !settings) { | |
| return { | |
| type: 'message', | |
| messageType: 'error', | |
| if (!settings) { | |
| return { | |
| type: 'message', | |
| messageType: 'error', | |
| content: t('Settings service not available.'), | |
| }; | |
| } |
— qwen3.7-max via Qwen Code /review
| settings: LoadedSettings | undefined, | ||
| currentAuthType: AuthType, |
There was a problem hiding this comment.
[Suggestion] settings widened to LoadedSettings | undefined, but the sole caller (line 877) guards with if (!settings) return before calling switchMainModel. The two if (!settings) throw blocks inside (lines 137, 157) are unreachable dead code. Concrete cost: a future maintainer reading LoadedSettings | undefined may add a call site that skips the caller guard, only to find the internal throws never fire.
| settings: LoadedSettings | undefined, | |
| currentAuthType: AuthType, | |
| settings: LoadedSettings, | |
| currentAuthType: AuthType, |
— qwen3.7-max via Qwen Code /review
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
[Critical] --default silently dropped with inline prompt (modelCommand.ts:~830): /model --default qwen-max "explain this codebase" parses persistDefault=true but the inline prompt branch only checks scopeOverride — there is no persistDefault guard. The function returns submit_prompt with modelOverride and the persistence intent is silently discarded. — Failure scenario: user believes they persisted the default AND sent the prompt, but only the one-shot override runs.
[Critical] Unconditional !settings guard blocks session-only switches (modelCommand.ts:~876): if (!settings) fires even when persistDefault=false, but switchMainModel only accesses settings inside if (persistDefault) blocks. A session-only /model <id> in an environment where settings is unavailable errors unnecessarily. — Failure scenario: bare /model qwen-max returns 'Settings service not available' even though it no longer needs settings.
— qwen3.7-max via Qwen Code /review
| : scopeOverride === SettingScope.User | ||
| ? t(' (global)') | ||
| : ''; | ||
| const defaultSuffix = persistDefault ? scopeSuffix || t(' (default)') : ''; |
There was a problem hiding this comment.
[Suggestion] CLI confirmation suffix inconsistent with ModelDialog for scoped defaults — Concrete cost: when running /model --default --project qwen-max, the CLI shows Model: qwen-max (this project) while the dialog shows Using model: qwen-max (this project default). The word "default" — the key qualifier confirming persistence — is absent from the CLI path.
| const defaultSuffix = persistDefault ? scopeSuffix || t(' (default)') : ''; | |
| const defaultSuffix = persistDefault | |
| ? scopeOverride === SettingScope.Workspace | |
| ? t(' (this project default)') | |
| : scopeOverride === SettingScope.User | |
| ? t(' (global default)') | |
| : t(' (default)') | |
| : ''; |
— qwen3.7-max via Qwen Code /review
| it('should reject /model --project without --default', async () => { | ||
| const ctx = setupContext(); | ||
| const result = await modelCommand.action!(ctx, '--project'); |
There was a problem hiding this comment.
[Suggestion] No test for --default combined with --fast, --voice, or --vision rejection — Concrete cost: the mutual-exclusion guard at modelCommand.ts:468-479 is a new code path. If the regex or flag parsing regresses, --default --voice some-model could silently accept --default with no test to catch it.
it('should reject --default combined with --fast', async () => {
const ctx = setupContext();
const result = await modelCommand.action!(ctx, '--default --fast');
expect(result).toMatchObject({
type: 'message',
messageType: 'error',
content: expect.stringContaining('--default only applies to the main model'),
});
});— qwen3.7-max via Qwen Code /review
| it('should include persistScope in dialog return for /model --default --project', async () => { | ||
| const ctx = setupContext(); | ||
| const result = await modelCommand.action!(ctx, '--default --project'); |
There was a problem hiding this comment.
[Suggestion] No test for bare /model --default opening the dialog with persistDefault: true — Concrete cost: if the persistDefault spread at modelCommand.ts:916 is accidentally dropped, the dialog opens without persist context and the user's --default intent is silently lost.
it('should open model picker with persistDefault for /model --default', async () => {
const ctx = setupContext();
const result = await modelCommand.action!(ctx, '--default');
expect(result).toEqual({
type: 'dialog',
dialog: 'model',
persistDefault: true,
});
});— qwen3.7-max via Qwen Code /review
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed. 2 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here. Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
— qwen3.7-max via Qwen Code /review
Review —
|
|
@qwen-code /resolve |
Co-authored-by: Qwen-Coder <[email protected]>
|
Qwen Code resolved the merge conflicts and pushed the branch update. Merge Conflict Resolution Summary — PR #6579Root causeThe Textual vs semanticMostly textual, with one semantic merge point. The description strings in The argumentHint: '[--default] [--fast|--voice|--vision|--image] [--project|--global] [<model-id>] | <model-id> <prompt>',The flag-stripping .replace(/(?:^|\s)--default(?:\s|$)/, ' ')
.replace(/(?:^|\s)--image(?:\s|$)/, ' ')The action handler in The ModelDialog title ternary chain needed both branches: : isImageModelMode
? t('Select Image Model')
: isFastModelMode
? t('Select Fast Model')
: persistDefault
? t('Set Default Model')
: t('Select Model')What is load-bearing
What I could not verify
中文说明合并冲突解决总结 — PR #6579根本原因: 解决方式:
关键约束:PR 的错误检查必须在图像模型逻辑之前执行,否则 未验证项:未运行构建和测试,PR 的 CI 将覆盖正确性验证。 |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
[Critical] --default silently dropped with inline prompt (modelCommand.ts:~1011): existing blocker (comment 3565595389) still stands at reviewed commit. The inline-prompt branch checks scopeOverride but not persistDefault, so /model --default qwen-max "explain this" silently discards the persistence intent.
— qwen3.7-max via Qwen Code /review
| if (scopeOverride && !persistDefault) { | ||
| return { | ||
| type: 'message', | ||
| messageType: 'error', | ||
| content: t( | ||
| 'Use --default with --project or --global when persisting the main model.', | ||
| ), | ||
| }; | ||
| } |
There was a problem hiding this comment.
[Critical] scopeOverride && !persistDefault guard blocks /model --project --image and /model --global --image — Failure scenario: the guard is positioned between the vision handler and the image handler, so it fires for /model --global --image (which should open the image-model picker with global persistence) but not for --fast/--voice/--vision (whose handlers return earlier). The existing test should include persistScope for /model --global --image dialog fails, confirming this is an unintended regression.
| if (scopeOverride && !persistDefault) { | |
| return { | |
| type: 'message', | |
| messageType: 'error', | |
| content: t( | |
| 'Use --default with --project or --global when persisting the main model.', | |
| ), | |
| }; | |
| } | |
| const isImageModelCommand = | |
| args === '--image' || args.startsWith('--image '); | |
| if (scopeOverride && !persistDefault && !isImageModelCommand) { | |
| return { | |
| type: 'message', | |
| messageType: 'error', | |
| content: t( | |
| 'Use --default with --project or --global when persisting the main model.', | |
| ), | |
| }; | |
| } |
— qwen3.7-max via Qwen Code /review
|
دوست عزیز وقتی طرح به کلی از عمومی عوض میشه خصوصی مشکل ترا شیدم اشتباه شما زیر ساخت بروز کن مستقل باش |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed. Suggestions are inline. Unresolved, please confirm: [Critical] modelCommand.ts:1019 — Unconditional !settings guard (existing blocker, comment 3550114717): the guard fires even for session-only switches, but CommandContext.services.settings is typed LoadedSettings (non-optional) and every production construction provides it. No user-visible failure is producible. Not reviewed: verification — a verifier ran and opened its brief, but no agent was launched with the prompt the CLI built — the launch was written by hand, and the posted findings cannot be counted as verified against it. [Critical] --default silently dropped with inline prompt (modelCommand.ts:~971): the inline-prompt branch checks scopeOverride but not persistDefault. /model --default qwen-max "explain this" parses persistDefault=true but returns submit_prompt — persistence silently discarded. User believes they persisted the default AND sent the prompt; only the one-shot override runs. Fix: add a persistDefault guard analogous to the scopeOverride check. [Critical] en.js missing the new --default description key — CI check-i18n fails with 'Extra key in zh.js/zh-TW.js (not in en.js)', blocking the test pipeline. Fix: add the identical key/value pair to packages/cli/src/i18n/locales/en.js.
中文说明
已审查。 建议见行内评论。 未决,请确认:[Critical] modelCommand.ts:1019 — Unconditional !settings guard (existing blocker, comment 3550114717): the guard fires even for session-only switches, but CommandContext.services.settings is typed LoadedSettings (non-optional) and every production construction provides it. No user-visible failure is producible. 未审查:验证——有验证 agent 运行并打开了自己的 brief,但没有 agent 是用 CLI 构建的 prompt 启动的——启动 prompt 是手写的,发布的发现不能算作经它验证。 [Critical] --default silently dropped with inline prompt (modelCommand.ts:~971): the inline-prompt branch checks scopeOverride but not persistDefault. /model --default qwen-max "explain this" parses persistDefault=true but returns submit_prompt — persistence silently discarded. User believes they persisted the default AND sent the prompt; only the one-shot override runs. Fix: add a persistDefault guard analogous to the scopeOverride check. [Critical] en.js missing the new --default description key — CI check-i18n fails with 'Extra key in zh.js/zh-TW.js (not in en.js)', blocking the test pipeline. Fix: add the identical key/value pair to packages/cli/src/i18n/locales/en.js.
— qwen3.7-max via Qwen Code /review
| if ( | ||
| persistDefault && | ||
| /(?:^|\s)--(?:fast|voice|vision)(?:\s|$)/.test(args) | ||
| ) { |
There was a problem hiding this comment.
[Suggestion] --image missing from the --default mutual-exclusion regex, and no test covers this guard.
-
/model --default --image <id>passes silently with--defaultignored — inconsistent with--default --fast/--voice/--visionwhich correctly error. Consider addingimageto the alternation:/(?:^|\s)--(?:fast|voice|vision|image)(?:\s|$)/. -
No test assertion exercises the
--default+--fast/--voice/--visionrejection path. If a future refactor broke this guard, the regression would ship undetected.
Concrete cost: user runs /model --default --image dall-e-3 expecting persistence feedback; the flag is silently discarded.
中文说明
--default 互斥正则表达式缺少 --image,且此守卫没有测试覆盖。
/model --default --image <id>会通过但--default被静默忽略——与--default --fast/--voice/--vision(正确报错)不一致。- 没有测试断言覆盖
--default与--fast、--voice或--vision组合的拒绝路径。
— qwen3.7-max via Qwen Code /review
| persistDefault, | ||
| scopeOverride, | ||
| ); |
There was a problem hiding this comment.
[Critical] Voice/fast/vision model !settings guard incorrectly relaxed at line 532 — persistDefault is always false in the voice path because --default --voice is rejected by the mutual-exclusion guard at line 496. This makes if (persistDefault && !settings) dead code. But persistSetting(settings, 'voiceModel', ...) is called unconditionally downstream — when settings is undefined, this crashes with a TypeError.
The fast model path (line 601) and vision model path (line 694) correctly retain if (!settings). Only the voice path was regressed.
The caller's unconditional if (!settings) guard here also blocks session-only model switches (/model qwen-max without --default) even though switchMainModel only accesses settings inside if (persistDefault) blocks.
| persistDefault, | |
| scopeOverride, | |
| ); | |
| persistDefault, | |
| scopeOverride, | |
| ); |
Fix the voice path at line 532:
if (!settings) {And relax this caller guard to:
if (persistDefault && !settings) {— qwen3.7-max via Qwen Code /review
| const defaultSuffix = persistDefault ? scopeSuffix || t(' (default)') : ''; | ||
| if ( | ||
| persistDefault && | ||
| /(?:^|\s)--(?:fast|voice|vision)(?:\s|$)/.test(args) |
There was a problem hiding this comment.
[Suggestion] --image is missing from the --default mutual-exclusion regex — Concrete cost: /model --default --image dall-e-3 passes the guard silently. The --default flag is stripped by parseDefaultFlag but the rejection check only tests for --fast, --voice, and --vision. The image model handler at line ~795 unconditionally persists imageModel via persistSetting regardless of persistDefault, so the user receives no feedback that --default was irrelevant.
| /(?:^|\s)--(?:fast|voice|vision)(?:\s|$)/.test(args) | |
| /(?:^|\s)--(?:fast|voice|vision|image)(?:\s|$)/.test(args) |
And update the error message to include --image.
— qwen3.7-max via Qwen Code /review
| const [modelDialogPersistDefault, setModelDialogPersistDefault] = | ||
| useState(false); |
There was a problem hiding this comment.
[Suggestion] No test coverage for the new modelDialogPersistDefault state — Concrete cost: the hook gained modelDialogPersistDefault state and a persistDefault option on openModelDialog(), plus reset logic in closeModelDialog(). The existing test file useModelCommand.test.ts has no coverage for: (a) openModelDialog({ persistDefault: true }) setting the flag, (b) closeModelDialog() resetting it. If a future refactor drops the setter calls, no test would catch it.
— qwen3.7-max via Qwen Code /review


What this PR does
This changes the main model switch flow so ordinary
/model <model-id>and ordinary model-picker selections only update the active session. Persisting the main model as the default now requires the explicit/model --default <model-id>path, and/model --defaultopens a default-setting picker. Scope flags for the main model are also explicit:/model --default --project ...writes the project default, and/model --default --global ...writes the user default.Auxiliary model settings keep their existing behavior:
/model --fast,/model --voice, and/model --visionstill persist their configured values.Why it's needed
The command description says
/modelswitches the model for this session, but the previous implementation also wrotemodel.name,model.baseUrl, and sometimessecurity.auth.selectedTypeto settings. That made a temporary model switch leak into later sessions and made it easy to accidentally change the global or project default.Reviewer Test Plan
How to verify
Run
/model <model-id>in a directory with configured models and confirm the active session switches whilesettings.jsonremains unchanged. Then run/model --default <model-id>and confirmsettings.jsonupdatesmodel.namefor future sessions. Also verify/model --default --project <model-id>and/model --default --global <model-id>write to the expected scope, while/model --fast,/model --voice, and/model --visioncontinue to persist their auxiliary settings.Evidence (Before & After)
Before: source-inspection repro from #4331 showed
/model <model-id>wrote persistent settings even though the command is described as session-scoped.After: local CLI verification with a temporary
QWEN_HOMEshowed/model gpt-4oprintedModel: gpt-4owhilesettings.jsonkeptmodel.name: gpt-4o-mini; running-p "/model --default gpt-4o"printedModel: gpt-4o (default)and updatedsettings.jsontomodel.name: gpt-4o.Focused tests passed:
cd packages/cli && npx vitest run src/ui/commands/modelCommand.test.ts src/ui/components/ModelDialog.test.tsx src/ui/hooks/slashCommandProcessor.test.ts.Full checks passed:
npm run buildandnpm run typecheck.Tested on
Environment (optional)
macOS, Node.js v22.23.1, local repo dev build via
npm run dev.Risk & Scope
/model <id>to update their default must now use/model --default <id>.Linked Issues
Closes #4331
中文说明
本 PR 做了什么
这个改动让主模型切换流程中普通的
/model <model-id>和普通模型选择器只更新当前会话。要把主模型持久化为默认值,现在必须显式使用/model --default <model-id>;/model --default会打开设置默认模型的选择器。主模型的作用域标志也变为显式语义:/model --default --project ...写项目默认值,/model --default --global ...写用户默认值。辅助模型设置保持原有行为:
/model --fast、/model --voice、/model --vision仍然会持久化对应配置值。为什么需要
命令描述说
/model是切换当前会话的模型,但旧实现还会把model.name、model.baseUrl,有时还有security.auth.selectedType写入 settings。这会让一次临时模型切换泄漏到后续会话,也很容易意外改掉全局或项目默认模型。Reviewer Test Plan
如何验证
在配置了模型的目录中运行
/model <model-id>,确认当前会话模型切换,但settings.json不变。然后运行/model --default <model-id>,确认settings.json的model.name被更新并影响后续会话。也可以验证/model --default --project <model-id>和/model --default --global <model-id>写入预期作用域,而/model --fast、/model --voice、/model --vision仍然持久化辅助设置。证据(Before & After)
Before:#4331 中的源码级复现显示
/model <model-id>会写持久设置,尽管命令文案说它是会话级切换。After:我用临时
QWEN_HOME做了本地 CLI 验证,/model gpt-4o输出Model: gpt-4o,但settings.json仍保持model.name: gpt-4o-mini;运行-p "/model --default gpt-4o"后输出Model: gpt-4o (default),并把settings.json更新为model.name: gpt-4o。聚焦测试已通过:
cd packages/cli && npx vitest run src/ui/commands/modelCommand.test.ts src/ui/components/ModelDialog.test.tsx src/ui/hooks/slashCommandProcessor.test.ts。完整检查已通过:
npm run build和npm run typecheck。测试平台
环境(可选)
macOS,Node.js v22.23.1,本地仓库 dev build,通过
npm run dev验证。风险与范围
/model <id>更新默认模型,现在需要改用/model --default <id>。关联 Issue
Closes #4331