diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index bea89475f..762ad4b10 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -448,7 +448,7 @@ describe('Settings Loading and Merging', () => { ); }); - it('should warn about unknown top-level keys in a v2 settings file', () => { + it('should silently ignore unknown top-level keys in a v2 settings file', () => { (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => p === USER_SETTINGS_PATH, ); @@ -466,13 +466,7 @@ describe('Settings Loading and Merging', () => { const settings = loadSettings(MOCK_WORKSPACE_DIR); - expect(getSettingsWarnings(settings)).toEqual( - expect.arrayContaining([ - expect.stringContaining( - "Unknown setting 'someUnknownKey' will be ignored", - ), - ]), - ); + expect(getSettingsWarnings(settings)).toEqual([]); }); it('should not warn for valid v2 container keys', () => { diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index e261cc723..434990508 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -14,6 +14,7 @@ import { QWEN_DIR, getErrorMessage, Storage, + createDebugLogger, } from '@qwen-code/qwen-code-core'; import stripJsonComments from 'strip-json-comments'; import { DefaultLight } from '../ui/themes/default-light.js'; @@ -32,6 +33,8 @@ import { customDeepMerge, type MergeableObject } from '../utils/deepMerge.js'; import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js'; import { writeStderrLine } from '../utils/stdioHelpers.js'; +const debugLogger = createDebugLogger('SETTINGS'); + function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined { let current: SettingDefinition | undefined = undefined; let currentSchema: SettingsSchema | undefined = getSettingsSchema(); @@ -564,7 +567,7 @@ function getSettingsFileKeyWarnings( ); } - // Unknown top-level keys. + // Unknown top-level keys — log silently to debug output. const schemaKeys = new Set(Object.keys(getSettingsSchema())); for (const key of Object.keys(settings)) { if (key === SETTINGS_VERSION_KEY) { @@ -577,8 +580,8 @@ function getSettingsFileKeyWarnings( continue; } - warnings.push( - `Warning: Unknown setting '${key}' will be ignored in ${settingsFilePath}.`, + debugLogger.warn( + `Unknown setting '${key}' will be ignored in ${settingsFilePath}.`, ); } diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index fd6c3e85b..1150a1bf6 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -822,9 +822,9 @@ const SETTINGS_SCHEMA = { label: 'Interactive Shell (PTY)', category: 'Tools', requiresRestart: true, - default: false, + default: true, description: - 'Use node-pty for an interactive shell experience. Fallback to child_process still applies.', + 'Use node-pty for an interactive shell experience. Falls back to child_process if PTY is unavailable.', showInDialog: true, }, pager: { diff --git a/packages/cli/src/constants/codingPlan.ts b/packages/cli/src/constants/codingPlan.ts index 03c164d8e..bc28a781a 100644 --- a/packages/cli/src/constants/codingPlan.ts +++ b/packages/cli/src/constants/codingPlan.ts @@ -64,6 +64,42 @@ export function generateCodingPlanTemplate( contextWindowSize: 1000000, }, }, + { + id: 'glm-5', + name: '[Bailian Coding Plan] glm-5', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + contextWindowSize: 202752, + }, + }, + { + id: 'kimi-k2.5', + name: '[Bailian Coding Plan] kimi-k2.5', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + contextWindowSize: 262144, + }, + }, + { + id: 'MiniMax-M2.5', + name: '[Bailian Coding Plan] MiniMax-M2.5', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + contextWindowSize: 1000000, + }, + }, { id: 'qwen3-coder-plus', name: '[Bailian Coding Plan] qwen3-coder-plus', @@ -106,42 +142,6 @@ export function generateCodingPlanTemplate( contextWindowSize: 202752, }, }, - { - id: 'glm-5', - name: '[Bailian Coding Plan] glm-5', - baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { - enable_thinking: true, - }, - contextWindowSize: 202752, - }, - }, - { - id: 'MiniMax-M2.5', - name: '[Bailian Coding Plan] MiniMax-M2.5', - baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { - enable_thinking: true, - }, - contextWindowSize: 1000000, - }, - }, - { - id: 'kimi-k2.5', - name: '[Bailian Coding Plan] kimi-k2.5', - baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { - enable_thinking: true, - }, - contextWindowSize: 262144, - }, - }, ]; } diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 1144aa31c..1562f5884 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1457,6 +1457,10 @@ export default { 'Neue Modellkonfigurationen sind für {{region}} 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 and model configs saved to settings.json (backed up).': - 'Erfolgreich mit {{region}} authentifiziert. API-Schlüssel und Modellkonfigurationen wurden in settings.json gespeichert (gesichert).', + '{{region}} configuration updated successfully.': + '{{region}}-Konfiguration erfolgreich aktualisiert.', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.': + 'Erfolgreich mit {{region}} authentifiziert. API-Schlüssel und Modellkonfigurationen wurden in settings.json gespeichert.', + 'Tip: Use /model to switch between available Coding Plan models.': + 'Tipp: Verwenden Sie /model, um zwischen verfügbaren Coding Plan-Modellen zu wechseln.', }; diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 1c27b760f..690419172 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1446,6 +1446,10 @@ export default { 'New model configurations are available for {{region}}. Update now?', '{{region}} configuration updated successfully. Model switched to "{{model}}".': '{{region}} configuration updated successfully. Model switched to "{{model}}".', - '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).', + '{{region}} configuration updated successfully.': + '{{region}} configuration updated successfully.', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.': + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.', + 'Tip: Use /model to switch between available Coding Plan models.': + 'Tip: Use /model to switch between available Coding Plan models.', }; diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 634cec49d..991aadb0f 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -964,6 +964,10 @@ export default { '{{region}} の新しいモデル設定が利用可能です。今すぐ更新しますか?', '{{region}} configuration updated successfully. Model switched to "{{model}}".': '{{region}} の設定が正常に更新されました。モデルが "{{model}}" に切り替わりました。', - 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': - '{{region}} での認証に成功しました。APIキーとモデル設定が settings.json に保存されました(バックアップ済み)。', + '{{region}} configuration updated successfully.': + '{{region}} の設定が正常に更新されました。', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.': + '{{region}} での認証に成功しました。APIキーとモデル設定が settings.json に保存されました。', + 'Tip: Use /model to switch between available Coding Plan models.': + 'ヒント: /model で利用可能な Coding Plan モデルを切り替えられます。', }; diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 729ebbd74..2cbead5e3 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1451,6 +1451,10 @@ export default { 'Novas configurações de modelo estão disponíveis para o {{region}}. 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 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).', + '{{region}} configuration updated successfully.': + 'Configuração do {{region}} atualizada com sucesso.', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.': + 'Autenticado com sucesso com {{region}}. Chave de API e configurações de modelo salvas em settings.json.', + 'Tip: Use /model to switch between available Coding Plan models.': + 'Dica: Use /model para alternar entre os modelos disponíveis do Coding Plan.', }; diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 867de9b9a..df6240787 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1461,6 +1461,10 @@ export default { 'Доступны новые конфигурации моделей для {{region}}. Обновить сейчас?', '{{region}} configuration updated successfully. Model switched to "{{model}}".': 'Конфигурация {{region}} успешно обновлена. Модель переключена на "{{model}}".', - 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': - 'Успешная аутентификация с {{region}}. API-ключ и конфигурации моделей сохранены в settings.json (резервная копия создана).', + '{{region}} configuration updated successfully.': + 'Конфигурация {{region}} успешно обновлена.', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.': + 'Успешная аутентификация с {{region}}. API-ключ и конфигурации моделей сохранены в settings.json.', + 'Tip: Use /model to switch between available Coding Plan models.': + 'Совет: Используйте /model для переключения между доступными моделями Coding Plan.', }; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 5bc2bef92..b2b17d980 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1279,6 +1279,9 @@ export default { '{{region}} 有新的模型配置可用。是否立即更新?', '{{region}} configuration updated successfully. Model switched to "{{model}}".': '{{region}} 配置更新成功。模型已切换至 "{{model}}"。', - 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': - '成功通过 {{region}} 认证。API Key 和模型配置已保存至 settings.json(已备份)。', + '{{region}} configuration updated successfully.': '{{region}} 配置更新成功。', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.': + '成功通过 {{region}} 认证。API Key 和模型配置已保存至 settings.json。', + 'Tip: Use /model to switch between available Coding Plan models.': + '提示:使用 /model 切换可用的 Coding Plan 模型。', }; diff --git a/packages/cli/src/services/insight/generators/StaticInsightGenerator.ts b/packages/cli/src/services/insight/generators/StaticInsightGenerator.ts index 99bcb9e26..6d0c661cc 100644 --- a/packages/cli/src/services/insight/generators/StaticInsightGenerator.ts +++ b/packages/cli/src/services/insight/generators/StaticInsightGenerator.ts @@ -14,9 +14,7 @@ import type { InsightProgressCallback, } from '../types/StaticInsightTypes.js'; -import { createDebugLogger, type Config } from '@qwen-code/qwen-code-core'; - -const logger = createDebugLogger('StaticInsightGenerator'); +import { updateSymlink, type Config } from '@qwen-code/qwen-code-core'; export class StaticInsightGenerator { private dataProcessor: DataProcessor; @@ -54,40 +52,12 @@ export class StaticInsightGenerator { return outputPath; } - // Create or update the "latest" alias (symlink preferred, copy as fallback) - private async updateLatestAlias( + private async updateInsightSymlink( outputDir: string, targetPath: string, ): Promise { const latestPath = path.join(outputDir, 'insight.html'); - const relativeTarget = path.relative(outputDir, targetPath); - - // Remove existing file/symlink if it exists - try { - await fs.unlink(latestPath); - } catch { - // File doesn't exist, ignore - } - - // Try symlink first (preferred - lightweight, always points to latest) - try { - await fs.symlink(relativeTarget, latestPath); - logger.debug('Created insight symlink:', relativeTarget); - return; - } catch (error) { - logger.debug( - 'Failed to create insight symlink, falling back to copy:', - error, - ); - } - - // Fallback: copy file (works everywhere, uses more disk space) - try { - await fs.copyFile(targetPath, latestPath); - logger.debug('Created insight copy:', targetPath); - } catch (error) { - logger.debug('Failed to create insight latest alias:', error); - } + await updateSymlink(latestPath, targetPath); } // Generate the static insight HTML file @@ -116,8 +86,7 @@ export class StaticInsightGenerator { // Write the HTML file await fs.writeFile(outputPath, html, 'utf-8'); - // Update latest alias (symlink preferred, copy as fallback) - await this.updateLatestAlias(outputDir, outputPath); + await this.updateInsightSymlink(outputDir, outputPath); return outputPath; } diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index 24cfbf61c..283a0d155 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -389,13 +389,24 @@ export const useAuthCommand = ( { type: MessageType.INFO, text: t( - '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.', { region: t('Alibaba Cloud Coding Plan') }, ), }, Date.now(), ); + // Hint about /model command + addItem( + { + type: MessageType.INFO, + text: t( + 'Tip: Use /model to switch between available Coding Plan models.', + ), + }, + Date.now(), + ); + // Log success const authEvent = new AuthEvent( AuthType.USE_OPENAI, diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap index d6cf8d2f8..5b1c5bb95 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -11,7 +11,7 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly │ Language: Model auto │ │ Theme Qwen Dark │ │ Vim Mode false │ -│ Interactive Shell (PTY) false │ +│ Interactive Shell (PTY) true │ │ Preferred Editor │ │ Auto-connect to IDE false │ │ ▼ │ @@ -32,7 +32,7 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select │ Language: Model auto │ │ Theme Qwen Dark │ │ Vim Mode false │ -│ Interactive Shell (PTY) false │ +│ Interactive Shell (PTY) true │ │ Preferred Editor │ │ Auto-connect to IDE false │ │ ▼ │ @@ -53,7 +53,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett │ Language: Model auto │ │ Theme Qwen Dark │ │ Vim Mode true* │ -│ Interactive Shell (PTY) false │ +│ Interactive Shell (PTY) true │ │ Preferred Editor │ │ Auto-connect to IDE false │ │ ▼ │ @@ -74,7 +74,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin │ Language: Model auto │ │ Theme Qwen Dark │ │ Vim Mode false* │ -│ Interactive Shell (PTY) false │ +│ Interactive Shell (PTY) true │ │ Preferred Editor │ │ Auto-connect to IDE false* │ │ ▼ │ @@ -95,7 +95,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se │ Language: Model auto │ │ Theme Qwen Dark │ │ Vim Mode (Modified in System) false │ -│ Interactive Shell (PTY) false │ +│ Interactive Shell (PTY) true │ │ Preferred Editor │ │ Auto-connect to IDE false │ │ ▼ │ @@ -116,7 +116,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se │ Language: Model auto │ │ Theme Qwen Dark │ │ Vim Mode (Modified in Workspace) false │ -│ Interactive Shell (PTY) false │ +│ Interactive Shell (PTY) true │ │ Preferred Editor │ │ Auto-connect to IDE false │ │ ▼ │ @@ -137,7 +137,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set │ Language: Model auto │ │ Theme Qwen Dark │ │ Vim Mode false │ -│ Interactive Shell (PTY) false │ +│ Interactive Shell (PTY) true │ │ Preferred Editor │ │ Auto-connect to IDE false │ │ ▼ │ @@ -158,7 +158,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and │ Language: Model auto │ │ Theme Qwen Dark │ │ Vim Mode false* │ -│ Interactive Shell (PTY) false │ +│ Interactive Shell (PTY) true │ │ Preferred Editor │ │ Auto-connect to IDE false │ │ ▼ │ @@ -179,7 +179,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security │ Language: Model auto │ │ Theme Qwen Dark │ │ Vim Mode false │ -│ Interactive Shell (PTY) false │ +│ Interactive Shell (PTY) true │ │ Preferred Editor │ │ Auto-connect to IDE false │ │ ▼ │ @@ -200,7 +200,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se │ Language: Model auto │ │ Theme Qwen Dark │ │ Vim Mode true* │ -│ Interactive Shell (PTY) false │ +│ Interactive Shell (PTY) true │ │ Preferred Editor │ │ Auto-connect to IDE true* │ │ ▼ │ diff --git a/packages/cli/src/ui/components/subagents/create/AgentCreationWizard.tsx b/packages/cli/src/ui/components/subagents/create/AgentCreationWizard.tsx index c36c72f52..70c0e0671 100644 --- a/packages/cli/src/ui/components/subagents/create/AgentCreationWizard.tsx +++ b/packages/cli/src/ui/components/subagents/create/AgentCreationWizard.tsx @@ -120,45 +120,6 @@ export function AgentCreationWizard({ ); }, [state.currentStep, state.generationMethod]); - const renderDebugContent = useCallback(() => { - if (process.env['NODE_ENV'] !== 'development') { - return null; - } - - return ( - - - - Debug Info: - - Step: {state.currentStep} - - Can Proceed: {state.canProceed ? 'Yes' : 'No'} - - - Generating: {state.isGenerating ? 'Yes' : 'No'} - - Location: {state.location} - - Method: {state.generationMethod} - - {state.validationErrors.length > 0 && ( - - Errors: {state.validationErrors.join(', ')} - - )} - - - ); - }, [ - state.currentStep, - state.canProceed, - state.isGenerating, - state.location, - state.generationMethod, - state.validationErrors, - ]); - const renderStepFooter = useCallback(() => { const getNavigationInstructions = () => { // Special case: During generation in description input step, only show cancel option @@ -331,7 +292,6 @@ export function AgentCreationWizard({ > {renderStepHeader()} {renderStepContent()} - {renderDebugContent()} {renderStepFooter()} diff --git a/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts index bcb5bce33..7f8be6a69 100644 --- a/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts +++ b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts @@ -481,6 +481,111 @@ describe('useCodingPlanUpdates', () => { ).toBe(true); }); + it('should show "model preserved" message when current model exists in new template', async () => { + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.CHINA, + version: 'old-version-hash', + }; + mockSettings.merged.modelProviders = { + [AuthType.USE_OPENAI]: [ + { + id: 'qwen3.5-plus', + baseUrl: chinaConfig.baseUrl, + envKey: CODING_PLAN_ENV_KEY, + }, + ], + }; + // Simulate the user's current model being one that exists in the new template + mockConfig.getModel.mockReturnValue('qwen3.5-plus'); + 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); + + await waitFor(() => { + expect(mockSettings.setValue).toHaveBeenCalled(); + }); + + // Should show plain success message without "switched" + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + text: expect.stringContaining('updated successfully'), + }), + expect.any(Number), + ); + expect(mockAddItem).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + text: expect.stringContaining('switched'), + }), + expect.any(Number), + ); + + // Reset mock + mockConfig.getModel.mockReturnValue('qwen-max'); + }); + + it('should show "model switched" message when current model is not in new template', async () => { + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.CHINA, + version: 'old-version-hash', + }; + mockSettings.merged.modelProviders = { + [AuthType.USE_OPENAI]: [ + { + id: 'removed-model', + baseUrl: chinaConfig.baseUrl, + envKey: CODING_PLAN_ENV_KEY, + }, + ], + }; + // The user's current model no longer exists in the new template + mockConfig.getModel.mockReturnValue('removed-model'); + 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); + + await waitFor(() => { + expect(mockSettings.setValue).toHaveBeenCalled(); + }); + + // Should show "model switched" message + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + text: expect.stringContaining('switched'), + }), + expect.any(Number), + ); + + // Reset mock + mockConfig.getModel.mockReturnValue('qwen-max'); + }); + it('should handle update errors gracefully', async () => { mockSettings.merged.codingPlan = { region: CodingPlanRegion.CHINA, diff --git a/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts b/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts index 138498abf..1d341b31f 100644 --- a/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts +++ b/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts @@ -42,6 +42,7 @@ export function useCodingPlanUpdates( /** * Execute the Coding Plan configuration update. * Removes old Coding Plan configs and replaces them with new ones from the template. + * Preserves the user's current model selection if it still exists in the new template. * Uses the region from settings.codingPlan.region (defaults to CHINA). */ const executeUpdate = useCallback( @@ -82,6 +83,12 @@ export function useCodingPlanUpdates( ...(nonCodingPlanConfigs as Array>), ] as Array>; + // Record the user's current model before the update + const previousModel = config.getModel(); + const previousModelStillAvailable = newConfigs.some( + (cfg) => cfg.id === previousModel, + ); + // Hot-reload model providers configuration first (in-memory only) const updatedModelProviders = { ...(settings.merged.modelProviders as @@ -112,12 +119,34 @@ export function useCodingPlanUpdates( const activeModel = config.getModel(); + if (previousModelStillAvailable && activeModel === previousModel) { + addItem( + { + type: 'info', + text: t('{{region}} configuration updated successfully.', { + region: t('Alibaba Cloud Coding Plan'), + }), + }, + Date.now(), + ); + } else { + addItem( + { + type: 'info', + text: t( + '{{region}} configuration updated successfully. Model switched to "{{model}}".', + { region: t('Alibaba Cloud Coding Plan'), model: activeModel }, + ), + }, + Date.now(), + ); + } + addItem( { type: 'info', text: t( - '{{region}} configuration updated successfully. Model switched to "{{model}}".', - { region: t('Alibaba Cloud Coding Plan'), model: activeModel }, + 'Tip: Use /model to switch between available Coding Plan models.', ), }, Date.now(), diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 0e5f29216..97616d25a 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -59,6 +59,7 @@ import { type TrackedToolCall, type TrackedCompletedToolCall, type TrackedCancelledToolCall, + type TrackedExecutingToolCall, type TrackedWaitingToolCall, } from './useReactToolScheduler.js'; import { promises as fs } from 'node:fs'; @@ -358,6 +359,23 @@ export const useGeminiStream = ( if (toolCalls.some((tc) => tc.status === 'awaiting_approval')) { return StreamingState.WaitingForConfirmation; } + // Check if any executing subagent task has a pending confirmation + if ( + toolCalls.some((tc) => { + if (tc.status !== 'executing') return false; + const liveOutput = (tc as TrackedExecutingToolCall).liveOutput; + return ( + typeof liveOutput === 'object' && + liveOutput !== null && + 'type' in liveOutput && + liveOutput.type === 'task_execution' && + 'pendingConfirmation' in liveOutput && + liveOutput.pendingConfirmation != null + ); + }) + ) { + return StreamingState.WaitingForConfirmation; + } if ( isResponding || toolCalls.some( diff --git a/packages/cli/src/utils/systemInfo.ts b/packages/cli/src/utils/systemInfo.ts index 564c5a08a..4ea281210 100644 --- a/packages/cli/src/utils/systemInfo.ts +++ b/packages/cli/src/utils/systemInfo.ts @@ -38,6 +38,7 @@ export interface SystemInfo { export interface ExtendedSystemInfo extends SystemInfo { memoryUsage: string; baseUrl?: string; + apiKeyEnvKey?: string; gitCommit?: string; proxy?: string; } @@ -154,12 +155,14 @@ export async function getExtendedSystemInfo( // For bug reports, use sandbox name without prefix const sandboxEnv = getSandboxEnv(true); - // Get base URL if using OpenAI auth - const baseUrl = + // Get base URL and apiKeyEnvKey if using OpenAI or Anthropic auth + const contentGeneratorConfig = baseInfo.selectedAuthType === AuthType.USE_OPENAI || baseInfo.selectedAuthType === AuthType.USE_ANTHROPIC - ? context.services.config?.getContentGeneratorConfig()?.baseUrl + ? context.services.config?.getContentGeneratorConfig() : undefined; + const baseUrl = contentGeneratorConfig?.baseUrl; + const apiKeyEnvKey = contentGeneratorConfig?.apiKeyEnvKey; // Get git commit info const gitCommit = @@ -172,6 +175,7 @@ export async function getExtendedSystemInfo( sandboxEnv, memoryUsage, baseUrl, + apiKeyEnvKey, gitCommit, }; } diff --git a/packages/cli/src/utils/systemInfoFields.ts b/packages/cli/src/utils/systemInfoFields.ts index ed43431f2..17062b66a 100644 --- a/packages/cli/src/utils/systemInfoFields.ts +++ b/packages/cli/src/utils/systemInfoFields.ts @@ -6,6 +6,7 @@ import type { ExtendedSystemInfo } from './systemInfo.js'; import { t } from '../i18n/index.js'; +import { isCodingPlanConfig } from '../constants/codingPlan.js'; /** * Field configuration for system information display @@ -30,6 +31,7 @@ export function getSystemInfoFields( addField(fields, t('IDE Client'), info.ideClient); addField(fields, t('OS'), formatOs(info)); addField(fields, t('Auth'), formatAuth(info)); + addField(fields, t('Base URL'), formatBaseUrl(info)); addField(fields, t('Model'), info.modelVersion); addField(fields, t('Session ID'), info.sessionId); addField(fields, t('Sandbox'), info.sandboxEnv); @@ -86,15 +88,34 @@ function formatAuth(info: ExtendedSystemInfo): string { if (!info.selectedAuthType) { return ''; } - const authType = formatAuthType(info.selectedAuthType); - if (!info.baseUrl) { - return authType; + + if (isCodingPlanConfig(info.baseUrl, info.apiKeyEnvKey)) { + return t('Alibaba Cloud Coding Plan'); } - return `${authType} (${info.baseUrl})`; + + if ( + info.selectedAuthType.startsWith('oauth') || + info.selectedAuthType === 'qwen-oauth' + ) { + return 'Qwen OAuth'; + } + + return `API Key - ${info.selectedAuthType}`; } -function formatAuthType(authType: string): string { - return authType.startsWith('oauth') ? 'OAuth' : authType; +function formatBaseUrl(info: ExtendedSystemInfo): string { + if (!info.selectedAuthType || !info.baseUrl) { + return ''; + } + + if ( + info.selectedAuthType.startsWith('oauth') || + info.selectedAuthType === 'qwen-oauth' + ) { + return ''; + } + + return info.baseUrl; } function formatProxy(proxy?: string): string { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 98b72c9c2..285ad2bce 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -617,7 +617,7 @@ export class Config { this.webSearch = params.webSearch; this.useRipgrep = params.useRipgrep ?? true; this.useBuiltinRipgrep = params.useBuiltinRipgrep ?? true; - this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? false; + this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? true; this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? true; this.shellExecutionConfig = { terminalWidth: params.shellExecutionConfig?.terminalWidth ?? 80, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2800e20f6..a7c58ca0b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -74,6 +74,7 @@ export * from './utils/paths.js'; export * from './utils/schemaValidator.js'; export * from './utils/errors.js'; export * from './utils/debugLogger.js'; +export * from './utils/symlink.js'; export * from './utils/getFolderStructure.js'; export * from './utils/memoryDiscovery.js'; export * from './utils/gitIgnoreParser.js'; @@ -287,6 +288,7 @@ export * from './utils/tool-utils.js'; export * from './utils/workspaceContext.js'; export * from './utils/yaml-parser.js'; export * from './utils/jsonl-utils.js'; +export * from './utils/symlink.js'; // ============================================================================ // OAuth & Authentication diff --git a/packages/core/src/services/shellExecutionService.test.ts b/packages/core/src/services/shellExecutionService.test.ts index 8c8e7bd4a..1e93076fd 100644 --- a/packages/core/src/services/shellExecutionService.test.ts +++ b/packages/core/src/services/shellExecutionService.test.ts @@ -19,6 +19,13 @@ const mockIsBinary = vi.hoisted(() => vi.fn()); const mockPlatform = vi.hoisted(() => vi.fn()); const mockGetPty = vi.hoisted(() => vi.fn()); const mockSerializeTerminalToObject = vi.hoisted(() => vi.fn()); +const mockGetShellConfiguration = vi.hoisted(() => + vi.fn().mockReturnValue({ + executable: 'bash', + argsPrefix: ['-c'], + shell: 'bash', + }), +); // Top-level Mocks vi.mock('@lydell/node-pty', () => ({ @@ -54,6 +61,9 @@ vi.mock('../utils/getPty.js', () => ({ vi.mock('../utils/terminalSerializer.js', () => ({ serializeTerminalToObject: mockSerializeTerminalToObject, })); +vi.mock('../utils/shell-utils.js', () => ({ + getShellConfiguration: mockGetShellConfiguration, +})); const mockProcessKill = vi .spyOn(process, 'kill') @@ -410,15 +420,25 @@ describe('ShellExecutionService', () => { describe('Platform-Specific Behavior', () => { it('should use cmd.exe on Windows', async () => { mockPlatform.mockReturnValue('win32'); + mockGetShellConfiguration.mockReturnValue({ + executable: 'cmd.exe', + argsPrefix: ['/d', '/s', '/c'], + shell: 'cmd', + }); await simulateExecution('dir "foo bar"', (pty) => pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }), ); expect(mockPtySpawn).toHaveBeenCalledWith( 'cmd.exe', - '/c dir "foo bar"', + ['/d', '/s', '/c', 'dir "foo bar"'], expect.any(Object), ); + mockGetShellConfiguration.mockReturnValue({ + executable: 'bash', + argsPrefix: ['-c'], + shell: 'bash', + }); }); it('should use bash on Linux', async () => { @@ -822,18 +842,28 @@ describe('ShellExecutionService child_process fallback', () => { describe('Platform-Specific Behavior', () => { it('should use cmd.exe and hide window on Windows', async () => { mockPlatform.mockReturnValue('win32'); + mockGetShellConfiguration.mockReturnValue({ + executable: 'cmd.exe', + argsPrefix: ['/d', '/s', '/c'], + shell: 'cmd', + }); await simulateExecution('dir "foo bar"', (cp) => cp.emit('exit', 0, null), ); expect(mockCpSpawn).toHaveBeenCalledWith( 'cmd.exe', - ['/c', 'dir "foo bar"'], + ['/d', '/s', '/c', 'dir "foo bar"'], expect.objectContaining({ detached: false, windowsHide: true, }), ); + mockGetShellConfiguration.mockReturnValue({ + executable: 'bash', + argsPrefix: ['-c'], + shell: 'bash', + }); }); it('should use bash and detached process group on Linux', async () => { diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index 3d812d899..50cdc3a09 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -13,6 +13,7 @@ import os from 'node:os'; import type { IPty } from '@lydell/node-pty'; import { getCachedEncodingForBuffer } from '../utils/systemEncoding.js'; import { isBinary } from '../utils/textUtils.js'; +import { getShellConfiguration } from '../utils/shell-utils.js'; import pkg from '@xterm/headless'; import { serializeTerminalToObject, @@ -223,14 +224,12 @@ export class ShellExecutionService { ): ShellExecutionHandle { try { const isWindows = os.platform() === 'win32'; - const shell = isWindows ? 'cmd.exe' : 'bash'; - const shellArgs = isWindows - ? ['/c', commandToExecute] - : ['-c', commandToExecute]; + const { executable, argsPrefix } = getShellConfiguration(); + const shellArgs = [...argsPrefix, commandToExecute]; // Note: CodeQL flags this as js/shell-command-injection-from-environment. // This is intentional - CLI tool executes user-provided shell commands. - const child = cpSpawn(shell, shellArgs, { + const child = cpSpawn(executable, shellArgs, { cwd, stdio: ['ignore', 'pipe', 'pipe'], windowsVerbatimArguments: isWindows, @@ -419,13 +418,10 @@ export class ShellExecutionService { try { const cols = shellExecutionConfig.terminalWidth ?? 80; const rows = shellExecutionConfig.terminalHeight ?? 30; - const isWindows = os.platform() === 'win32'; - const shell = isWindows ? 'cmd.exe' : 'bash'; - const args = isWindows - ? `/c ${commandToExecute}` - : ['-c', commandToExecute]; + const { executable, argsPrefix } = getShellConfiguration(); + const args = [...argsPrefix, commandToExecute]; - const ptyProcess = ptyInfo.module.spawn(shell, args, { + const ptyProcess = ptyInfo.module.spawn(executable, args, { cwd, name: 'xterm', cols, @@ -435,6 +431,7 @@ export class ShellExecutionService { QWEN_CODE: '1', TERM: 'xterm-256color', PAGER: shellExecutionConfig.pager ?? 'cat', + GIT_PAGER: shellExecutionConfig.pager ?? 'cat', }, handleFlowControl: true, }); @@ -463,85 +460,107 @@ export class ShellExecutionService { let hasStartedOutput = false; let renderTimeout: NodeJS.Timeout | null = null; - const render = (finalRender = false) => { - if (renderTimeout) { - clearTimeout(renderTimeout); + const RENDER_THROTTLE_MS = 100; + + const renderFn = () => { + if (!isStreamingRawContent) { + return; } - const renderFn = () => { - if (!isStreamingRawContent) { - return; - } - - if (!shellExecutionConfig.disableDynamicLineTrimming) { - if (!hasStartedOutput) { - const bufferText = getFullBufferText(headlessTerminal); - if (bufferText.trim().length === 0) { - return; - } - hasStartedOutput = true; + if (!shellExecutionConfig.disableDynamicLineTrimming) { + if (!hasStartedOutput) { + const bufferText = getFullBufferText(headlessTerminal); + if (bufferText.trim().length === 0) { + return; } + hasStartedOutput = true; } + } - let newOutput: AnsiOutput; - if (shellExecutionConfig.showColor) { - newOutput = serializeTerminalToObject(headlessTerminal); - } else { - const buffer = headlessTerminal.buffer.active; - const lines: AnsiOutput = []; - for (let y = 0; y < headlessTerminal.rows; y++) { - const line = buffer.getLine(buffer.viewportY + y); - const lineContent = line ? line.translateToString(true) : ''; - lines.push([ - { - text: lineContent, - bold: false, - italic: false, - underline: false, - dim: false, - inverse: false, - fg: '', - bg: '', - }, - ]); - } - newOutput = lines; - } - - let lastNonEmptyLine = -1; - for (let i = newOutput.length - 1; i >= 0; i--) { - const line = newOutput[i]; - if ( - line - .map((segment) => segment.text) - .join('') - .trim().length > 0 - ) { - lastNonEmptyLine = i; - break; - } - } - - const trimmedOutput = newOutput.slice(0, lastNonEmptyLine + 1); - - const finalOutput = shellExecutionConfig.disableDynamicLineTrimming - ? newOutput - : trimmedOutput; - - // Using stringify for a quick deep comparison. - if (JSON.stringify(output) !== JSON.stringify(finalOutput)) { - output = finalOutput; - onOutputEvent({ - type: 'data', - chunk: finalOutput, - }); - } - }; - - if (finalRender) { - renderFn(); + let newOutput: AnsiOutput; + if (shellExecutionConfig.showColor) { + newOutput = serializeTerminalToObject(headlessTerminal); } else { - renderTimeout = setTimeout(renderFn, 17); + const buffer = headlessTerminal.buffer.active; + const lines: AnsiOutput = []; + for (let y = 0; y < headlessTerminal.rows; y++) { + const line = buffer.getLine(buffer.viewportY + y); + const lineContent = line ? line.translateToString(true) : ''; + lines.push([ + { + text: lineContent, + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + fg: '', + bg: '', + }, + ]); + } + newOutput = lines; + } + + let lastNonEmptyLine = -1; + for (let i = newOutput.length - 1; i >= 0; i--) { + const line = newOutput[i]; + if ( + line + .map((segment) => segment.text) + .join('') + .trim().length > 0 + ) { + lastNonEmptyLine = i; + break; + } + } + + const trimmedOutput = newOutput.slice(0, lastNonEmptyLine + 1); + + const finalOutput = shellExecutionConfig.disableDynamicLineTrimming + ? newOutput + : trimmedOutput; + + // Using stringify for a quick deep comparison. + if (JSON.stringify(output) !== JSON.stringify(finalOutput)) { + output = finalOutput; + onOutputEvent({ + type: 'data', + chunk: finalOutput, + }); + } + }; + + // Throttle: render immediately on first call, then at most + // once per RENDER_THROTTLE_MS during continuous output. + // A trailing render is scheduled to ensure the final state + // is always displayed. + let pendingTrailingRender = false; + + const render = (finalRender = false) => { + if (finalRender) { + if (renderTimeout) { + clearTimeout(renderTimeout); + renderTimeout = null; + } + renderFn(); + return; + } + + if (!renderTimeout) { + // No active throttle — render now and start throttle window + renderFn(); + renderTimeout = setTimeout(() => { + renderTimeout = null; + if (pendingTrailingRender) { + pendingTrailingRender = false; + render(); + } + }, RENDER_THROTTLE_MS); + } else { + // Throttled — mark that we need a trailing render + pendingTrailingRender = true; } }; @@ -610,7 +629,7 @@ export class ShellExecutionService { abortSignal.removeEventListener('abort', abortHandler); this.activePtys.delete(ptyProcess.pid); - processingChain.then(() => { + const finalize = () => { render(true); const finalBuffer = Buffer.concat(outputChunks); @@ -626,6 +645,18 @@ export class ShellExecutionService { (ptyInfo?.name as 'node-pty' | 'lydell-node-pty') ?? 'node-pty', }); + }; + + // Always try to flush pending terminal writes before + // finalizing so result.output is as complete as possible. + // Race against abort or a short timeout to avoid hanging. + const processingComplete = processingChain.then(() => 'processed'); + const deadline = new Promise<'timeout'>((res) => + setTimeout(() => res('timeout'), SIGKILL_TIMEOUT_MS), + ); + + void Promise.race([processingComplete, deadline]).then(() => { + finalize(); }); }, ); @@ -636,11 +667,18 @@ export class ShellExecutionService { ptyProcess.kill(); } else { try { - // Kill the entire process group - process.kill(-ptyProcess.pid, 'SIGINT'); + // Send SIGTERM first to allow graceful shutdown + process.kill(-ptyProcess.pid, 'SIGTERM'); + await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS)); + if (!exited) { + // Escalate to SIGKILL if still running + process.kill(-ptyProcess.pid, 'SIGKILL'); + } } catch (_e) { // Fallback to killing just the process if the group kill fails - ptyProcess.kill('SIGINT'); + if (!exited) { + ptyProcess.kill(); + } } } } @@ -652,19 +690,28 @@ export class ShellExecutionService { return { pid: ptyProcess.pid, result }; } catch (e) { const error = e as Error; - return { - pid: undefined, - result: Promise.resolve({ - error, - rawOutput: Buffer.from(''), - output: '', - exitCode: 1, - signal: null, - aborted: false, + if (error.message.includes('posix_spawnp failed')) { + onOutputEvent({ + type: 'data', + chunk: + '[WARNING] PTY execution failed, falling back to child_process. This may be due to sandbox restrictions.\n', + }); + throw e; + } else { + return { pid: undefined, - executionMethod: 'none', - }), - }; + result: Promise.resolve({ + error, + rawOutput: Buffer.from(''), + output: '', + exitCode: 1, + signal: null, + aborted: false, + pid: undefined, + executionMethod: 'none', + }), + }; + } } } diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index a3d738580..d03509451 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -268,10 +268,10 @@ describe('ShellTool', () => { resolveExecutionPromise(fullResult); }; - it('should wrap command on linux and parse pgrep output', async () => { + it('should wrap background command on linux and parse pgrep output', async () => { const invocation = shellTool.build({ - command: 'my-command &', - is_background: false, + command: 'my-command', + is_background: true, }); const promise = invocation.execute(mockAbortSignal); resolveShellExecution({ pid: 54321 }); @@ -291,7 +291,7 @@ describe('ShellTool', () => { false, {}, ); - expect(result.llmContent).toContain('Background PIDs: 54322'); + expect(result.llmContent).toContain('PIDs: 54322'); expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile); }); @@ -353,15 +353,11 @@ describe('ShellTool', () => { const promise = invocation.execute(mockAbortSignal); resolveShellExecution({ pid: 54321 }); - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue('54321\n54322\n'); - await promise; - const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp'); - const wrappedCommand = `{ npm test; }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`; + // Foreground commands should not be wrapped with pgrep expect(mockShellExecutionService).toHaveBeenCalledWith( - wrappedCommand, + 'npm test', expect.any(String), expect.any(Function), expect.any(AbortSignal), @@ -383,10 +379,9 @@ describe('ShellTool', () => { resolveShellExecution(); await promise; - const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp'); - const wrappedCommand = `{ ls; }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`; + // Foreground commands should not be wrapped with pgrep expect(mockShellExecutionService).toHaveBeenCalledWith( - wrappedCommand, + 'ls', '/test/dir/subdir', expect.any(Function), expect.any(AbortSignal), @@ -733,7 +728,6 @@ describe('ShellTool', () => { await promise; - // On Linux, commands are wrapped with pgrep functionality expect(mockShellExecutionService).toHaveBeenCalledWith( expect.stringContaining('npm install'), expect.any(String), @@ -762,7 +756,6 @@ describe('ShellTool', () => { await promise; - // On Linux, commands are wrapped with pgrep functionality expect(mockShellExecutionService).toHaveBeenCalledWith( expect.stringContaining('git commit'), expect.any(String), @@ -828,7 +821,6 @@ describe('ShellTool', () => { await promise; - // On Linux, commands are wrapped with pgrep functionality expect(mockShellExecutionService).toHaveBeenCalledWith( expect.stringContaining('git commit -m "Initial commit"'), expect.any(String), diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index e55d03626..01a9ac5cf 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -181,15 +181,16 @@ export class ShellToolInvocation extends BaseToolInvocation< finalCommand = finalCommand.trim().replace(/&+$/, '').trim(); } - // pgrep is not available on Windows, so we can't get background PIDs - const commandToExecute = isWindows - ? finalCommand - : (() => { - // wrap command to append subprocess pids (via pgrep) to temporary file - let command = finalCommand.trim(); - if (!command.endsWith('&')) command += ';'; - return `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`; - })(); + // On non-Windows background commands, wrap with pgrep to capture + // subprocess PIDs so we can report them to the user. + const commandToExecute = + !isWindows && shouldRunInBackground + ? (() => { + let command = finalCommand.trim(); + if (!command.endsWith('&')) command += ';'; + return `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`; + })() + : finalCommand; const cwd = this.params.directory || this.config.getTargetDir(); @@ -240,7 +241,9 @@ export class ShellToolInvocation extends BaseToolInvocation< } }, combinedSignal, - this.config.getShouldUseNodePtyShell(), + shouldRunInBackground + ? false + : this.config.getShouldUseNodePtyShell(), shellExecutionConfig ?? {}, ); @@ -248,14 +251,11 @@ export class ShellToolInvocation extends BaseToolInvocation< setPidCallback(pid); } - if (shouldRunInBackground) { - // For background tasks, return immediately with PID info - // Note: We cannot reliably detect startup errors for background processes - // since their stdio is typically detached/ignored + // On Windows, background commands rely on early return since there's + // no & backgrounding or pgrep. Awaiting would block until completion. + if (shouldRunInBackground && isWindows) { const pidMsg = pid ? ` PID: ${pid}` : ''; - const killHint = isWindows - ? ' (Use taskkill /F /T /PID to stop)' - : ' (Use kill to stop)'; + const killHint = ' (Use taskkill /F /T /PID to stop)'; return { llmContent: `Background command started.${pidMsg}${killHint}`, @@ -265,27 +265,42 @@ export class ShellToolInvocation extends BaseToolInvocation< const result = await resultPromise; - const backgroundPIDs: number[] = []; - if (os.platform() !== 'win32') { - if (fs.existsSync(tempFilePath)) { - const pgrepLines = fs - .readFileSync(tempFilePath, 'utf8') - .split(EOL) - .filter(Boolean); - for (const line of pgrepLines) { - if (!/^\d+$/.test(line)) { - debugLogger.warn(`pgrep: ${line}`); + if (shouldRunInBackground) { + // Read subprocess PIDs captured by the pgrep wrapper (non-Windows only) + const backgroundPIDs: number[] = []; + if (!isWindows) { + if (fs.existsSync(tempFilePath)) { + const pgrepLines = fs + .readFileSync(tempFilePath, 'utf8') + .split(EOL) + .filter(Boolean); + for (const line of pgrepLines) { + if (!/^\d+$/.test(line)) { + debugLogger.warn(`pgrep: ${line}`); + continue; + } + const bgPid = Number(line); + if (bgPid !== result.pid) { + backgroundPIDs.push(bgPid); + } } - const pid = Number(line); - if (pid !== result.pid) { - backgroundPIDs.push(pid); - } - } - } else { - if (!signal.aborted) { + } else if (!signal.aborted) { debugLogger.warn('missing pgrep output'); } } + + const bgPidMsg = + backgroundPIDs.length > 0 + ? ` PIDs: ${backgroundPIDs.join(', ')}` + : pid + ? ` PID: ${pid}` + : ''; + const killHint = ' (Use kill to stop)'; + + return { + llmContent: `Background command started.${bgPidMsg}${killHint}`, + returnDisplay: `Background command started.${bgPidMsg}${killHint}`, + }; } let llmContent = ''; @@ -327,9 +342,6 @@ export class ShellToolInvocation extends BaseToolInvocation< `Error: ${finalError}`, // Use the cleaned error string. `Exit Code: ${result.exitCode ?? '(none)'}`, `Signal: ${result.signal ?? '(none)'}`, - `Background PIDs: ${ - backgroundPIDs.length ? backgroundPIDs.join(', ') : '(none)' - }`, `Process Group PGID: ${result.pid ?? '(none)'}`, ].join('\n'); } diff --git a/packages/core/src/utils/debugLogger.test.ts b/packages/core/src/utils/debugLogger.test.ts index af7d04f48..8549359c0 100644 --- a/packages/core/src/utils/debugLogger.test.ts +++ b/packages/core/src/utils/debugLogger.test.ts @@ -13,6 +13,7 @@ import { type DebugLogSession, } from './debugLogger.js'; import { promises as fs } from 'node:fs'; +import path from 'node:path'; import { Storage } from '../config/storage.js'; vi.mock('node:fs', async (importOriginal) => { @@ -23,6 +24,9 @@ vi.mock('node:fs', async (importOriginal) => { ...actual.promises, mkdir: vi.fn().mockResolvedValue(undefined), appendFile: vi.fn().mockResolvedValue(undefined), + unlink: vi.fn().mockResolvedValue(undefined), + symlink: vi.fn().mockResolvedValue(undefined), + copyFile: vi.fn().mockResolvedValue(undefined), }, }; }); @@ -154,6 +158,7 @@ describe('debugLogger', () => { }); it('returns true when mkdir fails', async () => { + resetDebugLoggingState(); vi.mocked(fs.mkdir).mockRejectedValueOnce(new Error('Permission denied')); const logger = createDebugLogger(); @@ -196,6 +201,55 @@ describe('debugLogger', () => { }); }); + describe('latest debug log symlink', () => { + const expectedLatestPath = path.join(Storage.getGlobalDebugDir(), 'latest'); + + it('creates a symlink to the current session log file', async () => { + resetDebugLoggingState(); + setDebugLogSession(mockSession); + + await vi.runAllTimersAsync(); + + expect(fs.unlink).toHaveBeenCalledWith(expectedLatestPath); + expect(fs.symlink).toHaveBeenCalledWith( + 'test-session-123.txt', + expectedLatestPath, + ); + }); + + it('does not create symlink when session is cleared', async () => { + vi.clearAllMocks(); + resetDebugLoggingState(); + setDebugLogSession(null); + + await vi.runAllTimersAsync(); + + expect(fs.symlink).not.toHaveBeenCalled(); + }); + + it('does not fall back to copy when symlink fails', async () => { + resetDebugLoggingState(); + vi.mocked(fs.symlink).mockRejectedValueOnce(new Error('EPERM')); + + setDebugLogSession(mockSession); + + await vi.runAllTimersAsync(); + + expect(fs.copyFile).not.toHaveBeenCalled(); + }); + + it('does not create symlink when debug logging is disabled', async () => { + process.env['QWEN_DEBUG_LOG_FILE'] = '0'; + vi.clearAllMocks(); + resetDebugLoggingState(); + setDebugLogSession(mockSession); + + await vi.runAllTimersAsync(); + + expect(fs.symlink).not.toHaveBeenCalled(); + }); + }); + describe('resetDebugLoggingState', () => { it('resets the degraded state', async () => { vi.mocked(fs.appendFile).mockRejectedValueOnce(new Error('Disk full')); diff --git a/packages/core/src/utils/debugLogger.ts b/packages/core/src/utils/debugLogger.ts index 8c9e60eae..356028a2f 100644 --- a/packages/core/src/utils/debugLogger.ts +++ b/packages/core/src/utils/debugLogger.ts @@ -5,9 +5,11 @@ */ import { promises as fs } from 'node:fs'; +import path from 'node:path'; import { AsyncLocalStorage } from 'node:async_hooks'; import util from 'node:util'; import { Storage } from '../config/storage.js'; +import { updateSymlink } from './symlink.js'; type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR'; @@ -115,6 +117,23 @@ export function resetDebugLoggingState(): void { ensureDebugDirPromise = null; } +const DEBUG_LATEST_ALIAS = 'latest'; + +function updateLatestDebugLogAlias(sessionId: string): void { + if (!isDebugLogFileEnabled()) { + return; + } + + const aliasPath = path.join(Storage.getGlobalDebugDir(), DEBUG_LATEST_ALIAS); + const targetPath = Storage.getDebugLogPath(sessionId); + + void ensureDebugDirExists() + .then(() => updateSymlink(aliasPath, targetPath, { fallbackCopy: false })) + .catch(() => { + // Best-effort; don't degrade overall logging + }); +} + /** * Sets the process-wide debug log session used by createDebugLogger(). * @@ -125,6 +144,9 @@ export function setDebugLogSession( session: DebugLogSession | null | undefined, ) { globalSession = session ?? null; + if (session) { + updateLatestDebugLogAlias(session.getSessionId()); + } } /** diff --git a/packages/core/src/utils/symlink.ts b/packages/core/src/utils/symlink.ts new file mode 100644 index 000000000..d000d0103 --- /dev/null +++ b/packages/core/src/utils/symlink.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +export interface UpdateSymlinkOptions { + /** + * When true, falls back to copying the file if symlinks are not + * available (e.g. Windows without elevated privileges). + * Disable this for targets that keep changing after creation (like log + * files) where a one-time copy would be immediately stale. + * + * @default true + */ + fallbackCopy?: boolean; +} + +/** + * Create or replace a symlink at {@link linkPath} pointing to + * {@link targetPath}. + * + * The symlink uses a relative target so it stays valid even when the + * parent directory is moved. + * + * All errors are swallowed — the operation is strictly best-effort. + */ +export async function updateSymlink( + linkPath: string, + targetPath: string, + options?: UpdateSymlinkOptions, +): Promise { + const { fallbackCopy = true } = options ?? {}; + const linkDir = path.dirname(linkPath); + const relativeTarget = path.relative(linkDir, targetPath); + + try { + await fs.unlink(linkPath); + } catch { + // File doesn't exist, ignore + } + + try { + await fs.symlink(relativeTarget, linkPath); + return; + } catch { + // Symlink not supported, try fallback + } + + if (fallbackCopy) { + try { + await fs.copyFile(targetPath, linkPath); + } catch { + // Best-effort; swallow error + } + } +} diff --git a/test-scripts/heartbeat.sh b/test-scripts/heartbeat.sh new file mode 100755 index 000000000..bef77c44f --- /dev/null +++ b/test-scripts/heartbeat.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Heartbeat script for testing interactive shell PTY behavior +# - Emits a numbered line every second +# - Writes to both stdout and a log file for verification +# - Handles SIGTERM/SIGINT gracefully + +LOG="/tmp/heartbeat_test.log" +echo "started at $(date)" > "$LOG" + +cleanup() { + echo "received signal, shutting down" >> "$LOG" + echo "[heartbeat] shutting down" + exit 0 +} + +trap cleanup SIGTERM SIGINT + +i=1 +while true; do + echo "[heartbeat] tick $i ($(date +%H:%M:%S))" + echo "tick $i at $(date +%H:%M:%S)" >> "$LOG" + i=$((i + 1)) + sleep 1 +done diff --git a/test-scripts/progress.sh b/test-scripts/progress.sh new file mode 100755 index 000000000..48b95813c --- /dev/null +++ b/test-scripts/progress.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Progress bar script that overwrites the same line using \r +# Tests PTY's ability to handle carriage return / cursor movement + +total=20 +for ((i = 1; i <= total; i++)); do + pct=$((i * 100 / total)) + filled=$((pct / 5)) + empty=$((20 - filled)) + bar=$(printf '%0.s#' $(seq 1 $filled 2>/dev/null)) + space=$(printf '%0.s-' $(seq 1 $empty 2>/dev/null)) + printf "\r[%s%s] %3d%% (%d/%d)" "$bar" "$space" "$pct" "$i" "$total" + sleep 0.5 +done +echo "" +echo "Done!" diff --git a/test-scripts/rapid-output.sh b/test-scripts/rapid-output.sh new file mode 100755 index 000000000..4085ce712 --- /dev/null +++ b/test-scripts/rapid-output.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Rapid output script for testing render throttle behavior +# Outputs 200 lines as fast as possible, then pauses, then outputs more + +echo "=== Phase 1: Rapid burst (200 lines) ===" +for i in $(seq 1 200); do + echo "line $i: $(date +%H:%M:%S.%N)" +done + +echo "" +echo "=== Phase 2: Pause 2s ===" +sleep 2 + +echo "=== Phase 3: Rapid burst with progress overwrite ===" +for i in $(seq 1 100); do + printf "\rProcessing item %3d/100..." "$i" +done +echo "" +echo "=== Done ==="