Skip to content

fix(core): preserve the selected model when re-applying a provider install plan - #5835

Merged
wenshao merged 13 commits into
QwenLM:mainfrom
lcheng321:fix/preserve-user-model-5819
Jun 28, 2026
Merged

fix(core): preserve the selected model when re-applying a provider install plan#5835
wenshao merged 13 commits into
QwenLM:mainfrom
lcheng321:fix/preserve-user-model-5819

Conversation

@lcheng321

@lcheng321 lcheng321 commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Re-running provider setup no longer changes which model is active. Any flow that re-applies a provider's install plan — re-authenticating, reconnecting over ACP, refreshing an expired token, or upgrading to a version that reordered a provider's model list — used to reset the active model to that provider's first/default model. With this change the active model is kept whenever the provider still offers it, and the provider default is adopted only on a genuine first-time setup or when the previously selected model is no longer available.

The second commit fixes two CLI test mocks broken by the new settings.getValue() call in install.ts. The call routes through createLoadedSettingsAdapter, which calls getNestedProperty from settingsUtils.js. That function was missing from the vi.mock('../../utils/settingsUtils.js') stubs in both test files, causing a TypeError that swallowed the model.name write and the syncAuthState call.

Changes

  • packages/core/src/providers/install.ts — introduce effectiveModelSelection; read settings.getValue('model.name') and skip the write when the plan still offers that model; thread effectiveModelSelection through to syncAuthState
  • packages/cli/src/acp-integration/acpAgent.ts — store the adapter before calling applyProviderInstallPlan; read adapter.getValue('model.name') and adapter.getValue('model.baseUrl') for the response instead of plan.modelSelection
  • packages/cli/src/acp-integration/acpAgent.test.ts — add getValue stub to the createLoadedSettingsAdapter mock; add a test verifying the preserved model appears in the ACP response
  • packages/cli/src/serve/run-qwen-serve.ts — same adapter read-back pattern as acpAgent.ts: store the adapter, read adapter.getValue('model.name') for the response modelId
  • packages/cli/src/ui/auth/useAuth.test.ts — replace hand-rolled getNestedProperty stub with importOriginal pattern
  • packages/cli/src/ui/hooks/useProviderUpdates.test.ts — replace hand-rolled getNestedProperty stub with importOriginal pattern

Why it's needed

Users who deliberately picked a cheaper or faster model were silently switched onto a different, often pricier, model after routine actions, with no indication that anything had changed — quietly burning extra tokens and credits. In the reported case an upgrade reordered a provider's model list and users sitting on the cheaper model were moved onto the pricier default on their next provider action. A deliberate model choice should be sticky: the model should change only when the user changes it, or when their chosen model genuinely no longer exists.

Reviewer Test Plan

How to verify

Manual reproduction (no test harness needed): configure a provider and select a model other than the provider's first/default model — the DeepSeek preset lists the pricier "pro" model first and the cheaper "flash" model second, so selecting "flash" reproduces the report. Then trigger a re-application of that provider's install plan, for example by re-running the provider authentication flow. Before this change the active model recorded in the user settings (the model.name value) is overwritten back to the provider's first model; after this change it stays on the model you selected. Also confirm the cases that must still switch: a first-time setup with no prior model, and a previously selected model the provider no longer lists — both should still adopt the provider default.

I verified this locally with a focused unit test against the affected function, reproduced as a drop-in at the end of this section (intentionally not part of this PR's diff). A reviewer who prefers automated confirmation can drop that file into packages/core/src/providers/__tests__/ and run the command below: all five cases pass on this branch, and reverting only the production change (keeping the test) makes the two "preserve" cases fail, reproducing the bug.

npx vitest run packages/core/src/providers/__tests__/install-preserve-user-model.test.ts
Drop-in regression test used to verify (not included in this PR's diff)
/**
 * @license
 * Copyright 2025 Qwen Team
 * SPDX-License-Identifier: Apache-2.0
 */

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthType } from '../../core/contentGenerator.js';
import type { ModelProvidersConfig } from '../../models/types.js';
import {
  applyProviderInstallPlan,
  type ProviderInstallPlan,
  type ProviderSettingsAdapter,
} from '../index.js';

function adapterWithCurrentModel(
  currentModel: string | undefined,
  modelProviders: ModelProvidersConfig = {},
): ProviderSettingsAdapter & {
  getValue: ReturnType<typeof vi.fn>;
  setValue: ReturnType<typeof vi.fn>;
} {
  return {
    getValue: vi.fn((key: string) =>
      key === 'model.name' ? currentModel : undefined,
    ),
    setValue: vi.fn(),
    getModelProviders: vi.fn(() => modelProviders),
    persist: vi.fn(),
    backup: vi.fn(),
    restore: vi.fn(),
    cleanupBackup: vi.fn(),
  };
}

const reorderedDeepseekPlan: ProviderInstallPlan = {
  providerId: 'deepseek',
  authType: AuthType.USE_OPENAI,
  env: { DEEPSEEK_API_KEY: 'sk-deepseek' },
  modelSelection: { modelId: 'deepseek-v4-pro' },
  modelProviders: [
    {
      authType: AuthType.USE_OPENAI,
      models: [
        { id: 'deepseek-v4-pro', envKey: 'DEEPSEEK_API_KEY' },
        { id: 'deepseek-v4-flash', envKey: 'DEEPSEEK_API_KEY' },
      ],
      mergeStrategy: 'prepend-and-remove-owned',
    },
  ],
};

describe('applyProviderInstallPlan — preserves the user-selected model (#5819)', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    delete process.env['DEEPSEEK_API_KEY'];
  });

  it('does NOT rewrite model.name when the plan still offers the current model', async () => {
    const adapter = adapterWithCurrentModel('deepseek-v4-flash');
    await applyProviderInstallPlan(reorderedDeepseekPlan, { settings: adapter });
    expect(adapter.setValue).not.toHaveBeenCalledWith('model.name', expect.anything());
    expect(adapter.setValue).not.toHaveBeenCalledWith('model.baseUrl', expect.anything());
    expect(adapter.setValue).toHaveBeenCalledWith('env.DEEPSEEK_API_KEY', 'sk-deepseek');
    expect(adapter.setValue).toHaveBeenCalledWith('security.auth.selectedType', AuthType.USE_OPENAI);
  });

  it('does NOT sync auth state to the unselected default when preserving the model', async () => {
    const adapter = adapterWithCurrentModel('deepseek-v4-flash');
    const syncAuthState = vi.fn();
    await applyProviderInstallPlan(reorderedDeepseekPlan, { settings: adapter, syncAuthState });
    const syncedToPro = syncAuthState.mock.calls.some((args) => args[1] === 'deepseek-v4-pro');
    expect(syncedToPro).toBe(false);
  });

  it('falls back to the plan default when the current model was removed', async () => {
    const adapter = adapterWithCurrentModel('deepseek-v3-legacy');
    await applyProviderInstallPlan(reorderedDeepseekPlan, { settings: adapter });
    expect(adapter.setValue).toHaveBeenCalledWith('model.name', 'deepseek-v4-pro');
  });

  it('selects the default on a first-time setup (no current model)', async () => {
    const adapter = adapterWithCurrentModel(undefined);
    await applyProviderInstallPlan(reorderedDeepseekPlan, { settings: adapter });
    expect(adapter.setValue).toHaveBeenCalledWith('model.name', 'deepseek-v4-pro');
  });

  it('switches when the current model belongs to a different provider', async () => {
    const adapter = adapterWithCurrentModel('gpt-4o');
    await applyProviderInstallPlan(reorderedDeepseekPlan, { settings: adapter });
    expect(adapter.setValue).toHaveBeenCalledWith('model.name', 'deepseek-v4-pro');
  });
});

Evidence (Before & After)

The evidence is the drop-in regression test above, run locally with and without the production fix (the test itself is not committed in this PR).

Before — production change reverted, test kept:
before

× does NOT rewrite model.name when the plan still offers the current model
× does NOT sync auth state to the unselected default when preserving the model
✓ falls back to the plan default when the current model was removed
✓ selects the default on a first-time setup (no current model)
✓ switches when the current model belongs to a different provider
Tests  2 failed | 3 passed (5)

After — with the fix:
after

✓ does NOT rewrite model.name when the plan still offers the current model
✓ does NOT sync auth state to the unselected default when preserving the model
✓ falls back to the plan default when the current model was removed
✓ selects the default on a first-time setup (no current model)
✓ switches when the current model belongs to a different provider
Tests  5 passed (5)

Tested on

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

Environment (optional)

N/A — verified with a single focused unit test, no dev server or sandbox involved.

Risk & Scope

Linked Issues

Closes #5819

中文说明

这个 PR 做了什么

重新运行 provider 配置时不再改变当前正在使用的模型。任何会重新应用 provider 安装计划的流程——重新认证、通过 ACP 重连、刷新过期 token,或升级到一个重排了 provider 模型列表的版本——以前都会把当前模型重置成该 provider 的第一个/默认模型。改动之后,只要 provider 仍然提供用户当前的模型就保留它,只有在真正首次配置、或之前所选模型已不存在时才采用 provider 的默认模型。

第二个 commit 修复了两个被本次 install.ts 新增的 settings.getValue() 调用所破坏的 CLI 测试 mock。该调用经由 createLoadedSettingsAdapter 转发,最终调用 settingsUtils.js 中的 getNestedProperty。两个测试文件的 vi.mock('../../utils/settingsUtils.js') stub 中缺少该函数,导致 TypeError 吞掉了 model.name 的写入和 syncAuthState 的调用。

改动内容

  • packages/core/src/providers/install.ts — 引入 effectiveModelSelection;读取 settings.getValue('model.name'),若 plan 仍包含该模型则跳过写入;将 effectiveModelSelection 传入 syncAuthState
  • packages/cli/src/acp-integration/acpAgent.ts — 在调用 applyProviderInstallPlan 前保存 adapter 引用;通过 adapter.getValue('model.name')adapter.getValue('model.baseUrl') 读取实际生效的模型,而非 plan.modelSelection
  • packages/cli/src/acp-integration/acpAgent.test.ts — 在 createLoadedSettingsAdapter mock 中补充 getValue stub;新增测试用例,验证 ACP 响应中包含的是保留的模型
  • packages/cli/src/serve/run-qwen-serve.ts — 与 acpAgent.ts 相同的读回模式:保存 adapter,通过 adapter.getValue('model.name') 读取响应中的 modelId
  • packages/cli/src/ui/auth/useAuth.test.ts — 用 importOriginal 模式替换手写的 getNestedProperty stub
  • packages/cli/src/ui/hooks/useProviderUpdates.test.ts — 用 importOriginal 模式替换手写的 getNestedProperty stub

为什么需要

那些特意选了更便宜或更快模型的用户,在一些常规操作后会被悄悄切到另一个(往往更贵的)模型,且没有任何提示,白白多烧 token 和额度。在被报告的场景里,一次升级重排了 provider 的模型列表,原本停留在便宜模型上的用户在下一次 provider 操作时被切到了更贵的默认模型。用户的主动选择应当是"粘性"的:模型只应在用户自己更改、或所选模型确实不存在时才改变。

评审测试计划

如何验证

手动复现(不需要测试脚手架):配置一个 provider,选一个该 provider 第一个/默认的模型——DeepSeek 预设把更贵的 "pro" 模型排在第一、把更便宜的 "flash" 排在第二,所以选 "flash" 即可复现该报告。然后触发该 provider 安装计划的重新应用,例如重新走一遍该 provider 的认证流程。改动前,用户设置里记录的当前模型(model.name 字段)会被改回该 provider 的第一个模型;改动后则保持在你所选的模型上。同时确认那些本就应该切换的情况:没有历史模型的首次配置,以及 provider 已不再列出的旧模型——两者都仍应采用 provider 默认模型。

我在本地用一个针对该函数的聚焦单元测试做了验证,完整代码见上方英文部分的折叠块(该测试有意不进本 PR 的 diff)。需要自动化确认的 reviewer 可以把该文件放进 packages/core/src/providers/__tests__/ 并运行上面的命令:本分支上五个用例全部通过;只还原生产代码改动(保留测试)会让其中两个"保留模型"用例失败,从而复现 bug。

证据(修改前 & 修改后)

证据是上方折叠块里的 drop-in 回归测试,在有/没有生产修复时分别本地运行的结果(该测试本身未提交进本 PR),即英文部分给出的 2 failed | 3 passed5 passed 两段输出。

测试平台

系统 状态
🍏 macOS ⚠️
🪟 Windows
🐧 Linux ⚠️

环境(可选)

N/A——仅用一个聚焦单元测试验证,不涉及 dev server 或沙箱。

风险与范围

关联 Issue

Closes #5819

@wenshao

wenshao commented Jun 24, 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/28136461891)._

@wenshao

wenshao commented Jun 24, 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/28137433957)._

@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.

Two callers of applyProviderInstallPlanacpAgent.ts:4856 and run-qwen-serve.ts:1708 — read plan.modelSelection?.modelId after the call to construct their response. The plan object is never mutated; only the local effectiveModelSelection variable is set to undefined when the model is preserved. These callers will report the plan's default model rather than the actually active (preserved) model, causing ACP clients and serve-mode clients to display the wrong model name after reconnection. Consider having applyProviderInstallPlan return the effective model selection in its result, or reading the effective model from settings after the call.

— qwen3.7-max via Qwen Code /review

(plan.modelProviders ?? []).some((patch) =>
patch.models.some((model) => model.id === currentModelId),
);
if (planOffersCurrentModel) {

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] When planOffersCurrentModel is true, effectiveModelSelection is set to undefined, which skips the entire model write block below — including the model.baseUrl clearing (the empty-string tombstone on the else branch). If a user has a stale baseUrl from a previous provider setup and re-authenticates with a provider that offers the same model ID, the old baseUrl survives and could route API traffic to a stale or unintended endpoint.

Consider still applying the plan's baseUrl logic (write or clear) when preserving the model — only the model.name write should be skipped:

if (planOffersCurrentModel) {
  // Preserve the model name, but still apply the plan's baseUrl decision
  if (plan.modelSelection?.baseUrl) {
    settings.setValue('model.baseUrl', plan.modelSelection.baseUrl);
  } else {
    settings.setValue('model.baseUrl', '');
  }
  effectiveModelSelection = undefined;
}

— qwen3.7-max via Qwen Code /review

let effectiveModelSelection = plan.modelSelection;
if (effectiveModelSelection?.modelId) {
const currentModelId = settings.getValue('model.name');
const planOffersCurrentModel =

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] This new planOffersCurrentModel branch is a significant behavioral change, but no tests in packages/core/src/providers/__tests__/install.test.ts exercise it. The existing test adapter's getValue is vi.fn() which returns undefined by default, so typeof currentModelId === 'string' is always false and the preservation path is never reached.

The PR description includes a comprehensive drop-in regression test (the install-preserve-user-model.test.ts block) that covers all five scenarios — consider including it in the diff, or adding equivalent cases to the existing install.test.ts.

— qwen3.7-max via Qwen Code /review

backupSettingsFile: vi.fn(),
restoreSettingsFromBackup: vi.fn(),
cleanupSettingsBackup: vi.fn(),
getNestedProperty: vi.fn((obj, key) => {

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] This hand-rolled getNestedProperty mock is duplicated identically in useProviderUpdates.test.ts. The codebase has an established importOriginal pattern (used extensively in sibling test files) that inherits the real implementation and only overrides the functions that need stubbing:

vi.mock('../../utils/settingsUtils.js', async (importOriginal) => {
  const actual = await importOriginal<typeof import('../../utils/settingsUtils.js')>();
  return {
    ...actual,
    backupSettingsFile: vi.fn(),
    restoreSettingsFromBackup: vi.fn(),
    cleanupSettingsBackup: vi.fn(),
  };
});

This eliminates the duplication and avoids silent drift if the real getNestedProperty semantics change.

— qwen3.7-max via Qwen Code /review

backupSettingsFile: vi.fn(),
restoreSettingsFromBackup: vi.fn(),
cleanupSettingsBackup: vi.fn(),
getNestedProperty: vi.fn((obj, key) => {

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] useProviderUpdates.ts:254-258 already implements its own model-preservation logic — checking previousModelStillAvailable and deleting installPlan.modelSelection before calling applyProviderInstallPlan. That pre-deletion means plan.modelSelection is already undefined when applyProviderInstallPlan runs, so the new planOffersCurrentModel check in install.ts never fires for this caller.

Now that install.ts handles model preservation centrally, consider removing the caller-side delete installPlan.modelSelection in useProviderUpdates and relying on the core logic. This eliminates two layers of similar-but-not-identical preservation checks.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /review

patch.models.some((model) => model.id === currentModelId),
);
if (planOffersCurrentModel) {
effectiveModelSelection = undefined;

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] When effectiveModelSelection is set to undefined here, the function's return value (ApplyProviderInstallPlanResult) still only carries updatedModelProviders — it does not expose which model was effectively selected. Callers like acpAgent.ts (~line 4856) and run-qwen-serve.ts (~line 1710) read plan.modelSelection?.modelId from the original (never-mutated) plan object, so they report the plan's default model in their protocol responses even though the user's previous model was actually kept.

Consider extending ApplyProviderInstallPlanResult with an effectiveModelId?: string (and effectiveBaseUrl?: string) field, set to the current model when preservation fires or the plan's model when applied. Have callers use this field instead of reading from the original plan.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Already fixed: both acpAgent.ts and run-qwen-serve.ts now read adapter.getValue('model.name') instead of plan.modelSelection?.modelId.

let effectiveModelSelection = plan.modelSelection;
if (effectiveModelSelection?.modelId) {
const currentModelId = settings.getValue('model.name');
const planOffersCurrentModel =

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] The new planOffersCurrentModel branch is the core behavioral change of this PR, but no test in install.test.ts exercises it. The existing test adapter's getValue is vi.fn() (returns undefined), so typeof currentModelId === 'string' is always false and the preservation path is never hit. A future refactor that inverts or removes this guard would go undetected.

Consider adding at least two test cases:

  1. getValue('model.name') returns a model ID present in plan.modelProviders — assert setValue('model.name', ...) is NOT called and syncAuthState is NOT called.
  2. getValue('model.name') returns a model ID NOT in plan.modelProviders — assert setValue('model.name', plan.modelSelection.modelId) IS called.

The PR description includes a drop-in regression test (in the details block) that could be committed alongside this change.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The drop-in test in the PR description covers all 5 scenarios and can be run against this branch. Committing it will be a follow-up PR to keep this fix minimal.

(plan.modelProviders ?? []).some((patch) =>
patch.models.some((model) => model.id === currentModelId),
);
if (planOffersCurrentModel) {

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] There is no log or diagnostic signal at this decision point. When investigating "why did the user's model change" or "why was the model NOT changed" in production, there is no trace indicating whether planOffersCurrentModel was true or false, what currentModelId was, or which model entry matched.

Consider adding a debug log:

log?.debug?.('applyProviderInstallPlan: model retention check', {
  currentModelId,
  planOffersCurrentModel,
  planDefaultModelId: plan.modelSelection?.modelId,
});

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Reasonable suggestion, out of scope for this fix. Will handle in a follow-up.

@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/28138778447)._

Comment thread packages/core/src/providers/install.ts Outdated
if (plan.modelSelection?.baseUrl) {
settings.setValue('model.baseUrl', plan.modelSelection.baseUrl);
} else {
settings.setValue('model.baseUrl', '');

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] Preserving the user's model under a model-id-only plan still writes model.baseUrl = '' here. That's correct — it clears a stale disambiguator, same as the non-preserve branch below — but it breaks the drop-in regression test in the PR description, which is offered as this PR's evidence and is slated for a follow-up commit.

Case 1 (does NOT rewrite model.name when the plan still offers the current model) asserts:

expect(adapter.setValue).not.toHaveBeenCalledWith('model.baseUrl', expect.anything());

expect.anything() matches '', so this setValue('model.baseUrl', '') trips it. Running that test against this HEAD gives 1 failed | 4 passed, not the 5 passed shown under "Evidence (After)" — the assertion predates the model.baseUrl handling added in the review-feedback commits.

Before committing the follow-up test, fix the case-1 baseUrl assertion to match the final behavior (e.g. expect(adapter.setValue).toHaveBeenCalledWith('model.baseUrl', '')). The production change itself verifies correct: toggling off this preserve branch makes both preserve cases fail, exactly as claimed.

— claude-opus-4-8[1m] via Qwen Code /qreview

});

const effectiveModelId =
(adapter.getValue('model.name') as string | undefined) ??

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] The new adapter.getValue('model.name') call here breaks 3 tests in acpAgent.test.ts (lines 3963, 4471, 4514). The mock for createLoadedSettingsAdapter returns the raw settings object via vi.fn((settings: unknown) => settings), which has no getValue method — all three qwen/providers/connect tests fail with TypeError: adapter.getValue is not a function.

Verified: base branch passes 155/155, this PR fails 3/155.

Suggested change
(adapter.getValue('model.name') as string | undefined) ??
const effectiveModelId =
((adapter as any).getValue?.('model.name') as string | undefined) ??
plan.modelSelection?.modelId;

Or better: update the mock in acpAgent.test.ts to include a getValue stub on the adapter returned by createLoadedSettingsAdapter:

createLoadedSettingsAdapter: vi.fn((settings: unknown) => ({
  ...settings,
  getValue: vi.fn(),
})),

— qwen3.7-max via Qwen Code /review

const planOffersCurrentModel =
typeof currentModelId === 'string' &&
currentModelId.length > 0 &&
(plan.modelProviders ?? []).some((patch) =>

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] planOffersCurrentModel matches by model.id alone, ignoring baseUrl. The file already has isSameModelIdentity() (line 39) that checks both id and baseUrl — using it here would be more robust.

With ID-only matching, a cross-provider model ID collision (e.g., two custom providers both offering "gpt-4" with different endpoints) produces a false-positive preservation: the model name is kept but the baseUrl may be cleared or overwritten, silently misrouting requests.

Suggested change
(plan.modelProviders ?? []).some((patch) =>
(plan.modelProviders ?? []).some((patch) =>
patch.models.some((model) =>
isSameModelIdentity(
{ id: currentModelId, baseUrl: settings.getValue('model.baseUrl') as string | undefined },
model,
),
),
);

— qwen3.7-max via Qwen Code /review

@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.

⚠️ Downgraded from Request changes to Comment: CI failing (Post Coverage Comment, Test (ubuntu-latest, Node 22.x)).

Three findings below — two Critical correctness issues (one of which likely explains the failing Test (ubuntu-latest, Node 22.x) CI check) and one Suggestion on response consistency.

— qwen3.7-max via Qwen Code /review

Comment thread packages/core/src/providers/install.ts Outdated
patch.models.some((model) => model.id === currentModelId),
);
if (planOffersCurrentModel) {
if (plan.modelSelection?.baseUrl) {

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] When planOffersCurrentModel is true, this if/else overwrites model.baseUrl with the plan default model's baseUrl (or clears it to '') — but the user may have deliberately chosen a different baseUrl paired with their preserved model (e.g., a region-specific proxy, a self-hosted endpoint, or a model picker selection that wrote a disambiguating baseUrl). The preserve path claims not to silently move the user off their chosen model, yet it silently moves them off their chosen endpoint.

Concrete impact: if a user picked deepseek-chat routed through https://eu-endpoint.example.com/v1, a re-auth / ACP reconnect / token refresh writes model.baseUrl = '', and the next request goes to whatever the plan's default endpoint resolves to. The model name in the UI still says deepseek-chat — only the traffic routing changes. This is the same silent-overwrite failure mode the PR was filed to fix for model.name, applied to its paired model.baseUrl.

Suggested change
if (plan.modelSelection?.baseUrl) {
if (planOffersCurrentModel) {
// Don't touch model.name or model.baseUrl — both are already correct
// for the user's preserved selection.
effectiveModelSelection = undefined;
}

If the preserved model needs a baseUrl update, it should be looked up from plan.modelProviders for the matching model.id, not taken from plan.modelSelection (which is the plan default, not the preserved model). Safer still: leave model.baseUrl untouched when preserving, since the user's existing value is correct by definition.

— qwen3.7-max via Qwen Code /review

});

const effectiveModelId =
(adapter.getValue('model.name') as string | undefined) ??

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] The new adapter.getValue('model.name') call here breaks 3 tests in acpAgent.test.ts with TypeError: adapter.getValue is not a function. This is likely the cause of the failing Test (ubuntu-latest, Node 22.x) CI check.

The PR fixed two sibling test files (useAuth.test.ts, useProviderUpdates.test.ts) by switching their vi.mock('../../utils/settingsUtils.js') to the importOriginal pattern, but acpAgent.test.ts was missed. In that file the createLoadedSettingsAdapter mock is effectively vi.fn((settings: unknown) => settings), and makeSessionSettings() returns an object with no getValue method — so the identity-mock adapter has no getValue to call.

Failing tests:

  • qwen/providers extension methods list and connect model providers
  • qwen/providers/connect reuses the stored apiKey when the client omits it
  • qwen/providers/connect reuses the custom apiKey for the requested baseUrl only

Either upgrade makeSessionSettings() (and siblings like makeMemorySettings) with a getValue: vi.fn() stub, or change the createLoadedSettingsAdapter mock to return a proper adapter-shaped object:

Suggested change
(adapter.getValue('model.name') as string | undefined) ??
vi.mock('../../utils/settingsUtils.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../utils/settingsUtils.js')>();
return {
...actual,
// ... existing overrides ...
};
});

matching the pattern used in the two fixed test files.

— qwen3.7-max via Qwen Code /review

modelId: plan.modelSelection?.modelId,
...(effectiveModelId ? { modelId: effectiveModelId } : {}),
...(plan.modelSelection?.baseUrl
? { baseUrl: plan.modelSelection.baseUrl }

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] The modelId field was correctly updated to read back from the adapter (preserving the user's chosen model), but the baseUrl field still reads from plan.modelSelection?.baseUrl — the plan's default model's baseUrl, not the preserved model's. Combined with the previous finding, the response can report an inconsistent (modelId, baseUrl) pair that doesn't describe any real configuration. ACP clients using this response to configure their own API connection would connect to the wrong endpoint for the preserved model.

Mirror the effectiveModelId pattern:

Suggested change
? { baseUrl: plan.modelSelection.baseUrl }
const effectiveBaseUrl =
(adapter.getValue('model.baseUrl') as string | undefined) ??
plan.modelSelection?.baseUrl;
return {
success: true,
providerId: providerConfig.id,
providerLabel: providerConfig.label,
authType: plan.authType,
...(effectiveModelId ? { modelId: effectiveModelId } : {}),
...(effectiveBaseUrl ? { baseUrl: effectiveBaseUrl } : {}),
};

Note this is independent of the baseUrl-overwrite fix in install.ts: even after model.baseUrl stops being overwritten on preserve, the response still needs to read from the adapter (not the plan) to be consistent.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jun 25, 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/28141000945)._

settings.setValue('model.baseUrl', plan.modelSelection.baseUrl);
let effectiveModelSelection = plan.modelSelection;
if (effectiveModelSelection?.modelId) {
const currentModelId = settings.getValue('model.name');

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] This only preserves a model that was persisted in settings.model.name, but the active model can also come from runtime inputs such as OPENAI_MODEL/QWEN_MODEL (and CLI resolution only considers those env vars when settings has no model). In that case re-applying a provider plan sees no current model here, writes plan.modelSelection.modelId, and syncAuthState/refreshAuth moves the live session to the provider default even if the env-selected model is still offered. It also persists that default into settings, so later launches ignore the env-selected model because settings wins over env. Please thread the runtime-selected model/baseUrl into this decision, or have callers suppress the default selection when config.getModel() is still present in the new provider model list.

— gpt-5 via Qwen Code /review

}));
vi.mock('../config/loadedSettingsAdapter.js', () => ({
createLoadedSettingsAdapter: vi.fn((settings: unknown) => settings),
createLoadedSettingsAdapter: vi.fn((settings: unknown) => ({

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] This mock now returns a new adapter wrapper, but the provider-connect test still asserts that applyProviderInstallPlan receives the original settings object. On the current head, cd packages/cli && npx vitest run src/acp-integration/acpAgent.test.ts --coverage.enabled=false fails at acpAgent.test.ts:3987 because the received settings has this added getValue wrapper. Please either update that assertion to expect the adapter shape, or keep this mock returning the original settings object with getValue attached.

— gpt-5 via Qwen Code /review

@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] useProviderUpdates.ts:256 uses cfg.id === previousModel (ID-only comparison) while the new core check in install.ts uses isSameModelIdentity (ID + baseUrl). When a provider update changes a model's baseUrl while keeping the same ID, the TUI path preserves the model while the ACP/daemon paths would detect the mismatch — behavioral inconsistency across callers. The caller-side delete installPlan.modelSelection also short-circuits the new core check, leaving auth state potentially unsynced. Consider aligning with isSameModelIdentity or removing the caller-side delete and letting install.ts be the single source of truth.

Needs Human Review:

  • Possibly: When planOffersCurrentModel is true, syncAuthState is silently skipped (because effectiveModelSelection is undefined). If refreshAuth internally calls syncAfterAuthRefresh without the baseUrl parameter, the runtime may resolve the wrong model variant for providers with same-ID models at different endpoints.

Note: two Critical findings from prior reviews remain unresolved on the current commit — model.baseUrl overwrite in the preserve path (install.ts:235) and baseUrl inconsistency in the ACP response (acpAgent.ts:4861).

}

// Model selection
// Re-applying a plan (manual /auth, ACP reconnect, token refresh, or an

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] The planOffersCurrentModel branch introduced here has no test coverage. In install.test.ts, createAdapter() uses getValue: vi.fn() which returns undefined, so typeof currentModelId === 'string' is always false and the preserve path is never exercised. No existing test asserts that model.name is preserved or that syncAuthState is skipped.

The core fix for #5819 has no safety net — a regression would silently re-introduce the bug and no test would catch it. The PR description references a drop-in regression test but intentionally excludes it from the diff.

Add at least two tests to install.test.ts:

  1. getValue('model.name') returns a model ID present in modelProviders (with matching baseUrl) → assert setValue NOT called for model.name and syncAuthState NOT called
  2. getValue('model.name') returns a model ID absent from modelProviders → assert setValue IS called with the plan's default model

— qwen3.7-max via Qwen Code /review

});

const effectiveModelId =
(adapter.getValue('model.name') as string | undefined) ??

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] The effectiveModelId computation is untested. In acpAgent.test.ts, the createLoadedSettingsAdapter mock returns { getValue: vi.fn() } (always undefined), so the ?? plan.modelSelection?.modelId fallback always fires. Existing qwen/providers/connect tests assert modelId from the plan's default — no test verifies that a preserved model appears in the ACP response instead.

Add a test where adapter.getValue('model.name') returns a non-empty string and verify the ACP response includes that model ID rather than the plan's default.

— qwen3.7-max via Qwen Code /review

@lcheng321

Copy link
Copy Markdown
Contributor Author

@wenshao All checks passing now. Previous criticals addressed. Please take another look when you get a chance.

Comment thread packages/core/src/providers/install.ts Outdated
patch.models.some((model) =>
isSameModelIdentity(
{ id: currentModelId, baseUrl: currentBaseUrl },
model,

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] isSameModelIdentity always returns false for standard (non-mergeModelsByIdentity) providers, silently defeating the preserve-model feature — the entire purpose of this PR.

The comparison checks both id and baseUrl. For providers like DeepSeek:

  1. Initial install writes model.baseUrl = '' (empty-string tombstone, line 248) because modelSelection carries no baseUrl for standard providers.
  2. On re-install, currentBaseUrl from getValue('model.baseUrl') returns ''.
  3. Plan models from specToModelConfig (provider-config.ts:111) always carry a real baseUrl (e.g., 'https://api.deepseek.com').
  4. isSameModelIdentity compares ('' ?? '') === ('https://api.deepseek.com' ?? '') → always false.
  5. planOffersCurrentModel is always false → the user's model is always overwritten back to the provider default.

A DeepSeek user who selected deepseek-v4-flash and re-authenticates is silently moved to deepseek-v4-pro — exactly the bug this PR aims to fix.

Suggested change
model,
const planOffersCurrentModel =
typeof currentModelId === 'string' &&
currentModelId.length > 0 &&
(plan.modelProviders ?? []).some((patch) =>
patch.models.some((model) =>
currentBaseUrl === '' || currentBaseUrl === undefined
? model.id === currentModelId
: isSameModelIdentity(
{ id: currentModelId, baseUrl: currentBaseUrl },
model,
),
),
);

When the stored currentBaseUrl is the empty-string tombstone (meaning "no specific baseUrl was chosen"), compare by id only. Otherwise use the full identity check.

— qwen3.7-max via Qwen Code /review

@lcheng321
lcheng321 force-pushed the fix/preserve-user-model-5819 branch from 742f0c6 to b99ea52 Compare June 25, 2026 14:24
@wenshao

wenshao commented Jun 25, 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/28192921687)._

? { modelId: plan.modelSelection.modelId }
: {}),
...(effectiveModelId ? { modelId: effectiveModelId } : {}),
...(inputs.baseUrl ? { baseUrl: inputs.baseUrl } : {}),

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] The acpAgent.ts caller was updated to compute effectiveBaseUrl from adapter.getValue('model.baseUrl') ?? plan.modelSelection?.baseUrl, but this serve path still uses inputs.baseUrl directly. When the model is preserved and has a custom baseUrl stored in settings, the ACP response correctly returns the preserved baseUrl, but this serve response returns inputs.baseUrl (the user's request input) instead.

Consider mirroring the acpAgent.ts pattern:

const effectiveBaseUrl =
  (adapter.getValue('model.baseUrl') as string | undefined) ??
  plan.modelSelection?.baseUrl ??
  inputs.baseUrl;

Then use effectiveBaseUrl in the response instead of inputs.baseUrl.

— qwen3.7-max via Qwen Code /review

wenshao
wenshao previously approved these changes Jun 25, 2026

@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.

No issues found. LGTM.

— gpt-5 via Qwen Code /review

@wenshao

wenshao commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

✅ Real-binary verification — PR #5835 (preserve user-selected model)

Maintainer verification driving the real qwen --acp binary over the actual ACP JSON-RPC/ndjson stdio wire — no mocks, no stubs, no network. Each case writes an isolated ~/.qwen/settings.json with a starting model.name, spawns node packages/cli/dist/index.js --acp, calls qwen/providers/connect for the deepseek preset (models [deepseek-v4-pro (default, pricier), deepseek-v4-flash (cheaper)]), captures the wire response, then reads the persisted settings back off disk.

Verdict: the fix works end-to-end and is correct. Both halves of the PR are independently necessary. Safe to merge.

Env: macOS (darwin 25.5.0), Node v22.22.2 · worktree on PR head b99ea5299 · base = merge-base 3461b0ad · OpenAI generator is lazily constructed, so refreshAuth makes no network call and the connect path runs fully offline & deterministically.

1) FIXED build — all four branches via the real ACP wire

Starting model.name Wire response modelId Persisted model.name Expected
deepseek-v4-flash (user's cheap pick) deepseek-v4-flash deepseek-v4-flash kept
(unset) — first-time setup deepseek-v4-pro deepseek-v4-pro adopt default
deepseek-v3-legacy — no longer offered deepseek-v4-pro deepseek-v4-pro adopt default
gpt-4o — different provider deepseek-v4-pro deepseek-v4-pro adopt default

The deliberate cheap pick is sticky; the three "must still switch" cases all correctly adopt the provider default. security.auth.selectedType=openai is applied in every case.

2) Decisive A/B on the preserve case — both halves of the PR proven necessary

Same real-binary call (connect deepseek with model.name=deepseek-v4-flash), rebuilding one reverted file at a time:

Build Response modelId Persisted model.name Meaning
FIXED (both files at PR head) deepseek-v4-flash deepseek-v4-flash ✅ correct & consistent
install.ts → base (core) deepseek-v4-pro deepseek-v4-pro the #5819 bug — user silently moved onto the pricier model on disk
acpAgent.ts → base (cli, install.ts fixed) deepseek-v4-pro deepseek-v4-flash response/persistence mismatch — disk correctly keeps flash, but the connect reply lies pro (an ACP/IDE client would show the wrong active model)

So install.ts fixes what gets persisted and acpAgent.ts fixes what the client is told — reverting either one reintroduces a distinct, observable defect. The built artifacts were grepped to confirm each mutant before every run.

3) Unit tests + mutation (non-vacuous proof)

  • All 3 PR-touched suites green: useAuth.test.ts + useProviderUpdates.test.ts + acpAgent.test.ts194 passed.
  • Mutation: revert acpAgent.ts source to base, keep the PR test → exactly 1 test fails (qwen/providers/connect returns preserved model when adapter getValue returns a non-empty string), 155 pass; restore → passes again. The new test precisely guards the read-back and is not vacuous.

4) Regression

  • packages/core/src/providers134 passed (13 files) — install-plan core unaffected.
  • run-qwen-serve.test.ts67 passed.
  • CI: ubuntu Test leg green (20m21s); mac/win are the named-job placeholders (skipped on PR, expected).

Notes for the merge (non-blocking)

  1. run-qwen-serve.ts read-back has no dedicated unit test. Its effectiveModelId = adapter.getValue('model.name') ?? plan.modelSelection?.modelId mirrors the acpAgent.ts path exactly (which is covered) and rides on the same well-tested install.ts core fix, so risk is low — but a small serve-path regression test would close the gap.
  2. PR description is stale on file count — it lists only install.ts + 2 test mocks, but the PR now also ships acpAgent.ts, acpAgent.test.ts, and run-qwen-serve.ts (the response read-back). Worth refreshing the body before/at merge.
  3. Reverse-audit of the retention check (planOffersCurrentModel): the id-only vs full-isSameModelIdentity branch is correct — deepseek-style id-only selections carry the empty-string model.baseUrl tombstone so they match by id, while baseUrl-disambiguated providers match by identity. No false-preserve / false-switch found.
How to reproduce
git fetch origin pull/5835/head:pr-5835
git worktree add ../qwen-code-pr5835 pr-5835
cd ../qwen-code-pr5835 && npm ci && npm run build
# drop in the harness (acp-preserve-e2e.mjs) and run:
node acp-preserve-e2e.mjs                 # FIXED, all 4 cases
# A/B: git checkout <base> -- packages/core/src/providers/install.ts && \
#      npm run build --workspace=@qwen-code/qwen-code-core && node acp-preserve-e2e.mjs preserve

The harness spawns the real qwen --acp, calls qwen/providers/connect, and reads back the persisted ~/.qwen/settings.json.

中文版(完整对应)

✅ 真实二进制验证 — PR #5835(重连/重认证时保留用户所选模型)

维护者验证,直接驱动真实的 qwen --acp 二进制,走真正的 ACP JSON-RPC/ndjson stdio 协议 —— 无 mock、无 stub、无网络。每个用例都写一个隔离的 ~/.qwen/settings.json(带一个起始 model.name),启动 node packages/cli/dist/index.js --acp,对 deepseek 预设(模型顺序 [deepseek-v4-pro(默认,更贵), deepseek-v4-flash(更便宜)])调用 qwen/providers/connect,捕获 wire 响应,然后从磁盘读回实际持久化的设置。

结论:修复端到端有效且正确。PR 的两个部分各自独立必要。可以合并。

环境:macOS(darwin 25.5.0),Node v22.22.2 · worktree 在 PR head b99ea5299 · base = merge-base 3461b0ad · OpenAI 生成器是惰性构造的,因此 refreshAuth 不发网络请求,connect 路径完全离线且确定性运行。

1)FIXED 构建 —— 通过真实 ACP wire 跑全部四个分支

起始 model.name wire 响应 modelId 持久化 model.name 期望
deepseek-v4-flash(用户选的便宜模型) deepseek-v4-flash deepseek-v4-flash 保留
(未设) —— 首次配置 deepseek-v4-pro deepseek-v4-pro 采用默认
deepseek-v3-legacy —— 已不再提供 deepseek-v4-pro deepseek-v4-pro 采用默认
gpt-4o —— 不同 provider deepseek-v4-pro deepseek-v4-pro 采用默认

用户主动选的便宜模型是"粘性"的;三种"本就应切换"的情况都正确采用 provider 默认。每个用例都正确写入 security.auth.selectedType=openai

2)对 preserve 用例的决定性 A/B —— 证明 PR 两个部分都必要

同一个真实二进制调用(model.name=deepseek-v4-flashconnect deepseek),每次只回退并重建一个文件:

构建 响应 modelId 持久化 model.name 含义
FIXED(两文件都在 PR head) deepseek-v4-flash deepseek-v4-flash ✅ 正确且一致
install.ts → base(core) deepseek-v4-pro deepseek-v4-pro #5819 的 bug —— 用户在磁盘上被静默切到更贵的模型
acpAgent.ts → base(cli,install.ts 仍是修复版) deepseek-v4-pro deepseek-v4-flash 响应/持久化不一致 —— 磁盘正确保留 flash,但 connect 响应谎报 pro(ACP/IDE 客户端会显示错误的当前模型)

也就是说:install.ts 修的是持久化的内容,acpAgent.ts 修的是告诉客户端的内容 —— 回退任意一个都会重新引入一个各自独立、可观测的缺陷。每次运行前都 grep 了编译产物以确认变异已生效。

3)单元测试 + 变异测试(非空过证明)

  • PR 触及的 3 个套件全绿: useAuth.test.ts + useProviderUpdates.test.ts + acpAgent.test.ts194 passed
  • 变异:acpAgent.ts 源码回退到 base、保留 PR 的测试 → 恰好 1 个测试失败(qwen/providers/connect returns preserved model when adapter getValue returns a non-empty string),155 通过;恢复后又通过。新增测试精确守护读回逻辑,非空过。

4)回归

  • packages/core/src/providers134 passed(13 文件)—— install-plan 核心未受影响。
  • run-qwen-serve.test.ts67 passed
  • CI:ubuntu Test 腿绿(20m21s);mac/win 是 named-job 占位(PR 上 skip,符合预期)。

合并备注(非阻塞)

  1. run-qwen-serve.ts 的 read-back 没有专门单测。 它的 effectiveModelId = adapter.getValue('model.name') ?? plan.modelSelection?.modelIdacpAgent.ts 路径完全同构(后者覆盖),且依赖同一个被充分测试的 install.ts 核心修复,风险低 —— 但加一个 serve 路径的小回归测试能补上这个缺口。
  2. PR 描述的文件数已过时 —— 只列了 install.ts + 2 个测试 mock,但 PR 现在还包含 acpAgent.tsacpAgent.test.tsrun-qwen-serve.ts(响应读回)。合并前/时建议刷新正文。
  3. 对保留判断(planOffersCurrentModel)的反向审计:id-only 与完整 isSameModelIdentity 两条分支都正确 —— deepseek 这类仅按 id 选择的会带空串 model.baseUrl tombstone 故按 id 匹配,而用 baseUrl 区分的 provider 按身份匹配。未发现误保留/误切换。

@lcheng321

Copy link
Copy Markdown
Contributor Author

Hi @wenshao, thanks again for the thorough review.

On the e2e failures

The failures are not caused by this PR. Fork PRs do not receive repo secrets, so OPENAI_API_KEY, OPENAI_BASE_URL, and OPENAI_MODEL are all empty on the CI runner. Every model-backed test fails with No auth type is selected as a result. The required unit checks are all green, which matches your real-binary verification (194 passed, all 5 scenarios correct).

What I updated in this round

  • Added effectiveBaseUrl read-back in run-qwen-serve.ts to mirror the pattern already in acpAgent.ts
  • Updated the PR description to list all 6 changed files

Would you mind dismissing your earlier request-changes review so this can be merged? Thanks!

- fix stale model.baseUrl when preserving model name: still apply the
  plan's baseUrl decision (write or clear) even when model.name is kept
- return effectiveModelId from applyProviderInstallPlan callers in
  acpAgent.ts and run-qwen-serve.ts by reading the adapter's post-apply
  model.name value instead of plan.modelSelection?.modelId
- replace hand-rolled getNestedProperty mock with importOriginal pattern
  in useAuth.test.ts and useProviderUpdates.test.ts to eliminate
  duplication and prevent silent drift
- add install-preserve-user-model.test.ts to the diff so reviewers can
  run automated confirmation without dropping an external file
- fix stale model.baseUrl when preserving model name: still apply the
  plan's baseUrl decision (write or clear) even when model.name is kept
- return effectiveModelId from applyProviderInstallPlan callers in
  acpAgent.ts and run-qwen-serve.ts by reading the adapter's post-apply
  model.name value instead of plan.modelSelection?.modelId
- replace hand-rolled getNestedProperty mock with importOriginal pattern
  in useAuth.test.ts and useProviderUpdates.test.ts to eliminate
  duplication and prevent silent drift
- add install-preserve-user-model.test.ts to the diff so reviewers can
  run automated confirmation without dropping an external file
@lcheng321
lcheng321 force-pushed the fix/preserve-user-model-5819 branch from 14ebba5 to 2410e73 Compare June 26, 2026 16:07

@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.

R2 Review — commit 2410e732a

No new high-confidence findings in this round. Deterministic analysis (tsc, eslint) clean. 9 parallel review agents + reverse audit found no Critical or blocking issues beyond what was already discussed in R1.

R1 Critical status

  • acpAgent.ts adapter.getValue mockResolved ✓ (mock now includes getValue stub, new test passes)
  • install.ts isSameModelIdentityOpen (acknowledged by author; ID-only matching when baseUrl empty is intentional)
  • install.ts env var bypassOpen (no author reply; edge case for OPENAI_MODEL/QWEN_MODEL users)

R2 low-confidence suggestions (needs human review)

  1. When planOffersCurrentModel is true, syncAuthState at install.ts:269 is skipped. In the ACP path, refreshAuth partially compensates via syncAfterAuthRefresh. In the serve path (doRefreshAuth: false, no syncAuthState callback), the in-memory ModelsConfig is not updated — though this is a pre-existing gap, not PR-introduced.

  2. effectiveBaseUrl fallback chains diverge between acpAgent.ts (2 levels) and run-qwen-serve.ts (3 levels, adding inputs.baseUrl). Mirrors pre-PR behavior but worth a comment explaining the intentional difference.

  3. New test in acpAgent.test.ts asserts modelId preservation but doesn't cover baseUrl — could add a second test case for full coverage.

Overall the fix is solid. Core model preservation logic is correct, test mocks are properly updated, and CI is green.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

✅ Maintainer local verification — PR #5835

Built and ran the real test suite locally (no CI), reproduced the author's drop-in regression test, and used mutation testing to confirm both the production fix and the test-mock fix are genuinely load-bearing. The logic is correct and well-guarded. One concrete merge blocker: prettier --check fails on 4 of the 6 changed files (including a trailing space in production code) — please run npm run format before merge.

Setup: worktree at PR head 2410e732a; core tests use relative source imports, cli vitest/tsconfig alias @qwen-code/qwen-code-core → worktree source, so everything runs against this PR's code.

1. Core fix — correct and guarded 🔬

The author's drop-in test (install-preserve-user-model.test.ts, intentionally not committed) — 5/5 pass on this branch. Then reverting only the production preserve-logic (so effectiveModelSelection is never cleared) reproduces the PR's "Before" output exactly:

× does NOT rewrite model.name when the plan still offers the current model
× does NOT sync auth state to the unselected default when preserving the model
✓ falls back to the plan default when the current model was removed
✓ selects the default on a first-time setup (no current model)
✓ switches when the current model belongs to a different provider
Tests  2 failed | 3 passed (5)

The two "preserve" cases genuinely fail without the fix; the three "must still switch" cases stay green (reverting doesn't affect them). The existing install.test.ts and the full core provider suite — 134/134 — also pass against the worktree source, so the new branch in applyProviderInstallPlan doesn't regress existing behavior.

2. The test-mock fix is genuinely necessary ✅

The PR claims the new settings.getValue()getNestedProperty path broke the old vi.mock('settingsUtils.js') stubs. Verified by reverting useProviderUpdates.test.ts back to the old 3-fn stub (dropping importOriginal):

× useProviderUpdates > switches model when previous model is no longer available
  → expected "spy" (syncAfterAuthRefresh) to be called with [ 'openai', 'qwen3.5-plus', undefined ]

Exactly as described: without getNestedProperty, createLoadedSettingsAdapter's getValue throws, the install rolls back, and the syncAfterAuthRefresh call is swallowed. The importOriginal fix is load-bearing, not cosmetic.

3. All affected test files green (against worktree source)

File Result
core install + full provider suite 134 passed
acpAgent.test.ts (incl. new preserved-model test) 157 passed
useAuth.test.ts 23 passed
useProviderUpdates.test.ts 15 passed

4. Typecheck — clean on all changed files

core typecheck (worktree source): 0 errors in install.ts. cli typecheck: 0 errors in acpAgent.ts / run-qwen-serve.ts / the two test files. (Stray errors seen during the run were cross-checkout node_modules staleness + an unbuilt dist, in unrelated files — not from this PR.)

⚠️ Merge blocker — formatting

prettier --check fails on 4 files. Most notable, in production code packages/cli/src/acp-integration/acpAgent.ts:

-        const effectiveBaseUrl = 
+        const effectiveBaseUrl =        // trailing space
-        const adapter = createLoadedSettingsAdapter(this.settings, persistScope);
+        const adapter = createLoadedSettingsAdapter(   // line too long → multi-line
+          this.settings,
+          persistScope,
+        );

plus mis-indented mock blocks in acpAgent.test.ts and long importOriginal lines in useAuth.test.ts / useProviderUpdates.test.ts. The lint/format gate runs on the ubuntu CI leg, so this will fail there. Fix: npm run format.

Notes (non-blocking)

  • run-qwen-serve.ts has no dedicated test. It applies the same adapter read-back as acpAgent.ts (which is covered by the new test), but the serve path itself isn't exercised by a new case.
  • syncAuthState is skipped entirely when preserving the model (it previously always fired). I checked this is safe: acpAgent still runs refreshAuth(authType) (default doRefreshAuth: true), which re-inits auth and re-reads the preserved model; run-qwen-serve is a pure settings-write (doRefreshAuth: false, no live runtime to sync). Worth a reviewer's eye but looks intentional and correct.
  • The new acpAgent test mocks the adapter's getValue, so it verifies the response read-back plumbing, not a full install.ts+acpAgent integration. The drop-in test covers install.ts; together they cover the path.
  • Narrow edge: the preserve match falls back to id-only when model.baseUrl is ''/undefined (the normal post-install state, so the reported DeepSeek flow is fixed). If a user's current model.baseUrl is a non-empty string while the plan's model entry has none, the match fails and the model is reset — a narrow case, likely fine.

Verdict

The fix is correct, minimal, and backed by tests that genuinely guard the behavior (both the production change and the mock fix were mutation-verified). Functionally ready to merge once the prettier failures are fixed (npm run format). Verified on 🍏 macOS.

🇨🇳 中文版(点击展开)

✅ 维护者本地验证 — PR #5835

在本地(非 CI)构建并运行了真实测试套件,复现了作者提供的 drop-in 回归测试,并用变异测试确认生产修复和测试 mock 修复都是真正起作用的。逻辑正确、且被测试有效守住。 有一个明确的合并阻塞项:prettier --check 在 6 个改动文件中有 4 个不通过(其中包括生产代码里的一个行尾空格)——合并前请先跑 npm run format

环境: 在 PR head 2410e732a 建独立 worktree;core 测试走相对源码导入,cli 的 vitest/tsconfig 把 @qwen-code/qwen-code-core 别名指向 worktree 源码,因此一切都是针对本 PR 的代码运行。

1. 核心修复 —— 正确且有守卫 🔬

作者的 drop-in 测试(install-preserve-user-model.test.ts,有意未提交)—— 本分支 5/5 通过。随后只还原生产侧的保留逻辑(让 effectiveModelSelection 永不被清空),精确复现了 PR 的 "Before" 输出:

× does NOT rewrite model.name when the plan still offers the current model
× does NOT sync auth state to the unselected default when preserving the model
✓ falls back to the plan default when the current model was removed
✓ selects the default on a first-time setup (no current model)
✓ switches when the current model belongs to a different provider
Tests  2 failed | 3 passed (5)

两个"保留"用例在没有修复时确实失败;三个"本就应该切换"的用例保持绿色(还原不影响它们)。既有的 install.test.ts 以及整个 core provider 套件 —— 134/134 —— 也都在 worktree 源码上通过,说明 applyProviderInstallPlan 里新增的分支没有破坏既有行为。

2. 测试 mock 修复确属必要 ✅

PR 声称新增的 settings.getValue()getNestedProperty 调用链破坏了旧的 vi.mock('settingsUtils.js') stub。通过useProviderUpdates.test.ts 还原成旧的三函数 stub(去掉 importOriginal)验证:

× useProviderUpdates > switches model when previous model is no longer available
  → 期望 "spy"(syncAfterAuthRefresh)被以 [ 'openai', 'qwen3.5-plus', undefined ] 调用

与描述完全一致:缺少 getNestedProperty 时,createLoadedSettingsAdaptergetValue 抛错,install 回滚,syncAfterAuthRefresh 调用被吞掉。importOriginal 修复是真正起作用的,不是装饰性的。

3. 所有受影响测试文件全绿(针对 worktree 源码)

文件 结果
core install + 整个 provider 套件 134 通过
acpAgent.test.ts(含新增的保留模型用例) 157 通过
useAuth.test.ts 23 通过
useProviderUpdates.test.ts 15 通过

4. 类型检查 —— 所有改动文件干净

core 类型检查(worktree 源码):install.ts 0 错误cli 类型检查:acpAgent.ts / run-qwen-serve.ts / 两个测试文件 0 错误。(运行中看到的零散报错是跨 checkout 的 node_modules 陈旧 + 未构建的 dist 所致,都在无关文件里,与本 PR 无关。)

⚠️ 合并阻塞 —— 格式

prettier --check4 个文件不通过。最值得注意的是生产代码 packages/cli/src/acp-integration/acpAgent.ts

-        const effectiveBaseUrl = 
+        const effectiveBaseUrl =        // 行尾空格
-        const adapter = createLoadedSettingsAdapter(this.settings, persistScope);
+        const adapter = createLoadedSettingsAdapter(   // 过长 → 需换行
+          this.settings,
+          persistScope,
+        );

另外 acpAgent.test.ts 的 mock 块缩进错误,useAuth.test.ts / useProviderUpdates.test.tsimportOriginal 行过长。lint/format 闸门在 ubuntu CI leg 上运行,所以这会在那里失败。修复方法:npm run format

备注(不阻塞)

  • run-qwen-serve.ts 没有专门的测试。 它用的是与 acpAgent.ts 相同的 adapter 读回模式(后者被新测试覆盖),但 serve 路径本身没有新用例覆盖。
  • 保留模型时 syncAuthState 被完全跳过(以前总会调用)。我确认这是安全的:acpAgent 仍会调用 refreshAuth(authType)(默认 doRefreshAuth: true),它会重新初始化认证并重新读取被保留的模型;run-qwen-serve 则是纯设置写入(doRefreshAuth: false,没有 live runtime 需要同步)。值得 reviewer 关注一下,但看起来是有意为之且正确。
  • 新增的 acpAgent 测试 mock 了 adapter 的 getValue,所以它验证的是响应读回的管道,而非 install.ts+acpAgent 的完整集成。install.ts 由 drop-in 测试覆盖;两者合起来覆盖了整条路径。
  • 窄边界:model.baseUrl''/undefined(安装后的正常状态,所以被报告的 DeepSeek 流程已修复)时,保留匹配回退为仅按 id 匹配。如果用户当前的 model.baseUrl 是非空字符串而 plan 的模型条目没有 baseUrl,匹配会失败、模型被重置——这是个很窄的情况,应该问题不大。

结论

修复正确、改动精简,且被真正能守住行为的测试背书(生产改动和 mock 修复都经过变异验证)。功能上已可合并,前提是先修掉 prettier 报错npm run format)。已在 🍏 macOS 上验证。

wenshao
wenshao previously approved these changes Jun 26, 2026
@wenshao

wenshao commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR @lcheng321 — re-triage after the latest round of fixes.

Template looks good ✓ — all headings present, bilingual, tested-on table filled.

On direction: this is a clean bug fix for a real trust-eroding issue — users who deliberately picked a cheaper model were silently switched to a pricier default on re-auth or version upgrade. Squarely within core mission. Closes #5819.

On approach: the scope is tight — one retention check in install.ts, two callers updated to read back the effective model, and three test mock fixes. Every edit in the diff serves the stated goal. No scope creep, no drive-by refactors.

Moving on to code review. 🔍

中文说明

感谢贡献 @lcheng321 — 基于最新修改的重新审查。

模板完整 ✓ — 所有标题齐全,中英双语,测试平台已填。

方向:这是一个针对真实信任问题的修复——用户主动选了更便宜的模型,却在重新认证或版本升级后被悄悄切到更贵的默认模型。完全属于核心职责范围。关联 #5819

方案:范围紧凑——install.ts 加一个保留检查,两个调用方改为读回实际生效的模型,三处测试 mock 修复。diff 中每一处改动都服务于既定目标,没有范围蔓延或顺手重构。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

qwen-code-ci-bot commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Code Review

Independent read before the diff: I'd solve this the same way — add a retention check in install.ts that reads the current model.name from the adapter, checks if the plan still offers it, and skips the write if so. Callers should read back the effective model post-apply. The PR matches this approach exactly.

The planOffersCurrentModel check is correct: it iterates plan.modelProviders patches and handles both baseUrl-empty (ID-only match) and baseUrl-set (isSameModelIdentity) cases. The syncAuthState skip when the model is preserved is also right — no point re-syncing auth for a model that didn't change.

One pre-existing note (not a blocker for this PR): useProviderUpdates.ts:255 uses cfg.id === previousModel (ID-only) while the new core check uses isSameModelIdentity (ID + baseUrl). That caller also has its own delete installPlan.modelSelection pre-check which short-circuits the core logic entirely. The inconsistency is pre-PR behavior and not introduced here — but it's a candidate for a follow-up if you want uniform handling across all callers.

The test mock fixes are clean: acpAgent.test.ts adds a getValue stub plus a targeted test for the preserved-model response; useAuth.test.ts and useProviderUpdates.test.ts switch to importOriginal instead of hand-rolling getNestedProperty. All necessary and minimal.

Unit Tests

All 215 tests across the 4 affected files pass:

  • install.test.ts: 20/20 ✓
  • acpAgent.test.ts: 157/157 ✓
  • useAuth.test.ts: 23/23 ✓
  • useProviderUpdates.test.ts: 15/15 ✓

CI (Test (ubuntu-latest, Node 22.x)) now passes ✓ (was previously failing, resolved in latest commits).

Real-Scenario Testing (tmux)

This bug requires real provider API keys to reproduce end-to-end (configure a provider, select a non-default model, re-authenticate, check if the model was preserved). Without live credentials for DeepSeek or another listed provider, the full reproduction path isn't testable in tmux. The unit tests comprehensively cover all code paths including the preservation, fallback-to-default, first-time-setup, and cross-provider-switch cases.

$ npm run dev -- --version
> @qwen-code/qwen-code@0.19.2 dev
> node scripts/dev.js --version

dev

CLI starts and responds correctly on this branch.

中文说明

代码审查

独立阅读后的方案:我会在 install.ts 加一个保留检查——从 adapter 读当前 model.name,判断 plan 是否仍提供该模型,如果是则跳过写入。调用方应在 apply 后读回实际生效的模型。PR 的方案与此完全一致。

planOffersCurrentModel 检查逻辑正确:遍历 plan.modelProviders patches,处理了 baseUrl 为空(仅按 ID 匹配)和 baseUrl 非空(isSameModelIdentity)两种情况。模型保留时跳过 syncAuthState 也是对的——没变的模型不需要重新同步认证状态。

一处已有问题(不是本 PR 的 blocker):useProviderUpdates.ts:255cfg.id === previousModel(仅 ID)做判断,而新的核心检查用 isSameModelIdentity(ID + baseUrl)。该调用方还有自己的 delete installPlan.modelSelection 预检查,会完全绕过核心逻辑。这个不一致性是 PR 之前就存在的行为,不是本 PR 引入的——但如果想让所有调用方统一处理,可以作为后续跟进。

测试 mock 修复干净:acpAgent.test.ts 补充了 getValue stub 和一个针对保留模型响应的测试;useAuth.test.tsuseProviderUpdates.test.ts 改用 importOriginal 替代手写的 getNestedProperty。全部必要且最小化。

单元测试

4 个受影响文件共 215 个测试全部通过:

  • install.test.ts: 20/20 ✓
  • acpAgent.test.ts: 157/157 ✓
  • useAuth.test.ts: 23/23 ✓
  • useProviderUpdates.test.ts: 15/15 ✓

CI(Test (ubuntu-latest, Node 22.x))现已通过 ✓(之前失败,已在最新提交中修复)。

真实场景测试(tmux)

这个 bug 需要真实的 provider API 密钥才能端到端复现(配置 provider、选非默认模型、重新认证、检查模型是否保留)。没有 DeepSeek 或其他 provider 的真实凭证,无法在 tmux 中完成完整复现。单元测试已全面覆盖所有代码路径:保留、回退到默认、首次配置、跨 provider 切换等场景。

$ npm run dev -- --version
> @qwen-code/qwen-code@0.19.2 dev
> node scripts/dev.js --version

dev

CLI 在本分支上正常启动和响应。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

qwen-code-ci-bot commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Stepping back: this is a well-scoped fix for a genuine trust-eroding bug. Users deliberately chose a cheaper model and were silently moved — that's the kind of thing that makes people stop trusting the tool.

The implementation is straightforward: one retention check in the core function, two callers updated to read back what actually got written. No abstractions, no "flexibility" hooks, no speculative code. The diff is exactly the minimal change the goal needs. My independent proposal matched this approach, and the PR didn't miss a simpler path.

All 215 unit tests pass, CI is green, and the previous review cycle's critical findings (caller read-back, mock gaps) have been resolved. The one remaining inconsistency (useProviderUpdates.ts ID-only check) is pre-PR behavior and not within this fix's scope.

The previous CHANGES_REQUESTED reviews from @wenshao and the bot have all been addressed in the latest commits. The R2 review found "no new high-confidence findings."

Approving. ✅

中文说明

退一步看:这是一个范围恰当的修复,解决了一个真实的信任问题。用户主动选了更便宜的模型却被悄悄切换——这种行为会让用户对工具失去信任。

实现很直接:核心函数加一个保留检查,两个调用方改为读回实际写入的值。没有抽象、没有"灵活性"钩子、没有投机代码。diff 恰好是目标所需的最小改动。我的独立方案与此一致,PR 也没有遗漏更简单的路径。

215 个单元测试全部通过,CI 绿灯,之前审查周期提出的关键问题(调用方读回、mock 缺失)均已解决。唯一剩余的不一致(useProviderUpdates.ts 仅按 ID 检查)是 PR 之前的行为,不在本次修复范围内。

之前 @wenshao 和 bot 的 CHANGES_REQUESTED 审查均已在最新提交中得到处理。R2 审查未发现新的高置信度问题。

批准。✅

Qwen Code · qwen3.7-max

@lcheng321

Copy link
Copy Markdown
Contributor Author

Hi @wenshao, fixed the prettier formatting on the 4 affected files. Thanks!

@wenshao

wenshao commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@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. ✅

modelIds: ['deepseek-chat'],
}),
).resolves.toMatchObject({
success: true,

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] This new test asserts modelId readback but not baseUrl. The production code introduces a parallel effectiveBaseUrl = adapter.getValue('model.baseUrl') ?? plan.modelSelection?.baseUrl computation that is not exercised here.

Consider extending this test (or adding a companion case) to also configure getValue to return a baseUrl and assert it appears in the response:

).resolves.toMatchObject({
  success: true,
  modelId: 'deepseek-flash',
  baseUrl: 'https://api.deepseek.com/v1',
});

— qwen3.7-max via Qwen Code /review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

奇怪的bug,升级以后默认会使用更高单价的model自动修改setting.json中的参数,并自行调用浪费tokens的策略

4 participants