diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 3b187a7217b..7638795068c 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -4165,6 +4165,22 @@ describe('createAcpSessionBridge', () => { expect(setModelCalls).toHaveLength(1); expect(setModelCalls[0]?.sessionId).toBe(session.sessionId); expect(setModelCalls[0]?.modelId).toBe('qwen3-coder'); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + lastEventId: 0, + }); + const it = iter[Symbol.asyncIterator](); + const switched = await it.next(); + expect(switched.value?.type).toBe('model_switched'); + const settingsChanged = await it.next(); + expect(settingsChanged.value?.type).toBe('settings_changed'); + expect(settingsChanged.value?.originatorClientId).toBe(session.clientId); + expect(settingsChanged.value?.data).toEqual({ + key: 'model.name', + value: 'qwen3-coder', + }); + abort.abort(); await bridge.shutdown(); }); @@ -5246,6 +5262,12 @@ describe('createAcpSessionBridge', () => { sessionId: session.sessionId, modelId: 'qwen3-coder', }); + const settingsChanged = await it.next(); + expect(settingsChanged.value?.type).toBe('settings_changed'); + expect(settingsChanged.value?.data).toEqual({ + key: 'model.name', + value: 'qwen3-coder', + }); abort.abort(); await bridge.shutdown(); }); @@ -5268,6 +5290,9 @@ describe('createAcpSessionBridge', () => { const next = await it.next(); expect(next.value?.type).toBe('model_switched'); expect(next.value?.originatorClientId).toBe(session.clientId); + const settingsChanged = await it.next(); + expect(settingsChanged.value?.type).toBe('settings_changed'); + expect(settingsChanged.value?.originatorClientId).toBe(session.clientId); abort.abort(); await bridge.shutdown(); }); @@ -9312,11 +9337,13 @@ describe('createHttpAcpBridge — side-channel state layer (#4511)', () => { undefined, ); - const seen: Array<{ type: string; modelId?: string }> = []; + const seen: Array<{ type: string; modelId?: string; value?: string }> = + []; for await (const e of iter) { seen.push({ type: e.type, modelId: (e.data as { modelId?: string })?.modelId, + value: (e.data as { value?: string })?.value, }); if (seen.filter((s) => s.type === 'model_switched').length === 2) break; } @@ -9324,6 +9351,17 @@ describe('createHttpAcpBridge — side-channel state layer (#4511)', () => { // First the requested change, then the corrective one from reconcile. expect(switches[0]?.modelId).toBe('qwen-max'); expect(switches[1]?.modelId).toBe('qwen-turbo'); + const requestedSwitchIndex = seen.findIndex( + (s) => s.type === 'model_switched' && s.modelId === 'qwen-max', + ); + const settingsChangedIndex = seen.findIndex( + (s) => s.type === 'settings_changed' && s.value === 'qwen-max', + ); + const correctiveSwitchIndex = seen.findIndex( + (s) => s.type === 'model_switched' && s.modelId === 'qwen-turbo', + ); + expect(settingsChangedIndex).toBeGreaterThan(requestedSwitchIndex); + expect(settingsChangedIndex).toBeLessThan(correctiveSwitchIndex); abort.abort(); await bridge.shutdown(); }); diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index d90e8eedefc..24af31254ac 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -1614,6 +1614,14 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { transportClosed, ]); publishModelSwitched(entry, modelId, originatorClientId); + broadcastWorkspaceEvent({ + type: 'settings_changed', + data: { + key: 'model.name', + value: modelId, + }, + ...(originatorClientId ? { originatorClientId } : {}), + }); succeeded = true; } catch (err) { // Surface the failure to ALL attached clients, not just the @@ -3944,6 +3952,14 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // the agent's authoritative canonical id and re-publishes if it // differs. publishModelSwitched(entry, req.modelId, originatorClientId); + broadcastWorkspaceEvent({ + type: 'settings_changed', + data: { + key: 'model.name', + value: req.modelId, + }, + ...(originatorClientId ? { originatorClientId } : {}), + }); succeeded = true; return result; } finally { @@ -3983,8 +3999,6 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { }); throw err; } - // model_switched is published inside the work callback above (while the - // suppress flag is still set), mirroring applyModelServiceId. return response; }, diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index 7f1ead748b1..4bd933aa6c4 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -380,6 +380,7 @@ export interface ServeWorkspaceProvidersStatus { v: typeof STATUS_SCHEMA_VERSION; workspaceCwd: string; initialized: boolean; + acpChannelLive?: boolean; current?: ServeWorkspaceProviderCurrent; providers: ServeWorkspaceProviderStatus[]; errors?: ServeStatusCell[]; diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 924ca497c68..cbd51a18430 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -460,14 +460,19 @@ vi.mock('./session/Session.js', () => ({ availableSkills: [], }), })); -vi.mock('../utils/acpModelUtils.js', () => ({ - formatAcpModelId: vi.fn( - (modelId: string, authType: string) => `${modelId}(${authType})`, - ), - parseAcpBaseModelId: vi.fn((modelId: string) => - modelId.replace(/\([^)]+\)$/, ''), - ), -})); +vi.mock('../utils/acpModelUtils.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + formatAcpModelId: vi.fn( + (modelId: string, authType: string) => `${modelId}(${authType})`, + ), + parseAcpBaseModelId: vi.fn((modelId: string) => + modelId.replace(/\([^)]+\)$/, ''), + ), + }; +}); vi.mock('../utils/languageUtils.js', () => ({ updateOutputLanguageFile: vi.fn(), writeOutputLanguageAndRegisterPath: vi.fn( @@ -1067,6 +1072,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { beforeEach(() => { vi.clearAllMocks(); mockConnectionState.reset(); + mockRunExitCleanup.mockResolvedValue(undefined); mockExtensionManagerState.extensions = []; mockExtensionManagerState.refreshCache.mockResolvedValue(undefined); lastSessionMock = undefined; diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index a5e65c4b363..05e8289d330 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -146,6 +146,7 @@ import { HistoryReplayer } from './session/HistoryReplayer.js'; import { formatAcpModelId, parseAcpBaseModelId, + sanitizeProviderBaseUrl, } from '../utils/acpModelUtils.js'; import { updateOutputLanguageFile, @@ -243,25 +244,6 @@ function hasFailedDisplayStatus( (display as { status?: unknown }).status === 'failed' ); } - -function sanitizeProviderBaseUrl(baseUrl: string): string { - const scheme = baseUrl.match(/^[A-Za-z][A-Za-z\d+.-]*:\/\//); - if (!scheme) { - return baseUrl; - } - - const authorityStart = scheme[0].length; - const rest = baseUrl.slice(authorityStart); - const authorityEnd = rest.search(/[/?#]/); - const authority = authorityEnd === -1 ? rest : rest.slice(0, authorityEnd); - const at = authority.lastIndexOf('@'); - if (at === -1) { - return baseUrl; - } - - return `${baseUrl.slice(0, authorityStart)}${authority.slice(at + 1)}${rest.slice(authority.length)}`; -} - /** * Env-var candidates per auth method, used by `buildAuthPreflightCell` for * a side-effect-free presence check. Mirrors `AUTH_ENV_MAPPINGS` from diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index bd712c7730c..d321bc8c4fb 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -52,6 +52,7 @@ import type { } from '@qwen-code/qwen-code-core'; import { createBridgeFileSystemAdapter } from './bridge-file-system-adapter.js'; import { createDaemonStatusProvider } from './daemon-status-provider.js'; +import { createWorkspaceProvidersStatusProvider } from './workspace-providers-status.js'; import { isLoopbackBind } from './loopback-binds.js'; import { resolveWebShellDir } from './web-shell-static.js'; import { parseAllowOriginPatterns } from './auth.js'; @@ -907,6 +908,8 @@ export async function runQwenServe( // service so both answer env/preflight cells from the same daemon-local // implementation. const statusProvider = createDaemonStatusProvider(); + const workspaceProvidersStatusProvider = + createWorkspaceProvidersStatusProvider(); const bridge = deps.bridge ?? @@ -989,6 +992,7 @@ export async function runQwenServe( contextFilename: contextFilenameForInit ?? 'QWEN.md', // Daemon-host status provider for env + preflight cells. statusProvider, + workspaceProvidersStatusProvider, // Channel liveness check — proxied through the bridge's live-channel // probe (not session count: a channel can be live with zero attached // sessions during the cold-spawn window). diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 72e0dae3146..807d7ab0bc0 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -101,6 +101,7 @@ import type { import { CAPABILITIES_SCHEMA_VERSION, type ServeOptions } from './types.js'; import type { DaemonLogger } from './daemon-logger.js'; import { FsError, type WorkspaceFileSystemFactory } from './fs/index.js'; +import { resetHomeEnvBootstrapForTesting } from '../config/settings.js'; const baseOpts: ServeOptions = { hostname: '127.0.0.1', @@ -120,6 +121,14 @@ function fakeDaemonLog(): DaemonLogger { }; } +function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } +} + // Workspace fixtures must round-trip through `path.resolve` so the // expected values match the canonicalized form the route produces on // every platform. On Windows `path.resolve('/work/bound')` returns @@ -2197,7 +2206,16 @@ describe('createServeApp', () => { expect(res.body.servers[2].disabledReason).toBe('budget'); }); - it('returns workspace skills and providers status from the bridge', async () => { + it('returns workspace skills from the bridge and providers from daemon-local settings', async () => { + const tempHome = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-serve-providers-'), + ); + const previousQwenHome = process.env['QWEN_HOME']; + const previousRuntimeDir = process.env['QWEN_RUNTIME_DIR']; + const previousSystemSettings = + process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH']; + const previousSystemDefaults = + process.env['QWEN_CODE_SYSTEM_DEFAULTS_PATH']; const skills: ServeWorkspaceSkillsStatus = { v: 1, workspaceCwd: WS_BOUND, @@ -2213,54 +2231,55 @@ describe('createServeApp', () => { }, ], }; - const providers: ServeWorkspaceProvidersStatus = { - v: 1, - workspaceCwd: WS_BOUND, - initialized: true, - current: { authType: 'qwen', modelId: 'qwen3(qwen)' }, - providers: [ - { - kind: 'model_provider', - status: 'ok', - authType: 'qwen', - current: true, - models: [ - { - modelId: 'qwen3(qwen)', - baseModelId: 'qwen3', - name: 'Qwen 3', - description: null, - contextLimit: 4096, - isCurrent: true, - isRuntime: false, - }, - ], - }, - ], - }; - const bridge = fakeBridge({ - workspaceSkillsImpl: async () => skills, - workspaceProvidersImpl: async () => providers, - }); - const app = createServeApp( - { ...baseOpts, workspace: WS_BOUND }, - undefined, - { bridge }, - ); + try { + process.env['QWEN_HOME'] = path.join(tempHome, 'home'); + process.env['QWEN_RUNTIME_DIR'] = path.join(tempHome, 'runtime'); + process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH'] = path.join( + tempHome, + 'system-settings.json', + ); + process.env['QWEN_CODE_SYSTEM_DEFAULTS_PATH'] = path.join( + tempHome, + 'system-defaults.json', + ); + resetHomeEnvBootstrapForTesting(); - const skillsRes = await request(app) - .get('/workspace/skills') - .set('Host', `127.0.0.1:${baseOpts.port}`); - const providersRes = await request(app) - .get('/workspace/providers') - .set('Host', `127.0.0.1:${baseOpts.port}`); + const bridge = fakeBridge({ + workspaceSkillsImpl: async () => skills, + }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const skillsRes = await request(app) + .get('/workspace/skills') + .set('Host', `127.0.0.1:${baseOpts.port}`); + const providersRes = await request(app) + .get('/workspace/providers') + .set('Host', `127.0.0.1:${baseOpts.port}`); - expect(skillsRes.status).toBe(200); - expect(skillsRes.body).toEqual(skills); - expect(providersRes.status).toBe(200); - expect(providersRes.body).toEqual(providers); - expect(bridge.workspaceSkillsCalls).toBe(1); - expect(bridge.workspaceProvidersCalls).toBe(1); + expect(skillsRes.status).toBe(200); + expect(skillsRes.body).toEqual(skills); + expect(providersRes.status).toBe(200); + expect(providersRes.body).toMatchObject({ + v: 1, + workspaceCwd: WS_BOUND, + initialized: true, + acpChannelLive: false, + }); + expect(providersRes.body.providers.length).toBeGreaterThan(0); + expect(bridge.workspaceSkillsCalls).toBe(1); + expect(bridge.workspaceProvidersCalls).toBe(0); + } finally { + restoreEnv('QWEN_HOME', previousQwenHome); + restoreEnv('QWEN_RUNTIME_DIR', previousRuntimeDir); + restoreEnv('QWEN_CODE_SYSTEM_SETTINGS_PATH', previousSystemSettings); + restoreEnv('QWEN_CODE_SYSTEM_DEFAULTS_PATH', previousSystemDefaults); + resetHomeEnvBootstrapForTesting(); + await fsp.rm(tempHome, { recursive: true, force: true }); + } }); it('returns workspace tools status from the bridge', async () => { diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 6f4c01556fe..a1106f646f4 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -63,6 +63,7 @@ import { mapDomainErrorToErrorKind } from '@qwen-code/acp-bridge'; import { QwenOAuthDeviceFlowProvider } from './auth/qwen-device-flow-provider.js'; import { createBridgeFileSystemAdapter } from './bridge-file-system-adapter.js'; import { createDaemonStatusProvider } from './daemon-status-provider.js'; +import { createWorkspaceProvidersStatusProvider } from './workspace-providers-status.js'; import { isServeDebugMode } from './debug-mode.js'; import { SUPPORTED_LANGUAGES } from '../i18n/index.js'; import { loadSettings } from '../config/settings.js'; @@ -1207,6 +1208,8 @@ export function createServeApp( boundWorkspace, contextFilename: deps.contextFilename ?? 'QWEN.md', statusProvider: createDaemonStatusProvider(), + workspaceProvidersStatusProvider: + createWorkspaceProvidersStatusProvider(), isChannelLive: () => bridge.isChannelLive(), persistDisabledTools: deps.persistDisabledTools ?? diff --git a/packages/cli/src/serve/workspace-providers-status.test.ts b/packages/cli/src/serve/workspace-providers-status.test.ts new file mode 100644 index 00000000000..b173437f8c0 --- /dev/null +++ b/packages/cli/src/serve/workspace-providers-status.test.ts @@ -0,0 +1,354 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { promises as fs } from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { resetHomeEnvBootstrapForTesting } from '../config/settings.js'; +import { createWorkspaceProvidersStatusProvider } from './workspace-providers-status.js'; + +const coreMock = vi.hoisted(() => ({ + throwModelsConfigError: false, + modelsConfigErrorMessage: + 'Failed loading provider https://user:secret@broken.example/v1', +})); + +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actual = + await importOriginal(); + class TestModelsConfig extends actual.ModelsConfig { + constructor(options: ConstructorParameters[0]) { + if (coreMock.throwModelsConfigError) { + throw new Error(coreMock.modelsConfigErrorMessage); + } + super(options); + } + } + return { + ...actual, + ModelsConfig: TestModelsConfig, + }; +}); + +describe('createWorkspaceProvidersStatusProvider', () => { + let tmpDir: string; + let workspace: string; + let qwenHome: string; + const originalQwenHome = process.env['QWEN_HOME']; + const originalQwenRuntimeDir = process.env['QWEN_RUNTIME_DIR']; + const originalSystemSettings = process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH']; + const originalSystemDefaults = process.env['QWEN_CODE_SYSTEM_DEFAULTS_PATH']; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'providers-status-')); + workspace = path.join(tmpDir, 'workspace'); + qwenHome = path.join(tmpDir, 'qwen-home'); + await fs.mkdir(workspace, { recursive: true }); + await fs.mkdir(qwenHome, { recursive: true }); + process.env['QWEN_HOME'] = qwenHome; + process.env['QWEN_RUNTIME_DIR'] = path.join(tmpDir, 'runtime'); + process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH'] = path.join( + tmpDir, + 'system-settings.json', + ); + process.env['QWEN_CODE_SYSTEM_DEFAULTS_PATH'] = path.join( + tmpDir, + 'system-defaults.json', + ); + coreMock.throwModelsConfigError = false; + coreMock.modelsConfigErrorMessage = + 'Failed loading provider https://user:secret@broken.example/v1'; + resetHomeEnvBootstrapForTesting(); + }); + + afterEach(async () => { + restoreEnv('QWEN_HOME', originalQwenHome); + restoreEnv('QWEN_RUNTIME_DIR', originalQwenRuntimeDir); + restoreEnv('QWEN_CODE_SYSTEM_SETTINGS_PATH', originalSystemSettings); + restoreEnv('QWEN_CODE_SYSTEM_DEFAULTS_PATH', originalSystemDefaults); + resetHomeEnvBootstrapForTesting(); + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('reads fresh default model settings on every request', async () => { + const provider = createWorkspaceProvidersStatusProvider({ env: {} }); + await writeUserSettings({ + security: { auth: { selectedType: 'openai' } }, + model: { name: 'model-a' }, + modelProviders: { + openai: [ + { + id: 'model-a', + name: 'Model A', + baseUrl: 'https://user:secret@api-a.example/v1', + }, + { + id: 'model-b', + name: 'Model B', + baseUrl: 'https://api-b.example/v1', + }, + ], + }, + }); + + const first = await provider(workspace, false); + expect(first).toMatchObject({ + initialized: true, + acpChannelLive: false, + current: { + authType: 'openai', + modelId: 'model-a(openai)', + baseUrl: 'https://api-a.example/v1', + }, + }); + expect(JSON.stringify(first)).not.toContain('secret'); + + await writeUserSettings({ + security: { auth: { selectedType: 'openai' } }, + model: { name: 'model-b' }, + modelProviders: { + openai: [ + { id: 'model-a', name: 'Model A' }, + { id: 'model-b', name: 'Model B' }, + ], + }, + }); + + const second = await provider(workspace, false); + expect(second.current?.modelId).toBe('model-b(openai)'); + }); + + it('marks only the model matching persisted model.baseUrl as current', async () => { + const provider = createWorkspaceProvidersStatusProvider({ env: {} }); + await writeUserSettings({ + security: { auth: { selectedType: 'openai' } }, + model: { + name: 'shared-model', + baseUrl: 'https://api-two.example/v1', + }, + modelProviders: { + openai: [ + { + id: 'shared-model', + name: 'Shared One', + baseUrl: 'https://api-one.example/v1', + }, + { + id: 'shared-model', + name: 'Shared Two', + baseUrl: 'https://api-two.example/v1', + }, + ], + }, + }); + + const result = await provider(workspace, false); + const models = result.providers.flatMap((p) => p.models); + + expect(result.current?.modelId).toBe('shared-model(openai)'); + expect( + models.find((m) => m.baseUrl === 'https://api-one.example/v1')?.isCurrent, + ).toBe(false); + expect( + models.find((m) => m.baseUrl === 'https://api-two.example/v1')?.isCurrent, + ).toBe(true); + }); + + it('sanitizes credentials from provider warning URLs', async () => { + const provider = createWorkspaceProvidersStatusProvider({ env: {} }); + await writeUserSettings({ + security: { auth: { selectedType: 'openai' } }, + model: { + name: 'shared-model', + baseUrl: 'https://user:sec ret@stale.example/v1', + }, + modelProviders: { + openai: { + protocol: 'openai', + models: [ + { + id: 'shared-model', + name: 'Shared Current', + baseUrl: `https://user:cur'rent@current.example/v1`, + }, + ], + }, + }, + }); + + const result = await provider(workspace, false); + const warning = result.errors?.[0]?.error; + + expect(warning).toContain('Persisted model.baseUrl'); + expect(warning).toContain('https://stale.example/v1'); + expect(warning).toContain('https://current.example/v1'); + expect(JSON.stringify(result)).not.toContain('secret'); + expect(JSON.stringify(result)).not.toContain('sec ret'); + expect(JSON.stringify(result)).not.toContain(`cur'rent`); + }); + + it('does not mark baseUrl variants current when no baseUrl is resolved', async () => { + const provider = createWorkspaceProvidersStatusProvider({ env: {} }); + await writeUserSettings({ + security: { auth: { selectedType: 'openai' } }, + model: { name: 'shared-model' }, + modelProviders: { + openai: [ + { + id: 'shared-model', + name: 'Shared Default', + }, + { + id: 'shared-model', + name: 'Shared Proxy', + baseUrl: 'https://proxy.example/v1', + }, + ], + }, + }); + + const result = await provider(workspace, false); + const models = result.providers.flatMap((p) => p.models); + + expect(result.current?.modelId).toBe('shared-model(openai)'); + expect(models.find((m) => m.name === 'Shared Default')?.isCurrent).toBe( + true, + ); + expect( + models.find((m) => m.baseUrl === 'https://proxy.example/v1')?.isCurrent, + ).toBe(false); + }); + + it('uses the auth-specific env model when settings.model.name is absent', async () => { + const provider = createWorkspaceProvidersStatusProvider({ + env: { OPENAI_MODEL: 'env-model' }, + }); + await writeUserSettings({ + security: { auth: { selectedType: 'openai' } }, + modelProviders: { + openai: [{ id: 'env-model', name: 'Env Model' }], + }, + }); + + const result = await provider(workspace, false); + + expect(result.current?.modelId).toBe('env-model(openai)'); + expect( + result.providers + .flatMap((p) => p.models) + .find((m) => m.modelId === 'env-model(openai)')?.isCurrent, + ).toBe(true); + }); + + it('includes only non-empty fast model settings in current selection', async () => { + const provider = createWorkspaceProvidersStatusProvider({ env: {} }); + await writeUserSettings({ + security: { auth: { selectedType: 'openai' } }, + model: { name: 'main-model' }, + fastModel: 'fast-model', + modelProviders: { + openai: [{ id: 'main-model', name: 'Main Model' }], + }, + }); + + const withFastModel = await provider(workspace, false); + expect(withFastModel.current?.fastModelId).toBe('fast-model'); + + await writeUserSettings({ + security: { auth: { selectedType: 'openai' } }, + model: { name: 'main-model' }, + fastModel: '', + modelProviders: { + openai: [{ id: 'main-model', name: 'Main Model' }], + }, + }); + + const withEmptyFastModel = await provider(workspace, false); + expect(withEmptyFastModel.current).not.toHaveProperty('fastModelId'); + }); + + it('does not include runtime models in the workspace provider catalog', async () => { + const provider = createWorkspaceProvidersStatusProvider({ + argv: { model: 'runtime-only-model' }, + env: { OPENAI_API_KEY: 'sk-test-key' }, + }); + await writeUserSettings({ + security: { auth: { selectedType: 'openai' } }, + modelProviders: { + openai: [{ id: 'registry-model', name: 'Registry Model' }], + }, + }); + + const result = await provider(workspace, false); + + expect(result.current?.modelId).toBe('runtime-only-model(openai)'); + expect( + result.providers + .flatMap((p) => p.models) + .some((m) => m.modelId === 'runtime-only-model(openai)'), + ).toBe(false); + }); + + it('does not report initialized when provider catalog construction fails', async () => { + const provider = createWorkspaceProvidersStatusProvider({ env: {} }); + await writeUserSettings({ + security: { auth: { selectedType: 'openai' } }, + modelProviders: { + openai: [{ name: 'Broken Model' }], + }, + }); + + const result = await provider(workspace, true); + + expect(result).toMatchObject({ + initialized: false, + acpChannelLive: true, + providers: [], + errors: [ + { + kind: 'providers', + status: 'error', + }, + ], + }); + }); + + it('sanitizes credentials from provider construction errors', async () => { + coreMock.throwModelsConfigError = true; + coreMock.modelsConfigErrorMessage = + 'Failed loading provider https://user:sec ret@broken.example/v1'; + const provider = createWorkspaceProvidersStatusProvider({ env: {} }); + await writeUserSettings({ + security: { auth: { selectedType: 'openai' } }, + modelProviders: { + openai: [{ id: 'model-a', name: 'Model A' }], + }, + }); + + const result = await provider(workspace, true); + + expect(JSON.stringify(result)).toContain('https://broken.example/v1'); + expect(JSON.stringify(result)).not.toContain('sec ret'); + expect(result.initialized).toBe(false); + }); + + async function writeUserSettings(settings: Record) { + await fs.writeFile( + path.join(qwenHome, 'settings.json'), + JSON.stringify(settings), + 'utf8', + ); + } +}); + +function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } +} diff --git a/packages/cli/src/serve/workspace-providers-status.ts b/packages/cli/src/serve/workspace-providers-status.ts new file mode 100644 index 00000000000..a663b883c32 --- /dev/null +++ b/packages/cli/src/serve/workspace-providers-status.ts @@ -0,0 +1,359 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ModelsConfig, tokenLimit } from '@qwen-code/qwen-code-core'; +import type { AuthType } from '@qwen-code/qwen-code-core'; +import type { + ServeWorkspaceProviderCurrent, + ServeWorkspaceProviderModel, + ServeWorkspaceProviderStatus, + ServeWorkspaceProvidersStatus, +} from '@qwen-code/acp-bridge/status'; +import { STATUS_SCHEMA_VERSION } from '@qwen-code/acp-bridge/status'; +import { loadSettings } from '../config/settings.js'; +import type { Settings } from '../config/settings.js'; +import { + getAuthTypeFromEnv, + resolveCliGenerationConfig, +} from '../utils/modelConfigUtils.js'; +import type { CliGenerationConfigInputs } from '../utils/modelConfigUtils.js'; +import { + formatAcpModelId, + parseAcpBaseModelId, + sanitizeProviderBaseUrl, +} from '../utils/acpModelUtils.js'; + +export type WorkspaceProvidersStatusProvider = ( + workspaceCwd: string, + acpChannelLive: boolean, +) => Promise; + +export interface WorkspaceProvidersStatusProviderOptions { + argv?: Partial; + env?: Record; +} + +export function createWorkspaceProvidersStatusProvider( + options: WorkspaceProvidersStatusProviderOptions = {}, +): WorkspaceProvidersStatusProvider { + return async (workspaceCwd, acpChannelLive) => + buildWorkspaceProvidersStatus(workspaceCwd, acpChannelLive, options); +} + +function buildWorkspaceProvidersStatus( + workspaceCwd: string, + acpChannelLive: boolean, + options: WorkspaceProvidersStatusProviderOptions, +): ServeWorkspaceProvidersStatus { + try { + const loaded = loadSettings(workspaceCwd); + const settings = loaded.merged; + const env = + options.env ?? (process.env as Record); + const selectedAuthType = + settings.security?.auth?.selectedType ?? getAuthTypeFromEnv(); + const argv: CliGenerationConfigInputs['argv'] = { + model: options.argv?.model, + openaiApiKey: options.argv?.openaiApiKey, + openaiBaseUrl: options.argv?.openaiBaseUrl, + openaiLogging: options.argv?.openaiLogging, + openaiLoggingDir: options.argv?.openaiLoggingDir, + }; + const resolvedCliConfig = resolveCliGenerationConfig({ + argv, + settings, + selectedAuthType, + env, + }); + const modelsConfig = new ModelsConfig({ + initialAuthType: selectedAuthType, + modelProvidersConfig: settings.modelProviders, + generationConfig: resolvedCliConfig.generationConfig, + generationConfigSources: resolvedCliConfig.sources, + }); + const currentAuth = selectedAuthType; + const currentModelId = ( + resolvedCliConfig.model || + modelsConfig.getModel() || + '' + ).trim(); + const hasCurrentModel = currentModelId.length > 0; + const currentAcpModelId = + hasCurrentModel && currentAuth + ? formatAcpModelId(currentModelId, currentAuth) + : currentModelId || undefined; + const currentBaseUrl = resolvedCliConfig.sources['baseUrl'] + ? resolvedCliConfig.baseUrl || undefined + : undefined; + const fastModelId = + typeof settings.fastModel === 'string' && settings.fastModel.length > 0 + ? settings.fastModel + : undefined; + const providers = new Map(); + const explicitModelBaseUrls = buildExplicitModelBaseUrls( + settings.modelProviders, + ); + + for (const model of modelsConfig.getAllConfiguredModels()) { + if (model.isRuntimeModel) continue; + const authType = String(model.authType); + let provider = providers.get(authType); + if (!provider) { + provider = { + kind: 'model_provider', + status: 'ok', + authType, + current: false, + models: [], + }; + providers.set(authType, provider); + } + + const effectiveModelId = model.id; + const modelId = formatAcpModelId(effectiveModelId, model.authType); + const isCurrent = + currentAuth === model.authType && + hasCurrentModel && + matchesCurrentModel(currentModelId, effectiveModelId, modelId) && + matchesCurrentBaseUrl( + currentBaseUrl, + model.baseUrl, + model.baseUrl !== undefined && + explicitModelBaseUrls.has( + modelBaseUrlKey(authType, effectiveModelId, model.baseUrl), + ), + ); + const providerModel: ServeWorkspaceProviderModel = { + modelId, + baseModelId: parseAcpBaseModelId(effectiveModelId), + name: model.label, + ...(model.description !== undefined + ? { description: model.description } + : {}), + contextLimit: model.contextWindowSize ?? tokenLimit(effectiveModelId), + ...(model.modalities !== undefined + ? { modalities: model.modalities } + : {}), + ...(model.baseUrl !== undefined + ? { baseUrl: sanitizeProviderBaseUrl(model.baseUrl) } + : {}), + ...(model.envKey !== undefined ? { envKey: model.envKey } : {}), + isCurrent, + isRuntime: false, + }; + provider.models.push(providerModel); + if (isCurrent) provider.current = true; + } + + const current = buildCurrent( + currentAuth, + currentAcpModelId, + currentBaseUrl, + fastModelId, + ); + + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd, + initialized: true, + acpChannelLive, + ...(current ? { current } : {}), + providers: [...providers.values()], + ...(resolvedCliConfig.warnings.length > 0 + ? { + errors: resolvedCliConfig.warnings.map((warning) => ({ + kind: 'providers', + status: 'warning' as const, + error: sanitizeProviderWarning(warning), + })), + } + : {}), + }; + } catch (error) { + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd, + initialized: false, + acpChannelLive, + providers: [], + errors: [ + { + kind: 'providers', + status: 'error', + error: sanitizeProviderWarning( + error instanceof Error ? error.message : String(error), + ), + }, + ], + }; + } +} + +function matchesCurrentModel( + currentModelId: string, + baseModelId: string, + acpModelId: string, +): boolean { + return currentModelId === baseModelId || currentModelId === acpModelId; +} + +function matchesCurrentBaseUrl( + currentBaseUrl: string | undefined, + modelBaseUrl: string | undefined, + modelHasExplicitBaseUrl: boolean, +): boolean { + if (!currentBaseUrl) return !modelHasExplicitBaseUrl; + return currentBaseUrl === modelBaseUrl; +} + +function buildExplicitModelBaseUrls( + modelProviders: Settings['modelProviders'], +): Set { + const baseUrls = new Set(); + if (!modelProviders) return baseUrls; + + for (const [authType, providerConfig] of Object.entries(modelProviders)) { + const models = readProviderModels(providerConfig); + for (const model of models) { + if ( + typeof model.id === 'string' && + typeof model.baseUrl === 'string' && + model.baseUrl.length > 0 + ) { + baseUrls.add(modelBaseUrlKey(authType, model.id, model.baseUrl)); + } + } + } + return baseUrls; +} + +type ProviderModelBaseUrlConfig = { + id?: unknown; + baseUrl?: unknown; +}; + +function readProviderModels( + providerConfig: unknown, +): ProviderModelBaseUrlConfig[] { + if (Array.isArray(providerConfig)) { + return providerConfig.filter(isProviderModelBaseUrlConfig); + } + if (typeof providerConfig !== 'object' || providerConfig === null) { + return []; + } + + const { models } = providerConfig as { models?: unknown }; + return Array.isArray(models) + ? models.filter(isProviderModelBaseUrlConfig) + : []; +} + +function isProviderModelBaseUrlConfig( + value: unknown, +): value is ProviderModelBaseUrlConfig { + return typeof value === 'object' && value !== null; +} + +function modelBaseUrlKey( + authType: string, + modelId: string, + baseUrl: string, +): string { + return `${authType}\0${modelId}\0${baseUrl}`; +} + +const URL_LIKE_PATTERN = /\b[A-Za-z][A-Za-z\d+.-]*:\/\/[^\s'"`<>]+/g; +const URL_START_PATTERN = /\b[A-Za-z][A-Za-z\d+.-]*:\/\//g; + +function sanitizeProviderWarning(warning: string): string { + let result = ''; + let index = 0; + let next = findNextUrlStart(warning, index); + + while (next) { + result += warning.slice(index, next.index); + + const segmentEnd = findUrlSegmentEnd(warning, next.index, next.marker); + const segment = warning.slice(next.index, segmentEnd); + result += sanitizeProviderWarningSegment(segment, next.marker.length); + + index = segmentEnd; + next = findNextUrlStart(warning, index); + } + + return result + warning.slice(index); +} + +function findNextUrlStart( + value: string, + from: number, +): { index: number; marker: string } | undefined { + URL_START_PATTERN.lastIndex = from; + const match = URL_START_PATTERN.exec(value); + return match ? { index: match.index, marker: match[0] } : undefined; +} + +function findUrlSegmentEnd( + value: string, + start: number, + marker: string, +): number { + const afterMarker = start + marker.length; + const carriageReturn = value.indexOf('\r', afterMarker); + const lineFeed = value.indexOf('\n', afterMarker); + let lineEnd = value.length; + if (carriageReturn !== -1) lineEnd = Math.min(lineEnd, carriageReturn); + if (lineFeed !== -1) lineEnd = Math.min(lineEnd, lineFeed); + + const nextUrl = findNextUrlStart(value, afterMarker); + + return Math.min(lineEnd, nextUrl?.index ?? value.length); +} + +function sanitizeProviderWarningSegment( + segment: string, + markerLength: number, +): string { + const at = segment.indexOf('@', markerLength); + if ( + at !== -1 && + hasCredentialPrefix(segment, markerLength, at) && + segment[at + 1] !== undefined && + /[A-Za-z0-9.[\]-]/.test(segment[at + 1]) + ) { + return `${segment.slice(0, markerLength)}${segment.slice(at + 1)}`; + } + + return segment.replace(URL_LIKE_PATTERN, (url) => + sanitizeProviderBaseUrl(url), + ); +} + +function hasCredentialPrefix( + segment: string, + markerLength: number, + at: number, +): boolean { + const colon = segment.indexOf(':', markerLength); + if (colon === -1 || colon > at) return false; + const username = segment.slice(markerLength, colon); + return !/[/?#\s'"`<>]/.test(username); +} + +function buildCurrent( + authType: AuthType | undefined, + modelId: string | undefined, + baseUrl: string | undefined, + fastModelId: string | undefined, +): ServeWorkspaceProviderCurrent | undefined { + if (!authType && !modelId && !baseUrl && !fastModelId) return undefined; + return { + ...(authType ? { authType: String(authType) } : {}), + ...(modelId ? { modelId } : {}), + ...(baseUrl ? { baseUrl: sanitizeProviderBaseUrl(baseUrl) } : {}), + ...(fastModelId ? { fastModelId } : {}), + }; +} diff --git a/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts b/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts index 8d3a2791921..ececedb8feb 100644 --- a/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts +++ b/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts @@ -146,7 +146,41 @@ describe('createDaemonWorkspaceService', () => { expect(result.skills).toEqual([]); }); - it('getWorkspaceProvidersStatus delegates with correct method', async () => { + it('getWorkspaceProvidersStatus uses daemon-local provider when present', async () => { + const queryWorkspaceStatus = vi + .fn() + .mockResolvedValue({ v: 1, providers: [] }); + const workspaceProvidersStatusProvider = vi.fn().mockResolvedValue({ + v: 1, + workspaceCwd: '/workspace', + initialized: true, + acpChannelLive: false, + current: { + authType: 'USE_OPENAI', + modelId: 'fresh-model(USE_OPENAI)', + }, + providers: [], + }); + const svc = createDaemonWorkspaceService( + makeDeps({ + queryWorkspaceStatus, + workspaceProvidersStatusProvider, + isChannelLive: () => false, + }), + ); + + const result = await svc.getWorkspaceProvidersStatus(makeCtx()); + + expect(result.current?.modelId).toBe('fresh-model(USE_OPENAI)'); + expect(result.acpChannelLive).toBe(false); + expect(workspaceProvidersStatusProvider).toHaveBeenCalledWith( + '/workspace', + false, + ); + expect(queryWorkspaceStatus).not.toHaveBeenCalled(); + }); + + it('getWorkspaceProvidersStatus keeps ACP fallback without daemon-local provider', async () => { const queryWorkspaceStatus = vi .fn() .mockResolvedValue({ v: 1, providers: [] }); diff --git a/packages/cli/src/serve/workspace-service/index.ts b/packages/cli/src/serve/workspace-service/index.ts index de6ab43a2bd..340ad57959d 100644 --- a/packages/cli/src/serve/workspace-service/index.ts +++ b/packages/cli/src/serve/workspace-service/index.ts @@ -135,6 +135,7 @@ export function createDaemonWorkspaceService( boundWorkspace, contextFilename, statusProvider, + workspaceProvidersStatusProvider, isChannelLive, persistDisabledTools, queryWorkspaceStatus, @@ -161,6 +162,12 @@ export function createDaemonWorkspaceService( }, async getWorkspaceProvidersStatus(_ctx: WorkspaceRequestContext) { + if (workspaceProvidersStatusProvider) { + return workspaceProvidersStatusProvider( + boundWorkspace, + isChannelLive?.() ?? false, + ); + } return queryWorkspaceStatus( SERVE_STATUS_EXT_METHODS.workspaceProviders, () => createIdleWorkspaceProvidersStatus(boundWorkspace), diff --git a/packages/cli/src/serve/workspace-service/types.ts b/packages/cli/src/serve/workspace-service/types.ts index f2b07a6ccea..7f3318dd782 100644 --- a/packages/cli/src/serve/workspace-service/types.ts +++ b/packages/cli/src/serve/workspace-service/types.ts @@ -24,6 +24,7 @@ import type { ServeWorkspacePreflightStatus, DaemonStatusProvider, } from '@qwen-code/acp-bridge'; +import type { WorkspaceProvidersStatusProvider } from '../workspace-providers-status.js'; // --------------------------------------------------------------------------- // WorkspaceRequestContext @@ -214,6 +215,13 @@ export interface DaemonWorkspaceServiceDeps { */ statusProvider?: DaemonStatusProvider; + /** + * Daemon-local provider catalog/default-model snapshot. When present, + * `/workspace/providers` is answered from fresh workspace settings/env + * instead of querying the ACP child. + */ + workspaceProvidersStatusProvider?: WorkspaceProvidersStatusProvider; + /** * Returns whether the ACP channel is currently live. Used by * `getWorkspaceEnvStatus` to populate the `acpChannelLive` field diff --git a/packages/cli/src/utils/acpModelUtils.test.ts b/packages/cli/src/utils/acpModelUtils.test.ts index 01195dfa185..75646a95ad0 100644 --- a/packages/cli/src/utils/acpModelUtils.test.ts +++ b/packages/cli/src/utils/acpModelUtils.test.ts @@ -10,6 +10,7 @@ import { formatAcpModelId, parseAcpBaseModelId, parseAcpModelOption, + sanitizeProviderBaseUrl, } from './acpModelUtils.js'; describe('acpModelUtils', () => { @@ -44,4 +45,27 @@ describe('acpModelUtils', () => { it('returns whole input as modelId when trailing parens are empty', () => { expect(parseAcpModelOption('qwen3()')).toEqual({ modelId: 'qwen3()' }); }); + + it.each([ + ['not-a-url', 'not-a-url'], + ['https://api.example/v1', 'https://api.example/v1'], + ['https://api.example/v1/@scope', 'https://api.example/v1/@scope'], + ['https://host:99999/path@domain', 'https://host:99999/path@domain'], + ['https://user@api.example/v1', 'https://api.example/v1'], + ['https://user@host:99999', 'https://host:99999'], + ['https://user:secret@api.example/v1', 'https://api.example/v1'], + [ + 'https://user:secret@api.example/v1/@scope', + 'https://api.example/v1/@scope', + ], + ['https://user:p ass@api.example/v1', 'https://api.example/v1'], + [`https://user:p'ass@api.example/v1`, 'https://api.example/v1'], + ['https://user:p%2Fx@api.example/v1', 'https://api.example/v1'], + ['https://user:p/x@api.example/v1', 'https://api.example/v1'], + ['https://user:p?x@api.example/v1', 'https://api.example/v1'], + ['https://user:p#x@api.example/v1', 'https://api.example/v1'], + ['https://user:secret@api.example', 'https://api.example'], + ])('sanitizes provider base URL credentials for %s', (input, expected) => { + expect(sanitizeProviderBaseUrl(input)).toBe(expected); + }); }); diff --git a/packages/cli/src/utils/acpModelUtils.ts b/packages/cli/src/utils/acpModelUtils.ts index cf6cc1e3c61..e0d95234afc 100644 --- a/packages/cli/src/utils/acpModelUtils.ts +++ b/packages/cli/src/utils/acpModelUtils.ts @@ -19,6 +19,74 @@ export function formatAcpModelId(modelId: string, authType: AuthType): string { return `${modelId}(${authType})`; } +export function sanitizeProviderBaseUrl(baseUrl: string): string { + const scheme = baseUrl.match(/^[A-Za-z][A-Za-z\d+.-]*:\/\//); + if (!scheme) { + return baseUrl; + } + + const authorityStart = scheme[0].length; + const stripAt = (at: number) => + `${baseUrl.slice(0, authorityStart)}${baseUrl.slice(at + 1)}`; + const authorityEnd = findAuthorityEnd(baseUrl, authorityStart); + const authorityAt = baseUrl + .slice(authorityStart, authorityEnd) + .lastIndexOf('@'); + const authorityAtIndex = + authorityAt === -1 ? -1 : authorityStart + authorityAt; + + try { + const parsed = new URL(baseUrl); + if (parsed.username || parsed.password) { + return authorityAtIndex >= authorityStart + ? stripAt(authorityAtIndex) + : baseUrl; + } + return baseUrl; + } catch { + if (authorityAtIndex >= authorityStart) { + return stripAt(authorityAtIndex); + } + + const fallbackAt = findUnescapedUserInfoFallbackAt( + baseUrl, + authorityStart, + authorityEnd, + ); + return fallbackAt === -1 ? baseUrl : stripAt(fallbackAt); + } +} + +function findUnescapedUserInfoFallbackAt( + baseUrl: string, + authorityStart: number, + authorityEnd: number, +): number { + const at = baseUrl.lastIndexOf('@'); + if (at < authorityStart || authorityEnd >= at) { + return -1; + } + + const colon = baseUrl.indexOf(':', authorityStart); + if (colon === -1 || colon > authorityEnd) { + return -1; + } + + const portCandidate = baseUrl.slice(colon + 1, authorityEnd); + return /^\d+$/.test(portCandidate) ? -1 : at; +} + +function findAuthorityEnd(baseUrl: string, authorityStart: number): number { + const slash = baseUrl.indexOf('/', authorityStart); + const query = baseUrl.indexOf('?', authorityStart); + const hash = baseUrl.indexOf('#', authorityStart); + let end = baseUrl.length; + if (slash !== -1) end = Math.min(end, slash); + if (query !== -1) end = Math.min(end, query); + if (hash !== -1) end = Math.min(end, hash); + return end; +} + /** * Extracts the base model id from an ACP model id string. * diff --git a/packages/cli/src/utils/modelConfigUtils.ts b/packages/cli/src/utils/modelConfigUtils.ts index de1f6c7924c..ceb6d1ddb71 100644 --- a/packages/cli/src/utils/modelConfigUtils.ts +++ b/packages/cli/src/utils/modelConfigUtils.ts @@ -15,6 +15,7 @@ import { stripRuntimeSnapshotPrefix, } from '@qwen-code/qwen-code-core'; import type { Settings } from '../config/settings.js'; +import { sanitizeProviderBaseUrl } from './acpModelUtils.js'; /** * Env var names that hold model selections for each auth type. @@ -213,10 +214,14 @@ export function resolveCliGenerationConfig( // Surface the silent fallback: the paired provider was removed or its // baseUrl changed, so traffic now routes to a different same-id provider. if (!exactMatch && modelProvider) { + const fallbackBaseUrl = + modelProvider.baseUrl === undefined + ? '(default baseUrl)' + : sanitizeProviderBaseUrl(modelProvider.baseUrl); disambiguationWarning = - `Persisted model.baseUrl '${persistedBaseUrl}' no longer matches any provider ` + + `Persisted model.baseUrl '${sanitizeProviderBaseUrl(persistedBaseUrl)}' no longer matches any provider ` + `for model '${resolvedModel}' (authType '${authType}'); using the first id match ` + - `('${modelProvider.baseUrl ?? '(default baseUrl)'}'). Re-select the model to update it.`; + `('${fallbackBaseUrl}'). Re-select the model to update it.`; } } else { modelProvider = providers.find((p) => p.id === resolvedModel); diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index a0bbaf43cd7..54aa3ba027d 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -437,6 +437,7 @@ export interface DaemonWorkspaceProvidersStatus { v: 1; workspaceCwd: string; initialized: boolean; + acpChannelLive?: boolean; current?: DaemonWorkspaceProviderCurrent; providers: DaemonWorkspaceProviderStatus[]; errors?: DaemonStatusCell[]; diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index 5a23871fb2d..6edc7e68ca9 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -186,6 +186,7 @@ const sdkMocks = vi.hoisted(() => { capabilities, MockDaemonClient, MockDaemonSessionClient, + workspaceProviders, workspaceMcpTools, branchSession, reset() { @@ -392,6 +393,298 @@ describe('DaemonSessionProvider', () => { expect(sdkMocks.capabilities).toHaveBeenCalledTimes(1); }); + it('uses session context models over workspace provider defaults', async () => { + sdkMocks.workspaceProviders.mockResolvedValue({ + v: 1, + workspaceCwd: '/mock-workspace', + initialized: true, + current: { + authType: 'USE_OPENAI', + modelId: 'workspace-default(USE_OPENAI)', + }, + providers: [ + { + kind: 'model_provider', + status: 'ok', + authType: 'USE_OPENAI', + current: true, + models: [ + { + modelId: 'workspace-default(USE_OPENAI)', + baseModelId: 'workspace-default', + name: 'Workspace Default', + contextLimit: 10_000, + isCurrent: true, + isRuntime: false, + }, + ], + }, + ], + }); + sdkMocks.sessions.push( + createMockSession({ + context: vi.fn(async () => ({ + v: 1 as const, + sessionId: 'session-1', + workspaceCwd: '/mock-workspace', + state: { + models: { + currentModelId: 'session-current(USE_OPENAI)', + availableModels: [ + { + modelId: 'session-current(USE_OPENAI)', + name: 'Session Current', + description: 'Session-scoped model', + _meta: { contextLimit: 20_000 }, + }, + ], + }, + }, + })), + }), + ); + let connection: DaemonConnectionState | undefined; + + function Harness() { + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + autoReconnect: false, + }); + await act(async () => { + await flushPromises(); + }); + + expect(connection?.currentModel).toBe('session-current(USE_OPENAI)'); + expect(connection?.contextWindow).toBe(20_000); + expect(connection?.models).toEqual([ + expect.objectContaining({ + id: 'session-current(USE_OPENAI)', + label: 'Session Current', + contextWindow: 20_000, + }), + ]); + }); + + it('falls back to provider context window for session context models', async () => { + sdkMocks.workspaceProviders.mockResolvedValue({ + v: 1, + workspaceCwd: '/mock-workspace', + initialized: true, + current: { + authType: 'USE_OPENAI', + modelId: 'workspace-default(USE_OPENAI)', + }, + providers: [ + { + kind: 'model_provider', + status: 'ok', + authType: 'USE_OPENAI', + current: true, + models: [ + { + modelId: 'workspace-default(USE_OPENAI)', + baseModelId: 'workspace-default', + name: 'Workspace Default', + contextLimit: 10_000, + isCurrent: true, + isRuntime: false, + }, + { + modelId: 'session-current(USE_OPENAI)', + baseModelId: 'session-current', + name: 'Session Current', + contextLimit: 20_000, + isCurrent: false, + isRuntime: false, + }, + ], + }, + ], + }); + sdkMocks.sessions.push( + createMockSession({ + context: vi.fn(async () => ({ + v: 1 as const, + sessionId: 'session-1', + workspaceCwd: '/mock-workspace', + state: { + models: { + currentModelId: 'session-current(USE_OPENAI)', + availableModels: [ + { + modelId: 'session-current(USE_OPENAI)', + name: 'Session Current', + }, + ], + }, + }, + })), + }), + ); + let connection: DaemonConnectionState | undefined; + + function Harness() { + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + autoReconnect: false, + }); + await act(async () => { + await flushPromises(); + }); + + expect(connection?.currentModel).toBe('session-current(USE_OPENAI)'); + expect(connection?.contextWindow).toBe(20_000); + expect(connection?.models).toEqual([ + expect.objectContaining({ + id: 'session-current(USE_OPENAI)', + label: 'Session Current', + }), + ]); + expect(connection?.models?.[0]?.contextWindow).toBeUndefined(); + }); + + it('falls back to provider models when session context only has current model', async () => { + sdkMocks.workspaceProviders.mockResolvedValue({ + v: 1, + workspaceCwd: '/mock-workspace', + initialized: true, + current: { + authType: 'USE_OPENAI', + modelId: 'workspace-default(USE_OPENAI)', + }, + providers: [ + { + kind: 'model_provider', + status: 'ok', + authType: 'USE_OPENAI', + current: true, + models: [ + { + modelId: 'workspace-default(USE_OPENAI)', + baseModelId: 'workspace-default', + name: 'Workspace Default', + contextLimit: 10_000, + isCurrent: true, + isRuntime: false, + }, + { + modelId: 'session-current(USE_OPENAI)', + baseModelId: 'session-current', + name: 'Session Current', + contextLimit: 20_000, + isCurrent: false, + isRuntime: false, + }, + ], + }, + ], + }); + sdkMocks.sessions.push( + createMockSession({ + context: vi.fn(async () => ({ + v: 1 as const, + sessionId: 'session-1', + workspaceCwd: '/mock-workspace', + state: { + models: { + currentModelId: 'session-current(USE_OPENAI)', + }, + }, + })), + }), + ); + let connection: DaemonConnectionState | undefined; + + function Harness() { + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + autoReconnect: false, + }); + await act(async () => { + await flushPromises(); + }); + + expect(connection?.currentModel).toBe('session-current(USE_OPENAI)'); + expect(connection?.contextWindow).toBe(20_000); + expect(connection?.models?.map((model) => model.id)).toEqual([ + 'workspace-default(USE_OPENAI)', + 'session-current(USE_OPENAI)', + ]); + }); + + it('does not use provider context window for an unmatched session model', async () => { + sdkMocks.workspaceProviders.mockResolvedValue({ + v: 1, + workspaceCwd: '/mock-workspace', + initialized: true, + current: { + authType: 'USE_OPENAI', + modelId: 'workspace-default(USE_OPENAI)', + }, + providers: [ + { + kind: 'model_provider', + status: 'ok', + authType: 'USE_OPENAI', + current: true, + models: [ + { + modelId: 'workspace-default(USE_OPENAI)', + baseModelId: 'workspace-default', + name: 'Workspace Default', + contextLimit: 10_000, + isCurrent: true, + isRuntime: false, + }, + ], + }, + ], + }); + sdkMocks.sessions.push( + createMockSession({ + context: vi.fn(async () => ({ + v: 1 as const, + sessionId: 'session-1', + workspaceCwd: '/mock-workspace', + state: { + models: { + currentModelId: 'runtime-only(USE_OPENAI)', + }, + }, + })), + }), + ); + let connection: DaemonConnectionState | undefined; + + function Harness() { + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + autoReconnect: false, + }); + await act(async () => { + await flushPromises(); + }); + + expect(connection?.currentModel).toBe('runtime-only(USE_OPENAI)'); + expect(connection?.contextWindow).toBeUndefined(); + }); + it('adds daemon goal status metadata to the transcript', async () => { const session = createMockSession({ events: async function* goalStatusEvents() { diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index 906859978dd..d084995e748 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -43,6 +43,7 @@ import { getReplayTokenUsage, getTokenCountFromUsage, mapProviderStatus, + mapSessionContextModels, mapSupportedCommands, updateConnectionFromDaemonEvent, } from './mappers.js'; @@ -483,8 +484,26 @@ export function DaemonSessionProvider({ ? loadWarningsRef.current?.context : undefined, ].filter((warning): warning is string => Boolean(warning)); - const { models, currentModel, contextWindow } = - mapProviderStatus(providers); + const providerModelStatus = mapProviderStatus(providers); + const contextModelStatus = mapSessionContextModels(context); + const sessionModels = + contextModelStatus && contextModelStatus.models.length > 0 + ? contextModelStatus.models + : providerModelStatus.models; + const sessionCurrentModel = + contextModelStatus?.currentModel ?? + providerModelStatus.currentModel; + const providerContextWindow = + sessionCurrentModel === providerModelStatus.currentModel + ? providerModelStatus.contextWindow + : providerModelStatus.models.find( + (model) => model.id === sessionCurrentModel, + )?.contextWindow; + const sessionContextWindow = + contextModelStatus?.contextWindow ?? + sessionModels.find((model) => model.id === sessionCurrentModel) + ?.contextWindow ?? + providerContextWindow; const { commands, skills } = mapSupportedCommands(supportedCommands); const currentMode = getCurrentMode(context); @@ -499,8 +518,8 @@ export function DaemonSessionProvider({ workspaceCwd: activeSession.workspaceCwd, commands, skills, - models, - currentModel, + models: sessionModels, + currentModel: sessionCurrentModel, currentMode, displayName: getSessionDisplayName(activeSession.state) ?? @@ -526,7 +545,7 @@ export function DaemonSessionProvider({ : current.sessionId === activeSession.sessionId ? (current.tokenCount ?? 0) : 0, - contextWindow, + contextWindow: sessionContextWindow, providers, supportedCommands, context, diff --git a/packages/webui/src/daemon/session/mappers.ts b/packages/webui/src/daemon/session/mappers.ts index 17857f15f08..692a080db01 100644 --- a/packages/webui/src/daemon/session/mappers.ts +++ b/packages/webui/src/daemon/session/mappers.ts @@ -70,6 +70,62 @@ export function mapProviderStatus( return { models, currentModel, contextWindow }; } +export function mapSessionContextModels( + status: DaemonSessionContextStatus | undefined, +): + | { + models: DaemonModelInfo[]; + currentModel?: string; + contextWindow?: number; + } + | undefined { + const modelState = getRecord(status?.state?.models); + if (!modelState) return undefined; + + const currentModel = + getString(modelState, 'currentModelId') ?? + getString(modelState, 'currentModel'); + const availableModels = modelState['availableModels']; + const models: DaemonModelInfo[] = []; + let contextWindow: number | undefined; + + if (Array.isArray(availableModels)) { + for (const rawModel of availableModels) { + const model = getRecord(rawModel); + const modelId = + getString(model, 'modelId') ?? + getString(model, 'id') ?? + getString(model, 'value'); + if (!modelId) continue; + const meta = getRecord(model?.['_meta']); + const modelContextWindow = + getNumber(meta, 'contextLimit') ?? + getNumber(meta, 'contextWindow') ?? + getNumber(model, 'contextLimit') ?? + getNumber(model, 'contextWindow'); + if ( + contextWindow === undefined && + currentModel !== undefined && + modelId === currentModel + ) { + contextWindow = modelContextWindow; + } + models.push({ + id: modelId, + baseModelId: + getString(model, 'baseModelId') ?? stripAcpAuthSuffix(modelId), + label: getString(model, 'name') ?? getString(model, 'label') ?? modelId, + ...(modelContextWindow !== undefined + ? { contextWindow: modelContextWindow } + : {}), + }); + } + } + + if (!currentModel && models.length === 0) return undefined; + return { models, currentModel, contextWindow }; +} + export function mapSupportedCommands( status: DaemonSessionSupportedCommandsStatus | undefined, ): { @@ -357,3 +413,12 @@ function getNumber( ? value : undefined; } + +function stripAcpAuthSuffix(modelId: string): string { + const closeIdx = modelId.lastIndexOf(')'); + const openIdx = modelId.lastIndexOf('('); + if (openIdx >= 0 && closeIdx === modelId.length - 1 && openIdx < closeIdx) { + return modelId.slice(0, openIdx); + } + return modelId; +}