Skip to content
Closed
101 changes: 101 additions & 0 deletions packages/cli/src/acp-integration/acpAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,45 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({
return undefined;
},
),
PROVIDER_METADATA_NS: 'providerMetadata',
resolveMetadataKey: vi.fn((provider: { id: string; models?: unknown[] }) =>
provider.models ? provider.id : undefined,
),
readRecordedBuiltinIds: vi.fn(
(
metadataKey: string | undefined,
providerMetadata: Record<string, unknown> | undefined,
) => {
if (!metadataKey) return [];
const record = providerMetadata?.[metadataKey];
const builtinIds =
record && typeof record === 'object'
? (record as Record<string, unknown>)['builtinIds']
: undefined;
return Array.isArray(builtinIds)
? builtinIds.filter((id): id is string => typeof id === 'string')
: [];
},
),
getStaleBuiltinIds: vi.fn(
(
provider: { id: string; models?: Array<{ id: string }> },
providerMetadata: Record<string, unknown> | undefined,
) => {
const metadataKey = provider.models ? provider.id : undefined;
if (!metadataKey) return [];
const record = providerMetadata?.[metadataKey];
const builtinIds =
record && typeof record === 'object'
? (record as Record<string, unknown>)['builtinIds']
: undefined;
Comment thread
yiliang114 marked this conversation as resolved.
const recorded = Array.isArray(builtinIds)
? builtinIds.filter((id): id is string => typeof id === 'string')
: [];
const defaults = new Set(provider.models?.map((model) => model.id) ?? []);
return recorded.filter((id) => !defaults.has(id));
},
),
ExtensionManager: vi.fn().mockImplementation(() => ({
refreshCache: mockExtensionManagerState.refreshCache,
getLoadedExtensions: vi.fn(() => mockExtensionManagerState.extensions),
Expand Down Expand Up @@ -11143,6 +11182,68 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
await agentPromise;
});

it('qwen/providers/list excludes stale built-ins from existing modelIds', async () => {
const settings = {
...makeSessionSettings(),
merged: {
mcpServers: {},
env: { DEEPSEEK_API_KEY: 'sk-existing' },
modelProviders: {
openai: [
{
id: 'deepseek-chat',
baseUrl: 'https://api.deepseek.com/v1',
envKey: 'DEEPSEEK_API_KEY',
},
{
id: 'deepseek-renamed-away',
baseUrl: 'https://api.deepseek.com/v1',
envKey: 'DEEPSEEK_API_KEY',
},
{
id: 'my-custom-model',
baseUrl: 'https://api.deepseek.com/v1',
envKey: 'DEEPSEEK_API_KEY',
},
],
},
providerMetadata: {
deepseek: {
version: 'old-version-hash',
baseUrl: 'https://api.deepseek.com',
builtinIds: ['deepseek-chat', 'deepseek-renamed-away'],
},
},
},
} as unknown as LoadedSettings;
const agentPromise = runAcpAgent(mockConfig, settings, mockArgv);

await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());

const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;

const providers = await agent.extMethod('qwen/providers/list', {});
expect(providers).toEqual({
providers: [
expect.objectContaining({
id: 'deepseek',
existingConfig: expect.objectContaining({
// The recorded built-in that is no longer a default must not be
// serialized — echoing it back on connect would reinstall it.
modelIds: ['deepseek-chat', 'my-custom-model'],
}),
}),
],
});

mockConnectionState.resolve();
await agentPromise;
});

it('qwen/skills/install rejects http and non-GitHub source URLs', async () => {
mockConfig.getSkillManager = vi.fn().mockReturnValue({
parseSkillContent: vi.fn(),
Expand Down
23 changes: 22 additions & 1 deletion packages/cli/src/acp-integration/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
getDefaultBaseUrlForProtocol,
getDefaultModelIds,
getScopedEnvContents,
getStaleBuiltinIds,
QwenOAuth2Event,
qwenOAuth2Events,
resolveBaseUrl,
Expand All @@ -44,6 +45,7 @@ import {
MCPServerStatus,
McpTransportPool,
POOLED_TRANSPORTS_DEFAULT,
PROVIDER_METADATA_NS,
INVOCATION_CONTEXT_META_KEY,
PRIVATE_ACP_CAPABILITY_ENV,
PRIVATE_PARENT_CAPABILITY_META_KEY,
Expand Down Expand Up @@ -2084,13 +2086,32 @@ function readExistingProviderConfig(

const advancedConfig = readExistingAdvancedConfig(firstModel);

// Same classification as the update flow (via getStaleBuiltinIds): ids
// recorded as built-in at the last install but no longer current defaults
// are stale built-ins — don't serialize them, or clients that echo modelIds
// back on connect would reinstall what the update cleans up.
const staleBuiltinIds = new Set(
getStaleBuiltinIds(
config,
(settings.merged as Record<string, unknown>)[PROVIDER_METADATA_NS] as
| Record<string, unknown>
| undefined,
),
);

return {
protocol,
baseUrl: sanitizeProviderBaseUrl(baseUrl),
// Never serialize the raw secret over the ACP wire. Expose only whether a
// key is stored; the client can omit `apiKey` on connect to keep it.
...(apiKey ? { hasApiKey: true } : {}),
...(existing ? { modelIds: existing.models.map((model) => model.id) } : {}),
...(existing
? {
modelIds: existing.models
Comment thread
yiliang114 marked this conversation as resolved.
.map((model) => model.id)
.filter((id) => !staleBuiltinIds.has(id)),
Comment thread
yiliang114 marked this conversation as resolved.
}
: {}),
...(advancedConfig ? { advancedConfig } : {}),
};
}
Expand Down
95 changes: 95 additions & 0 deletions packages/cli/src/ui/auth/AuthDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1338,6 +1338,101 @@ describe('AuthDialog', { timeout: 15000 }, () => {
},
);

itWhenTuiInputReliable(
'should not pre-fill stale built-in model IDs recorded in providerMetadata',
async () => {
// The saved model list holds a renamed-away built-in (recorded in
// providerMetadata.builtinIds but no longer a default) alongside a
// true custom model — only the custom one may be prefilled, or the
// wizard would reinstall what the update flow cleans up.
const savedSettings = {
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
modelProviders: {
openai: [
{
id: 'renamed-away-model',
name: '[ModelStudio Token Plan] renamed-away-model',
baseUrl:
'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1',
envKey: 'BAILIAN_TOKEN_PLAN_API_KEY',
},
{
id: 'my-custom-token-model',
name: '[ModelStudio Token Plan] my-custom-token-model',
baseUrl:
'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1',
envKey: 'BAILIAN_TOKEN_PLAN_API_KEY',
},
],
},
providerMetadata: {
'token-plan': {
version: 'old-version-hash',
baseUrl:
'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1',
builtinIds: ['qwen3.7-plus', 'qwen3.7-max', 'renamed-away-model'],
},
},
} as unknown as Settings;
const settings: LoadedSettings = new LoadedSettings(
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
originalSettings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: {},
originalSettings: {},
path: '',
},
{
settings: savedSettings,
originalSettings: savedSettings,
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
originalSettings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
true,
new Set(),
);

const { stdin, lastFrame, unmount } = renderAuthDialog(settings);

await waitForSelectedOption(lastFrame, 'Alibaba ModelStudio');
stdin.write('\r');
await waitForSelectedOption(lastFrame, 'Coding Plan');
await moveDownAndWaitForSelection(stdin, lastFrame, 'Token Plan');
await pressEnterAndWaitFor(
stdin,
lastFrame,
'Alibaba ModelStudio · Step 1/3 · Region',
);
await pressEnterAndWaitFor(
stdin,
lastFrame,
'Alibaba ModelStudio · Step 2/3 · API Key',
);

await typeText(stdin, 'sk-token-plan');

await pressEnterAndWaitFor(
stdin,
lastFrame,
'Alibaba ModelStudio · Step 3/3 · Model IDs',
);

expect(lastFrame()).toContain('my-custom-token-model');
expect(lastFrame()).not.toContain('renamed-away-model');

unmount();
},
);

itWhenTuiInputReliable(
'should return from Token Plan API key input to Token Plan selection',
async () => {
Expand Down
18 changes: 17 additions & 1 deletion packages/cli/src/ui/auth/AuthDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
findProviderByCredentials,
findExistingProviderModels,
getDefaultModelIds,
getStaleBuiltinIds,
PROVIDER_METADATA_NS,
customProvider,
ALIBABA_PROVIDERS,
THIRD_PARTY_PROVIDERS,
Expand Down Expand Up @@ -180,7 +182,21 @@ export function AuthDialog(): React.JSX.Element {
);
if (!saved) return [];
const builtinIds = new Set(getDefaultModelIds(providerConfig));
return saved.models.map((m) => m.id).filter((id) => !builtinIds.has(id));
// Same classification as the update flow (via getStaleBuiltinIds): ids
// recorded as built-in at the last install but no longer current defaults
// are stale built-ins, not user customs — don't prefill what the update
// cleans up.
const staleBuiltinIds = new Set(
getStaleBuiltinIds(
providerConfig,
(settings.merged as Record<string, unknown>)[PROVIDER_METADATA_NS] as
| Record<string, unknown>
| undefined,
),
);
return saved.models
.map((m) => m.id)
.filter((id) => !builtinIds.has(id) && !staleBuiltinIds.has(id));
};

const handleProviderSelect = (providerId: string) => {
Expand Down
Loading
Loading