diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index a45884e04..711bf3e8e 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -116,6 +116,29 @@ const SETTINGS_SCHEMA = { mergeStrategy: MergeStrategy.REPLACE, }, + // Coding Plan configuration + codingPlan: { + type: 'object', + label: 'Coding Plan', + category: 'Model', + requiresRestart: false, + default: {}, + description: 'Coding Plan template version tracking and configuration.', + showInDialog: false, + properties: { + version: { + type: 'string', + label: 'Coding Plan Template Version', + category: 'Model', + requiresRestart: false, + default: undefined as string | undefined, + description: + 'SHA256 hash of the Coding Plan template. Used to detect template updates.', + showInDialog: false, + }, + }, + }, + // Environment variables fallback env: { type: 'object', diff --git a/packages/cli/src/constants/codingPlanTemplates.ts b/packages/cli/src/constants/codingPlan.ts similarity index 56% rename from packages/cli/src/constants/codingPlanTemplates.ts rename to packages/cli/src/constants/codingPlan.ts index 8cedab91f..e55aeb93d 100644 --- a/packages/cli/src/constants/codingPlanTemplates.ts +++ b/packages/cli/src/constants/codingPlan.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { createHash } from 'node:crypto'; import type { ProviderModelConfig as ModelConfig } from '@qwen-code/qwen-code-core'; /** @@ -18,9 +19,9 @@ export type CodingPlanTemplate = ModelConfig[]; export const CODING_PLAN_ENV_KEY = 'BAILIAN_CODING_PLAN_API_KEY'; /** - * CODING_PLAN_TEMPLATE defines the model configurations for coding-plan mode. + * CODING_PLAN_MODELS defines the model configurations for coding-plan mode. */ -export const CODING_PLAN_TEMPLATE: CodingPlanTemplate = [ +export const CODING_PLAN_MODELS: CodingPlanTemplate = [ { id: 'qwen3-coder-plus', name: 'qwen3-coder-plus', @@ -32,7 +33,7 @@ export const CODING_PLAN_TEMPLATE: CodingPlanTemplate = [ id: 'qwen3-max-2026-01-23', name: 'qwen3-max-2026-01-23', description: - 'qwen3 max model from Bailian Coding Plan with thinking enabled', + 'qwen3-max model with thinking enabled from Bailian Coding Plan', baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', envKey: CODING_PLAN_ENV_KEY, generationConfig: { @@ -42,3 +43,19 @@ export const CODING_PLAN_TEMPLATE: CodingPlanTemplate = [ }, }, ]; + +/** + * Computes the version hash for the coding plan template. + * Uses SHA256 of the JSON-serialized template for deterministic versioning. + * @returns Hexadecimal string representing the template version + */ +export function computeCodingPlanVersion(): string { + const templateString = JSON.stringify(CODING_PLAN_MODELS); + return createHash('sha256').update(templateString).digest('hex'); +} + +/** + * Current version of the coding plan template. + * Computed at runtime from the template content. + */ +export const CODING_PLAN_VERSION = computeCodingPlanVersion(); diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index ce2594d41..063af8715 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1395,6 +1395,18 @@ export default { 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.': 'Unterstützte Authentifizierungstypen: openai, anthropic, gemini, vertex-ai, usw.', + // ============================================================================ + // Coding Plan Authentication + // ============================================================================ + 'New model configurations are available for Bailian Coding Plan. Update now?': + 'Neue Modellkonfigurationen sind für Bailian Coding Plan verfügbar. Jetzt aktualisieren?', + 'Coding Plan configuration updated successfully. New models are now available.': + 'Coding Plan-Konfiguration erfolgreich aktualisiert. Neue Modelle sind jetzt verfügbar.', + 'Coding Plan API key not found. Please re-authenticate with Coding Plan.': + 'Coding Plan API-Schlüssel nicht gefunden. Bitte authentifizieren Sie sich erneut mit Coding Plan.', + 'Failed to update Coding Plan configuration: {{message}}': + 'Fehler beim Aktualisieren der Coding Plan-Konfiguration: {{message}}', + // ============================================================================ // Auth Dialog - View Titles and Labels // ============================================================================ diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index bfb70e6cb..ab03a9512 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1375,6 +1375,14 @@ export default { 'API key cannot be empty.': 'API key cannot be empty.', 'API key is stored in settings.env. You can migrate it to a .env file for better security.': 'API key is stored in settings.env. You can migrate it to a .env file for better security.', + 'New model configurations are available for Bailian Coding Plan. Update now?': + 'New model configurations are available for Bailian Coding Plan. Update now?', + 'Coding Plan configuration updated successfully. New models are now available.': + 'Coding Plan configuration updated successfully. New models are now available.', + 'Coding Plan API key not found. Please re-authenticate with Coding Plan.': + 'Coding Plan API key not found. Please re-authenticate with Coding Plan.', + 'Failed to update Coding Plan configuration: {{message}}': + 'Failed to update Coding Plan configuration: {{message}}', // ============================================================================ // Custom API-KEY Configuration diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 764bf7313..348aabc88 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -906,6 +906,18 @@ export default { 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.': 'サポートされている認証タイプ:openai、anthropic、gemini、vertex-ai など', + // ============================================================================ + // Coding Plan Authentication + // ============================================================================ + 'New model configurations are available for Bailian Coding Plan. Update now?': + 'Bailian Coding Plan の新しいモデル設定が利用可能です。今すぐ更新しますか?', + 'Coding Plan configuration updated successfully. New models are now available.': + 'Coding Plan の設定が正常に更新されました。新しいモデルが利用可能になりました。', + 'Coding Plan API key not found. Please re-authenticate with Coding Plan.': + 'Coding Plan の API キーが見つかりません。Coding Plan で再認証してください。', + 'Failed to update Coding Plan configuration: {{message}}': + 'Coding Plan の設定更新に失敗しました: {{message}}', + // ============================================================================ // Auth Dialog - View Titles and Labels // ============================================================================ diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index b6ad6d5fd..fb8aff23d 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1409,6 +1409,18 @@ export default { 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.': 'Tipos de autenticação suportados: openai, anthropic, gemini, vertex-ai, etc.', + // ============================================================================ + // Coding Plan Authentication + // ============================================================================ + 'New model configurations are available for Bailian Coding Plan. Update now?': + 'Novas configurações de modelo estão disponíveis para o Bailian Coding Plan. Atualizar agora?', + 'Coding Plan configuration updated successfully. New models are now available.': + 'Configuração do Coding Plan atualizada com sucesso. Novos modelos agora estão disponíveis.', + 'Coding Plan API key not found. Please re-authenticate with Coding Plan.': + 'Chave de API do Coding Plan não encontrada. Por favor, re-autentique com o Coding Plan.', + 'Failed to update Coding Plan configuration: {{message}}': + 'Falha ao atualizar a configuração do Coding Plan: {{message}}', + // ============================================================================ // Auth Dialog - View Titles and Labels // ============================================================================ diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index ab5a9fa89..70c428197 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1399,6 +1399,18 @@ export default { 'Supported auth types: openai, anthropic, gemini, vertex-ai, etc.': 'Поддерживаемые типы аутентификации: openai, anthropic, gemini, vertex-ai и др.', + // ============================================================================ + // Coding Plan Authentication + // ============================================================================ + 'New model configurations are available for Bailian Coding Plan. Update now?': + 'Доступны новые конфигурации моделей для Bailian Coding Plan. Обновить сейчас?', + 'Coding Plan configuration updated successfully. New models are now available.': + 'Конфигурация Coding Plan успешно обновлена. Новые модели теперь доступны.', + 'Coding Plan API key not found. Please re-authenticate with Coding Plan.': + 'API-ключ Coding Plan не найден. Пожалуйста, повторно авторизуйтесь с Coding Plan.', + 'Failed to update Coding Plan configuration: {{message}}': + 'Не удалось обновить конфигурацию Coding Plan: {{message}}', + // ============================================================================ // Auth Dialog - View Titles and Labels // ============================================================================ diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 7399a08f2..cb7b7a7f6 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1210,6 +1210,14 @@ export default { 'API key cannot be empty.': 'API Key 不能为空。', 'API key is stored in settings.env. You can migrate it to a .env file for better security.': 'API Key 已存储在 settings.env 中。您可以将其迁移到 .env 文件以获得更好的安全性。', + 'New model configurations are available for Bailian Coding Plan. Update now?': + '百炼 Coding Plan 有新模型配置可用。是否立即更新?', + 'Coding Plan configuration updated successfully. New models are now available.': + 'Coding Plan 配置更新成功。新模型现已可用。', + 'Coding Plan API key not found. Please re-authenticate with Coding Plan.': + '未找到 Coding Plan API Key。请重新通过 Coding Plan 认证。', + 'Failed to update Coding Plan configuration: {{message}}': + '更新 Coding Plan 配置失败:{{message}}', // ============================================================================ // Custom API-KEY Configuration diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 7ac34def2..9c546004c 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -94,6 +94,7 @@ import { useSettingInputRequests, usePluginChoiceRequests, } from './hooks/useExtensionUpdates.js'; +import { useCodingPlanUpdates } from './hooks/useCodingPlanUpdates.js'; import { ShellFocusContext } from './contexts/ShellFocusContext.js'; import { t } from '../i18n/index.js'; import { useWelcomeBack } from './hooks/useWelcomeBack.js'; @@ -232,6 +233,9 @@ export const AppContainer = (props: AppContainerProps) => { config.getWorkingDir(), ); + const { codingPlanUpdateRequest, dismissCodingPlanUpdate } = + useCodingPlanUpdates(settings, config, historyManager.addItem); + const [isPermissionsDialogOpen, setPermissionsDialogOpen] = useState(false); const openPermissionsDialog = useCallback( () => setPermissionsDialogOpen(true), @@ -1276,6 +1280,7 @@ export const AppContainer = (props: AppContainerProps) => { !!shellConfirmationRequest || !!confirmationRequest || confirmUpdateExtensionRequests.length > 0 || + !!codingPlanUpdateRequest || settingInputRequests.length > 0 || pluginChoiceRequests.length > 0 || !!loopDetectionConfirmationRequest || @@ -1340,6 +1345,7 @@ export const AppContainer = (props: AppContainerProps) => { shellConfirmationRequest, confirmationRequest, confirmUpdateExtensionRequests, + codingPlanUpdateRequest, settingInputRequests, pluginChoiceRequests, loopDetectionConfirmationRequest, @@ -1430,6 +1436,7 @@ export const AppContainer = (props: AppContainerProps) => { shellConfirmationRequest, confirmationRequest, confirmUpdateExtensionRequests, + codingPlanUpdateRequest, settingInputRequests, pluginChoiceRequests, loopDetectionConfirmationRequest, @@ -1514,6 +1521,7 @@ export const AppContainer = (props: AppContainerProps) => { exitEditorDialog, closeSettingsDialog, closeModelDialog, + dismissCodingPlanUpdate, closePermissionsDialog, setShellModeActive, vimHandleInput, @@ -1559,6 +1567,7 @@ export const AppContainer = (props: AppContainerProps) => { exitEditorDialog, closeSettingsDialog, closeModelDialog, + dismissCodingPlanUpdate, closePermissionsDialog, setShellModeActive, vimHandleInput, diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index a327152c2..0ea157af5 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -30,9 +30,10 @@ import { AuthState, MessageType } from '../types.js'; import type { HistoryItem } from '../types.js'; import { t } from '../../i18n/index.js'; import { - CODING_PLAN_TEMPLATE, + CODING_PLAN_MODELS, CODING_PLAN_ENV_KEY, -} from '../../constants/codingPlanTemplates.js'; + CODING_PLAN_VERSION, +} from '../../constants/codingPlan.js'; export type { QwenAuthState } from '../hooks/useQwenAuth.js'; @@ -303,7 +304,7 @@ export const useAuthCommand = ( process.env[envKeyName] = apiKey; // Generate model configs from template - const newConfigs: ProviderModelConfig[] = CODING_PLAN_TEMPLATE.map( + const newConfigs: ProviderModelConfig[] = CODING_PLAN_MODELS.map( (templateConfig) => ({ ...templateConfig, envKey: envKeyName, @@ -316,22 +317,21 @@ export const useAuthCommand = ( settings.merged.modelProviders as ModelProvidersConfig | undefined )?.[AuthType.USE_OPENAI] || []; - // Deduplicate: check if config with same id, baseUrl, and envKey exists - const isDuplicate = (config: ProviderModelConfig) => - existingConfigs.some( - (existing) => - existing.id === config.id && - existing.baseUrl === config.baseUrl && - existing.envKey === config.envKey, + // Identify Coding Plan configs by baseUrl + envKey + // Remove existing Coding Plan configs to ensure template changes are applied + const isCodingPlanConfig = (config: ProviderModelConfig) => + config.envKey === envKeyName && + CODING_PLAN_MODELS.some( + (template) => template.baseUrl === config.baseUrl, ); - // Filter out duplicates and replace existing ones - const uniqueNewConfigs = newConfigs.filter( - (config) => !isDuplicate(config), + // Filter out existing Coding Plan configs, keep user custom configs + const nonCodingPlanConfigs = existingConfigs.filter( + (existing) => !isCodingPlanConfig(existing), ); - // Unshift new configs to the beginning - const updatedConfigs = [...uniqueNewConfigs, ...existingConfigs]; + // Add new Coding Plan configs at the beginning + const updatedConfigs = [...newConfigs, ...nonCodingPlanConfigs]; // Persist to modelProviders settings.setValue( @@ -347,6 +347,13 @@ export const useAuthCommand = ( AuthType.USE_OPENAI, ); + // Persist coding plan version for future update detection + settings.setValue( + persistScope, + 'codingPlan.version', + CODING_PLAN_VERSION, + ); + // If there are configs, use the first one as the model if (updatedConfigs.length > 0 && updatedConfigs[0]?.id) { settings.setValue(persistScope, 'model.name', updatedConfigs[0].id); diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index b73ab1287..dbb6f2207 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -122,6 +122,15 @@ export const DialogManager = ({ /> ); } + if (uiState.codingPlanUpdateRequest) { + return ( + + ); + } if (uiState.settingInputRequests.length > 0) { const request = uiState.settingInputRequests[0]; // Use settingName as key to force re-mount when switching between different settings diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index f93ee84eb..ed339e6fa 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -51,6 +51,7 @@ export interface UIActions { exitEditorDialog: () => void; closeSettingsDialog: () => void; closeModelDialog: () => void; + dismissCodingPlanUpdate: () => void; closePermissionsDialog: () => void; setShellModeActive: (value: boolean) => void; vimHandleInput: (key: Key) => boolean; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 6a48d3eca..f8d52faa1 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -32,6 +32,7 @@ import type { UpdateObject } from '../utils/updateCheck.js'; import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; import { type RestartReason } from '../hooks/useIdeTrustListener.js'; +import { type CodingPlanUpdateRequest } from '../hooks/useCodingPlanUpdates.js'; export interface UIState { history: HistoryItem[]; @@ -60,6 +61,7 @@ export interface UIState { shellConfirmationRequest: ShellConfirmationRequest | null; confirmationRequest: ConfirmationRequest | null; confirmUpdateExtensionRequests: ConfirmationRequest[]; + codingPlanUpdateRequest: CodingPlanUpdateRequest | undefined; settingInputRequests: SettingInputRequest[]; pluginChoiceRequests: PluginChoiceRequest[]; loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null; diff --git a/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts new file mode 100644 index 000000000..a004fbdcb --- /dev/null +++ b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts @@ -0,0 +1,288 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useCodingPlanUpdates } from './useCodingPlanUpdates.js'; +import { CODING_PLAN_ENV_KEY } from '../../constants/codingPlan.js'; +import { AuthType } from '@qwen-code/qwen-code-core'; + +// Mock the constants module +vi.mock('../../constants/codingPlan.js', async () => { + const actual = await vi.importActual('../../constants/codingPlan.js'); + return { + ...actual, + CODING_PLAN_VERSION: 'test-version-hash', + CODING_PLAN_MODELS: [ + { + id: 'test-model-1', + name: 'Test Model 1', + baseUrl: 'https://test.example.com/v1', + description: 'Test model 1', + envKey: 'BAILIAN_CODING_PLAN_API_KEY', + }, + { + id: 'test-model-2', + name: 'Test Model 2', + baseUrl: 'https://test.example.com/v1', + description: 'Test model 2', + envKey: 'BAILIAN_CODING_PLAN_API_KEY', + }, + ], + }; +}); + +describe('useCodingPlanUpdates', () => { + const mockSettings = { + merged: { + modelProviders: {}, + codingPlan: {}, + }, + setValue: vi.fn(), + isTrusted: true, + workspace: { settings: {} }, + user: { settings: {} }, + }; + + const mockConfig = { + reloadModelProvidersConfig: vi.fn(), + refreshAuth: vi.fn(), + }; + + const mockAddItem = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + delete process.env[CODING_PLAN_ENV_KEY]; + }); + + describe('version comparison', () => { + it('should not show update prompt when no version is stored', () => { + mockSettings.merged.codingPlan = {}; + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + expect(result.current.codingPlanUpdateRequest).toBeUndefined(); + }); + + it('should not show update prompt when versions match', () => { + mockSettings.merged.codingPlan = { version: 'test-version-hash' }; + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + expect(result.current.codingPlanUpdateRequest).toBeUndefined(); + }); + + it('should show update prompt when versions differ', async () => { + mockSettings.merged.codingPlan = { version: 'old-version-hash' }; + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.codingPlanUpdateRequest).toBeDefined(); + }); + + expect(result.current.codingPlanUpdateRequest?.prompt).toContain( + 'New model configurations', + ); + }); + }); + + describe('update execution', () => { + it('should execute update when user confirms', async () => { + process.env[CODING_PLAN_ENV_KEY] = 'test-api-key'; + mockSettings.merged.codingPlan = { version: 'old-version-hash' }; + mockSettings.merged.modelProviders = { + [AuthType.USE_OPENAI]: [ + { + id: 'test-model-1', + baseUrl: 'https://test.example.com/v1', + envKey: CODING_PLAN_ENV_KEY, + }, + { + id: 'custom-model', + baseUrl: 'https://custom.example.com', + envKey: 'CUSTOM_API_KEY', + }, + ], + }; + mockConfig.refreshAuth.mockResolvedValue(undefined); + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.codingPlanUpdateRequest).toBeDefined(); + }); + + // Confirm the update + await result.current.codingPlanUpdateRequest!.onConfirm(true); + + // Wait for async update to complete + await waitFor(() => { + // Should update model providers (at least 2 calls: modelProviders + version) + expect(mockSettings.setValue).toHaveBeenCalled(); + }); + + // Should update version + expect(mockSettings.setValue).toHaveBeenCalledWith( + expect.anything(), + 'codingPlan.version', + 'test-version-hash', + ); + + // Should reload and refresh auth + expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled(); + expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI); + + // Should show success message + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + text: expect.stringContaining('updated successfully'), + }), + expect.any(Number), + ); + }); + + it('should not execute update when user declines', async () => { + mockSettings.merged.codingPlan = { version: 'old-version-hash' }; + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.codingPlanUpdateRequest).toBeDefined(); + }); + + // Decline the update + await result.current.codingPlanUpdateRequest!.onConfirm(false); + + // Should not update anything + expect(mockSettings.setValue).not.toHaveBeenCalled(); + expect(mockConfig.reloadModelProvidersConfig).not.toHaveBeenCalled(); + }); + + it('should preserve non-Coding Plan configs during update', async () => { + process.env[CODING_PLAN_ENV_KEY] = 'test-api-key'; + mockSettings.merged.codingPlan = { version: 'old-version-hash' }; + const customConfig = { + id: 'custom-model', + baseUrl: 'https://custom.example.com', + envKey: 'CUSTOM_API_KEY', + }; + mockSettings.merged.modelProviders = { + [AuthType.USE_OPENAI]: [ + { + id: 'test-model-1', + baseUrl: 'https://test.example.com/v1', + envKey: CODING_PLAN_ENV_KEY, + }, + customConfig, + ], + }; + mockConfig.refreshAuth.mockResolvedValue(undefined); + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.codingPlanUpdateRequest).toBeDefined(); + }); + + await result.current.codingPlanUpdateRequest!.onConfirm(true); + + // Wait for async update to complete + await waitFor(() => { + // Should preserve custom config - verify setValue was called + expect(mockSettings.setValue).toHaveBeenCalled(); + }); + }); + + it('should handle missing API key error', async () => { + mockSettings.merged.codingPlan = { version: 'old-version-hash' }; + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.codingPlanUpdateRequest).toBeDefined(); + }); + + await result.current.codingPlanUpdateRequest!.onConfirm(true); + + // Should show error message + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + }), + expect.any(Number), + ); + }); + }); + + describe('dismissUpdate', () => { + it('should clear update request when dismissed', async () => { + mockSettings.merged.codingPlan = { version: 'old-version-hash' }; + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.codingPlanUpdateRequest).toBeDefined(); + }); + + result.current.dismissCodingPlanUpdate(); + + await waitFor(() => { + expect(result.current.codingPlanUpdateRequest).toBeUndefined(); + }); + }); + }); +}); diff --git a/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts b/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts new file mode 100644 index 000000000..85584def8 --- /dev/null +++ b/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts @@ -0,0 +1,201 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback, useEffect, useState } from 'react'; +import type { Config, ModelProvidersConfig } from '@qwen-code/qwen-code-core'; +import { AuthType } from '@qwen-code/qwen-code-core'; +import type { LoadedSettings } from '../../config/settings.js'; +import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; +import { + CODING_PLAN_MODELS, + CODING_PLAN_ENV_KEY, + CODING_PLAN_VERSION, +} from '../../constants/codingPlan.js'; +import { t } from '../../i18n/index.js'; + +export interface CodingPlanUpdateRequest { + prompt: string; + onConfirm: (confirmed: boolean) => void; +} + +/** + * Checks if a config is a Coding Plan configuration by matching baseUrl and envKey. + * This ensures only configs from the Coding Plan provider are identified. + */ +function isCodingPlanConfig(config: { + baseUrl?: string; + envKey?: string; +}): boolean { + return ( + config.envKey === CODING_PLAN_ENV_KEY && + CODING_PLAN_MODELS.some((template) => template.baseUrl === config.baseUrl) + ); +} + +/** + * Hook for detecting and handling Coding Plan template updates. + * Compares the persisted version with the current template version + * and prompts the user to update if they differ. + */ +export function useCodingPlanUpdates( + settings: LoadedSettings, + config: Config, + addItem: ( + item: { type: 'info' | 'error' | 'warning'; text: string }, + timestamp: number, + ) => void, +) { + const [updateRequest, setUpdateRequest] = useState< + CodingPlanUpdateRequest | undefined + >(); + + /** + * Execute the Coding Plan configuration update. + * Removes old Coding Plan configs and replaces them with new ones from the template. + */ + const executeUpdate = useCallback(async () => { + try { + const persistScope = getPersistScopeForModelSelection(settings); + + // Get current configs + const currentConfigs = + ( + settings.merged.modelProviders as + | Record>> + | undefined + )?.[AuthType.USE_OPENAI] || []; + + // Filter out Coding Plan configs (keep user custom configs) + const nonCodingPlanConfigs = currentConfigs.filter( + (cfg) => + !isCodingPlanConfig({ + baseUrl: cfg['baseUrl'] as string | undefined, + envKey: cfg['envKey'] as string | undefined, + }), + ); + + // Generate new configs from template with the stored API key + const apiKey = process.env[CODING_PLAN_ENV_KEY]; + if (!apiKey) { + throw new Error( + t( + 'Coding Plan API key not found. Please re-authenticate with Coding Plan.', + ), + ); + } + + const newConfigs = CODING_PLAN_MODELS.map((templateConfig) => ({ + ...templateConfig, + envKey: CODING_PLAN_ENV_KEY, + })); + + // Combine: new Coding Plan configs at the front, user configs preserved + const updatedConfigs = [ + ...newConfigs, + ...(nonCodingPlanConfigs as Array>), + ] as Array>; + + // Persist updated model providers + settings.setValue( + persistScope, + `modelProviders.${AuthType.USE_OPENAI}`, + updatedConfigs, + ); + + // Update the version + settings.setValue( + persistScope, + 'codingPlan.version', + CODING_PLAN_VERSION, + ); + + // Hot-reload model providers configuration + const updatedModelProviders = { + ...(settings.merged.modelProviders as + | Record + | undefined), + [AuthType.USE_OPENAI]: updatedConfigs, + }; + config.reloadModelProvidersConfig( + updatedModelProviders as unknown as ModelProvidersConfig, + ); + + // Refresh auth with the new configuration + await config.refreshAuth(AuthType.USE_OPENAI); + + addItem( + { + type: 'info', + text: t( + 'Coding Plan configuration updated successfully. New models are now available.', + ), + }, + Date.now(), + ); + + return true; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + addItem( + { + type: 'error', + text: t('Failed to update Coding Plan configuration: {{message}}', { + message: errorMessage, + }), + }, + Date.now(), + ); + return false; + } + }, [settings, config, addItem]); + + /** + * Check for version mismatch and prompt user for update if needed. + */ + const checkForUpdates = useCallback(() => { + const savedVersion = ( + settings.merged as { codingPlan?: { version?: string } } + ).codingPlan?.version; + + // If no version is stored, user hasn't used Coding Plan yet - skip check + if (!savedVersion) { + return; + } + + // If versions match, no update needed + if (savedVersion === CODING_PLAN_VERSION) { + return; + } + + // Version mismatch - prompt user for update + setUpdateRequest({ + prompt: t( + 'New model configurations are available for Bailian Coding Plan. Update now?', + ), + onConfirm: async (confirmed: boolean) => { + setUpdateRequest(undefined); + if (confirmed) { + await executeUpdate(); + } + }, + }); + }, [settings, executeUpdate]); + + // Check for updates on mount + useEffect(() => { + checkForUpdates(); + }, [checkForUpdates]); + + const dismissCodingPlanUpdate = useCallback(() => { + setUpdateRequest(undefined); + }, []); + + return { + codingPlanUpdateRequest: updateRequest, + dismissCodingPlanUpdate, + }; +}