diff --git a/docs/developers/tools/sandbox.md b/docs/developers/tools/sandbox.md index 92550f164..de3e23716 100644 --- a/docs/developers/tools/sandbox.md +++ b/docs/developers/tools/sandbox.md @@ -38,6 +38,7 @@ ls -la $(dirname $(which qwen))/../lib/node_modules/@qwen-code/qwen-code # 7.Test the version of qwen qwen -v # npm link will overwrite the global qwen. To avoid being unable to distinguish the same version number, you can uninstall the global CLI first + ``` #### 3、Create your sandbox Dockerfile under the root directory of your own project diff --git a/docs/users/configuration/auth.md b/docs/users/configuration/auth.md index 1d5d30240..bce47dd3f 100644 --- a/docs/users/configuration/auth.md +++ b/docs/users/configuration/auth.md @@ -266,12 +266,12 @@ This is the approach used in the [one-file setup example](#recommended-one-file- **Priority summary:** -| Priority | Source | Override behavior | -| ----------- | ------------------------------ | ---------------------------------------- | -| 1 (highest) | CLI flags (`--openai-api-key`) | Always wins | -| 2 | System env (`export`, inline) | Overrides `.env` and `settings.env` | -| 3 | `.env` file | Only sets if not in system env | -| 4 (lowest) | `settings.json` → `env` | Only sets if not in system env or `.env` | +| Priority | Source | Override behavior | +| ----------- | ------------------------------ | -------------------------------------------- | +| 1 (highest) | CLI flags (`--openai-api-key`) | Always wins | +| 2 | System env (`export`, inline) | Overrides `.env` and `settings.json` → `env` | +| 3 | `.env` file | Only sets if not in system env | +| 4 (lowest) | `settings.json` → `env` | Only sets if not in system env or `.env` | #### Step 3: Switch models with `/model` @@ -292,7 +292,7 @@ qwen --model "qwen3-coder-plus" # In another terminal -qwen --model "qwen3-coder-next" +qwen --model "qwen3.5-plus" ``` ## Security notes diff --git a/docs/users/configuration/model-providers.md b/docs/users/configuration/model-providers.md index 2e6265917..836237457 100644 --- a/docs/users/configuration/model-providers.md +++ b/docs/users/configuration/model-providers.md @@ -7,9 +7,11 @@ Qwen Code allows you to configure multiple model providers through the `modelPro Use `modelProviders` to declare curated model lists per auth type that the `/model` picker can switch between. Keys must be valid auth types (`openai`, `anthropic`, `gemini`, `vertex-ai`, etc.). Each entry requires an `id` and **must include `envKey`**, with optional `name`, `description`, `baseUrl`, and `generationConfig`. Credentials are never persisted in settings; the runtime reads them from `process.env[envKey]`. Qwen OAuth models remain hard-coded and cannot be overridden. > [!note] +> > Only the `/model` command exposes non-default auth types. Anthropic, Gemini, Vertex AI, etc., must be defined via `modelProviders`. The `/auth` command intentionally lists only the built-in Qwen OAuth and OpenAI flows. > [!warning] +> > **Duplicate model IDs within the same authType:** Defining multiple models with the same `id` under a single `authType` (e.g., two entries with `"id": "gpt-4o"` in `openai`) is currently not supported. If duplicates exist, **the first occurrence wins** and subsequent duplicates are skipped with a warning. Note that the `id` field is used both as the configuration identifier and as the actual model name sent to the API, so using unique IDs (e.g., `gpt-4o-creative`, `gpt-4o-balanced`) is not a viable workaround. This is a known limitation that we plan to address in a future release. ## Configuration Examples by Auth Type @@ -273,6 +275,7 @@ export VLLM_API_KEY="not-needed" ``` > [!note] +> > The `extra_body` parameter is **only supported for OpenAI-compatible providers** (`openai`, `qwen-oauth`). It is ignored for Anthropic, Gemini, and Vertex AI providers. ## Bailian Coding Plan @@ -314,9 +317,10 @@ The region is selected during authentication and stored in `settings.json` under ### API Key Storage -When you configure Coding Plan through the `/auth` command, the API key is stored using the reserved environment variable name `BAILIAN_CODING_PLAN_API_KEY`. By default, it is stored in the `settings.env` field of your `settings.json` file. +When you configure Coding Plan through the `/auth` command, the API key is stored using the reserved environment variable name `BAILIAN_CODING_PLAN_API_KEY`. By default, it is stored in the `env` field of your `settings.json` file. > [!warning] +> > **Security Recommendation**: For better security, it is recommended to move the API key from `settings.json` to a separate `.env` file and load it as an environment variable. For example: > > ```bash @@ -357,13 +361,15 @@ If you prefer to manually configure Coding Plan models, you can add them to your ``` > [!note] +> > When using manual configuration: - +> > - You can use any environment variable name for `envKey` > - You do not need to configure `codingPlan.*` > - **Automatic updates will not apply** to manually configured Coding Plan models > [!warning] +> > If you also use automatic Coding Plan configuration, automatic updates may overwrite your manual configurations if they use the same `envKey` and `baseUrl` as the automatic configuration. To avoid this, ensure your manual configuration uses a different `envKey` if possible. ## Resolution Layers and Atomicity @@ -382,6 +388,7 @@ The effective auth/model/credential values are chosen per field using the follow \*When present, CLI auth flags override settings. Otherwise, `security.auth.selectedType` or the implicit default determine the auth type. Qwen OAuth and OpenAI are the only auth types surfaced without extra configuration. > [!warning] +> > **Deprecation of `security.auth.apiKey` and `security.auth.baseUrl`:** Directly configuring API credentials via `security.auth.apiKey` and `security.auth.baseUrl` in `settings.json` is deprecated. These settings were used in historical versions for credentials entered through the UI, but the credential input flow was removed in version 0.10.1. These fields will be fully removed in a future release. **It is strongly recommended to migrate to `modelProviders`** for all model and credential configurations. Use `envKey` in `modelProviders` to reference environment variables for secure credential management instead of hardcoding credentials in settings files. ## Generation Config Layering: The Impermeable Provider Layer @@ -515,6 +522,7 @@ The snapshot: ## Selection Persistence and Recommendations > [!important] +> > Define `modelProviders` in the user-scope `~/.qwen/settings.json` whenever possible and avoid persisting credential overrides in any scope. Keeping the provider catalog in user settings prevents merge/override conflicts between project and user scopes and ensures `/auth` and `/model` updates always write back to a consistent scope. - `/model` and `/auth` persist `model.name` (where applicable) and `security.auth.selectedType` to the closest writable scope that already defines `modelProviders`; otherwise they fall back to the user scope. This keeps workspace/user files in sync with the active provider catalog. diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md index 9a3b2c051..ba980db80 100644 --- a/docs/users/features/commands.md +++ b/docs/users/features/commands.md @@ -121,7 +121,9 @@ Environment Variables: Commands executed via `!` will set the `QWEN_CODE=1` envi Save frequently used prompts as shortcut commands to improve work efficiency and ensure consistency. -> **Note:** Custom commands now use Markdown format with optional YAML frontmatter. TOML format is deprecated but still supported for backwards compatibility. When TOML files are detected, an automatic migration prompt will be displayed. +> [!note] +> +> Custom commands now use Markdown format with optional YAML frontmatter. TOML format is deprecated but still supported for backwards compatibility. When TOML files are detected, an automatic migration prompt will be displayed. ### Quick Overview @@ -137,10 +139,10 @@ Priority Rules: Project commands > User commands (project command used when name #### File Path to Command Name Mapping Table -| File Location | Generated Command | Example Call | -| -------------------------- | ----------------- | --------------------- | -| `~/.qwen/commands/test.md` | `/test` | `/test Parameter` | -| `/git/commit.md` | `/git:commit` | `/git:commit Message` | +| File Location | Generated Command | Example Call | +| ---------------------------------------- | ----------------- | --------------------- | +| `~/.qwen/commands/test.md` | `/test` | `/test Parameter` | +| `/.qwen/commands/git/commit.md` | `/git:commit` | `/git:commit Message` | Naming Rules: Path separator (`/` or `\`) converted to colon (`:`) @@ -164,6 +166,8 @@ Use {{args}} for parameter injection. ### TOML File Format (Deprecated) +> [!warning] +> > **Deprecated:** TOML format is still supported but will be removed in a future version. Please migrate to Markdown format. | Field | Required | Description | Example | @@ -225,8 +229,6 @@ Please generate a Commit message based on the following diff: ``` ```` -```` - #### 4. File Content Injection (`@{...}`) | File Type | Support Status | Processing Method | @@ -246,7 +248,7 @@ description: Code review based on best practices Review {{args}}, reference standards: @{docs/code-standards.md} -```` +``` ### Practical Creation Example diff --git a/integration-tests/acp-integration.test.ts b/integration-tests/acp-integration.test.ts index 93389d605..07e53e960 100644 --- a/integration-tests/acp-integration.test.ts +++ b/integration-tests/acp-integration.test.ts @@ -659,7 +659,13 @@ function setupAcpTest( }> = []; const { sendRequest, cleanup, stderr, sessionUpdates } = setupAcpTest(rig, { - permissionHandler: () => ({ optionId: 'proceed_once' }), + permissionHandler: (request) => { + // Cancel exit_plan_mode to keep plan mode active + if (request.toolCall?.kind === 'switch_mode') { + return { outcome: 'cancelled' }; + } + return { optionId: 'proceed_once' }; + }, }); try { diff --git a/integration-tests/globalSetup.ts b/integration-tests/globalSetup.ts index a8a9877fe..02cea6859 100644 --- a/integration-tests/globalSetup.ts +++ b/integration-tests/globalSetup.ts @@ -94,7 +94,7 @@ export async function setup() { // Environment variables for CLI integration tests process.env['INTEGRATION_TEST_FILE_DIR'] = runDir; - process.env['GEMINI_CLI_INTEGRATION_TEST'] = 'true'; + process.env['QWEN_CODE_INTEGRATION_TEST'] = 'true'; process.env['TELEMETRY_LOG_FILE'] = join(runDir, 'telemetry.log'); // Environment variables for SDK E2E tests diff --git a/package.json b/package.json index 758e2f459..bc2544c9b 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "build:vscode": "node scripts/build_vscode_companion.js", "build:all": "npm run build && npm run build:sandbox && npm run build:vscode", "build:packages": "npm run build --workspaces", - "build:sandbox": "node scripts/build_sandbox.js -s", + "build:sandbox": "node scripts/build_sandbox.js", "bundle": "npm run generate && node esbuild.config.js && node scripts/copy_bundle_assets.js", "test": "npm run test --workspaces --if-present --parallel", "test:ci": "npm run test:ci --workspaces --if-present --parallel && npm run test:scripts", diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index c31ffa216..2440d6804 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -137,7 +137,6 @@ export interface CliArgs { googleSearchEngineId: string | undefined; webSearchDefault: string | undefined; screenReader: boolean | undefined; - vlmSwitchMode: string | undefined; inputFormat?: string | undefined; outputFormat: string | undefined; includePartialMessages?: boolean; @@ -426,13 +425,6 @@ export async function parseArguments(): Promise { type: 'boolean', description: 'Enable screen reader mode for accessibility.', }) - .option('vlm-switch-mode', { - type: 'string', - choices: ['once', 'session', 'persist'], - description: - 'Default behavior when images are detected in input. Values: once (one-time switch), session (switch for entire session), persist (continue with current model). Overrides settings files.', - default: process.env['VLM_SWITCH_MODE'], - }) .option('input-format', { type: 'string', choices: ['text', 'stream-json'], @@ -903,9 +895,6 @@ export async function loadCliConfig( ? argv.screenReader : (settings.ui?.accessibility?.screenReader ?? false); - const vlmSwitchMode = - argv.vlmSwitchMode || settings.experimental?.vlmSwitchMode; - let sessionId: string | undefined; let sessionData: ResumedSessionData | undefined; @@ -1002,6 +991,7 @@ export async function loadCliConfig( modelProvidersConfig, generationConfigSources: resolvedCliConfig.sources, generationConfig: resolvedCliConfig.generationConfig, + warnings: resolvedCliConfig.warnings, cliVersion: await getCliVersion(), webSearch: buildWebSearchConfig(argv, settings, selectedAuthType), summarizeToolOutput: settings.model?.summarizeToolOutput, @@ -1016,7 +1006,6 @@ export async function loadCliConfig( skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck, skipLoopDetection: settings.model?.skipLoopDetection ?? false, skipStartupContext: settings.model?.skipStartupContext ?? false, - vlmSwitchMode, truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold, truncateToolOutputLines: settings.tools?.truncateToolOutputLines, enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation, diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index fe2f63bd1..e261cc723 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -122,8 +122,6 @@ const MIGRATION_MAP: Record = { skipStartupContext: 'model.skipStartupContext', enableOpenAILogging: 'model.enableOpenAILogging', tavilyApiKey: 'advanced.tavilyApiKey', - vlmSwitchMode: 'experimental.vlmSwitchMode', - visionModelPreview: 'experimental.visionModelPreview', }; // Settings that need boolean inversion during migration (V1 -> V3) diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index fc902234f..cfde449ca 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -28,7 +28,7 @@ describe('SettingsSchema', () => { 'mcp', 'security', 'advanced', - 'experimental', + 'webSearch', ]; expectedSettings.forEach((setting) => { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 283baee26..fd6c3e85b 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -147,7 +147,7 @@ const SETTINGS_SCHEMA = { requiresRestart: true, default: {} as Record, description: - 'Environment variables to set as fallback defaults. These are loaded with the lowest priority: system environment variables > .env files > settings.env.', + 'Environment variables to set as fallback defaults. These are loaded with the lowest priority: system environment variables > .env files > settings.json env field.', showInDialog: false, mergeStrategy: MergeStrategy.SHALLOW_MERGE, }, @@ -1176,38 +1176,6 @@ const SETTINGS_SCHEMA = { description: 'Configuration for web search providers.', showInDialog: false, }, - - experimental: { - type: 'object', - label: 'Experimental', - category: 'Experimental', - requiresRestart: true, - default: {}, - description: 'Setting to enable experimental features', - showInDialog: false, - properties: { - visionModelPreview: { - type: 'boolean', - label: 'Vision Model Preview', - category: 'Experimental', - requiresRestart: false, - default: true, - description: - 'Enable vision model support and auto-switching functionality. When disabled, vision models like qwen-vl-max-latest will be hidden and auto-switching will not occur.', - showInDialog: false, - }, - vlmSwitchMode: { - type: 'string', - label: 'VLM Switch Mode', - category: 'Experimental', - requiresRestart: false, - default: undefined as string | undefined, - description: - 'Default behavior when images are detected in input. Values: once (one-time switch), session (switch for entire session), persist (continue with current model). If not set, user will be prompted each time. This is a temporary experimental feature.', - showInDialog: false, - }, - }, - }, } as const satisfies SettingsSchema; export type SettingsSchemaType = typeof SETTINGS_SCHEMA; diff --git a/packages/cli/src/constants/codingPlan.ts b/packages/cli/src/constants/codingPlan.ts index 72e7fc1b0..80c41f50b 100644 --- a/packages/cli/src/constants/codingPlan.ts +++ b/packages/cli/src/constants/codingPlan.ts @@ -61,6 +61,7 @@ export function generateCodingPlanTemplate( extra_body: { enable_thinking: true, }, + contextWindowSize: 1000000, }, }, { @@ -68,12 +69,18 @@ export function generateCodingPlanTemplate( name: '[Bailian Coding Plan] qwen3-coder-plus', baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + contextWindowSize: 1000000, + }, }, { id: 'qwen3-coder-next', name: '[Bailian Coding Plan] qwen3-coder-next', baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + contextWindowSize: 262144, + }, }, { id: 'qwen3-max-2026-01-23', @@ -84,6 +91,7 @@ export function generateCodingPlanTemplate( extra_body: { enable_thinking: true, }, + contextWindowSize: 262144, }, }, { @@ -95,6 +103,7 @@ export function generateCodingPlanTemplate( extra_body: { enable_thinking: true, }, + contextWindowSize: 202752, }, }, { @@ -106,6 +115,7 @@ export function generateCodingPlanTemplate( extra_body: { enable_thinking: true, }, + contextWindowSize: 202752, }, }, { @@ -117,6 +127,7 @@ export function generateCodingPlanTemplate( extra_body: { enable_thinking: true, }, + contextWindowSize: 1000000, }, }, { @@ -128,6 +139,7 @@ export function generateCodingPlanTemplate( extra_body: { enable_thinking: true, }, + contextWindowSize: 262144, }, }, ]; @@ -144,6 +156,7 @@ export function generateCodingPlanTemplate( extra_body: { enable_thinking: true, }, + contextWindowSize: 1000000, }, }, { @@ -151,12 +164,18 @@ export function generateCodingPlanTemplate( name: '[Bailian Coding Plan for Global/Intl] qwen3-coder-plus', baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + contextWindowSize: 1000000, + }, }, { id: 'qwen3-coder-next', name: '[Bailian Coding Plan for Global/Intl] qwen3-coder-next', baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + contextWindowSize: 262144, + }, }, { id: 'qwen3-max-2026-01-23', @@ -167,6 +186,7 @@ export function generateCodingPlanTemplate( extra_body: { enable_thinking: true, }, + contextWindowSize: 262144, }, }, { @@ -178,6 +198,7 @@ export function generateCodingPlanTemplate( extra_body: { enable_thinking: true, }, + contextWindowSize: 202752, }, }, { @@ -189,6 +210,7 @@ export function generateCodingPlanTemplate( extra_body: { enable_thinking: true, }, + contextWindowSize: 202752, }, }, { @@ -200,6 +222,7 @@ export function generateCodingPlanTemplate( extra_body: { enable_thinking: true, }, + contextWindowSize: 1000000, }, }, { @@ -211,6 +234,7 @@ export function generateCodingPlanTemplate( extra_body: { enable_thinking: true, }, + contextWindowSize: 262144, }, }, ]; diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 44c4c29d7..6c48658ad 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -48,6 +48,7 @@ vi.mock('./config/config.js', () => ({ getSandbox: vi.fn(() => false), getQuestion: vi.fn(() => ''), isInteractive: () => false, + getWarnings: vi.fn(() => []), } as unknown as Config), parseArguments: vi.fn().mockResolvedValue({}), isDebugMode: vi.fn(() => false), @@ -177,6 +178,7 @@ describe('gemini.tsx main function', () => { getGeminiMdFileCount: () => 0, getProjectRoot: () => '/', getOutputFormat: () => OutputFormat.TEXT, + getWarnings: () => [], } as unknown as Config; }); vi.mocked(loadSettings).mockReturnValue({ @@ -341,6 +343,7 @@ describe('gemini.tsx main function', () => { getProjectRoot: () => '/', getInputFormat: () => 'stream-json', getContentGeneratorConfig: () => ({ authType: 'test-auth' }), + getWarnings: () => [], } as unknown as Config; vi.mocked(loadCliConfig).mockResolvedValue(configStub); @@ -438,6 +441,7 @@ describe('gemini.tsx main function kitty protocol', () => { getExperimentalZedIntegration: () => false, getScreenReader: () => false, getGeminiMdFileCount: () => 0, + getWarnings: () => [], } as unknown as Config); vi.mocked(loadSettings).mockReturnValue({ errors: [], @@ -483,7 +487,6 @@ describe('gemini.tsx main function kitty protocol', () => { googleSearchEngineId: undefined, webSearchDefault: undefined, screenReader: undefined, - vlmSwitchMode: undefined, inputFormat: undefined, outputFormat: undefined, includePartialMessages: undefined, diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 08c0631a8..c5e742ee6 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -411,6 +411,7 @@ export async function main() { useBuiltinRipgrep: settings.merged.tools?.useBuiltinRipgrep ?? true, })), ...getSettingsWarnings(settings), + ...config.getWarnings(), ]), ]; diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 431b70910..8ae18e16e 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1450,6 +1450,6 @@ export default { 'Neue Modellkonfigurationen sind für Coding Plan (Bailian, Global/Intl) verfügbar. Jetzt aktualisieren?', '{{region}} configuration updated successfully. Model switched to "{{model}}".': '{{region}}-Konfiguration erfolgreich aktualisiert. Modell auf "{{model}}" umgeschaltet.', - 'Authenticated successfully with {{region}}. API key is stored in settings.env.': - 'Erfolgreich mit {{region}} authentifiziert. API-Schlüssel ist in settings.env gespeichert.', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': + 'Erfolgreich mit {{region}} authentifiziert. API-Schlüssel und Modellkonfigurationen wurden in settings.json gespeichert (gesichert).', }; diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 775f470b7..0d3d422a7 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1449,6 +1449,6 @@ export default { 'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?', '{{region}} configuration updated successfully. Model switched to "{{model}}".': '{{region}} configuration updated successfully. Model switched to "{{model}}".', - 'Authenticated successfully with {{region}}. API key is stored in settings.env.': - 'Authenticated successfully with {{region}}. API key is stored in settings.env.', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).', }; diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index c00954858..9632d5675 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -955,6 +955,6 @@ export default { 'Coding Plan (Bailian, グローバル/国際) の新しいモデル設定が利用可能です。今すぐ更新しますか?', '{{region}} configuration updated successfully. Model switched to "{{model}}".': '{{region}} の設定が正常に更新されました。モデルが "{{model}}" に切り替わりました。', - 'Authenticated successfully with {{region}}. API key is stored in settings.env.': - '{{region}} での認証に成功しました。APIキーは settings.env に保存されています。', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': + '{{region}} での認証に成功しました。APIキーとモデル設定が settings.json に保存されました(バックアップ済み)。', }; diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index a6130b2fb..d630879d1 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1458,6 +1458,6 @@ export default { 'Novas configurações de modelo estão disponíveis para o Coding Plan (Bailian, Global/Intl). Atualizar agora?', '{{region}} configuration updated successfully. Model switched to "{{model}}".': 'Configuração do {{region}} atualizada com sucesso. Modelo alterado para "{{model}}".', - 'Authenticated successfully with {{region}}. API key is stored in settings.env.': - 'Autenticado com sucesso com {{region}}. A chave de API está armazenada em settings.env.', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': + 'Autenticado com sucesso com {{region}}. Chave de API e configurações de modelo salvas em settings.json (com backup).', }; diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index a8299a762..b8b332b76 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1454,6 +1454,6 @@ export default { 'Доступны новые конфигурации моделей для Coding Plan (Bailian, Глобальный/Международный). Обновить сейчас?', '{{region}} configuration updated successfully. Model switched to "{{model}}".': 'Конфигурация {{region}} успешно обновлена. Модель переключена на "{{model}}".', - 'Authenticated successfully with {{region}}. API key is stored in settings.env.': - 'Успешная аутентификация с {{region}}. API-ключ сохранён в settings.env.', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': + 'Успешная аутентификация с {{region}}. API-ключ и конфигурации моделей сохранены в settings.json (резервная копия создана).', }; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index b0db2d0e5..02ae707b6 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1282,6 +1282,6 @@ export default { 'Coding Plan (百炼, 全球/国际) 有新的模型配置可用。是否立即更新?', '{{region}} configuration updated successfully. Model switched to "{{model}}".': '{{region}} 配置更新成功。模型已切换至 "{{model}}"。', - 'Authenticated successfully with {{region}}. API key is stored in settings.env.': - '成功通过 {{region}} 认证。API Key 已存储在 settings.env 中。', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': + '成功通过 {{region}} 认证。API Key 和模型配置已保存至 settings.json(已备份)。', }; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 53e1ea9e3..2ab8eeec4 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -100,8 +100,6 @@ import { t } from '../i18n/index.js'; import { useWelcomeBack } from './hooks/useWelcomeBack.js'; import { useDialogClose } from './hooks/useDialogClose.js'; import { useInitializationAuthError } from './hooks/useInitializationAuthError.js'; -import { type VisionSwitchOutcome } from './components/ModelSwitchDialog.js'; -import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js'; import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js'; import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js'; import { useAttentionNotifications } from './hooks/useAttentionNotifications.js'; @@ -496,18 +494,6 @@ export const AppContainer = (props: AppContainerProps) => { closeAgentsManagerDialog, } = useAgentsManagerDialog(); - // Vision model auto-switch dialog state (must be before slashCommandActions) - const [isVisionSwitchDialogOpen, setIsVisionSwitchDialogOpen] = - useState(false); - const [visionSwitchResolver, setVisionSwitchResolver] = useState<{ - resolve: (result: { - modelOverride?: string; - persistSessionModel?: string; - showGuidance?: boolean; - }) => void; - reject: () => void; - } | null>(null); - const slashCommandActions = useMemo( () => ({ openAuthDialog, @@ -563,6 +549,7 @@ export const AppContainer = (props: AppContainerProps) => { historyManager.loadHistory, refreshStatic, toggleVimEnabled, + isProcessing, setIsProcessing, setGeminiMdFileCount, slashCommandActions, @@ -571,32 +558,6 @@ export const AppContainer = (props: AppContainerProps) => { logger, ); - // Vision switch handlers - const handleVisionSwitchRequired = useCallback( - async (_query: unknown) => - new Promise<{ - modelOverride?: string; - persistSessionModel?: string; - showGuidance?: boolean; - }>((resolve, reject) => { - setVisionSwitchResolver({ resolve, reject }); - setIsVisionSwitchDialogOpen(true); - }), - [], - ); - - const handleVisionSwitchSelect = useCallback( - (outcome: VisionSwitchOutcome) => { - setIsVisionSwitchDialogOpen(false); - if (visionSwitchResolver) { - const result = processVisionSwitchOutcome(outcome); - visionSwitchResolver.resolve(result); - setVisionSwitchResolver(null); - } - }, - [visionSwitchResolver], - ); - // onDebugMessage should log to debug logfile, not update footer debugMessage const onDebugMessage = useCallback( (message: string) => { @@ -687,11 +648,9 @@ export const AppContainer = (props: AppContainerProps) => { setModelSwitchedFromQuotaError, refreshStatic, () => cancelHandlerRef.current(), - settings.merged.experimental?.visionModelPreview ?? false, // visionModelPreviewEnabled setEmbeddedShellFocused, terminalWidth, terminalHeight, - handleVisionSwitchRequired, // onVisionSwitchRequired ); // Track whether suggestions are visible for Tab key handling @@ -846,7 +805,6 @@ export const AppContainer = (props: AppContainerProps) => { !isThemeDialogOpen && !isEditorDialogOpen && !showWelcomeBackDialog && - !isVisionSwitchDialogOpen && welcomeBackChoice !== 'restart' && geminiClient?.isInitialized?.() ) { @@ -862,7 +820,6 @@ export const AppContainer = (props: AppContainerProps) => { isThemeDialogOpen, isEditorDialogOpen, showWelcomeBackDialog, - isVisionSwitchDialogOpen, welcomeBackChoice, geminiClient, ]); @@ -1334,7 +1291,6 @@ export const AppContainer = (props: AppContainerProps) => { isThemeDialogOpen || isSettingsDialogOpen || isModelDialogOpen || - isVisionSwitchDialogOpen || isPermissionsDialogOpen || isAuthDialogOpen || isAuthenticating || @@ -1446,8 +1402,6 @@ export const AppContainer = (props: AppContainerProps) => { extensionsUpdateState, activePtyId, embeddedShellFocused, - // Vision switch dialog - isVisionSwitchDialogOpen, // Welcome back dialog showWelcomeBackDialog, welcomeBackInfo, @@ -1538,8 +1492,6 @@ export const AppContainer = (props: AppContainerProps) => { activePtyId, historyManager, embeddedShellFocused, - // Vision switch dialog - isVisionSwitchDialogOpen, // Welcome back dialog showWelcomeBackDialog, welcomeBackInfo, @@ -1581,8 +1533,6 @@ export const AppContainer = (props: AppContainerProps) => { refreshStatic, handleFinalSubmit, handleClearScreen, - // Vision switch dialog - handleVisionSwitchSelect, // Welcome back dialog handleWelcomeBackSelection, handleWelcomeBackClose, @@ -1626,7 +1576,6 @@ export const AppContainer = (props: AppContainerProps) => { refreshStatic, handleFinalSubmit, handleClearScreen, - handleVisionSwitchSelect, handleWelcomeBackSelection, handleWelcomeBackClose, // Subagent dialogs diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index bb05172aa..50b4890c2 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -35,6 +35,7 @@ import { CodingPlanRegion, CODING_PLAN_ENV_KEY, } from '../../constants/codingPlan.js'; +import { backupSettingsFile } from '../../utils/settingsUtils.js'; export type { QwenAuthState } from '../hooks/useQwenAuth.js'; @@ -304,6 +305,10 @@ export const useAuthCommand = ( // Get persist scope const persistScope = getPersistScopeForModelSelection(settings); + // Backup settings file before modification + const settingsFile = settings.forScope(persistScope); + backupSettingsFile(settingsFile.path); + // Store api-key in settings.env (unified env key) settings.setValue(persistScope, `env.${CODING_PLAN_ENV_KEY}`, apiKey); @@ -384,7 +389,7 @@ export const useAuthCommand = ( { type: MessageType.INFO, text: t( - 'Authenticated successfully with {{region}}. API key is stored in settings.env.', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).', { region: regionName }, ), }, diff --git a/packages/cli/src/ui/commands/compressCommand.ts b/packages/cli/src/ui/commands/compressCommand.ts index 8818c42b7..cdd07d45d 100644 --- a/packages/cli/src/ui/commands/compressCommand.ts +++ b/packages/cli/src/ui/commands/compressCommand.ts @@ -20,6 +20,7 @@ export const compressCommand: SlashCommand = { action: async (context) => { const { ui } = context; const executionMode = context.executionMode ?? 'interactive'; + const abortSignal = context.abortSignal; if (executionMode === 'interactive' && ui.pendingItem) { ui.addItem( @@ -96,6 +97,10 @@ export const compressCommand: SlashCommand = { const compressed = await doCompress(); + if (abortSignal?.aborted) { + return; + } + if (!compressed) { if (executionMode === 'interactive') { ui.addItem( @@ -137,6 +142,10 @@ export const compressCommand: SlashCommand = { content: `Context compressed (${compressed.originalTokenCount} -> ${compressed.newTokenCount}).`, }; } catch (e) { + // If cancelled via ESC, don't show error — cancelSlashCommand already handled UI + if (abortSignal?.aborted) { + return; + } if (executionMode === 'interactive') { ui.addItem( { diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts index baba59b6c..40bec554f 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -104,6 +104,15 @@ export const setupGithubCommand: SlashCommand = { ): Promise => { const abortController = new AbortController(); + // If we have a context abort signal (from ESC cancellation), link it to our controller + if (context.abortSignal) { + context.abortSignal.addEventListener( + 'abort', + () => abortController.abort(), + { once: true }, + ); + } + if (!isGitHubRepository()) { throw new Error( 'Unable to determine the GitHub repository. /setup-github must be run from a git repository.', diff --git a/packages/cli/src/ui/commands/summaryCommand.ts b/packages/cli/src/ui/commands/summaryCommand.ts index de75fadd2..5e84bf53e 100644 --- a/packages/cli/src/ui/commands/summaryCommand.ts +++ b/packages/cli/src/ui/commands/summaryCommand.ts @@ -27,6 +27,7 @@ export const summaryCommand: SlashCommand = { const { config } = context.services; const { ui } = context; const executionMode = context.executionMode ?? 'interactive'; + const abortSignal = context.abortSignal; if (!config) { return { @@ -101,7 +102,7 @@ export const summaryCommand: SlashCommand = { }, ], {}, - new AbortController().signal, + abortSignal ?? new AbortController().signal, config.getModel(), ); @@ -197,6 +198,10 @@ export const summaryCommand: SlashCommand = { if (executionMode !== 'interactive') { return; } + // If cancelled via ESC, don't show error — cancelSlashCommand already handled UI + if (abortSignal?.aborted) { + return; + } ui.setPendingItem(null); ui.addItem( { @@ -241,6 +246,9 @@ export const summaryCommand: SlashCommand = { }> => { emitInteractivePending('generating'); const markdownSummary = await generateSummaryMarkdown(history); + if (abortSignal?.aborted) { + throw new DOMException('Summary generation cancelled.', 'AbortError'); + } emitInteractivePending('saving'); const { filePathForDisplay } = await saveSummaryToDisk(markdownSummary); completeInteractive(filePathForDisplay); diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 6c03ec136..90330e988 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -89,6 +89,8 @@ export interface CommandContext { }; // Flag to indicate if an overwrite has been confirmed overwriteConfirmed?: boolean; + /** Abort signal for cancelling long-running slash command operations via ESC. */ + abortSignal?: AbortSignal; } /** diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index dbb6f2207..c79e91119 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -32,7 +32,6 @@ import process from 'node:process'; import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js'; import { WelcomeBackDialog } from './WelcomeBackDialog.js'; -import { ModelSwitchDialog } from './ModelSwitchDialog.js'; import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js'; import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js'; import { SessionPicker } from './SessionPicker.js'; @@ -236,10 +235,6 @@ export const DialogManager = ({ if (uiState.isModelDialogOpen) { return ; } - if (uiState.isVisionSwitchDialogOpen) { - return ; - } - if (uiState.isAuthDialogOpen || uiState.authError) { return ( diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index b5900c80c..70b87bde5 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -12,14 +12,10 @@ import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSel import { ConfigContext } from '../contexts/ConfigContext.js'; import { SettingsContext } from '../contexts/SettingsContext.js'; import type { Config } from '@qwen-code/qwen-code-core'; -import { AuthType } from '@qwen-code/qwen-code-core'; +import { AuthType, DEFAULT_QWEN_MODEL } from '@qwen-code/qwen-code-core'; import type { LoadedSettings } from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js'; -import { - AVAILABLE_MODELS_QWEN, - MAINLINE_CODER, - MAINLINE_VLM, -} from '../models/availableModels.js'; +import { getFilteredQwenModels } from '../models/availableModels.js'; vi.mock('../hooks/useKeypress.js', () => ({ useKeypress: vi.fn(), @@ -29,6 +25,19 @@ const mockedUseKeypress = vi.mocked(useKeypress); vi.mock('./shared/DescriptiveRadioButtonSelect.js', () => ({ DescriptiveRadioButtonSelect: vi.fn(() => null), })); + +// Helper to create getAvailableModelsForAuthType mock +const createMockGetAvailableModelsForAuthType = () => + vi.fn((t: AuthType) => { + if (t === AuthType.QWEN_OAUTH) { + return getFilteredQwenModels().map((m) => ({ + id: m.id, + label: m.label, + authType: AuthType.QWEN_OAUTH, + })); + } + return []; + }); const mockedSelect = vi.mocked(DescriptiveRadioButtonSelect); const renderComponent = ( @@ -49,12 +58,12 @@ const renderComponent = ( const mockConfig = { // --- Functions used by ModelDialog --- - getModel: vi.fn(() => MAINLINE_CODER), + getModel: vi.fn(() => DEFAULT_QWEN_MODEL), setModel: vi.fn().mockResolvedValue(undefined), switchModel: vi.fn().mockResolvedValue(undefined), getAuthType: vi.fn(() => 'qwen-oauth'), getAllConfiguredModels: vi.fn(() => - AVAILABLE_MODELS_QWEN.map((m) => ({ + getFilteredQwenModels().map((m) => ({ id: m.id, label: m.label, description: m.description || '', @@ -68,7 +77,7 @@ const renderComponent = ( getDebugMode: vi.fn(() => false), getContentGeneratorConfig: vi.fn(() => ({ authType: AuthType.QWEN_OAUTH, - model: MAINLINE_CODER, + model: DEFAULT_QWEN_MODEL, })), getUseModelRouter: vi.fn(() => false), getProxy: vi.fn(() => undefined), @@ -116,24 +125,34 @@ describe('', () => { expect(mockedSelect).toHaveBeenCalledTimes(1); const props = mockedSelect.mock.calls[0][0]; - expect(props.items).toHaveLength(AVAILABLE_MODELS_QWEN.length); + expect(props.items).toHaveLength(getFilteredQwenModels().length); + // coder-model is the only model and it has vision capability expect(props.items[0].value).toBe( - `${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`, - ); - expect(props.items[1].value).toBe( - `${AuthType.QWEN_OAUTH}::${MAINLINE_VLM}`, + `${AuthType.QWEN_OAUTH}::${DEFAULT_QWEN_MODEL}`, ); expect(props.showNumbers).toBe(true); }); it('initializes with the model from ConfigContext', () => { - const mockGetModel = vi.fn(() => MAINLINE_VLM); - renderComponent({}, { getModel: mockGetModel }); + const mockGetModel = vi.fn(() => DEFAULT_QWEN_MODEL); + renderComponent( + {}, + { + getModel: mockGetModel, + getAvailableModelsForAuthType: + createMockGetAvailableModelsForAuthType(), + }, + ); expect(mockGetModel).toHaveBeenCalled(); + // Calculate expected index dynamically based on model list + const qwenModels = getFilteredQwenModels(); + const expectedIndex = qwenModels.findIndex( + (m) => m.id === DEFAULT_QWEN_MODEL, + ); expect(mockedSelect).toHaveBeenCalledWith( expect.objectContaining({ - initialIndex: 1, + initialIndex: expectedIndex, }), undefined, ); @@ -151,14 +170,19 @@ describe('', () => { }); it('initializes with default coder model if getModel returns undefined', () => { - const mockGetModel = vi.fn(() => undefined); - // @ts-expect-error This test validates component robustness when getModel - // returns an unexpected undefined value. - renderComponent({}, { getModel: mockGetModel }); + const mockGetModel = vi.fn(() => undefined as unknown as string); + renderComponent( + {}, + { + getModel: mockGetModel, + getAvailableModelsForAuthType: + createMockGetAvailableModelsForAuthType(), + }, + ); expect(mockGetModel).toHaveBeenCalled(); - // When getModel returns undefined, preferredModel falls back to MAINLINE_CODER + // When getModel returns undefined, preferredModel falls back to DEFAULT_QWEN_MODEL // which has index 0, so initialIndex should be 0 expect(mockedSelect).toHaveBeenCalledWith( expect.objectContaining({ @@ -170,22 +194,36 @@ describe('', () => { }); it('calls config.switchModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', async () => { - const { props, mockConfig, mockSettings } = renderComponent({}, {}); // Pass empty object for contextValue + const { props, mockConfig, mockSettings } = renderComponent( + {}, + { + getAvailableModelsForAuthType: vi.fn((t: AuthType) => { + if (t === AuthType.QWEN_OAUTH) { + return getFilteredQwenModels().map((m) => ({ + id: m.id, + label: m.label, + authType: AuthType.QWEN_OAUTH, + })); + } + return []; + }), + }, + ); const childOnSelect = mockedSelect.mock.calls[0][0].onSelect; expect(childOnSelect).toBeDefined(); - await childOnSelect(`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`); + await childOnSelect(`${AuthType.QWEN_OAUTH}::${DEFAULT_QWEN_MODEL}`); expect(mockConfig?.switchModel).toHaveBeenCalledWith( AuthType.QWEN_OAUTH, - MAINLINE_CODER, + DEFAULT_QWEN_MODEL, undefined, ); expect(mockSettings.setValue).toHaveBeenCalledWith( SettingScope.User, 'model.name', - MAINLINE_CODER, + DEFAULT_QWEN_MODEL, ); expect(mockSettings.setValue).toHaveBeenCalledWith( SettingScope.User, @@ -203,7 +241,7 @@ describe('', () => { return [{ id: 'gpt-4', label: 'GPT-4', authType: t }]; } if (t === AuthType.QWEN_OAUTH) { - return AVAILABLE_MODELS_QWEN.map((m) => ({ + return getFilteredQwenModels().map((m) => ({ id: m.id, label: m.label, authType: AuthType.QWEN_OAUTH, @@ -217,7 +255,7 @@ describe('', () => { getModel: vi.fn(() => 'gpt-4'), getContentGeneratorConfig: vi.fn(() => ({ authType: AuthType.QWEN_OAUTH, - model: MAINLINE_CODER, + model: DEFAULT_QWEN_MODEL, })), // Add switchModel to the mock object (not the type) switchModel, @@ -231,17 +269,17 @@ describe('', () => { ); const childOnSelect = mockedSelect.mock.calls[0][0].onSelect; - await childOnSelect(`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`); + await childOnSelect(`${AuthType.QWEN_OAUTH}::${DEFAULT_QWEN_MODEL}`); expect(switchModel).toHaveBeenCalledWith( AuthType.QWEN_OAUTH, - MAINLINE_CODER, + DEFAULT_QWEN_MODEL, { requireCachedCredentials: true }, ); expect(mockSettings.setValue).toHaveBeenCalledWith( SettingScope.User, 'model.name', - MAINLINE_CODER, + DEFAULT_QWEN_MODEL, ); expect(mockSettings.setValue).toHaveBeenCalledWith( SettingScope.User, @@ -291,7 +329,7 @@ describe('', () => { }); it('updates initialIndex when config context changes', () => { - const mockGetModel = vi.fn(() => MAINLINE_CODER); + const mockGetModel = vi.fn(() => DEFAULT_QWEN_MODEL); const mockGetAuthType = vi.fn(() => 'qwen-oauth'); const mockSettings = { isTrusted: true, @@ -306,8 +344,10 @@ describe('', () => { { getModel: mockGetModel, getAuthType: mockGetAuthType, + getAvailableModelsForAuthType: + createMockGetAvailableModelsForAuthType(), getAllConfiguredModels: vi.fn(() => - AVAILABLE_MODELS_QWEN.map((m) => ({ + getFilteredQwenModels().map((m) => ({ id: m.id, label: m.label, description: m.description || '', @@ -322,14 +362,16 @@ describe('', () => { , ); + // DEFAULT_QWEN_MODEL (coder-model) is at index 0 expect(mockedSelect.mock.calls[0][0].initialIndex).toBe(0); - mockGetModel.mockReturnValue(MAINLINE_VLM); + mockGetModel.mockReturnValue(DEFAULT_QWEN_MODEL); const newMockConfig = { getModel: mockGetModel, getAuthType: mockGetAuthType, + getAvailableModelsForAuthType: createMockGetAvailableModelsForAuthType(), getAllConfiguredModels: vi.fn(() => - AVAILABLE_MODELS_QWEN.map((m) => ({ + getFilteredQwenModels().map((m) => ({ id: m.id, label: m.label, description: m.description || '', @@ -348,6 +390,11 @@ describe('', () => { // Should be called at least twice: initial render + re-render after context change expect(mockedSelect).toHaveBeenCalledTimes(2); - expect(mockedSelect.mock.calls[1][0].initialIndex).toBe(1); + // Calculate expected index for DEFAULT_QWEN_MODEL dynamically + const qwenModels = getFilteredQwenModels(); + const expectedCoderIndex = qwenModels.findIndex( + (m) => m.id === DEFAULT_QWEN_MODEL, + ); + expect(mockedSelect.mock.calls[1][0].initialIndex).toBe(expectedCoderIndex); }); }); diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index 056dfa571..79551050e 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -11,6 +11,7 @@ import { AuthType, ModelSlashCommandEvent, logModelSlashCommand, + MAINLINE_CODER_MODEL, type AvailableModel as CoreAvailableModel, type ContentGeneratorConfig, type InputModalities, @@ -21,7 +22,6 @@ import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSel import { ConfigContext } from '../contexts/ConfigContext.js'; import { UIStateContext, type UIState } from '../contexts/UIStateContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; -import { MAINLINE_CODER } from '../models/availableModels.js'; import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; import { t } from '../../i18n/index.js'; @@ -243,7 +243,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { [availableModelEntries], ); - const preferredModelId = config?.getModel() || MAINLINE_CODER; + const preferredModelId = config?.getModel() || MAINLINE_CODER_MODEL; // Check if current model is a runtime model // Runtime snapshot ID is already in $runtime|${authType}|${modelId} format const activeRuntimeSnapshot = config?.getActiveRuntimeModelSnapshot?.(); diff --git a/packages/cli/src/ui/components/ModelSwitchDialog.test.tsx b/packages/cli/src/ui/components/ModelSwitchDialog.test.tsx deleted file mode 100644 index 63c85f972..000000000 --- a/packages/cli/src/ui/components/ModelSwitchDialog.test.tsx +++ /dev/null @@ -1,184 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from 'react'; -import { render } from 'ink-testing-library'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { ModelSwitchDialog, VisionSwitchOutcome } from './ModelSwitchDialog.js'; - -// Mock the useKeypress hook -const mockUseKeypress = vi.hoisted(() => vi.fn()); -vi.mock('../hooks/useKeypress.js', () => ({ - useKeypress: mockUseKeypress, -})); - -// Mock the RadioButtonSelect component -const mockRadioButtonSelect = vi.hoisted(() => vi.fn()); -vi.mock('./shared/RadioButtonSelect.js', () => ({ - RadioButtonSelect: mockRadioButtonSelect, -})); - -describe('ModelSwitchDialog', () => { - const mockOnSelect = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - - // Mock RadioButtonSelect to return a simple div - mockRadioButtonSelect.mockReturnValue( - React.createElement('div', { 'data-testid': 'radio-select' }), - ); - }); - - it('should setup RadioButtonSelect with correct options', () => { - render(); - - const expectedItems = [ - { - key: 'switch-once', - label: 'Switch for this request only', - value: VisionSwitchOutcome.SwitchOnce, - }, - { - key: 'switch-session', - label: 'Switch session to vision model', - value: VisionSwitchOutcome.SwitchSessionToVL, - }, - { - key: 'continue', - label: 'Continue with current model', - value: VisionSwitchOutcome.ContinueWithCurrentModel, - }, - ]; - - const callArgs = mockRadioButtonSelect.mock.calls[0][0]; - expect(callArgs.items).toEqual(expectedItems); - expect(callArgs.initialIndex).toBe(0); - expect(callArgs.isFocused).toBe(true); - }); - - it('should call onSelect when an option is selected', () => { - render(); - - const callArgs = mockRadioButtonSelect.mock.calls[0][0]; - expect(typeof callArgs.onSelect).toBe('function'); - - // Simulate selection of "Switch for this request only" - const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect; - onSelectCallback(VisionSwitchOutcome.SwitchOnce); - - expect(mockOnSelect).toHaveBeenCalledWith(VisionSwitchOutcome.SwitchOnce); - }); - - it('should call onSelect with SwitchSessionToVL when second option is selected', () => { - render(); - - const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect; - onSelectCallback(VisionSwitchOutcome.SwitchSessionToVL); - - expect(mockOnSelect).toHaveBeenCalledWith( - VisionSwitchOutcome.SwitchSessionToVL, - ); - }); - - it('should call onSelect with ContinueWithCurrentModel when third option is selected', () => { - render(); - - const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect; - onSelectCallback(VisionSwitchOutcome.ContinueWithCurrentModel); - - expect(mockOnSelect).toHaveBeenCalledWith( - VisionSwitchOutcome.ContinueWithCurrentModel, - ); - }); - - it('should setup escape key handler to call onSelect with ContinueWithCurrentModel', () => { - render(); - - expect(mockUseKeypress).toHaveBeenCalledWith(expect.any(Function), { - isActive: true, - }); - - // Simulate escape key press - const keypressHandler = mockUseKeypress.mock.calls[0][0]; - keypressHandler({ name: 'escape' }); - - expect(mockOnSelect).toHaveBeenCalledWith( - VisionSwitchOutcome.ContinueWithCurrentModel, - ); - }); - - it('should not call onSelect for non-escape keys', () => { - render(); - - const keypressHandler = mockUseKeypress.mock.calls[0][0]; - keypressHandler({ name: 'enter' }); - - expect(mockOnSelect).not.toHaveBeenCalled(); - }); - - it('should set initial index to 0 (first option)', () => { - render(); - - const callArgs = mockRadioButtonSelect.mock.calls[0][0]; - expect(callArgs.initialIndex).toBe(0); - }); - - describe('VisionSwitchOutcome enum', () => { - it('should have correct enum values', () => { - expect(VisionSwitchOutcome.SwitchOnce).toBe('once'); - expect(VisionSwitchOutcome.SwitchSessionToVL).toBe('session'); - expect(VisionSwitchOutcome.ContinueWithCurrentModel).toBe('persist'); - }); - }); - - it('should handle multiple onSelect calls correctly', () => { - render(); - - const onSelectCallback = mockRadioButtonSelect.mock.calls[0][0].onSelect; - - // Call multiple times - onSelectCallback(VisionSwitchOutcome.SwitchOnce); - onSelectCallback(VisionSwitchOutcome.SwitchSessionToVL); - onSelectCallback(VisionSwitchOutcome.ContinueWithCurrentModel); - - expect(mockOnSelect).toHaveBeenCalledTimes(3); - expect(mockOnSelect).toHaveBeenNthCalledWith( - 1, - VisionSwitchOutcome.SwitchOnce, - ); - expect(mockOnSelect).toHaveBeenNthCalledWith( - 2, - VisionSwitchOutcome.SwitchSessionToVL, - ); - expect(mockOnSelect).toHaveBeenNthCalledWith( - 3, - VisionSwitchOutcome.ContinueWithCurrentModel, - ); - }); - - it('should pass isFocused prop to RadioButtonSelect', () => { - render(); - - const callArgs = mockRadioButtonSelect.mock.calls[0][0]; - expect(callArgs.isFocused).toBe(true); - }); - - it('should handle escape key multiple times', () => { - render(); - - const keypressHandler = mockUseKeypress.mock.calls[0][0]; - - // Call escape multiple times - keypressHandler({ name: 'escape' }); - keypressHandler({ name: 'escape' }); - - expect(mockOnSelect).toHaveBeenCalledTimes(2); - expect(mockOnSelect).toHaveBeenCalledWith( - VisionSwitchOutcome.ContinueWithCurrentModel, - ); - }); -}); diff --git a/packages/cli/src/ui/components/ModelSwitchDialog.tsx b/packages/cli/src/ui/components/ModelSwitchDialog.tsx deleted file mode 100644 index 97bfc53a3..000000000 --- a/packages/cli/src/ui/components/ModelSwitchDialog.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Box, Text } from 'ink'; -import { Colors } from '../colors.js'; -import { - RadioButtonSelect, - type RadioSelectItem, -} from './shared/RadioButtonSelect.js'; -import { useKeypress } from '../hooks/useKeypress.js'; - -export enum VisionSwitchOutcome { - SwitchOnce = 'once', - SwitchSessionToVL = 'session', - ContinueWithCurrentModel = 'persist', -} - -export interface ModelSwitchDialogProps { - onSelect: (outcome: VisionSwitchOutcome) => void; -} - -export const ModelSwitchDialog: React.FC = ({ - onSelect, -}) => { - useKeypress( - (key) => { - if (key.name === 'escape') { - onSelect(VisionSwitchOutcome.ContinueWithCurrentModel); - } - }, - { isActive: true }, - ); - - const options: Array> = [ - { - key: 'switch-once', - label: 'Switch for this request only', - value: VisionSwitchOutcome.SwitchOnce, - }, - { - key: 'switch-session', - label: 'Switch session to vision model', - value: VisionSwitchOutcome.SwitchSessionToVL, - }, - { - key: 'continue', - label: 'Continue with current model', - value: VisionSwitchOutcome.ContinueWithCurrentModel, - }, - ]; - - const handleSelect = (outcome: VisionSwitchOutcome) => { - onSelect(outcome); - }; - - return ( - - - Vision Model Switch Required - - Your message contains an image, but the current model doesn't - support vision. - - How would you like to proceed? - - - - - - - - Press Enter to select, Esc to cancel - - - ); -}; diff --git a/packages/cli/src/ui/components/messages/InfoMessage.tsx b/packages/cli/src/ui/components/messages/InfoMessage.tsx index fb03fbef1..af036237a 100644 --- a/packages/cli/src/ui/components/messages/InfoMessage.tsx +++ b/packages/cli/src/ui/components/messages/InfoMessage.tsx @@ -29,7 +29,7 @@ export const InfoMessage: React.FC = ({ text }) => { - + diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index 7bfe9a962..b285b0a35 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -330,7 +330,7 @@ export const ToolConfirmationMessage: React.FC< bodyContent = ( - + {displayUrls && infoProps.urls && infoProps.urls.length > 0 && ( diff --git a/packages/cli/src/ui/components/messages/WarningMessage.tsx b/packages/cli/src/ui/components/messages/WarningMessage.tsx index 4bc2c899c..589ca4b07 100644 --- a/packages/cli/src/ui/components/messages/WarningMessage.tsx +++ b/packages/cli/src/ui/components/messages/WarningMessage.tsx @@ -8,6 +8,7 @@ import type React from 'react'; import { Box, Text } from 'ink'; import { Colors } from '../../colors.js'; import { RenderInline } from '../../utils/InlineMarkdownRenderer.js'; +import { theme } from '../../semantic-colors.js'; interface WarningMessageProps { text: string; @@ -24,7 +25,7 @@ export const WarningMessage: React.FC = ({ text }) => { - + diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 7534b6d3a..1965ceb26 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -17,7 +17,6 @@ import { import { type SettingScope } from '../../config/settings.js'; import { type CodingPlanRegion } from '../../constants/codingPlan.js'; import type { AuthState } from '../types.js'; -import { type VisionSwitchOutcome } from '../components/ModelSwitchDialog.js'; // OpenAICredentials type (previously imported from OpenAIKeyPrompt) export interface OpenAICredentials { apiKey: string; @@ -68,8 +67,6 @@ export interface UIActions { refreshStatic: () => void; handleFinalSubmit: (value: string) => void; handleClearScreen: () => void; - // Vision switch dialog - handleVisionSwitchSelect: (outcome: VisionSwitchOutcome) => void; // Welcome back dialog handleWelcomeBackSelection: (choice: 'continue' | 'restart') => void; handleWelcomeBackClose: () => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index f8d52faa1..9d1a21e83 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -115,8 +115,6 @@ export interface UIState { extensionsUpdateState: Map; activePtyId: number | undefined; embeddedShellFocused: boolean; - // Vision switch dialog - isVisionSwitchDialogOpen: boolean; // Welcome back dialog showWelcomeBackDialog: boolean; welcomeBackInfo: { diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 42ce40993..c48653970 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -88,6 +88,10 @@ vi.mock('../../utils/cleanup.js', () => ({ runExitCleanup: mockRunExitCleanup, })); +vi.mock('./useKeypress.js', () => ({ + useKeypress: vi.fn(), +})); + function createTestCommand( overrides: Partial, kind: CommandKind = CommandKind.BUILT_IN, @@ -143,6 +147,7 @@ describe('useSlashCommandProcessor', () => { mockLoadHistory, vi.fn(), // refreshStatic vi.fn(), // toggleVimEnabled + false, // isProcessing setIsProcessing, vi.fn(), // setGeminiMdFileCount { @@ -151,9 +156,19 @@ describe('useSlashCommandProcessor', () => { openEditorDialog: vi.fn(), openSettingsDialog: vi.fn(), openModelDialog: mockOpenModelDialog, + openPermissionsDialog: vi.fn(), + openApprovalModeDialog: vi.fn(), + openResumeDialog: vi.fn(), quit: mockSetQuittingMessages, setDebugMessage: vi.fn(), + dispatchExtensionStateUpdate: vi.fn(), + addConfirmUpdateExtensionRequest: vi.fn(), + openSubagentCreateDialog: vi.fn(), + openAgentsManagerDialog: vi.fn(), }, + new Map(), // extensionsUpdateState + true, // isConfigInitialized + null, // logger ), ); @@ -459,7 +474,7 @@ describe('useSlashCommandProcessor', () => { name: 'loadwiththoughts', action: vi.fn().mockResolvedValue({ type: 'load_history', - history: [{ type: MessageType.MODEL, text: 'response' }], + history: [{ type: MessageType.GEMINI, text: 'response' }], clientHistory: historyWithThoughts, }), }); @@ -904,18 +919,29 @@ describe('useSlashCommandProcessor', () => { mockClearItems, mockLoadHistory, vi.fn(), // refreshStatic - vi.fn(), // onDebugMessage - vi.fn(), // openThemeDialog - mockOpenAuthDialog, - vi.fn(), // openEditorDialog - mockSetQuittingMessages, - vi.fn(), // openSettingsDialog - vi.fn(), // openModelSelectionDialog - vi.fn(), // openSubagentCreateDialog - vi.fn(), // openAgentsManagerDialog vi.fn(), // toggleVimEnabled + false, // isProcessing vi.fn(), // setIsProcessing vi.fn(), // setGeminiMdFileCount + { + openAuthDialog: mockOpenAuthDialog, + openThemeDialog: mockOpenThemeDialog, + openEditorDialog: vi.fn(), + openSettingsDialog: vi.fn(), + openModelDialog: vi.fn(), + openPermissionsDialog: vi.fn(), + openApprovalModeDialog: vi.fn(), + openResumeDialog: vi.fn(), + quit: mockSetQuittingMessages, + setDebugMessage: vi.fn(), + dispatchExtensionStateUpdate: vi.fn(), + addConfirmUpdateExtensionRequest: vi.fn(), + openSubagentCreateDialog: vi.fn(), + openAgentsManagerDialog: vi.fn(), + }, + new Map(), // extensionsUpdateState + true, // isConfigInitialized + null, // logger ), ); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index e3243a2bb..80c6bec35 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useCallback, useMemo, useEffect, useState } from 'react'; +import { useCallback, useMemo, useEffect, useRef, useState } from 'react'; import { type PartListUnion } from '@google/genai'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; import { @@ -35,6 +35,7 @@ import { FileCommandLoader } from '../../services/FileCommandLoader.js'; import { McpPromptLoader } from '../../services/McpPromptLoader.js'; import { parseSlashCommand } from '../../utils/commands.js'; import { clearScreen } from '../../utils/stdioHelpers.js'; +import { useKeypress } from './useKeypress.js'; import { type ExtensionUpdateAction, type ExtensionUpdateStatus, @@ -90,6 +91,7 @@ export const useSlashCommandProcessor = ( loadHistory: UseHistoryManagerReturn['loadHistory'], refreshStatic: () => void, toggleVimEnabled: () => Promise, + isProcessing: boolean, setIsProcessing: (isProcessing: boolean) => void, setGeminiMdFileCount: (count: number) => void, actions: SlashCommandProcessorActions, @@ -131,6 +133,34 @@ export const useSlashCommandProcessor = ( null, ); + // AbortController for cancelling async slash commands via ESC + const abortControllerRef = useRef(null); + + const cancelSlashCommand = useCallback(() => { + if (!abortControllerRef.current) { + return; + } + abortControllerRef.current.abort(); + addItem( + { + type: MessageType.INFO, + text: 'Command cancelled.', + }, + Date.now(), + ); + setPendingItem(null); + setIsProcessing(false); + }, [addItem, setIsProcessing]); + + useKeypress( + (key) => { + if (key.name === 'escape') { + cancelSlashCommand(); + } + }, + { isActive: isProcessing }, + ); + const pendingHistoryItems = useMemo(() => { const items: HistoryItemWithoutId[] = []; if (pendingItem != null) { @@ -324,6 +354,10 @@ export const useSlashCommandProcessor = ( setIsProcessing(true); + // Create a new AbortController for this command execution + const abortController = new AbortController(); + abortControllerRef.current = abortController; + const userMessageTimestamp = Date.now(); addItemWithRecording( { type: MessageType.USER, text: trimmed }, @@ -357,6 +391,7 @@ export const useSlashCommandProcessor = ( args, }, overwriteConfirmed, + abortSignal: abortController.signal, }; // If a one-time list is provided for a "Proceed" action, temporarily @@ -370,10 +405,27 @@ export const useSlashCommandProcessor = ( ]), }; } - const result = await commandToExecute.action( - fullCommandContext, - args, - ); + // Race the command action against the abort signal so that + // ESC cancellation immediately unblocks the await chain. + // Without this, commands like /compress whose underlying + // operation (tryCompressChat) doesn't accept an AbortSignal + // would keep submitQuery stuck until the operation completes. + const abortPromise = new Promise((resolve) => { + abortController.signal.addEventListener( + 'abort', + () => resolve(undefined), + { once: true }, + ); + }); + const result = await Promise.race([ + commandToExecute.action(fullCommandContext, args), + abortPromise, + ]); + + // If the command was cancelled via ESC while executing, skip result processing + if (abortController.signal.aborted) { + return { type: 'handled' }; + } if (result) { switch (result.type) { @@ -566,6 +618,10 @@ export const useSlashCommandProcessor = ( return { type: 'handled' }; } catch (e: unknown) { + // If cancelled via ESC, the cancelSlashCommand callback already handled cleanup + if (abortController.signal.aborted) { + return { type: 'handled' }; + } hasError = true; if (config) { const event = makeSlashCommandEvent({ diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index edf0e0576..e855eefc3 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -74,14 +74,6 @@ const mockParseAndFormatApiError = vi.hoisted(() => ); const mockLogApiCancel = vi.hoisted(() => vi.fn()); -// Vision auto-switch mocks (hoisted) -const mockHandleVisionSwitch = vi.hoisted(() => - vi.fn().mockResolvedValue({ shouldProceed: true }), -); -const mockRestoreOriginalModel = vi.hoisted(() => - vi.fn().mockResolvedValue(undefined), -); - vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { const actualCoreModule = (await importOriginal()) as any; return { @@ -104,13 +96,6 @@ vi.mock('./useReactToolScheduler.js', async (importOriginal) => { }; }); -vi.mock('./useVisionAutoSwitch.js', () => ({ - useVisionAutoSwitch: vi.fn(() => ({ - handleVisionSwitch: mockHandleVisionSwitch, - restoreOriginalModel: mockRestoreOriginalModel, - })), -})); - vi.mock('./shellCommandProcessor.js', () => ({ useShellCommandProcessor: vi.fn().mockReturnValue({ handleShellCommand: vi.fn(), @@ -306,7 +291,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -472,7 +456,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -557,7 +540,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -670,7 +652,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -784,7 +765,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -901,7 +881,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, cancelSubmitSpy, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -945,7 +924,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, vi.fn(), - false, setShellInputFocusedSpy, // Pass the spy here 80, 24, @@ -1273,7 +1251,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -1331,7 +1308,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -1825,7 +1801,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -1881,7 +1856,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -1938,7 +1912,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -2035,7 +2008,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled vi.fn(), 80, 24, @@ -2090,7 +2062,6 @@ describe('useGeminiStream', () => { vi.fn(), // setModelSwitched vi.fn(), // onEditorClose vi.fn(), // onCancelSubmit - false, // visionModelPreviewEnabled vi.fn(), // setShellInputFocused 80, // terminalWidth 24, // terminalHeight @@ -2164,7 +2135,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -2256,7 +2226,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -2317,7 +2286,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -2418,7 +2386,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled vi.fn(), // setShellInputFocused 80, 24, @@ -2489,7 +2456,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -2548,7 +2514,6 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, - false, // visionModelPreviewEnabled () => {}, 80, 24, @@ -2736,187 +2701,6 @@ describe('useGeminiStream', () => { }); // --- New tests focused on recent modifications --- - describe('Vision Auto Switch Integration', () => { - it('should call handleVisionSwitch and proceed to send when allowed', async () => { - mockHandleVisionSwitch.mockResolvedValueOnce({ shouldProceed: true }); - mockSendMessageStream.mockReturnValue( - (async function* () { - yield { type: ServerGeminiEventType.Content, value: 'ok' }; - yield { type: ServerGeminiEventType.Finished, value: 'STOP' }; - })(), - ); - - const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), - [], - mockAddItem, - mockConfig, - mockLoadedSettings, - mockOnDebugMessage, - mockHandleSlashCommand, - false, - () => 'vscode' as EditorType, - () => {}, - () => Promise.resolve(), - false, - () => {}, - () => {}, - () => {}, - false, // visionModelPreviewEnabled - vi.fn(), // setShellInputFocused - 80, - 24, - ), - ); - - await act(async () => { - await result.current.submitQuery('image prompt'); - }); - - await waitFor(() => { - expect(mockHandleVisionSwitch).toHaveBeenCalled(); - expect(mockSendMessageStream).toHaveBeenCalled(); - }); - }); - - it('should gate submission when handleVisionSwitch returns shouldProceed=false', async () => { - mockHandleVisionSwitch.mockResolvedValueOnce({ shouldProceed: false }); - - const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), - [], - mockAddItem, - mockConfig, - mockLoadedSettings, - mockOnDebugMessage, - mockHandleSlashCommand, - false, - () => 'vscode' as EditorType, - () => {}, - () => Promise.resolve(), - false, - () => {}, - () => {}, - () => {}, - false, // visionModelPreviewEnabled - vi.fn(), // setShellInputFocused - 80, - 24, - ), - ); - - await act(async () => { - await result.current.submitQuery('vision-gated'); - }); - - // No call to API, no restoreOriginalModel needed since no override occurred - expect(mockSendMessageStream).not.toHaveBeenCalled(); - expect(mockRestoreOriginalModel).not.toHaveBeenCalled(); - - // Next call allowed (flag reset path) - mockHandleVisionSwitch.mockResolvedValueOnce({ shouldProceed: true }); - mockSendMessageStream.mockReturnValue( - (async function* () { - yield { type: ServerGeminiEventType.Content, value: 'ok' }; - yield { type: ServerGeminiEventType.Finished, value: 'STOP' }; - })(), - ); - await act(async () => { - await result.current.submitQuery('after-gate'); - }); - await waitFor(() => { - expect(mockSendMessageStream).toHaveBeenCalled(); - }); - }); - }); - - describe('Model restore on completion and errors', () => { - it('should restore model after successful stream completion', async () => { - mockSendMessageStream.mockReturnValue( - (async function* () { - yield { type: ServerGeminiEventType.Content, value: 'content' }; - yield { type: ServerGeminiEventType.Finished, value: 'STOP' }; - })(), - ); - - const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), - [], - mockAddItem, - mockConfig, - mockLoadedSettings, - mockOnDebugMessage, - mockHandleSlashCommand, - false, - () => 'vscode' as EditorType, - () => {}, - () => Promise.resolve(), - false, - () => {}, - () => {}, - () => {}, - false, // visionModelPreviewEnabled - vi.fn(), // setShellInputFocused - 80, - 24, - ), - ); - - await act(async () => { - await result.current.submitQuery('restore-success'); - }); - - await waitFor(() => { - expect(mockRestoreOriginalModel).toHaveBeenCalledTimes(1); - }); - }); - - it('should restore model when an error occurs during streaming', async () => { - const testError = new Error('stream failure'); - mockSendMessageStream.mockReturnValue( - (async function* () { - yield { type: ServerGeminiEventType.Content, value: 'content' }; - throw testError; - })(), - ); - - const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), - [], - mockAddItem, - mockConfig, - mockLoadedSettings, - mockOnDebugMessage, - mockHandleSlashCommand, - false, - () => 'vscode' as EditorType, - () => {}, - () => Promise.resolve(), - false, - () => {}, - () => {}, - () => {}, - false, // visionModelPreviewEnabled - vi.fn(), // setShellInputFocused - 80, - 24, - ), - ); - - await act(async () => { - await result.current.submitQuery('restore-error'); - }); - - await waitFor(() => { - expect(mockRestoreOriginalModel).toHaveBeenCalledTimes(1); - }); - }); - }); - describe('Loop Detection Confirmation', () => { beforeEach(() => { // Add mock for getLoopDetectionService to the config diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 5bebbac7e..2da4eed53 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -35,6 +35,8 @@ import { ToolConfirmationOutcome, logApiCancel, ApiCancelEvent, + isSupportedImageMimeType, + getUnsupportedImageFormatWarning, } from '@qwen-code/qwen-code-core'; import { type Part, type PartListUnion, FinishReason } from '@google/genai'; import type { @@ -46,7 +48,6 @@ import type { import { StreamingState, MessageType, ToolCallStatus } from '../types.js'; import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js'; import { useShellCommandProcessor } from './shellCommandProcessor.js'; -import { useVisionAutoSwitch } from './useVisionAutoSwitch.js'; import { handleAtCommand } from './atCommandProcessor.js'; import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js'; import { useStateAndRef } from './useStateAndRef.js'; @@ -68,6 +69,60 @@ import { t } from '../../i18n/index.js'; const debugLogger = createDebugLogger('GEMINI_STREAM'); +/** + * Checks if image parts have supported formats and returns unsupported ones + */ +function checkImageFormatsSupport(parts: PartListUnion): { + hasImages: boolean; + hasUnsupportedFormats: boolean; + unsupportedMimeTypes: string[]; +} { + const unsupportedMimeTypes: string[] = []; + let hasImages = false; + + if (typeof parts === 'string') { + return { + hasImages: false, + hasUnsupportedFormats: false, + unsupportedMimeTypes: [], + }; + } + + const partsArray = Array.isArray(parts) ? parts : [parts]; + + for (const part of partsArray) { + if (typeof part === 'string') continue; + + let mimeType: string | undefined; + + // Check inlineData + if ( + 'inlineData' in part && + part.inlineData?.mimeType?.startsWith('image/') + ) { + hasImages = true; + mimeType = part.inlineData.mimeType; + } + + // Check fileData + if ('fileData' in part && part.fileData?.mimeType?.startsWith('image/')) { + hasImages = true; + mimeType = part.fileData.mimeType; + } + + // Check if the mime type is supported + if (mimeType && !isSupportedImageMimeType(mimeType)) { + unsupportedMimeTypes.push(mimeType); + } + } + + return { + hasImages, + hasUnsupportedFormats: unsupportedMimeTypes.length > 0, + unsupportedMimeTypes, + }; +} + enum StreamProcessingStatus { Completed, UserCancelled, @@ -106,15 +161,9 @@ export const useGeminiStream = ( setModelSwitchedFromQuotaError: React.Dispatch>, onEditorClose: () => void, onCancelSubmit: () => void, - visionModelPreviewEnabled: boolean, setShellInputFocused: (value: boolean) => void, terminalWidth: number, terminalHeight: number, - onVisionSwitchRequired?: (query: PartListUnion) => Promise<{ - modelOverride?: string; - persistSessionModel?: string; - showGuidance?: boolean; - }>, ) => { const [initError, setInitError] = useState(null); const abortControllerRef = useRef(null); @@ -278,12 +327,6 @@ export const useGeminiStream = ( terminalHeight, ); - const { handleVisionSwitch, restoreOriginalModel } = useVisionAutoSwitch( - config, - addItem, - visionModelPreviewEnabled, - onVisionSwitchRequired, - ); const activePtyId = activeShellPtyId || activeToolPtyId; useEffect(() => { @@ -1028,16 +1071,18 @@ export const useGeminiStream = ( return; } - // Handle vision switch requirement - const visionSwitchResult = await handleVisionSwitch( - queryToSend, - userMessageTimestamp, - options?.isContinuation || false, - ); - - if (!visionSwitchResult.shouldProceed) { - isSubmittingQueryRef.current = false; - return; + // Check image format support for non-continuations + if (!options?.isContinuation) { + const formatCheck = checkImageFormatsSupport(queryToSend); + if (formatCheck.hasUnsupportedFormats) { + addItem( + { + type: MessageType.INFO, + text: getUnsupportedImageFormatWarning(), + }, + userMessageTimestamp, + ); + } } const finalQueryToSend = queryToSend; @@ -1081,10 +1126,6 @@ export const useGeminiStream = ( ); if (processingStatus === StreamProcessingStatus.UserCancelled) { - // Restore original model if it was temporarily overridden - restoreOriginalModel().catch((error) => { - debugLogger.error('Failed to restore original model:', error); - }); isSubmittingQueryRef.current = false; return; } @@ -1097,17 +1138,7 @@ export const useGeminiStream = ( loopDetectedRef.current = false; handleLoopDetectedEvent(); } - - // Restore original model if it was temporarily overridden - restoreOriginalModel().catch((error) => { - debugLogger.error('Failed to restore original model:', error); - }); } catch (error: unknown) { - // Restore original model if it was temporarily overridden - restoreOriginalModel().catch((error) => { - debugLogger.error('Failed to restore original model:', error); - }); - if (error instanceof UnauthorizedError) { onAuthError('Session expired or is unauthorized.'); } else if (!isNodeError(error) || error.name !== 'AbortError') { @@ -1143,8 +1174,6 @@ export const useGeminiStream = ( startNewPrompt, getPromptCount, handleLoopDetectedEvent, - handleVisionSwitch, - restoreOriginalModel, ], ); diff --git a/packages/cli/src/ui/hooks/useVisionAutoSwitch.test.ts b/packages/cli/src/ui/hooks/useVisionAutoSwitch.test.ts deleted file mode 100644 index 782986ce9..000000000 --- a/packages/cli/src/ui/hooks/useVisionAutoSwitch.test.ts +++ /dev/null @@ -1,874 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; -import type { Part, PartListUnion } from '@google/genai'; -import { AuthType, type Config, ApprovalMode } from '@qwen-code/qwen-code-core'; - -// Mock the image format functions from core package -vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { - const actual = (await importOriginal()) as Record; - return { - ...actual, - isSupportedImageMimeType: vi.fn((mimeType: string) => - [ - 'image/png', - 'image/jpeg', - 'image/jpg', - 'image/gif', - 'image/webp', - ].includes(mimeType), - ), - getUnsupportedImageFormatWarning: vi.fn( - () => - 'Only the following image formats are supported: BMP, JPEG, JPG, PNG, TIFF, WEBP, HEIC. Other formats may not work as expected.', - ), - }; -}); -import { - shouldOfferVisionSwitch, - processVisionSwitchOutcome, - getVisionSwitchGuidanceMessage, - useVisionAutoSwitch, -} from './useVisionAutoSwitch.js'; -import { VisionSwitchOutcome } from '../components/ModelSwitchDialog.js'; -import { MessageType } from '../types.js'; -import { getDefaultVisionModel } from '../models/availableModels.js'; - -describe('useVisionAutoSwitch helpers', () => { - describe('shouldOfferVisionSwitch', () => { - it('returns false when authType is not QWEN_OAUTH', () => { - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.USE_GEMINI, - 'qwen3-coder-plus', - true, - ); - expect(result).toBe(false); - }); - - it('returns false when current model is already a vision model', () => { - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.QWEN_OAUTH, - 'vision-model', - true, - ); - expect(result).toBe(false); - }); - - it('returns true when image parts exist, QWEN_OAUTH, and model is not vision', () => { - const parts: PartListUnion = [ - { text: 'hello' }, - { inlineData: { mimeType: 'image/jpeg', data: '...' } }, - ]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - true, - ); - expect(result).toBe(true); - }); - - it('detects image when provided as a single Part object (non-array)', () => { - const singleImagePart: PartListUnion = { - fileData: { mimeType: 'image/gif', fileUri: 'file://image.gif' }, - } as Part; - const result = shouldOfferVisionSwitch( - singleImagePart, - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - true, - ); - expect(result).toBe(true); - }); - - it('returns false when parts contain no images', () => { - const parts: PartListUnion = [{ text: 'just text' }]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - true, - ); - expect(result).toBe(false); - }); - - it('returns false when parts is a plain string', () => { - const parts: PartListUnion = 'plain text'; - const result = shouldOfferVisionSwitch( - parts, - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - true, - ); - expect(result).toBe(false); - }); - - it('returns false when visionModelPreviewEnabled is false', () => { - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - false, - ); - expect(result).toBe(false); - }); - - it('returns true when image parts exist in YOLO mode context', () => { - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - true, - ); - expect(result).toBe(true); - }); - - it('returns false when no image parts exist in YOLO mode context', () => { - const parts: PartListUnion = [{ text: 'just text' }]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - true, - ); - expect(result).toBe(false); - }); - - it('returns false when already using vision model in YOLO mode context', () => { - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.QWEN_OAUTH, - 'vision-model', - true, - ); - expect(result).toBe(false); - }); - - it('returns false when authType is not QWEN_OAUTH in YOLO mode context', () => { - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - const result = shouldOfferVisionSwitch( - parts, - AuthType.USE_GEMINI, - 'qwen3-coder-plus', - true, - ); - expect(result).toBe(false); - }); - }); - - describe('processVisionSwitchOutcome', () => { - it('maps SwitchOnce to a one-time model override', () => { - const vl = getDefaultVisionModel(); - const result = processVisionSwitchOutcome(VisionSwitchOutcome.SwitchOnce); - expect(result).toEqual({ modelOverride: vl }); - }); - - it('maps SwitchSessionToVL to a persistent session model', () => { - const vl = getDefaultVisionModel(); - const result = processVisionSwitchOutcome( - VisionSwitchOutcome.SwitchSessionToVL, - ); - expect(result).toEqual({ persistSessionModel: vl }); - }); - - it('maps ContinueWithCurrentModel to empty result', () => { - const result = processVisionSwitchOutcome( - VisionSwitchOutcome.ContinueWithCurrentModel, - ); - expect(result).toEqual({}); - }); - }); - - describe('getVisionSwitchGuidanceMessage', () => { - it('returns the expected guidance message', () => { - const vl = getDefaultVisionModel(); - const expected = - 'To use images with your query, you can:\n' + - `• Use /model set ${vl} to switch to a vision-capable model\n` + - '• Or remove the image and provide a text description instead'; - expect(getVisionSwitchGuidanceMessage()).toBe(expected); - }); - }); -}); - -describe('useVisionAutoSwitch hook', () => { - type AddItemFn = ( - item: { type: MessageType; text: string }, - ts: number, - ) => any; - - const createMockConfig = ( - authType: AuthType, - initialModel: string, - approvalMode: ApprovalMode = ApprovalMode.DEFAULT, - vlmSwitchMode?: string, - ) => { - let currentModel = initialModel; - const mockConfig: Partial = { - getModel: vi.fn(() => currentModel), - setModel: vi.fn(async (m: string) => { - currentModel = m; - }), - getApprovalMode: vi.fn(() => approvalMode), - getVlmSwitchMode: vi.fn(() => vlmSwitchMode), - getContentGeneratorConfig: vi.fn(() => ({ - authType, - model: currentModel, - apiKey: 'test-key', - vertexai: false, - })), - }; - return mockConfig as Config; - }; - - let addItem: AddItemFn; - - beforeEach(() => { - vi.clearAllMocks(); - addItem = vi.fn(); - }); - - it('returns shouldProceed=true immediately for continuations', async () => { - const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus'); - const { result } = renderHook(() => - useVisionAutoSwitch(config, addItem as any, true, vi.fn()), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, Date.now(), true); - }); - expect(res).toEqual({ shouldProceed: true }); - expect(addItem).not.toHaveBeenCalled(); - }); - - it('does nothing when authType is not QWEN_OAUTH', async () => { - const config = createMockConfig(AuthType.USE_GEMINI, 'qwen3-coder-plus'); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 123, false); - }); - expect(res).toEqual({ shouldProceed: true }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - }); - - it('does nothing when there are no image parts', async () => { - const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus'); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired), - ); - - const parts: PartListUnion = [{ text: 'no images here' }]; - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 456, false); - }); - expect(res).toEqual({ shouldProceed: true }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - }); - - it('continues with current model when dialog returns empty result', async () => { - const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus'); - const onVisionSwitchRequired = vi.fn().mockResolvedValue({}); // Empty result for ContinueWithCurrentModel - const { result } = renderHook(() => - useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - - const userTs = 1010; - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, userTs, false); - }); - - // Should not add any guidance message - expect(addItem).not.toHaveBeenCalledWith( - { type: MessageType.INFO, text: getVisionSwitchGuidanceMessage() }, - userTs, - ); - expect(res).toEqual({ shouldProceed: true }); - expect(config.setModel).not.toHaveBeenCalled(); - }); - - it('applies a one-time override and returns originalModel, then restores', async () => { - const initialModel = 'qwen3-coder-plus'; - const config = createMockConfig(AuthType.QWEN_OAUTH, initialModel); - const onVisionSwitchRequired = vi - .fn() - .mockResolvedValue({ modelOverride: 'coder-model' }); - const { result } = renderHook(() => - useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 2020, false); - }); - - expect(res).toEqual({ shouldProceed: true, originalModel: initialModel }); - expect(config.setModel).toHaveBeenCalledWith('coder-model', { - reason: 'vision_auto_switch', - context: 'User-prompted vision switch (one-time override)', - }); - - // Now restore - await act(async () => { - await result.current.restoreOriginalModel(); - }); - expect(config.setModel).toHaveBeenLastCalledWith(initialModel, { - reason: 'vision_auto_switch', - context: 'Restoring original model after vision switch', - }); - }); - - it('persists session model when dialog requests persistence', async () => { - const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus'); - const onVisionSwitchRequired = vi - .fn() - .mockResolvedValue({ persistSessionModel: 'coder-model' }); - const { result } = renderHook(() => - useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 3030, false); - }); - - expect(res).toEqual({ shouldProceed: true }); - expect(config.setModel).toHaveBeenCalledWith('coder-model', { - reason: 'vision_auto_switch', - context: 'User-prompted vision switch (session persistent)', - }); - - // Restore should be a no-op since no one-time override was used - await act(async () => { - await result.current.restoreOriginalModel(); - }); - // Last call should still be the persisted model set - expect((config.setModel as any).mock.calls.pop()?.[0]).toBe('coder-model'); - }); - - it('returns shouldProceed=true when dialog returns no special flags', async () => { - const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus'); - const onVisionSwitchRequired = vi.fn().mockResolvedValue({}); - const { result } = renderHook(() => - useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 4040, false); - }); - expect(res).toEqual({ shouldProceed: true }); - expect(config.setModel).not.toHaveBeenCalled(); - }); - - it('blocks when dialog throws or is cancelled', async () => { - const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus'); - const onVisionSwitchRequired = vi.fn().mockRejectedValue(new Error('x')); - const { result } = renderHook(() => - useVisionAutoSwitch(config, addItem as any, true, onVisionSwitchRequired), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 5050, false); - }); - expect(res).toEqual({ shouldProceed: false }); - expect(config.setModel).not.toHaveBeenCalled(); - }); - - it('does nothing when visionModelPreviewEnabled is false', async () => { - const config = createMockConfig(AuthType.QWEN_OAUTH, 'qwen3-coder-plus'); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - false, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 6060, false); - }); - expect(res).toEqual({ shouldProceed: true }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - }); - - describe('YOLO mode behavior', () => { - it('automatically switches to vision model in YOLO mode without showing dialog', async () => { - const initialModel = 'qwen3-coder-plus'; - const config = createMockConfig( - AuthType.QWEN_OAUTH, - initialModel, - ApprovalMode.YOLO, - ); - const onVisionSwitchRequired = vi.fn(); // Should not be called in YOLO mode - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 7070, false); - }); - - // Should automatically switch without calling the dialog - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - expect(res).toEqual({ - shouldProceed: true, - originalModel: initialModel, - }); - expect(config.setModel).toHaveBeenCalledWith(getDefaultVisionModel(), { - reason: 'vision_auto_switch', - context: 'YOLO mode auto-switch for image content', - }); - }); - - it('does not switch in YOLO mode when no images are present', async () => { - const config = createMockConfig( - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - ApprovalMode.YOLO, - ); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [{ text: 'no images here' }]; - - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 8080, false); - }); - - expect(res).toEqual({ shouldProceed: true }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - expect(config.setModel).not.toHaveBeenCalled(); - }); - - it('does not switch in YOLO mode when already using vision model', async () => { - const config = createMockConfig( - AuthType.QWEN_OAUTH, - 'vision-model', - ApprovalMode.YOLO, - ); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 9090, false); - }); - - expect(res).toEqual({ shouldProceed: true }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - expect(config.setModel).not.toHaveBeenCalled(); - }); - - it('restores original model after YOLO mode auto-switch', async () => { - const initialModel = 'qwen3-coder-plus'; - const config = createMockConfig( - AuthType.QWEN_OAUTH, - initialModel, - ApprovalMode.YOLO, - ); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - - // First, trigger the auto-switch - await act(async () => { - await result.current.handleVisionSwitch(parts, 10100, false); - }); - - // Verify model was switched - expect(config.setModel).toHaveBeenCalledWith(getDefaultVisionModel(), { - reason: 'vision_auto_switch', - context: 'YOLO mode auto-switch for image content', - }); - - // Now restore the original model - await act(async () => { - await result.current.restoreOriginalModel(); - }); - - // Verify model was restored - expect(config.setModel).toHaveBeenLastCalledWith(initialModel, { - reason: 'vision_auto_switch', - context: 'Restoring original model after vision switch', - }); - }); - - it('does not switch in YOLO mode when authType is not QWEN_OAUTH', async () => { - const config = createMockConfig( - AuthType.USE_GEMINI, - 'qwen3-coder-plus', - ApprovalMode.YOLO, - ); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 11110, false); - }); - - expect(res).toEqual({ shouldProceed: true }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - expect(config.setModel).not.toHaveBeenCalled(); - }); - - it('does not switch in YOLO mode when visionModelPreviewEnabled is false', async () => { - const config = createMockConfig( - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - ApprovalMode.YOLO, - ); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - false, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/png', data: '...' } }, - ]; - - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 12120, false); - }); - - expect(res).toEqual({ shouldProceed: true }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - expect(config.setModel).not.toHaveBeenCalled(); - }); - - it('handles multiple image formats in YOLO mode', async () => { - const initialModel = 'qwen3-coder-plus'; - const config = createMockConfig( - AuthType.QWEN_OAUTH, - initialModel, - ApprovalMode.YOLO, - ); - const onVisionSwitchRequired = vi.fn(); - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { text: 'Here are some images:' }, - { inlineData: { mimeType: 'image/jpeg', data: '...' } }, - { fileData: { mimeType: 'image/png', fileUri: 'file://image.png' } }, - { text: 'Please analyze them.' }, - ]; - - let res: any; - await act(async () => { - res = await result.current.handleVisionSwitch(parts, 13130, false); - }); - - expect(res).toEqual({ - shouldProceed: true, - originalModel: initialModel, - }); - expect(config.setModel).toHaveBeenCalledWith(getDefaultVisionModel(), { - reason: 'vision_auto_switch', - context: 'YOLO mode auto-switch for image content', - }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - }); - }); - - describe('VLM switch mode default behavior', () => { - it('should automatically switch once when vlmSwitchMode is "once"', async () => { - const config = createMockConfig( - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - ApprovalMode.DEFAULT, - 'once', - ); - const onVisionSwitchRequired = vi.fn(); // Should not be called - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/jpeg', data: 'base64data' } }, - ]; - - const switchResult = await result.current.handleVisionSwitch( - parts, - Date.now(), - false, - ); - - expect(switchResult.shouldProceed).toBe(true); - expect(switchResult.originalModel).toBe('qwen3-coder-plus'); - expect(config.setModel).toHaveBeenCalledWith('vision-model', { - reason: 'vision_auto_switch', - context: 'Default VLM switch mode: once (one-time override)', - }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - }); - - it('should switch session when vlmSwitchMode is "session"', async () => { - const config = createMockConfig( - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - ApprovalMode.DEFAULT, - 'session', - ); - const onVisionSwitchRequired = vi.fn(); // Should not be called - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/jpeg', data: 'base64data' } }, - ]; - - const switchResult = await result.current.handleVisionSwitch( - parts, - Date.now(), - false, - ); - - expect(switchResult.shouldProceed).toBe(true); - expect(switchResult.originalModel).toBeUndefined(); // No original model for session switch - expect(config.setModel).toHaveBeenCalledWith('vision-model', { - reason: 'vision_auto_switch', - context: 'Default VLM switch mode: session (session persistent)', - }); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - }); - - it('should continue with current model when vlmSwitchMode is "persist"', async () => { - const config = createMockConfig( - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - ApprovalMode.DEFAULT, - 'persist', - ); - const onVisionSwitchRequired = vi.fn(); // Should not be called - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/jpeg', data: 'base64data' } }, - ]; - - const switchResult = await result.current.handleVisionSwitch( - parts, - Date.now(), - false, - ); - - expect(switchResult.shouldProceed).toBe(true); - expect(switchResult.originalModel).toBeUndefined(); - expect(config.setModel).not.toHaveBeenCalled(); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - }); - - it('should fall back to user prompt when vlmSwitchMode is not set', async () => { - const config = createMockConfig( - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - ApprovalMode.DEFAULT, - undefined, // No default mode - ); - const onVisionSwitchRequired = vi - .fn() - .mockResolvedValue({ modelOverride: 'vision-model' }); - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/jpeg', data: 'base64data' } }, - ]; - - const switchResult = await result.current.handleVisionSwitch( - parts, - Date.now(), - false, - ); - - expect(switchResult.shouldProceed).toBe(true); - expect(onVisionSwitchRequired).toHaveBeenCalledWith(parts); - }); - - it('should fall back to persist behavior when vlmSwitchMode has invalid value', async () => { - const config = createMockConfig( - AuthType.QWEN_OAUTH, - 'qwen3-coder-plus', - ApprovalMode.DEFAULT, - 'invalid-value', - ); - const onVisionSwitchRequired = vi.fn(); // Should not be called - const { result } = renderHook(() => - useVisionAutoSwitch( - config, - addItem as any, - true, - onVisionSwitchRequired, - ), - ); - - const parts: PartListUnion = [ - { inlineData: { mimeType: 'image/jpeg', data: 'base64data' } }, - ]; - - const switchResult = await result.current.handleVisionSwitch( - parts, - Date.now(), - false, - ); - - expect(switchResult.shouldProceed).toBe(true); - expect(switchResult.originalModel).toBeUndefined(); - // For invalid values, it should continue with current model (persist behavior) - expect(config.setModel).not.toHaveBeenCalled(); - expect(onVisionSwitchRequired).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/cli/src/ui/hooks/useVisionAutoSwitch.ts b/packages/cli/src/ui/hooks/useVisionAutoSwitch.ts deleted file mode 100644 index f489c843a..000000000 --- a/packages/cli/src/ui/hooks/useVisionAutoSwitch.ts +++ /dev/null @@ -1,363 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import { type PartListUnion, type Part } from '@google/genai'; -import { AuthType, type Config, ApprovalMode } from '@qwen-code/qwen-code-core'; -import { useCallback, useRef } from 'react'; -import { VisionSwitchOutcome } from '../components/ModelSwitchDialog.js'; -import { - getDefaultVisionModel, - isVisionModel, -} from '../models/availableModels.js'; -import { MessageType } from '../types.js'; -import type { UseHistoryManagerReturn } from './useHistoryManager.js'; -import { - isSupportedImageMimeType, - getUnsupportedImageFormatWarning, -} from '@qwen-code/qwen-code-core'; - -/** - * Checks if a PartListUnion contains image parts - */ -function hasImageParts(parts: PartListUnion): boolean { - if (typeof parts === 'string') { - return false; - } - - if (Array.isArray(parts)) { - return parts.some((part) => { - // Skip string parts - if (typeof part === 'string') return false; - return isImagePart(part); - }); - } - - // If it's a single Part (not a string), check if it's an image - if (typeof parts === 'object') { - return isImagePart(parts); - } - - return false; -} - -/** - * Checks if a single Part is an image part - */ -function isImagePart(part: Part): boolean { - // Check for inlineData with image mime type - if ('inlineData' in part && part.inlineData?.mimeType?.startsWith('image/')) { - return true; - } - - // Check for fileData with image mime type - if ('fileData' in part && part.fileData?.mimeType?.startsWith('image/')) { - return true; - } - - return false; -} - -/** - * Checks if image parts have supported formats and returns unsupported ones - */ -function checkImageFormatsSupport(parts: PartListUnion): { - hasImages: boolean; - hasUnsupportedFormats: boolean; - unsupportedMimeTypes: string[]; -} { - const unsupportedMimeTypes: string[] = []; - let hasImages = false; - - if (typeof parts === 'string') { - return { - hasImages: false, - hasUnsupportedFormats: false, - unsupportedMimeTypes: [], - }; - } - - const partsArray = Array.isArray(parts) ? parts : [parts]; - - for (const part of partsArray) { - if (typeof part === 'string') continue; - - let mimeType: string | undefined; - - // Check inlineData - if ( - 'inlineData' in part && - part.inlineData?.mimeType?.startsWith('image/') - ) { - hasImages = true; - mimeType = part.inlineData.mimeType; - } - - // Check fileData - if ('fileData' in part && part.fileData?.mimeType?.startsWith('image/')) { - hasImages = true; - mimeType = part.fileData.mimeType; - } - - // Check if the mime type is supported - if (mimeType && !isSupportedImageMimeType(mimeType)) { - unsupportedMimeTypes.push(mimeType); - } - } - - return { - hasImages, - hasUnsupportedFormats: unsupportedMimeTypes.length > 0, - unsupportedMimeTypes, - }; -} - -/** - * Determines if we should offer vision switch for the given parts, auth type, and current model - */ -export function shouldOfferVisionSwitch( - parts: PartListUnion, - authType: AuthType, - currentModel: string, - visionModelPreviewEnabled: boolean = true, -): boolean { - // Only trigger for qwen-oauth - if (authType !== AuthType.QWEN_OAUTH) { - return false; - } - - // If vision model preview is disabled, never offer vision switch - if (!visionModelPreviewEnabled) { - return false; - } - - // If current model is already a vision model, no need to switch - if (isVisionModel(currentModel)) { - return false; - } - - // Check if the current message contains image parts - return hasImageParts(parts); -} - -/** - * Interface for vision switch result - */ -export interface VisionSwitchResult { - modelOverride?: string; - persistSessionModel?: string; - showGuidance?: boolean; -} - -/** - * Processes the vision switch outcome and returns the appropriate result - */ -export function processVisionSwitchOutcome( - outcome: VisionSwitchOutcome, -): VisionSwitchResult { - const vlModelId = getDefaultVisionModel(); - - switch (outcome) { - case VisionSwitchOutcome.SwitchOnce: - return { modelOverride: vlModelId }; - - case VisionSwitchOutcome.SwitchSessionToVL: - return { persistSessionModel: vlModelId }; - - case VisionSwitchOutcome.ContinueWithCurrentModel: - return {}; // Continue with current model, no changes needed - - default: - return {}; // Default to continuing with current model - } -} - -/** - * Gets the guidance message for when vision switch is disallowed - */ -export function getVisionSwitchGuidanceMessage(): string { - const vlModelId = getDefaultVisionModel(); - return `To use images with your query, you can: -• Use /model set ${vlModelId} to switch to a vision-capable model -• Or remove the image and provide a text description instead`; -} - -/** - * Interface for vision switch handling result - */ -export interface VisionSwitchHandlingResult { - shouldProceed: boolean; - originalModel?: string; -} - -/** - * Custom hook for handling vision model auto-switching - */ -export function useVisionAutoSwitch( - config: Config, - addItem: UseHistoryManagerReturn['addItem'], - visionModelPreviewEnabled: boolean = true, - onVisionSwitchRequired?: (query: PartListUnion) => Promise<{ - modelOverride?: string; - persistSessionModel?: string; - showGuidance?: boolean; - }>, -) { - const originalModelRef = useRef(null); - - const handleVisionSwitch = useCallback( - async ( - query: PartListUnion, - userMessageTimestamp: number, - isContinuation: boolean, - ): Promise => { - // Skip vision switch handling for continuations or if no handler provided - if (isContinuation || !onVisionSwitchRequired) { - return { shouldProceed: true }; - } - - const contentGeneratorConfig = config.getContentGeneratorConfig(); - - // Only handle qwen-oauth auth type - if (contentGeneratorConfig?.authType !== AuthType.QWEN_OAUTH) { - return { shouldProceed: true }; - } - - // Check image format support first - const formatCheck = checkImageFormatsSupport(query); - - // If there are unsupported image formats, show warning - if (formatCheck.hasUnsupportedFormats) { - addItem( - { - type: MessageType.INFO, - text: getUnsupportedImageFormatWarning(), - }, - userMessageTimestamp, - ); - // Continue processing but with warning shown - } - - // Check if vision switch is needed - if ( - !shouldOfferVisionSwitch( - query, - contentGeneratorConfig.authType, - config.getModel(), - visionModelPreviewEnabled, - ) - ) { - return { shouldProceed: true }; - } - - // In YOLO mode, automatically switch to vision model without user interaction - if (config.getApprovalMode() === ApprovalMode.YOLO) { - const vlModelId = getDefaultVisionModel(); - originalModelRef.current = config.getModel(); - await config.setModel(vlModelId, { - reason: 'vision_auto_switch', - context: 'YOLO mode auto-switch for image content', - }); - return { - shouldProceed: true, - originalModel: originalModelRef.current, - }; - } - - // Check if there's a default VLM switch mode configured - const defaultVlmSwitchMode = config.getVlmSwitchMode(); - if (defaultVlmSwitchMode) { - // Convert string value to VisionSwitchOutcome enum - let outcome: VisionSwitchOutcome; - switch (defaultVlmSwitchMode) { - case 'once': - outcome = VisionSwitchOutcome.SwitchOnce; - break; - case 'session': - outcome = VisionSwitchOutcome.SwitchSessionToVL; - break; - case 'persist': - outcome = VisionSwitchOutcome.ContinueWithCurrentModel; - break; - default: - // Invalid value, fall back to prompting user - outcome = VisionSwitchOutcome.ContinueWithCurrentModel; - } - - // Process the default outcome - const visionSwitchResult = processVisionSwitchOutcome(outcome); - - if (visionSwitchResult.modelOverride) { - // One-time model override - originalModelRef.current = config.getModel(); - await config.setModel(visionSwitchResult.modelOverride, { - reason: 'vision_auto_switch', - context: `Default VLM switch mode: ${defaultVlmSwitchMode} (one-time override)`, - }); - return { - shouldProceed: true, - originalModel: originalModelRef.current, - }; - } else if (visionSwitchResult.persistSessionModel) { - // Persistent session model change - await config.setModel(visionSwitchResult.persistSessionModel, { - reason: 'vision_auto_switch', - context: `Default VLM switch mode: ${defaultVlmSwitchMode} (session persistent)`, - }); - return { shouldProceed: true }; - } - - // For ContinueWithCurrentModel or any other case, proceed with current model - return { shouldProceed: true }; - } - - try { - const visionSwitchResult = await onVisionSwitchRequired(query); - - if (visionSwitchResult.modelOverride) { - // One-time model override - originalModelRef.current = config.getModel(); - await config.setModel(visionSwitchResult.modelOverride, { - reason: 'vision_auto_switch', - context: 'User-prompted vision switch (one-time override)', - }); - return { - shouldProceed: true, - originalModel: originalModelRef.current, - }; - } else if (visionSwitchResult.persistSessionModel) { - // Persistent session model change - await config.setModel(visionSwitchResult.persistSessionModel, { - reason: 'vision_auto_switch', - context: 'User-prompted vision switch (session persistent)', - }); - return { shouldProceed: true }; - } - - // For ContinueWithCurrentModel or any other case, proceed with current model - return { shouldProceed: true }; - } catch (_error) { - // If vision switch dialog was cancelled or errored, don't proceed - return { shouldProceed: false }; - } - }, - [config, addItem, visionModelPreviewEnabled, onVisionSwitchRequired], - ); - - const restoreOriginalModel = useCallback(async () => { - if (originalModelRef.current) { - await config.setModel(originalModelRef.current, { - reason: 'vision_auto_switch', - context: 'Restoring original model after vision switch', - }); - originalModelRef.current = null; - } - }, [config]); - - return { - handleVisionSwitch, - restoreOriginalModel, - }; -} diff --git a/packages/cli/src/ui/models/availableModels.test.ts b/packages/cli/src/ui/models/availableModels.test.ts index feac835c6..767fb6f06 100644 --- a/packages/cli/src/ui/models/availableModels.test.ts +++ b/packages/cli/src/ui/models/availableModels.test.ts @@ -9,42 +9,30 @@ import { getAvailableModelsForAuthType, getFilteredQwenModels, getOpenAIAvailableModelFromEnv, - isVisionModel, - getDefaultVisionModel, - AVAILABLE_MODELS_QWEN, - MAINLINE_VLM, - MAINLINE_CODER, } from './availableModels.js'; import { AuthType, type Config } from '@qwen-code/qwen-code-core'; describe('availableModels', () => { - describe('AVAILABLE_MODELS_QWEN', () => { - it('should include coder model', () => { - const coderModel = AVAILABLE_MODELS_QWEN.find( - (m) => m.id === MAINLINE_CODER, - ); - expect(coderModel).toBeDefined(); - expect(coderModel?.isVision).toBeFalsy(); + describe('Qwen models', () => { + const qwenModels = getFilteredQwenModels(); + + it('should include only coder-model', () => { + expect(qwenModels.length).toBe(1); + expect(qwenModels[0].id).toBe('coder-model'); }); - it('should include vision model', () => { - const visionModel = AVAILABLE_MODELS_QWEN.find( - (m) => m.id === MAINLINE_VLM, - ); - expect(visionModel).toBeDefined(); - expect(visionModel?.isVision).toBe(true); + it('should have coder-model with vision capability', () => { + const coderModel = qwenModels[0]; + expect(coderModel.isVision).toBe(true); }); }); describe('getFilteredQwenModels', () => { - it('should return all models when vision preview is enabled', () => { - const models = getFilteredQwenModels(true); - expect(models.length).toBe(AVAILABLE_MODELS_QWEN.length); - }); - - it('should filter out vision models when preview is disabled', () => { - const models = getFilteredQwenModels(false); - expect(models.every((m) => !m.isVision)).toBe(true); + it('should return coder-model with vision capability', () => { + const models = getFilteredQwenModels(); + expect(models.length).toBe(1); + expect(models[0].id).toBe('coder-model'); + expect(models[0].isVision).toBe(true); }); }); @@ -91,23 +79,36 @@ describe('availableModels', () => { it('should return hard-coded qwen models for qwen-oauth', () => { const models = getAvailableModelsForAuthType(AuthType.QWEN_OAUTH); - expect(models).toEqual(AVAILABLE_MODELS_QWEN); + expect(models.length).toBe(1); + expect(models[0].id).toBe('coder-model'); + expect(models[0].isVision).toBe(true); }); - it('should return hard-coded qwen models even when config is provided', () => { + it('should use config models for qwen-oauth when config is provided', () => { const mockConfig = { - getAvailableModels: vi - .fn() - .mockReturnValue([ - { id: 'custom', label: 'Custom', authType: AuthType.QWEN_OAUTH }, - ]), + getAvailableModelsForAuthType: vi.fn().mockReturnValue([ + { + id: 'custom', + label: 'Custom', + description: 'Custom model', + authType: AuthType.QWEN_OAUTH, + isVision: false, + }, + ]), } as unknown as Config; const models = getAvailableModelsForAuthType( AuthType.QWEN_OAUTH, mockConfig, ); - expect(models).toEqual(AVAILABLE_MODELS_QWEN); + expect(models).toEqual([ + { + id: 'custom', + label: 'Custom', + description: 'Custom model', + isVision: false, + }, + ]); }); it('should use config.getAvailableModels for openai authType when available', () => { @@ -182,24 +183,4 @@ describe('availableModels', () => { expect(models).toEqual([]); }); }); - - describe('isVisionModel', () => { - it('should return true for vision model', () => { - expect(isVisionModel(MAINLINE_VLM)).toBe(true); - }); - - it('should return false for non-vision model', () => { - expect(isVisionModel(MAINLINE_CODER)).toBe(false); - }); - - it('should return false for unknown model', () => { - expect(isVisionModel('unknown-model')).toBe(false); - }); - }); - - describe('getDefaultVisionModel', () => { - it('should return the vision model ID', () => { - expect(getDefaultVisionModel()).toBe(MAINLINE_VLM); - }); - }); }); diff --git a/packages/cli/src/ui/models/availableModels.ts b/packages/cli/src/ui/models/availableModels.ts index 0b9727642..def4f12a7 100644 --- a/packages/cli/src/ui/models/availableModels.ts +++ b/packages/cli/src/ui/models/availableModels.ts @@ -6,9 +6,9 @@ import { AuthType, - DEFAULT_QWEN_MODEL, type Config, type AvailableModel as CoreAvailableModel, + QWEN_OAUTH_MODELS, } from '@qwen-code/qwen-code-core'; import { t } from '../../i18n/index.js'; @@ -19,41 +19,25 @@ export type AvailableModel = { isVision?: boolean; }; -export const MAINLINE_VLM = 'vision-model'; -export const MAINLINE_CODER = DEFAULT_QWEN_MODEL; +const CACHED_QWEN_OAUTH_MODELS: AvailableModel[] = QWEN_OAUTH_MODELS.map( + (model) => ({ + id: model.id, + label: model.name ?? model.id, + description: model.description, + isVision: model.capabilities?.vision ?? false, + }), +); -export const AVAILABLE_MODELS_QWEN: AvailableModel[] = [ - { - id: MAINLINE_CODER, - label: MAINLINE_CODER, - get description() { - return t( - 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance', - ); - }, - }, - { - id: MAINLINE_VLM, - label: MAINLINE_VLM, - get description() { - return t( - 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)', - ); - }, - isVision: true, - }, -]; +function getQwenOAuthModels(): readonly AvailableModel[] { + return CACHED_QWEN_OAUTH_MODELS; +} /** - * Get available Qwen models filtered by vision model preview setting + * Get available Qwen models + * coder-model now has vision capabilities by default. */ -export function getFilteredQwenModels( - visionModelPreviewEnabled: boolean, -): AvailableModel[] { - if (visionModelPreviewEnabled) { - return AVAILABLE_MODELS_QWEN; - } - return AVAILABLE_MODELS_QWEN.filter((model) => !model.isVision); +export function getFilteredQwenModels(): AvailableModel[] { + return [...getQwenOAuthModels()]; } /** @@ -104,18 +88,12 @@ function convertCoreModelToCliModel( * Get available models for the given authType. * * If a Config object is provided, uses config.getAvailableModelsForAuthType(). - * For qwen-oauth, always returns the hard-coded models. * Falls back to environment variables only when no config is provided. */ export function getAvailableModelsForAuthType( authType: AuthType, config?: Config, ): AvailableModel[] { - // For qwen-oauth, always use hard-coded models, this aligns with the API gateway. - if (authType === AuthType.QWEN_OAUTH) { - return AVAILABLE_MODELS_QWEN; - } - // Use config's model registry when available if (config) { try { @@ -134,6 +112,9 @@ export function getAvailableModelsForAuthType( // Fall back to environment variables for specific auth types (no config provided) switch (authType) { + case AuthType.QWEN_OAUTH: { + return [...getQwenOAuthModels()]; + } case AuthType.USE_OPENAI: { const openAIModel = getOpenAIAvailableModelFromEnv(); return openAIModel ? [openAIModel] : []; @@ -146,17 +127,3 @@ export function getAvailableModelsForAuthType( return []; } } - -/** - * Hard code the default vision model as a string literal, - * until our coding model supports multimodal. - */ -export function getDefaultVisionModel(): string { - return MAINLINE_VLM; -} - -export function isVisionModel(modelId: string): boolean { - return AVAILABLE_MODELS_QWEN.some( - (model) => model.id === modelId && model.isVision, - ); -} diff --git a/packages/cli/src/utils/modelConfigUtils.test.ts b/packages/cli/src/utils/modelConfigUtils.test.ts index 0d39ed06e..97cea9974 100644 --- a/packages/cli/src/utils/modelConfigUtils.test.ts +++ b/packages/cli/src/utils/modelConfigUtils.test.ts @@ -506,7 +506,7 @@ describe('modelConfigUtils', () => { ); }); - it('should log warnings from resolveModelConfig', () => { + it('should return warnings from resolveModelConfig', () => { const argv = {}; const settings = makeMockSettings(); const selectedAuthType = AuthType.USE_OPENAI; @@ -521,14 +521,13 @@ describe('modelConfigUtils', () => { warnings: ['Warning 1', 'Warning 2'], }); - resolveCliGenerationConfig({ + const result = resolveCliGenerationConfig({ argv, settings, selectedAuthType, }); - expect(mockWriteStderrLine).toHaveBeenCalledWith('Warning 1'); - expect(mockWriteStderrLine).toHaveBeenCalledWith('Warning 2'); + expect(result.warnings).toEqual(['Warning 1', 'Warning 2']); }); it('should use custom env when provided', () => { diff --git a/packages/cli/src/utils/modelConfigUtils.ts b/packages/cli/src/utils/modelConfigUtils.ts index 948e6b253..aa5ac5e82 100644 --- a/packages/cli/src/utils/modelConfigUtils.ts +++ b/packages/cli/src/utils/modelConfigUtils.ts @@ -13,7 +13,6 @@ import { type ProviderModelConfig, } from '@qwen-code/qwen-code-core'; import type { Settings } from '../config/settings.js'; -import { writeStderrLine } from './stdioHelpers.js'; export interface CliGenerationConfigInputs { argv: { @@ -42,6 +41,8 @@ export interface ResolvedCliGenerationConfig { generationConfig: Partial; /** Source attribution for each resolved field */ sources: ContentGeneratorConfigSources; + /** Warnings generated during resolution */ + warnings: string[]; } export function getAuthTypeFromEnv(): AuthType | undefined { @@ -130,11 +131,6 @@ export function resolveCliGenerationConfig( const resolved = resolveModelConfig(configSources); - // Log warnings if any - for (const warning of resolved.warnings) { - writeStderrLine(warning); - } - // Resolve OpenAI logging config (CLI-specific, not part of core resolver) const enableOpenAILogging = (typeof argv.openaiLogging === 'undefined' @@ -158,5 +154,6 @@ export function resolveCliGenerationConfig( baseUrl: resolved.config.baseUrl || '', generationConfig, sources: resolved.sources, + warnings: resolved.warnings, }; } diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index c22bf94a5..849a0dbd4 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -541,7 +541,7 @@ export async function start_sandbox( // name container after image, plus random suffix to avoid conflicts const imageName = parseImageName(image); const isIntegrationTest = - process.env['GEMINI_CLI_INTEGRATION_TEST'] === 'true'; + process.env['QWEN_CODE_INTEGRATION_TEST'] === 'true'; let containerName; if (isIntegrationTest) { containerName = `qwen-code-integration-test-${randomBytes(4).toString( @@ -721,11 +721,10 @@ export async function start_sandbox( // tests that need to access host's ~/.qwen (e.g., --resume functionality) const useCurrentUser = await shouldUseCurrentUserInSandbox(); - if (!useCurrentUser) { - // Use root user (default for integration tests or when explicitly disabled) - args.push('--user', 'root'); - userFlag = '--user root'; - } else { + if (useCurrentUser) { + // SANDBOX_SET_UID_GID is enabled: create user with host's UID/GID + // This includes integration test mode with SANDBOX_SET_UID_GID=true, + // allowing tests that need to access host's ~/.qwen (e.g., --resume) to work. // For the user-creation logic to work, the container must start as root. // The entrypoint script then handles dropping privileges to the correct user. args.push('--user', 'root'); @@ -735,10 +734,10 @@ export async function start_sandbox( // Instead of passing --user to the main sandbox container, we let it // start as root, then create a user with the host's UID/GID, and - // finally switch to that user to run the gemini process. This is + // finally switch to that user to run the qwen process. This is // necessary on Linux to ensure the user exists within the // container's /etc/passwd file, which is required by os.userInfo(). - const username = 'gemini'; + const username = 'qwen'; const homeDir = getContainerPath(os.homedir()); const setupUserCommands = [ @@ -761,7 +760,12 @@ export async function start_sandbox( userFlag = `--user ${uid}:${gid}`; // When forcing a UID in the sandbox, $HOME can be reset to '/', so we copy $HOME as well. args.push('--env', `HOME=${os.homedir()}`); + } else if (isIntegrationTest) { + // Integration test mode with UID/GID matching disabled: use root + args.push('--user', 'root'); + userFlag = '--user root'; } + // else: non-IT mode with UID/GID matching disabled - use image default user (node) // push container image name args.push(image); diff --git a/packages/cli/src/utils/settingsUtils.ts b/packages/cli/src/utils/settingsUtils.ts index fe3b6df8d..1bd5988eb 100644 --- a/packages/cli/src/utils/settingsUtils.ts +++ b/packages/cli/src/utils/settingsUtils.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import * as fs from 'node:fs'; import type { Settings, SettingScope, @@ -577,4 +578,25 @@ export function getEffectiveDisplayValue( return getSettingValue(key, settings, mergedSettings); } +/** + * Backup a settings file before modification. + * Creates a backup with `.orig` suffix if the file exists and backup doesn't already exist. + * @param filePath - Path to the settings file to backup + * @returns boolean indicating whether a backup was created + */ +export function backupSettingsFile(filePath: string): boolean { + try { + if (fs.existsSync(filePath)) { + const backupPath = `${filePath}.orig`; + if (!fs.existsSync(backupPath)) { + fs.renameSync(filePath, backupPath); + return true; + } + } + } catch (_e) { + // Ignore backup errors, proceed without backup + } + return false; +} + export const TEST_ONLY = { clearFlattenedSchema }; diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 4d2af04b3..2be01125f 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -370,9 +370,9 @@ describe('Server Config (config.ts)', () => { // Spy after initial refresh to ensure model switch does not re-trigger refreshAuth. const refreshSpy = vi.spyOn(config, 'refreshAuth'); - await config.switchModel(AuthType.QWEN_OAUTH, 'vision-model'); + await config.switchModel(AuthType.QWEN_OAUTH, 'coder-model'); - expect(config.getModel()).toBe('vision-model'); + expect(config.getModel()).toBe('coder-model'); expect(refreshSpy).not.toHaveBeenCalled(); // Called once during initial refreshAuth + once during handleModelChange diffing. expect( diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index e1598a641..98b72c9c2 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -364,7 +364,6 @@ export interface ConfigParameters { skipNextSpeakerCheck?: boolean; shellExecutionConfig?: ShellExecutionConfig; skipLoopDetection?: boolean; - vlmSwitchMode?: string; truncateToolOutputThreshold?: number; truncateToolOutputLines?: number; enableToolOutputTruncation?: boolean; @@ -378,6 +377,8 @@ export interface ConfigParameters { channel?: string; /** Model providers configuration grouped by authType */ modelProvidersConfig?: ModelProvidersConfig; + /** Warnings generated during configuration resolution */ + warnings?: string[]; } function normalizeConfigOutputFormat( @@ -508,7 +509,7 @@ export class Config { private shellExecutionConfig: ShellExecutionConfig; private readonly skipLoopDetection: boolean; private readonly skipStartupContext: boolean; - private readonly vlmSwitchMode: string | undefined; + private readonly warnings: string[]; private initialized: boolean = false; readonly storage: Storage; private readonly fileExclusions: FileExclusions; @@ -610,6 +611,7 @@ export class Config { this.trustedFolder = params.trustedFolder; this.skipLoopDetection = params.skipLoopDetection ?? false; this.skipStartupContext = params.skipStartupContext ?? false; + this.warnings = params.warnings ?? []; // Web search this.webSearch = params.webSearch; @@ -632,7 +634,6 @@ export class Config { this.channel = params.channel; this.defaultFileEncoding = params.defaultFileEncoding ?? FileEncoding.UTF8; this.storage = new Storage(this.targetDir); - this.vlmSwitchMode = params.vlmSwitchMode; this.inputFormat = params.inputFormat ?? InputFormat.TEXT; this.fileExclusions = new FileExclusions(this); this.eventEmitter = params.eventEmitter; @@ -842,6 +843,15 @@ export class Config { return this.sessionId; } + /** + * Returns warnings generated during configuration resolution. + * These warnings are collected from model configuration resolution + * and should be displayed to the user during startup. + */ + getWarnings(): string[] { + return this.warnings; + } + getDebugLogger(): DebugLogger { return this.debugLogger; } @@ -1562,10 +1572,6 @@ export class Config { return this.skipStartupContext; } - getVlmSwitchMode(): string | undefined { - return this.vlmSwitchMode; - } - getEnableToolOutputTruncation(): boolean { return this.enableToolOutputTruncation; } diff --git a/packages/core/src/config/models.ts b/packages/core/src/config/models.ts index a07dec7ce..a507b7fa7 100644 --- a/packages/core/src/config/models.ts +++ b/packages/core/src/config/models.ts @@ -7,3 +7,4 @@ export const DEFAULT_QWEN_MODEL = 'coder-model'; export const DEFAULT_QWEN_FLASH_MODEL = 'coder-model'; export const DEFAULT_QWEN_EMBEDDING_MODEL = 'text-embedding-v4'; +export const MAINLINE_CODER_MODEL = 'qwen3.5-plus'; diff --git a/packages/core/src/core/contentGenerator.test.ts b/packages/core/src/core/contentGenerator.test.ts index eef7f5ac8..bb8e5f741 100644 --- a/packages/core/src/core/contentGenerator.test.ts +++ b/packages/core/src/core/contentGenerator.test.ts @@ -90,11 +90,11 @@ describe('createContentGeneratorConfig', () => { it('should preserve provided fields and set authType for QWEN_OAUTH', () => { const cfg = createContentGeneratorConfig(mockConfig, AuthType.QWEN_OAUTH, { - model: 'vision-model', + model: 'coder-model', apiKey: 'QWEN_OAUTH_DYNAMIC_TOKEN', }); expect(cfg.authType).toBe(AuthType.QWEN_OAUTH); - expect(cfg.model).toBe('vision-model'); + expect(cfg.model).toBe('coder-model'); expect(cfg.apiKey).toBe('QWEN_OAUTH_DYNAMIC_TOKEN'); }); diff --git a/packages/core/src/core/modalityDefaults.test.ts b/packages/core/src/core/modalityDefaults.test.ts index 8aae4be76..b90bc069e 100644 --- a/packages/core/src/core/modalityDefaults.test.ts +++ b/packages/core/src/core/modalityDefaults.test.ts @@ -120,12 +120,6 @@ describe('defaultModalities', () => { expect(m.video).toBe(true); }); - it('returns image + video for vision-model', () => { - const m = defaultModalities('vision-model'); - expect(m.image).toBe(true); - expect(m.video).toBe(true); - }); - it('returns text-only for qwen3-coder-plus', () => { expect(defaultModalities('qwen3-coder-plus')).toEqual({}); }); diff --git a/packages/core/src/core/modalityDefaults.ts b/packages/core/src/core/modalityDefaults.ts index 790499dfe..f17927325 100644 --- a/packages/core/src/core/modalityDefaults.ts +++ b/packages/core/src/core/modalityDefaults.ts @@ -47,7 +47,6 @@ const MODALITY_PATTERNS: Array<[RegExp, InputModalities]> = [ // Qwen VL (vision-language) models: image + video [/^qwen-vl-/, { image: true, video: true }], [/^qwen3-vl-/, { image: true, video: true }], - [/^vision-model$/, { image: true, video: true }], // Qwen coder / text models: text-only [/^qwen3-coder-/, {}], diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts index 006cf1abd..2e528120a 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts @@ -733,7 +733,7 @@ describe('DashScopeOpenAICompatibleProvider', () => { describe('output token limits', () => { it('should limit max_tokens when it exceeds model limit', () => { const request: OpenAI.Chat.ChatCompletionCreateParams = { - model: 'qwen3-coder-plus', + model: 'qwen3-max', messages: [{ role: 'user', content: 'Hello' }], max_tokens: 100000, // Exceeds the model's output limit }; @@ -757,7 +757,7 @@ describe('DashScopeOpenAICompatibleProvider', () => { it('should not modify max_tokens when it is within model limit', () => { const request: OpenAI.Chat.ChatCompletionCreateParams = { - model: 'qwen3-coder-plus', + model: 'qwen3-max', messages: [{ role: 'user', content: 'Hello' }], max_tokens: 1000, // Within the model's output limit }; @@ -769,7 +769,7 @@ describe('DashScopeOpenAICompatibleProvider', () => { it('should not add max_tokens when not present in request', () => { const request: OpenAI.Chat.ChatCompletionCreateParams = { - model: 'qwen3-coder-plus', + model: 'qwen3-max', messages: [{ role: 'user', content: 'Hello' }], // No max_tokens parameter }; @@ -781,7 +781,7 @@ describe('DashScopeOpenAICompatibleProvider', () => { it('should handle null max_tokens parameter', () => { const request: OpenAI.Chat.ChatCompletionCreateParams = { - model: 'qwen3-coder-plus', + model: 'qwen3-max', messages: [{ role: 'user', content: 'Hello' }], max_tokens: null, }; @@ -805,7 +805,7 @@ describe('DashScopeOpenAICompatibleProvider', () => { it('should preserve other request parameters when limiting max_tokens', () => { const request: OpenAI.Chat.ChatCompletionCreateParams = { - model: 'qwen3-coder-plus', + model: 'qwen3-max', messages: [{ role: 'user', content: 'Hello' }], max_tokens: 100000, // Will be limited temperature: 0.8, @@ -872,21 +872,19 @@ describe('DashScopeOpenAICompatibleProvider', () => { ], }, ], - max_tokens: 50000, // Exceeds the 32768 limit }; const result = provider.buildRequest(request, 'test-prompt-id'); - expect(result.max_tokens).toBe(32768); // Limited to model's output limit (32K) expect( (result as { vl_high_resolution_images?: boolean }) .vl_high_resolution_images, ).toBe(true); }); - it('should set high resolution flag for the vision-model alias', () => { + it('should set high resolution flag for the coder-model model', () => { const request: OpenAI.Chat.ChatCompletionCreateParams = { - model: 'vision-model', + model: 'coder-model', messages: [ { role: 'user', @@ -899,12 +897,12 @@ describe('DashScopeOpenAICompatibleProvider', () => { ], }, ], - max_tokens: 50000, // Exceeds the 32768 limit + max_tokens: 100000, // Exceeds the 64K limit }; const result = provider.buildRequest(request, 'test-prompt-id'); - expect(result.max_tokens).toBe(32768); // Limited to model's output limit (32K) + expect(result.max_tokens).toBe(65536); // Limited to model's output limit (64K) expect( (result as { vl_high_resolution_images?: boolean }) .vl_high_resolution_images, @@ -913,7 +911,7 @@ describe('DashScopeOpenAICompatibleProvider', () => { it('should handle streaming requests with output token limits', () => { const request: OpenAI.Chat.ChatCompletionCreateParams = { - model: 'qwen3-coder-plus', + model: 'qwen3-max', messages: [{ role: 'user', content: 'Hello' }], max_tokens: 100000, // Exceeds the model's output limit stream: true, diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts index ccf201e24..c2134914a 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts @@ -279,6 +279,18 @@ export class DashScopeOpenAICompatibleProvider return contentArray; } + /** + * Vision-capable model patterns. + * Supports exact matches and prefix patterns for easy extension. + */ + private static readonly VISION_MODEL_EXACT_MATCHES = new Set(['coder-model']); + + private static readonly VISION_MODEL_PREFIX_PATTERNS = [ + 'qwen-vl', // qwen-vl-max, qwen-vl-max-latest, etc. + 'qwen3-vl-plus', // qwen3-vl-plus variants + 'qwen3.5-plus', // qwen3.5-plus (has built-in vision capabilities) + ]; + private isVisionModel(model: string | undefined): boolean { if (!model) { return false; @@ -286,16 +298,20 @@ export class DashScopeOpenAICompatibleProvider const normalized = model.toLowerCase(); - if (normalized === 'vision-model') { + // Check exact matches + if ( + DashScopeOpenAICompatibleProvider.VISION_MODEL_EXACT_MATCHES.has( + normalized, + ) + ) { return true; } - if (normalized.startsWith('qwen-vl')) { - return true; - } - - if (normalized.startsWith('qwen3-vl-plus')) { - return true; + // Check prefix patterns + for (const prefix of DashScopeOpenAICompatibleProvider.VISION_MODEL_PREFIX_PATTERNS) { + if (normalized.startsWith(prefix)) { + return true; + } } return false; diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts index 1081aac01..5e13cf208 100644 --- a/packages/core/src/core/prompts.ts +++ b/packages/core/src/core/prompts.ts @@ -801,10 +801,6 @@ function getToolCallExamples(model?: string): string { if (/coder-model/i.test(model)) { return qwenCoderToolCallExamples; } - // Match vision-model pattern (same as qwen3-vl) - if (/vision-model/i.test(model)) { - return qwenVlToolCallExamples; - } } return generalToolCallExamples; diff --git a/packages/core/src/core/tokenLimits.test.ts b/packages/core/src/core/tokenLimits.test.ts index 8aa947262..edea10a10 100644 --- a/packages/core/src/core/tokenLimits.test.ts +++ b/packages/core/src/core/tokenLimits.test.ts @@ -167,7 +167,6 @@ describe('tokenLimit', () => { expect(tokenLimit('qwen-turbo')).toBe(262144); expect(tokenLimit('qwen2.5')).toBe(262144); expect(tokenLimit('qwen-vl-max-latest')).toBe(262144); - expect(tokenLimit('vision-model')).toBe(262144); }); }); @@ -270,14 +269,15 @@ describe('tokenLimit with output type', () => { describe('Qwen output limits', () => { it('should return correct output limits for Qwen models', () => { - expect(tokenLimit('qwen3-coder-plus', 'output')).toBe(65536); - expect(tokenLimit('qwen3-coder-next', 'output')).toBe(65536); expect(tokenLimit('qwen3.5-plus', 'output')).toBe(65536); expect(tokenLimit('qwen3-max', 'output')).toBe(65536); expect(tokenLimit('qwen3-max-2026-01-23', 'output')).toBe(65536); - expect(tokenLimit('qwen3-vl-plus', 'output')).toBe(32768); + expect(tokenLimit('coder-model', 'output')).toBe(65536); + // Models without specific output limits fall back to default + expect(tokenLimit('qwen3-coder-plus', 'output')).toBe(8192); + expect(tokenLimit('qwen3-coder-next', 'output')).toBe(8192); + expect(tokenLimit('qwen3-vl-plus', 'output')).toBe(8192); expect(tokenLimit('qwen-vl-max-latest', 'output')).toBe(8192); - expect(tokenLimit('vision-model', 'output')).toBe(32768); }); }); @@ -311,8 +311,8 @@ describe('tokenLimit with output type', () => { describe('input vs output comparison', () => { it('should return different limits for input vs output', () => { - expect(tokenLimit('qwen3-coder-plus', 'input')).toBe(1000000); - expect(tokenLimit('qwen3-coder-plus', 'output')).toBe(65536); + expect(tokenLimit('qwen3-max', 'input')).toBe(262144); + expect(tokenLimit('qwen3-max', 'output')).toBe(65536); }); it('should default to input type when no type is specified', () => { @@ -323,8 +323,8 @@ describe('tokenLimit with output type', () => { describe('normalization with output limits', () => { it('should handle normalized model names for output limits', () => { - expect(tokenLimit('QWEN3-CODER-PLUS', 'output')).toBe(65536); - expect(tokenLimit('qwen3-coder-plus-20250601', 'output')).toBe(65536); + expect(tokenLimit('QWEN3-MAX', 'output')).toBe(65536); + expect(tokenLimit('qwen3-max-20250601', 'output')).toBe(65536); expect(tokenLimit('QWEN-VL-MAX-LATEST', 'output')).toBe(8192); }); }); diff --git a/packages/core/src/core/tokenLimits.ts b/packages/core/src/core/tokenLimits.ts index 7d18497b7..d038133cb 100644 --- a/packages/core/src/core/tokenLimits.ts +++ b/packages/core/src/core/tokenLimits.ts @@ -103,16 +103,13 @@ const PATTERNS: Array<[RegExp, TokenCount]> = [ [/^qwen3-coder-plus/, LIMITS['1m']], [/^qwen3-coder-flash/, LIMITS['1m']], [/^qwen3\.5-plus/, LIMITS['1m']], + [/^qwen-plus-latest$/, LIMITS['1m']], + [/^qwen-flash-latest$/, LIMITS['1m']], [/^coder-model$/, LIMITS['1m']], // Commercial API models (256K context) [/^qwen3-max/, LIMITS['256k']], - [/^qwen3-vl-plus$/, LIMITS['256k']], - [/^vision-model$/, LIMITS['256k']], // Open-source Qwen3 variants: 256K native [/^qwen3-coder-/, LIMITS['256k']], - // Studio commercial Qwen-Plus / Qwen-Flash - [/^qwen-plus-latest$/, LIMITS['1m']], - [/^qwen-flash-latest$/, LIMITS['1m']], // Qwen fallback (VL, turbo, plus, 2.5, etc.): 128K [/^qwen/, LIMITS['256k']], @@ -139,7 +136,7 @@ const PATTERNS: Array<[RegExp, TokenCount]> = [ [/^kimi-/, LIMITS['256k']], // Kimi fallback: 256K // ------------------- - // Other + // ByteDance Seed-OSS (512K) // ------------------- [/^seed-oss/, LIMITS['512k']], ]; @@ -167,9 +164,7 @@ const OUTPUT_PATTERNS: Array<[RegExp, TokenCount]> = [ // Alibaba / Qwen [/^qwen3\.5/, LIMITS['64k']], [/^coder-model$/, LIMITS['64k']], - [/^qwen3-vl-plus$/, LIMITS['32k']], - [/^vision-model$/, LIMITS['32k']], - [/^qwen3-/, LIMITS['64k']], + [/^qwen3-max/, LIMITS['64k']], // DeepSeek [/^deepseek-reasoner/, LIMITS['64k']], diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dc267b031..2800e20f6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -18,6 +18,7 @@ export { DEFAULT_QWEN_MODEL, DEFAULT_QWEN_FLASH_MODEL, DEFAULT_QWEN_EMBEDDING_MODEL, + MAINLINE_CODER_MODEL, } from './config/models.js'; export { type AvailableModel, diff --git a/packages/core/src/lsp/LspServerManager.ts b/packages/core/src/lsp/LspServerManager.ts index ce5898062..d38b23851 100644 --- a/packages/core/src/lsp/LspServerManager.ts +++ b/packages/core/src/lsp/LspServerManager.ts @@ -523,7 +523,7 @@ export class LspServerManager { codeAction: { dynamicRegistration: true }, }, workspace: { - workspaceFolders: { supported: true }, + workspaceFolders: true, }, }, initializationOptions: config.initializationOptions, diff --git a/packages/core/src/models/constants.ts b/packages/core/src/models/constants.ts index 4ed57ae42..4551a2f43 100644 --- a/packages/core/src/models/constants.ts +++ b/packages/core/src/models/constants.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { DEFAULT_QWEN_MODEL } from '../config/models.js'; +import { DEFAULT_QWEN_MODEL, MAINLINE_CODER_MODEL } from '../config/models.js'; import type { ModelConfig } from './types.js'; @@ -89,15 +89,10 @@ export const AUTH_ENV_MAPPINGS = { } as const satisfies Record; export const DEFAULT_MODELS = { - openai: 'qwen3-coder-plus', + openai: MAINLINE_CODER_MODEL, 'qwen-oauth': DEFAULT_QWEN_MODEL, } as Partial>; -export const QWEN_OAUTH_ALLOWED_MODELS = [ - DEFAULT_QWEN_MODEL, - 'vision-model', -] as const; - /** * Hard-coded Qwen OAuth models that are always available. * These cannot be overridden by user configuration. @@ -110,10 +105,12 @@ export const QWEN_OAUTH_MODELS: ModelConfig[] = [ 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance', capabilities: { vision: true }, }, - { - id: 'vision-model', - name: 'vision-model', - description: 'The latest Qwen Vision model from Alibaba Cloud ModelStudio', - capabilities: { vision: true }, - }, ]; + +/** + * Derive allowed models from QWEN_OAUTH_MODELS for authorization. + * This ensures single source of truth (SSOT). + */ +export const QWEN_OAUTH_ALLOWED_MODELS = QWEN_OAUTH_MODELS.map( + (model) => model.id, +) as readonly string[]; diff --git a/packages/core/src/models/modelConfigResolver.test.ts b/packages/core/src/models/modelConfigResolver.test.ts index a69ca678e..978949b2c 100644 --- a/packages/core/src/models/modelConfigResolver.test.ts +++ b/packages/core/src/models/modelConfigResolver.test.ts @@ -10,7 +10,7 @@ import { validateModelConfig, } from './modelConfigResolver.js'; import { AuthType } from '../core/contentGenerator.js'; -import { DEFAULT_QWEN_MODEL } from '../config/models.js'; +import { DEFAULT_QWEN_MODEL, MAINLINE_CODER_MODEL } from '../config/models.js'; describe('modelConfigResolver', () => { describe('resolveModelConfig', () => { @@ -95,7 +95,7 @@ describe('modelConfigResolver', () => { }, }); - expect(result.config.model).toBe('qwen3-coder-plus'); + expect(result.config.model).toBe(MAINLINE_CODER_MODEL); expect(result.sources['model'].kind).toBe('default'); }); @@ -157,17 +157,17 @@ describe('modelConfigResolver', () => { expect(result.sources['apiKey'].kind).toBe('computed'); }); - it('allows vision-model for Qwen OAuth', () => { + it('allows coder-model for Qwen OAuth', () => { const result = resolveModelConfig({ authType: AuthType.QWEN_OAUTH, cli: { - model: 'vision-model', + model: 'coder-model', }, settings: {}, env: {}, }); - expect(result.config.model).toBe('vision-model'); + expect(result.config.model).toBe('coder-model'); expect(result.sources['model'].kind).toBe('cli'); }); diff --git a/packages/core/src/models/modelConfigResolver.ts b/packages/core/src/models/modelConfigResolver.ts index 1afad58eb..c7db1611c 100644 --- a/packages/core/src/models/modelConfigResolver.ts +++ b/packages/core/src/models/modelConfigResolver.ts @@ -295,8 +295,13 @@ function resolveQwenOAuthConfig( : settingsSource('model.name'); } else { if (requestedModel) { + const isVisionModel = + requestedModel.includes('vl') || requestedModel.includes('vision'); + const extraMessage = isVisionModel + ? ` Note: vision-model has been removed since coder-model now supports vision capabilities.` + : ''; warnings.push( - `Unsupported Qwen OAuth model '${requestedModel}', falling back to '${DEFAULT_QWEN_MODEL}'.`, + `Warning: Unsupported Qwen OAuth model '${requestedModel}', falling back to '${DEFAULT_QWEN_MODEL}'.${extraMessage}`, ); } resolvedModel = DEFAULT_QWEN_MODEL; diff --git a/packages/core/src/models/modelRegistry.test.ts b/packages/core/src/models/modelRegistry.test.ts index 01ccc8207..9005dd52a 100644 --- a/packages/core/src/models/modelRegistry.test.ts +++ b/packages/core/src/models/modelRegistry.test.ts @@ -17,7 +17,6 @@ describe('ModelRegistry', () => { const qwenModels = registry.getModelsForAuthType(AuthType.QWEN_OAUTH); expect(qwenModels.length).toBe(QWEN_OAUTH_MODELS.length); expect(qwenModels[0].id).toBe('coder-model'); - expect(qwenModels[1].id).toBe('vision-model'); }); it('should initialize with empty config', () => { diff --git a/packages/core/src/models/modelsConfig.test.ts b/packages/core/src/models/modelsConfig.test.ts index 03a724829..25268aebe 100644 --- a/packages/core/src/models/modelsConfig.test.ts +++ b/packages/core/src/models/modelsConfig.test.ts @@ -454,7 +454,7 @@ describe('ModelsConfig', () => { }); // Switching within qwen-oauth triggers applyResolvedModelDefaults(). - await modelsConfig.switchModel(AuthType.QWEN_OAUTH, 'vision-model'); + await modelsConfig.switchModel(AuthType.QWEN_OAUTH, 'coder-model'); const gc = currentGenerationConfig(modelsConfig); expect(gc.apiKey).toBe('QWEN_OAUTH_DYNAMIC_TOKEN'); diff --git a/packages/core/src/models/modelsConfig.ts b/packages/core/src/models/modelsConfig.ts index 3b53c868c..d22cc790c 100644 --- a/packages/core/src/models/modelsConfig.ts +++ b/packages/core/src/models/modelsConfig.ts @@ -309,10 +309,11 @@ export class ModelsConfig { newModel: string, metadata?: ModelSwitchMetadata, ): Promise { - // Special case: qwen-oauth VLM auto-switch - hot update in place + // Special case: qwen-oauth model switch - hot update in place + // coder-model supports vision capabilities and can be hot-updated if ( this.currentAuthType === AuthType.QWEN_OAUTH && - (newModel === DEFAULT_QWEN_MODEL || newModel === 'vision-model') + newModel === DEFAULT_QWEN_MODEL ) { this.strictModelProviderSelection = false; this._generationConfig.model = newModel; @@ -792,7 +793,7 @@ export class ModelsConfig { * - We're checking if switching between two models within the SAME authType needs refresh * * Examples: - * - Qwen OAuth: coder-model -> vision-model (same authType, hot-update safe) + * - Qwen OAuth: coder-model switches (same authType, hot-update safe) * - OpenAI: model-a -> model-b with same envKey (same authType, hot-update safe) * - OpenAI: gpt-4 -> deepseek-chat with different envKey (same authType, needs refresh) * @@ -809,7 +810,7 @@ export class ModelsConfig { } // For Qwen OAuth, model switches within the same authType can always be hot-updated - // (coder-model <-> vision-model don't require ContentGenerator recreation) + // (coder-model supports vision capabilities and doesn't require ContentGenerator recreation) if (authType === AuthType.QWEN_OAUTH) { return false; }