diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 32d3aebe2..33847b6c0 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,6 @@ * @tanzhenxin @DennisYu07 @gwinthis @LaZzyMan @pomelo-nwu @Mingholy @DragonnZhang # SDK TypeScript package changes require review from Mingholy packages/sdk-typescript/** @Mingholy +# vscode-ide-companion and webui packages require review from yiliang114 +packages/vscode-ide-companion/** @yiliang114 +packages/webui/** @yiliang114 diff --git a/README.md b/README.md index ab598666c..8d7293137 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Qwen Code is an open-source AI agent for the terminal, optimized for [Qwen3-Code #### Linux / macOS ```bash -curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh | bash +bash -c "$(curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh)" ``` #### Windows (Run as Administrator CMD) diff --git a/package-lock.json b/package-lock.json index 6834e60eb..7231b7cf6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qwen-code/qwen-code", - "version": "0.12.1", + "version": "0.12.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qwen-code/qwen-code", - "version": "0.12.1", + "version": "0.12.2", "workspaces": [ "packages/*" ], @@ -22,7 +22,6 @@ "@types/mime-types": "^3.0.1", "@types/minimatch": "^5.1.2", "@types/mock-fs": "^4.13.4", - "@types/qrcode-terminal": "^0.12.2", "@types/shell-quote": "^1.7.5", "@types/uuid": "^10.0.0", "@vitest/coverage-v8": "^3.1.1", @@ -4538,13 +4537,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/qrcode-terminal": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/@types/qrcode-terminal/-/qrcode-terminal-0.12.2.tgz", - "integrity": "sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -14293,7 +14285,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -14711,14 +14702,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/qrcode-terminal": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz", - "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==", - "bin": { - "qrcode-terminal": "bin/qrcode-terminal.js" - } - }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", @@ -18800,7 +18783,7 @@ }, "packages/cli": { "name": "@qwen-code/qwen-code", - "version": "0.12.1", + "version": "0.12.2", "dependencies": { "@agentclientprotocol/sdk": "^0.14.1", "@google/genai": "1.30.0", @@ -18825,7 +18808,6 @@ "open": "^10.1.2", "p-limit": "^7.3.0", "prompts": "^2.4.2", - "qrcode-terminal": "^0.12.0", "react": "^19.1.0", "read-package-up": "^11.0.0", "shell-quote": "^1.8.3", @@ -19458,7 +19440,7 @@ }, "packages/core": { "name": "@qwen-code/qwen-code-core", - "version": "0.12.1", + "version": "0.12.2", "hasInstallScript": true, "dependencies": { "@anthropic-ai/sdk": "^0.36.1", @@ -22889,7 +22871,7 @@ }, "packages/test-utils": { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.12.1", + "version": "0.12.2", "dev": true, "license": "Apache-2.0", "devDependencies": { @@ -22901,7 +22883,7 @@ }, "packages/vscode-ide-companion": { "name": "qwen-code-vscode-ide-companion", - "version": "0.12.1", + "version": "0.12.2", "license": "LICENSE", "dependencies": { "@agentclientprotocol/sdk": "^0.14.1", @@ -23149,7 +23131,7 @@ }, "packages/web-templates": { "name": "@qwen-code/web-templates", - "version": "0.12.1", + "version": "0.12.2", "devDependencies": { "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", @@ -23677,7 +23659,7 @@ }, "packages/webui": { "name": "@qwen-code/webui", - "version": "0.12.1", + "version": "0.12.2", "license": "MIT", "dependencies": { "markdown-it": "^14.1.0" diff --git a/package.json b/package.json index 001b2deda..06c66074b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.12.1", + "version": "0.12.2", "engines": { "node": ">=20.0.0" }, @@ -13,7 +13,7 @@ "url": "git+https://github.com/QwenLM/qwen-code.git" }, "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.12.1" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.12.2" }, "scripts": { "start": "cross-env node scripts/start.js", @@ -80,7 +80,6 @@ "@types/mime-types": "^3.0.1", "@types/minimatch": "^5.1.2", "@types/mock-fs": "^4.13.4", - "@types/qrcode-terminal": "^0.12.2", "@types/shell-quote": "^1.7.5", "@types/uuid": "^10.0.0", "@vitest/coverage-v8": "^3.1.1", diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 8e9912f10..3b00b9546 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -9,8 +9,30 @@ import './src/gemini.js'; import { main } from './src/gemini.js'; import { FatalError } from '@qwen-code/qwen-code-core'; +import { writeStderrLine } from './src/utils/stdioHelpers.js'; // --- Global Entry Point --- + +// Suppress known race condition in @lydell/node-pty on Windows where a +// deferred resize fires after the pty process has already exited. +// Tracking bug: https://github.com/microsoft/node-pty/issues/827 +process.on('uncaughtException', (error) => { + if ( + process.platform === 'win32' && + error instanceof Error && + error.message === 'Cannot resize a pty that has already exited' + ) { + return; + } + + if (error instanceof Error) { + writeStderrLine(error.stack ?? error.message); + } else { + writeStderrLine(String(error)); + } + process.exit(1); +}); + main().catch((error) => { if (error instanceof FatalError) { let errorMessage = error.message; diff --git a/packages/cli/package.json b/packages/cli/package.json index 11fdb8d96..743fccd94 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.12.1", + "version": "0.12.2", "description": "Qwen Code", "repository": { "type": "git", @@ -33,7 +33,7 @@ "dist" ], "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.12.1" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.12.2" }, "dependencies": { "@agentclientprotocol/sdk": "^0.14.1", @@ -59,7 +59,6 @@ "open": "^10.1.2", "p-limit": "^7.3.0", "prompts": "^2.4.2", - "qrcode-terminal": "^0.12.0", "react": "^19.1.0", "read-package-up": "^11.0.0", "shell-quote": "^1.8.3", diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 9a007a68f..455411096 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -745,6 +745,15 @@ export default { "Authentifizierung mit MCP-Server '{{name}}' fehlgeschlagen: {{error}}", "Re-discovering tools from '{{name}}'...": "Werkzeuge von '{{name}}' werden neu erkannt...", + "Discovered {{count}} tool(s) from '{{name}}'.": + "{{count}} Werkzeug(e) von '{{name}}' entdeckt.", + 'Authentication complete. Returning to server details...': + 'Authentifizierung abgeschlossen. Zurück zu den Serverdetails...', + 'Authentication successful.': 'Authentifizierung erfolgreich.', + 'If the browser does not open, copy and paste this URL into your browser:': + 'Falls der Browser sich nicht öffnet, kopieren Sie diese URL und fügen Sie sie in Ihren Browser ein:', + 'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.': + '⚠️ Stellen Sie sicher, dass Sie die VOLLSTÄNDIGE URL kopieren – sie kann über mehrere Zeilen gehen.', // ============================================================================ // Commands - Chat @@ -916,6 +925,8 @@ export default { Disable: 'Deaktivieren', Enable: 'Aktivieren', Authenticate: 'Authentifizieren', + 'Re-authenticate': 'Erneut authentifizieren', + 'Clear Authentication': 'Authentifizierung löschen', disabled: 'deaktiviert', 'Server:': 'Server:', Reconnect: 'Neu verbinden', diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 768506c06..cbaee7889 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -811,6 +811,15 @@ export default { "Failed to authenticate with MCP server '{{name}}': {{error}}", "Re-discovering tools from '{{name}}'...": "Re-discovering tools from '{{name}}'...", + "Discovered {{count}} tool(s) from '{{name}}'.": + "Discovered {{count}} tool(s) from '{{name}}'.", + 'Authentication complete. Returning to server details...': + 'Authentication complete. Returning to server details...', + 'Authentication successful.': 'Authentication successful.', + 'If the browser does not open, copy and paste this URL into your browser:': + 'If the browser does not open, copy and paste this URL into your browser:', + 'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.': + 'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.', // ============================================================================ // MCP Management Dialog @@ -843,6 +852,8 @@ export default { Enable: 'Enable', Disable: 'Disable', Authenticate: 'Authenticate', + 'Re-authenticate': 'Re-authenticate', + 'Clear Authentication': 'Clear Authentication', 'Server:': 'Server:', 'Command:': 'Command:', 'Working Directory:': 'Working Directory:', diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 3a1bf21c6..02898e8bc 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -507,6 +507,15 @@ export default { "MCPサーバー '{{name}}' での認証に失敗: {{error}}", "Re-discovering tools from '{{name}}'...": "'{{name}}' からツールを再検出中...", + "Discovered {{count}} tool(s) from '{{name}}'.": + "'{{name}}' から {{count}} 個のツールを検出しました。", + 'Authentication complete. Returning to server details...': + '認証完了。サーバー詳細に戻ります...', + 'Authentication successful.': '認証成功。', + 'If the browser does not open, copy and paste this URL into your browser:': + 'ブラウザが開かない場合は、このURLをコピーしてブラウザに貼り付けてください:', + 'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.': + '⚠️ URL全体をコピーしてください——複数行にまたがる場合があります。', 'Configured MCP servers:': '設定済みMCPサーバー:', Ready: '準備完了', Disconnected: '切断', @@ -655,6 +664,8 @@ export default { Disable: '無効化', Enable: '有効化', Authenticate: '認証', + 'Re-authenticate': '再認証', + 'Clear Authentication': '認証をクリア', disabled: '無効', 'Server:': 'サーバー:', Reconnect: '再接続', diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 37efeda6f..7492fe5f4 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -751,6 +751,15 @@ export default { "Falha ao autenticar com o servidor MCP '{{name}}': {{error}}", "Re-discovering tools from '{{name}}'...": "Redescobrindo ferramentas de '{{name}}'...", + "Discovered {{count}} tool(s) from '{{name}}'.": + "{{count}} ferramenta(s) descoberta(s) de '{{name}}'.", + 'Authentication complete. Returning to server details...': + 'Autenticação concluída. Retornando aos detalhes do servidor...', + 'Authentication successful.': 'Autenticação bem-sucedida.', + 'If the browser does not open, copy and paste this URL into your browser:': + 'Se o navegador não abrir, copie e cole esta URL no seu navegador:', + 'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.': + '⚠️ Certifique-se de copiar a URL COMPLETA – ela pode ocupar várias linhas.', // ============================================================================ // Commands - Chat @@ -922,6 +931,8 @@ export default { Disable: 'Desativar', Enable: 'Ativar', Authenticate: 'Autenticar', + 'Re-authenticate': 'Reautenticar', + 'Clear Authentication': 'Limpar autenticação', disabled: 'desativado', 'Server:': 'Servidor:', Reconnect: 'Reconectar', diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index eaecb4228..66cf2791d 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -754,6 +754,15 @@ export default { "Не удалось авторизоваться на MCP-сервере '{{name}}': {{error}}", "Re-discovering tools from '{{name}}'...": "Повторное обнаружение инструментов от '{{name}}'...", + "Discovered {{count}} tool(s) from '{{name}}'.": + "Обнаружено {{count}} инструмент(ов) от '{{name}}'.", + 'Authentication complete. Returning to server details...': + 'Аутентификация завершена. Возврат к деталям сервера...', + 'Authentication successful.': 'Аутентификация успешна.', + 'If the browser does not open, copy and paste this URL into your browser:': + 'Если браузер не открылся, скопируйте этот URL и вставьте его в браузер:', + 'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.': + '⚠️ Убедитесь, что скопировали ПОЛНЫЙ URL — он может занимать несколько строк.', // ============================================================================ // Команды - Чат @@ -900,6 +909,8 @@ export default { Disable: 'Отключить', Enable: 'Включить', Authenticate: 'Аутентификация', + 'Re-authenticate': 'Повторная аутентификация', + 'Clear Authentication': 'Очистить аутентификацию', disabled: 'отключен', 'Server:': 'Сервер:', Reconnect: 'Переподключить', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index d6f6b2ead..b1086a72d 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -763,6 +763,15 @@ export default { "认证 MCP 服务器 '{{name}}' 失败:{{error}}", "Re-discovering tools from '{{name}}'...": "正在重新发现 '{{name}}' 的工具...", + "Discovered {{count}} tool(s) from '{{name}}'.": + "从 '{{name}}' 发现了 {{count}} 个工具。", + 'Authentication complete. Returning to server details...': + '认证完成,正在返回服务器详情...', + 'Authentication successful.': '认证成功。', + 'If the browser does not open, copy and paste this URL into your browser:': + '如果浏览器未自动打开,请复制以下 URL 并粘贴到浏览器中:', + 'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.': + '⚠️ 请确保复制完整的 URL —— 它可能跨越多行。', // ============================================================================ // MCP Management Dialog @@ -793,6 +802,8 @@ export default { Enable: '启用', Disable: '禁用', Authenticate: '认证', + 'Re-authenticate': '重新认证', + 'Clear Authentication': '清空认证', disabled: '已禁用', 'Server:': '服务器:', '(disabled)': '(已禁用)', diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index 309e77adf..4469a0759 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -345,7 +345,7 @@ export function AuthDialog(): React.JSX.Element { return ( ({ - loadLastSession: vi.fn(), + loadSession: vi.fn(), })); vi.mock('@qwen-code/qwen-code-core', () => { class SessionService { constructor(_cwd: string) {} - async loadLastSession() { - return mockSessionServiceMocks.loadLastSession(); + async loadSession(_sessionId: string) { + return mockSessionServiceMocks.loadSession(); } } @@ -68,13 +68,14 @@ describe('exportCommand', () => { beforeEach(() => { vi.clearAllMocks(); - mockSessionServiceMocks.loadLastSession.mockResolvedValue(mockSessionData); + mockSessionServiceMocks.loadSession.mockResolvedValue(mockSessionData); mockContext = createMockCommandContext({ services: { config: { getWorkingDir: vi.fn().mockReturnValue('/test/dir'), getProjectRoot: vi.fn().mockReturnValue('/test/project'), + getSessionId: vi.fn().mockReturnValue('test-session-id'), }, }, }); @@ -132,7 +133,7 @@ describe('exportCommand', () => { content: expect.stringContaining('export-2025-01-01T00-00-00-000Z.md'), }); - expect(mockSessionServiceMocks.loadLastSession).toHaveBeenCalled(); + expect(mockSessionServiceMocks.loadSession).toHaveBeenCalled(); expect(collectSessionData).toHaveBeenCalledWith( mockSessionData.conversation, expect.anything(), @@ -191,7 +192,7 @@ describe('exportCommand', () => { }); it('should return error when no session is found', async () => { - mockSessionServiceMocks.loadLastSession.mockResolvedValue(undefined); + mockSessionServiceMocks.loadSession.mockResolvedValue(undefined); const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md'); if (!mdCommand?.action) { @@ -260,7 +261,7 @@ describe('exportCommand', () => { ), }); - expect(mockSessionServiceMocks.loadLastSession).toHaveBeenCalled(); + expect(mockSessionServiceMocks.loadSession).toHaveBeenCalled(); expect(collectSessionData).toHaveBeenCalledWith( mockSessionData.conversation, expect.anything(), @@ -323,7 +324,7 @@ describe('exportCommand', () => { }); it('should return error when no session is found', async () => { - mockSessionServiceMocks.loadLastSession.mockResolvedValue(undefined); + mockSessionServiceMocks.loadSession.mockResolvedValue(undefined); const htmlCommand = exportCommand.subCommands?.find( (c) => c.name === 'html', diff --git a/packages/cli/src/ui/commands/exportCommand.ts b/packages/cli/src/ui/commands/exportCommand.ts index 42af225ac..8edec9f4d 100644 --- a/packages/cli/src/ui/commands/exportCommand.ts +++ b/packages/cli/src/ui/commands/exportCommand.ts @@ -50,9 +50,10 @@ async function exportMarkdownAction( } try { - // Load the current session + // Load the current session using the current session ID const sessionService = new SessionService(cwd); - const sessionData = await sessionService.loadLastSession(); + const sessionId = config.getSessionId(); + const sessionData = await sessionService.loadSession(sessionId); if (!sessionData) { return { @@ -122,9 +123,10 @@ async function exportHtmlAction( } try { - // Load the current session + // Load the current session using the current session ID const sessionService = new SessionService(cwd); - const sessionData = await sessionService.loadLastSession(); + const sessionId = config.getSessionId(); + const sessionData = await sessionService.loadSession(sessionId); if (!sessionData) { return { @@ -194,9 +196,10 @@ async function exportJsonAction( } try { - // Load the current session + // Load the current session using the current session ID const sessionService = new SessionService(cwd); - const sessionData = await sessionService.loadLastSession(); + const sessionId = config.getSessionId(); + const sessionData = await sessionService.loadSession(sessionId); if (!sessionData) { return { @@ -266,9 +269,10 @@ async function exportJsonlAction( } try { - // Load the current session + // Load the current session using the current session ID const sessionService = new SessionService(cwd); - const sessionData = await sessionService.loadLastSession(); + const sessionId = config.getSessionId(); + const sessionData = await sessionService.loadSession(sessionId); if (!sessionData) { return { diff --git a/packages/cli/src/ui/components/Header.test.tsx b/packages/cli/src/ui/components/Header.test.tsx index 99bb053da..72da62aba 100644 --- a/packages/cli/src/ui/components/Header.test.tsx +++ b/packages/cli/src/ui/components/Header.test.tsx @@ -78,7 +78,7 @@ describe('
', () => { it('renders with border around info panel', () => { const { lastFrame } = render(
); - expect(lastFrame()).toContain('╭'); - expect(lastFrame()).toContain('╯'); + expect(lastFrame()).toContain('┌'); + expect(lastFrame()).toContain('┐'); }); }); diff --git a/packages/cli/src/ui/components/Header.tsx b/packages/cli/src/ui/components/Header.tsx index 45fce4385..2d919385f 100644 --- a/packages/cli/src/ui/components/Header.tsx +++ b/packages/cli/src/ui/components/Header.tsx @@ -128,7 +128,7 @@ export const Header: React.FC = ({ {/* Right side: Info panel (flexible width, max 60 in two-column layout) */} ({ useKeypress: vi.fn(), })); -// Mock qrcode-terminal module -vi.mock('qrcode-terminal', () => ({ - default: { - generate: vi.fn(), - }, -})); - -// Mock ink-spinner -vi.mock('ink-spinner', () => ({ - default: ({ type }: { type: string }) => `MockSpinner(${type})`, -})); - // Mock ink-link vi.mock('ink-link', () => ({ default: ({ children }: { children: React.ReactNode; url: string }) => @@ -95,19 +83,17 @@ describe('QwenOAuthProgress', () => { const { lastFrame } = renderComponent(); const output = lastFrame(); - expect(output).toContain('MockSpinner(dots)'); expect(output).toContain('Waiting for Qwen OAuth authentication...'); - expect(output).toContain('(Press ESC or CTRL+C to cancel)'); + expect(output).toContain('Esc to cancel'); }); - it('should render loading state with gray border', () => { + it('should render loading state with single border', () => { const { lastFrame } = renderComponent(); const output = lastFrame(); - // Should not contain auth flow elements - expect(output).not.toContain('Qwen OAuth Authentication'); - expect(output).not.toContain('Please visit this URL to authorize:'); - // Loading state still shows time remaining with default timeout + // Should contain the auth title even in loading state + expect(output).toContain('Qwen OAuth Authentication'); + // Loading state shows time remaining with default timeout expect(output).toContain('Time remaining:'); }); }); @@ -117,44 +103,20 @@ describe('QwenOAuthProgress', () => { const { lastFrame } = renderComponent({ deviceAuth: mockDeviceAuth }); const output = lastFrame(); - // Initially no QR code shown until it's generated, but the status area should be visible - expect(output).toContain('MockSpinner(dots)'); expect(output).toContain('Waiting for authorization'); expect(output).toContain('Time remaining: 5:00'); - expect(output).toContain('(Press ESC or CTRL+C to cancel)'); + expect(output).toContain('Esc to cancel'); }); - it('should display correct URL in Static component when QR code is generated', async () => { - const qrcode = await import('qrcode-terminal'); - const mockGenerate = vi.mocked(qrcode.default.generate); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let qrCallback: any = null; - mockGenerate.mockImplementation((url, options, callback) => { - qrCallback = callback; - }); - + it('should display correct URL in auth URL display', () => { const customAuth = createMockDeviceAuth({ verification_uri_complete: 'https://custom.com/auth?code=XYZ789', }); - const { lastFrame, rerender } = renderComponent({ + const { lastFrame } = renderComponent({ deviceAuth: customAuth, }); - // Manually trigger the QR code callback - if (qrCallback && typeof qrCallback === 'function') { - qrCallback('Mock QR Code Data'); - } - - rerender( - , - ); - expect(lastFrame()).toContain('https://custom.com/auth?code=XYZ789'); }); @@ -282,10 +244,11 @@ describe('QwenOAuthProgress', () => { />, ); - // Initial state should have no dots - expect(lastFrame()).toContain('Waiting for authorization'); + // Initial state should show '...' (default value) + const initialOutput = lastFrame(); + expect(initialOutput).toContain('Waiting for authorization'); - // Advance by 500ms to add first dot + // Advance by 500ms to cycle animation vi.advanceTimersByTime(500); rerender( { deviceAuth={mockDeviceAuth} />, ); - expect(lastFrame()).toContain('Waiting for authorization.'); + const after500ms = lastFrame(); + expect(after500ms).toContain('Waiting for authorization'); - // Advance by another 500ms to add second dot + // Advance by another 500ms to continue animation vi.advanceTimersByTime(500); rerender( { deviceAuth={mockDeviceAuth} />, ); - expect(lastFrame()).toContain('Waiting for authorization..'); + const after1000ms = lastFrame(); + expect(after1000ms).toContain('Waiting for authorization'); - // Advance by another 500ms to add third dot + // Advance by another 500ms to complete cycle vi.advanceTimersByTime(500); rerender( { deviceAuth={mockDeviceAuth} />, ); - expect(lastFrame()).toContain('Waiting for authorization...'); - - // Advance by another 500ms to reset dots - vi.advanceTimersByTime(500); - rerender( - , - ); - expect(lastFrame()).toContain('Waiting for authorization'); - }); - }); - - describe('QR Code functionality', () => { - it('should generate QR code when deviceAuth is provided', async () => { - const qrcode = await import('qrcode-terminal'); - const mockGenerate = vi.mocked(qrcode.default.generate); - - mockGenerate.mockImplementation((url, options, callback) => { - callback!('Mock QR Code Data'); - }); - - render( - , - ); - - expect(mockGenerate).toHaveBeenCalledWith( - mockDeviceAuth.verification_uri_complete, - { small: true }, - expect.any(Function), - ); - }); - - it('should display QR code in Static component when available', async () => { - const qrcode = await import('qrcode-terminal'); - const mockGenerate = vi.mocked(qrcode.default.generate); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let qrCallback: any = null; - mockGenerate.mockImplementation((url, options, callback) => { - qrCallback = callback; - }); - - const { lastFrame, rerender } = render( - , - ); - - // Manually trigger the QR code callback - if (qrCallback && typeof qrCallback === 'function') { - qrCallback('Mock QR Code Data'); - } - - rerender( - , - ); - - const output = lastFrame(); - expect(output).toContain('Or scan the QR code below:'); - expect(output).toContain('Mock QR Code Data'); - }); - - it('should handle QR code generation errors gracefully', async () => { - const qrcode = await import('qrcode-terminal'); - const mockGenerate = vi.mocked(qrcode.default.generate); - mockGenerate.mockImplementation(() => { - throw new Error('QR Code generation failed'); - }); - - const { lastFrame } = render( - , - ); - - // Should not crash and should not show QR code section since QR generation failed - const output = lastFrame(); - expect(output).not.toContain('Or scan the QR code below:'); - }); - - it('should not generate QR code when deviceAuth is null', async () => { - const qrcode = await import('qrcode-terminal'); - const mockGenerate = vi.mocked(qrcode.default.generate); - - render( - , - ); - - expect(mockGenerate).not.toHaveBeenCalled(); + const after1500ms = lastFrame(); + expect(after1500ms).toContain('Waiting for authorization'); }); }); diff --git a/packages/cli/src/ui/components/QwenOAuthProgress.tsx b/packages/cli/src/ui/components/QwenOAuthProgress.tsx index 69d42818d..7655e7915 100644 --- a/packages/cli/src/ui/components/QwenOAuthProgress.tsx +++ b/packages/cli/src/ui/components/QwenOAuthProgress.tsx @@ -5,14 +5,11 @@ */ import type React from 'react'; -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect } from 'react'; import { Box, Text } from 'ink'; -import Spinner from 'ink-spinner'; import Link from 'ink-link'; -import qrcode from 'qrcode-terminal'; -import { Colors } from '../colors.js'; +import { theme } from '../semantic-colors.js'; import type { DeviceAuthorizationData } from '@qwen-code/qwen-code-core'; -import { createDebugLogger } from '@qwen-code/qwen-code-core'; import { useKeypress } from '../hooks/useKeypress.js'; import { t } from '../../i18n/index.js'; @@ -30,98 +27,10 @@ interface QwenOAuthProgressProps { authMessage?: string | null; } -const debugLogger = createDebugLogger('QWEN_OAUTH_PROGRESS'); - -/** - * Static QR Code Display Component - * Renders the QR code and URL once and doesn't re-render unless the URL changes - */ -function QrCodeDisplay({ - verificationUrl, - qrCodeData, -}: { - verificationUrl: string; - qrCodeData: string | null; -}): React.JSX.Element | null { - if (!qrCodeData) { - return null; - } - - return ( - - - {t('Qwen OAuth Authentication')} - - - - {t('Please visit this URL to authorize:')} - - - - - {verificationUrl} - - - - - {t('Or scan the QR code below:')} - - - - {qrCodeData} - - - ); -} - -/** - * Dynamic Status Display Component - * Shows the loading spinner, timer, and status messages - */ -function StatusDisplay({ - timeRemaining, - dots, -}: { - timeRemaining: number; - dots: string; -}): React.JSX.Element { - const formatTime = (seconds: number): string => { - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`; - }; - - return ( - - - - {t('Waiting for authorization')} - {dots} - - - - - - {t('Time remaining:')} {formatTime(timeRemaining)} - - - {t('(Press ESC or CTRL+C to cancel)')} - - - - ); +function formatTime(seconds: number): string { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`; } export function QwenOAuthProgress({ @@ -133,13 +42,11 @@ export function QwenOAuthProgress({ }: QwenOAuthProgressProps): React.JSX.Element { const defaultTimeout = deviceAuth?.expires_in || 300; // Default 5 minutes const [timeRemaining, setTimeRemaining] = useState(defaultTimeout); - const [dots, setDots] = useState(''); - const [qrCodeData, setQrCodeData] = useState(null); + const [dots, setDots] = useState('...'); useKeypress( (key) => { if (authStatus === 'timeout' || authStatus === 'error') { - // Any key press in timeout or error state should trigger cancel to return to auth dialog onCancel(); } else if (key.name === 'escape' || (key.ctrl && key.name === 'c')) { onCancel(); @@ -148,30 +55,6 @@ export function QwenOAuthProgress({ { isActive: true }, ); - // Generate QR code once when device auth is available - useEffect(() => { - if (!deviceAuth?.verification_uri_complete) { - return; - } - - const generateQR = () => { - try { - qrcode.generate( - deviceAuth.verification_uri_complete, - { small: true }, - (qrcode: string) => { - setQrCodeData(qrcode); - }, - ); - } catch (error) { - debugLogger.error('Failed to generate QR code:', error); - setQrCodeData(null); - } - }; - - generateQR(); - }, [deviceAuth?.verification_uri_complete]); - // Countdown timer useEffect(() => { const timer = setInterval(() => { @@ -187,41 +70,29 @@ export function QwenOAuthProgress({ return () => clearInterval(timer); }, [onTimeout]); - // Animated dots + // Animated dots — cycle through fixed-width patterns to avoid layout shift useEffect(() => { + const dotFrames = ['. ', '.. ', '...']; + let frameIndex = 0; const dotsTimer = setInterval(() => { - setDots((prev) => { - if (prev.length >= 3) return ''; - return prev + '.'; - }); + frameIndex = (frameIndex + 1) % dotFrames.length; + setDots(dotFrames[frameIndex]!); }, 500); return () => clearInterval(dotsTimer); }, []); - // Memoize the QR code display to prevent unnecessary re-renders - const qrCodeDisplay = useMemo(() => { - if (!deviceAuth?.verification_uri_complete) return null; - - return ( - - ); - }, [deviceAuth?.verification_uri_complete, qrCodeData]); - // Handle timeout state if (authStatus === 'timeout') { return ( - + {t('Qwen OAuth Authentication Timeout')} @@ -238,7 +109,7 @@ export function QwenOAuthProgress({ - + {t('Press any key to return to authentication type selection.')} @@ -249,26 +120,26 @@ export function QwenOAuthProgress({ if (authStatus === 'error') { return ( - - Qwen OAuth Authentication Error + + {t('Qwen OAuth Authentication Error')} {authMessage || - 'An error occurred during authentication. Please try again.'} + t('An error occurred during authentication. Please try again.')} - - Press any key to return to authentication type selection. + + {t('Press any key to return to authentication type selection.')} @@ -279,38 +150,61 @@ export function QwenOAuthProgress({ if (!deviceAuth) { return ( - + {t('Qwen OAuth Authentication')} + + + {t('Waiting for Qwen OAuth authentication...')} - - {t('Waiting for Qwen OAuth authentication...')} + {t('Time remaining:')} {formatTime(timeRemaining)} - - - {t('Time remaining:')} {Math.floor(timeRemaining / 60)}: - {(timeRemaining % 60).toString().padStart(2, '0')} - - - {t('(Press ESC or CTRL+C to cancel)')} - + + + {t('Esc to cancel')} ); } return ( - - {/* Static QR Code Display */} - {qrCodeDisplay} + + {t('Qwen OAuth Authentication')} - {/* Dynamic Status Display */} - + + {t('Please visit this URL to authorize:')} + + + + + {deviceAuth.verification_uri_complete} + + + + + + {t('Waiting for authorization')} + {dots} + + + {t('Time remaining:')} {formatTime(timeRemaining)} + + + + + {t('Esc to cancel')} + ); } diff --git a/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx b/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx index ce84814a7..94910fd72 100644 --- a/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx +++ b/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx @@ -25,6 +25,7 @@ import { useConfig } from '../../contexts/ConfigContext.js'; import { getMCPServerStatus, DiscoveredMCPTool, + MCPOAuthTokenStorage, type MCPServerConfig, type AnyDeclarativeTool, type DiscoveredMCPPrompt, @@ -109,6 +110,16 @@ export const MCPManagementDialog: React.FC = ({ (t) => !t.name || !t.description, ).length; + // Check if OAuth tokens exist for this server + let hasOAuthTokens = false; + try { + const tokenStorage = new MCPOAuthTokenStorage(); + const credentials = await tokenStorage.getCredentials(name); + hasOAuthTokens = credentials !== null; + } catch { + // Ignore errors when checking token existence + } + serverInfos.push({ name, status, @@ -118,6 +129,7 @@ export const MCPManagementDialog: React.FC = ({ invalidToolCount, promptCount: serverPrompts.length, isDisabled, + hasOAuthTokens, }); } @@ -249,6 +261,36 @@ export const MCPManagementDialog: React.FC = ({ } }, [fetchServerData]); + // Clear OAuth authentication tokens and disconnect the server + const handleClearAuth = useCallback(async () => { + if (!config || !selectedServer) return; + + try { + setIsLoading(true); + const tokenStorage = new MCPOAuthTokenStorage(); + await tokenStorage.deleteCredentials(selectedServer.name); + debugLogger.info( + `Cleared OAuth tokens for server '${selectedServer.name}'`, + ); + + // Disconnect the server so it no longer appears as connected + const toolRegistry = config.getToolRegistry(); + if (toolRegistry) { + await toolRegistry.disconnectServer(selectedServer.name); + } + + // Reload to update hasOAuthTokens flag and server status + await reloadServers(); + } catch (error) { + debugLogger.error( + `Error clearing OAuth tokens for server '${selectedServer.name}':`, + error, + ); + } finally { + setIsLoading(false); + } + }, [config, selectedServer, reloadServers]); + // Reconnect server const handleReconnect = useCallback(async () => { if (!config || !selectedServer) return; @@ -537,6 +579,7 @@ export const MCPManagementDialog: React.FC = ({ onReconnect={handleReconnect} onDisable={handleDisable} onAuthenticate={handleAuthenticate} + onClearAuth={handleClearAuth} onBack={handleNavigateBack} /> ); @@ -569,10 +612,10 @@ export const MCPManagementDialog: React.FC = ({ return ( { + onBack={() => { + handleNavigateBack(); void reloadServers(); }} - onBack={handleNavigateBack} /> ); @@ -594,6 +637,7 @@ export const MCPManagementDialog: React.FC = ({ handleReconnect, handleDisable, handleAuthenticate, + handleClearAuth, handleNavigateBack, handleSelectTool, handleSelectDisableScope, diff --git a/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx b/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx index e4d4e373a..6e0011a77 100644 --- a/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx @@ -16,13 +16,15 @@ import { MCPOAuthTokenStorage, getErrorMessage, } from '@qwen-code/qwen-code-core'; +import type { OAuthDisplayPayload } from '@qwen-code/qwen-code-core'; import { appEvents, AppEvent } from '../../../../utils/events.js'; type AuthState = 'idle' | 'authenticating' | 'success' | 'error'; +const AUTO_BACK_DELAY_MS = 2000; + export const AuthenticateStep: React.FC = ({ server, - onSuccess, onBack, }) => { const config = useConfig(); @@ -39,9 +41,12 @@ export const AuthenticateStep: React.FC = ({ setMessages([]); setErrorMessage(null); - // Listen for OAuth display messages (same as mcpCommand.ts) - const displayListener = (message: string) => { - setMessages((prev) => [...prev, message]); + // Listen for OAuth display messages - supports both plain strings and + // structured i18n messages ({ key, params }) emitted by the core layer. + const displayListener = (message: OAuthDisplayPayload) => { + const text = + typeof message === 'string' ? message : t(message.key, message.params); + setMessages((prev) => [...prev, text]); }; appEvents.on(AppEvent.OauthDisplayMessage, displayListener); @@ -83,6 +88,16 @@ export const AuthenticateStep: React.FC = ({ }), ]); await toolRegistry.discoverToolsForServer(server.name); + + // Show discovered tool count + const discoveredTools = toolRegistry.getToolsByServer(server.name); + setMessages((prev) => [ + ...prev, + t("Discovered {{count}} tool(s) from '{{name}}'.", { + count: String(discoveredTools.length), + name: server.name, + }), + ]); } // Update the client with the new tools @@ -91,8 +106,12 @@ export const AuthenticateStep: React.FC = ({ await geminiClient.setTools(); } + setMessages((prev) => [ + ...prev, + t('Authentication complete. Returning to server details...'), + ]); + setAuthState('success'); - onSuccess?.(); } catch (error) { setErrorMessage(getErrorMessage(error)); setAuthState('error'); @@ -100,13 +119,22 @@ export const AuthenticateStep: React.FC = ({ isRunning.current = false; appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener); } - }, [server, config, onSuccess]); + }, [server, config]); useEffect(() => { runAuthentication(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Auto-navigate back after authentication succeeds + useEffect(() => { + if (authState !== 'success') return; + const timer = setTimeout(() => { + onBack(); + }, AUTO_BACK_DELAY_MS); + return () => clearTimeout(timer); + }, [authState, onBack]); + useKeypress( (key) => { if (key.name === 'escape') { @@ -158,6 +186,11 @@ export const AuthenticateStep: React.FC = ({ {t('Authenticating... Please complete the login in your browser.')} )} + {authState === 'success' && ( + + {t('Authentication successful.')} + + )} ); diff --git a/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx b/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx index a4463476f..3718f5e87 100644 --- a/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx @@ -24,7 +24,8 @@ type ServerAction = | 'view-tools' | 'reconnect' | 'toggle-disable' - | 'authenticate'; + | 'authenticate' + | 'clear-auth'; export const ServerDetailStep: React.FC = ({ server, @@ -32,6 +33,7 @@ export const ServerDetailStep: React.FC = ({ onReconnect, onDisable, onAuthenticate, + onClearAuth, onBack, }) => { const statusColor = server @@ -77,15 +79,24 @@ export const ServerDetailStep: React.FC = ({ value: 'toggle-disable', }); - // 待补充准确的认证判断方案,暂时全部开放 + // 已认证的服务器显示"重新认证",未认证的显示"认证" if (!server.isDisabled) { result.push({ key: 'authenticate', - label: t('Authenticate'), + label: server.hasOAuthTokens ? t('Re-authenticate') : t('Authenticate'), value: 'authenticate', }); } + // 只在存储有 OAuth 认证信息时显示“清空认证”选项 + if (!server.isDisabled && server.hasOAuthTokens) { + result.push({ + key: 'clear-auth', + label: t('Clear Authentication'), + value: 'clear-auth', + }); + } + return result; }, [server]); @@ -222,6 +233,9 @@ export const ServerDetailStep: React.FC = ({ case 'authenticate': onAuthenticate?.(); break; + case 'clear-auth': + onClearAuth?.(); + break; default: break; } diff --git a/packages/cli/src/ui/components/mcp/types.ts b/packages/cli/src/ui/components/mcp/types.ts index 8812c5f12..82d9ab7ba 100644 --- a/packages/cli/src/ui/components/mcp/types.ts +++ b/packages/cli/src/ui/components/mcp/types.ts @@ -48,6 +48,8 @@ export interface MCPServerDisplayInfo { errorMessage?: string; /** 是否被禁用(在排除列表中) */ isDisabled: boolean; + /** 是否存储有 OAuth 认证信息 */ + hasOAuthTokens?: boolean; } /** @@ -132,6 +134,8 @@ export interface ServerDetailStepProps { onDisable?: () => void; /** OAuth 认证回调 */ onAuthenticate?: () => void; + /** 清空认证信息回调 */ + onClearAuth?: () => void; /** 返回回调 */ onBack: () => void; } @@ -178,8 +182,6 @@ export interface ToolDetailStepProps { export interface AuthenticateStepProps { /** 服务器信息 */ server: MCPServerDisplayInfo | null; - /** 认证成功回调 */ - onSuccess?: () => void; /** 返回回调 */ onBack: () => void; } diff --git a/packages/cli/src/ui/components/messages/AskUserQuestionDialog.test.tsx b/packages/cli/src/ui/components/messages/AskUserQuestionDialog.test.tsx index a88b1bb4a..2addc296d 100644 --- a/packages/cli/src/ui/components/messages/AskUserQuestionDialog.test.tsx +++ b/packages/cli/src/ui/components/messages/AskUserQuestionDialog.test.tsx @@ -303,40 +303,6 @@ describe('', () => { }); describe('multiple questions', () => { - it('navigates between tabs with left/right arrows', async () => { - const onConfirm = vi.fn(); - const details = createConfirmationDetails({ - questions: [ - createSingleQuestion({ header: 'Q1' }), - createSingleQuestion({ - header: 'Q2', - question: 'Second question?', - }), - ], - }); - - const { stdin, lastFrame, unmount } = renderWithProviders( - , - ); - await wait(); - - // Navigate right to Q2 - stdin.write('\u001B[C'); // Right arrow - await wait(); - - expect(lastFrame()).toContain('Second question?'); - - // Navigate left back to Q1 - stdin.write('\u001B[D'); // Left arrow - await wait(); - - expect(lastFrame()).toContain('What is your favorite color?'); - unmount(); - }); - it('shows Submit tab for multiple questions', async () => { const onConfirm = vi.fn(); const details = createConfirmationDetails({ diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index c4a5a6117..e6696ae6b 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -2244,6 +2244,7 @@ describe('useGeminiStream', () => { it('should show a retry countdown and update pending history over time', async () => { vi.useFakeTimers(); try { + let continueToRetryAttempt: (() => void) | undefined; let resolveStream: (() => void) | undefined; mockSendMessageStream.mockReturnValue( (async function* () { @@ -2256,6 +2257,9 @@ describe('useGeminiStream', () => { delayMs: 3000, }, }; + await new Promise((resolve) => { + continueToRetryAttempt = resolve; + }); yield { type: ServerGeminiEventType.Retry, }; @@ -2330,6 +2334,12 @@ describe('useGeminiStream', () => { '2s', ); + continueToRetryAttempt?.(); + + await act(async () => { + await Promise.resolve(); + }); + resolveStream?.(); await act(async () => { @@ -2347,6 +2357,103 @@ describe('useGeminiStream', () => { } }); + it('should clear retry errors after auto-retry succeeds once the countdown has elapsed', async () => { + vi.useFakeTimers(); + try { + let continueAfterCountdown: (() => void) | undefined; + mockSendMessageStream.mockReturnValue( + (async function* () { + yield { + type: ServerGeminiEventType.Retry, + retryInfo: { + message: '[API Error: Rate limit exceeded]', + attempt: 1, + maxRetries: 3, + delayMs: 1000, + }, + }; + await new Promise((resolve) => { + continueAfterCountdown = resolve; + }); + yield { + type: ServerGeminiEventType.Retry, + }; + yield { + type: ServerGeminiEventType.Text, + value: 'Success after retry', + }; + yield { + type: ServerGeminiEventType.Finished, + value: { reason: 'STOP', usageMetadata: undefined }, + }; + })(), + ); + + const { result } = renderHook(() => + useGeminiStream( + new MockedGeminiClientClass(mockConfig), + [], + mockAddItem, + mockConfig, + mockLoadedSettings, + mockOnDebugMessage, + mockHandleSlashCommand, + false, + () => 'vscode' as EditorType, + () => {}, + () => Promise.resolve(), + false, + () => {}, + () => {}, + () => {}, + () => {}, + 80, + 24, + ), + ); + + act(() => { + void result.current.submitQuery('Trigger retry after countdown'); + }); + + let errorItem = result.current.pendingHistoryItems.find( + (item) => item.type === MessageType.ERROR, + ) as { hint?: string } | undefined; + for (let attempts = 0; attempts < 5 && !errorItem; attempts++) { + await act(async () => { + await Promise.resolve(); + }); + errorItem = result.current.pendingHistoryItems.find( + (item) => item.type === MessageType.ERROR, + ) as { hint?: string } | undefined; + } + expect(errorItem?.hint).toContain('1s'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + + const staleErrorBeforeRetryCompletes = + result.current.pendingHistoryItems.find( + (item) => item.type === MessageType.ERROR, + ) as { hint?: string } | undefined; + expect(staleErrorBeforeRetryCompletes?.hint).toContain('0s'); + + await act(async () => { + continueAfterCountdown?.(); + await Promise.resolve(); + await Promise.resolve(); + }); + + const remainingError = result.current.pendingHistoryItems.find( + (item) => item.type === MessageType.ERROR, + ); + expect(remainingError).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); + it('should memoize pendingHistoryItems', () => { mockUseReactToolScheduler.mockReturnValue([ [], diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 1d0851501..7614eed00 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -1034,7 +1034,8 @@ export const useGeminiStream = ( // Show retry info if available (rate-limit / throttling errors) if (event.retryInfo) { startRetryCountdown(event.retryInfo); - } else if (!pendingRetryCountdownItemRef.current) { + } else { + // The retry attempt is starting now, so any prior retry UI is stale. clearRetryCountdown(); } break; @@ -1075,7 +1076,6 @@ export const useGeminiStream = ( setThought, pendingHistoryItemRef, setPendingHistoryItem, - pendingRetryCountdownItemRef, ], ); @@ -1301,24 +1301,13 @@ export const useGeminiStream = ( return; } - // Commit the error to history (without hint) before clearing - const errorItem = pendingRetryErrorItemRef.current; - if (errorItem) { - addItem({ type: errorItem.type, text: errorItem.text }, Date.now()); - } clearRetryCountdown(); await submitQuery(lastPrompt, { isContinuation: false, skipPreparation: true, }); - }, [ - streamingState, - addItem, - clearRetryCountdown, - submitQuery, - pendingRetryErrorItemRef, - ]); + }, [streamingState, addItem, clearRetryCountdown, submitQuery]); const handleApprovalModeChange = useCallback( async (newApprovalMode: ApprovalMode) => { diff --git a/packages/core/package.json b/packages/core/package.json index daa01de83..2c791806b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-core", - "version": "0.12.1", + "version": "0.12.2", "description": "Qwen Code Core", "repository": { "type": "git", diff --git a/packages/core/src/core/openaiContentGenerator/provider/deepseek.test.ts b/packages/core/src/core/openaiContentGenerator/provider/deepseek.test.ts index 9a69cd326..f4ced4c45 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/deepseek.test.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/deepseek.test.ts @@ -5,6 +5,7 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type OpenAI from 'openai'; import { DeepSeekOpenAICompatibleProvider } from './deepseek.js'; import type { ContentGeneratorConfig } from '../../contentGenerator.js'; import type { Config } from '../../../config/config.js'; @@ -17,6 +18,7 @@ vi.mock('openai', () => ({ })); describe('DeepSeekOpenAICompatibleProvider', () => { + let provider: DeepSeekOpenAICompatibleProvider; let mockContentGeneratorConfig: ContentGeneratorConfig; let mockCliConfig: Config; @@ -32,6 +34,11 @@ describe('DeepSeekOpenAICompatibleProvider', () => { mockCliConfig = { getCliVersion: vi.fn().mockReturnValue('1.0.0'), } as unknown as Config; + + provider = new DeepSeekOpenAICompatibleProvider( + mockContentGeneratorConfig, + mockCliConfig, + ); }); describe('isDeepSeekProvider', () => { @@ -54,12 +61,102 @@ describe('DeepSeekOpenAICompatibleProvider', () => { }); }); + describe('buildRequest', () => { + const userPromptId = 'prompt-123'; + + it('converts array content into a string', () => { + const originalRequest: OpenAI.Chat.ChatCompletionCreateParams = { + model: 'deepseek-chat', + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'Hello' }, + { type: 'text', text: ' world' }, + ], + }, + ], + }; + + const result = provider.buildRequest(originalRequest, userPromptId); + + expect(result.messages).toHaveLength(1); + expect(result.messages?.[0]).toEqual({ + role: 'user', + content: 'Hello\n\n world', + }); + expect(originalRequest.messages?.[0].content).toEqual([ + { type: 'text', text: 'Hello' }, + { type: 'text', text: ' world' }, + ]); + }); + + it('leaves string content unchanged', () => { + const originalRequest: OpenAI.Chat.ChatCompletionCreateParams = { + model: 'deepseek-chat', + messages: [ + { + role: 'user', + content: 'Hello world', + }, + ], + }; + + const result = provider.buildRequest(originalRequest, userPromptId); + + expect(result.messages?.[0].content).toBe('Hello world'); + }); + + it('handles plain string parts in the content array', () => { + const originalRequest = { + model: 'deepseek-chat', + messages: [ + { + role: 'user' as const, + content: [ + 'Hello', + { type: 'text' as const, text: ' world' }, + ] as unknown as OpenAI.Chat.ChatCompletionContentPart[], + }, + ], + }; + + const result = provider.buildRequest(originalRequest, userPromptId); + + expect(result.messages?.[0]).toEqual({ + role: 'user', + content: 'Hello\n\n world', + }); + }); + + it('replaces non-text parts with a placeholder', () => { + const originalRequest: OpenAI.Chat.ChatCompletionCreateParams = { + model: 'deepseek-chat', + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'Hello ' }, + { + type: 'image_url', + image_url: { url: 'https://example.com/image.png' }, + }, + ], + }, + ], + }; + + const result = provider.buildRequest(originalRequest, userPromptId); + + expect(result.messages?.[0]).toEqual({ + role: 'user', + content: 'Hello \n\n[Unsupported content type: image_url]', + }); + }); + }); + describe('getDefaultGenerationConfig', () => { it('returns temperature 0', () => { - const provider = new DeepSeekOpenAICompatibleProvider( - mockContentGeneratorConfig, - mockCliConfig, - ); expect(provider.getDefaultGenerationConfig()).toEqual({ temperature: 0, }); diff --git a/packages/core/src/core/openaiContentGenerator/provider/deepseek.ts b/packages/core/src/core/openaiContentGenerator/provider/deepseek.ts index 0e246725f..e34dc724d 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/deepseek.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/deepseek.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type OpenAI from 'openai'; import type { Config } from '../../../config/config.js'; import type { ContentGeneratorConfig } from '../../contentGenerator.js'; import { DefaultOpenAICompatibleProvider } from './default.js'; @@ -25,6 +26,63 @@ export class DeepSeekOpenAICompatibleProvider extends DefaultOpenAICompatiblePro return baseUrl.toLowerCase().includes('api.deepseek.com'); } + /** + * DeepSeek's API requires message content to be a plain string, not an + * array of content parts. Flatten any text-part arrays into joined strings + * and reject non-text parts that DeepSeek cannot handle. + */ + override buildRequest( + request: OpenAI.Chat.ChatCompletionCreateParams, + userPromptId: string, + ): OpenAI.Chat.ChatCompletionCreateParams { + const baseRequest = super.buildRequest(request, userPromptId); + if (!baseRequest.messages?.length) { + return baseRequest; + } + + const messages = baseRequest.messages.map((message) => { + if (!('content' in message)) { + return message; + } + + const { content } = message; + + if ( + typeof content === 'string' || + content === null || + content === undefined + ) { + return message; + } + + if (!Array.isArray(content)) { + return message; + } + + const text = content + .map((part) => { + if (typeof part === 'string') { + return part; + } + if (part.type === 'text') { + return part.text ?? ''; + } + return `[Unsupported content type: ${part.type}]`; + }) + .join('\n\n'); + + return { + ...message, + content: text, + } as OpenAI.Chat.ChatCompletionMessageParam; + }); + + return { + ...baseRequest, + messages, + }; + } + override getDefaultGenerationConfig(): GenerateContentConfig { return { temperature: 0, diff --git a/packages/core/src/ide/ide-client.test.ts b/packages/core/src/ide/ide-client.test.ts index 88788fc57..a483ccb38 100644 --- a/packages/core/src/ide/ide-client.test.ts +++ b/packages/core/src/ide/ide-client.test.ts @@ -40,6 +40,7 @@ vi.mock('node:fs', async (importOriginal) => { readFile: vi.fn(), readdir: vi.fn(), stat: vi.fn(), + unlink: vi.fn(), }, realpathSync: (p: string) => p, existsSync: vi.fn().mockReturnValue(false), @@ -49,10 +50,7 @@ vi.mock('node:dns', async (importOriginal) => { const actual = await importOriginal(); return { ...(actual as object), - promises: { - ...actual.promises, - lookup: vi.fn(), - }, + lookup: vi.fn(), }; }); vi.mock('./process-utils.js'); @@ -84,6 +82,10 @@ describe('IdeClient', () => { // Mock dependencies vi.spyOn(process, 'cwd').mockReturnValue('/test/workspace/sub-dir'); + vi.mocked(fs.existsSync).mockImplementation((filePath: fs.PathLike) => { + const file = String(filePath); + return file !== '/.dockerenv' && file !== '/run/.containerenv'; + }); vi.mocked(detectIde).mockReturnValue(IDE_DEFINITIONS.vscode); vi.mocked(getIdeProcessInfo).mockResolvedValue({ pid: 12345, @@ -218,10 +220,18 @@ describe('IdeClient', () => { vi.mocked(fs.existsSync).mockImplementation( (filePath: fs.PathLike) => filePath === '/.dockerenv', ); - (dns.promises.lookup as unknown as Mock).mockResolvedValue({ - address: '192.168.65.254', - family: 4, - }); + (dns.lookup as unknown as Mock).mockImplementation( + ( + _hostname: string, + callback: ( + err: Error | null, + address?: string, + family?: number, + ) => void, + ) => { + callback(null, '192.168.65.254', 4); + }, + ); mockClient.connect .mockRejectedValueOnce(new Error('localhost unreachable')) .mockResolvedValueOnce(undefined); @@ -248,6 +258,85 @@ describe('IdeClient', () => { delete process.env['QWEN_CODE_IDE_SERVER_PORT']; }); + it('should try a newer lock-file port when the configured port is stale', async () => { + process.env['QWEN_CODE_IDE_SERVER_PORT'] = '1111'; + const primaryConfig = { + port: '1111', + authToken: 'stale-token', + workspacePath: '/test/workspace', + }; + const fallbackConfig = { + port: '2222', + authToken: 'fresh-token', + workspacePath: '/test/workspace', + }; + vi.mocked(fs.promises.readFile).mockImplementation( + async (filePath: fs.PathLike | FileHandle) => { + const file = String(filePath); + if (file === path.join('/home/test', '.qwen', 'ide', '1111.lock')) { + return JSON.stringify(primaryConfig); + } + if (file === path.join('/home/test', '.qwen', 'ide', '2222.lock')) { + return JSON.stringify(fallbackConfig); + } + throw new Error(`unexpected path: ${file}`); + }, + ); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue(['1111.lock', '2222.lock']); + ( + vi.mocked(fs.promises.stat) as Mock< + (path: fs.PathLike) => Promise + > + ).mockImplementation(async (filePath: fs.PathLike) => { + const now = Date.now(); + const file = String(filePath); + return { + mtimeMs: file.endsWith('2222.lock') ? now : now - 1000, + } as fs.Stats; + }); + vi.mocked(fs.existsSync).mockImplementation( + (filePath: fs.PathLike) => String(filePath) === '/test/workspace', + ); + mockClient.request.mockResolvedValue({ tools: [] }); + mockClient.connect + .mockRejectedValueOnce(new Error('stale port')) + .mockResolvedValueOnce(undefined); + + const ideClient = await IdeClient.getInstance(); + await ideClient.connect(); + + expect(StreamableHTTPClientTransport).toHaveBeenNthCalledWith( + 1, + new URL('http://127.0.0.1:1111/mcp'), + expect.objectContaining({ + requestInit: { + headers: { + Authorization: 'Bearer stale-token', + }, + }, + }), + ); + expect(StreamableHTTPClientTransport).toHaveBeenNthCalledWith( + 2, + new URL('http://127.0.0.1:2222/mcp'), + expect.objectContaining({ + requestInit: { + headers: { + Authorization: 'Bearer fresh-token', + }, + }, + }), + ); + expect(ideClient.getConnectionStatus().status).toBe( + IDEConnectionStatus.Connected, + ); + delete process.env['QWEN_CODE_IDE_SERVER_PORT']; + }); + it('should connect using stdio when stdio config is in environment variables', async () => { vi.mocked(fs.promises.readFile).mockRejectedValue( new Error('File not found'), @@ -342,6 +431,24 @@ describe('IdeClient', () => { delete process.env['QWEN_CODE_IDE_SERVER_PORT']; }); + it('should not scan the lock directory when the env port lock file exists', async () => { + process.env['QWEN_CODE_IDE_SERVER_PORT'] = '1234'; + const config = { port: '1234', workspacePath: '/test/workspace' }; + vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + + const ideClient = await IdeClient.getInstance(); + vi.mocked(fs.promises.readdir).mockClear(); + const result = await ( + ideClient as unknown as { + getConnectionConfigFromFile: () => Promise; + } + ).getConnectionConfigFromFile(); + + expect(result).toEqual(config); + expect(fs.promises.readdir).not.toHaveBeenCalled(); + delete process.env['QWEN_CODE_IDE_SERVER_PORT']; + }); + it('should return undefined if no config files are found', async () => { vi.mocked(fs.promises.readFile).mockRejectedValue(new Error('not found')); @@ -424,6 +531,102 @@ describe('IdeClient', () => { delete process.env['QWEN_CODE_IDE_SERVER_PORT']; }); + it('should keep a live lock file even when it is older than 7 days', async () => { + const liveConfig = { + port: '1000', + workspacePath: '/test/workspace', + ppid: 4242, + }; + const oldTime = Date.now() - 8 * 24 * 60 * 60 * 1000; + + vi.mocked(fs.promises.readFile).mockImplementation( + async (filePath: fs.PathLike | FileHandle) => { + const file = String(filePath); + if (file === path.join('/tmp', 'qwen-code-ide-server-12345.json')) { + throw new Error('not found'); + } + if (file === path.join('/home/test', '.qwen', 'ide', '1000.lock')) { + return JSON.stringify(liveConfig); + } + throw new Error(`unexpected path: ${file}`); + }, + ); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue(['1000.lock']); + ( + vi.mocked(fs.promises.stat) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue({ mtimeMs: oldTime } as fs.Stats); + vi.spyOn(process, 'kill').mockImplementation(() => true); + + const ideClient = await IdeClient.getInstance(); + const result = await ( + ideClient as unknown as { + getConnectionConfigFromFile: () => Promise; + } + ).getConnectionConfigFromFile(); + + expect(result).toEqual(liveConfig); + expect(fs.promises.unlink).not.toHaveBeenCalled(); + }); + + it('should keep incomplete old lock files when there is no stronger stale signal', async () => { + const latestConfig = { + port: '2000', + workspacePath: '/test/workspace', + }; + const now = Date.now(); + const staleTime = now - 7 * 24 * 60 * 60 * 1000 - 1000; + + vi.mocked(fs.promises.readFile).mockImplementation( + async (filePath: fs.PathLike | FileHandle) => { + const file = String(filePath); + if (file === path.join('/tmp', 'qwen-code-ide-server-12345.json')) { + throw new Error('not found'); + } + if (file === path.join('/home/test', '.qwen', 'ide', '1000.lock')) { + return JSON.stringify({ port: '1000' }); + } + if (file === path.join('/home/test', '.qwen', 'ide', '2000.lock')) { + return JSON.stringify(latestConfig); + } + throw new Error(`unexpected path: ${file}`); + }, + ); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue(['1000.lock', '2000.lock']); + ( + vi.mocked(fs.promises.stat) as Mock< + (path: fs.PathLike) => Promise + > + ).mockImplementation(async (filePath: fs.PathLike) => { + const file = String(filePath); + return { + mtimeMs: file.endsWith('1000.lock') ? staleTime : now, + } as fs.Stats; + }); + vi.mocked(fs.existsSync).mockImplementation( + (filePath: fs.PathLike) => String(filePath) === '/test/workspace', + ); + + const ideClient = await IdeClient.getInstance(); + const result = await ( + ideClient as unknown as { + getConnectionConfigFromFile: () => Promise; + } + ).getConnectionConfigFromFile(); + + expect(fs.promises.unlink).not.toHaveBeenCalled(); + expect(result).toEqual(latestConfig); + }); + it('should scan IDE lock directory when env and legacy config are unavailable', async () => { const latestConfig = { port: '2000', @@ -458,9 +661,10 @@ describe('IdeClient', () => { (path: fs.PathLike) => Promise > ).mockImplementation(async (filePath: fs.PathLike) => { + const now = Date.now(); const file = String(filePath); return { - mtimeMs: file.endsWith('2000.lock') ? 2000 : 1000, + mtimeMs: file.endsWith('2000.lock') ? now : now - 1000, } as fs.Stats; }); @@ -509,9 +713,10 @@ describe('IdeClient', () => { (path: fs.PathLike) => Promise > ).mockImplementation(async (filePath: fs.PathLike) => { + const now = Date.now(); const file = String(filePath); return { - mtimeMs: file.endsWith('2000.lock') ? 2000 : 1000, + mtimeMs: file.endsWith('2000.lock') ? now : now - 1000, } as fs.Stats; }); @@ -647,14 +852,18 @@ describe('IdeClient', () => { }); describe('getIdeServerHost', () => { - const dnsLookupMock = dns.promises.lookup as unknown as Mock; + const dnsLookupMock = dns.lookup as unknown as Mock; function mockDnsResolvable(reachable: boolean): void { - if (reachable) { - dnsLookupMock.mockResolvedValue({ address: '192.168.65.254', family: 4 }); - } else { - dnsLookupMock.mockRejectedValue(new Error('ENOTFOUND')); - } + dnsLookupMock.mockImplementation( + (_hostname: string, callback: (err: Error | null) => void) => { + if (reachable) { + callback(null); + } else { + callback(new Error('ENOTFOUND')); + } + }, + ); } beforeEach(() => { @@ -682,7 +891,10 @@ describe('getIdeServerHost', () => { const host = await getIdeServerHost(); expect(host).toBe('host.docker.internal'); - expect(dnsLookupMock).toHaveBeenCalledWith('host.docker.internal'); + expect(dnsLookupMock).toHaveBeenCalledWith( + 'host.docker.internal', + expect.any(Function), + ); }); it('should fall back to 127.0.0.1 when in a container but host.docker.internal is not reachable', async () => { @@ -694,7 +906,10 @@ describe('getIdeServerHost', () => { const host = await getIdeServerHost(); expect(host).toBe('127.0.0.1'); - expect(dnsLookupMock).toHaveBeenCalledWith('host.docker.internal'); + expect(dnsLookupMock).toHaveBeenCalledWith( + 'host.docker.internal', + expect.any(Function), + ); }); it('should detect container via /run/.containerenv', async () => { @@ -727,15 +942,19 @@ describe('getIdeServerHost', () => { vi.mocked(fs.existsSync).mockImplementation( (filePath: fs.PathLike) => filePath === '/.dockerenv', ); - // Simulate dns.promises.lookup that never resolves - dnsLookupMock.mockReturnValue(new Promise(() => {})); + dnsLookupMock.mockImplementation(() => { + // Never call the callback to simulate a hung lookup. + }); const hostPromise = getIdeServerHost(); await vi.advanceTimersByTimeAsync(3000); const host = await hostPromise; expect(host).toBe('127.0.0.1'); - expect(dnsLookupMock).toHaveBeenCalledWith('host.docker.internal'); + expect(dnsLookupMock).toHaveBeenCalledWith( + 'host.docker.internal', + expect.any(Function), + ); }); it('should perform only one DNS lookup when called concurrently', async () => { @@ -746,13 +965,9 @@ describe('getIdeServerHost', () => { // Simulate a slow DNS lookup dnsLookupMock.mockImplementation( - () => - new Promise((resolve) => - setTimeout( - () => resolve({ address: '192.168.65.254', family: 4 }), - 50, - ), - ), + (_hostname: string, callback: (err: Error | null) => void) => { + setTimeout(() => callback(null), 50); + }, ); const promises = Array.from({ length: 5 }, () => getIdeServerHost()); diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index b4835e30e..d51607eef 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -62,6 +62,19 @@ type ConnectionConfig = { stdio?: StdioConfig; }; +type IdeConnectionConfig = ConnectionConfig & { + workspacePath?: string; + ideInfo?: IdeInfo; + ppid?: number; +}; + +type ParsedConnectionLockFile = { + file: string; + fullPath: string; + mtimeMs: number; + parsed: IdeConnectionConfig; +}; + function getRealPath(path: string): string { try { return fs.realpathSync(path); @@ -85,9 +98,7 @@ export class IdeClient { }; private currentIde: IdeInfo | undefined; private ideProcessInfo: { pid: number; command: string } | undefined; - private connectionConfig: - | (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }) - | undefined; + private connectionConfig: IdeConnectionConfig | undefined; private authToken: string | undefined; private diffResponses = new Map void>(); private statusListeners = new Set<(state: IDEConnectionState) => void>(); @@ -172,6 +183,10 @@ export class IdeClient { if (connected) { return; } + const fallbackConnected = await this.tryFallbackPorts(); + if (fallbackConnected) { + return; + } } if (this.connectionConfig.stdio) { const connected = await this.establishStdioConnection( @@ -570,10 +585,10 @@ export class IdeClient { } private async getConnectionConfigFromFile(): Promise< - | (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }) - | undefined + IdeConnectionConfig | undefined > { const portFromEnv = this.getPortFromEnv(); + if (portFromEnv) { try { const ideDir = Storage.getGlobalIdeDir(); @@ -591,37 +606,20 @@ export class IdeClient { return legacyConfig; } - // Scan lock directory as a last resort when neither env var nor legacy - // file is available (e.g. code-server where the env var is not injected). - // Configs are sorted by modification time (most recent first). Pick the - // first one whose workspace matches the current working directory. - if (!portFromEnv) { - const ideDir = Storage.getGlobalIdeDir(); - const configs = await this.getAllConnectionConfigs(ideDir); - if (configs.length > 0) { - debugLogger.debug( - `Discovered ${configs.length} IDE lock file(s) via directory scan`, - ); - const cwd = process.cwd(); - const match = configs.find( - (c) => - c.workspacePath !== undefined && - IdeClient.validateWorkspacePath(c.workspacePath, cwd).isValid, - ); - return match; - } - } - - return undefined; + const ideDir = Storage.getGlobalIdeDir(); + const configs = await this.getAllConnectionConfigs(ideDir); + const cwd = process.cwd(); + return configs.find( + (config) => + config.workspacePath !== undefined && + IdeClient.validateWorkspacePath(config.workspacePath, cwd).isValid, + ); } // Legacy connection files were written in the global temp directory. private async getLegacyConnectionConfig( portFromEnv?: string, - ): Promise< - | (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }) - | undefined - > { + ): Promise { if (this.ideProcessInfo) { try { const portFile = path.join( @@ -656,15 +654,13 @@ export class IdeClient { protected async getAllConnectionConfigs( ideDir: string, - ): Promise< - ConnectionConfig & Array<{ workspacePath?: string; ideInfo?: IdeInfo }> - > { - const fileRegex = new RegExp('^\\d+\\.lock$'); + ): Promise { + const fileRegex = /^\d+\.lock$/; let lockFiles: string[]; try { - lockFiles = (await fs.promises.readdir(ideDir)).filter((file) => - fileRegex.test(file), - ); + lockFiles = (await fs.promises.readdir(ideDir)) + .map((file) => file.toString()) + .filter((file) => fileRegex.test(file)); } catch (e) { debugLogger.debug('Failed to read IDE connection directory:', e); return []; @@ -677,27 +673,131 @@ export class IdeClient { const stat = await fs.promises.stat(fullPath); const content = await fs.promises.readFile(fullPath, 'utf8'); try { - const parsed = JSON.parse(content); - return { file, mtimeMs: stat.mtimeMs, parsed }; - } catch (e) { - debugLogger.debug('Failed to parse JSON from lock file: ', e); - return { file, mtimeMs: stat.mtimeMs, parsed: undefined }; + return { + file, + fullPath, + mtimeMs: stat.mtimeMs, + parsed: JSON.parse(content) as IdeConnectionConfig, + }; + } catch (error) { + debugLogger.debug('Failed to parse JSON from lock file: ', error); + return undefined; } - } catch (e) { - // If we can't stat/read the file, treat it as very old so it doesn't - // win ties, and skip parsing by returning undefined content. - debugLogger.debug('Failed to read/stat IDE lock file:', e); - return { file, mtimeMs: -Infinity, parsed: undefined }; + } catch (error) { + debugLogger.debug('Failed to read/stat IDE lock file:', error); + return undefined; } }), ); - return fileContents - .filter(({ parsed }) => parsed !== undefined) + const parsedLockFiles = fileContents.filter( + (lockFile): lockFile is ParsedConnectionLockFile => + lockFile !== undefined, + ); + const activeLockFiles = await Promise.all( + parsedLockFiles.map(async (lockFile) => ({ + lockFile, + isStale: await this.cleanupStaleLockFile(lockFile), + })), + ); + + const staleCount = activeLockFiles.filter(({ isStale }) => isStale).length; + if (staleCount > 0) { + debugLogger.debug( + `[cleanupStaleLockFiles] Cleaned up ${staleCount} stale lock file(s)`, + ); + } + + return activeLockFiles + .filter(({ isStale }) => !isStale) + .map(({ lockFile }) => lockFile) .sort((a, b) => b.mtimeMs - a.mtimeMs) .map(({ parsed }) => parsed); } + private async cleanupStaleLockFile({ + file, + fullPath, + parsed, + }: ParsedConnectionLockFile): Promise { + try { + if (parsed.ppid) { + try { + process.kill(parsed.ppid, 0); + return false; + } catch { + debugLogger.debug( + `[cleanupStaleLockFiles] Removing lock file "${file}" - ppid ${parsed.ppid} no longer exists`, + ); + await fs.promises.unlink(fullPath); + return true; + } + } + + if (parsed.workspacePath) { + if (fs.existsSync(parsed.workspacePath)) { + return false; + } + + debugLogger.debug( + `[cleanupStaleLockFiles] Removing lock file "${file}" - workspace doesn't exist`, + ); + await fs.promises.unlink(fullPath); + return true; + } + + return false; + } catch (error) { + debugLogger.debug( + `[cleanupStaleLockFiles] Error checking lock file "${file}":`, + error, + ); + return false; + } + } + + private async tryFallbackPorts(): Promise { + const cwd = process.cwd(); + const currentPort = this.connectionConfig?.port; + const configs = await this.getAllConnectionConfigs( + Storage.getGlobalIdeDir(), + ); + const workspaceMatches: IdeConnectionConfig[] = []; + const otherConfigs: IdeConnectionConfig[] = []; + + for (const config of configs) { + if (!config.port || config.port === currentPort) { + continue; + } + + if ( + config.workspacePath !== undefined && + IdeClient.validateWorkspacePath(config.workspacePath, cwd).isValid + ) { + workspaceMatches.push(config); + } else { + otherConfigs.push(config); + } + } + + for (const config of [...workspaceMatches, ...otherConfigs]) { + const port = config.port; + if (!port) { + continue; + } + if (config.authToken) { + this.authToken = config.authToken; + } + const connected = await this.establishHttpConnection(port); + if (connected) { + this.connectionConfig = config; + return true; + } + } + + return false; + } + private createProxyAwareFetch(ideHost: string) { // Ignore proxy for IDE server host to allow connecting to the ide mcp // server even when HTTP_PROXY is set @@ -929,21 +1029,26 @@ export function _resetCachedIdeServerHost(): void { /** * Check if a hostname is DNS-resolvable, with a timeout guard. + * Uses callback-based dns.lookup() for better compatibility across + * different Node.js environments (e.g., VSCode, Cursor). */ async function isHostResolvable(hostname: string): Promise { - try { - const timeout = new Promise((_, reject) => { - const timer = setTimeout( - () => reject(new Error('DNS lookup timeout')), - DNS_LOOKUP_TIMEOUT_MS, - ); - timer.unref?.(); + return new Promise((resolve) => { + let settled = false; + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + resolve(false); + }, DNS_LOOKUP_TIMEOUT_MS); + timeout.unref?.(); + + dns.lookup(hostname, (err) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + resolve(!err); }); - await Promise.race([dns.promises.lookup(hostname), timeout]); - return true; - } catch { - return false; - } + }); } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d0dcce945..4330431db 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -202,7 +202,11 @@ export * from './lsp/types.js'; // ============================================================================ export { MCPOAuthProvider } from './mcp/oauth-provider.js'; -export type { MCPOAuthConfig } from './mcp/oauth-provider.js'; +export type { + MCPOAuthConfig, + OAuthDisplayMessage, + OAuthDisplayPayload, +} from './mcp/oauth-provider.js'; export { MCPOAuthTokenStorage } from './mcp/oauth-token-storage.js'; export { KeychainTokenStorage } from './mcp/token-storage/keychain-token-storage.js'; export type { diff --git a/packages/core/src/mcp/oauth-provider.ts b/packages/core/src/mcp/oauth-provider.ts index 1d1157c27..a2fca6eec 100644 --- a/packages/core/src/mcp/oauth-provider.ts +++ b/packages/core/src/mcp/oauth-provider.ts @@ -22,8 +22,28 @@ import { export const OAUTH_DISPLAY_MESSAGE_EVENT = 'oauth-display-message' as const; +/** + * Structured display message for i18n support. + * The `key` is the i18n translation key (English text as key). + * The `params` are optional interpolation parameters. + */ +export interface OAuthDisplayMessage { + key: string; + params?: Record; +} + +/** Payload type for OAuth display message events: structured i18n message or plain string. */ +export type OAuthDisplayPayload = string | OAuthDisplayMessage; + const debugLogger = createDebugLogger('MCP_OAUTH'); +// Module-level reference to the active OAuth callback server. +// This ensures that if a new authentication is started before the previous one +// finishes (e.g. user navigated back and re-entered), the old server is closed +// first to avoid EADDRINUSE errors. +let activeCallbackServer: http.Server | null = null; +let activeCallbackTimeout: ReturnType | null = null; + /** * OAuth configuration for an MCP server. */ @@ -195,6 +215,20 @@ export class MCPOAuthProvider { private async startCallbackServer( expectedState: string, ): Promise { + // Close any previously active callback server to avoid EADDRINUSE + if (activeCallbackServer) { + try { + activeCallbackServer.close(); + } catch { + // Ignore errors when closing stale server + } + activeCallbackServer = null; + } + if (activeCallbackTimeout) { + clearTimeout(activeCallbackTimeout); + activeCallbackTimeout = null; + } + return new Promise((resolve, reject) => { const server = http.createServer( async (req: http.IncomingMessage, res: http.ServerResponse) => { @@ -226,6 +260,7 @@ export class MCPOAuthProvider { `); + activeCallbackServer = null; server.close(); reject(new Error(`OAuth error: ${error}`)); return; @@ -240,6 +275,7 @@ export class MCPOAuthProvider { if (state !== expectedState) { res.writeHead(400); res.end('Invalid state parameter'); + activeCallbackServer = null; server.close(); reject(new Error('State mismatch - possible CSRF attack')); return; @@ -257,9 +293,11 @@ export class MCPOAuthProvider { `); + activeCallbackServer = null; server.close(); resolve({ code, state }); } catch (error) { + activeCallbackServer = null; server.close(); reject(error); } @@ -273,9 +311,14 @@ export class MCPOAuthProvider { ); }); + // Track the active server so it can be cleaned up if a new auth starts + activeCallbackServer = server; + // Timeout after 5 minutes - setTimeout( + activeCallbackTimeout = setTimeout( () => { + activeCallbackServer = null; + activeCallbackTimeout = null; server.close(); reject(new Error('OAuth callback timeout')); }, @@ -603,11 +646,17 @@ export class MCPOAuthProvider { events?: EventEmitter, ): Promise { // Helper function to display messages through handler or fallback to debugLogger - const displayMessage = (message: string) => { + const displayMessage = (message: OAuthDisplayPayload) => { if (events) { events.emit(OAUTH_DISPLAY_MESSAGE_EVENT, message); } else { - debugLogger.info(message); + if (typeof message === 'string') { + debugLogger.info(message); + } else { + debugLogger.info( + `[${message.key}]${message.params ? ` ${JSON.stringify(message.params)}` : ''}`, + ); + } } }; @@ -746,13 +795,13 @@ export class MCPOAuthProvider { mcpServerUrl, ); - displayMessage(`→ Opening your browser for OAuth sign-in... - -If the browser does not open, copy and paste this URL into your browser: -${authUrl} - -💡 TIP: Triple-click to select the entire URL, then copy and paste it into your browser. -⚠️ Make sure to copy the COMPLETE URL - it may wrap across multiple lines.`); + displayMessage({ + key: 'If the browser does not open, copy and paste this URL into your browser:', + }); + displayMessage(`\n${authUrl.toString()}\n`); + displayMessage({ + key: 'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.', + }); // Start callback server const callbackPromise = this.startCallbackServer(pkceParams.state); diff --git a/packages/core/src/qwen/qwenOAuth2.test.ts b/packages/core/src/qwen/qwenOAuth2.test.ts index 7ff3207d8..41d06afbe 100644 --- a/packages/core/src/qwen/qwenOAuth2.test.ts +++ b/packages/core/src/qwen/qwenOAuth2.test.ts @@ -91,13 +91,6 @@ vi.mock('./sharedTokenManager.js', () => ({ }, })); -// Mock qrcode-terminal -vi.mock('qrcode-terminal', () => ({ - default: { - generate: vi.fn(), - }, -})); - // Mock open vi.mock('open', () => ({ default: vi.fn(), diff --git a/packages/core/src/tools/ls.test.ts b/packages/core/src/tools/ls.test.ts index 39a6b7b31..da6273eb1 100644 --- a/packages/core/src/tools/ls.test.ts +++ b/packages/core/src/tools/ls.test.ts @@ -41,6 +41,7 @@ describe('LSTool', () => { respectGitIgnore: true, respectQwenIgnore: true, }), + getTruncateToolOutputLines: () => 1000, storage: { getUserSkillsDir: () => userSkillsBase, }, @@ -100,7 +101,7 @@ describe('LSTool', () => { expect(result.llmContent).toContain('[DIR] subdir'); expect(result.llmContent).toContain('file1.txt'); - expect(result.returnDisplay).toBe('Listed 2 item(s).'); + expect(result.returnDisplay).toBe('Listed 2 item(s)'); }); it('should list files from secondary workspace directory', async () => { @@ -115,7 +116,7 @@ describe('LSTool', () => { const result = await invocation.execute(abortSignal); expect(result.llmContent).toContain('secondary-file.txt'); - expect(result.returnDisplay).toBe('Listed 1 item(s).'); + expect(result.returnDisplay).toBe('Listed 1 item(s)'); }); it('should handle empty directories', async () => { @@ -140,7 +141,7 @@ describe('LSTool', () => { expect(result.llmContent).toContain('file1.txt'); expect(result.llmContent).not.toContain('file2.log'); - expect(result.returnDisplay).toBe('Listed 1 item(s).'); + expect(result.returnDisplay).toBe('Listed 1 item(s)'); }); it('should respect gitignore patterns', async () => { @@ -154,7 +155,7 @@ describe('LSTool', () => { expect(result.llmContent).toContain('file1.txt'); expect(result.llmContent).not.toContain('file2.log'); // .git is always ignored by default. - expect(result.returnDisplay).toBe('Listed 2 item(s). (2 git-ignored)'); + expect(result.returnDisplay).toBe('Listed 2 item(s) (2 git-ignored)'); }); it('should respect qwenignore patterns', async () => { @@ -166,7 +167,7 @@ describe('LSTool', () => { expect(result.llmContent).toContain('file1.txt'); expect(result.llmContent).not.toContain('file2.log'); - expect(result.returnDisplay).toBe('Listed 2 item(s). (1 qwen-ignored)'); + expect(result.returnDisplay).toBe('Listed 2 item(s) (1 qwen-ignored)'); }); it('should handle non-directory paths', async () => { @@ -204,7 +205,7 @@ describe('LSTool', () => { typeof result.llmContent === 'string' ? result.llmContent : '' ) .split('\n') - .filter(Boolean); + .filter((l) => l.trim() && l.trim() !== '---'); const entries = lines.slice(1); // Skip header expect(entries[0]).toBe('[DIR] x-dir'); @@ -259,12 +260,70 @@ describe('LSTool', () => { // Should still list the other files expect(result.llmContent).toContain('file1.txt'); expect(result.llmContent).not.toContain('problematic.txt'); - expect(result.returnDisplay).toBe('Listed 1 item(s).'); + expect(result.returnDisplay).toBe('Listed 1 item(s)'); statSpy.mockRestore(); }); }); + describe('truncation', () => { + it('should truncate when entries exceed config line limit', async () => { + const lowLimitConfig = { + ...mockConfig, + getTruncateToolOutputLines: () => 5, + } as unknown as Config; + const lowLimitTool = new LSTool(lowLimitConfig); + + for (let i = 0; i < 10; i++) { + await fs.writeFile( + path.join(tempRootDir, `file${String(i).padStart(2, '0')}.txt`), + `content${i}`, + ); + } + + const invocation = lowLimitTool.build({ path: tempRootDir }); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('[5 items truncated]'); + expect(result.returnDisplay).toBe('Listed 10 item(s) (truncated)'); + }); + + it('should not truncate when entries are within limit', async () => { + for (let i = 0; i < 3; i++) { + await fs.writeFile( + path.join(tempRootDir, `file${i}.txt`), + `content${i}`, + ); + } + + const invocation = lsTool.build({ path: tempRootDir }); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).not.toContain('truncated'); + expect(result.returnDisplay).toBe('Listed 3 item(s)'); + }); + + it('should use singular "entry" when exactly one entry is truncated', async () => { + const lowLimitConfig = { + ...mockConfig, + getTruncateToolOutputLines: () => 2, + } as unknown as Config; + const lowLimitTool = new LSTool(lowLimitConfig); + + for (let i = 0; i < 3; i++) { + await fs.writeFile( + path.join(tempRootDir, `file${i}.txt`), + `content${i}`, + ); + } + + const invocation = lowLimitTool.build({ path: tempRootDir }); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('[1 item truncated]'); + }); + }); + describe('getDescription', () => { it('should return shortened relative path', () => { const deeplyNestedDir = path.join(tempRootDir, 'deeply', 'nested'); @@ -319,7 +378,7 @@ describe('LSTool', () => { const result = await invocation.execute(abortSignal); expect(result.llmContent).toContain('secondary-file.txt'); - expect(result.returnDisplay).toBe('Listed 1 item(s).'); + expect(result.returnDisplay).toBe('Listed 1 item(s)'); }); }); }); diff --git a/packages/core/src/tools/ls.ts b/packages/core/src/tools/ls.ts index b8edbe163..877a1274b 100644 --- a/packages/core/src/tools/ls.ts +++ b/packages/core/src/tools/ls.ts @@ -18,6 +18,8 @@ import { createDebugLogger } from '../utils/debugLogger.js'; const debugLogger = createDebugLogger('LS'); +const MAX_ENTRY_COUNT = 100; + /** * Parameters for the LS tool */ @@ -216,12 +218,27 @@ class LSToolInvocation extends BaseToolInvocation { return a.name.localeCompare(b.name); }); - // Create formatted content for LLM - const directoryContent = entries + const totalEntryCount = entries.length; + const entryLimit = Math.min( + MAX_ENTRY_COUNT, + this.config.getTruncateToolOutputLines(), + ); + const truncated = totalEntryCount > entryLimit; + + const entriesToShow = truncated ? entries.slice(0, entryLimit) : entries; + + const directoryContent = entriesToShow .map((entry) => `${entry.isDirectory ? '[DIR] ' : ''}${entry.name}`) .join('\n'); - let resultMessage = `Directory listing for ${this.params.path}:\n${directoryContent}`; + let resultMessage = `Listed ${totalEntryCount} item(s) in ${this.params.path}:\n---\n${directoryContent}`; + + if (truncated) { + const omittedEntries = totalEntryCount - entryLimit; + const entryTerm = omittedEntries === 1 ? 'item' : 'items'; + resultMessage += `\n---\n[${omittedEntries} ${entryTerm} truncated] ...`; + } + const ignoredMessages = []; if (gitIgnoredCount > 0) { ignoredMessages.push(`${gitIgnoredCount} git-ignored`); @@ -233,10 +250,13 @@ class LSToolInvocation extends BaseToolInvocation { resultMessage += `\n\n(${ignoredMessages.join(', ')})`; } - let displayMessage = `Listed ${entries.length} item(s).`; + let displayMessage = `Listed ${totalEntryCount} item(s)`; if (ignoredMessages.length > 0) { displayMessage += ` (${ignoredMessages.join(', ')})`; } + if (truncated) { + displayMessage += ' (truncated)'; + } return { llmContent: resultMessage, diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index dc14bef86..5fccddb4b 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -229,6 +229,22 @@ export class ToolRegistry { } } + /** + * Disconnects an MCP server by removing its tools, prompts, and disconnecting the client. + * Unlike disableMcpServer, this does NOT add the server to the exclusion list. + * @param serverName The name of the server to disconnect. + */ + async disconnectServer(serverName: string): Promise { + // Remove tools from registry + this.removeMcpToolsByServer(serverName); + + // Remove prompts + this.config.getPromptRegistry().removePromptsByServer(serverName); + + // Disconnect the MCP client + await this.mcpClientManager.disconnectServer(serverName); + } + /** * Disables an MCP server by removing its tools, prompts, and disconnecting the client. * Also updates the config's exclusion list. diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index b9ac81d5b..36413ff1a 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.12.1", + "version": "0.12.2", "private": true, "main": "src/index.ts", "license": "Apache-2.0", diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index d35dae11d..83f75bc4c 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -2,7 +2,7 @@ "name": "qwen-code-vscode-ide-companion", "displayName": "Qwen Code Companion", "description": "Enable Qwen Code with direct access to your VS Code workspace.", - "version": "0.12.1", + "version": "0.12.2", "publisher": "qwenlm", "icon": "assets/icon.png", "repository": { diff --git a/packages/web-templates/package.json b/packages/web-templates/package.json index c4416be38..9b33de80c 100644 --- a/packages/web-templates/package.json +++ b/packages/web-templates/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/web-templates", - "version": "0.12.1", + "version": "0.12.2", "description": "Web templates bundled as embeddable JS/CSS strings", "repository": { "type": "git", diff --git a/packages/webui/package.json b/packages/webui/package.json index 7826ce2b2..6a00c139b 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/webui", - "version": "0.12.1", + "version": "0.12.2", "description": "Shared UI components for Qwen Code packages", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/webui/src/components/layout/CompletionMenu.tsx b/packages/webui/src/components/layout/CompletionMenu.tsx index 159b35847..06727f7ee 100644 --- a/packages/webui/src/components/layout/CompletionMenu.tsx +++ b/packages/webui/src/components/layout/CompletionMenu.tsx @@ -123,6 +123,7 @@ export const CompletionMenu: FC = ({ setSelected((prev) => Math.max(prev - 1, 0)); break; case 'Enter': + case 'Tab': event.preventDefault(); if (items[selected]) { onSelect(items[selected]); diff --git a/scripts/installation/install-qwen-with-source.bat b/scripts/installation/install-qwen-with-source.bat index fcc9d9ac3..fe5263e0e 100644 --- a/scripts/installation/install-qwen-with-source.bat +++ b/scripts/installation/install-qwen-with-source.bat @@ -134,18 +134,20 @@ call :CheckCommandExists qwen if %ERRORLEVEL% EQU 0 ( echo SUCCESS: Qwen Code is available as 'qwen' command. call qwen --version + echo. + echo INFO: Starting Qwen Code... + echo. + call qwen ) else ( echo WARNING: Qwen Code may not be in PATH. Please check your npm global bin directory. + echo. + echo =========================================== + echo SUCCESS: Installation completed! + echo The source information is stored in %USERPROFILE%\.qwen\source.json + echo. + echo =========================================== ) -echo. -echo =========================================== -echo SUCCESS: Installation completed! -echo The source information is stored in %USERPROFILE%\.qwen\source.json -echo Tips: Please restart your terminal and run: qwen -echo. -echo =========================================== - endlocal exit /b 0 diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index 6f67e469b..ce6d46c26 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -553,14 +553,18 @@ main() { if command_exists qwen; then log_success "Qwen Code is ready to use!" echo "" - log_info "Tips: Please restart your terminal and run: qwen" + echo "You can now run: qwen" echo "" + # Auto-start qwen + log_info "Starting Qwen Code..." + echo "" + exec qwen else - log_warning "Tips: To start using Qwen Code, please run:" + log_warning "Qwen Code command not found in current session" echo "" - local PROFILE_FILE - PROFILE_FILE=$(get_shell_profile) - echo " source ${PROFILE_FILE}" + echo "To use Qwen Code immediately without restarting your terminal," + echo "run the following command in your current shell:" + echo " eval \$(${0} --print-env)" echo "" log_info "Or simply restart your terminal, then run: qwen" fi