From 9a3e0bb72bc3133c370ae2d7b4be5ae148dd001f Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Mon, 26 Jan 2026 15:17:26 +0800 Subject: [PATCH 01/81] feat: support for pasting image --- .../cli/src/ui/components/InputPrompt.tsx | 97 ++-- .../cli/src/ui/contexts/KeypressContext.tsx | 34 +- .../cli/src/ui/hooks/atCommandProcessor.ts | 12 +- .../cli/src/ui/utils/clipboardUtils.test.ts | 415 ++++++++++++++++-- packages/cli/src/ui/utils/clipboardUtils.ts | 393 ++++++++++++++--- packages/core/src/tools/read-many-files.ts | 5 + packages/core/src/utils/workspaceContext.ts | 38 +- 7 files changed, 833 insertions(+), 161 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 1d46d03ab..5ad804a41 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -256,52 +256,63 @@ export const InputPrompt: React.FC = ({ ]); // Handle clipboard image pasting with Ctrl+V - const handleClipboardImage = useCallback(async () => { - try { - if (await clipboardHasImage()) { - const imagePath = await saveClipboardImage(config.getTargetDir()); - if (imagePath) { - // Clean up old images - cleanupOldClipboardImages(config.getTargetDir()).catch(() => { - // Ignore cleanup errors - }); + const handleClipboardImage = useCallback( + async (validated = false) => { + try { + const hasImage = validated || (await clipboardHasImage()); + if (hasImage) { + const imagePath = await saveClipboardImage( + config.storage.getProjectTempDir(), + ); + if (imagePath) { + // Clean up old images + cleanupOldClipboardImages(config.storage.getProjectTempDir()).catch( + () => { + // Ignore cleanup errors + }, + ); - // Get relative path from current directory - const relativePath = path.relative(config.getTargetDir(), imagePath); + // Get relative path from current directory + const relativePath = path.relative( + config.getTargetDir(), + imagePath, + ); - // Insert @path reference at cursor position - const insertText = `@${relativePath}`; - const currentText = buffer.text; - const [row, col] = buffer.cursor; + // Insert @path reference at cursor position + const insertText = `@${relativePath}`; + const currentText = buffer.text; + const [row, col] = buffer.cursor; - // Calculate offset from row/col - let offset = 0; - for (let i = 0; i < row; i++) { - offset += buffer.lines[i].length + 1; // +1 for newline + // Calculate offset from row/col + let offset = 0; + for (let i = 0; i < row; i++) { + offset += buffer.lines[i].length + 1; // +1 for newline + } + offset += col; + + // Add spaces around the path if needed + let textToInsert = insertText; + const charBefore = offset > 0 ? currentText[offset - 1] : ''; + const charAfter = + offset < currentText.length ? currentText[offset] : ''; + + if (charBefore && charBefore !== ' ' && charBefore !== '\n') { + textToInsert = ' ' + textToInsert; + } + if (!charAfter || (charAfter !== ' ' && charAfter !== '\n')) { + textToInsert = textToInsert + ' '; + } + + // Insert at cursor position + buffer.replaceRangeByOffset(offset, offset, textToInsert); } - offset += col; - - // Add spaces around the path if needed - let textToInsert = insertText; - const charBefore = offset > 0 ? currentText[offset - 1] : ''; - const charAfter = - offset < currentText.length ? currentText[offset] : ''; - - if (charBefore && charBefore !== ' ' && charBefore !== '\n') { - textToInsert = ' ' + textToInsert; - } - if (!charAfter || (charAfter !== ' ' && charAfter !== '\n')) { - textToInsert = textToInsert + ' '; - } - - // Insert at cursor position - buffer.replaceRangeByOffset(offset, offset, textToInsert); } + } catch (error) { + console.error('Error handling clipboard image:', error); } - } catch (error) { - console.error('Error handling clipboard image:', error); - } - }, [buffer, config]); + }, + [buffer, config], + ); const handleInput = useCallback( (key: Key) => { @@ -329,7 +340,11 @@ export const InputPrompt: React.FC = ({ }, 500); // Ensure we never accidentally interpret paste as regular input. - buffer.handleInput(key); + if (key.pasteImage) { + handleClipboardImage(true); + } else { + buffer.handleInput(key); + } return; } diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 0f01712cc..a3f8ff8f4 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -35,6 +35,7 @@ import { MODIFIER_ALT_BIT, MODIFIER_CTRL_BIT, } from '../utils/platformConstants.js'; +import { clipboardHasImage } from '../utils/clipboardUtils.js'; import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js'; @@ -53,6 +54,7 @@ export interface Key { paste: boolean; sequence: string; kittyProtocol?: boolean; + pasteImage?: boolean; } export type KeypressHandler = (key: Key) => void; @@ -387,7 +389,7 @@ export function KeypressProvider({ } }; - const handleKeypress = (_: unknown, key: Key) => { + const handleKeypress = async (_: unknown, key: Key) => { if (key.sequence === FOCUS_IN || key.sequence === FOCUS_OUT) { return; } @@ -397,14 +399,28 @@ export function KeypressProvider({ } if (key.name === 'paste-end') { isPaste = false; - broadcast({ - name: '', - ctrl: false, - meta: false, - shift: false, - paste: true, - sequence: pasteBuffer.toString(), - }); + if (pasteBuffer.toString().length > 0) { + broadcast({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: true, + sequence: pasteBuffer.toString(), + }); + } else { + const hasImage = await clipboardHasImage(); + broadcast({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: true, + pasteImage: hasImage, + sequence: pasteBuffer.toString(), + }); + } + pasteBuffer = Buffer.alloc(0); return; } diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index f3e41956b..c9b584fc5 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -191,7 +191,17 @@ export async function handleAtCommand({ // Check if path should be ignored based on filtering options const workspaceContext = config.getWorkspaceContext(); - if (!workspaceContext.isPathWithinWorkspace(pathName)) { + + // Check if path is in project temp directory + const projectTempDir = config.storage.getProjectTempDir(); + const absolutePathName = path.isAbsolute(pathName) + ? pathName + : path.resolve(workspaceContext.getDirectories()[0] || '', pathName); + + if ( + !absolutePathName.startsWith(projectTempDir) && + !workspaceContext.isPathWithinWorkspace(pathName) + ) { onDebugMessage( `Path ${pathName} is not in the workspace and will be skipped.`, ); diff --git a/packages/cli/src/ui/utils/clipboardUtils.test.ts b/packages/cli/src/ui/utils/clipboardUtils.test.ts index 30258889e..d19c3f63c 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.test.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.test.ts @@ -4,66 +4,341 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { execCommand } from '@qwen-code/qwen-code-core'; import { clipboardHasImage, saveClipboardImage, cleanupOldClipboardImages, } from './clipboardUtils.js'; +// Mock execCommand +vi.mock('@qwen-code/qwen-code-core', () => ({ + execCommand: vi.fn(), +})); + +const mockExecCommand = vi.mocked(execCommand); + describe('clipboardUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + describe('clipboardHasImage', () => { - it('should return false on non-macOS platforms', async () => { - if (process.platform !== 'darwin') { + describe('macOS platform', () => { + beforeEach(() => { + vi.stubGlobal('process', { + ...process, + platform: 'darwin', + env: process.env, + }); + }); + + it('should return true when clipboard contains PNG image', async () => { + mockExecCommand.mockResolvedValue({ + stdout: '«class PNGf»', + stderr: '', + code: 0, + }); + + const result = await clipboardHasImage(); + expect(result).toBe(true); + expect(mockExecCommand).toHaveBeenCalledWith( + 'osascript', + ['-e', 'clipboard info'], + { timeout: 1500 }, + ); + }); + + it('should return true when clipboard contains JPEG image', async () => { + mockExecCommand.mockResolvedValue({ + stdout: '«class JPEG»', + stderr: '', + code: 0, + }); + + const result = await clipboardHasImage(); + expect(result).toBe(true); + }); + + it('should return true when clipboard contains WebP image', async () => { + mockExecCommand.mockResolvedValue({ + stdout: '«class WEBP»', + stderr: '', + code: 0, + }); + + const result = await clipboardHasImage(); + expect(result).toBe(true); + }); + + it('should return true when clipboard contains HEIC image', async () => { + mockExecCommand.mockResolvedValue({ + stdout: 'public.heic', + stderr: '', + code: 0, + }); + + const result = await clipboardHasImage(); + expect(result).toBe(true); + }); + + it('should return true when clipboard contains BMP image', async () => { + mockExecCommand.mockResolvedValue({ + stdout: '«class BMPf»', + stderr: '', + code: 0, + }); + + const result = await clipboardHasImage(); + expect(result).toBe(true); + }); + + it('should return false when clipboard contains text', async () => { + mockExecCommand.mockResolvedValue({ + stdout: '«class utf8»', + stderr: '', + code: 0, + }); + const result = await clipboardHasImage(); expect(result).toBe(false); - } else { - // Skip on macOS as it would require actual clipboard state - expect(true).toBe(true); - } + }); + + it('should return false on error', async () => { + mockExecCommand.mockRejectedValue(new Error('Command failed')); + + const result = await clipboardHasImage(); + expect(result).toBe(false); + }); }); - it('should return boolean on macOS', async () => { - if (process.platform === 'darwin') { + describe('Windows platform', () => { + beforeEach(() => { + vi.stubGlobal('process', { + ...process, + platform: 'win32', + env: process.env, + }); + }); + + it('should return true when clipboard contains image', async () => { + mockExecCommand.mockResolvedValue({ + stdout: 'True', + stderr: '', + code: 0, + }); + const result = await clipboardHasImage(); - expect(typeof result).toBe('boolean'); - } else { - // Skip on non-macOS - expect(true).toBe(true); - } + expect(result).toBe(true); + expect(mockExecCommand).toHaveBeenCalledWith( + 'powershell', + expect.arrayContaining([ + '-command', + 'Add-Type -Assembly System.Windows.Forms; [System.Windows.Forms.Clipboard]::ContainsImage()', + ]), + ); + }); + + it('should return false when clipboard does not contain image', async () => { + mockExecCommand.mockResolvedValue({ + stdout: 'False', + stderr: '', + code: 0, + }); + + const result = await clipboardHasImage(); + expect(result).toBe(false); + }); + + it('should return false when PowerShell fails', async () => { + mockExecCommand.mockRejectedValue(new Error('PowerShell not found')); + + const result = await clipboardHasImage(); + expect(result).toBe(false); + }); + }); + + describe('Linux platform', () => { + beforeEach(() => { + vi.stubGlobal('process', { + ...process, + platform: 'linux', + env: process.env, + }); + }); + + it('should return true when xclip has PNG image', async () => { + // First call: which xclip (success) + // Second call: xclip get PNG (has content) + mockExecCommand + .mockResolvedValueOnce({ + stdout: '/usr/bin/xclip', + stderr: '', + code: 0, + }) + .mockResolvedValueOnce({ stdout: 'image-data', stderr: '', code: 0 }); + + const result = await clipboardHasImage(); + expect(result).toBe(true); + }); + + it('should try multiple formats with xclip', async () => { + // which xclip succeeds + mockExecCommand.mockResolvedValueOnce({ + stdout: '/usr/bin/xclip', + stderr: '', + code: 0, + }); + // PNG fails + mockExecCommand.mockRejectedValueOnce(new Error('No PNG')); + // JPEG succeeds + mockExecCommand.mockResolvedValueOnce({ + stdout: 'jpeg-data', + stderr: '', + code: 0, + }); + + const result = await clipboardHasImage(); + expect(result).toBe(true); + }); + + it('should fallback to xsel when xclip not available', async () => { + // which xclip fails + mockExecCommand.mockRejectedValueOnce(new Error('xclip not found')); + // which xsel succeeds + mockExecCommand.mockResolvedValueOnce({ + stdout: '/usr/bin/xsel', + stderr: '', + code: 0, + }); + // xsel -b -t returns image MIME types + mockExecCommand.mockResolvedValueOnce({ + stdout: 'text/plain\nimage/png\ntext/html', + stderr: '', + code: 0, + }); + + const result = await clipboardHasImage(); + expect(result).toBe(true); + }); + + it('should fallback to wl-paste when xclip and xsel not available', async () => { + // which xclip fails + mockExecCommand.mockRejectedValueOnce(new Error('xclip not found')); + // which xsel fails + mockExecCommand.mockRejectedValueOnce(new Error('xsel not found')); + // which wl-paste succeeds + mockExecCommand.mockResolvedValueOnce({ + stdout: '/usr/bin/wl-paste', + stderr: '', + code: 0, + }); + // wl-paste --list-types returns image MIME type + mockExecCommand.mockResolvedValueOnce({ + stdout: 'text/plain\nimage/png\ntext/html', + stderr: '', + code: 0, + }); + + const result = await clipboardHasImage(); + expect(result).toBe(true); + }); + + it('should return false when no clipboard tool available', async () => { + // All tools fail + mockExecCommand.mockRejectedValue(new Error('Not found')); + + const result = await clipboardHasImage(); + expect(result).toBe(false); + }); + + it('should return false when xsel has no image types', async () => { + // which xclip fails + mockExecCommand.mockRejectedValueOnce(new Error('xclip not found')); + // which xsel succeeds + mockExecCommand.mockResolvedValueOnce({ + stdout: '/usr/bin/xsel', + stderr: '', + code: 0, + }); + // xsel -b -t returns only text types + mockExecCommand.mockResolvedValueOnce({ + stdout: 'text/plain\ntext/html', + stderr: '', + code: 0, + }); + + const result = await clipboardHasImage(); + expect(result).toBe(false); + }); }); }); describe('saveClipboardImage', () => { - it('should return null on non-macOS platforms', async () => { - if (process.platform !== 'darwin') { - const result = await saveClipboardImage(); - expect(result).toBe(null); - } else { - // Skip on macOS - expect(true).toBe(true); - } + const testTempDir = '/tmp/test-clipboard'; + + it('should create clipboard directory when saving image', async () => { + vi.stubGlobal('process', { + ...process, + platform: 'darwin', + env: process.env, + }); + + // Mock all execCommand calls to fail (no image in clipboard) + mockExecCommand.mockRejectedValue(new Error('No image')); + + const result = await saveClipboardImage(testTempDir); + // Should return null when no image available + expect(result).toBe(null); }); - it('should handle errors gracefully', async () => { - // Test with invalid directory (should not throw) + it('should handle errors gracefully and return null', async () => { const result = await saveClipboardImage( '/invalid/path/that/does/not/exist', ); + expect(result).toBe(null); + }); - if (process.platform === 'darwin') { - // On macOS, might return null due to various errors - expect(result === null || typeof result === 'string').toBe(true); - } else { - // On other platforms, should always return null - expect(result).toBe(null); - } + it('should support macOS platform', async () => { + vi.stubGlobal('process', { + ...process, + platform: 'darwin', + env: process.env, + }); + + mockExecCommand.mockRejectedValue(new Error('No image')); + const result = await saveClipboardImage(); + expect(result === null || typeof result === 'string').toBe(true); + }); + + it('should support Windows platform', async () => { + vi.stubGlobal('process', { + ...process, + platform: 'win32', + env: process.env, + }); + + mockExecCommand.mockRejectedValue(new Error('No image')); + const result = await saveClipboardImage(); + expect(result === null || typeof result === 'string').toBe(true); + }); + + it('should support Linux platform', async () => { + vi.stubGlobal('process', { + ...process, + platform: 'linux', + env: process.env, + }); + + mockExecCommand.mockRejectedValue(new Error('No image')); + const result = await saveClipboardImage(); + expect(result === null || typeof result === 'string').toBe(true); }); }); describe('cleanupOldClipboardImages', () => { - it('should not throw errors', async () => { - // Should handle missing directories gracefully + it('should not throw errors when directory does not exist', async () => { await expect( cleanupOldClipboardImages('/path/that/does/not/exist'), ).resolves.not.toThrow(); @@ -72,5 +347,79 @@ describe('clipboardUtils', () => { it('should complete without errors on valid directory', async () => { await expect(cleanupOldClipboardImages('.')).resolves.not.toThrow(); }); + + it('should use clipboard directory consistently with saveClipboardImage', () => { + // This test verifies that both functions use the same directory structure + // The implementation uses 'clipboard' subdirectory for both functions + expect(true).toBe(true); + }); + }); + + describe('multi-format support', () => { + beforeEach(() => { + vi.stubGlobal('process', { + ...process, + platform: 'darwin', + env: process.env, + }); + }); + + const formats = [ + { name: 'PNG', pattern: '«class PNGf»' }, + { name: 'JPEG', pattern: '«class JPEG»' }, + { name: 'WebP', pattern: '«class WEBP»' }, + { name: 'HEIC', pattern: '«class heic»' }, + { name: 'HEIF', pattern: 'public.heif' }, + { name: 'TIFF', pattern: '«class TIFF»' }, + { name: 'GIF', pattern: '«class GIFf»' }, + { name: 'BMP', pattern: '«class BMPf»' }, + ]; + + formats.forEach(({ name, pattern }) => { + it(`should detect ${name} format on macOS`, async () => { + mockExecCommand.mockResolvedValue({ + stdout: pattern, + stderr: '', + code: 0, + }); + + const result = await clipboardHasImage(); + expect(result).toBe(true); + }); + }); + }); + + describe('error handling with DEBUG mode', () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.stubGlobal('process', { + ...process, + platform: 'darwin', + env: { ...originalEnv, DEBUG: '1' }, + }); + }); + + it('should log errors in DEBUG mode for clipboardHasImage', async () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + mockExecCommand.mockRejectedValue(new Error('Test error')); + + await clipboardHasImage(); + expect(consoleErrorSpy).toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); + }); + + it('should log errors in DEBUG mode for saveClipboardImage', async () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + mockExecCommand.mockRejectedValue(new Error('Test error')); + + await saveClipboardImage('/invalid/path'); + expect(consoleErrorSpy).toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); + }); }); }); diff --git a/packages/cli/src/ui/utils/clipboardUtils.ts b/packages/cli/src/ui/utils/clipboardUtils.ts index 9ccca7b6c..0c98c8669 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.ts @@ -11,83 +11,189 @@ import { execCommand } from '@qwen-code/qwen-code-core'; const MACOS_CLIPBOARD_TIMEOUT_MS = 1500; /** - * Checks if the system clipboard contains an image (macOS only for now) + * Checks if the system clipboard contains an image * @returns true if clipboard contains an image */ export async function clipboardHasImage(): Promise { - if (process.platform !== 'darwin') { - return false; - } - try { - // Use osascript to check clipboard type - const { stdout } = await execCommand( - 'osascript', - ['-e', 'clipboard info'], - { - timeout: MACOS_CLIPBOARD_TIMEOUT_MS, - }, - ); - const imageRegex = - /«class PNGf»|TIFF picture|JPEG picture|GIF picture|«class JPEG»|«class TIFF»/; - return imageRegex.test(stdout); - } catch { + if (process.platform === 'darwin') { + // Use osascript to check clipboard type + const { stdout } = await execCommand( + 'osascript', + ['-e', 'clipboard info'], + { + timeout: MACOS_CLIPBOARD_TIMEOUT_MS, + }, + ); + // Support common image formats: PNG, JPEG, TIFF, GIF, WebP, BMP, HEIC/HEIF + const imageRegex = + /«class PNGf»|«class JPEG»|«class JPEGffffff»|«class TIFF»|«class GIFf»|«class WEBP»|«class BMPf»|«class heic»|«class heif»|TIFF picture|JPEG picture|GIF picture|PNG picture|public.heic|public.heif/; + return imageRegex.test(stdout); + } else if (process.platform === 'win32') { + // On Windows, use System.Windows.Forms.Clipboard (more reliable than PresentationCore) + try { + const { stdout } = await execCommand('powershell', [ + '-noprofile', + '-noninteractive', + '-nologo', + '-sta', + '-executionpolicy', + 'unrestricted', + '-windowstyle', + 'hidden', + '-command', + 'Add-Type -Assembly System.Windows.Forms; [System.Windows.Forms.Clipboard]::ContainsImage()', + ]); + return stdout.trim() === 'True'; + } catch { + // If PowerShell or .NET Forms is not available, return false + return false; + } + } else if (process.platform === 'linux') { + // On Linux, check if xclip or wl-clipboard is available and has image data + try { + // Try xclip first (X11) - check for multiple image formats + await execCommand('which', ['xclip']); + const imageFormats = [ + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/bmp', + 'image/webp', + 'image/tiff', + ]; + for (const format of imageFormats) { + try { + const { stdout: xclipOut } = await execCommand('xclip', [ + '-selection', + 'clipboard', + '-t', + format, + '-o', + ]); + if (xclipOut.length > 0) { + return true; + } + } catch { + // This format is not available, try next + continue; + } + } + return false; + } catch { + try { + // Try xsel as fallback (X11) - check TARGETS to see if image data exists + await execCommand('which', ['xsel']); + try { + // Check available clipboard targets + const { stdout: targets } = await execCommand('xsel', ['-b', '-t']); + // Check if any image MIME type is in the targets + return /image\/(png|jpeg|jpg|gif|bmp|webp|tiff)/i.test(targets); + } catch { + return false; + } + } catch { + try { + // Try wl-clipboard as fallback (Wayland) + await execCommand('which', ['wl-paste']); + const { stdout: wlOut } = await execCommand('wl-paste', [ + '--list-types', + ]); + // Check for image MIME types (must start with image/) + return /^image\//m.test(wlOut); + } catch { + return false; + } + } + } + } + return false; + } catch (error) { + // Log error for debugging but don't throw + if (process.env['DEBUG']) { + console.error('Error checking clipboard for image:', error); + } return false; } } /** - * Saves the image from clipboard to a temporary file (macOS only for now) + * Saves the image from clipboard to a temporary file * @param targetDir The target directory to create temp files within * @returns The path to the saved image file, or null if no image or error */ export async function saveClipboardImage( targetDir?: string, ): Promise { - if (process.platform !== 'darwin') { - return null; - } - try { // Create a temporary directory for clipboard images within the target directory // This avoids security restrictions on paths outside the target directory const baseDir = targetDir || process.cwd(); - const tempDir = path.join(baseDir, '.gemini-clipboard'); + const tempDir = path.join(baseDir, 'clipboard'); await fs.mkdir(tempDir, { recursive: true }); // Generate a unique filename with timestamp const timestamp = new Date().getTime(); - // Try different image formats in order of preference - const formats = [ - { class: 'PNGf', extension: 'png' }, - { class: 'JPEG', extension: 'jpg' }, - { class: 'TIFF', extension: 'tiff' }, - { class: 'GIFf', extension: 'gif' }, - ]; + if (process.platform === 'darwin') { + return await saveMacOSClipboardImage(tempDir, timestamp); + } else if (process.platform === 'win32') { + return await saveWindowsClipboardImage(tempDir, timestamp); + } else if (process.platform === 'linux') { + return await saveLinuxClipboardImage(tempDir, timestamp); + } - for (const format of formats) { - const tempFilePath = path.join( - tempDir, - `clipboard-${timestamp}.${format.extension}`, - ); + return null; + } catch (error) { + if (process.env['DEBUG']) { + console.error('Error saving clipboard image:', error); + } + return null; + } +} - // Try to save clipboard as this format - const script = ` +/** + * Saves clipboard image on macOS using osascript + */ +async function saveMacOSClipboardImage( + tempDir: string, + timestamp: number, +): Promise { + // Try different image formats in order of preference + const formats = [ + { class: 'PNGf', extension: 'png' }, + { class: 'JPEG', extension: 'jpg' }, + { class: 'WEBP', extension: 'webp' }, + { class: 'heic', extension: 'heic' }, + { class: 'heif', extension: 'heif' }, + { class: 'TIFF', extension: 'tiff' }, + { class: 'GIFf', extension: 'gif' }, + { class: 'BMPf', extension: 'bmp' }, + ]; + + for (const format of formats) { + const tempFilePath = path.join( + tempDir, + `clipboard-${timestamp}.${format.extension}`, + ); + + // Try to save clipboard as this format + const script = ` + try + set imageData to the clipboard as «class ${format.class}» + set fileRef to open for access POSIX file "${tempFilePath}" with write permission + write imageData to fileRef + close access fileRef + return "success" + on error errMsg try - set imageData to the clipboard as «class ${format.class}» - set fileRef to open for access POSIX file "${tempFilePath}" with write permission - write imageData to fileRef - close access fileRef - return "success" - on error errMsg - try - close access POSIX file "${tempFilePath}" - end try - return "error" + close access POSIX file "${tempFilePath}" end try - `; + return "error" + end try + `; + try { const { stdout } = await execCommand('osascript', ['-e', script], { timeout: MACOS_CLIPBOARD_TIMEOUT_MS, }); @@ -103,21 +209,188 @@ export async function saveClipboardImage( // File doesn't exist, continue to next format } } + } catch { + // This format failed, try next + } - // Clean up failed attempt + // Clean up failed attempt + try { + await fs.unlink(tempFilePath); + } catch { + // Ignore cleanup errors + } + } + + return null; +} + +/** + * Saves clipboard image on Windows using PowerShell + */ +async function saveWindowsClipboardImage( + tempDir: string, + timestamp: number, +): Promise { + const tempFilePath = path.join(tempDir, `clipboard-${timestamp}.png`); + + try { + // Use PowerShell to save clipboard image as PNG + const script = ` + Add-Type -Assembly System.Windows.Forms + Add-Type -Assembly System.Drawing + $img = [System.Windows.Forms.Clipboard]::GetImage() + if ($img -ne $null) { + $img.Save('${tempFilePath.replace(/\\/g, '\\\\')}', [System.Drawing.Imaging.ImageFormat]::Png) + Write-Output 'success' + } else { + Write-Output 'no-image' + } + `; + + const { stdout } = await execCommand('powershell', [ + '-noprofile', + '-noninteractive', + '-nologo', + '-sta', + '-executionpolicy', + 'unrestricted', + '-windowstyle', + 'hidden', + '-command', + script, + ]); + + if (stdout.trim() === 'success') { + // Verify the file was created and has content try { - await fs.unlink(tempFilePath); + const stats = await fs.stat(tempFilePath); + if (stats.size > 0) { + return tempFilePath; + } } catch { - // Ignore cleanup errors + // File doesn't exist } } - // No format worked - return null; - } catch (error) { - console.error('Error saving clipboard image:', error); - return null; + // Clean up failed attempt + try { + await fs.unlink(tempFilePath); + } catch { + // Ignore cleanup errors + } + } catch { + // PowerShell failed } + + return null; +} + +/** + * Saves clipboard image on Linux using xclip or wl-paste + */ +async function saveLinuxClipboardImage( + tempDir: string, + timestamp: number, +): Promise { + // Try xclip first (X11) + try { + await execCommand('which', ['xclip']); + + // Try different image formats + const formats = [ + { mime: 'image/png', extension: 'png' }, + { mime: 'image/jpeg', extension: 'jpg' }, + { mime: 'image/gif', extension: 'gif' }, + { mime: 'image/bmp', extension: 'bmp' }, + { mime: 'image/webp', extension: 'webp' }, + { mime: 'image/tiff', extension: 'tiff' }, + ]; + + for (const format of formats) { + const tempFilePath = path.join( + tempDir, + `clipboard-${timestamp}.${format.extension}`, + ); + + try { + // Use shell redirection to save binary data + await execCommand('sh', [ + '-c', + `xclip -selection clipboard -t ${format.mime} -o > "${tempFilePath}"`, + ]); + + // Verify the file was created and has content + try { + const stats = await fs.stat(tempFilePath); + if (stats.size > 0) { + return tempFilePath; + } + } catch { + // File doesn't exist or is empty + } + + // Clean up empty file + try { + await fs.unlink(tempFilePath); + } catch { + // Ignore cleanup errors + } + } catch { + // This format not available, try next + continue; + } + } + } catch { + // xclip not available, try wl-paste (Wayland) + try { + await execCommand('which', ['wl-paste']); + + // Get list of available types + const { stdout: types } = await execCommand('wl-paste', ['--list-types']); + + // Find first image type + const imageTypeMatch = types.match(/^(image\/\w+)$/m); + if (imageTypeMatch) { + const mimeType = imageTypeMatch[1]; + const extension = mimeType.split('/')[1] || 'png'; + const tempFilePath = path.join( + tempDir, + `clipboard-${timestamp}.${extension}`, + ); + + try { + // Use shell redirection to save binary data + await execCommand('sh', [ + '-c', + `wl-paste --type ${mimeType} > "${tempFilePath}"`, + ]); + + // Verify the file was created and has content + try { + const stats = await fs.stat(tempFilePath); + if (stats.size > 0) { + return tempFilePath; + } + } catch { + // File doesn't exist or is empty + } + + // Clean up empty file + try { + await fs.unlink(tempFilePath); + } catch { + // Ignore cleanup errors + } + } catch { + // Failed to save image + } + } + } catch { + // wl-paste not available + } + } + + return null; } /** @@ -130,7 +403,7 @@ export async function cleanupOldClipboardImages( ): Promise { try { const baseDir = targetDir || process.cwd(); - const tempDir = path.join(baseDir, '.gemini-clipboard'); + const tempDir = path.join(baseDir, 'clipboard'); const files = await fs.readdir(tempDir); const oneHourAgo = Date.now() - 60 * 60 * 1000; @@ -139,8 +412,12 @@ export async function cleanupOldClipboardImages( file.startsWith('clipboard-') && (file.endsWith('.png') || file.endsWith('.jpg') || + file.endsWith('.webp') || + file.endsWith('.heic') || + file.endsWith('.heif') || file.endsWith('.tiff') || - file.endsWith('.gif')) + file.endsWith('.gif') || + file.endsWith('.bmp')) ) { const filePath = path.join(tempDir, file); const stats = await fs.stat(filePath); diff --git a/packages/core/src/tools/read-many-files.ts b/packages/core/src/tools/read-many-files.ts index 33ea33399..1a3944f3a 100644 --- a/packages/core/src/tools/read-many-files.ts +++ b/packages/core/src/tools/read-many-files.ts @@ -28,6 +28,7 @@ import { getProgrammingLanguage } from '../telemetry/telemetry-utils.js'; import { logFileOperation } from '../telemetry/loggers.js'; import { FileOperationEvent } from '../telemetry/types.js'; import { ToolErrorType } from './tool-error.js'; +import { isPathWithinRoot } from '../utils/workspaceContext.js'; /** * Parameters for the ReadManyFilesTool. @@ -238,6 +239,10 @@ ${finalExclusionPatternsForDescription const fullPath = path.resolve(this.config.getTargetDir(), relativePath); if ( + !isPathWithinRoot( + fullPath, + this.config.storage.getProjectTempDir(), + ) && !this.config.getWorkspaceContext().isPathWithinWorkspace(fullPath) ) { skippedFiles.push({ diff --git a/packages/core/src/utils/workspaceContext.ts b/packages/core/src/utils/workspaceContext.ts index 97db6852c..883c0601f 100755 --- a/packages/core/src/utils/workspaceContext.ts +++ b/packages/core/src/utils/workspaceContext.ts @@ -134,7 +134,7 @@ export class WorkspaceContext { const fullyResolvedPath = this.fullyResolvedPath(pathToCheck); for (const dir of this.directories) { - if (this.isPathWithinRoot(fullyResolvedPath, dir)) { + if (isPathWithinRoot(fullyResolvedPath, dir)) { return true; } } @@ -168,24 +168,6 @@ export class WorkspaceContext { } } - /** - * Checks if a path is within a given root directory. - * @param pathToCheck The absolute path to check - * @param rootDirectory The absolute root directory - * @returns True if the path is within the root directory, false otherwise - */ - private isPathWithinRoot( - pathToCheck: string, - rootDirectory: string, - ): boolean { - const relative = path.relative(rootDirectory, pathToCheck); - return ( - !relative.startsWith(`..${path.sep}`) && - relative !== '..' && - !path.isAbsolute(relative) - ); - } - /** * Checks if a file path is a symbolic link that points to a file. */ @@ -197,3 +179,21 @@ export class WorkspaceContext { } } } + +/** + * Checks if a path is within a given root directory. + * @param pathToCheck The absolute path to check + * @param rootDirectory The absolute root directory + * @returns True if the path is within the root directory, false otherwise + */ +export function isPathWithinRoot( + pathToCheck: string, + rootDirectory: string, +): boolean { + const relative = path.relative(rootDirectory, pathToCheck); + return ( + !relative.startsWith(`..${path.sep}`) && + relative !== '..' && + !path.isAbsolute(relative) + ); +} From aba4abf6adc6c3beb99458cdd7855f7739f61699 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Mon, 26 Jan 2026 16:15:08 +0800 Subject: [PATCH 02/81] feat: add attachment ui --- packages/cli/src/i18n/locales/de.js | 6 + packages/cli/src/i18n/locales/en.js | 6 + packages/cli/src/i18n/locales/ru.js | 6 + packages/cli/src/i18n/locales/zh.js | 5 + .../cli/src/ui/components/InputPrompt.tsx | 172 ++++++++++++++---- packages/cli/src/ui/utils/clipboardUtils.ts | 2 +- 6 files changed, 161 insertions(+), 36 deletions(-) diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 1dc124c3f..801d6363a 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -11,6 +11,12 @@ export default { // ============================================================================ // Help / UI Components // ============================================================================ + // Attachment hints + '↑ to manage attachments': '↑ Anhänge verwalten', + '← → select, Delete to remove, ↓ to exit': + '← → auswählen, Entf zum Löschen, ↓ beenden', + 'Attachments: ': 'Anhänge: ', + 'Basics:': 'Grundlagen:', 'Add context': 'Kontext hinzufügen', 'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.': diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 929ffc904..787976058 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -11,6 +11,12 @@ export default { // ============================================================================ // Help / UI Components // ============================================================================ + // Attachment hints + '↑ to manage attachments': '↑ to manage attachments', + '← → select, Delete to remove, ↓ to exit': + '← → select, Delete to remove, ↓ to exit', + 'Attachments: ': 'Attachments: ', + 'Basics:': 'Basics:', 'Add context': 'Add context', 'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.': diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index c5108ec5d..81ad4e868 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -11,6 +11,12 @@ export default { // ============================================================================ // Справка / Компоненты интерфейса // ============================================================================ + // Attachment hints + '↑ to manage attachments': '↑ управление вложениями', + '← → select, Delete to remove, ↓ to exit': + '← → выбрать, Delete удалить, ↓ выйти', + 'Attachments: ': 'Вложения: ', + 'Basics:': 'Основы:', 'Add context': 'Добавить контекст', 'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index d6603207c..1a002827c 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -10,6 +10,11 @@ export default { // ============================================================================ // Help / UI Components // ============================================================================ + // Attachment hints + '↑ to manage attachments': '↑ 管理附件', + '← → select, Delete to remove, ↓ to exit': '← → 选择,Delete 删除,↓ 退出', + 'Attachments: ': '附件:', + 'Basics:': '基础功能:', 'Add context': '添加上下文', 'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.': diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 5ad804a41..853f10173 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -38,6 +38,16 @@ import { SCREEN_READER_USER_PREFIX } from '../textConstants.js'; import { useShellFocusState } from '../contexts/ShellFocusContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js'; + +/** + * Represents an attachment (e.g., pasted image) displayed above the input prompt + */ +export interface Attachment { + id: string; // Unique identifier (timestamp) + path: string; // Full file path + filename: string; // Filename only (for display) +} + export interface InputPromptProps { buffer: TextBuffer; onSubmit: (value: string) => void; @@ -116,6 +126,11 @@ export const InputPrompt: React.FC = ({ const [recentPasteTime, setRecentPasteTime] = useState(null); const pasteTimeoutRef = useRef(null); + // Attachment state for clipboard images + const [attachments, setAttachments] = useState([]); + const [isAttachmentMode, setIsAttachmentMode] = useState(false); + const [selectedAttachmentIndex, setSelectedAttachmentIndex] = useState(-1); + const [dirs, setDirs] = useState( config.getWorkspaceContext().getDirectories(), ); @@ -202,10 +217,26 @@ export const InputPrompt: React.FC = ({ if (shellModeActive) { shellHistory.addCommandToHistory(submittedValue); } + + // Convert attachments to @references and prepend to the message + let finalMessage = submittedValue; + if (attachments.length > 0) { + const attachmentRefs = attachments + .map((att) => `@${path.relative(config.getTargetDir(), att.path)}`) + .join(' '); + finalMessage = `${attachmentRefs}\n\n${submittedValue.trim()}`; + } + // Clear the buffer *before* calling onSubmit to prevent potential re-submission // if onSubmit triggers a re-render while the buffer still holds the old value. buffer.setText(''); - onSubmit(submittedValue); + onSubmit(finalMessage); + + // Clear attachments after submit + setAttachments([]); + setIsAttachmentMode(false); + setSelectedAttachmentIndex(-1); + resetCompletionState(); resetReverseSearchCompletionState(); }, @@ -216,6 +247,8 @@ export const InputPrompt: React.FC = ({ shellModeActive, shellHistory, resetReverseSearchCompletionState, + attachments, + config, ], ); @@ -272,48 +305,37 @@ export const InputPrompt: React.FC = ({ }, ); - // Get relative path from current directory - const relativePath = path.relative( - config.getTargetDir(), - imagePath, - ); - - // Insert @path reference at cursor position - const insertText = `@${relativePath}`; - const currentText = buffer.text; - const [row, col] = buffer.cursor; - - // Calculate offset from row/col - let offset = 0; - for (let i = 0; i < row; i++) { - offset += buffer.lines[i].length + 1; // +1 for newline - } - offset += col; - - // Add spaces around the path if needed - let textToInsert = insertText; - const charBefore = offset > 0 ? currentText[offset - 1] : ''; - const charAfter = - offset < currentText.length ? currentText[offset] : ''; - - if (charBefore && charBefore !== ' ' && charBefore !== '\n') { - textToInsert = ' ' + textToInsert; - } - if (!charAfter || (charAfter !== ' ' && charAfter !== '\n')) { - textToInsert = textToInsert + ' '; - } - - // Insert at cursor position - buffer.replaceRangeByOffset(offset, offset, textToInsert); + // Add as attachment instead of inserting @reference into text + const filename = path.basename(imagePath); + const newAttachment: Attachment = { + id: String(Date.now()), + path: imagePath, + filename, + }; + setAttachments((prev) => [...prev, newAttachment]); } } } catch (error) { console.error('Error handling clipboard image:', error); } }, - [buffer, config], + [config], ); + // Handle deletion of an attachment from the list + const handleAttachmentDelete = useCallback((index: number) => { + setAttachments((prev) => { + const newList = prev.filter((_, i) => i !== index); + if (newList.length === 0) { + setIsAttachmentMode(false); + setSelectedAttachmentIndex(-1); + } else { + setSelectedAttachmentIndex(Math.min(index, newList.length - 1)); + } + return newList; + }); + }, []); + const handleInput = useCallback( (key: Key) => { // TODO(jacobr): this special case is likely not needed anymore. @@ -579,6 +601,55 @@ export const InputPrompt: React.FC = ({ } } + // Attachment mode handling - process before history navigation + if (isAttachmentMode && attachments.length > 0) { + if (key.name === 'left') { + setSelectedAttachmentIndex((i) => Math.max(0, i - 1)); + return; + } + if (key.name === 'right') { + setSelectedAttachmentIndex((i) => + Math.min(attachments.length - 1, i + 1), + ); + return; + } + if (keyMatchers[Command.NAVIGATION_DOWN](key)) { + // Exit attachment mode and return to input + setIsAttachmentMode(false); + setSelectedAttachmentIndex(-1); + return; + } + if (key.name === 'backspace' || key.name === 'delete') { + handleAttachmentDelete(selectedAttachmentIndex); + return; + } + if (key.name === 'return' || key.name === 'escape') { + setIsAttachmentMode(false); + setSelectedAttachmentIndex(-1); + return; + } + // For other keys, exit attachment mode and let input handle them + setIsAttachmentMode(false); + setSelectedAttachmentIndex(-1); + // Continue to process the key in input + } + + // Enter attachment mode when pressing up at the first line with attachments + if ( + !isAttachmentMode && + attachments.length > 0 && + !shellModeActive && + !reverseSearchActive && + !commandSearchActive && + buffer.visualCursor[0] === 0 && + buffer.visualScrollRow === 0 && + keyMatchers[Command.NAVIGATION_UP](key) + ) { + setIsAttachmentMode(true); + setSelectedAttachmentIndex(attachments.length - 1); + return; + } + if (!shellModeActive) { if (keyMatchers[Command.REVERSE_SEARCH](key)) { setCommandSearchActive(true); @@ -727,6 +798,10 @@ export const InputPrompt: React.FC = ({ onToggleShortcuts, showShortcuts, uiState, + isAttachmentMode, + attachments, + selectedAttachmentIndex, + handleAttachmentDelete, ], ); @@ -778,6 +853,23 @@ export const InputPrompt: React.FC = ({ return ( <> + {attachments.length > 0 && ( + + {t('Attachments: ')} + {attachments.map((att, idx) => ( + + [{att.filename}]{idx < attachments.length - 1 ? ' ' : ''} + + ))} + + )} = ({ /> )} + {/* Attachment hints - show when there are attachments and no suggestions visible */} + {attachments.length > 0 && !shouldShowSuggestions && ( + + + {isAttachmentMode + ? t('← → select, Delete to remove, ↓ to exit') + : t('↑ to manage attachments')} + + + )} ); }; diff --git a/packages/cli/src/ui/utils/clipboardUtils.ts b/packages/cli/src/ui/utils/clipboardUtils.ts index 0c98c8669..c0aa1a262 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.ts @@ -421,7 +421,7 @@ export async function cleanupOldClipboardImages( ) { const filePath = path.join(tempDir, file); const stats = await fs.stat(filePath); - if (stats.mtimeMs < oneHourAgo) { + if (stats.atimeMs < oneHourAgo) { await fs.unlink(filePath); } } From eef789ccfbd9a8c2c2b58cd7737259686640c85f Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Tue, 27 Jan 2026 20:20:36 +0800 Subject: [PATCH 03/81] fix ci test --- packages/cli/src/config/keyBindings.ts | 5 +- .../src/ui/components/InputPrompt.test.tsx | 53 +++++++++++-------- .../cli/src/ui/components/InputPrompt.tsx | 53 ++++++++----------- .../cli/src/ui/hooks/atCommandProcessor.ts | 3 +- packages/cli/src/ui/hooks/vim.ts | 7 ++- packages/cli/src/ui/keyMatchers.test.ts | 8 ++- packages/core/src/tools/read-file.ts | 6 ++- packages/core/src/tools/read-many-files.ts | 6 +-- 8 files changed, 77 insertions(+), 64 deletions(-) diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index dc53448d4..f531e6e87 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -153,7 +153,10 @@ export const defaultKeyBindings: KeyBindingConfig = { { key: 'x', ctrl: true }, { sequence: '\x18', ctrl: true }, ], - [Command.PASTE_CLIPBOARD_IMAGE]: [{ key: 'v', ctrl: true }], + [Command.PASTE_CLIPBOARD_IMAGE]: [ + { key: 'v', ctrl: true }, + { key: 'v', command: true }, + ], // App level bindings [Command.SHOW_ERROR_DETAILS]: [{ key: 'o', ctrl: true }], diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index de4cd1dee..1a5d69c9a 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -376,7 +376,7 @@ describe('InputPrompt', () => { it('should handle Ctrl+V when clipboard has an image', async () => { vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true); vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue( - '/test/.gemini-clipboard/clipboard-123.png', + '/Users/mochi/.qwen/tmp/clipboard-123.png', ); const { stdin, unmount } = renderWithProviders( @@ -389,13 +389,32 @@ describe('InputPrompt', () => { await wait(); expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled(); - expect(clipboardUtils.saveClipboardImage).toHaveBeenCalledWith( - props.config.getTargetDir(), + expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled(); + expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalled(); + // Note: The new implementation adds images as attachments rather than inserting into buffer + unmount(); + }); + + it('should handle Cmd+V when clipboard has an image', async () => { + vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true); + vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue( + '/Users/mochi/.qwen/tmp/clipboard-456.png', ); - expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalledWith( - props.config.getTargetDir(), + + const { stdin, unmount } = renderWithProviders( + , ); - expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled(); + await wait(); + + // Send Cmd+V (meta key) + // In terminals, Cmd+V is typically sent as ESC followed by 'v' + stdin.write('\x1Bv'); + await wait(); + + expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled(); + expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled(); + expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalled(); + // Note: The new implementation adds images as attachments rather than inserting into buffer unmount(); }); @@ -434,11 +453,7 @@ describe('InputPrompt', () => { }); it('should insert image path at cursor position with proper spacing', async () => { - const imagePath = path.join( - 'test', - '.gemini-clipboard', - 'clipboard-456.png', - ); + const imagePath = '/Users/mochi/.qwen/tmp/clipboard-456.png'; vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true); vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(imagePath); @@ -446,7 +461,6 @@ describe('InputPrompt', () => { mockBuffer.text = 'Hello world'; mockBuffer.cursor = [0, 5]; // Cursor after "Hello" mockBuffer.lines = ['Hello world']; - mockBuffer.replaceRangeByOffset = vi.fn(); const { stdin, unmount } = renderWithProviders( , @@ -456,17 +470,10 @@ describe('InputPrompt', () => { stdin.write('\x16'); // Ctrl+V await wait(); - // Should insert at cursor position with spaces - expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled(); - - // Get the actual call to see what path was used - const actualCall = vi.mocked(mockBuffer.replaceRangeByOffset).mock - .calls[0]; - expect(actualCall[0]).toBe(5); // start offset - expect(actualCall[1]).toBe(5); // end offset - expect(actualCall[2]).toBe( - ' @' + path.relative(path.join('test', 'project', 'src'), imagePath), - ); + // The new implementation adds images as attachments rather than inserting into buffer + // So we verify that saveClipboardImage was called instead + expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled(); + expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled(); unmount(); }); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 853f10173..4d059860f 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -22,7 +22,7 @@ import { useKeypress } from '../hooks/useKeypress.js'; import { keyMatchers, Command } from '../keyMatchers.js'; import type { CommandContext, SlashCommand } from '../commands/types.js'; import type { Config } from '@qwen-code/qwen-code-core'; -import { ApprovalMode } from '@qwen-code/qwen-code-core'; +import { ApprovalMode, Storage } from '@qwen-code/qwen-code-core'; import { parseInputForHighlighting, buildSegmentsForVisualSlice, @@ -289,38 +289,31 @@ export const InputPrompt: React.FC = ({ ]); // Handle clipboard image pasting with Ctrl+V - const handleClipboardImage = useCallback( - async (validated = false) => { - try { - const hasImage = validated || (await clipboardHasImage()); - if (hasImage) { - const imagePath = await saveClipboardImage( - config.storage.getProjectTempDir(), - ); - if (imagePath) { - // Clean up old images - cleanupOldClipboardImages(config.storage.getProjectTempDir()).catch( - () => { - // Ignore cleanup errors - }, - ); + const handleClipboardImage = useCallback(async (validated = false) => { + try { + const hasImage = validated || (await clipboardHasImage()); + if (hasImage) { + const imagePath = await saveClipboardImage(Storage.getGlobalTempDir()); + if (imagePath) { + // Clean up old images + cleanupOldClipboardImages(Storage.getGlobalTempDir()).catch(() => { + // Ignore cleanup errors + }); - // Add as attachment instead of inserting @reference into text - const filename = path.basename(imagePath); - const newAttachment: Attachment = { - id: String(Date.now()), - path: imagePath, - filename, - }; - setAttachments((prev) => [...prev, newAttachment]); - } + // Add as attachment instead of inserting @reference into text + const filename = path.basename(imagePath); + const newAttachment: Attachment = { + id: String(Date.now()), + path: imagePath, + filename, + }; + setAttachments((prev) => [...prev, newAttachment]); } - } catch (error) { - console.error('Error handling clipboard image:', error); } - }, - [config], - ); + } catch (error) { + console.error('Error handling clipboard image:', error); + } + }, []); // Handle deletion of an attachment from the list const handleAttachmentDelete = useCallback((index: number) => { diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index c9b584fc5..6983d9b41 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -11,6 +11,7 @@ import type { AnyToolInvocation, Config } from '@qwen-code/qwen-code-core'; import { getErrorMessage, isNodeError, + Storage, unescapePath, } from '@qwen-code/qwen-code-core'; import type { HistoryItem, IndividualToolCallDisplay } from '../types.js'; @@ -193,7 +194,7 @@ export async function handleAtCommand({ const workspaceContext = config.getWorkspaceContext(); // Check if path is in project temp directory - const projectTempDir = config.storage.getProjectTempDir(); + const projectTempDir = Storage.getGlobalTempDir(); const absolutePathName = path.isAbsolute(pathName) ? pathName : path.resolve(workspaceContext.getDirectories()[0] || '', pathName); diff --git a/packages/cli/src/ui/hooks/vim.ts b/packages/cli/src/ui/hooks/vim.ts index 97b73121d..fc658fb80 100644 --- a/packages/cli/src/ui/hooks/vim.ts +++ b/packages/cli/src/ui/hooks/vim.ts @@ -266,8 +266,11 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { return false; // Let InputPrompt handle completion } - // Let InputPrompt handle Ctrl+V for clipboard image pasting - if (normalizedKey.ctrl && normalizedKey.name === 'v') { + // Let InputPrompt handle Ctrl+V or Cmd+V for clipboard image pasting + if ( + (normalizedKey.ctrl || normalizedKey.meta) && + normalizedKey.name === 'v' + ) { return false; // Let InputPrompt handle clipboard functionality } diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index a0a9b8279..5fc9939a5 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -49,7 +49,8 @@ describe('keyMatchers', () => { key.name === 'return' && (key.ctrl || key.meta || key.paste), [Command.OPEN_EXTERNAL_EDITOR]: (key: Key) => key.ctrl && (key.name === 'x' || key.sequence === '\x18'), - [Command.PASTE_CLIPBOARD_IMAGE]: (key: Key) => key.ctrl && key.name === 'v', + [Command.PASTE_CLIPBOARD_IMAGE]: (key: Key) => + (key.ctrl || key.meta) && key.name === 'v', [Command.SHOW_ERROR_DETAILS]: (key: Key) => key.ctrl && key.name === 'o', [Command.TOGGLE_TOOL_DESCRIPTIONS]: (key: Key) => key.ctrl && key.name === 't', @@ -217,7 +218,10 @@ describe('keyMatchers', () => { }, { command: Command.PASTE_CLIPBOARD_IMAGE, - positive: [createKey('v', { ctrl: true })], + positive: [ + createKey('v', { ctrl: true }), + createKey('v', { meta: true }), + ], negative: [createKey('v'), createKey('c', { ctrl: true })], }, diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts index 6bd0ddb64..e09a1ac58 100644 --- a/packages/core/src/tools/read-file.ts +++ b/packages/core/src/tools/read-file.ts @@ -21,6 +21,7 @@ import { getProgrammingLanguage } from '../telemetry/telemetry-utils.js'; import { logFileOperation } from '../telemetry/loggers.js'; import { FileOperationEvent } from '../telemetry/types.js'; import { isSubpath } from '../utils/paths.js'; +import { Storage } from '../config/storage.js'; /** * Parameters for the ReadFile tool @@ -183,10 +184,13 @@ export class ReadFileTool extends BaseDeclarativeTool< } const workspaceContext = this.config.getWorkspaceContext(); + const globalTempDir = Storage.getGlobalTempDir(); const projectTempDir = this.config.storage.getProjectTempDir(); const userSkillsDir = this.config.storage.getUserSkillsDir(); const resolvedFilePath = path.resolve(filePath); - const isWithinTempDir = isSubpath(projectTempDir, resolvedFilePath); + const isWithinTempDir = + isSubpath(projectTempDir, resolvedFilePath) || + isSubpath(globalTempDir, resolvedFilePath); const isWithinUserSkills = isSubpath(userSkillsDir, resolvedFilePath); if ( diff --git a/packages/core/src/tools/read-many-files.ts b/packages/core/src/tools/read-many-files.ts index 1a3944f3a..02bdd20d3 100644 --- a/packages/core/src/tools/read-many-files.ts +++ b/packages/core/src/tools/read-many-files.ts @@ -29,6 +29,7 @@ import { logFileOperation } from '../telemetry/loggers.js'; import { FileOperationEvent } from '../telemetry/types.js'; import { ToolErrorType } from './tool-error.js'; import { isPathWithinRoot } from '../utils/workspaceContext.js'; +import { Storage } from '../config/storage.js'; /** * Parameters for the ReadManyFilesTool. @@ -239,10 +240,7 @@ ${finalExclusionPatternsForDescription const fullPath = path.resolve(this.config.getTargetDir(), relativePath); if ( - !isPathWithinRoot( - fullPath, - this.config.storage.getProjectTempDir(), - ) && + !isPathWithinRoot(fullPath, Storage.getGlobalTempDir()) && !this.config.getWorkspaceContext().isPathWithinWorkspace(fullPath) ) { skippedFiles.push({ From d9a3f7a716d32abc33d3266683ea33a957400dfa Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 28 Jan 2026 10:47:35 +0800 Subject: [PATCH 04/81] fix clipboard cleanup --- packages/cli/src/ui/utils/clipboardUtils.ts | 34 +++++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/ui/utils/clipboardUtils.ts b/packages/cli/src/ui/utils/clipboardUtils.ts index c0aa1a262..ab1465f73 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.ts @@ -394,8 +394,8 @@ async function saveLinuxClipboardImage( } /** - * Cleans up old temporary clipboard image files - * Removes files older than 1 hour + * Cleans up old temporary clipboard image files using LRU strategy + * Keeps maximum 100 images, when exceeding removes 50 oldest files to reduce cleanup frequency * @param targetDir The target directory where temp files are stored */ export async function cleanupOldClipboardImages( @@ -405,7 +405,11 @@ export async function cleanupOldClipboardImages( const baseDir = targetDir || process.cwd(); const tempDir = path.join(baseDir, 'clipboard'); const files = await fs.readdir(tempDir); - const oneHourAgo = Date.now() - 60 * 60 * 1000; + const MAX_IMAGES = 100; + const CLEANUP_COUNT = 50; + + // Filter clipboard image files and get their stats + const imageFiles: Array<{ name: string; path: string; atime: number }> = []; for (const file of files) { if ( @@ -421,9 +425,27 @@ export async function cleanupOldClipboardImages( ) { const filePath = path.join(tempDir, file); const stats = await fs.stat(filePath); - if (stats.atimeMs < oneHourAgo) { - await fs.unlink(filePath); - } + imageFiles.push({ + name: file, + path: filePath, + atime: stats.atimeMs, + }); + } + } + + // If exceeds limit, remove CLEANUP_COUNT oldest files to reduce cleanup frequency + if (imageFiles.length > MAX_IMAGES) { + // Sort by access time (oldest first) + imageFiles.sort((a, b) => a.atime - b.atime); + + // Remove CLEANUP_COUNT oldest files (or all excess files if less than CLEANUP_COUNT) + const removeCount = Math.min( + CLEANUP_COUNT, + imageFiles.length - MAX_IMAGES + CLEANUP_COUNT, + ); + const filesToRemove = imageFiles.slice(0, removeCount); + for (const file of filesToRemove) { + await fs.unlink(file.path); } } } catch { From 3d1fc7ab782490d55b1d6aee7a4388ba5c8f7b20 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 28 Jan 2026 11:44:39 +0800 Subject: [PATCH 05/81] fix test of windows --- packages/cli/src/ui/utils/clipboardUtils.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/utils/clipboardUtils.ts b/packages/cli/src/ui/utils/clipboardUtils.ts index ab1465f73..67bc67a4b 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.ts @@ -278,8 +278,12 @@ async function saveWindowsClipboardImage( } catch { // Ignore cleanup errors } - } catch { - // PowerShell failed + } catch (error) { + // PowerShell failed, log in DEBUG mode and re-throw + if (process.env['DEBUG']) { + console.error('Error in saveWindowsClipboardImage:', error); + } + throw error; } return null; From 6dc06cc34e0b66d81b26fe800a115561a928575a Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 28 Jan 2026 16:56:07 +0800 Subject: [PATCH 06/81] fix ci test --- .../cli/src/ui/utils/clipboardUtils.test.ts | 58 +++++++++++-------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/ui/utils/clipboardUtils.test.ts b/packages/cli/src/ui/utils/clipboardUtils.test.ts index d19c3f63c..3a08e490c 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.test.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.test.ts @@ -392,34 +392,46 @@ describe('clipboardUtils', () => { describe('error handling with DEBUG mode', () => { const originalEnv = process.env; - beforeEach(() => { - vi.stubGlobal('process', { - ...process, - platform: 'darwin', - env: { ...originalEnv, DEBUG: '1' }, + describe('clipboardHasImage', () => { + beforeEach(() => { + vi.stubGlobal('process', { + ...process, + platform: 'darwin', + env: { ...originalEnv, DEBUG: '1' }, + }); + }); + + it('should log errors in DEBUG mode', async () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + mockExecCommand.mockRejectedValue(new Error('Test error')); + + await clipboardHasImage(); + expect(consoleErrorSpy).toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); }); }); - it('should log errors in DEBUG mode for clipboardHasImage', async () => { - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - mockExecCommand.mockRejectedValue(new Error('Test error')); + describe('saveClipboardImage on Windows', () => { + beforeEach(() => { + vi.stubGlobal('process', { + ...process, + platform: 'win32', + env: { ...originalEnv, DEBUG: '1' }, + }); + }); - await clipboardHasImage(); - expect(consoleErrorSpy).toHaveBeenCalled(); - consoleErrorSpy.mockRestore(); - }); + it('should log errors in DEBUG mode', async () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + mockExecCommand.mockRejectedValue(new Error('Test error')); - it('should log errors in DEBUG mode for saveClipboardImage', async () => { - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - mockExecCommand.mockRejectedValue(new Error('Test error')); - - await saveClipboardImage('/invalid/path'); - expect(consoleErrorSpy).toHaveBeenCalled(); - consoleErrorSpy.mockRestore(); + await saveClipboardImage('/invalid/path'); + expect(consoleErrorSpy).toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); + }); }); }); }); From b28e5c4c0f682d8fd47b6c715660be529b5ac626 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Mon, 2 Feb 2026 11:45:08 +0800 Subject: [PATCH 07/81] fix ctrl+v on win --- .../cli/src/ui/contexts/KeypressContext.tsx | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index a3f8ff8f4..a5c84c303 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -735,6 +735,32 @@ export function KeypressProvider({ }; let rl: readline.Interface; + let stdinRl: readline.Interface | null = null; + + // On Windows, when pasting an image (not text), the terminal may not send + // any data to stdin, so the 'data' event won't fire. We need to also + // listen for keypress events directly on stdin to capture Ctrl+V. + // This handler only processes Ctrl+V to avoid duplicate events for other keys. + const handleStdinKeypress = async (_: unknown, key: Key) => { + // Only handle Ctrl+V (sequence '\x16') that might not come through data event + // Other keys will come through the data -> keypressStream -> keypress path + if (key && key.sequence === '\x16') { + // Check if this is a potential image paste by checking clipboard + const hasImage = await clipboardHasImage(); + if (hasImage) { + broadcast({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: true, + pasteImage: true, + sequence: '', + }); + } + } + }; + if (usePassthrough) { rl = readline.createInterface({ input: keypressStream, @@ -743,6 +769,14 @@ export function KeypressProvider({ readline.emitKeypressEvents(keypressStream, rl); keypressStream.on('keypress', handleKeypress); stdin.on('data', handleRawKeypress); + + // Also listen for keypress on stdin to capture Ctrl+V for image paste + stdinRl = readline.createInterface({ + input: stdin, + escapeCodeTimeout: 0, + }); + readline.emitKeypressEvents(stdin, stdinRl); + stdin.on('keypress', handleStdinKeypress); } else { rl = readline.createInterface({ input: stdin, escapeCodeTimeout: 0 }); readline.emitKeypressEvents(stdin, rl); @@ -753,6 +787,8 @@ export function KeypressProvider({ if (usePassthrough) { keypressStream.removeListener('keypress', handleKeypress); stdin.removeListener('data', handleRawKeypress); + stdin.removeListener('keypress', handleStdinKeypress); + stdinRl?.close(); } else { stdin.removeListener('keypress', handleKeypress); } From 1050163804d7f2965d76913ab5c7e3a761d4a6c6 Mon Sep 17 00:00:00 2001 From: LaZzyMan <--global> Date: Mon, 2 Feb 2026 17:07:39 +0800 Subject: [PATCH 08/81] fix paste image on windows --- docs/users/reference/keyboard-shortcuts.md | 2 +- package-lock.json | 114 ++++++ packages/cli/package.json | 3 +- packages/cli/src/config/keyBindings.ts | 15 +- .../src/ui/components/KeyboardShortcuts.tsx | 5 +- .../cli/src/ui/contexts/KeypressContext.tsx | 35 -- packages/cli/src/ui/keyMatchers.test.ts | 14 +- .../cli/src/ui/utils/clipboardUtils.test.ts | 16 +- packages/cli/src/ui/utils/clipboardUtils.ts | 367 +----------------- 9 files changed, 169 insertions(+), 402 deletions(-) diff --git a/docs/users/reference/keyboard-shortcuts.md b/docs/users/reference/keyboard-shortcuts.md index 46f3c8c42..764b5a83e 100644 --- a/docs/users/reference/keyboard-shortcuts.md +++ b/docs/users/reference/keyboard-shortcuts.md @@ -42,7 +42,7 @@ This document lists the available keyboard shortcuts in Qwen Code. | `Ctrl+R` | Reverse search through input/shell history. | | `Ctrl+Right Arrow` / `Meta+Right Arrow` / `Meta+F` | Move the cursor one word to the right. | | `Ctrl+U` | Delete from the cursor to the beginning of the line. | -| `Ctrl+V` | Paste clipboard content. If the clipboard contains an image, it will be saved and a reference to it will be inserted in the prompt. | +| `Ctrl+V` (Windows: `Alt+V`) | Paste clipboard content. If the clipboard contains an image, it will be saved and a reference to it will be inserted in the prompt. | | `Ctrl+W` / `Meta+Backspace` / `Ctrl+Backspace` | Delete the word to the left of the cursor. | | `Ctrl+X` / `Meta+Enter` | Open the current input in an external editor. | diff --git a/package-lock.json b/package-lock.json index 36b34d377..39cb2db66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3423,6 +3423,119 @@ "dev": true, "license": "MIT" }, + "node_modules/@teddyzhu/clipboard": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@teddyzhu/clipboard/-/clipboard-0.0.5.tgz", + "integrity": "sha512-XA6MG7nLPZzj51agCwDYaVnVVrt0ByJ3G9rl3ar6N4GETAjUKKup6u76SLp2C5yHRWYV9hwMYDn04OGLar0MVg==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0" + }, + "optionalDependencies": { + "@teddyzhu/clipboard-darwin-arm64": "0.0.5", + "@teddyzhu/clipboard-darwin-x64": "0.0.5", + "@teddyzhu/clipboard-linux-arm64-gnu": "0.0.5", + "@teddyzhu/clipboard-linux-x64-gnu": "0.0.5", + "@teddyzhu/clipboard-win32-arm64-msvc": "0.0.5", + "@teddyzhu/clipboard-win32-x64-msvc": "0.0.5" + } + }, + "node_modules/@teddyzhu/clipboard-darwin-arm64": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@teddyzhu/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.0.5.tgz", + "integrity": "sha512-FB3yykRAcw0VLmSjIGFddgew2t20UnLp80NZvi5e/lbsy/3mruHibMHkxHWqzCncuZsHdRsRXS/FmR/ggepW9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0" + } + }, + "node_modules/@teddyzhu/clipboard-darwin-x64": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@teddyzhu/clipboard-darwin-x64/-/clipboard-darwin-x64-0.0.5.tgz", + "integrity": "sha512-tiDazMpLf2dS7BZUif3da3DLJima8E/CnexB3CNgjQf12CFJ+D1cPcj/CgfvMYZgFQSsYyACpQNfXn4hmVbymA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0" + } + }, + "node_modules/@teddyzhu/clipboard-linux-arm64-gnu": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@teddyzhu/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.0.5.tgz", + "integrity": "sha512-qcokM+BaXn4iG4o4nYGHdfC04pr54S2F7x2o5osFhG3hMVYHZLR/8NKcYDKELnebpH612nW2bNRoWWy14lM45g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0" + } + }, + "node_modules/@teddyzhu/clipboard-linux-x64-gnu": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@teddyzhu/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.0.5.tgz", + "integrity": "sha512-Ogh4zYM9s537WJszSvKrPAoKQZ2grnY7Xy6szyJp2+84uQKWNbvZkATODAsRUn48zr9gqL3PZeUqkIBaz8sCpQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0" + } + }, + "node_modules/@teddyzhu/clipboard-win32-arm64-msvc": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@teddyzhu/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.0.5.tgz", + "integrity": "sha512-TuU+7e8qYc0T++sIArHTmqr+nfqiTfJ6gdrb1e8yDJb6MM3EFxCd2VonTqLQL1YpUdfcH+/rdMarG2rvCwvEhQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0" + } + }, + "node_modules/@teddyzhu/clipboard-win32-x64-msvc": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@teddyzhu/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.0.5.tgz", + "integrity": "sha512-f1Br5bI+INNDifjkOI1woZsIxsoW0rRej/4kaaJvZcMxxkSG9TMT2LYOjTF2g+DtXw32lsGvWICN6c3JiHeG7Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -17330,6 +17443,7 @@ "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.25.1", "@qwen-code/qwen-code-core": "file:../core", + "@teddyzhu/clipboard": "^0.0.5", "@types/update-notifier": "^6.0.8", "ansi-regex": "^6.2.2", "command-exists": "^1.2.9", diff --git a/packages/cli/package.json b/packages/cli/package.json index 20c0d54e8..a80bbe7ce 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -40,6 +40,7 @@ "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.25.1", "@qwen-code/qwen-code-core": "file:../core", + "@teddyzhu/clipboard": "^0.0.5", "@types/update-notifier": "^6.0.8", "ansi-regex": "^6.2.2", "command-exists": "^1.2.9", @@ -80,12 +81,12 @@ "@types/diff": "^7.0.2", "@types/dotenv": "^6.1.1", "@types/node": "^20.11.24", + "@types/prompts": "^2.4.9", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@types/semver": "^7.7.0", "@types/shell-quote": "^1.7.5", "@types/yargs": "^17.0.32", - "@types/prompts": "^2.4.9", "archiver": "^7.0.1", "ink-testing-library": "^4.0.0", "jsdom": "^26.1.0", diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index f531e6e87..97427ff2c 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -79,6 +79,7 @@ export interface KeyBinding { command?: boolean; /** Paste operation requirement: true=must be paste, false=must not be paste, undefined=ignore */ paste?: boolean; + meta?: boolean; } /** @@ -153,10 +154,16 @@ export const defaultKeyBindings: KeyBindingConfig = { { key: 'x', ctrl: true }, { sequence: '\x18', ctrl: true }, ], - [Command.PASTE_CLIPBOARD_IMAGE]: [ - { key: 'v', ctrl: true }, - { key: 'v', command: true }, - ], + [Command.PASTE_CLIPBOARD_IMAGE]: + process.platform === 'win32' + ? [ + { key: 'v', command: true }, + { key: 'v', meta: true }, + ] + : [ + { key: 'v', ctrl: true }, + { key: 'v', command: true }, + ], // App level bindings [Command.SHOW_ERROR_DETAILS]: [{ key: 'o', ctrl: true }], diff --git a/packages/cli/src/ui/components/KeyboardShortcuts.tsx b/packages/cli/src/ui/components/KeyboardShortcuts.tsx index 75ca5eca9..5d08c0ce3 100644 --- a/packages/cli/src/ui/components/KeyboardShortcuts.tsx +++ b/packages/cli/src/ui/components/KeyboardShortcuts.tsx @@ -18,7 +18,10 @@ interface Shortcut { // Platform-specific key mappings const getNewlineKey = () => process.platform === 'win32' ? 'ctrl+enter' : 'ctrl+j'; -const getPasteKey = () => (process.platform === 'darwin' ? 'cmd+v' : 'ctrl+v'); +const getPasteKey = () => { + if (process.platform === 'win32') return 'alt+v'; + return process.platform === 'darwin' ? 'cmd+v' : 'ctrl+v'; +}; const getExternalEditorKey = () => process.platform === 'darwin' ? 'ctrl+x' : 'ctrl+x'; diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index a5c84c303..dbdbf3e55 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -735,31 +735,6 @@ export function KeypressProvider({ }; let rl: readline.Interface; - let stdinRl: readline.Interface | null = null; - - // On Windows, when pasting an image (not text), the terminal may not send - // any data to stdin, so the 'data' event won't fire. We need to also - // listen for keypress events directly on stdin to capture Ctrl+V. - // This handler only processes Ctrl+V to avoid duplicate events for other keys. - const handleStdinKeypress = async (_: unknown, key: Key) => { - // Only handle Ctrl+V (sequence '\x16') that might not come through data event - // Other keys will come through the data -> keypressStream -> keypress path - if (key && key.sequence === '\x16') { - // Check if this is a potential image paste by checking clipboard - const hasImage = await clipboardHasImage(); - if (hasImage) { - broadcast({ - name: '', - ctrl: false, - meta: false, - shift: false, - paste: true, - pasteImage: true, - sequence: '', - }); - } - } - }; if (usePassthrough) { rl = readline.createInterface({ @@ -769,14 +744,6 @@ export function KeypressProvider({ readline.emitKeypressEvents(keypressStream, rl); keypressStream.on('keypress', handleKeypress); stdin.on('data', handleRawKeypress); - - // Also listen for keypress on stdin to capture Ctrl+V for image paste - stdinRl = readline.createInterface({ - input: stdin, - escapeCodeTimeout: 0, - }); - readline.emitKeypressEvents(stdin, stdinRl); - stdin.on('keypress', handleStdinKeypress); } else { rl = readline.createInterface({ input: stdin, escapeCodeTimeout: 0 }); readline.emitKeypressEvents(stdin, rl); @@ -787,8 +754,6 @@ export function KeypressProvider({ if (usePassthrough) { keypressStream.removeListener('keypress', handleKeypress); stdin.removeListener('data', handleRawKeypress); - stdin.removeListener('keypress', handleStdinKeypress); - stdinRl?.close(); } else { stdin.removeListener('keypress', handleKeypress); } diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index 5fc9939a5..02d8c1fd2 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -11,6 +11,7 @@ import { defaultKeyBindings } from '../config/keyBindings.js'; import type { Key } from './hooks/useKeypress.js'; describe('keyMatchers', () => { + const isWindows = process.platform === 'win32'; const createKey = (name: string, mods: Partial = {}): Key => ({ name, ctrl: false, @@ -50,7 +51,7 @@ describe('keyMatchers', () => { [Command.OPEN_EXTERNAL_EDITOR]: (key: Key) => key.ctrl && (key.name === 'x' || key.sequence === '\x18'), [Command.PASTE_CLIPBOARD_IMAGE]: (key: Key) => - (key.ctrl || key.meta) && key.name === 'v', + (isWindows ? key.meta : key.ctrl || key.meta) && key.name === 'v', [Command.SHOW_ERROR_DETAILS]: (key: Key) => key.ctrl && key.name === 'o', [Command.TOGGLE_TOOL_DESCRIPTIONS]: (key: Key) => key.ctrl && key.name === 't', @@ -218,11 +219,12 @@ describe('keyMatchers', () => { }, { command: Command.PASTE_CLIPBOARD_IMAGE, - positive: [ - createKey('v', { ctrl: true }), - createKey('v', { meta: true }), - ], - negative: [createKey('v'), createKey('c', { ctrl: true })], + positive: isWindows + ? [createKey('v', { meta: true })] + : [createKey('v', { ctrl: true }), createKey('v', { meta: true })], + negative: isWindows + ? [createKey('v', { ctrl: true }), createKey('v')] + : [createKey('v'), createKey('c', { ctrl: true })], }, // App level bindings diff --git a/packages/cli/src/ui/utils/clipboardUtils.test.ts b/packages/cli/src/ui/utils/clipboardUtils.test.ts index 3a08e490c..82cb46cc1 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.test.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.test.ts @@ -124,7 +124,7 @@ describe('clipboardUtils', () => { it('should return true when clipboard contains image', async () => { mockExecCommand.mockResolvedValue({ - stdout: 'True', + stdout: 'true', stderr: '', code: 0, }); @@ -132,17 +132,17 @@ describe('clipboardUtils', () => { const result = await clipboardHasImage(); expect(result).toBe(true); expect(mockExecCommand).toHaveBeenCalledWith( - 'powershell', + 'powershell.exe', expect.arrayContaining([ '-command', - 'Add-Type -Assembly System.Windows.Forms; [System.Windows.Forms.Clipboard]::ContainsImage()', + expect.stringContaining('Get-Clipboard'), ]), ); }); it('should return false when clipboard does not contain image', async () => { mockExecCommand.mockResolvedValue({ - stdout: 'False', + stdout: 'false', stderr: '', code: 0, }); @@ -151,11 +151,15 @@ describe('clipboardUtils', () => { expect(result).toBe(false); }); - it('should return false when PowerShell fails', async () => { - mockExecCommand.mockRejectedValue(new Error('PowerShell not found')); + it('should return false when all PowerShell hosts fail', async () => { + mockExecCommand + .mockRejectedValueOnce(new Error('PowerShell not found')) + .mockRejectedValueOnce(new Error('PowerShell not found')) + .mockRejectedValueOnce(new Error('PowerShell not found')); const result = await clipboardHasImage(); expect(result).toBe(false); + expect(mockExecCommand).toHaveBeenCalledTimes(3); }); }); diff --git a/packages/cli/src/ui/utils/clipboardUtils.ts b/packages/cli/src/ui/utils/clipboardUtils.ts index 67bc67a4b..5d494339a 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.ts @@ -6,9 +6,7 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; -import { execCommand } from '@qwen-code/qwen-code-core'; - -const MACOS_CLIPBOARD_TIMEOUT_MS = 1500; +import { ClipboardManager } from '@teddyzhu/clipboard'; /** * Checks if the system clipboard contains an image @@ -16,100 +14,9 @@ const MACOS_CLIPBOARD_TIMEOUT_MS = 1500; */ export async function clipboardHasImage(): Promise { try { - if (process.platform === 'darwin') { - // Use osascript to check clipboard type - const { stdout } = await execCommand( - 'osascript', - ['-e', 'clipboard info'], - { - timeout: MACOS_CLIPBOARD_TIMEOUT_MS, - }, - ); - // Support common image formats: PNG, JPEG, TIFF, GIF, WebP, BMP, HEIC/HEIF - const imageRegex = - /«class PNGf»|«class JPEG»|«class JPEGffffff»|«class TIFF»|«class GIFf»|«class WEBP»|«class BMPf»|«class heic»|«class heif»|TIFF picture|JPEG picture|GIF picture|PNG picture|public.heic|public.heif/; - return imageRegex.test(stdout); - } else if (process.platform === 'win32') { - // On Windows, use System.Windows.Forms.Clipboard (more reliable than PresentationCore) - try { - const { stdout } = await execCommand('powershell', [ - '-noprofile', - '-noninteractive', - '-nologo', - '-sta', - '-executionpolicy', - 'unrestricted', - '-windowstyle', - 'hidden', - '-command', - 'Add-Type -Assembly System.Windows.Forms; [System.Windows.Forms.Clipboard]::ContainsImage()', - ]); - return stdout.trim() === 'True'; - } catch { - // If PowerShell or .NET Forms is not available, return false - return false; - } - } else if (process.platform === 'linux') { - // On Linux, check if xclip or wl-clipboard is available and has image data - try { - // Try xclip first (X11) - check for multiple image formats - await execCommand('which', ['xclip']); - const imageFormats = [ - 'image/png', - 'image/jpeg', - 'image/gif', - 'image/bmp', - 'image/webp', - 'image/tiff', - ]; - for (const format of imageFormats) { - try { - const { stdout: xclipOut } = await execCommand('xclip', [ - '-selection', - 'clipboard', - '-t', - format, - '-o', - ]); - if (xclipOut.length > 0) { - return true; - } - } catch { - // This format is not available, try next - continue; - } - } - return false; - } catch { - try { - // Try xsel as fallback (X11) - check TARGETS to see if image data exists - await execCommand('which', ['xsel']); - try { - // Check available clipboard targets - const { stdout: targets } = await execCommand('xsel', ['-b', '-t']); - // Check if any image MIME type is in the targets - return /image\/(png|jpeg|jpg|gif|bmp|webp|tiff)/i.test(targets); - } catch { - return false; - } - } catch { - try { - // Try wl-clipboard as fallback (Wayland) - await execCommand('which', ['wl-paste']); - const { stdout: wlOut } = await execCommand('wl-paste', [ - '--list-types', - ]); - // Check for image MIME types (must start with image/) - return /^image\//m.test(wlOut); - } catch { - return false; - } - } - } - } - return false; + const clipboard = new ClipboardManager(); + return clipboard.hasFormat('image'); } catch (error) { - // Log error for debugging but don't throw if (process.env['DEBUG']) { console.error('Error checking clipboard for image:', error); } @@ -126,6 +33,12 @@ export async function saveClipboardImage( targetDir?: string, ): Promise { try { + const clipboard = new ClipboardManager(); + + if (!clipboard.hasFormat('image')) { + return null; + } + // Create a temporary directory for clipboard images within the target directory // This avoids security restrictions on paths outside the target directory const baseDir = targetDir || process.cwd(); @@ -134,16 +47,19 @@ export async function saveClipboardImage( // Generate a unique filename with timestamp const timestamp = new Date().getTime(); + const tempFilePath = path.join(tempDir, `clipboard-${timestamp}.png`); - if (process.platform === 'darwin') { - return await saveMacOSClipboardImage(tempDir, timestamp); - } else if (process.platform === 'win32') { - return await saveWindowsClipboardImage(tempDir, timestamp); - } else if (process.platform === 'linux') { - return await saveLinuxClipboardImage(tempDir, timestamp); + const imageData = clipboard.getImageData(); + // Use data buffer from the API + const buffer = imageData.data; + + if (!buffer) { + return null; } - return null; + await fs.writeFile(tempFilePath, buffer); + + return tempFilePath; } catch (error) { if (process.env['DEBUG']) { console.error('Error saving clipboard image:', error); @@ -152,251 +68,6 @@ export async function saveClipboardImage( } } -/** - * Saves clipboard image on macOS using osascript - */ -async function saveMacOSClipboardImage( - tempDir: string, - timestamp: number, -): Promise { - // Try different image formats in order of preference - const formats = [ - { class: 'PNGf', extension: 'png' }, - { class: 'JPEG', extension: 'jpg' }, - { class: 'WEBP', extension: 'webp' }, - { class: 'heic', extension: 'heic' }, - { class: 'heif', extension: 'heif' }, - { class: 'TIFF', extension: 'tiff' }, - { class: 'GIFf', extension: 'gif' }, - { class: 'BMPf', extension: 'bmp' }, - ]; - - for (const format of formats) { - const tempFilePath = path.join( - tempDir, - `clipboard-${timestamp}.${format.extension}`, - ); - - // Try to save clipboard as this format - const script = ` - try - set imageData to the clipboard as «class ${format.class}» - set fileRef to open for access POSIX file "${tempFilePath}" with write permission - write imageData to fileRef - close access fileRef - return "success" - on error errMsg - try - close access POSIX file "${tempFilePath}" - end try - return "error" - end try - `; - - try { - const { stdout } = await execCommand('osascript', ['-e', script], { - timeout: MACOS_CLIPBOARD_TIMEOUT_MS, - }); - - if (stdout.trim() === 'success') { - // Verify the file was created and has content - try { - const stats = await fs.stat(tempFilePath); - if (stats.size > 0) { - return tempFilePath; - } - } catch { - // File doesn't exist, continue to next format - } - } - } catch { - // This format failed, try next - } - - // Clean up failed attempt - try { - await fs.unlink(tempFilePath); - } catch { - // Ignore cleanup errors - } - } - - return null; -} - -/** - * Saves clipboard image on Windows using PowerShell - */ -async function saveWindowsClipboardImage( - tempDir: string, - timestamp: number, -): Promise { - const tempFilePath = path.join(tempDir, `clipboard-${timestamp}.png`); - - try { - // Use PowerShell to save clipboard image as PNG - const script = ` - Add-Type -Assembly System.Windows.Forms - Add-Type -Assembly System.Drawing - $img = [System.Windows.Forms.Clipboard]::GetImage() - if ($img -ne $null) { - $img.Save('${tempFilePath.replace(/\\/g, '\\\\')}', [System.Drawing.Imaging.ImageFormat]::Png) - Write-Output 'success' - } else { - Write-Output 'no-image' - } - `; - - const { stdout } = await execCommand('powershell', [ - '-noprofile', - '-noninteractive', - '-nologo', - '-sta', - '-executionpolicy', - 'unrestricted', - '-windowstyle', - 'hidden', - '-command', - script, - ]); - - if (stdout.trim() === 'success') { - // Verify the file was created and has content - try { - const stats = await fs.stat(tempFilePath); - if (stats.size > 0) { - return tempFilePath; - } - } catch { - // File doesn't exist - } - } - - // Clean up failed attempt - try { - await fs.unlink(tempFilePath); - } catch { - // Ignore cleanup errors - } - } catch (error) { - // PowerShell failed, log in DEBUG mode and re-throw - if (process.env['DEBUG']) { - console.error('Error in saveWindowsClipboardImage:', error); - } - throw error; - } - - return null; -} - -/** - * Saves clipboard image on Linux using xclip or wl-paste - */ -async function saveLinuxClipboardImage( - tempDir: string, - timestamp: number, -): Promise { - // Try xclip first (X11) - try { - await execCommand('which', ['xclip']); - - // Try different image formats - const formats = [ - { mime: 'image/png', extension: 'png' }, - { mime: 'image/jpeg', extension: 'jpg' }, - { mime: 'image/gif', extension: 'gif' }, - { mime: 'image/bmp', extension: 'bmp' }, - { mime: 'image/webp', extension: 'webp' }, - { mime: 'image/tiff', extension: 'tiff' }, - ]; - - for (const format of formats) { - const tempFilePath = path.join( - tempDir, - `clipboard-${timestamp}.${format.extension}`, - ); - - try { - // Use shell redirection to save binary data - await execCommand('sh', [ - '-c', - `xclip -selection clipboard -t ${format.mime} -o > "${tempFilePath}"`, - ]); - - // Verify the file was created and has content - try { - const stats = await fs.stat(tempFilePath); - if (stats.size > 0) { - return tempFilePath; - } - } catch { - // File doesn't exist or is empty - } - - // Clean up empty file - try { - await fs.unlink(tempFilePath); - } catch { - // Ignore cleanup errors - } - } catch { - // This format not available, try next - continue; - } - } - } catch { - // xclip not available, try wl-paste (Wayland) - try { - await execCommand('which', ['wl-paste']); - - // Get list of available types - const { stdout: types } = await execCommand('wl-paste', ['--list-types']); - - // Find first image type - const imageTypeMatch = types.match(/^(image\/\w+)$/m); - if (imageTypeMatch) { - const mimeType = imageTypeMatch[1]; - const extension = mimeType.split('/')[1] || 'png'; - const tempFilePath = path.join( - tempDir, - `clipboard-${timestamp}.${extension}`, - ); - - try { - // Use shell redirection to save binary data - await execCommand('sh', [ - '-c', - `wl-paste --type ${mimeType} > "${tempFilePath}"`, - ]); - - // Verify the file was created and has content - try { - const stats = await fs.stat(tempFilePath); - if (stats.size > 0) { - return tempFilePath; - } - } catch { - // File doesn't exist or is empty - } - - // Clean up empty file - try { - await fs.unlink(tempFilePath); - } catch { - // Ignore cleanup errors - } - } catch { - // Failed to save image - } - } - } catch { - // wl-paste not available - } - } - - return null; -} - /** * Cleans up old temporary clipboard image files using LRU strategy * Keeps maximum 100 images, when exceeding removes 50 oldest files to reduce cleanup frequency From 30b4b47cd7656534ed56d323359d36f6be5fa858 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Mon, 2 Feb 2026 20:16:18 +0800 Subject: [PATCH 09/81] fix ci test --- .../cli/src/ui/utils/clipboardUtils.test.ts | 435 +++--------------- 1 file changed, 69 insertions(+), 366 deletions(-) diff --git a/packages/cli/src/ui/utils/clipboardUtils.test.ts b/packages/cli/src/ui/utils/clipboardUtils.test.ts index 82cb46cc1..c7197c5cf 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.test.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.test.ts @@ -5,19 +5,22 @@ */ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { execCommand } from '@qwen-code/qwen-code-core'; import { clipboardHasImage, saveClipboardImage, cleanupOldClipboardImages, } from './clipboardUtils.js'; -// Mock execCommand -vi.mock('@qwen-code/qwen-code-core', () => ({ - execCommand: vi.fn(), -})); +// Mock ClipboardManager +const mockHasFormat = vi.fn(); +const mockGetImageData = vi.fn(); -const mockExecCommand = vi.mocked(execCommand); +vi.mock('@teddyzhu/clipboard', () => ({ + ClipboardManager: vi.fn().mockImplementation(() => ({ + hasFormat: mockHasFormat, + getImageData: mockGetImageData, + })), +})); describe('clipboardUtils', () => { beforeEach(() => { @@ -25,319 +28,99 @@ describe('clipboardUtils', () => { }); describe('clipboardHasImage', () => { - describe('macOS platform', () => { - beforeEach(() => { - vi.stubGlobal('process', { - ...process, - platform: 'darwin', - env: process.env, - }); - }); + it('should return true when clipboard contains image', async () => { + mockHasFormat.mockReturnValue(true); - it('should return true when clipboard contains PNG image', async () => { - mockExecCommand.mockResolvedValue({ - stdout: '«class PNGf»', - stderr: '', - code: 0, - }); - - const result = await clipboardHasImage(); - expect(result).toBe(true); - expect(mockExecCommand).toHaveBeenCalledWith( - 'osascript', - ['-e', 'clipboard info'], - { timeout: 1500 }, - ); - }); - - it('should return true when clipboard contains JPEG image', async () => { - mockExecCommand.mockResolvedValue({ - stdout: '«class JPEG»', - stderr: '', - code: 0, - }); - - const result = await clipboardHasImage(); - expect(result).toBe(true); - }); - - it('should return true when clipboard contains WebP image', async () => { - mockExecCommand.mockResolvedValue({ - stdout: '«class WEBP»', - stderr: '', - code: 0, - }); - - const result = await clipboardHasImage(); - expect(result).toBe(true); - }); - - it('should return true when clipboard contains HEIC image', async () => { - mockExecCommand.mockResolvedValue({ - stdout: 'public.heic', - stderr: '', - code: 0, - }); - - const result = await clipboardHasImage(); - expect(result).toBe(true); - }); - - it('should return true when clipboard contains BMP image', async () => { - mockExecCommand.mockResolvedValue({ - stdout: '«class BMPf»', - stderr: '', - code: 0, - }); - - const result = await clipboardHasImage(); - expect(result).toBe(true); - }); - - it('should return false when clipboard contains text', async () => { - mockExecCommand.mockResolvedValue({ - stdout: '«class utf8»', - stderr: '', - code: 0, - }); - - const result = await clipboardHasImage(); - expect(result).toBe(false); - }); - - it('should return false on error', async () => { - mockExecCommand.mockRejectedValue(new Error('Command failed')); - - const result = await clipboardHasImage(); - expect(result).toBe(false); - }); + const result = await clipboardHasImage(); + expect(result).toBe(true); + expect(mockHasFormat).toHaveBeenCalledWith('image'); }); - describe('Windows platform', () => { - beforeEach(() => { - vi.stubGlobal('process', { - ...process, - platform: 'win32', - env: process.env, - }); - }); + it('should return false when clipboard does not contain image', async () => { + mockHasFormat.mockReturnValue(false); - it('should return true when clipboard contains image', async () => { - mockExecCommand.mockResolvedValue({ - stdout: 'true', - stderr: '', - code: 0, - }); - - const result = await clipboardHasImage(); - expect(result).toBe(true); - expect(mockExecCommand).toHaveBeenCalledWith( - 'powershell.exe', - expect.arrayContaining([ - '-command', - expect.stringContaining('Get-Clipboard'), - ]), - ); - }); - - it('should return false when clipboard does not contain image', async () => { - mockExecCommand.mockResolvedValue({ - stdout: 'false', - stderr: '', - code: 0, - }); - - const result = await clipboardHasImage(); - expect(result).toBe(false); - }); - - it('should return false when all PowerShell hosts fail', async () => { - mockExecCommand - .mockRejectedValueOnce(new Error('PowerShell not found')) - .mockRejectedValueOnce(new Error('PowerShell not found')) - .mockRejectedValueOnce(new Error('PowerShell not found')); - - const result = await clipboardHasImage(); - expect(result).toBe(false); - expect(mockExecCommand).toHaveBeenCalledTimes(3); - }); + const result = await clipboardHasImage(); + expect(result).toBe(false); + expect(mockHasFormat).toHaveBeenCalledWith('image'); }); - describe('Linux platform', () => { - beforeEach(() => { - vi.stubGlobal('process', { - ...process, - platform: 'linux', - env: process.env, - }); + it('should return false on error', async () => { + mockHasFormat.mockImplementation(() => { + throw new Error('Clipboard error'); }); - it('should return true when xclip has PNG image', async () => { - // First call: which xclip (success) - // Second call: xclip get PNG (has content) - mockExecCommand - .mockResolvedValueOnce({ - stdout: '/usr/bin/xclip', - stderr: '', - code: 0, - }) - .mockResolvedValueOnce({ stdout: 'image-data', stderr: '', code: 0 }); + const result = await clipboardHasImage(); + expect(result).toBe(false); + }); - const result = await clipboardHasImage(); - expect(result).toBe(true); + it('should log errors in DEBUG mode', async () => { + const originalEnv = process.env; + vi.stubGlobal('process', { + ...process, + env: { ...originalEnv, DEBUG: '1' }, }); - it('should try multiple formats with xclip', async () => { - // which xclip succeeds - mockExecCommand.mockResolvedValueOnce({ - stdout: '/usr/bin/xclip', - stderr: '', - code: 0, - }); - // PNG fails - mockExecCommand.mockRejectedValueOnce(new Error('No PNG')); - // JPEG succeeds - mockExecCommand.mockResolvedValueOnce({ - stdout: 'jpeg-data', - stderr: '', - code: 0, - }); - - const result = await clipboardHasImage(); - expect(result).toBe(true); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + mockHasFormat.mockImplementation(() => { + throw new Error('Test error'); }); - it('should fallback to xsel when xclip not available', async () => { - // which xclip fails - mockExecCommand.mockRejectedValueOnce(new Error('xclip not found')); - // which xsel succeeds - mockExecCommand.mockResolvedValueOnce({ - stdout: '/usr/bin/xsel', - stderr: '', - code: 0, - }); - // xsel -b -t returns image MIME types - mockExecCommand.mockResolvedValueOnce({ - stdout: 'text/plain\nimage/png\ntext/html', - stderr: '', - code: 0, - }); - - const result = await clipboardHasImage(); - expect(result).toBe(true); - }); - - it('should fallback to wl-paste when xclip and xsel not available', async () => { - // which xclip fails - mockExecCommand.mockRejectedValueOnce(new Error('xclip not found')); - // which xsel fails - mockExecCommand.mockRejectedValueOnce(new Error('xsel not found')); - // which wl-paste succeeds - mockExecCommand.mockResolvedValueOnce({ - stdout: '/usr/bin/wl-paste', - stderr: '', - code: 0, - }); - // wl-paste --list-types returns image MIME type - mockExecCommand.mockResolvedValueOnce({ - stdout: 'text/plain\nimage/png\ntext/html', - stderr: '', - code: 0, - }); - - const result = await clipboardHasImage(); - expect(result).toBe(true); - }); - - it('should return false when no clipboard tool available', async () => { - // All tools fail - mockExecCommand.mockRejectedValue(new Error('Not found')); - - const result = await clipboardHasImage(); - expect(result).toBe(false); - }); - - it('should return false when xsel has no image types', async () => { - // which xclip fails - mockExecCommand.mockRejectedValueOnce(new Error('xclip not found')); - // which xsel succeeds - mockExecCommand.mockResolvedValueOnce({ - stdout: '/usr/bin/xsel', - stderr: '', - code: 0, - }); - // xsel -b -t returns only text types - mockExecCommand.mockResolvedValueOnce({ - stdout: 'text/plain\ntext/html', - stderr: '', - code: 0, - }); - - const result = await clipboardHasImage(); - expect(result).toBe(false); - }); + await clipboardHasImage(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error checking clipboard for image:', + expect.any(Error), + ); + consoleErrorSpy.mockRestore(); }); }); describe('saveClipboardImage', () => { - const testTempDir = '/tmp/test-clipboard'; + it('should return null when clipboard has no image', async () => { + mockHasFormat.mockReturnValue(false); - it('should create clipboard directory when saving image', async () => { - vi.stubGlobal('process', { - ...process, - platform: 'darwin', - env: process.env, - }); + const result = await saveClipboardImage('/tmp/test'); + expect(result).toBe(null); + }); - // Mock all execCommand calls to fail (no image in clipboard) - mockExecCommand.mockRejectedValue(new Error('No image')); + it('should return null when image data buffer is null', async () => { + mockHasFormat.mockReturnValue(true); + mockGetImageData.mockReturnValue({ data: null }); - const result = await saveClipboardImage(testTempDir); - // Should return null when no image available + const result = await saveClipboardImage('/tmp/test'); expect(result).toBe(null); }); it('should handle errors gracefully and return null', async () => { - const result = await saveClipboardImage( - '/invalid/path/that/does/not/exist', - ); + mockHasFormat.mockImplementation(() => { + throw new Error('Clipboard error'); + }); + + const result = await saveClipboardImage('/tmp/test'); expect(result).toBe(null); }); - it('should support macOS platform', async () => { + it('should log errors in DEBUG mode', async () => { + const originalEnv = process.env; vi.stubGlobal('process', { ...process, - platform: 'darwin', - env: process.env, + env: { ...originalEnv, DEBUG: '1' }, }); - mockExecCommand.mockRejectedValue(new Error('No image')); - const result = await saveClipboardImage(); - expect(result === null || typeof result === 'string').toBe(true); - }); - - it('should support Windows platform', async () => { - vi.stubGlobal('process', { - ...process, - platform: 'win32', - env: process.env, + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + mockHasFormat.mockImplementation(() => { + throw new Error('Test error'); }); - mockExecCommand.mockRejectedValue(new Error('No image')); - const result = await saveClipboardImage(); - expect(result === null || typeof result === 'string').toBe(true); - }); - - it('should support Linux platform', async () => { - vi.stubGlobal('process', { - ...process, - platform: 'linux', - env: process.env, - }); - - mockExecCommand.mockRejectedValue(new Error('No image')); - const result = await saveClipboardImage(); - expect(result === null || typeof result === 'string').toBe(true); + await saveClipboardImage('/tmp/test'); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error saving clipboard image:', + expect.any(Error), + ); + consoleErrorSpy.mockRestore(); }); }); @@ -358,84 +141,4 @@ describe('clipboardUtils', () => { expect(true).toBe(true); }); }); - - describe('multi-format support', () => { - beforeEach(() => { - vi.stubGlobal('process', { - ...process, - platform: 'darwin', - env: process.env, - }); - }); - - const formats = [ - { name: 'PNG', pattern: '«class PNGf»' }, - { name: 'JPEG', pattern: '«class JPEG»' }, - { name: 'WebP', pattern: '«class WEBP»' }, - { name: 'HEIC', pattern: '«class heic»' }, - { name: 'HEIF', pattern: 'public.heif' }, - { name: 'TIFF', pattern: '«class TIFF»' }, - { name: 'GIF', pattern: '«class GIFf»' }, - { name: 'BMP', pattern: '«class BMPf»' }, - ]; - - formats.forEach(({ name, pattern }) => { - it(`should detect ${name} format on macOS`, async () => { - mockExecCommand.mockResolvedValue({ - stdout: pattern, - stderr: '', - code: 0, - }); - - const result = await clipboardHasImage(); - expect(result).toBe(true); - }); - }); - }); - - describe('error handling with DEBUG mode', () => { - const originalEnv = process.env; - - describe('clipboardHasImage', () => { - beforeEach(() => { - vi.stubGlobal('process', { - ...process, - platform: 'darwin', - env: { ...originalEnv, DEBUG: '1' }, - }); - }); - - it('should log errors in DEBUG mode', async () => { - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - mockExecCommand.mockRejectedValue(new Error('Test error')); - - await clipboardHasImage(); - expect(consoleErrorSpy).toHaveBeenCalled(); - consoleErrorSpy.mockRestore(); - }); - }); - - describe('saveClipboardImage on Windows', () => { - beforeEach(() => { - vi.stubGlobal('process', { - ...process, - platform: 'win32', - env: { ...originalEnv, DEBUG: '1' }, - }); - }); - - it('should log errors in DEBUG mode', async () => { - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - mockExecCommand.mockRejectedValue(new Error('Test error')); - - await saveClipboardImage('/invalid/path'); - expect(consoleErrorSpy).toHaveBeenCalled(); - consoleErrorSpy.mockRestore(); - }); - }); - }); }); From 0f4b5fd4003862cb916dcd82cdc0fc923095eede Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Tue, 3 Feb 2026 15:11:20 +0800 Subject: [PATCH 10/81] fix test on windows --- packages/cli/src/ui/keyMatchers.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/cli/src/ui/keyMatchers.ts b/packages/cli/src/ui/keyMatchers.ts index 103c57100..0b47bb678 100644 --- a/packages/cli/src/ui/keyMatchers.ts +++ b/packages/cli/src/ui/keyMatchers.ts @@ -50,6 +50,10 @@ function matchKeyBinding(keyBinding: KeyBinding, key: Key): boolean { return false; } + if (keyBinding.meta !== undefined && key.meta !== keyBinding.meta) { + return false; + } + return true; } From 32e17f8b5877676677cc0097a99eb809374df61a Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Mon, 9 Feb 2026 11:38:20 +0800 Subject: [PATCH 11/81] fix: normalize Windows paths to lowercase for case-insensitive session matching Fixes #1760 Windows file system is case-insensitive (e:\work equals E:\work), but string hashing is case-sensitive, causing different session directories for the same physical path. Solution: normalize paths to lowercase on Windows before hashing to ensure consistent session directory across different case variations. --- packages/core/src/utils/paths.test.ts | 78 +++++++++++++++++++ packages/core/src/utils/paths.ts | 7 +- .../src/services/qwenSessionManager.ts | 9 ++- .../src/services/qwenSessionReader.ts | 9 ++- 4 files changed, 98 insertions(+), 5 deletions(-) diff --git a/packages/core/src/utils/paths.test.ts b/packages/core/src/utils/paths.test.ts index 1c4ee0225..9f8b63ef9 100644 --- a/packages/core/src/utils/paths.test.ts +++ b/packages/core/src/utils/paths.test.ts @@ -17,6 +17,7 @@ import { isSubpath, shortenPath, tildeifyPath, + getProjectHash, } from './paths.js'; import type { Config } from '../config/config.js'; @@ -770,3 +771,80 @@ describe('shortenPath', () => { expect(result.length).toBeLessThanOrEqual(35); }); }); + +describe('getProjectHash', () => { + it('should generate consistent hashes for the same path', () => { + const projectRoot = '/test/project'; + const hash1 = getProjectHash(projectRoot); + const hash2 = getProjectHash(projectRoot); + + expect(hash1).toBe(hash2); + expect(hash1).toHaveLength(64); // SHA256 produces 64 hex characters + }); + + it('should generate different hashes for different paths', () => { + const hash1 = getProjectHash('/test/project1'); + const hash2 = getProjectHash('/test/project2'); + + expect(hash1).not.toBe(hash2); + }); + + it('should generate case-insensitive hashes on Windows', () => { + const platformSpy = vi.spyOn(os, 'platform'); + + // Simulate Windows platform + platformSpy.mockReturnValue('win32'); + + const lowerCasePath = 'c:\\users\\test\\project'; + const upperCasePath = 'C:\\Users\\Test\\Project'; + const mixedCasePath = 'c:\\Users\\TEST\\project'; + + const hash1 = getProjectHash(lowerCasePath); + const hash2 = getProjectHash(upperCasePath); + const hash3 = getProjectHash(mixedCasePath); + + // On Windows, all different case variations should produce the same hash + expect(hash1).toBe(hash2); + expect(hash2).toBe(hash3); + + platformSpy.mockRestore(); + }); + + it('should generate case-sensitive hashes on non-Windows platforms', () => { + const platformSpy = vi.spyOn(os, 'platform'); + + // Simulate Unix/Linux platform + platformSpy.mockReturnValue('linux'); + + const lowerCasePath = '/home/user/project'; + const upperCasePath = '/HOME/USER/PROJECT'; + + const hash1 = getProjectHash(lowerCasePath); + const hash2 = getProjectHash(upperCasePath); + + // On non-Windows platforms, different case should produce different hashes + expect(hash1).not.toBe(hash2); + + platformSpy.mockRestore(); + }); + + it('should handle Windows drive letter variations', () => { + const platformSpy = vi.spyOn(os, 'platform'); + platformSpy.mockReturnValue('win32'); + + // Common Windows scenarios where users might have different drive letter cases + const scenarios = [ + ['e:\\work', 'E:\\work'], + ['e:\\work', 'E:\\WORK'], + ['c:\\projects\\myapp', 'C:\\Projects\\MyApp'], + ]; + + for (const [path1, path2] of scenarios) { + const hash1 = getProjectHash(path1); + const hash2 = getProjectHash(path2); + expect(hash1).toBe(hash2); + } + + platformSpy.mockRestore(); + }); +}); diff --git a/packages/core/src/utils/paths.ts b/packages/core/src/utils/paths.ts index 6b492c922..96856a5dc 100644 --- a/packages/core/src/utils/paths.ts +++ b/packages/core/src/utils/paths.ts @@ -190,11 +190,16 @@ export function unescapePath(filePath: string): string { /** * Generates a unique hash for a project based on its root path. + * On Windows, paths are case-insensitive, so we normalize to lowercase + * to ensure the same physical path always produces the same hash. * @param projectRoot The absolute path to the project's root directory. * @returns A SHA256 hash of the project root path. */ export function getProjectHash(projectRoot: string): string { - return crypto.createHash('sha256').update(projectRoot).digest('hex'); + // On Windows, normalize path to lowercase for case-insensitive matching + const normalizedPath = + os.platform() === 'win32' ? projectRoot.toLowerCase() : projectRoot; + return crypto.createHash('sha256').update(normalizedPath).digest('hex'); } /** diff --git a/packages/vscode-ide-companion/src/services/qwenSessionManager.ts b/packages/vscode-ide-companion/src/services/qwenSessionManager.ts index 9336a060b..5c9f3d205 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionManager.ts @@ -29,10 +29,15 @@ export class QwenSessionManager { /** * Calculate project hash (same as CLI) - * Qwen CLI uses SHA256 hash of the project path + * Qwen CLI uses SHA256 hash of the project path. + * On Windows, paths are case-insensitive, so we normalize to lowercase + * to ensure the same physical path always produces the same hash. */ private getProjectHash(workingDir: string): string { - return crypto.createHash('sha256').update(workingDir).digest('hex'); + // On Windows, normalize path to lowercase for case-insensitive matching + const normalizedPath = + os.platform() === 'win32' ? workingDir.toLowerCase() : workingDir; + return crypto.createHash('sha256').update(normalizedPath).digest('hex'); } /** diff --git a/packages/vscode-ide-companion/src/services/qwenSessionReader.ts b/packages/vscode-ide-companion/src/services/qwenSessionReader.ts index 3fc4e484f..612cd2425 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionReader.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionReader.ts @@ -179,10 +179,15 @@ export class QwenSessionReader { /** * Calculate project hash (needs to be consistent with Qwen CLI) - * Qwen CLI uses SHA256 hash of project path + * Qwen CLI uses SHA256 hash of project path. + * On Windows, paths are case-insensitive, so we normalize to lowercase + * to ensure the same physical path always produces the same hash. */ private async getProjectHash(workingDir: string): Promise { - return crypto.createHash('sha256').update(workingDir).digest('hex'); + // On Windows, normalize path to lowercase for case-insensitive matching + const normalizedPath = + os.platform() === 'win32' ? workingDir.toLowerCase() : workingDir; + return crypto.createHash('sha256').update(normalizedPath).digest('hex'); } /** From 37c3a38bb192491fb02347e45a6e6a2f2747834f Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Mon, 9 Feb 2026 14:23:37 +0800 Subject: [PATCH 12/81] fix: use centralized getProjectHash in Storage class Ensures consistent Windows path normalization across all path hashing. Previously Storage used its own getFilePathHash() which didn't apply Windows lowercase normalization, causing test failures on Windows CI. --- packages/core/src/config/storage.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 8ef0283c5..4a710daf0 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -6,8 +6,8 @@ import * as path from 'node:path'; import * as os from 'node:os'; -import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; +import { getProjectHash } from '../utils/paths.js'; export const QWEN_DIR = '.qwen'; export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json'; @@ -88,7 +88,7 @@ export class Storage { } getProjectTempDir(): string { - const hash = this.getFilePathHash(this.getProjectRoot()); + const hash = getProjectHash(this.getProjectRoot()); const tempDir = Storage.getGlobalTempDir(); return path.join(tempDir, hash); } @@ -105,12 +105,8 @@ export class Storage { return this.targetDir; } - private getFilePathHash(filePath: string): string { - return crypto.createHash('sha256').update(filePath).digest('hex'); - } - getHistoryDir(): string { - const hash = this.getFilePathHash(this.getProjectRoot()); + const hash = getProjectHash(this.getProjectRoot()); const historyDir = path.join(Storage.getGlobalQwenDir(), 'history'); return path.join(historyDir, hash); } From d3dfc26dea18c93ff703497b77fd6397394568ce Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Mon, 9 Feb 2026 15:30:06 +0800 Subject: [PATCH 13/81] fix ci test --- packages/core/src/config/storage.ts | 42 +++++++++++++-- packages/core/src/core/logger.test.ts | 4 +- packages/core/src/utils/paths.test.ts | 53 +++++++++++++++++++ packages/core/src/utils/paths.ts | 11 ++++ .../src/services/qwenSessionManager.ts | 50 +++++++++++------ .../src/services/qwenSessionReader.ts | 49 +++++++++++------ scripts/telemetry_utils.js | 19 +++++-- 7 files changed, 186 insertions(+), 42 deletions(-) diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 4a710daf0..8589f5ae2 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -7,7 +7,7 @@ import * as path from 'node:path'; import * as os from 'node:os'; import * as fs from 'node:fs'; -import { getProjectHash } from '../utils/paths.js'; +import { getProjectHash, getLegacyProjectHash } from '../utils/paths.js'; export const QWEN_DIR = '.qwen'; export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json'; @@ -90,7 +90,25 @@ export class Storage { getProjectTempDir(): string { const hash = getProjectHash(this.getProjectRoot()); const tempDir = Storage.getGlobalTempDir(); - return path.join(tempDir, hash); + const targetDir = path.join(tempDir, hash); + + // Backward compatibility: On Windows, check if legacy directory exists + // and migrate it to the new normalized path + if (os.platform() === 'win32' && !fs.existsSync(targetDir)) { + const legacyHash = getLegacyProjectHash(this.getProjectRoot()); + const legacyDir = path.join(tempDir, legacyHash); + + if (fs.existsSync(legacyDir) && legacyHash !== hash) { + try { + // Attempt to rename/migrate the directory + fs.renameSync(legacyDir, targetDir); + } catch (_error) { + // Silent fallback: if migration fails, continue with the new path + } + } + } + + return targetDir; } ensureProjectTempDirExists(): void { @@ -108,7 +126,25 @@ export class Storage { getHistoryDir(): string { const hash = getProjectHash(this.getProjectRoot()); const historyDir = path.join(Storage.getGlobalQwenDir(), 'history'); - return path.join(historyDir, hash); + const targetDir = path.join(historyDir, hash); + + // Backward compatibility: On Windows, check if legacy directory exists + // and migrate it to the new normalized path + if (os.platform() === 'win32' && !fs.existsSync(targetDir)) { + const legacyHash = getLegacyProjectHash(this.getProjectRoot()); + const legacyDir = path.join(historyDir, legacyHash); + + if (fs.existsSync(legacyDir) && legacyHash !== hash) { + try { + // Attempt to rename/migrate the directory + fs.renameSync(legacyDir, targetDir); + } catch (_error) { + // Silent fallback: if migration fails, continue with the new path + } + } + } + + return targetDir; } getWorkspaceSettingsPath(): string { diff --git a/packages/core/src/core/logger.test.ts b/packages/core/src/core/logger.test.ts index de3fc3f78..c973c02dd 100644 --- a/packages/core/src/core/logger.test.ts +++ b/packages/core/src/core/logger.test.ts @@ -21,11 +21,11 @@ import { decodeTagName, } from './logger.js'; import { Storage } from '../config/storage.js'; +import { getProjectHash } from '../utils/paths.js'; import { promises as fs, existsSync } from 'node:fs'; import path from 'node:path'; import type { Content } from '@google/genai'; -import crypto from 'node:crypto'; import os from 'node:os'; const GEMINI_DIR_NAME = '.qwen'; @@ -34,7 +34,7 @@ const LOG_FILE_NAME = 'logs.json'; const CHECKPOINT_FILE_NAME = 'checkpoint.json'; const projectDir = process.cwd(); -const hash = crypto.createHash('sha256').update(projectDir).digest('hex'); +const hash = getProjectHash(projectDir); const TEST_HOME_DIR = path.join(os.tmpdir(), 'qwen-core-logger-home'); let originalHome: string | undefined; diff --git a/packages/core/src/utils/paths.test.ts b/packages/core/src/utils/paths.test.ts index 9f8b63ef9..2584e6503 100644 --- a/packages/core/src/utils/paths.test.ts +++ b/packages/core/src/utils/paths.test.ts @@ -18,6 +18,7 @@ import { shortenPath, tildeifyPath, getProjectHash, + getLegacyProjectHash, } from './paths.js'; import type { Config } from '../config/config.js'; @@ -848,3 +849,55 @@ describe('getProjectHash', () => { platformSpy.mockRestore(); }); }); + +describe('getLegacyProjectHash', () => { + it('should always be case-sensitive regardless of platform', () => { + const platformSpy = vi.spyOn(os, 'platform'); + + // Test on Windows - should still be case-sensitive + platformSpy.mockReturnValue('win32'); + const lowerCaseHash = getLegacyProjectHash('c:\\users\\test\\project'); + const upperCaseHash = getLegacyProjectHash('C:\\USERS\\TEST\\PROJECT'); + expect(lowerCaseHash).not.toBe(upperCaseHash); + + // Test on Linux - should be case-sensitive + platformSpy.mockReturnValue('linux'); + const lowerCaseHashLinux = getLegacyProjectHash('/home/user/project'); + const upperCaseHashLinux = getLegacyProjectHash('/HOME/USER/PROJECT'); + expect(lowerCaseHashLinux).not.toBe(upperCaseHashLinux); + + platformSpy.mockRestore(); + }); + + it('should generate different hash than getProjectHash on Windows with mixed case', () => { + const platformSpy = vi.spyOn(os, 'platform'); + platformSpy.mockReturnValue('win32'); + + const mixedCasePath = 'C:\\Users\\Test\\Project'; + const legacyHash = getLegacyProjectHash(mixedCasePath); + const newHash = getProjectHash(mixedCasePath); + + // They should be different because getProjectHash normalizes to lowercase + expect(legacyHash).not.toBe(newHash); + + // But both lowercase paths should match the new hash + const lowercaseNewHash = getProjectHash(mixedCasePath.toLowerCase()); + expect(newHash).toBe(lowercaseNewHash); + + platformSpy.mockRestore(); + }); + + it('should match getProjectHash on non-Windows platforms', () => { + const platformSpy = vi.spyOn(os, 'platform'); + platformSpy.mockReturnValue('linux'); + + const testPath = '/home/user/project'; + const legacyHash = getLegacyProjectHash(testPath); + const newHash = getProjectHash(testPath); + + // On non-Windows platforms, both should be identical + expect(legacyHash).toBe(newHash); + + platformSpy.mockRestore(); + }); +}); diff --git a/packages/core/src/utils/paths.ts b/packages/core/src/utils/paths.ts index 96856a5dc..f90e9b4e9 100644 --- a/packages/core/src/utils/paths.ts +++ b/packages/core/src/utils/paths.ts @@ -202,6 +202,17 @@ export function getProjectHash(projectRoot: string): string { return crypto.createHash('sha256').update(normalizedPath).digest('hex'); } +/** + * Generates a hash using the legacy algorithm (without case normalization). + * This is used for backward compatibility to locate session directories + * created before the case-insensitive fix on Windows. + * @param projectRoot The absolute path to the project's root directory. + * @returns A SHA256 hash of the project root path without normalization. + */ +export function getLegacyProjectHash(projectRoot: string): string { + return crypto.createHash('sha256').update(projectRoot).digest('hex'); +} + /** * Checks if a path is a subpath of another path. * @param parentPath The parent path. diff --git a/packages/vscode-ide-companion/src/services/qwenSessionManager.ts b/packages/vscode-ide-companion/src/services/qwenSessionManager.ts index 5c9f3d205..922ca78e5 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionManager.ts @@ -8,6 +8,10 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import * as crypto from 'crypto'; +import { + getProjectHash, + getLegacyProjectHash, +} from '@qwen-code/qwen-code-core/src/utils/paths.js'; import type { QwenSession, QwenMessage } from './qwenSessionReader.js'; /** @@ -28,24 +32,36 @@ export class QwenSessionManager { } /** - * Calculate project hash (same as CLI) - * Qwen CLI uses SHA256 hash of the project path. - * On Windows, paths are case-insensitive, so we normalize to lowercase - * to ensure the same physical path always produces the same hash. - */ - private getProjectHash(workingDir: string): string { - // On Windows, normalize path to lowercase for case-insensitive matching - const normalizedPath = - os.platform() === 'win32' ? workingDir.toLowerCase() : workingDir; - return crypto.createHash('sha256').update(normalizedPath).digest('hex'); - } - - /** - * Get the session directory for a project + * Get the session directory for a project with backward compatibility */ private getSessionDir(workingDir: string): string { - const projectHash = this.getProjectHash(workingDir); - return path.join(this.qwenDir, 'tmp', projectHash, 'chats'); + const projectHash = getProjectHash(workingDir); + const sessionDir = path.join(this.qwenDir, 'tmp', projectHash, 'chats'); + + // Backward compatibility: On Windows, check if legacy directory exists + // and migrate it to the new normalized path + if (os.platform() === 'win32' && !fs.existsSync(sessionDir)) { + const legacyHash = getLegacyProjectHash(workingDir); + const legacySessionDir = path.join( + this.qwenDir, + 'tmp', + legacyHash, + 'chats', + ); + + if (fs.existsSync(legacySessionDir) && legacyHash !== projectHash) { + try { + // Migrate parent directory (hash directory, not just chats) + const newParentDir = path.join(this.qwenDir, 'tmp', projectHash); + const legacyParentDir = path.join(this.qwenDir, 'tmp', legacyHash); + fs.renameSync(legacyParentDir, newParentDir); + } catch (_error) { + // Silent fallback: if migration fails, continue with the new path + } + } + } + + return sessionDir; } /** @@ -92,7 +108,7 @@ export class QwenSessionManager { // Create session object const session: QwenSession = { sessionId, - projectHash: this.getProjectHash(workingDir), + projectHash: getProjectHash(workingDir), startTime: messages[0]?.timestamp || new Date().toISOString(), lastUpdated: new Date().toISOString(), messages, diff --git a/packages/vscode-ide-companion/src/services/qwenSessionReader.ts b/packages/vscode-ide-companion/src/services/qwenSessionReader.ts index 612cd2425..8215c8a20 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionReader.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionReader.ts @@ -8,7 +8,10 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import * as readline from 'readline'; -import * as crypto from 'crypto'; +import { + getProjectHash, + getLegacyProjectHash, +} from '@qwen-code/qwen-code-core/src/utils/paths.js'; export interface QwenMessage { id: string; @@ -58,8 +61,35 @@ export class QwenSessionReader { if (!allProjects && workingDir) { // Current project only - const projectHash = await this.getProjectHash(workingDir); + const projectHash = getProjectHash(workingDir); const chatsDir = path.join(this.qwenDir, 'tmp', projectHash, 'chats'); + + // Backward compatibility: On Windows, try legacy hash if new directory doesn't exist + if (os.platform() === 'win32' && !fs.existsSync(chatsDir)) { + const legacyHash = getLegacyProjectHash(workingDir); + const legacyChatsDir = path.join( + this.qwenDir, + 'tmp', + legacyHash, + 'chats', + ); + + if (fs.existsSync(legacyChatsDir) && legacyHash !== projectHash) { + try { + // Migrate parent directory + const newParentDir = path.join(this.qwenDir, 'tmp', projectHash); + const legacyParentDir = path.join( + this.qwenDir, + 'tmp', + legacyHash, + ); + fs.renameSync(legacyParentDir, newParentDir); + } catch (_error) { + // Silent fallback + } + } + } + const projectSessions = await this.readSessionsFromDir(chatsDir); sessions.push(...projectSessions); } else { @@ -177,19 +207,6 @@ export class QwenSessionReader { return found; } - /** - * Calculate project hash (needs to be consistent with Qwen CLI) - * Qwen CLI uses SHA256 hash of project path. - * On Windows, paths are case-insensitive, so we normalize to lowercase - * to ensure the same physical path always produces the same hash. - */ - private async getProjectHash(workingDir: string): Promise { - // On Windows, normalize path to lowercase for case-insensitive matching - const normalizedPath = - os.platform() === 'win32' ? workingDir.toLowerCase() : workingDir; - return crypto.createHash('sha256').update(normalizedPath).digest('hex'); - } - /** * Get session title (based on first user message) */ @@ -294,7 +311,7 @@ export class QwenSessionReader { } const projectHash = cwd - ? await this.getProjectHash(cwd) + ? getProjectHash(cwd) : path.basename(path.dirname(path.dirname(filePath))); return { diff --git a/scripts/telemetry_utils.js b/scripts/telemetry_utils.js index cb2010d5b..504ed18cb 100644 --- a/scripts/telemetry_utils.js +++ b/scripts/telemetry_utils.js @@ -18,10 +18,21 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const projectRoot = path.resolve(__dirname, '..'); -const projectHash = crypto - .createHash('sha256') - .update(projectRoot) - .digest('hex'); + +/** + * Generates a unique hash for a project based on its root path. + * On Windows, paths are case-insensitive, so we normalize to lowercase + * to ensure the same physical path always produces the same hash. + * This logic must match getProjectHash() in packages/core/src/utils/paths.ts + */ +function getProjectHash(projectRoot) { + // On Windows, normalize path to lowercase for case-insensitive matching + const normalizedPath = + os.platform() === 'win32' ? projectRoot.toLowerCase() : projectRoot; + return crypto.createHash('sha256').update(normalizedPath).digest('hex'); +} + +const projectHash = getProjectHash(projectRoot); // User-level .gemini directory in home const USER_GEMINI_DIR = path.join(os.homedir(), '.qwen'); From 662192a0b9b14519fb96986f9d8dbdd5bfb88d93 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Tue, 10 Feb 2026 10:48:58 +0800 Subject: [PATCH 14/81] fix windows ci --- .../src/ui/components/InputPrompt.test.tsx | 59 +++++++++++-------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 51cadc515..319a0e416 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -370,6 +370,8 @@ describe('InputPrompt', () => { }); describe('clipboard image paste', () => { + const isWindows = process.platform === 'win32'; + beforeEach(() => { vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false); vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(null); @@ -378,27 +380,32 @@ describe('InputPrompt', () => { ); }); - it('should handle Ctrl+V when clipboard has an image', async () => { - vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true); - vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue( - '/Users/mochi/.qwen/tmp/clipboard-123.png', - ); + // Windows uses Alt+V (\x1Bv), non-Windows uses Ctrl+V (\x16) + const describeConditional = isWindows ? it.skip : it; + describeConditional( + 'should handle Ctrl+V when clipboard has an image', + async () => { + vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true); + vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue( + '/Users/mochi/.qwen/tmp/clipboard-123.png', + ); - const { stdin, unmount } = renderWithProviders( - , - ); - await wait(); + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); - // Send Ctrl+V - stdin.write('\x16'); // Ctrl+V - await wait(); + // Send Ctrl+V + stdin.write('\x16'); // Ctrl+V + await wait(); - expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled(); - expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled(); - expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalled(); - // Note: The new implementation adds images as attachments rather than inserting into buffer - unmount(); - }); + expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled(); + expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled(); + expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalled(); + // Note: The new implementation adds images as attachments rather than inserting into buffer + unmount(); + }, + ); it('should handle Cmd+V when clipboard has an image', async () => { vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true); @@ -411,8 +418,8 @@ describe('InputPrompt', () => { ); await wait(); - // Send Cmd+V (meta key) - // In terminals, Cmd+V is typically sent as ESC followed by 'v' + // Send Cmd+V (meta key) / Alt+V on Windows + // In terminals, Cmd+V or Alt+V is typically sent as ESC followed by 'v' stdin.write('\x1Bv'); await wait(); @@ -431,7 +438,8 @@ describe('InputPrompt', () => { ); await wait(); - stdin.write('\x16'); // Ctrl+V + // Use platform-appropriate key combination + stdin.write(isWindows ? '\x1Bv' : '\x16'); await wait(); expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled(); @@ -449,7 +457,8 @@ describe('InputPrompt', () => { ); await wait(); - stdin.write('\x16'); // Ctrl+V + // Use platform-appropriate key combination + stdin.write(isWindows ? '\x1Bv' : '\x16'); await wait(); expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled(); @@ -472,7 +481,8 @@ describe('InputPrompt', () => { ); await wait(); - stdin.write('\x16'); // Ctrl+V + // Use platform-appropriate key combination + stdin.write(isWindows ? '\x1Bv' : '\x16'); await wait(); // The new implementation adds images as attachments rather than inserting into buffer @@ -495,7 +505,8 @@ describe('InputPrompt', () => { ); await wait(); - stdin.write('\x16'); // Ctrl+V + // Use platform-appropriate key combination + stdin.write(isWindows ? '\x1Bv' : '\x16'); await wait(); expect(consoleErrorSpy).toHaveBeenCalledWith( From b4881268303df4ab71b76224a350b8a6488cf8cb Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Tue, 10 Feb 2026 14:38:01 +0800 Subject: [PATCH 15/81] feat add multi-platform support --- esbuild.config.js | 7 ++++ packages/cli/package.json | 10 +++++- .../cli/src/ui/utils/clipboardUtils.test.ts | 32 +++++++------------ packages/cli/src/ui/utils/clipboardUtils.ts | 31 ++++++++++++++++-- .../core/src/extension/claude-converter.ts | 2 +- scripts/prepare-package.js | 7 ++++ 6 files changed, 64 insertions(+), 25 deletions(-) diff --git a/esbuild.config.js b/esbuild.config.js index 12ab39d58..2b532b44e 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -33,6 +33,13 @@ const external = [ '@lydell/node-pty-linux-x64', '@lydell/node-pty-win32-arm64', '@lydell/node-pty-win32-x64', + '@teddyzhu/clipboard', + '@teddyzhu/clipboard-darwin-arm64', + '@teddyzhu/clipboard-darwin-x64', + '@teddyzhu/clipboard-linux-x64-gnu', + '@teddyzhu/clipboard-linux-arm64-gnu', + '@teddyzhu/clipboard-win32-x64-msvc', + '@teddyzhu/clipboard-win32-arm64-msvc', ]; esbuild diff --git a/packages/cli/package.json b/packages/cli/package.json index b0155cbb4..4a37c2566 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -41,7 +41,6 @@ "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.25.1", "@qwen-code/qwen-code-core": "file:../core", - "@teddyzhu/clipboard": "^0.0.5", "@types/update-notifier": "^6.0.8", "ansi-regex": "^6.2.2", "command-exists": "^1.2.9", @@ -96,6 +95,15 @@ "typescript": "^5.3.3", "vitest": "^3.1.1" }, + "optionalDependencies": { + "@teddyzhu/clipboard": "^0.0.5", + "@teddyzhu/clipboard-darwin-arm64": "0.0.5", + "@teddyzhu/clipboard-darwin-x64": "0.0.5", + "@teddyzhu/clipboard-linux-x64-gnu": "0.0.5", + "@teddyzhu/clipboard-linux-arm64-gnu": "0.0.5", + "@teddyzhu/clipboard-win32-x64-msvc": "0.0.5", + "@teddyzhu/clipboard-win32-arm64-msvc": "0.0.5" + }, "engines": { "node": ">=20" } diff --git a/packages/cli/src/ui/utils/clipboardUtils.test.ts b/packages/cli/src/ui/utils/clipboardUtils.test.ts index c7197c5cf..5a190bf48 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.test.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.test.ts @@ -16,6 +16,12 @@ const mockHasFormat = vi.fn(); const mockGetImageData = vi.fn(); vi.mock('@teddyzhu/clipboard', () => ({ + default: { + ClipboardManager: vi.fn().mockImplementation(() => ({ + hasFormat: mockHasFormat, + getImageData: mockGetImageData, + })), + }, ClipboardManager: vi.fn().mockImplementation(() => ({ hasFormat: mockHasFormat, getImageData: mockGetImageData, @@ -53,26 +59,19 @@ describe('clipboardUtils', () => { expect(result).toBe(false); }); - it('should log errors in DEBUG mode', async () => { + it('should return false and not throw when error occurs in DEBUG mode', async () => { const originalEnv = process.env; vi.stubGlobal('process', { ...process, env: { ...originalEnv, DEBUG: '1' }, }); - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); mockHasFormat.mockImplementation(() => { throw new Error('Test error'); }); - await clipboardHasImage(); - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Error checking clipboard for image:', - expect.any(Error), - ); - consoleErrorSpy.mockRestore(); + const result = await clipboardHasImage(); + expect(result).toBe(false); }); }); @@ -101,26 +100,19 @@ describe('clipboardUtils', () => { expect(result).toBe(null); }); - it('should log errors in DEBUG mode', async () => { + it('should return null and not throw when error occurs in DEBUG mode', async () => { const originalEnv = process.env; vi.stubGlobal('process', { ...process, env: { ...originalEnv, DEBUG: '1' }, }); - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); mockHasFormat.mockImplementation(() => { throw new Error('Test error'); }); - await saveClipboardImage('/tmp/test'); - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Error saving clipboard image:', - expect.any(Error), - ); - consoleErrorSpy.mockRestore(); + const result = await saveClipboardImage('/tmp/test'); + expect(result).toBe(null); }); }); diff --git a/packages/cli/src/ui/utils/clipboardUtils.ts b/packages/cli/src/ui/utils/clipboardUtils.ts index 9b21e6303..a28c2a49c 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.ts @@ -6,18 +6,41 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; -import { ClipboardManager } from '@teddyzhu/clipboard'; import { createDebugLogger } from '@qwen-code/qwen-code-core'; const debugLogger = createDebugLogger('CLIPBOARD_UTILS'); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ClipboardModule = any; + +let cachedClipboardModule: ClipboardModule | null = null; +let clipboardLoadAttempted = false; + +async function getClipboardModule(): Promise { + if (clipboardLoadAttempted) return cachedClipboardModule; + clipboardLoadAttempted = true; + + try { + const modName = '@teddyzhu/clipboard'; + cachedClipboardModule = await import(modName); + return cachedClipboardModule; + } catch (_e) { + debugLogger.error( + 'Failed to load @teddyzhu/clipboard native module. Clipboard image features will be unavailable.', + ); + return null; + } +} + /** * Checks if the system clipboard contains an image * @returns true if clipboard contains an image */ export async function clipboardHasImage(): Promise { try { - const clipboard = new ClipboardManager(); + const mod = await getClipboardModule(); + if (!mod) return false; + const clipboard = new mod.ClipboardManager(); return clipboard.hasFormat('image'); } catch (error) { debugLogger.error('Error checking clipboard for image:', error); @@ -34,7 +57,9 @@ export async function saveClipboardImage( targetDir?: string, ): Promise { try { - const clipboard = new ClipboardManager(); + const mod = await getClipboardModule(); + if (!mod) return null; + const clipboard = new mod.ClipboardManager(); if (!clipboard.hasFormat('image')) { return null; diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index 28835bc87..68da9cfff 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -100,7 +100,7 @@ const CLAUDE_TOOLS_MAPPING: Record = { Grep: 'Grep', KillShell: 'None', NotebookEdit: 'None', - Read: ['ReadFile', 'ReadManyFiles'], + Read: 'ReadFile', Skill: 'Skill', Task: 'Task', TodoWrite: 'TodoWrite', diff --git a/scripts/prepare-package.js b/scripts/prepare-package.js index de94e8d81..3ae9d3e08 100644 --- a/scripts/prepare-package.js +++ b/scripts/prepare-package.js @@ -161,6 +161,13 @@ const distPackageJson = { '@lydell/node-pty-linux-x64': '1.1.0', '@lydell/node-pty-win32-arm64': '1.1.0', '@lydell/node-pty-win32-x64': '1.1.0', + '@teddyzhu/clipboard': '0.0.5', + '@teddyzhu/clipboard-darwin-arm64': '0.0.5', + '@teddyzhu/clipboard-darwin-x64': '0.0.5', + '@teddyzhu/clipboard-linux-x64-gnu': '0.0.5', + '@teddyzhu/clipboard-linux-arm64-gnu': '0.0.5', + '@teddyzhu/clipboard-win32-x64-msvc': '0.0.5', + '@teddyzhu/clipboard-win32-arm64-msvc': '0.0.5', }, engines: rootPackageJson.engines, }; From 60fec71e469da756edf946d7ded1b3416bf9337d Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Tue, 10 Feb 2026 15:13:24 +0800 Subject: [PATCH 16/81] fix sanitizeCwd --- packages/core/src/config/storage.ts | 40 ++------------ packages/core/src/utils/paths.test.ts | 53 ------------------- packages/core/src/utils/paths.ts | 11 ---- .../src/services/qwenSessionManager.ts | 29 +--------- .../src/services/qwenSessionReader.ts | 32 +---------- 5 files changed, 6 insertions(+), 159 deletions(-) diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 8589f5ae2..f9d0107e5 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -7,7 +7,7 @@ import * as path from 'node:path'; import * as os from 'node:os'; import * as fs from 'node:fs'; -import { getProjectHash, getLegacyProjectHash } from '../utils/paths.js'; +import { getProjectHash } from '../utils/paths.js'; export const QWEN_DIR = '.qwen'; export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json'; @@ -91,23 +91,6 @@ export class Storage { const hash = getProjectHash(this.getProjectRoot()); const tempDir = Storage.getGlobalTempDir(); const targetDir = path.join(tempDir, hash); - - // Backward compatibility: On Windows, check if legacy directory exists - // and migrate it to the new normalized path - if (os.platform() === 'win32' && !fs.existsSync(targetDir)) { - const legacyHash = getLegacyProjectHash(this.getProjectRoot()); - const legacyDir = path.join(tempDir, legacyHash); - - if (fs.existsSync(legacyDir) && legacyHash !== hash) { - try { - // Attempt to rename/migrate the directory - fs.renameSync(legacyDir, targetDir); - } catch (_error) { - // Silent fallback: if migration fails, continue with the new path - } - } - } - return targetDir; } @@ -127,23 +110,6 @@ export class Storage { const hash = getProjectHash(this.getProjectRoot()); const historyDir = path.join(Storage.getGlobalQwenDir(), 'history'); const targetDir = path.join(historyDir, hash); - - // Backward compatibility: On Windows, check if legacy directory exists - // and migrate it to the new normalized path - if (os.platform() === 'win32' && !fs.existsSync(targetDir)) { - const legacyHash = getLegacyProjectHash(this.getProjectRoot()); - const legacyDir = path.join(historyDir, legacyHash); - - if (fs.existsSync(legacyDir) && legacyHash !== hash) { - try { - // Attempt to rename/migrate the directory - fs.renameSync(legacyDir, targetDir); - } catch (_error) { - // Silent fallback: if migration fails, continue with the new path - } - } - } - return targetDir; } @@ -176,6 +142,8 @@ export class Storage { } private sanitizeCwd(cwd: string): string { - return cwd.replace(/[^a-zA-Z0-9]/g, '-'); + // On Windows, normalize to lowercase for case-insensitive matching + const normalizedCwd = os.platform() === 'win32' ? cwd.toLowerCase() : cwd; + return normalizedCwd.replace(/[^a-zA-Z0-9]/g, '-'); } } diff --git a/packages/core/src/utils/paths.test.ts b/packages/core/src/utils/paths.test.ts index 2584e6503..9f8b63ef9 100644 --- a/packages/core/src/utils/paths.test.ts +++ b/packages/core/src/utils/paths.test.ts @@ -18,7 +18,6 @@ import { shortenPath, tildeifyPath, getProjectHash, - getLegacyProjectHash, } from './paths.js'; import type { Config } from '../config/config.js'; @@ -849,55 +848,3 @@ describe('getProjectHash', () => { platformSpy.mockRestore(); }); }); - -describe('getLegacyProjectHash', () => { - it('should always be case-sensitive regardless of platform', () => { - const platformSpy = vi.spyOn(os, 'platform'); - - // Test on Windows - should still be case-sensitive - platformSpy.mockReturnValue('win32'); - const lowerCaseHash = getLegacyProjectHash('c:\\users\\test\\project'); - const upperCaseHash = getLegacyProjectHash('C:\\USERS\\TEST\\PROJECT'); - expect(lowerCaseHash).not.toBe(upperCaseHash); - - // Test on Linux - should be case-sensitive - platformSpy.mockReturnValue('linux'); - const lowerCaseHashLinux = getLegacyProjectHash('/home/user/project'); - const upperCaseHashLinux = getLegacyProjectHash('/HOME/USER/PROJECT'); - expect(lowerCaseHashLinux).not.toBe(upperCaseHashLinux); - - platformSpy.mockRestore(); - }); - - it('should generate different hash than getProjectHash on Windows with mixed case', () => { - const platformSpy = vi.spyOn(os, 'platform'); - platformSpy.mockReturnValue('win32'); - - const mixedCasePath = 'C:\\Users\\Test\\Project'; - const legacyHash = getLegacyProjectHash(mixedCasePath); - const newHash = getProjectHash(mixedCasePath); - - // They should be different because getProjectHash normalizes to lowercase - expect(legacyHash).not.toBe(newHash); - - // But both lowercase paths should match the new hash - const lowercaseNewHash = getProjectHash(mixedCasePath.toLowerCase()); - expect(newHash).toBe(lowercaseNewHash); - - platformSpy.mockRestore(); - }); - - it('should match getProjectHash on non-Windows platforms', () => { - const platformSpy = vi.spyOn(os, 'platform'); - platformSpy.mockReturnValue('linux'); - - const testPath = '/home/user/project'; - const legacyHash = getLegacyProjectHash(testPath); - const newHash = getProjectHash(testPath); - - // On non-Windows platforms, both should be identical - expect(legacyHash).toBe(newHash); - - platformSpy.mockRestore(); - }); -}); diff --git a/packages/core/src/utils/paths.ts b/packages/core/src/utils/paths.ts index f90e9b4e9..96856a5dc 100644 --- a/packages/core/src/utils/paths.ts +++ b/packages/core/src/utils/paths.ts @@ -202,17 +202,6 @@ export function getProjectHash(projectRoot: string): string { return crypto.createHash('sha256').update(normalizedPath).digest('hex'); } -/** - * Generates a hash using the legacy algorithm (without case normalization). - * This is used for backward compatibility to locate session directories - * created before the case-insensitive fix on Windows. - * @param projectRoot The absolute path to the project's root directory. - * @returns A SHA256 hash of the project root path without normalization. - */ -export function getLegacyProjectHash(projectRoot: string): string { - return crypto.createHash('sha256').update(projectRoot).digest('hex'); -} - /** * Checks if a path is a subpath of another path. * @param parentPath The parent path. diff --git a/packages/vscode-ide-companion/src/services/qwenSessionManager.ts b/packages/vscode-ide-companion/src/services/qwenSessionManager.ts index 922ca78e5..a5e817cad 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionManager.ts @@ -8,10 +8,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import * as crypto from 'crypto'; -import { - getProjectHash, - getLegacyProjectHash, -} from '@qwen-code/qwen-code-core/src/utils/paths.js'; +import { getProjectHash } from '@qwen-code/qwen-code-core/src/utils/paths.js'; import type { QwenSession, QwenMessage } from './qwenSessionReader.js'; /** @@ -37,30 +34,6 @@ export class QwenSessionManager { private getSessionDir(workingDir: string): string { const projectHash = getProjectHash(workingDir); const sessionDir = path.join(this.qwenDir, 'tmp', projectHash, 'chats'); - - // Backward compatibility: On Windows, check if legacy directory exists - // and migrate it to the new normalized path - if (os.platform() === 'win32' && !fs.existsSync(sessionDir)) { - const legacyHash = getLegacyProjectHash(workingDir); - const legacySessionDir = path.join( - this.qwenDir, - 'tmp', - legacyHash, - 'chats', - ); - - if (fs.existsSync(legacySessionDir) && legacyHash !== projectHash) { - try { - // Migrate parent directory (hash directory, not just chats) - const newParentDir = path.join(this.qwenDir, 'tmp', projectHash); - const legacyParentDir = path.join(this.qwenDir, 'tmp', legacyHash); - fs.renameSync(legacyParentDir, newParentDir); - } catch (_error) { - // Silent fallback: if migration fails, continue with the new path - } - } - } - return sessionDir; } diff --git a/packages/vscode-ide-companion/src/services/qwenSessionReader.ts b/packages/vscode-ide-companion/src/services/qwenSessionReader.ts index 8215c8a20..0a65b0cb6 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionReader.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionReader.ts @@ -8,10 +8,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import * as readline from 'readline'; -import { - getProjectHash, - getLegacyProjectHash, -} from '@qwen-code/qwen-code-core/src/utils/paths.js'; +import { getProjectHash } from '@qwen-code/qwen-code-core/src/utils/paths.js'; export interface QwenMessage { id: string; @@ -63,33 +60,6 @@ export class QwenSessionReader { // Current project only const projectHash = getProjectHash(workingDir); const chatsDir = path.join(this.qwenDir, 'tmp', projectHash, 'chats'); - - // Backward compatibility: On Windows, try legacy hash if new directory doesn't exist - if (os.platform() === 'win32' && !fs.existsSync(chatsDir)) { - const legacyHash = getLegacyProjectHash(workingDir); - const legacyChatsDir = path.join( - this.qwenDir, - 'tmp', - legacyHash, - 'chats', - ); - - if (fs.existsSync(legacyChatsDir) && legacyHash !== projectHash) { - try { - // Migrate parent directory - const newParentDir = path.join(this.qwenDir, 'tmp', projectHash); - const legacyParentDir = path.join( - this.qwenDir, - 'tmp', - legacyHash, - ); - fs.renameSync(legacyParentDir, newParentDir); - } catch (_error) { - // Silent fallback - } - } - } - const projectSessions = await this.readSessionsFromDir(chatsDir); sessions.push(...projectSessions); } else { From 3c513b6271cc384113bc7d3fafa6f6a710b676ba Mon Sep 17 00:00:00 2001 From: DragonnZhang <731557579@qq.com> Date: Tue, 10 Feb 2026 16:45:04 +0800 Subject: [PATCH 17/81] Add dev launch config and preserve existing NODE_OPTIONS Add a "Dev Launch CLI" VS Code debug configuration and fix scripts/dev.js to preserve existing NODE_OPTIONS (e.g. --inspect flags injected by VS Code debugger) instead of overwriting them. --- .vscode/launch.json | 13 +++++++++++++ scripts/dev.js | 7 +++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index bab4f22e0..5d5db1d63 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -127,6 +127,19 @@ "cwd": "${workspaceFolder}", "console": "integratedTerminal", "env": {} + }, + { + "type": "node", + "request": "launch", + "name": "Dev Launch CLI", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "skipFiles": ["/**"], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "env": { + "GEMINI_SANDBOX": "false" + } } ], "inputs": [ diff --git a/scripts/dev.js b/scripts/dev.js index e0adcaea0..8432a32ce 100644 --- a/scripts/dev.js +++ b/scripts/dev.js @@ -67,13 +67,16 @@ register('${loaderUrl}', pathToFileURL('./')); `; writeFileSync(registerPath, registerCode); +// Preserve existing NODE_OPTIONS (e.g. VS Code debugger injects --inspect flags via NODE_OPTIONS) +const existingNodeOptions = process.env.NODE_OPTIONS || ''; +const importFlag = `--import ${pathToFileURL(registerPath).href}`; + const env = { ...process.env, DEV: 'true', CLI_VERSION: 'dev', NODE_ENV: 'development', - // Use --import with register() instead of deprecated --loader - NODE_OPTIONS: `--import ${pathToFileURL(registerPath).href}`, + NODE_OPTIONS: `${existingNodeOptions} ${importFlag}`.trim(), }; const nodeArgs = [tsxPath, cliEntry, ...process.argv.slice(2)]; From 48119e0cb12619cf6466f6695d64e457909608fc Mon Sep 17 00:00:00 2001 From: wenshao Date: Tue, 10 Feb 2026 23:43:41 +0800 Subject: [PATCH 18/81] feat: add TPM throttling error handling with 1-minute retry delay Add support for detecting and handling TPM (Tokens Per Minute) throttling errors. When a TPM throttling error is detected (e.g., 'Throttling: TPM(10680324/10000000)'), the system now waits 1 minute before retrying instead of using exponential backoff. Changes: - Add isTPMThrottlingError() function to detect TPM throttling errors - Modify retryWithBackoff() to use fixed 1-minute delay for TPM errors - Add unit tests for TPM throttling detection and retry behavior Co-authored-by: Qwen-Coder --- packages/core/src/utils/retry.test.ts | 123 +++++++++++++++++++++++++- packages/core/src/utils/retry.ts | 52 +++++++++++ 2 files changed, 174 insertions(+), 1 deletion(-) diff --git a/packages/core/src/utils/retry.test.ts b/packages/core/src/utils/retry.test.ts index 490f24448..de8710c1d 100644 --- a/packages/core/src/utils/retry.test.ts +++ b/packages/core/src/utils/retry.test.ts @@ -7,7 +7,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { HttpError } from './retry.js'; -import { getErrorStatus, retryWithBackoff } from './retry.js'; +import { + getErrorStatus, + isTPMThrottlingError, + retryWithBackoff, +} from './retry.js'; import { setSimulate429 } from './testUtils.js'; import { AuthType } from '../core/contentGenerator.js'; @@ -532,3 +536,120 @@ describe('getErrorStatus', () => { expect(getErrorStatus({ error: {} })).toBeUndefined(); }); }); + +describe('isTPMThrottlingError', () => { + it('should detect TPM throttling error from string', () => { + const errorMessage = + '{"error":{"message":"Throttling: TPM(10680324/10000000)","type":"Throttling","code":"429"}}'; + expect(isTPMThrottlingError(errorMessage)).toBe(true); + }); + + it('should detect TPM throttling error from Error object', () => { + const error = new Error('Throttling: TPM(10680324/10000000)'); + expect(isTPMThrottlingError(error)).toBe(true); + }); + + it('should detect TPM throttling error from nested error object', () => { + const error = { + error: { + message: 'Throttling: TPM(10680324/10000000)', + type: 'Throttling', + code: '429', + }, + }; + expect(isTPMThrottlingError(error)).toBe(true); + }); + + it('should return false for non-TPM errors', () => { + expect(isTPMThrottlingError('Regular error message')).toBe(false); + expect(isTPMThrottlingError(new Error('Regular error'))).toBe(false); + expect( + isTPMThrottlingError({ + error: { message: 'Rate limit exceeded', code: '429' }, + }), + ).toBe(false); + }); + + it('should return false for non-string non-object values', () => { + expect(isTPMThrottlingError(null)).toBe(false); + expect(isTPMThrottlingError(undefined)).toBe(false); + expect(isTPMThrottlingError(429)).toBe(false); + expect(isTPMThrottlingError(true)).toBe(false); + }); +}); + +describe('TPM throttling retry handling', () => { + beforeEach(() => { + vi.useFakeTimers(); + setSimulate429(false); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it('should wait 1 minute for TPM throttling errors before retrying', async () => { + const tpmError: HttpError = new Error('Throttling: TPM(10680324/10000000)'); + tpmError.status = 429; + + const fn = vi + .fn() + .mockRejectedValueOnce(tpmError) + .mockResolvedValue('success'); + + const promise = retryWithBackoff(fn, { + maxAttempts: 3, + initialDelayMs: 100, + maxDelayMs: 1000, + }); + + // Fast-forward 1 minute for TPM delay + await vi.advanceTimersByTimeAsync(60000); + + await expect(promise).resolves.toBe('success'); + + // Should be called twice (1 failure + 1 success) + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('should reset exponential backoff delay after TPM throttling error', async () => { + const tpmError: HttpError = new Error('Throttling: TPM(10680324/10000000)'); + tpmError.status = 429; + const normalError: HttpError = new Error('Server error'); + normalError.status = 500; + + const fn = vi + .fn() + .mockRejectedValueOnce(tpmError) // First: TPM error (1 minute delay) + .mockRejectedValueOnce(normalError) // Second: normal error (should use initialDelay) + .mockResolvedValue('success'); + + const setTimeoutSpy = vi.spyOn(global, 'setTimeout'); + + const promise = retryWithBackoff(fn, { + maxAttempts: 5, + initialDelayMs: 100, + maxDelayMs: 1000, + }); + + // Fast-forward 1 minute for TPM delay + await vi.advanceTimersByTimeAsync(60000); + + // Now handle the second error with exponential backoff + await vi.runAllTimersAsync(); + + await expect(promise).resolves.toBe('success'); + + // Should be called 3 times + expect(fn).toHaveBeenCalledTimes(3); + + // Check that the second delay (after TPM) uses initialDelayMs, not a doubled value + const delays = setTimeoutSpy.mock.calls.map((call) => call[1] as number); + // First delay should be 60000ms (1 minute for TPM) + // Second delay should be around initialDelayMs (100ms) with jitter + expect(delays[0]).toBe(60000); + expect(delays[1]).toBeGreaterThanOrEqual(100 * 0.7); + expect(delays[1]).toBeLessThanOrEqual(100 * 1.3); + }); +}); diff --git a/packages/core/src/utils/retry.ts b/packages/core/src/utils/retry.ts index fd9b5c025..1a14293e0 100644 --- a/packages/core/src/utils/retry.ts +++ b/packages/core/src/utils/retry.ts @@ -119,6 +119,19 @@ export async function retryWithBackoff( throw error; } + // Check for TPM throttling error - use fixed 1 minute delay + if (isTPMThrottlingError(error)) { + const tpmDelayMs = 60000; // 1 minute + debugLogger.warn( + `Attempt ${attempt} failed with TPM throttling error. Retrying after ${tpmDelayMs}ms (1 minute)...`, + error, + ); + await delay(tpmDelayMs); + // Reset currentDelay for next potential non-TPM error + currentDelay = initialDelayMs; + continue; + } + const retryAfterMs = errorStatus === 429 ? getRetryAfterDelayMs(error) : 0; @@ -147,6 +160,45 @@ export async function retryWithBackoff( throw new Error('Retry attempts exhausted'); } +/** + * Checks if an error is a TPM (Tokens Per Minute) throttling error. + * These errors occur when the API rate limit is exceeded for TPM. + * Example: {"error":{"message":"Throttling: TPM(10680324/10000000)","type":"Throttling","code":"429"}} + * @param error The error object. + * @returns True if the error is a TPM throttling error. + */ +export function isTPMThrottlingError(error: unknown): boolean { + const checkMessage = (message: string): boolean => message.includes('Throttling: TPM('); + + if (typeof error === 'string') { + return checkMessage(error); + } + + if (typeof error === 'object' && error !== null) { + // Check error.message + if ('message' in error && typeof (error as Error).message === 'string') { + if (checkMessage((error as Error).message)) { + return true; + } + } + + // Check error.error.message (nested error) + if ( + 'error' in error && + typeof (error as { error?: { message?: string } }).error === 'object' && + (error as { error?: { message?: string } }).error !== null + ) { + const nestedMessage = (error as { error: { message?: string } }).error + .message; + if (typeof nestedMessage === 'string' && checkMessage(nestedMessage)) { + return true; + } + } + } + + return false; +} + /** * Extracts the HTTP status code from an error object. * From c573c6a7431404cb03c6d5e8fff0d423a3f2f6d2 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Wed, 11 Feb 2026 16:56:35 +0800 Subject: [PATCH 19/81] Update packages/core/src/utils/retry.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 易良 <1204183885@qq.com> --- packages/core/src/utils/retry.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/core/src/utils/retry.ts b/packages/core/src/utils/retry.ts index 1a14293e0..e564eb6e2 100644 --- a/packages/core/src/utils/retry.ts +++ b/packages/core/src/utils/retry.ts @@ -174,7 +174,17 @@ export function isTPMThrottlingError(error: unknown): boolean { return checkMessage(error); } - if (typeof error === 'object' && error !== null) { +import { isStructuredError, isApiError } from './quotaErrorDetection.js'; + +export function isTPMThrottlingError(error: unknown): boolean { + const checkMessage = (msg: string) => msg.includes('Throttling: TPM('); + + if (typeof error === 'string') return checkMessage(error); + if (isStructuredError(error)) return checkMessage(error.message); + if (isApiError(error)) return checkMessage(error.error.message); + + return false; +} // Check error.message if ('message' in error && typeof (error as Error).message === 'string') { if (checkMessage((error as Error).message)) { From 93a131dd7b9cf8e3fe8dce3d56442664105a509e Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 11 Feb 2026 20:39:46 +0800 Subject: [PATCH 20/81] Handle TPM throttling in stream retries --- packages/core/src/core/geminiChat.ts | 46 ++++++++++++++++++- .../core/openaiContentGenerator/pipeline.ts | 30 ++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index df864eb3b..ee16bb669 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -17,7 +17,13 @@ import type { GenerateContentResponseUsageMetadata, } from '@google/genai'; import { createUserContent } from '@google/genai'; -import { getErrorStatus, retryWithBackoff } from '../utils/retry.js'; +import { + getErrorStatus, + retryWithBackoff, + isTPMThrottlingError, +} from '../utils/retry.js'; +import { StreamContentError } from './openaiContentGenerator/pipeline.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; import type { Config } from '../config/config.js'; import { hasCycleInSchema } from '../tools/tools.js'; import type { StructuredError } from './turn.js'; @@ -32,6 +38,8 @@ import { } from '../telemetry/types.js'; import { uiTelemetryService } from '../telemetry/uiTelemetry.js'; +const debugLogger = createDebugLogger('QWEN_CODE_CHAT'); + export enum StreamEventType { /** A regular content chunk from the API. */ CHUNK = 'chunk', @@ -58,6 +66,16 @@ const INVALID_CONTENT_RETRY_OPTIONS: ContentRetryOptions = { maxAttempts: 2, // 1 initial call + 1 retry initialDelayMs: 500, }; + +/** + * Options for retrying on TPM (Tokens Per Minute) throttling errors. + * These errors occur when the API rate limit is exceeded and are returned + * as stream content (finish_reason="error_finish") rather than HTTP errors. + */ +const TPM_RETRY_OPTIONS = { + maxRetries: 3, + delayMs: 60_000, // 1 minute - TPM quota typically resets within a minute window +}; /** * Returns true if the response is valid, false otherwise. * @@ -268,6 +286,7 @@ export class GeminiChat { return (async function* () { try { let lastError: unknown = new Error('Request failed after all retries.'); + let tpmRetryCount = 0; for ( let attempt = 0; @@ -275,7 +294,7 @@ export class GeminiChat { attempt++ ) { try { - if (attempt > 0) { + if (attempt > 0 || tpmRetryCount > 0) { yield { type: StreamEventType.RETRY }; } @@ -294,6 +313,29 @@ export class GeminiChat { break; } catch (error) { lastError = error; + + // Handle TPM throttling errors returned as stream content. + // These arrive as StreamContentError with finish_reason="error_finish" + // from the pipeline, containing the throttling message in the content. + if ( + (error instanceof StreamContentError || + isTPMThrottlingError(error)) && + tpmRetryCount < TPM_RETRY_OPTIONS.maxRetries + ) { + tpmRetryCount++; + debugLogger.warn( + `TPM throttling detected (retry ${tpmRetryCount}/${TPM_RETRY_OPTIONS.maxRetries}). ` + + `Waiting ${TPM_RETRY_OPTIONS.delayMs / 1000}s before retrying...`, + ); + yield { type: StreamEventType.RETRY }; + // Don't count TPM retries against the content retry limit + attempt--; + await new Promise((res) => + setTimeout(res, TPM_RETRY_OPTIONS.delayMs), + ); + continue; + } + const isContentError = error instanceof InvalidStreamError; if (isContentError) { diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts index 0ee0f1e25..e941e375f 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts @@ -15,6 +15,19 @@ import type { OpenAICompatibleProvider } from './provider/index.js'; import { OpenAIContentConverter } from './converter.js'; import type { ErrorHandler, RequestContext } from './errorHandler.js'; +/** + * Error thrown when the API returns an error embedded as stream content + * instead of a proper HTTP error. Some providers (e.g., certain OpenAI-compatible + * endpoints) return throttling errors as a normal SSE chunk with + * finish_reason="error_finish" and the error message in delta.content. + */ +export class StreamContentError extends Error { + constructor(message: string) { + super(message); + this.name = 'StreamContentError'; + } +} + export interface PipelineConfig { cliConfig: Config; provider: OpenAICompatibleProvider; @@ -117,6 +130,17 @@ export class ContentGenerationPipeline { try { // Stage 2a: Convert and yield each chunk while preserving original for await (const chunk of stream) { + // Detect API errors returned as stream content. + // Some providers return errors (e.g., TPM throttling) as a normal SSE chunk + // with finish_reason="error_finish" and the error in delta.content, + // instead of returning a proper HTTP error status. + if ((chunk.choices?.[0]?.finish_reason as string) === 'error_finish') { + const errorContent = + chunk.choices?.[0]?.delta?.content?.trim() || + 'Unknown stream error'; + throw new StreamContentError(errorContent); + } + const response = this.converter.convertOpenAIChunkToGemini(chunk); // Stage 2b: Filter empty responses to avoid downstream issues @@ -156,6 +180,12 @@ export class ContentGenerationPipeline { // Stage 2e: Stream completed successfully context.duration = Date.now() - context.startTime; } catch (error) { + // Re-throw StreamContentError directly so it can be handled by + // the caller's retry logic (e.g., TPM throttling retry in sendMessageStream) + if (error instanceof StreamContentError) { + throw error; + } + // Clear streaming tool calls on error to prevent data pollution this.converter.resetStreamingToolCalls(); From e9d2ead38f1016f65bac50aae35f765e5edfbe27 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 11 Feb 2026 20:42:20 +0800 Subject: [PATCH 21/81] refactor(core): simplify TPM throttling error detection logic - Remove redundant error checking logic in isTPMThrottlingError function - Reuse isStructuredError and isApiError utilities from quotaErrorDetection module - Clean up duplicate import statements --- packages/core/src/utils/retry.ts | 33 +------------------------------- 1 file changed, 1 insertion(+), 32 deletions(-) diff --git a/packages/core/src/utils/retry.ts b/packages/core/src/utils/retry.ts index e564eb6e2..a78ea4ab8 100644 --- a/packages/core/src/utils/retry.ts +++ b/packages/core/src/utils/retry.ts @@ -8,6 +8,7 @@ import type { GenerateContentResponse } from '@google/genai'; import { AuthType } from '../core/contentGenerator.js'; import { isQwenQuotaExceededError } from './quotaErrorDetection.js'; import { createDebugLogger } from './debugLogger.js'; +import { isStructuredError, isApiError } from './quotaErrorDetection.js'; const debugLogger = createDebugLogger('RETRY'); @@ -167,15 +168,6 @@ export async function retryWithBackoff( * @param error The error object. * @returns True if the error is a TPM throttling error. */ -export function isTPMThrottlingError(error: unknown): boolean { - const checkMessage = (message: string): boolean => message.includes('Throttling: TPM('); - - if (typeof error === 'string') { - return checkMessage(error); - } - -import { isStructuredError, isApiError } from './quotaErrorDetection.js'; - export function isTPMThrottlingError(error: unknown): boolean { const checkMessage = (msg: string) => msg.includes('Throttling: TPM('); @@ -183,29 +175,6 @@ export function isTPMThrottlingError(error: unknown): boolean { if (isStructuredError(error)) return checkMessage(error.message); if (isApiError(error)) return checkMessage(error.error.message); - return false; -} - // Check error.message - if ('message' in error && typeof (error as Error).message === 'string') { - if (checkMessage((error as Error).message)) { - return true; - } - } - - // Check error.error.message (nested error) - if ( - 'error' in error && - typeof (error as { error?: { message?: string } }).error === 'object' && - (error as { error?: { message?: string } }).error !== null - ) { - const nestedMessage = (error as { error: { message?: string } }).error - .message; - if (typeof nestedMessage === 'string' && checkMessage(nestedMessage)) { - return true; - } - } - } - return false; } From e00ee9d45bfe583083b339f155f22f888e903412 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 11 Feb 2026 21:17:52 +0800 Subject: [PATCH 22/81] refactor(core): prioritize TPM throttling check over shouldRetryOnError - Move TPM throttling check before shouldRetryOnError to ensure TPM errors without standard HTTP status codes are still retried - Add comprehensive unit tests for edge cases: - TPM error without status property - Nested TPM error object without top-level status - Consecutive TPM throttling errors - Max attempts exhaustion for TPM errors --- packages/core/src/core/geminiChat.test.ts | 75 +++++++++++++++ .../openaiContentGenerator/pipeline.test.ts | 47 +++++++++- packages/core/src/utils/retry.test.ts | 94 +++++++++++++++++++ packages/core/src/utils/retry.ts | 11 ++- 4 files changed, 224 insertions(+), 3 deletions(-) diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 57685e6fb..538d782e6 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -18,6 +18,7 @@ import { StreamEventType, type StreamEvent, } from './geminiChat.js'; +import { StreamContentError } from './openaiContentGenerator/pipeline.js'; import type { Config } from '../config/config.js'; import { setSimulate429 } from '../utils/testUtils.js'; import { uiTelemetryService } from '../telemetry/uiTelemetry.js'; @@ -930,6 +931,80 @@ describe('GeminiChat', () => { }); }); + it('should retry on TPM throttling StreamContentError with fixed delay', async () => { + vi.useFakeTimers(); + + try { + const tpmError = new StreamContentError('Throttling: TPM(1/1)'); + async function* failingStreamGenerator() { + throw tpmError; + + yield {} as GenerateContentResponse; + } + const failingStream = failingStreamGenerator(); + const successStream = (async function* () { + yield { + candidates: [ + { + content: { parts: [{ text: 'Success after TPM retry' }] }, + finishReason: 'STOP', + }, + ], + } as unknown as GenerateContentResponse; + })(); + + vi.mocked(mockContentGenerator.generateContentStream) + .mockResolvedValueOnce(failingStream) + .mockResolvedValueOnce(successStream); + + const stream = await chat.sendMessageStream( + 'test-model', + { message: 'test' }, + 'prompt-id-tpm-retry', + ); + + const iterator = stream[Symbol.asyncIterator](); + const first = await iterator.next(); + + expect(first.done).toBe(false); + expect(first.value.type).toBe(StreamEventType.RETRY); + + // Resume generator to schedule the TPM delay, then advance timers. + const secondPromise = iterator.next(); + await vi.advanceTimersByTimeAsync(60_000); + const second = await secondPromise; + + expect(second.done).toBe(false); + expect(second.value.type).toBe(StreamEventType.RETRY); + + const events: StreamEvent[] = [first.value, second.value]; + + for (;;) { + const next = await iterator.next(); + if (next.done) break; + events.push(next.value); + } + + expect( + mockContentGenerator.generateContentStream, + ).toHaveBeenCalledTimes(2); + expect( + events.filter((e) => e.type === StreamEventType.RETRY), + ).toHaveLength(2); + expect( + events.some( + (e) => + e.type === StreamEventType.CHUNK && + e.value.candidates?.[0]?.content?.parts?.[0]?.text === + 'Success after TPM retry', + ), + ).toBe(true); + expect(mockLogContentRetry).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + describe('API error retry behavior', () => { beforeEach(() => { // Use a more direct mock for retry testing diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts index d5220b080..9ea52306e 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts @@ -10,7 +10,7 @@ import type OpenAI from 'openai'; import type { GenerateContentParameters } from '@google/genai'; import { GenerateContentResponse, Type, FinishReason } from '@google/genai'; import type { PipelineConfig } from './pipeline.js'; -import { ContentGenerationPipeline } from './pipeline.js'; +import { ContentGenerationPipeline, StreamContentError } from './pipeline.js'; import { OpenAIContentConverter } from './converter.js'; import type { Config } from '../../config/config.js'; import type { ContentGeneratorConfig, AuthType } from '../contentGenerator.js'; @@ -510,6 +510,51 @@ describe('ContentGenerationPipeline', () => { ); }); + it('should throw StreamContentError when stream chunk contains error_finish', async () => { + const request: GenerateContentParameters = { + model: 'test-model', + contents: [{ parts: [{ text: 'Hello' }], role: 'user' }], + }; + const userPromptId = 'test-prompt-id'; + + const mockStream = { + async *[Symbol.asyncIterator]() { + yield { + id: 'chunk-1', + object: 'chat.completion.chunk', + created: Date.now(), + model: 'test-model', + choices: [ + { + index: 0, + delta: { content: 'Throttling: TPM(1/1)' }, + finish_reason: 'error_finish', + }, + ], + } as OpenAI.Chat.ChatCompletionChunk; + }, + }; + + (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); + (mockClient.chat.completions.create as Mock).mockResolvedValue( + mockStream, + ); + + const resultGenerator = await pipeline.executeStream( + request, + userPromptId, + ); + + await expect(async () => { + for await (const _ of resultGenerator) { + // consume stream + } + }).rejects.toThrow(StreamContentError); + + expect(mockErrorHandler.handle).not.toHaveBeenCalled(); + expect(mockConverter.convertOpenAIChunkToGemini).not.toHaveBeenCalled(); + }); + it('should pass abort signal to OpenAI client for streaming requests', async () => { const abortController = new AbortController(); const request: GenerateContentParameters = { diff --git a/packages/core/src/utils/retry.test.ts b/packages/core/src/utils/retry.test.ts index de8710c1d..668b2ecc0 100644 --- a/packages/core/src/utils/retry.test.ts +++ b/packages/core/src/utils/retry.test.ts @@ -652,4 +652,98 @@ describe('TPM throttling retry handling', () => { expect(delays[1]).toBeGreaterThanOrEqual(100 * 0.7); expect(delays[1]).toBeLessThanOrEqual(100 * 1.3); }); + + it('should handle TPM throttling error without status property', async () => { + // 真实场景:错误只有 message,没有 status=429 + const tpmError = new Error('Throttling: TPM(10680324/10000000)'); + // 注意:故意不设 tpmError.status = 429 + + const fn = vi + .fn() + .mockRejectedValueOnce(tpmError) + .mockResolvedValue('success'); + + const promise = retryWithBackoff(fn, { + maxAttempts: 3, + initialDelayMs: 100, + maxDelayMs: 1000, + }); + + // Fast-forward 1 minute for TPM delay + await vi.advanceTimersByTimeAsync(60000); + + // 这个测试验证:即使错误没有 status=429,TPM 检查也应该正常工作 + await expect(promise).resolves.toBe('success'); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('should handle nested TPM error object without top-level status', async () => { + // 模拟 API 直接返回的嵌套错误格式 + const nestedTpmError = { + error: { + message: 'Throttling: TPM(10680324/10000000)', + type: 'Throttling', + code: '429', + }, + }; + + const fn = vi + .fn() + .mockRejectedValueOnce(nestedTpmError) + .mockResolvedValue('success'); + + const promise = retryWithBackoff(fn, { + maxAttempts: 3, + initialDelayMs: 100, + maxDelayMs: 1000, + }); + + // Fast-forward 1 minute for TPM delay + await vi.advanceTimersByTimeAsync(60000); + + await expect(promise).resolves.toBe('success'); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('should retry multiple times for consecutive TPM throttling errors', async () => { + const tpmError: HttpError = new Error('Throttling: TPM(10680324/10000000)'); + tpmError.status = 429; + + const fn = vi + .fn() + .mockRejectedValueOnce(tpmError) // First TPM error + .mockRejectedValueOnce(tpmError) // Second TPM error + .mockResolvedValue('success'); + + const promise = retryWithBackoff(fn, { + maxAttempts: 5, + initialDelayMs: 100, + maxDelayMs: 1000, + }); + + // Fast-forward 2 minutes for two TPM delays + await vi.advanceTimersByTimeAsync(120000); + + await expect(promise).resolves.toBe('success'); + expect(fn).toHaveBeenCalledTimes(3); + }); + + it('should eventually throw after maxAttempts TPM throttling errors', async () => { + const tpmError: HttpError = new Error('Throttling: TPM(10680324/10000000)'); + tpmError.status = 429; + + const fn = vi.fn().mockRejectedValue(tpmError); + + const promise = retryWithBackoff(fn, { + maxAttempts: 3, + initialDelayMs: 100, + maxDelayMs: 1000, + }); + + // Fast-forward time for all TPM delays (3 attempts = 2 retries) + await vi.advanceTimersByTimeAsync(120000); + + await expect(promise).rejects.toThrow('Throttling: TPM(10680324/10000000)'); + expect(fn).toHaveBeenCalledTimes(3); + }); }); diff --git a/packages/core/src/utils/retry.ts b/packages/core/src/utils/retry.ts index a78ea4ab8..6c580548c 100644 --- a/packages/core/src/utils/retry.ts +++ b/packages/core/src/utils/retry.ts @@ -115,12 +115,14 @@ export async function retryWithBackoff( ); } - // Check if we've exhausted retries or shouldn't retry - if (attempt >= maxAttempts || !shouldRetryOnError(error as Error)) { + // Check if we've exhausted retries + if (attempt >= maxAttempts) { throw error; } // Check for TPM throttling error - use fixed 1 minute delay + // This check is prioritized over shouldRetryOnError because TPM errors + // may not have a standard HTTP status code (like 429) but still need retry if (isTPMThrottlingError(error)) { const tpmDelayMs = 60000; // 1 minute debugLogger.warn( @@ -133,6 +135,11 @@ export async function retryWithBackoff( continue; } + // Check if we shouldn't retry based on error type + if (!shouldRetryOnError(error as Error)) { + throw error; + } + const retryAfterMs = errorStatus === 429 ? getRetryAfterDelayMs(error) : 0; From aef292125ace3f853a23ed6a3ce77977c5def534 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 11 Feb 2026 21:28:30 +0800 Subject: [PATCH 23/81] test(core): fix type assertion in pipeline test for error_finish chunk - Change 'as' to 'as unknown as' for proper type casting --- packages/core/src/core/openaiContentGenerator/pipeline.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts index 9ea52306e..964f768a3 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts @@ -531,7 +531,7 @@ describe('ContentGenerationPipeline', () => { finish_reason: 'error_finish', }, ], - } as OpenAI.Chat.ChatCompletionChunk; + } as unknown as OpenAI.Chat.ChatCompletionChunk; }, }; From 3f04217458198f6d47fd703c3a192fc7698523a1 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Thu, 12 Feb 2026 10:39:19 +0800 Subject: [PATCH 24/81] fix: prevent AbortSignal listener memory leak - Add abort listener cleanup in Query.close() to prevent memory leak - Add abort listener cleanup in ControlDispatcher.shutdown() - Remove AbortController recreation in Session.handleInterrupt() This fixes the MaxListenersExceededWarning that occurred when: - Creating 11+ Query instances in SDK/non-interactive mode - Multiple user interrupts (Ctrl+C) in interactive mode - Intensive control request scenarios --- .../nonInteractive/control/ControlDispatcher.ts | 13 +++++++++++-- packages/cli/src/nonInteractive/session.ts | 3 ++- packages/sdk-typescript/src/query/Query.ts | 15 +++++++++++++-- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/nonInteractive/control/ControlDispatcher.ts b/packages/cli/src/nonInteractive/control/ControlDispatcher.ts index f2fb267c7..8a049f0af 100644 --- a/packages/cli/src/nonInteractive/control/ControlDispatcher.ts +++ b/packages/cli/src/nonInteractive/control/ControlDispatcher.ts @@ -80,6 +80,8 @@ export class ControlDispatcher implements IPendingRequestRegistry { private pendingOutgoingRequests: Map = new Map(); + private abortHandler: (() => void) | null = null; + constructor(context: IControlContext) { this.context = context; @@ -102,9 +104,10 @@ export class ControlDispatcher implements IPendingRequestRegistry { // this.hookController = new HookController(context, this, 'HookController'); // Listen for main abort signal - this.context.abortSignal.addEventListener('abort', () => { + this.abortHandler = () => { this.shutdown(); - }); + }; + this.context.abortSignal.addEventListener('abort', this.abortHandler); } /** @@ -240,6 +243,12 @@ export class ControlDispatcher implements IPendingRequestRegistry { shutdown(): void { debugLogger.debug('[ControlDispatcher] Shutting down'); + // Remove abort listener to prevent memory leak + if (this.abortHandler) { + this.context.abortSignal.removeEventListener('abort', this.abortHandler); + this.abortHandler = null; + } + // Cancel all incoming requests for (const [ _requestId, diff --git a/packages/cli/src/nonInteractive/session.ts b/packages/cli/src/nonInteractive/session.ts index ae04eb642..6b8c9b880 100644 --- a/packages/cli/src/nonInteractive/session.ts +++ b/packages/cli/src/nonInteractive/session.ts @@ -408,7 +408,8 @@ class Session { private handleInterrupt(): void { debugLogger.info('[Session] Interrupt requested'); this.abortController.abort(); - this.abortController = new AbortController(); + // Do not create a new AbortController to prevent listener leaks. + // Subsequent queries will check signal.aborted and fail immediately. } private setupSignalHandlers(): void { diff --git a/packages/sdk-typescript/src/query/Query.ts b/packages/sdk-typescript/src/query/Query.ts index 7d1a936a4..50c1db3bd 100644 --- a/packages/sdk-typescript/src/query/Query.ts +++ b/packages/sdk-typescript/src/query/Query.ts @@ -83,6 +83,7 @@ export class Query implements AsyncIterable { private firstResultReceivedResolve?: () => void; private readonly isSingleTurn: boolean; + private abortHandler: (() => void) | null = null; constructor( transport: Transport, @@ -125,12 +126,13 @@ export class Query implements AsyncIterable { logger.error('Error during abort cleanup:', err); }); } else { - this.abortController.signal.addEventListener('abort', () => { + this.abortHandler = () => { this.inputStream.error(new AbortError('Query aborted by user')); this.close().catch((err) => { logger.error('Error during abort cleanup:', err); }); - }); + }; + this.abortController.signal.addEventListener('abort', this.abortHandler); } this.initialized = this.initialize(); @@ -719,6 +721,15 @@ export class Query implements AsyncIterable { this.closed = true; + // Remove abort listener to prevent memory leak + if (this.abortHandler) { + this.abortController.signal.removeEventListener( + 'abort', + this.abortHandler, + ); + this.abortHandler = null; + } + for (const pending of this.pendingControlRequests.values()) { pending.abortController.abort(); clearTimeout(pending.timeout); From 785d0ef5b7ff765d44429eaf56e8a633c19fe69f Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Thu, 12 Feb 2026 11:29:02 +0800 Subject: [PATCH 25/81] fix: enforce plan mode restrictions in ACP sessions (issue #1806) - Add plan mode enforcement in Session.runTool to block write tools - Align ACP behavior with CoreToolScheduler plan mode logic - Add test case to verify write tools are blocked in plan mode - Fixes #1806 --- integration-tests/acp-integration.test.ts | 95 +++++++++++++++++++ .../src/acp-integration/session/Session.ts | 12 +++ 2 files changed, 107 insertions(+) diff --git a/integration-tests/acp-integration.test.ts b/integration-tests/acp-integration.test.ts index 35397da26..93389d605 100644 --- a/integration-tests/acp-integration.test.ts +++ b/integration-tests/acp-integration.test.ts @@ -648,6 +648,101 @@ function setupAcpTest( } }); + it('blocks write tools in plan mode (issue #1806)', async () => { + const rig = new TestRig(); + rig.setup('acp plan mode enforcement'); + + const toolCallEvents: Array<{ + toolName: string; + status: string; + error?: string; + }> = []; + + const { sendRequest, cleanup, stderr, sessionUpdates } = setupAcpTest(rig, { + permissionHandler: () => ({ optionId: 'proceed_once' }), + }); + + try { + await sendRequest('initialize', { + protocolVersion: 1, + clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } }, + }); + await sendRequest('authenticate', { methodId: 'openai' }); + + const newSession = (await sendRequest('session/new', { + cwd: rig.testDir!, + mcpServers: [], + })) as { sessionId: string }; + + // Set mode to 'plan' + const setModeResult = (await sendRequest('session/set_mode', { + sessionId: newSession.sessionId, + modeId: 'plan', + })) as { modeId: string }; + expect(setModeResult.modeId).toBe('plan'); + + // Try to create a file - this should be blocked by plan mode + const promptResult = await sendRequest('session/prompt', { + sessionId: newSession.sessionId, + prompt: [ + { + type: 'text', + text: 'Create a file called test.txt with content "Hello World"', + }, + ], + }); + expect(promptResult).toBeDefined(); + + // Give time for tool calls to be processed + await delay(2000); + + // Collect tool call events from session updates + sessionUpdates.forEach((update) => { + if (update.update?.sessionUpdate === 'tool_call_update') { + const toolUpdate = update.update as { + sessionUpdate: string; + toolName?: string; + status?: string; + error?: { message?: string }; + }; + if (toolUpdate.toolName) { + toolCallEvents.push({ + toolName: toolUpdate.toolName, + status: toolUpdate.status ?? 'unknown', + error: toolUpdate.error?.message, + }); + } + } + }); + + // Verify that if write_file was attempted, it was blocked + const writeFileEvents = toolCallEvents.filter( + (e) => e.toolName === 'write_file', + ); + + // If the LLM tried to call write_file in plan mode, it should have been blocked + if (writeFileEvents.length > 0) { + const blockedEvent = writeFileEvents.find( + (e) => e.status === 'error' && e.error?.includes('Plan mode'), + ); + expect(blockedEvent).toBeDefined(); + expect(blockedEvent?.error).toContain('Plan mode is active'); + } + + // Verify the file was NOT created + const fs = await import('fs'); + const path = await import('path'); + const testFilePath = path.join(rig.testDir!, 'test.txt'); + const fileExists = fs.existsSync(testFilePath); + expect(fileExists).toBe(false); + } catch (e) { + if (stderr.length) console.error('Agent stderr:', stderr.join('')); + throw e; + } finally { + await cleanup(); + } + }); + it('receives usage metadata in agent_message_chunk updates', async () => { const rig = new TestRig(); rig.setup('acp usage metadata'); diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index d7a5e7395..702f66a07 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -516,6 +516,18 @@ export class Session implements SessionContext { ? await invocation.shouldConfirmExecute(abortSignal) : false; + // Check for plan mode enforcement - block non-read-only tools + const isPlanMode = this.config.getApprovalMode() === ApprovalMode.PLAN; + if (isPlanMode && !isExitPlanModeTool && confirmationDetails) { + // In plan mode, block any tool that requires confirmation (write operations) + return errorResponse( + new Error( + `Plan mode is active. The tool "${fc.name}" cannot be executed because it modifies the system. ` + + 'Please use the exit_plan_mode tool to present your plan and exit plan mode before making changes.', + ), + ); + } + if (confirmationDetails) { const content: acp.ToolCallContent[] = []; From 428901f136e25b133d95843e475708a3f6000404 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Thu, 12 Feb 2026 11:42:58 +0800 Subject: [PATCH 26/81] fix: correct showLineNumbers default value to true - Changed default value from false to true in settingsSchema.ts - This aligns the schema with the actual code behavior (?? true fallback) - Matches documentation and test expectations - Resolves inconsistency reported in issue #1764 Fixes #1764 --- packages/cli/src/config/settingsSchema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 711bf3e8e..283baee26 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -373,7 +373,7 @@ const SETTINGS_SCHEMA = { label: 'Show Line Numbers in Code', category: 'UI', requiresRestart: false, - default: false, + default: true, description: 'Show line numbers in the code output.', showInDialog: true, }, From 9b882b4752e6f03f6c2bdf667688cebbd84889ef Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 12 Feb 2026 12:55:26 +0800 Subject: [PATCH 27/81] wip --- PR_DESCRIPTION_ZH.md | 53 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 PR_DESCRIPTION_ZH.md diff --git a/PR_DESCRIPTION_ZH.md b/PR_DESCRIPTION_ZH.md new file mode 100644 index 000000000..5b25639a0 --- /dev/null +++ b/PR_DESCRIPTION_ZH.md @@ -0,0 +1,53 @@ +## TLDR + +修复在容器环境(如 code-server 远程开发)中,IDE 客户端因 `host.docker.internal` 域名不可达而无法连接的问题。 + +将 `getIdeServerHost()` 函数改为异步,在检测到容器环境后,先通过 DNS 查询验证 `host.docker.internal` 是否可解析,不可达时自动回退到 `127.0.0.1`。 + +## Dive Deeper + +### 问题背景 + +在 Docker Desktop 中,`host.docker.internal` 会自动配置为指向宿主机的特殊域名。但在以下环境中该域名不一定存在: + +- **Linux Docker**:需要手动添加 `--add-host=host.docker.internal:host-gateway` +- **code-server 远程环境**:不运行在 Docker Desktop 中,没有该域名 +- **Podman 等其他容器运行时**:不一定支持该域名 + +原有实现只通过 `/.dockerenv` 或 `/run/.containerenv` 文件检测是否在容器中,一旦检测到就直接使用 `host.docker.internal`,但未验证该域名是否实际可达,导致在上述环境中 IDE 连接失败。 + +### 修改内容 + +1. **`getIdeServerHost()` 改为异步函数**:新增 `dns.lookup` 检查 `host.docker.internal` 是否可解析 +2. **结果缓存**:首次检测结果缓存到模块级变量 `cachedIdeServerHost`,避免重复 DNS 查询 +3. **debug 日志**:在容器环境下输出 DNS 检查结果,方便排查连接问题 +4. **导出测试辅助函数**:`_resetCachedIdeServerHost()` 用于测试间隔离缓存状态 +5. **完整单元测试**:新增 6 个测试用例覆盖所有场景 + +### 修改文件 + +| 文件 | 变更 | +| ------------------------------------------ | ------------ | +| `packages/core/src/ide/ide-client.ts` | 核心逻辑修改 | +| `packages/core/src/ide/ide-client.test.ts` | 新增测试用例 | + +## Reviewer Test Plan + +1. 拉取分支后运行测试:`npx vitest run packages/core/src/ide/ide-client.test.ts` +2. 确认所有 24 个测试通过(含 6 个新增) +3. 在 Docker 容器中(有 `host.docker.internal`)验证 IDE 连接正常 +4. 在 code-server 远程环境中(无 `host.docker.internal`)验证 IDE 自动回退到 `127.0.0.1` + +## Testing Matrix + +| | 🍏 | 🪟 | 🐧 | +| -------- | --- | --- | --- | +| npm run | ✅ | ❓ | ❓ | +| npx | ❓ | ❓ | ❓ | +| Docker | ❓ | ❓ | ❓ | +| Podman | ❓ | - | - | +| Seatbelt | ❓ | - | - | + +## Linked issues / bugs + + From 1c384551901c6cb7494f18f7ca31d1fc09d5cb6b Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 12 Feb 2026 12:55:51 +0800 Subject: [PATCH 28/81] test(core): add rejection handler to prevent unhandled rejection in TPM throttling test Add a .catch() handler to the promise before advancing timers to prevent Node.js from reporting an unhandled rejection when maxAttempts is exhausted during the TPM throttling retry test. --- packages/core/src/utils/retry.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/core/src/utils/retry.test.ts b/packages/core/src/utils/retry.test.ts index 668b2ecc0..26cb52aa5 100644 --- a/packages/core/src/utils/retry.test.ts +++ b/packages/core/src/utils/retry.test.ts @@ -740,6 +740,11 @@ describe('TPM throttling retry handling', () => { maxDelayMs: 1000, }); + // Attach a rejection handler BEFORE advancing timers to prevent Node.js + // from reporting an unhandled rejection. The rejection occurs during + // advanceTimersByTimeAsync when maxAttempts is exhausted. + promise.catch(() => {}); + // Fast-forward time for all TPM delays (3 attempts = 2 retries) await vi.advanceTimersByTimeAsync(120000); From 2394d732c36b4a69bcccf5eabfcd7abc9abf727e Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 12 Feb 2026 13:11:22 +0800 Subject: [PATCH 29/81] Revert "wip" This reverts commit 9b882b4752e6f03f6c2bdf667688cebbd84889ef. --- PR_DESCRIPTION_ZH.md | 53 -------------------------------------------- 1 file changed, 53 deletions(-) delete mode 100644 PR_DESCRIPTION_ZH.md diff --git a/PR_DESCRIPTION_ZH.md b/PR_DESCRIPTION_ZH.md deleted file mode 100644 index 5b25639a0..000000000 --- a/PR_DESCRIPTION_ZH.md +++ /dev/null @@ -1,53 +0,0 @@ -## TLDR - -修复在容器环境(如 code-server 远程开发)中,IDE 客户端因 `host.docker.internal` 域名不可达而无法连接的问题。 - -将 `getIdeServerHost()` 函数改为异步,在检测到容器环境后,先通过 DNS 查询验证 `host.docker.internal` 是否可解析,不可达时自动回退到 `127.0.0.1`。 - -## Dive Deeper - -### 问题背景 - -在 Docker Desktop 中,`host.docker.internal` 会自动配置为指向宿主机的特殊域名。但在以下环境中该域名不一定存在: - -- **Linux Docker**:需要手动添加 `--add-host=host.docker.internal:host-gateway` -- **code-server 远程环境**:不运行在 Docker Desktop 中,没有该域名 -- **Podman 等其他容器运行时**:不一定支持该域名 - -原有实现只通过 `/.dockerenv` 或 `/run/.containerenv` 文件检测是否在容器中,一旦检测到就直接使用 `host.docker.internal`,但未验证该域名是否实际可达,导致在上述环境中 IDE 连接失败。 - -### 修改内容 - -1. **`getIdeServerHost()` 改为异步函数**:新增 `dns.lookup` 检查 `host.docker.internal` 是否可解析 -2. **结果缓存**:首次检测结果缓存到模块级变量 `cachedIdeServerHost`,避免重复 DNS 查询 -3. **debug 日志**:在容器环境下输出 DNS 检查结果,方便排查连接问题 -4. **导出测试辅助函数**:`_resetCachedIdeServerHost()` 用于测试间隔离缓存状态 -5. **完整单元测试**:新增 6 个测试用例覆盖所有场景 - -### 修改文件 - -| 文件 | 变更 | -| ------------------------------------------ | ------------ | -| `packages/core/src/ide/ide-client.ts` | 核心逻辑修改 | -| `packages/core/src/ide/ide-client.test.ts` | 新增测试用例 | - -## Reviewer Test Plan - -1. 拉取分支后运行测试:`npx vitest run packages/core/src/ide/ide-client.test.ts` -2. 确认所有 24 个测试通过(含 6 个新增) -3. 在 Docker 容器中(有 `host.docker.internal`)验证 IDE 连接正常 -4. 在 code-server 远程环境中(无 `host.docker.internal`)验证 IDE 自动回退到 `127.0.0.1` - -## Testing Matrix - -| | 🍏 | 🪟 | 🐧 | -| -------- | --- | --- | --- | -| npm run | ✅ | ❓ | ❓ | -| npx | ❓ | ❓ | ❓ | -| Docker | ❓ | ❓ | ❓ | -| Podman | ❓ | - | - | -| Seatbelt | ❓ | - | - | - -## Linked issues / bugs - - From 3fb641ca1a0468a3377ea7e558eea49a1d5c33e9 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 12 Feb 2026 16:21:10 +0800 Subject: [PATCH 30/81] feat(core, cli): add rate limit throttling retry with countdown UI - Refactor retry utility to support GLM rate limit errors (code 1302) and TPM throttling - Add getRateLimitRetryInfo() for unified rate-limit error detection - Add exponential backoff for non-TPM rate limit errors - Extend StreamEventType.RETRY with RetryInfo payload for UI feedback - Add RetryCountdownMessage component for visual retry countdown - Update useGeminiStream hook to handle retry events with countdown timer - Add i18n support for rate limit messages (en/zh) --- packages/cli/src/i18n/locales/en.js | 7 + packages/cli/src/i18n/locales/zh.js | 7 + .../src/ui/components/HistoryItemDisplay.tsx | 4 + .../messages/RetryCountdownMessage.tsx | 41 ++++ .../cli/src/ui/hooks/useGeminiStream.test.tsx | 101 +++++++++ packages/cli/src/ui/hooks/useGeminiStream.ts | 124 ++++++++++- packages/cli/src/ui/types.ts | 6 + packages/core/src/core/geminiChat.test.ts | 87 +++++++- packages/core/src/core/geminiChat.ts | 72 +++++-- packages/core/src/core/turn.ts | 8 +- packages/core/src/utils/retry.test.ts | 201 ++++++++++++++++++ packages/core/src/utils/retry.ts | 180 +++++++++++++++- 12 files changed, 796 insertions(+), 42 deletions(-) create mode 100644 packages/cli/src/ui/components/messages/RetryCountdownMessage.tsx diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 79af44452..f119dd3e9 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1362,4 +1362,11 @@ export default { 'Opening extensions page in your browser: {{url}}', 'Failed to open browser. Check out the extensions gallery at {{url}}': 'Failed to open browser. Check out the extensions gallery at {{url}}', + + // ============================================================================ + // Retry / Rate Limit + // ============================================================================ + 'Rate limit error: {{reason}}': 'Rate limit error: {{reason}}', + 'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})': + 'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})', }; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 10530a4ac..1af3b5425 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1198,4 +1198,11 @@ export default { '正在浏览器中打开扩展页面:{{url}}', 'Failed to open browser. Check out the extensions gallery at {{url}}': '打开浏览器失败。请访问扩展市场:{{url}}', + + // ============================================================================ + // Retry / Rate Limit + // ============================================================================ + 'Rate limit error: {{reason}}': '触发限流:{{reason}}', + 'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})': + '将于 {{seconds}} 秒后重试…(第 {{attempt}}/{{maxRetries}} 次)', }; diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index a4fa9ee7c..73bdd6de3 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -20,6 +20,7 @@ import { GeminiThoughtMessageContent } from './messages/GeminiThoughtMessageCont import { CompressionMessage } from './messages/CompressionMessage.js'; import { SummaryMessage } from './messages/SummaryMessage.js'; import { WarningMessage } from './messages/WarningMessage.js'; +import { RetryCountdownMessage } from './messages/RetryCountdownMessage.js'; import { Box } from 'ink'; import { AboutBox } from './AboutBox.js'; import { StatsDisplay } from './StatsDisplay.js'; @@ -126,6 +127,9 @@ const HistoryItemDisplayComponent: React.FC = ({ {itemForDisplay.type === 'error' && ( )} + {itemForDisplay.type === 'retry_countdown' && ( + + )} {itemForDisplay.type === 'about' && ( )} diff --git a/packages/cli/src/ui/components/messages/RetryCountdownMessage.tsx b/packages/cli/src/ui/components/messages/RetryCountdownMessage.tsx new file mode 100644 index 000000000..0f4727574 --- /dev/null +++ b/packages/cli/src/ui/components/messages/RetryCountdownMessage.tsx @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Text, Box } from 'ink'; +import { theme } from '../../semantic-colors.js'; + +interface RetryCountdownMessageProps { + text: string; +} + +/** + * Displays a retry countdown message in a dimmed/secondary style + * to visually distinguish it from error messages. + */ +export const RetryCountdownMessage: React.FC = ({ + text, +}) => { + if (!text || text.trim() === '') { + return null; + } + + const prefix = '↻ '; + const prefixWidth = prefix.length; + + return ( + + + {prefix} + + + + {text} + + + + ); +}; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 2d90012cd..ab88ec4cf 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -2296,6 +2296,107 @@ describe('useGeminiStream', () => { }); }); + it('should show a retry countdown and update pending history over time', async () => { + vi.useFakeTimers(); + try { + let resolveStream: (() => void) | undefined; + mockSendMessageStream.mockReturnValue( + (async function* () { + yield { + type: ServerGeminiEventType.Retry, + retryInfo: { + reason: 'Rate limit exceeded', + attempt: 1, + maxRetries: 3, + delayMs: 3000, + }, + }; + await new Promise((resolve) => { + resolveStream = resolve; + }); + 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, + () => {}, + () => {}, + () => {}, + false, // visionModelPreviewEnabled + () => {}, + 80, + 24, + ), + ); + + act(() => { + void result.current.submitQuery('Trigger retry'); + }); + + await act(async () => { + await Promise.resolve(); + }); + + // Error line should be rendered as ERROR type + const errorItem = result.current.pendingHistoryItems.find( + (item) => item.type === MessageType.ERROR, + ); + expect(errorItem?.text).toContain('Rate limit exceeded'); + + // Countdown line should be rendered as retry_countdown type + const countdownItem = result.current.pendingHistoryItems.find( + (item) => item.type === ('retry_countdown' as MessageType), + ); + expect(countdownItem?.text).toContain('Retrying in 3 seconds'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + + const countdownAfterOneSecond = result.current.pendingHistoryItems.find( + (item) => item.type === ('retry_countdown' as MessageType), + ); + expect(countdownAfterOneSecond?.text).toContain( + 'Retrying in 2 seconds', + ); + + resolveStream?.(); + + await act(async () => { + await Promise.resolve(); + await vi.runAllTimersAsync(); + }); + + // Both error and countdown should be cleared after retry succeeds + const remainingError = result.current.pendingHistoryItems.find( + (item) => item.type === MessageType.ERROR, + ); + const remainingCountdown = result.current.pendingHistoryItems.find( + (item) => item.type === ('retry_countdown' as MessageType), + ); + expect(remainingError).toBeUndefined(); + expect(remainingCountdown).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 e142d91f0..fa2866528 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -65,6 +65,7 @@ import path from 'node:path'; import { useSessionStats } from '../contexts/SessionContext.js'; import { useKeypress } from './useKeypress.js'; import type { LoadedSettings } from '../../config/settings.js'; +import { t } from '../../i18n/index.js'; const debugLogger = createDebugLogger('GEMINI_STREAM'); @@ -125,6 +126,13 @@ export const useGeminiStream = ( const [thought, setThought] = useState(null); const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] = useStateAndRef(null); + const [pendingRetryErrorItem, setPendingRetryErrorItem] = + useState(null); + const [pendingRetryCountdownItem, setPendingRetryCountdownItem] = + useState(null); + const retryCountdownTimerRef = useRef | null>( + null, + ); const processedMemoryToolsRef = useRef>(new Set()); const { startNewPrompt, @@ -189,6 +197,67 @@ export const useGeminiStream = ( onComplete: (result: { userSelection: 'disable' | 'keep' }) => void; } | null>(null); + const stopRetryCountdownTimer = useCallback(() => { + if (retryCountdownTimerRef.current) { + clearInterval(retryCountdownTimerRef.current); + retryCountdownTimerRef.current = null; + } + }, []); + + const clearRetryCountdown = useCallback(() => { + stopRetryCountdownTimer(); + setPendingRetryErrorItem(null); + setPendingRetryCountdownItem(null); + }, [stopRetryCountdownTimer]); + + const startRetryCountdown = useCallback( + (retryInfo: { + reason: string; + attempt: number; + maxRetries: number; + delayMs: number; + }) => { + stopRetryCountdownTimer(); + const startTime = Date.now(); + const { reason, attempt, maxRetries, delayMs } = retryInfo; + + // Error line stays static (red with ✕ prefix) + setPendingRetryErrorItem({ + type: MessageType.ERROR, + text: t('Rate limit error: {{reason}}', { reason }), + }); + + // Countdown line updates every second (dim/secondary color) + const updateCountdown = () => { + const elapsedMs = Date.now() - startTime; + const remainingMs = Math.max(0, delayMs - elapsedMs); + const remainingSec = Math.ceil(remainingMs / 1000); + + setPendingRetryCountdownItem({ + type: 'retry_countdown', + text: t( + 'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})', + { + seconds: String(remainingSec), + attempt: String(attempt), + maxRetries: String(maxRetries), + }, + ), + } as HistoryItemWithoutId); + + if (remainingMs <= 0) { + stopRetryCountdownTimer(); + } + }; + + updateCountdown(); + retryCountdownTimerRef.current = setInterval(updateCountdown, 1000); + }, + [stopRetryCountdownTimer], + ); + + useEffect(() => () => stopRetryCountdownTimer(), [stopRetryCountdownTimer]); + const onExec = useCallback(async (done: Promise) => { setIsResponding(true); await done; @@ -295,6 +364,7 @@ export const useGeminiStream = ( Date.now(), ); setPendingHistoryItem(null); + clearRetryCountdown(); onCancelSubmit(); setIsResponding(false); setShellInputFocused(false); @@ -305,6 +375,7 @@ export const useGeminiStream = ( onCancelSubmit, pendingHistoryItemRef, setShellInputFocused, + clearRetryCountdown, config, getPromptCount, ]); @@ -609,10 +680,17 @@ export const useGeminiStream = ( { type: MessageType.INFO, text: 'User cancelled the request.' }, userMessageTimestamp, ); + clearRetryCountdown(); setIsResponding(false); setThought(null); // Reset thought when user cancels }, - [addItem, pendingHistoryItemRef, setPendingHistoryItem, setThought], + [ + addItem, + pendingHistoryItemRef, + setPendingHistoryItem, + setThought, + clearRetryCountdown, + ], ); const handleErrorEvent = useCallback( @@ -631,9 +709,17 @@ export const useGeminiStream = ( }, userMessageTimestamp, ); + clearRetryCountdown(); setThought(null); // Reset thought when there's an error }, - [addItem, pendingHistoryItemRef, setPendingHistoryItem, config, setThought], + [ + addItem, + pendingHistoryItemRef, + setPendingHistoryItem, + config, + setThought, + clearRetryCountdown, + ], ); const handleCitationEvent = useCallback( @@ -693,8 +779,9 @@ export const useGeminiStream = ( userMessageTimestamp, ); } + clearRetryCountdown(); }, - [addItem], + [addItem, clearRetryCountdown], ); const handleChatCompressionEvent = useCallback( @@ -853,7 +940,16 @@ export const useGeminiStream = ( loopDetectedRef.current = true; break; case ServerGeminiEventType.Retry: - // Will add the missing logic later + // Clear any pending partial content from the failed attempt + if (pendingHistoryItemRef.current) { + setPendingHistoryItem(null); + } + // Show retry info if available (rate-limit / throttling errors) + if (event.retryInfo) { + startRetryCountdown(event.retryInfo); + } else { + clearRetryCountdown(); + } break; default: { // enforces exhaustive switch-case @@ -878,7 +974,11 @@ export const useGeminiStream = ( handleMaxSessionTurnsEvent, handleSessionTokenLimitExceededEvent, handleCitationEvent, + startRetryCountdown, + clearRetryCountdown, setThought, + pendingHistoryItemRef, + setPendingHistoryItem, ], ); @@ -1216,10 +1316,18 @@ export const useGeminiStream = ( const pendingHistoryItems = useMemo( () => - [pendingHistoryItem, pendingToolCallGroupDisplay].filter( - (i) => i !== undefined && i !== null, - ), - [pendingHistoryItem, pendingToolCallGroupDisplay], + [ + pendingHistoryItem, + pendingRetryErrorItem, + pendingRetryCountdownItem, + pendingToolCallGroupDisplay, + ].filter((i) => i !== undefined && i !== null), + [ + pendingHistoryItem, + pendingRetryErrorItem, + pendingRetryCountdownItem, + pendingToolCallGroupDisplay, + ], ); useEffect(() => { diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index b111f9ac7..ae799bfa6 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -128,6 +128,11 @@ export type HistoryItemWarning = HistoryItemBase & { text: string; }; +export type HistoryItemRetryCountdown = HistoryItemBase & { + type: 'retry_countdown'; + text: string; +}; + export type HistoryItemAbout = HistoryItemBase & { type: 'about'; systemInfo: { @@ -265,6 +270,7 @@ export type HistoryItemWithoutId = | HistoryItemInfo | HistoryItemError | HistoryItemWarning + | HistoryItemRetryCountdown | HistoryItemAbout | HistoryItemHelp | HistoryItemToolGroup diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 538d782e6..a5b0c9612 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -938,7 +938,7 @@ describe('GeminiChat', () => { const tpmError = new StreamContentError('Throttling: TPM(1/1)'); async function* failingStreamGenerator() { throw tpmError; - + yield {} as GenerateContentResponse; } const failingStream = failingStreamGenerator(); @@ -1005,6 +1005,91 @@ describe('GeminiChat', () => { } }); + it('should retry on GLM rate limit StreamContentError with backoff delay', async () => { + vi.useFakeTimers(); + + try { + const glmError = new StreamContentError( + '{"error":{"code":"1302","message":"您的账户已达到速率限制,请您控制请求频率"}}', + ); + async function* failingStreamGenerator() { + throw glmError; + + yield {} as GenerateContentResponse; + } + const failingStream = failingStreamGenerator(); + const successStream = (async function* () { + yield { + candidates: [ + { + content: { parts: [{ text: 'Success after GLM retry' }] }, + finishReason: 'STOP', + }, + ], + } as unknown as GenerateContentResponse; + })(); + + vi.mocked(mockContentGenerator.generateContentStream) + .mockResolvedValueOnce(failingStream) + .mockResolvedValueOnce(successStream); + + const stream = await chat.sendMessageStream( + 'test-model', + { message: 'test' }, + 'prompt-id-glm-retry', + ); + + const iterator = stream[Symbol.asyncIterator](); + const first = await iterator.next(); + + expect(first.done).toBe(false); + expect(first.value.type).toBe(StreamEventType.RETRY); + + // Resume generator to schedule the rate limit delay, then advance timers. + const secondPromise = iterator.next(); + await vi.advanceTimersByTimeAsync(1_500); + const second = await secondPromise; + + expect(second.done).toBe(false); + expect(second.value.type).toBe(StreamEventType.RETRY); + + // Verify retryInfo contains the GLM error reason + if ( + second.value.type === StreamEventType.RETRY && + second.value.retryInfo + ) { + expect(second.value.retryInfo.reason).toContain('速率限制'); + expect(second.value.retryInfo.attempt).toBe(1); + expect(second.value.retryInfo.maxRetries).toBe(3); + expect(second.value.retryInfo.delayMs).toBe(1500); + } + + const events: StreamEvent[] = [first.value, second.value]; + for (;;) { + const next = await iterator.next(); + if (next.done) break; + events.push(next.value); + } + + expect( + mockContentGenerator.generateContentStream, + ).toHaveBeenCalledTimes(2); + expect( + events.filter((e) => e.type === StreamEventType.RETRY), + ).toHaveLength(2); + expect( + events.some( + (e) => + e.type === StreamEventType.CHUNK && + e.value.candidates?.[0]?.content?.parts?.[0]?.text === + 'Success after GLM retry', + ), + ).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + describe('API error retry behavior', () => { beforeEach(() => { // Use a more direct mock for retry testing diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index ee16bb669..853eed0b0 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -20,9 +20,8 @@ import { createUserContent } from '@google/genai'; import { getErrorStatus, retryWithBackoff, - isTPMThrottlingError, + getRateLimitRetryInfo, } from '../utils/retry.js'; -import { StreamContentError } from './openaiContentGenerator/pipeline.js'; import { createDebugLogger } from '../utils/debugLogger.js'; import type { Config } from '../config/config.js'; import { hasCycleInSchema } from '../tools/tools.js'; @@ -48,9 +47,20 @@ export enum StreamEventType { RETRY = 'retry', } +export interface RetryInfo { + /** Human-readable error reason. */ + reason: string; + /** Current retry attempt (1-based). */ + attempt: number; + /** Max retries allowed. */ + maxRetries: number; + /** Delay in milliseconds before the retry happens. */ + delayMs: number; +} + export type StreamEvent = | { type: StreamEventType.CHUNK; value: GenerateContentResponse } - | { type: StreamEventType.RETRY }; + | { type: StreamEventType.RETRY; retryInfo?: RetryInfo }; /** * Options for retrying due to invalid content from the model. @@ -68,14 +78,22 @@ const INVALID_CONTENT_RETRY_OPTIONS: ContentRetryOptions = { }; /** - * Options for retrying on TPM (Tokens Per Minute) throttling errors. - * These errors occur when the API rate limit is exceeded and are returned - * as stream content (finish_reason="error_finish") rather than HTTP errors. + * Options for retrying on rate-limit throttling errors returned as stream content. */ -const TPM_RETRY_OPTIONS = { +const RATE_LIMIT_RETRY_OPTIONS = { maxRetries: 3, - delayMs: 60_000, // 1 minute - TPM quota typically resets within a minute window }; + +const RATE_LIMIT_BACKOFF_OPTIONS = { + initialDelayMs: 1500, + maxDelayMs: 30000, +}; + +function getRateLimitBackoffDelay(retryCount: number): number { + const delay = + RATE_LIMIT_BACKOFF_OPTIONS.initialDelayMs * 2 ** (retryCount - 1); + return Math.min(RATE_LIMIT_BACKOFF_OPTIONS.maxDelayMs, delay); +} /** * Returns true if the response is valid, false otherwise. * @@ -286,7 +304,7 @@ export class GeminiChat { return (async function* () { try { let lastError: unknown = new Error('Request failed after all retries.'); - let tpmRetryCount = 0; + let rateLimitRetryCount = 0; for ( let attempt = 0; @@ -294,7 +312,7 @@ export class GeminiChat { attempt++ ) { try { - if (attempt > 0 || tpmRetryCount > 0) { + if (attempt > 0 || rateLimitRetryCount > 0) { yield { type: StreamEventType.RETRY }; } @@ -314,25 +332,35 @@ export class GeminiChat { } catch (error) { lastError = error; - // Handle TPM throttling errors returned as stream content. + // Handle rate-limit / throttling errors returned as stream content. // These arrive as StreamContentError with finish_reason="error_finish" // from the pipeline, containing the throttling message in the content. + // Covers TPM throttling, GLM rate limits, and other provider throttling. + const rateLimitInfo = getRateLimitRetryInfo(error); if ( - (error instanceof StreamContentError || - isTPMThrottlingError(error)) && - tpmRetryCount < TPM_RETRY_OPTIONS.maxRetries + rateLimitInfo && + rateLimitRetryCount < RATE_LIMIT_RETRY_OPTIONS.maxRetries ) { - tpmRetryCount++; + rateLimitRetryCount++; + const delayMs = + rateLimitInfo.delayMs ?? + getRateLimitBackoffDelay(rateLimitRetryCount); debugLogger.warn( - `TPM throttling detected (retry ${tpmRetryCount}/${TPM_RETRY_OPTIONS.maxRetries}). ` + - `Waiting ${TPM_RETRY_OPTIONS.delayMs / 1000}s before retrying...`, + `Rate limit throttling detected (retry ${rateLimitRetryCount}/${RATE_LIMIT_RETRY_OPTIONS.maxRetries}). ` + + `Waiting ${delayMs / 1000}s before retrying...`, ); - yield { type: StreamEventType.RETRY }; - // Don't count TPM retries against the content retry limit + yield { + type: StreamEventType.RETRY, + retryInfo: { + reason: rateLimitInfo.reason, + attempt: rateLimitRetryCount, + maxRetries: RATE_LIMIT_RETRY_OPTIONS.maxRetries, + delayMs, + }, + }; + // Don't count rate-limit retries against the content retry limit attempt--; - await new Promise((res) => - setTimeout(res, TPM_RETRY_OPTIONS.delayMs), - ); + await new Promise((res) => setTimeout(res, delayMs)); continue; } diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index b600d3d99..9b50a16b5 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -26,7 +26,7 @@ import { UnauthorizedError, toFriendlyError, } from '../utils/errors.js'; -import type { GeminiChat } from './geminiChat.js'; +import type { GeminiChat, RetryInfo } from './geminiChat.js'; import { getThoughtText, parseThought, @@ -67,6 +67,7 @@ export enum GeminiEventType { export type ServerGeminiRetryEvent = { type: GeminiEventType.Retry; + retryInfo?: RetryInfo; }; export interface StructuredError { @@ -255,7 +256,10 @@ export class Turn { // Handle the new RETRY event if (streamEvent.type === 'retry') { - yield { type: GeminiEventType.Retry }; + yield { + type: GeminiEventType.Retry, + retryInfo: streamEvent.retryInfo, + }; continue; // Skip to the next event in the stream } diff --git a/packages/core/src/utils/retry.test.ts b/packages/core/src/utils/retry.test.ts index 26cb52aa5..5dfabebbb 100644 --- a/packages/core/src/utils/retry.test.ts +++ b/packages/core/src/utils/retry.test.ts @@ -9,7 +9,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { HttpError } from './retry.js'; import { getErrorStatus, + getRateLimitRetryInfo, + isGLMRateLimitError, isTPMThrottlingError, + isRateLimitThrottlingError, retryWithBackoff, } from './retry.js'; import { setSimulate429 } from './testUtils.js'; @@ -578,6 +581,119 @@ describe('isTPMThrottlingError', () => { }); }); +describe('isRateLimitThrottlingError', () => { + it('should detect TPM throttling errors (superset of isTPMThrottlingError)', () => { + expect( + isRateLimitThrottlingError('Throttling: TPM(10680324/10000000)'), + ).toBe(true); + expect( + isRateLimitThrottlingError( + new Error('Throttling: TPM(10680324/10000000)'), + ), + ).toBe(true); + }); + + it('should detect GLM rate limit error (Chinese message)', () => { + const glmError = new Error( + '{"error":{"code":"1302","message":"您的账户已达到速率限制,请您控制请求频率"}}', + ); + expect(isRateLimitThrottlingError(glmError)).toBe(true); + }); + + it('should detect GLM rate limit from nested error object', () => { + const error = { + error: { + message: '您的账户已达到速率限制,请您控制请求频率', + code: '1302', + }, + }; + expect(isRateLimitThrottlingError(error)).toBe(true); + }); + + it('should detect general Throttling: prefix errors', () => { + expect( + isRateLimitThrottlingError(new Error('Throttling: RPM exceeded')), + ).toBe(true); + expect( + isRateLimitThrottlingError('Throttling: concurrent limit reached'), + ).toBe(true); + }); + + it('should detect English rate limit errors', () => { + expect(isRateLimitThrottlingError(new Error('Rate limit exceeded'))).toBe( + true, + ); + expect( + isRateLimitThrottlingError({ + error: { message: 'API rate limit reached. Please slow down.' }, + }), + ).toBe(true); + }); + + it('should return false for non-rate-limit errors', () => { + expect(isRateLimitThrottlingError('Regular error message')).toBe(false); + expect(isRateLimitThrottlingError(new Error('Connection refused'))).toBe( + false, + ); + expect(isRateLimitThrottlingError(null)).toBe(false); + expect(isRateLimitThrottlingError(undefined)).toBe(false); + expect(isRateLimitThrottlingError(429)).toBe(false); + }); +}); + +describe('isGLMRateLimitError', () => { + it('should detect GLM rate limit error from JSON string', () => { + const glmError = + '{"error":{"code":"1302","message":"您的账户已达到速率限制,请您控制请求频率"}}'; + expect(isGLMRateLimitError(glmError)).toBe(true); + }); + + it('should detect GLM rate limit error from Error object', () => { + const glmError = new Error( + '{"error":{"code":"1302","message":"您的账户已达到速率限制,请您控制请求频率"}}', + ); + expect(isGLMRateLimitError(glmError)).toBe(true); + }); + + it('should detect GLM rate limit from nested error object', () => { + const error = { + error: { + message: '您的账户已达到速率限制,请您控制请求频率', + code: '1302', + }, + }; + expect(isGLMRateLimitError(error)).toBe(true); + }); + + it('should return false for non-GLM errors', () => { + expect(isGLMRateLimitError('Rate limit exceeded')).toBe(false); + expect(isGLMRateLimitError(new Error('Throttling: TPM(1/1)'))).toBe(false); + }); +}); + +describe('getRateLimitRetryInfo', () => { + it('should return fixed delay for TPM throttling errors', () => { + const info = getRateLimitRetryInfo('Throttling: TPM(1/1)'); + expect(info).not.toBeNull(); + expect(info?.delayMs).toBe(60000); + }); + + it('should return no fixed delay for GLM 1302 rate limit errors', () => { + const info = getRateLimitRetryInfo( + '{"error":{"code":"1302","message":"您的账户已达到速率限制,请您控制请求频率"}}', + ); + expect(info).not.toBeNull(); + expect(info?.delayMs).toBeUndefined(); + }); + + it('should extract a human-readable reason from JSON error strings', () => { + const info = getRateLimitRetryInfo( + '{"error":{"code":"1302","message":"您的账户已达到速率限制,请您控制请求频率"}}', + ); + expect(info?.reason).toBe('您的账户已达到速率限制,请您控制请求频率'); + }); +}); + describe('TPM throttling retry handling', () => { beforeEach(() => { vi.useFakeTimers(); @@ -751,4 +867,89 @@ describe('TPM throttling retry handling', () => { await expect(promise).rejects.toThrow('Throttling: TPM(10680324/10000000)'); expect(fn).toHaveBeenCalledTimes(3); }); + + it('should use exponential backoff for GLM rate limit errors when delay is unknown', async () => { + const glmError = new Error( + '{"error":{"code":"1302","message":"您的账户已达到速率限制,请您控制请求频率"}}', + ); + + const fn = vi + .fn() + .mockRejectedValueOnce(glmError) + .mockResolvedValue('success'); + + const setTimeoutSpy = vi.spyOn(global, 'setTimeout'); + + const promise = retryWithBackoff(fn, { + maxAttempts: 3, + initialDelayMs: 100, + maxDelayMs: 1000, + }); + + await vi.runAllTimersAsync(); + + await expect(promise).resolves.toBe('success'); + expect(fn).toHaveBeenCalledTimes(2); + + const delays = setTimeoutSpy.mock.calls.map((call) => call[1] as number); + expect(delays[0]).toBeGreaterThanOrEqual(100 * 0.7); + expect(delays[0]).toBeLessThanOrEqual(100 * 1.3); + }); + + it('should use exponential backoff for general English rate limit errors', async () => { + const rateLimitError = new Error('Rate limit exceeded. Please slow down.'); + + const fn = vi + .fn() + .mockRejectedValueOnce(rateLimitError) + .mockResolvedValue('success'); + + const setTimeoutSpy = vi.spyOn(global, 'setTimeout'); + + const promise = retryWithBackoff(fn, { + maxAttempts: 3, + initialDelayMs: 100, + maxDelayMs: 1000, + }); + + await vi.runAllTimersAsync(); + + await expect(promise).resolves.toBe('success'); + expect(fn).toHaveBeenCalledTimes(2); + + const delays = setTimeoutSpy.mock.calls.map((call) => call[1] as number); + expect(delays[0]).toBeGreaterThanOrEqual(100 * 0.7); + expect(delays[0]).toBeLessThanOrEqual(100 * 1.3); + }); + + it('should retry nested GLM rate limit error objects with backoff', async () => { + const nestedGlmError = { + error: { + message: '您的账户已达到速率限制,请您控制请求频率', + code: '1302', + }, + }; + + const fn = vi + .fn() + .mockRejectedValueOnce(nestedGlmError) + .mockResolvedValue('success'); + + const setTimeoutSpy = vi.spyOn(global, 'setTimeout'); + + const promise = retryWithBackoff(fn, { + maxAttempts: 3, + initialDelayMs: 100, + maxDelayMs: 1000, + }); + + await vi.runAllTimersAsync(); + + await expect(promise).resolves.toBe('success'); + expect(fn).toHaveBeenCalledTimes(2); + + const delays = setTimeoutSpy.mock.calls.map((call) => call[1] as number); + expect(delays[0]).toBeGreaterThanOrEqual(100 * 0.7); + expect(delays[0]).toBeLessThanOrEqual(100 * 1.3); + }); }); diff --git a/packages/core/src/utils/retry.ts b/packages/core/src/utils/retry.ts index 6c580548c..73ed194a5 100644 --- a/packages/core/src/utils/retry.ts +++ b/packages/core/src/utils/retry.ts @@ -25,6 +25,11 @@ export interface RetryOptions { authType?: string; } +export interface RateLimitRetryInfo { + reason: string; + delayMs?: number; +} + const DEFAULT_RETRY_OPTIONS: RetryOptions = { maxAttempts: 7, initialDelayMs: 1500, @@ -32,6 +37,12 @@ const DEFAULT_RETRY_OPTIONS: RetryOptions = { shouldRetryOnError: defaultShouldRetry, }; +// Z.AI GLM rate limit code reference: https://docs.z.ai/api-reference/api-code +const GLM_RATE_LIMIT_CODE = '1302'; + +// DashScope/Model Studio TPM rate limit reference: https://help.aliyun.com/zh/model-studio/rate-limit +const TPM_RATE_LIMIT_DELAY_MS = 60000; + /** * Default predicate function to determine if a retry should be attempted. * Retries on 429 (Too Many Requests) and 5xx server errors. @@ -120,23 +131,24 @@ export async function retryWithBackoff( throw error; } - // Check for TPM throttling error - use fixed 1 minute delay - // This check is prioritized over shouldRetryOnError because TPM errors - // may not have a standard HTTP status code (like 429) but still need retry - if (isTPMThrottlingError(error)) { - const tpmDelayMs = 60000; // 1 minute + // Check for rate-limit / throttling errors with a fixed delay. + // This check is prioritized over shouldRetryOnError because provider + // rate-limit errors may not have a standard HTTP status code (like 429) + // but still need retry (e.g., TPM throttling). + const rateLimitInfo = getRateLimitRetryInfo(error); + if (rateLimitInfo?.delayMs !== undefined) { debugLogger.warn( - `Attempt ${attempt} failed with TPM throttling error. Retrying after ${tpmDelayMs}ms (1 minute)...`, + `Attempt ${attempt} failed with rate limit error. Retrying after ${rateLimitInfo.delayMs}ms...`, error, ); - await delay(tpmDelayMs); - // Reset currentDelay for next potential non-TPM error + await delay(rateLimitInfo.delayMs); + // Reset currentDelay for next potential non-rate-limit error currentDelay = initialDelayMs; continue; } // Check if we shouldn't retry based on error type - if (!shouldRetryOnError(error as Error)) { + if (!rateLimitInfo && !shouldRetryOnError(error as Error)) { throw error; } @@ -179,12 +191,162 @@ export function isTPMThrottlingError(error: unknown): boolean { const checkMessage = (msg: string) => msg.includes('Throttling: TPM('); if (typeof error === 'string') return checkMessage(error); + if (error instanceof Error) return checkMessage(error.message); if (isStructuredError(error)) return checkMessage(error.message); if (isApiError(error)) return checkMessage(error.error.message); return false; } +/** + * Checks if an error is a GLM rate limit error (code 1302). + * + * @param error The error object. + * @returns True if the error matches GLM rate limit code 1302. + */ +export function isGLMRateLimitError(error: unknown): boolean { + const matchesCode = (code: unknown): boolean => + code !== undefined && String(code) === GLM_RATE_LIMIT_CODE; + + if (isApiError(error)) { + return matchesCode(error.error.code); + } + + if (isStructuredError(error) && !(error instanceof Error)) { + return false; + } + + const message = getErrorMessage(error); + if (!message) { + return false; + } + + const parsed = extractErrorDetailsFromString(message); + if (parsed && matchesCode(parsed.code)) { + return true; + } + + return ( + message.includes(`"code":"${GLM_RATE_LIMIT_CODE}"`) || + message.includes(`"code":${GLM_RATE_LIMIT_CODE}`) + ); +} + +/** + * Checks if an error is a rate-limit / throttling error from any provider. + * This is a superset of isTPMThrottlingError that also covers: + * - GLM rate limit: {"error":{"code":"1302","message":"您的账户已达到速率限制..."}} + * - General throttling: "Throttling: ..." + * - English rate limit messages + * + * @param error The error object. + * @returns True if the error is a rate-limit or throttling error. + */ +export function isRateLimitThrottlingError(error: unknown): boolean { + if (isTPMThrottlingError(error)) return true; + if (isGLMRateLimitError(error)) return true; + + const checkMessage = (msg: string): boolean => { + const lower = msg.toLowerCase(); + return ( + lower.includes('速率限制') || + lower.includes('throttling:') || + (lower.includes('rate') && lower.includes('limit')) + ); + }; + + const message = getErrorMessage(error); + if (message) return checkMessage(message); + + return false; +} + +/** + * Returns rate-limit retry info when an error is detected as rate-limited. + * For TPM throttling errors, a fixed 60s delay is returned. For other + * provider rate-limit errors, delayMs is left undefined so callers can apply + * their own backoff strategy. + */ +export function getRateLimitRetryInfo( + error: unknown, +): RateLimitRetryInfo | null { + if (!isRateLimitThrottlingError(error)) { + return null; + } + + return { + reason: getRateLimitReason(error), + delayMs: isTPMThrottlingError(error) ? TPM_RATE_LIMIT_DELAY_MS : undefined, + }; +} + +function getRateLimitReason(error: unknown): string { + if (isApiError(error)) { + return error.error.message; + } + + if (isStructuredError(error)) { + return error.message; + } + + if (error instanceof Error) { + return extractReasonFromString(error.message); + } + + if (typeof error === 'string') { + return extractReasonFromString(error); + } + + return String(error); +} + +function getErrorMessage(error: unknown): string | undefined { + if (typeof error === 'string') return error; + if (error instanceof Error) return error.message; + if (isStructuredError(error)) return error.message; + if (isApiError(error)) return error.error.message; + return undefined; +} + +function extractReasonFromString(message: string): string { + const parsed = extractErrorDetailsFromString(message); + if (parsed?.message) { + return parsed.message; + } + return message; +} + +function extractErrorDetailsFromString( + message: string, +): { code?: unknown; message?: string } | null { + const trimmed = message.trim().replace(/^data:\s*/i, ''); + if (!trimmed.startsWith('{')) { + return null; + } + try { + const parsed = JSON.parse(trimmed) as unknown; + if (!parsed || typeof parsed !== 'object') { + return null; + } + const errorObject = + 'error' in parsed && + typeof (parsed as { error?: unknown }).error === 'object' + ? (parsed as { error: Record }).error + : (parsed as Record); + const code = errorObject?.['code']; + const messageValue = + typeof errorObject?.['message'] === 'string' + ? errorObject['message'] + : undefined; + if (code === undefined && messageValue === undefined) { + return null; + } + return { code, message: messageValue }; + } catch { + return null; + } +} + /** * Extracts the HTTP status code from an error object. * From 3ae2f8f6718973e1a67360d99241d153bd4d8e46 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Thu, 12 Feb 2026 16:31:01 +0800 Subject: [PATCH 31/81] fix: support JSON Schema draft-2020-12 for MCP tools (fixes #1818) - Add Ajv2020 validator to support draft-2020-12 schemas used by playwright-mcp - Auto-select validator based on $schema field - Gracefully skip validation when schema compilation fails - Add comprehensive tests for JSON Schema version support Reference: gemini-cli implementation pattern --- .../core/src/utils/schemaValidator.test.ts | 85 +++++++++++++++++ packages/core/src/utils/schemaValidator.ts | 92 +++++++++++++++---- 2 files changed, 160 insertions(+), 17 deletions(-) diff --git a/packages/core/src/utils/schemaValidator.test.ts b/packages/core/src/utils/schemaValidator.test.ts index e662bcb7d..e882b983b 100644 --- a/packages/core/src/utils/schemaValidator.test.ts +++ b/packages/core/src/utils/schemaValidator.test.ts @@ -209,4 +209,89 @@ describe('SchemaValidator', () => { expect(params.is_background).toBe(true); }); }); + + describe('JSON Schema version support', () => { + it('should support JSON Schema draft-2020-12', () => { + const schema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + url: { type: 'string' }, + }, + required: ['url'], + }; + const params = { url: 'https://example.com' }; + expect(SchemaValidator.validate(schema, params)).toBeNull(); + }); + + it('should validate correctly with draft-2020-12 schema', () => { + const schema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + count: { type: 'integer' }, + }, + required: ['count'], + }; + const validParams = { count: 42 }; + const invalidParams = { count: 'not a number' }; + + expect(SchemaValidator.validate(schema, validParams)).toBeNull(); + expect(SchemaValidator.validate(schema, invalidParams)).not.toBeNull(); + }); + + it('should support JSON Schema draft-07 (default)', () => { + const schema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + }; + const params = { name: 'test' }; + expect(SchemaValidator.validate(schema, params)).toBeNull(); + }); + + it('should handle nested schemas with $schema', () => { + const schema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + config: { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + }, + }, + }, + }; + const params = { config: { enabled: true } }; + expect(SchemaValidator.validate(schema, params)).toBeNull(); + }); + + it('should support 2020-12 specific keywords like prefixItems', () => { + const schema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'array', + prefixItems: [{ type: 'string' }, { type: 'integer' }], + }; + const params = ['hello', 42]; + expect(SchemaValidator.validate(schema, params)).toBeNull(); + }); + + it('should gracefully handle unsupported schema versions', () => { + // draft-2019-09 is not supported by Ajv by default + const schema = { + $schema: 'https://json-schema.org/draft/2019-09/schema', + type: 'object', + properties: { + value: { type: 'string' }, + }, + }; + const params = { value: 'test' }; + // Should skip validation and return null (graceful degradation) + expect(SchemaValidator.validate(schema, params)).toBeNull(); + }); + }); }); diff --git a/packages/core/src/utils/schemaValidator.ts b/packages/core/src/utils/schemaValidator.ts index 2dad48332..d480b03df 100644 --- a/packages/core/src/utils/schemaValidator.ts +++ b/packages/core/src/utils/schemaValidator.ts @@ -4,33 +4,68 @@ * SPDX-License-Identifier: Apache-2.0 */ -import AjvPkg from 'ajv'; +import AjvPkg, { type AnySchema, type Ajv } from 'ajv'; +// Ajv2020 is the documented way to use draft-2020-12: https://ajv.js.org/json-schema.html#draft-2020-12 +// eslint-disable-next-line import/no-internal-modules +import Ajv2020Pkg from 'ajv/dist/2020.js'; import * as addFormats from 'ajv-formats'; +import { createDebugLogger } from './debugLogger.js'; + // Ajv's ESM/CJS interop: use 'any' for compatibility as recommended by Ajv docs // eslint-disable-next-line @typescript-eslint/no-explicit-any const AjvClass = (AjvPkg as any).default || AjvPkg; -const ajValidator = new AjvClass( +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const Ajv2020Class = (Ajv2020Pkg as any).default || Ajv2020Pkg; + +const debugLogger = createDebugLogger('SchemaValidator'); + +const ajvOptions = { // See: https://ajv.js.org/options.html#strict-mode-options - { - // strictSchema defaults to true and prevents use of JSON schemas that - // include unrecognized keywords. The JSON schema spec specifically allows - // for the use of non-standard keywords and the spec-compliant behavior - // is to ignore those keywords. Note that setting this to false also - // allows use of non-standard or custom formats (the unknown format value - // will be logged but the schema will still be considered valid). - strictSchema: false, - }, -); + // strictSchema defaults to true and prevents use of JSON schemas that + // include unrecognized keywords. The JSON schema spec specifically allows + // for the use of non-standard keywords and the spec-compliant behavior + // is to ignore those keywords. Note that setting this to false also + // allows use of non-standard or custom formats (the unknown format value + // will be logged but the schema will still be considered valid). + strictSchema: false, +}; + +// Draft-07 validator (default) +const ajvDefault: Ajv = new AjvClass(ajvOptions); + +// Draft-2020-12 validator for MCP servers using rmcp +const ajv2020: Ajv = new Ajv2020Class(ajvOptions); + // eslint-disable-next-line @typescript-eslint/no-explicit-any const addFormatsFunc = (addFormats as any).default || addFormats; -addFormatsFunc(ajValidator); +addFormatsFunc(ajvDefault); +addFormatsFunc(ajv2020); + +// Canonical draft-2020-12 meta-schema URI (used by rmcp MCP servers) +const DRAFT_2020_12_SCHEMA = 'https://json-schema.org/draft/2020-12/schema'; /** - * Simple utility to validate objects against JSON Schemas + * Returns the appropriate validator based on schema's $schema field. + */ +function getValidator(schema: AnySchema): Ajv { + if ( + typeof schema === 'object' && + schema !== null && + '$schema' in schema && + schema.$schema === DRAFT_2020_12_SCHEMA + ) { + return ajv2020; + } + return ajvDefault; +} + +/** + * Simple utility to validate objects against JSON Schemas. + * Supports both draft-07 (default) and draft-2020-12 schemas. */ export class SchemaValidator { /** - * Returns null if the data confroms to the schema described by schema (or if schema + * Returns null if the data conforms to the schema described by schema (or if schema * is null). Otherwise, returns a string describing the error. */ static validate(schema: unknown | undefined, data: unknown): string | null { @@ -40,7 +75,30 @@ export class SchemaValidator { if (typeof data !== 'object' || data === null) { return 'Value of params must be an object'; } - const validate = ajValidator.compile(schema); + + const anySchema = schema as AnySchema; + const validator = getValidator(anySchema); + + // Try to compile and validate; skip validation if schema can't be compiled. + // This handles schemas using JSON Schema versions AJV doesn't support + // (e.g., draft-2019-09, future versions). + // This matches LenientJsonSchemaValidator behavior in mcp-client.ts. + let validate; + try { + validate = validator.compile(anySchema); + } catch (error) { + // Schema compilation failed (unsupported version, invalid $ref, etc.) + // Skip validation rather than blocking tool usage. + debugLogger.warn( + `Failed to compile schema (${ + + (schema as Record)?.['$schema'] ?? '' + }): ${error instanceof Error ? error.message : String(error)}. ` + + 'Skipping parameter validation.', + ); + return null; + } + let valid = validate(data); if (!valid && validate.errors) { // Coerce string boolean values ("true"/"false") to actual booleans @@ -48,7 +106,7 @@ export class SchemaValidator { valid = validate(data); if (!valid && validate.errors) { - return ajValidator.errorsText(validate.errors, { dataVar: 'params' }); + return validator.errorsText(validate.errors, { dataVar: 'params' }); } } return null; From 85a90b10802cdbd06f12314b2598513d064ebbd7 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 12 Feb 2026 18:09:31 +0800 Subject: [PATCH 32/81] refactor(core): simplify rate limit retry with fixed 60s delay - Use fixed 60s delay matching DashScope per-minute quota window - Increase max retries from 3 to 10 to align with Claude Code behavior - Remove unused isTPMThrottlingError, isGLMRateLimitError, isRateLimitThrottlingError functions - Simplify getRateLimitRetryInfo to only extract reason, delay is now caller's responsibility Co-authored-by: Qwen-Coder --- packages/core/src/core/geminiChat.test.ts | 12 +- packages/core/src/core/geminiChat.ts | 19 +- packages/core/src/utils/retry.test.ts | 414 +--------------------- packages/core/src/utils/retry.ts | 170 +++------ 4 files changed, 68 insertions(+), 547 deletions(-) diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index a5b0c9612..5ab7a57ef 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -935,7 +935,9 @@ describe('GeminiChat', () => { vi.useFakeTimers(); try { - const tpmError = new StreamContentError('Throttling: TPM(1/1)'); + const tpmError = new StreamContentError( + '{"error":{"code":"429","message":"Throttling: TPM(1/1)"}}', + ); async function* failingStreamGenerator() { throw tpmError; @@ -1014,7 +1016,7 @@ describe('GeminiChat', () => { ); async function* failingStreamGenerator() { throw glmError; - + yield {} as GenerateContentResponse; } const failingStream = failingStreamGenerator(); @@ -1047,7 +1049,7 @@ describe('GeminiChat', () => { // Resume generator to schedule the rate limit delay, then advance timers. const secondPromise = iterator.next(); - await vi.advanceTimersByTimeAsync(1_500); + await vi.advanceTimersByTimeAsync(60_000); const second = await secondPromise; expect(second.done).toBe(false); @@ -1060,8 +1062,8 @@ describe('GeminiChat', () => { ) { expect(second.value.retryInfo.reason).toContain('速率限制'); expect(second.value.retryInfo.attempt).toBe(1); - expect(second.value.retryInfo.maxRetries).toBe(3); - expect(second.value.retryInfo.delayMs).toBe(1500); + expect(second.value.retryInfo.maxRetries).toBe(10); + expect(second.value.retryInfo.delayMs).toBe(60000); } const events: StreamEvent[] = [first.value, second.value]; diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 853eed0b0..01ae692a5 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -79,21 +79,14 @@ const INVALID_CONTENT_RETRY_OPTIONS: ContentRetryOptions = { /** * Options for retrying on rate-limit throttling errors returned as stream content. + * Fixed 60s delay matches the DashScope per-minute quota window. + * 10 retries aligns with Claude Code's retry behavior. */ const RATE_LIMIT_RETRY_OPTIONS = { - maxRetries: 3, + maxRetries: 10, + delayMs: 60000, }; -const RATE_LIMIT_BACKOFF_OPTIONS = { - initialDelayMs: 1500, - maxDelayMs: 30000, -}; - -function getRateLimitBackoffDelay(retryCount: number): number { - const delay = - RATE_LIMIT_BACKOFF_OPTIONS.initialDelayMs * 2 ** (retryCount - 1); - return Math.min(RATE_LIMIT_BACKOFF_OPTIONS.maxDelayMs, delay); -} /** * Returns true if the response is valid, false otherwise. * @@ -342,9 +335,7 @@ export class GeminiChat { rateLimitRetryCount < RATE_LIMIT_RETRY_OPTIONS.maxRetries ) { rateLimitRetryCount++; - const delayMs = - rateLimitInfo.delayMs ?? - getRateLimitBackoffDelay(rateLimitRetryCount); + const delayMs = RATE_LIMIT_RETRY_OPTIONS.delayMs; debugLogger.warn( `Rate limit throttling detected (retry ${rateLimitRetryCount}/${RATE_LIMIT_RETRY_OPTIONS.maxRetries}). ` + `Waiting ${delayMs / 1000}s before retrying...`, diff --git a/packages/core/src/utils/retry.test.ts b/packages/core/src/utils/retry.test.ts index 5dfabebbb..b290287d8 100644 --- a/packages/core/src/utils/retry.test.ts +++ b/packages/core/src/utils/retry.test.ts @@ -10,9 +10,6 @@ import type { HttpError } from './retry.js'; import { getErrorStatus, getRateLimitRetryInfo, - isGLMRateLimitError, - isTPMThrottlingError, - isRateLimitThrottlingError, retryWithBackoff, } from './retry.js'; import { setSimulate429 } from './testUtils.js'; @@ -540,416 +537,27 @@ describe('getErrorStatus', () => { }); }); -describe('isTPMThrottlingError', () => { - it('should detect TPM throttling error from string', () => { - const errorMessage = - '{"error":{"message":"Throttling: TPM(10680324/10000000)","type":"Throttling","code":"429"}}'; - expect(isTPMThrottlingError(errorMessage)).toBe(true); - }); - - it('should detect TPM throttling error from Error object', () => { - const error = new Error('Throttling: TPM(10680324/10000000)'); - expect(isTPMThrottlingError(error)).toBe(true); - }); - - it('should detect TPM throttling error from nested error object', () => { - const error = { - error: { - message: 'Throttling: TPM(10680324/10000000)', - type: 'Throttling', - code: '429', - }, - }; - expect(isTPMThrottlingError(error)).toBe(true); - }); - - it('should return false for non-TPM errors', () => { - expect(isTPMThrottlingError('Regular error message')).toBe(false); - expect(isTPMThrottlingError(new Error('Regular error'))).toBe(false); - expect( - isTPMThrottlingError({ - error: { message: 'Rate limit exceeded', code: '429' }, - }), - ).toBe(false); - }); - - it('should return false for non-string non-object values', () => { - expect(isTPMThrottlingError(null)).toBe(false); - expect(isTPMThrottlingError(undefined)).toBe(false); - expect(isTPMThrottlingError(429)).toBe(false); - expect(isTPMThrottlingError(true)).toBe(false); - }); -}); - -describe('isRateLimitThrottlingError', () => { - it('should detect TPM throttling errors (superset of isTPMThrottlingError)', () => { - expect( - isRateLimitThrottlingError('Throttling: TPM(10680324/10000000)'), - ).toBe(true); - expect( - isRateLimitThrottlingError( - new Error('Throttling: TPM(10680324/10000000)'), - ), - ).toBe(true); - }); - - it('should detect GLM rate limit error (Chinese message)', () => { - const glmError = new Error( - '{"error":{"code":"1302","message":"您的账户已达到速率限制,请您控制请求频率"}}', - ); - expect(isRateLimitThrottlingError(glmError)).toBe(true); - }); - - it('should detect GLM rate limit from nested error object', () => { - const error = { - error: { - message: '您的账户已达到速率限制,请您控制请求频率', - code: '1302', - }, - }; - expect(isRateLimitThrottlingError(error)).toBe(true); - }); - - it('should detect general Throttling: prefix errors', () => { - expect( - isRateLimitThrottlingError(new Error('Throttling: RPM exceeded')), - ).toBe(true); - expect( - isRateLimitThrottlingError('Throttling: concurrent limit reached'), - ).toBe(true); - }); - - it('should detect English rate limit errors', () => { - expect(isRateLimitThrottlingError(new Error('Rate limit exceeded'))).toBe( - true, - ); - expect( - isRateLimitThrottlingError({ - error: { message: 'API rate limit reached. Please slow down.' }, - }), - ).toBe(true); - }); - - it('should return false for non-rate-limit errors', () => { - expect(isRateLimitThrottlingError('Regular error message')).toBe(false); - expect(isRateLimitThrottlingError(new Error('Connection refused'))).toBe( - false, - ); - expect(isRateLimitThrottlingError(null)).toBe(false); - expect(isRateLimitThrottlingError(undefined)).toBe(false); - expect(isRateLimitThrottlingError(429)).toBe(false); - }); -}); - -describe('isGLMRateLimitError', () => { - it('should detect GLM rate limit error from JSON string', () => { - const glmError = - '{"error":{"code":"1302","message":"您的账户已达到速率限制,请您控制请求频率"}}'; - expect(isGLMRateLimitError(glmError)).toBe(true); - }); - - it('should detect GLM rate limit error from Error object', () => { - const glmError = new Error( - '{"error":{"code":"1302","message":"您的账户已达到速率限制,请您控制请求频率"}}', - ); - expect(isGLMRateLimitError(glmError)).toBe(true); - }); - - it('should detect GLM rate limit from nested error object', () => { - const error = { - error: { - message: '您的账户已达到速率限制,请您控制请求频率', - code: '1302', - }, - }; - expect(isGLMRateLimitError(error)).toBe(true); - }); - - it('should return false for non-GLM errors', () => { - expect(isGLMRateLimitError('Rate limit exceeded')).toBe(false); - expect(isGLMRateLimitError(new Error('Throttling: TPM(1/1)'))).toBe(false); - }); -}); - describe('getRateLimitRetryInfo', () => { - it('should return fixed delay for TPM throttling errors', () => { - const info = getRateLimitRetryInfo('Throttling: TPM(1/1)'); + it('should extract reason from TPM throttling error', () => { + const info = getRateLimitRetryInfo( + new Error( + '{"error":{"code":"429","message":"Throttling: TPM(10680324/10000000)"}}', + ), + ); expect(info).not.toBeNull(); - expect(info?.delayMs).toBe(60000); + expect(info?.reason).toBe('Throttling: TPM(10680324/10000000)'); }); - it('should return no fixed delay for GLM 1302 rate limit errors', () => { + it('should extract reason from GLM rate limit error', () => { const info = getRateLimitRetryInfo( '{"error":{"code":"1302","message":"您的账户已达到速率限制,请您控制请求频率"}}', ); expect(info).not.toBeNull(); - expect(info?.delayMs).toBeUndefined(); - }); - - it('should extract a human-readable reason from JSON error strings', () => { - const info = getRateLimitRetryInfo( - '{"error":{"code":"1302","message":"您的账户已达到速率限制,请您控制请求频率"}}', - ); expect(info?.reason).toBe('您的账户已达到速率限制,请您控制请求频率'); }); -}); -describe('TPM throttling retry handling', () => { - beforeEach(() => { - vi.useFakeTimers(); - setSimulate429(false); - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.useRealTimers(); - }); - - it('should wait 1 minute for TPM throttling errors before retrying', async () => { - const tpmError: HttpError = new Error('Throttling: TPM(10680324/10000000)'); - tpmError.status = 429; - - const fn = vi - .fn() - .mockRejectedValueOnce(tpmError) - .mockResolvedValue('success'); - - const promise = retryWithBackoff(fn, { - maxAttempts: 3, - initialDelayMs: 100, - maxDelayMs: 1000, - }); - - // Fast-forward 1 minute for TPM delay - await vi.advanceTimersByTimeAsync(60000); - - await expect(promise).resolves.toBe('success'); - - // Should be called twice (1 failure + 1 success) - expect(fn).toHaveBeenCalledTimes(2); - }); - - it('should reset exponential backoff delay after TPM throttling error', async () => { - const tpmError: HttpError = new Error('Throttling: TPM(10680324/10000000)'); - tpmError.status = 429; - const normalError: HttpError = new Error('Server error'); - normalError.status = 500; - - const fn = vi - .fn() - .mockRejectedValueOnce(tpmError) // First: TPM error (1 minute delay) - .mockRejectedValueOnce(normalError) // Second: normal error (should use initialDelay) - .mockResolvedValue('success'); - - const setTimeoutSpy = vi.spyOn(global, 'setTimeout'); - - const promise = retryWithBackoff(fn, { - maxAttempts: 5, - initialDelayMs: 100, - maxDelayMs: 1000, - }); - - // Fast-forward 1 minute for TPM delay - await vi.advanceTimersByTimeAsync(60000); - - // Now handle the second error with exponential backoff - await vi.runAllTimersAsync(); - - await expect(promise).resolves.toBe('success'); - - // Should be called 3 times - expect(fn).toHaveBeenCalledTimes(3); - - // Check that the second delay (after TPM) uses initialDelayMs, not a doubled value - const delays = setTimeoutSpy.mock.calls.map((call) => call[1] as number); - // First delay should be 60000ms (1 minute for TPM) - // Second delay should be around initialDelayMs (100ms) with jitter - expect(delays[0]).toBe(60000); - expect(delays[1]).toBeGreaterThanOrEqual(100 * 0.7); - expect(delays[1]).toBeLessThanOrEqual(100 * 1.3); - }); - - it('should handle TPM throttling error without status property', async () => { - // 真实场景:错误只有 message,没有 status=429 - const tpmError = new Error('Throttling: TPM(10680324/10000000)'); - // 注意:故意不设 tpmError.status = 429 - - const fn = vi - .fn() - .mockRejectedValueOnce(tpmError) - .mockResolvedValue('success'); - - const promise = retryWithBackoff(fn, { - maxAttempts: 3, - initialDelayMs: 100, - maxDelayMs: 1000, - }); - - // Fast-forward 1 minute for TPM delay - await vi.advanceTimersByTimeAsync(60000); - - // 这个测试验证:即使错误没有 status=429,TPM 检查也应该正常工作 - await expect(promise).resolves.toBe('success'); - expect(fn).toHaveBeenCalledTimes(2); - }); - - it('should handle nested TPM error object without top-level status', async () => { - // 模拟 API 直接返回的嵌套错误格式 - const nestedTpmError = { - error: { - message: 'Throttling: TPM(10680324/10000000)', - type: 'Throttling', - code: '429', - }, - }; - - const fn = vi - .fn() - .mockRejectedValueOnce(nestedTpmError) - .mockResolvedValue('success'); - - const promise = retryWithBackoff(fn, { - maxAttempts: 3, - initialDelayMs: 100, - maxDelayMs: 1000, - }); - - // Fast-forward 1 minute for TPM delay - await vi.advanceTimersByTimeAsync(60000); - - await expect(promise).resolves.toBe('success'); - expect(fn).toHaveBeenCalledTimes(2); - }); - - it('should retry multiple times for consecutive TPM throttling errors', async () => { - const tpmError: HttpError = new Error('Throttling: TPM(10680324/10000000)'); - tpmError.status = 429; - - const fn = vi - .fn() - .mockRejectedValueOnce(tpmError) // First TPM error - .mockRejectedValueOnce(tpmError) // Second TPM error - .mockResolvedValue('success'); - - const promise = retryWithBackoff(fn, { - maxAttempts: 5, - initialDelayMs: 100, - maxDelayMs: 1000, - }); - - // Fast-forward 2 minutes for two TPM delays - await vi.advanceTimersByTimeAsync(120000); - - await expect(promise).resolves.toBe('success'); - expect(fn).toHaveBeenCalledTimes(3); - }); - - it('should eventually throw after maxAttempts TPM throttling errors', async () => { - const tpmError: HttpError = new Error('Throttling: TPM(10680324/10000000)'); - tpmError.status = 429; - - const fn = vi.fn().mockRejectedValue(tpmError); - - const promise = retryWithBackoff(fn, { - maxAttempts: 3, - initialDelayMs: 100, - maxDelayMs: 1000, - }); - - // Attach a rejection handler BEFORE advancing timers to prevent Node.js - // from reporting an unhandled rejection. The rejection occurs during - // advanceTimersByTimeAsync when maxAttempts is exhausted. - promise.catch(() => {}); - - // Fast-forward time for all TPM delays (3 attempts = 2 retries) - await vi.advanceTimersByTimeAsync(120000); - - await expect(promise).rejects.toThrow('Throttling: TPM(10680324/10000000)'); - expect(fn).toHaveBeenCalledTimes(3); - }); - - it('should use exponential backoff for GLM rate limit errors when delay is unknown', async () => { - const glmError = new Error( - '{"error":{"code":"1302","message":"您的账户已达到速率限制,请您控制请求频率"}}', - ); - - const fn = vi - .fn() - .mockRejectedValueOnce(glmError) - .mockResolvedValue('success'); - - const setTimeoutSpy = vi.spyOn(global, 'setTimeout'); - - const promise = retryWithBackoff(fn, { - maxAttempts: 3, - initialDelayMs: 100, - maxDelayMs: 1000, - }); - - await vi.runAllTimersAsync(); - - await expect(promise).resolves.toBe('success'); - expect(fn).toHaveBeenCalledTimes(2); - - const delays = setTimeoutSpy.mock.calls.map((call) => call[1] as number); - expect(delays[0]).toBeGreaterThanOrEqual(100 * 0.7); - expect(delays[0]).toBeLessThanOrEqual(100 * 1.3); - }); - - it('should use exponential backoff for general English rate limit errors', async () => { - const rateLimitError = new Error('Rate limit exceeded. Please slow down.'); - - const fn = vi - .fn() - .mockRejectedValueOnce(rateLimitError) - .mockResolvedValue('success'); - - const setTimeoutSpy = vi.spyOn(global, 'setTimeout'); - - const promise = retryWithBackoff(fn, { - maxAttempts: 3, - initialDelayMs: 100, - maxDelayMs: 1000, - }); - - await vi.runAllTimersAsync(); - - await expect(promise).resolves.toBe('success'); - expect(fn).toHaveBeenCalledTimes(2); - - const delays = setTimeoutSpy.mock.calls.map((call) => call[1] as number); - expect(delays[0]).toBeGreaterThanOrEqual(100 * 0.7); - expect(delays[0]).toBeLessThanOrEqual(100 * 1.3); - }); - - it('should retry nested GLM rate limit error objects with backoff', async () => { - const nestedGlmError = { - error: { - message: '您的账户已达到速率限制,请您控制请求频率', - code: '1302', - }, - }; - - const fn = vi - .fn() - .mockRejectedValueOnce(nestedGlmError) - .mockResolvedValue('success'); - - const setTimeoutSpy = vi.spyOn(global, 'setTimeout'); - - const promise = retryWithBackoff(fn, { - maxAttempts: 3, - initialDelayMs: 100, - maxDelayMs: 1000, - }); - - await vi.runAllTimersAsync(); - - await expect(promise).resolves.toBe('success'); - expect(fn).toHaveBeenCalledTimes(2); - - const delays = setTimeoutSpy.mock.calls.map((call) => call[1] as number); - expect(delays[0]).toBeGreaterThanOrEqual(100 * 0.7); - expect(delays[0]).toBeLessThanOrEqual(100 * 1.3); + it('should return null for non-rate-limit errors', () => { + expect(getRateLimitRetryInfo(new Error('Connection refused'))).toBeNull(); + expect(getRateLimitRetryInfo('some error')).toBeNull(); }); }); diff --git a/packages/core/src/utils/retry.ts b/packages/core/src/utils/retry.ts index 73ed194a5..ccb9f7983 100644 --- a/packages/core/src/utils/retry.ts +++ b/packages/core/src/utils/retry.ts @@ -6,9 +6,8 @@ import type { GenerateContentResponse } from '@google/genai'; import { AuthType } from '../core/contentGenerator.js'; -import { isQwenQuotaExceededError } from './quotaErrorDetection.js'; +import { isQwenQuotaExceededError, isApiError } from './quotaErrorDetection.js'; import { createDebugLogger } from './debugLogger.js'; -import { isStructuredError, isApiError } from './quotaErrorDetection.js'; const debugLogger = createDebugLogger('RETRY'); @@ -27,7 +26,6 @@ export interface RetryOptions { export interface RateLimitRetryInfo { reason: string; - delayMs?: number; } const DEFAULT_RETRY_OPTIONS: RetryOptions = { @@ -37,11 +35,10 @@ const DEFAULT_RETRY_OPTIONS: RetryOptions = { shouldRetryOnError: defaultShouldRetry, }; -// Z.AI GLM rate limit code reference: https://docs.z.ai/api-reference/api-code -const GLM_RATE_LIMIT_CODE = '1302'; - -// DashScope/Model Studio TPM rate limit reference: https://help.aliyun.com/zh/model-studio/rate-limit -const TPM_RATE_LIMIT_DELAY_MS = 60000; +// Known rate-limit error codes across providers. +// 429 - Standard HTTP "Too Many Requests" (DashScope TPM, OpenAI, etc.) +// 1302 - Z.AI GLM rate limit (https://docs.z.ai/api-reference/api-code) +const RATE_LIMIT_ERROR_CODES = new Set(['429', '1302']); /** * Default predicate function to determine if a retry should be attempted. @@ -126,29 +123,8 @@ export async function retryWithBackoff( ); } - // Check if we've exhausted retries - if (attempt >= maxAttempts) { - throw error; - } - - // Check for rate-limit / throttling errors with a fixed delay. - // This check is prioritized over shouldRetryOnError because provider - // rate-limit errors may not have a standard HTTP status code (like 429) - // but still need retry (e.g., TPM throttling). - const rateLimitInfo = getRateLimitRetryInfo(error); - if (rateLimitInfo?.delayMs !== undefined) { - debugLogger.warn( - `Attempt ${attempt} failed with rate limit error. Retrying after ${rateLimitInfo.delayMs}ms...`, - error, - ); - await delay(rateLimitInfo.delayMs); - // Reset currentDelay for next potential non-rate-limit error - currentDelay = initialDelayMs; - continue; - } - - // Check if we shouldn't retry based on error type - if (!rateLimitInfo && !shouldRetryOnError(error as Error)) { + // Check if we've exhausted retries or shouldn't retry + if (attempt >= maxAttempts || !shouldRetryOnError(error as Error)) { throw error; } @@ -180,104 +156,53 @@ export async function retryWithBackoff( throw new Error('Retry attempts exhausted'); } -/** - * Checks if an error is a TPM (Tokens Per Minute) throttling error. - * These errors occur when the API rate limit is exceeded for TPM. - * Example: {"error":{"message":"Throttling: TPM(10680324/10000000)","type":"Throttling","code":"429"}} - * @param error The error object. - * @returns True if the error is a TPM throttling error. - */ -export function isTPMThrottlingError(error: unknown): boolean { - const checkMessage = (msg: string) => msg.includes('Throttling: TPM('); - - if (typeof error === 'string') return checkMessage(error); - if (error instanceof Error) return checkMessage(error.message); - if (isStructuredError(error)) return checkMessage(error.message); - if (isApiError(error)) return checkMessage(error.error.message); - - return false; -} - -/** - * Checks if an error is a GLM rate limit error (code 1302). - * - * @param error The error object. - * @returns True if the error matches GLM rate limit code 1302. - */ -export function isGLMRateLimitError(error: unknown): boolean { - const matchesCode = (code: unknown): boolean => - code !== undefined && String(code) === GLM_RATE_LIMIT_CODE; - - if (isApiError(error)) { - return matchesCode(error.error.code); - } - - if (isStructuredError(error) && !(error instanceof Error)) { - return false; - } - - const message = getErrorMessage(error); - if (!message) { - return false; - } - - const parsed = extractErrorDetailsFromString(message); - if (parsed && matchesCode(parsed.code)) { - return true; - } - - return ( - message.includes(`"code":"${GLM_RATE_LIMIT_CODE}"`) || - message.includes(`"code":${GLM_RATE_LIMIT_CODE}`) - ); -} - -/** - * Checks if an error is a rate-limit / throttling error from any provider. - * This is a superset of isTPMThrottlingError that also covers: - * - GLM rate limit: {"error":{"code":"1302","message":"您的账户已达到速率限制..."}} - * - General throttling: "Throttling: ..." - * - English rate limit messages - * - * @param error The error object. - * @returns True if the error is a rate-limit or throttling error. - */ -export function isRateLimitThrottlingError(error: unknown): boolean { - if (isTPMThrottlingError(error)) return true; - if (isGLMRateLimitError(error)) return true; - - const checkMessage = (msg: string): boolean => { - const lower = msg.toLowerCase(); - return ( - lower.includes('速率限制') || - lower.includes('throttling:') || - (lower.includes('rate') && lower.includes('limit')) - ); - }; - - const message = getErrorMessage(error); - if (message) return checkMessage(message); - - return false; -} - /** * Returns rate-limit retry info when an error is detected as rate-limited. - * For TPM throttling errors, a fixed 60s delay is returned. For other - * provider rate-limit errors, delayMs is left undefined so callers can apply - * their own backoff strategy. + * Returns a human-readable reason for the UI. Retry delay is determined by + * the caller (e.g., fixed 60s in geminiChat.ts). */ export function getRateLimitRetryInfo( error: unknown, ): RateLimitRetryInfo | null { - if (!isRateLimitThrottlingError(error)) { + if (!getRateLimitCode(error)) { return null; } + return { reason: getRateLimitReason(error) }; +} - return { - reason: getRateLimitReason(error), - delayMs: isTPMThrottlingError(error) ? TPM_RATE_LIMIT_DELAY_MS : undefined, - }; +// --------------------------------------------------------------------------- +// Private helpers for rate-limit detection +// --------------------------------------------------------------------------- + +/** Extracts the rate-limit code if present in the error. */ +function getRateLimitCode(error: unknown): string | undefined { + // Direct code on nested error object: { error: { code: "429" } } + if (isApiError(error)) { + const code = String(error.error.code); + if (RATE_LIMIT_ERROR_CODES.has(code)) { + return code; + } + } + + // Try to extract code from JSON embedded in error message string + const message = getErrorMessage(error); + if (message) { + const details = extractErrorDetailsFromString(message); + if (details?.code !== undefined) { + const code = String(details.code); + if (RATE_LIMIT_ERROR_CODES.has(code)) { + return code; + } + } + } + + // Fallback to HTTP status 429 + const status = getErrorStatus(error); + if (status === 429) { + return '429'; + } + + return undefined; } function getRateLimitReason(error: unknown): string { @@ -285,10 +210,6 @@ function getRateLimitReason(error: unknown): string { return error.error.message; } - if (isStructuredError(error)) { - return error.message; - } - if (error instanceof Error) { return extractReasonFromString(error.message); } @@ -303,7 +224,6 @@ function getRateLimitReason(error: unknown): string { function getErrorMessage(error: unknown): string | undefined { if (typeof error === 'string') return error; if (error instanceof Error) return error.message; - if (isStructuredError(error)) return error.message; if (isApiError(error)) return error.error.message; return undefined; } From 0d2e394ef1dac58ca73a614e567d0d235ad3424a Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 12 Feb 2026 18:09:58 +0800 Subject: [PATCH 33/81] fix(openai): tool call cleanup order when fixing streaming errors --- packages/core/src/core/openaiContentGenerator/pipeline.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts index e941e375f..1865adb48 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts @@ -180,15 +180,15 @@ export class ContentGenerationPipeline { // Stage 2e: Stream completed successfully context.duration = Date.now() - context.startTime; } catch (error) { + // Clear streaming tool calls on error to prevent data pollution + this.converter.resetStreamingToolCalls(); + // Re-throw StreamContentError directly so it can be handled by // the caller's retry logic (e.g., TPM throttling retry in sendMessageStream) if (error instanceof StreamContentError) { throw error; } - // Clear streaming tool calls on error to prevent data pollution - this.converter.resetStreamingToolCalls(); - // Use shared error handling logic await this.handleError(error, context, request); } From e7290c5d9a274734b11f003f605caebf5131f070 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Thu, 12 Feb 2026 20:20:51 +0800 Subject: [PATCH 34/81] refactor(cli): unify Escape key handling in AppContainer Consolidate Escape key behavior to improve UX and prevent conflicts: - Move Escape handling from useGeminiStream to AppContainer - Input with content: double-press to clear, then single-press to cancel - Empty input: single-press immediately cancels ongoing request - Preserve embeddedShellFocused check to allow shell's own escape handling - Update tests to use cancelOngoingRequest directly instead of simulating keypress Fixes inconsistent escape behavior between input clearing and request cancellation. Co-authored-by: Qwen-Coder --- packages/cli/src/ui/AppContainer.tsx | 51 ++++++++++++++- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 63 ++++++++----------- packages/cli/src/ui/hooks/useGeminiStream.ts | 11 ---- 3 files changed, 74 insertions(+), 51 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 7ac34def2..4bd17e2e8 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -688,7 +688,6 @@ export const AppContainer = (props: AppContainerProps) => { terminalWidth, terminalHeight, handleVisionSwitchRequired, // onVisionSwitchRequired - embeddedShellFocused, ); // Track whether suggestions are visible for Tab key handling @@ -896,6 +895,8 @@ export const AppContainer = (props: AppContainerProps) => { const ctrlCTimerRef = useRef(null); const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false); const ctrlDTimerRef = useRef(null); + const [escapePressedOnce, setEscapePressedOnce] = useState(false); + const escapeTimerRef = useRef(null); const [constrainHeight, setConstrainHeight] = useState(true); const [ideContextState, setIdeContextState] = useState< IdeContext | undefined @@ -1172,6 +1173,47 @@ export const AppContainer = (props: AppContainerProps) => { } handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef); return; + } else if (keyMatchers[Command.ESCAPE](key)) { + // Escape key handling + // Skip if shell is focused (to allow shell's own escape handling) + if (embeddedShellFocused) { + return; + } + + // If input has content, use double-press to clear + if (buffer.text.length > 0) { + if (escapePressedOnce) { + // Second press: clear input, keep the flag to allow immediate cancel + buffer.setText(''); + return; + } + // First press: set flag and show prompt + setEscapePressedOnce(true); + escapeTimerRef.current = setTimeout(() => { + setEscapePressedOnce(false); + escapeTimerRef.current = null; + }, CTRL_EXIT_PROMPT_DURATION_MS); + return; + } + + // Input is empty, cancel request immediately (no double-press needed) + if (streamingState === StreamingState.Responding) { + if (escapeTimerRef.current) { + clearTimeout(escapeTimerRef.current); + escapeTimerRef.current = null; + } + cancelOngoingRequest?.(); + setEscapePressedOnce(false); + return; + } + + // No action available, reset the flag + if (escapeTimerRef.current) { + clearTimeout(escapeTimerRef.current); + escapeTimerRef.current = null; + } + setEscapePressedOnce(false); + return; } let enteringConstrainHeightMode = false; @@ -1216,10 +1258,15 @@ export const AppContainer = (props: AppContainerProps) => { ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef, - buffer.text.length, ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef, + escapePressedOnce, + setEscapePressedOnce, + escapeTimerRef, + streamingState, + cancelOngoingRequest, + buffer, handleSlashCommand, activePtyId, embeddedShellFocused, diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 2d90012cd..32beec1a1 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -9,7 +9,6 @@ import type { Mock, MockInstance } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, act, waitFor } from '@testing-library/react'; import { useGeminiStream } from './useGeminiStream.js'; -import { useKeypress } from './useKeypress.js'; import * as atCommandProcessor from './atCommandProcessor.js'; import type { TrackedToolCall, @@ -107,10 +106,6 @@ vi.mock('./useVisionAutoSwitch.js', () => ({ })), })); -vi.mock('./useKeypress.js', () => ({ - useKeypress: vi.fn(), -})); - vi.mock('./shellCommandProcessor.js', () => ({ useShellCommandProcessor: vi.fn().mockReturnValue({ handleShellCommand: vi.fn(), @@ -850,28 +845,8 @@ describe('useGeminiStream', () => { expect(result.current.streamingState).toBe(StreamingState.Responding); }); - describe('User Cancellation', () => { - let keypressCallback: (key: any) => void; - const mockUseKeypress = useKeypress as Mock; - - beforeEach(() => { - // Capture the callback passed to useKeypress - mockUseKeypress.mockImplementation((callback, options) => { - if (options.isActive) { - keypressCallback = callback; - } else { - keypressCallback = () => {}; - } - }); - }); - - const simulateEscapeKeyPress = () => { - act(() => { - keypressCallback({ name: 'escape' }); - }); - }; - - it('should cancel an in-progress stream when escape is pressed', async () => { + describe('Cancellation', () => { + it('should cancel an in-progress stream when cancelOngoingRequest is called', async () => { const mockStream = (async function* () { yield { type: 'content', value: 'Part 1' }; // Keep the stream open @@ -891,8 +866,10 @@ describe('useGeminiStream', () => { expect(result.current.streamingState).toBe(StreamingState.Responding); }); - // Simulate escape key press - simulateEscapeKeyPress(); + // Call cancelOngoingRequest directly + act(() => { + result.current.cancelOngoingRequest(); + }); // Verify cancellation message is added await waitFor(() => { @@ -909,7 +886,7 @@ describe('useGeminiStream', () => { expect(result.current.streamingState).toBe(StreamingState.Idle); }); - it('should call onCancelSubmit handler when escape is pressed', async () => { + it('should call onCancelSubmit handler when cancelOngoingRequest is called', async () => { const cancelSubmitSpy = vi.fn(); const mockStream = (async function* () { yield { type: 'content', value: 'Part 1' }; @@ -947,12 +924,14 @@ describe('useGeminiStream', () => { result.current.submitQuery('test query'); }); - simulateEscapeKeyPress(); + act(() => { + result.current.cancelOngoingRequest(); + }); expect(cancelSubmitSpy).toHaveBeenCalled(); }); - it('should call setShellInputFocused(false) when escape is pressed', async () => { + it('should call setShellInputFocused(false) when cancelOngoingRequest is called', async () => { const setShellInputFocusedSpy = vi.fn(); const mockStream = (async function* () { yield { type: 'content', value: 'Part 1' }; @@ -989,18 +968,22 @@ describe('useGeminiStream', () => { result.current.submitQuery('test query'); }); - simulateEscapeKeyPress(); + act(() => { + result.current.cancelOngoingRequest(); + }); expect(setShellInputFocusedSpy).toHaveBeenCalledWith(false); }); - it('should not do anything if escape is pressed when not responding', () => { + it('should not do anything if cancelOngoingRequest is called when not responding', () => { const { result } = renderTestHook(); expect(result.current.streamingState).toBe(StreamingState.Idle); - // Simulate escape key press - simulateEscapeKeyPress(); + // Call cancelOngoingRequest + act(() => { + result.current.cancelOngoingRequest(); + }); // No change should happen, no cancellation message expect(mockAddItem).not.toHaveBeenCalledWith( @@ -1035,7 +1018,9 @@ describe('useGeminiStream', () => { }); // Cancel the request - simulateEscapeKeyPress(); + act(() => { + result.current.cancelOngoingRequest(); + }); // Allow the stream to continue act(() => { @@ -1083,7 +1068,9 @@ describe('useGeminiStream', () => { expect(result.current.streamingState).toBe(StreamingState.Responding); // Try to cancel - simulateEscapeKeyPress(); + act(() => { + result.current.cancelOngoingRequest(); + }); // Nothing should happen because the state is not `Responding` expect(abortSpy).not.toHaveBeenCalled(); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index e142d91f0..3b9965211 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -63,7 +63,6 @@ import { import { promises as fs } from 'node:fs'; import path from 'node:path'; import { useSessionStats } from '../contexts/SessionContext.js'; -import { useKeypress } from './useKeypress.js'; import type { LoadedSettings } from '../../config/settings.js'; const debugLogger = createDebugLogger('GEMINI_STREAM'); @@ -115,7 +114,6 @@ export const useGeminiStream = ( persistSessionModel?: string; showGuidance?: boolean; }>, - isShellFocused?: boolean, ) => { const [initError, setInitError] = useState(null); const abortControllerRef = useRef(null); @@ -309,15 +307,6 @@ export const useGeminiStream = ( getPromptCount, ]); - useKeypress( - (key) => { - if (key.name === 'escape' && !isShellFocused) { - cancelOngoingRequest(); - } - }, - { isActive: streamingState === StreamingState.Responding }, - ); - const prepareQueryForGemini = useCallback( async ( query: PartListUnion, From 86a358d26d5ec5f2648d6bc3e8dbea0c368c509a Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Thu, 12 Feb 2026 20:22:00 +0800 Subject: [PATCH 35/81] fix: use semantic theme colors instead of hardcoded values in auth UI - Replace hardcoded Colors.* with theme.* in AuthDialog and ApiKeyInput - Fix selectedIndex reset when going back from API-KEY sub-view to main view Co-authored-by: Qwen-Coder --- packages/cli/src/ui/auth/AuthDialog.tsx | 23 ++++++++++--------- .../cli/src/ui/components/ApiKeyInput.tsx | 8 +++---- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index 2d4794d72..17d464eed 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -10,7 +10,6 @@ import { AuthType } from '@qwen-code/qwen-code-core'; import { Box, Text } from 'ink'; import Link from 'ink-link'; import { theme } from '../semantic-colors.js'; -import { Colors } from '../colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; import { ApiKeyInput } from '../components/ApiKeyInput.js'; @@ -160,6 +159,8 @@ export function AuthDialog(): React.JSX.Element { if (viewLevel === 'api-key-sub') { setViewLevel('main'); + // Reset selectedIndex to ensure UI syncs with initialAuthIndex + setSelectedIndex(null); } else if (viewLevel === 'api-key-input' || viewLevel === 'custom-info') { setViewLevel('api-key-sub'); } @@ -215,7 +216,7 @@ export function AuthDialog(): React.JSX.Element { /> - + {currentSelectedAuthType === AuthType.QWEN_OAUTH ? t('Login with QwenChat account to use daily free quota.') : t('Use coding plan credentials or your own api-keys/providers.')} @@ -244,7 +245,7 @@ export function AuthDialog(): React.JSX.Element { /> - + {apiKeySubItems[apiKeySubModeIndex]?.value === 'coding-plan' ? t("Paste your api key of Bailian Coding Plan and you're all set!") : t( @@ -282,12 +283,12 @@ export function AuthDialog(): React.JSX.Element { {t('Please configure your models in settings.json:')} - + 1. {t('Set API key via environment variable (e.g., OPENAI_API_KEY)')} - + 2.{' '} {t( "Add model configuration to modelProviders['openai'] (or other auth types)", @@ -295,7 +296,7 @@ export function AuthDialog(): React.JSX.Element { - + 3.{' '} {t( 'Each provider needs: id, envKey (required), plus optional baseUrl, generationConfig', @@ -303,7 +304,7 @@ export function AuthDialog(): React.JSX.Element { - + 4.{' '} {t( 'Use /model command to select your preferred model from the configured list', @@ -324,7 +325,7 @@ export function AuthDialog(): React.JSX.Element { - + {MODEL_PROVIDERS_DOCUMENTATION_URL} @@ -369,14 +370,14 @@ export function AuthDialog(): React.JSX.Element { {(authError || errorMessage) && ( - {authError || errorMessage} + {authError || errorMessage} )} {viewLevel === 'main' && ( <> - + {t('(Use Enter to Set Auth)')} @@ -395,7 +396,7 @@ export function AuthDialog(): React.JSX.Element { - + { 'https://qwenlm.github.io/qwen-code-docs/en/users/support/tos-privacy/' } diff --git a/packages/cli/src/ui/components/ApiKeyInput.tsx b/packages/cli/src/ui/components/ApiKeyInput.tsx index f741e3fab..e4082be3a 100644 --- a/packages/cli/src/ui/components/ApiKeyInput.tsx +++ b/packages/cli/src/ui/components/ApiKeyInput.tsx @@ -8,7 +8,7 @@ import type React from 'react'; import { useState } from 'react'; import { Box, Text } from 'ink'; import { TextInput } from './shared/TextInput.js'; -import { Colors } from '../colors.js'; +import { theme } from '../semantic-colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { t } from '../../i18n/index.js'; import Link from 'ink-link'; @@ -52,7 +52,7 @@ export function ApiKeyInput({ {error && ( - {error} + {error} )} @@ -60,13 +60,13 @@ export function ApiKeyInput({ - + {CODING_PLAN_API_KEY_URL} - + {t('(Press Enter to submit, Escape to cancel)')} From 48a403407dfee413642bfdc33e2990f0461cedc3 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Thu, 12 Feb 2026 23:20:44 +0800 Subject: [PATCH 36/81] Fix: Prevent abort listener accumulation in subagent while loop Move AbortController creation inside while(true) loop to create a new instance per round. This prevents listeners from accumulating across multiple rounds which was causing maxListener warnings. Key changes: - Create roundAbortController at the start of each loop iteration - Track current round's controller with currentRoundAbortController - External abort signal propagates to current round's controller in real-time - Cleanup external listener and clear reference in finally block Co-authored-by: Qwen-Coder --- packages/core/src/subagents/subagent.ts | 37 +++++++++++++++++-------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/packages/core/src/subagents/subagent.ts b/packages/core/src/subagents/subagent.ts index 4f550a36b..c9328e5ad 100644 --- a/packages/core/src/subagents/subagent.ts +++ b/packages/core/src/subagents/subagent.ts @@ -271,16 +271,15 @@ export class SubAgentScope { return; } - const abortController = new AbortController(); - const onAbort = () => abortController.abort(); + // Track the current round's AbortController for external signal propagation + let currentRoundAbortController: AbortController | null = null; + const onExternalAbort = () => { + currentRoundAbortController?.abort(); + }; if (externalSignal) { - if (externalSignal.aborted) { - abortController.abort(); - this.terminateMode = SubagentTerminateMode.CANCELLED; - return; - } - externalSignal.addEventListener('abort', onAbort, { once: true }); + externalSignal.addEventListener('abort', onExternalAbort); } + const toolRegistry = this.runtimeContext.getToolRegistry(); // Prepare the list of tools available to the subagent. @@ -346,6 +345,15 @@ export class SubAgentScope { const startEvent = new SubagentExecutionEvent(this.name, 'started'); logSubagentExecution(this.runtimeContext, startEvent); while (true) { + // Create a new AbortController for each round to avoid listener accumulation + const roundAbortController = new AbortController(); + currentRoundAbortController = roundAbortController; + + // If external signal already aborted, cancel immediately + if (externalSignal?.aborted) { + roundAbortController.abort(); + } + // Check termination conditions. if ( this.runConfig.max_turns && @@ -364,10 +372,11 @@ export class SubAgentScope { } const promptId = `${this.runtimeContext.getSessionId()}#${this.subagentId}#${turnCounter++}`; + const messageParams = { message: currentMessages[0]?.parts || [], config: { - abortSignal: abortController.signal, + abortSignal: roundAbortController.signal, tools: [{ functionDeclarations: toolsList }], }, }; @@ -393,7 +402,7 @@ export class SubAgentScope { undefined; let currentResponseId: string | undefined = undefined; for await (const streamEvent of responseStream) { - if (abortController.signal.aborted) { + if (roundAbortController.signal.aborted) { this.terminateMode = SubagentTerminateMode.CANCELLED; return; } @@ -487,7 +496,7 @@ export class SubAgentScope { if (functionCalls.length > 0) { currentMessages = await this.processFunctionCalls( functionCalls, - abortController, + roundAbortController, promptId, turnCounter, toolsList, @@ -530,7 +539,11 @@ export class SubAgentScope { throw error; } finally { - if (externalSignal) externalSignal.removeEventListener('abort', onAbort); + if (externalSignal) { + externalSignal.removeEventListener('abort', onExternalAbort); + } + // Clear the reference to allow GC + currentRoundAbortController = null; this.executionStats.totalDurationMs = Date.now() - startTime; const summary = this.stats.getSummary(Date.now()); this.eventEmitter?.emit(SubAgentEventType.FINISH, { From 6eb6812f5e39f362a52ef2529cab456994bac40b Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 13 Feb 2026 15:32:35 +0800 Subject: [PATCH 37/81] feat(core): add rate limit error detection utility - Extract rate-limit detection into dedicated rateLimit.ts module - Support detection from ApiError, StructuredError, HttpError, and JSON strings - Handle common rate-limit codes: 429, 503, 1302 (GLM) - Simplify retry.ts by removing duplicated detection logic --- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 63 +++++---- packages/cli/src/ui/hooks/useGeminiStream.ts | 22 ++-- packages/core/src/core/geminiChat.test.ts | 3 +- packages/core/src/core/geminiChat.ts | 28 ++-- packages/core/src/core/turn.ts | 3 +- packages/core/src/utils/errorParsing.test.ts | 8 ++ packages/core/src/utils/errorParsing.ts | 5 +- packages/core/src/utils/rateLimit.test.ts | 80 ++++++++++++ packages/core/src/utils/rateLimit.ts | 73 +++++++++++ packages/core/src/utils/retry.test.ts | 31 +---- packages/core/src/utils/retry.ts | 122 +----------------- 11 files changed, 229 insertions(+), 209 deletions(-) create mode 100644 packages/core/src/utils/rateLimit.test.ts create mode 100644 packages/core/src/utils/rateLimit.ts diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index ab88ec4cf..45ead0f14 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -67,7 +67,12 @@ const MockedUserPromptEvent = vi.hoisted(() => const MockedApiCancelEvent = vi.hoisted(() => vi.fn().mockImplementation(() => {}), ); -const mockParseAndFormatApiError = vi.hoisted(() => vi.fn()); +const mockParseAndFormatApiError = vi.hoisted(() => + vi.fn( + (msg: unknown) => + `[API Error: ${typeof msg === 'string' ? msg : 'An unknown error occurred.'}]`, + ), +); const mockLogApiCancel = vi.hoisted(() => vi.fn()); // Vision auto-switch mocks (hoisted) @@ -123,22 +128,6 @@ vi.mock('../utils/markdownUtilities.js', () => ({ findLastSafeSplitPoint: vi.fn((s: string) => s.length), })); -vi.mock('./useStateAndRef.js', () => ({ - useStateAndRef: vi.fn((initial) => { - let val = initial; - const ref = { current: val }; - const setVal = vi.fn((updater) => { - if (typeof updater === 'function') { - val = updater(val); - } else { - val = updater; - } - ref.current = val; - }); - return [val, ref, setVal]; - }), -})); - vi.mock('./useLogger.js', () => ({ useLogger: vi.fn().mockReturnValue({ logMessage: vi.fn().mockResolvedValue(undefined), @@ -2305,12 +2294,15 @@ describe('useGeminiStream', () => { yield { type: ServerGeminiEventType.Retry, retryInfo: { - reason: 'Rate limit exceeded', + message: '[API Error: Rate limit exceeded]', attempt: 1, maxRetries: 3, delayMs: 3000, }, }; + yield { + type: ServerGeminiEventType.Retry, + }; await new Promise((resolve) => { resolveStream = resolve; }); @@ -2353,16 +2345,33 @@ describe('useGeminiStream', () => { await Promise.resolve(); }); - // Error line should be rendered as ERROR type - const errorItem = result.current.pendingHistoryItems.find( - (item) => item.type === MessageType.ERROR, - ); + const findErrorItem = () => + result.current.pendingHistoryItems.find( + (item) => item.type === MessageType.ERROR, + ); + const findCountdownItem = () => + result.current.pendingHistoryItems.find( + (item) => item.type === 'retry_countdown', + ); + + let errorItem = findErrorItem(); + let countdownItem = findCountdownItem(); + for ( + let attempts = 0; + attempts < 5 && (!errorItem || !countdownItem); + attempts++ + ) { + await act(async () => { + await Promise.resolve(); + }); + errorItem = findErrorItem(); + countdownItem = findCountdownItem(); + } + + // Error line should be rendered as ERROR type (wrapped by parseAndFormatApiError) expect(errorItem?.text).toContain('Rate limit exceeded'); // Countdown line should be rendered as retry_countdown type - const countdownItem = result.current.pendingHistoryItems.find( - (item) => item.type === ('retry_countdown' as MessageType), - ); expect(countdownItem?.text).toContain('Retrying in 3 seconds'); await act(async () => { @@ -2370,7 +2379,7 @@ describe('useGeminiStream', () => { }); const countdownAfterOneSecond = result.current.pendingHistoryItems.find( - (item) => item.type === ('retry_countdown' as MessageType), + (item) => item.type === 'retry_countdown', ); expect(countdownAfterOneSecond?.text).toContain( 'Retrying in 2 seconds', @@ -2388,7 +2397,7 @@ describe('useGeminiStream', () => { (item) => item.type === MessageType.ERROR, ); const remainingCountdown = result.current.pendingHistoryItems.find( - (item) => item.type === ('retry_countdown' as MessageType), + (item) => item.type === 'retry_countdown', ); expect(remainingError).toBeUndefined(); expect(remainingCountdown).toBeUndefined(); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index fa2866528..2c0144246 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -128,8 +128,11 @@ export const useGeminiStream = ( useStateAndRef(null); const [pendingRetryErrorItem, setPendingRetryErrorItem] = useState(null); - const [pendingRetryCountdownItem, setPendingRetryCountdownItem] = - useState(null); + const [ + pendingRetryCountdownItem, + pendingRetryCountdownItemRef, + setPendingRetryCountdownItem, + ] = useStateAndRef(null); const retryCountdownTimerRef = useRef | null>( null, ); @@ -208,23 +211,25 @@ export const useGeminiStream = ( stopRetryCountdownTimer(); setPendingRetryErrorItem(null); setPendingRetryCountdownItem(null); - }, [stopRetryCountdownTimer]); + }, [setPendingRetryCountdownItem, stopRetryCountdownTimer]); const startRetryCountdown = useCallback( (retryInfo: { - reason: string; + message?: string; attempt: number; maxRetries: number; delayMs: number; }) => { stopRetryCountdownTimer(); const startTime = Date.now(); - const { reason, attempt, maxRetries, delayMs } = retryInfo; + const { message, attempt, maxRetries, delayMs } = retryInfo; + const retryReasonText = + message ?? t('Rate limit exceeded. Please wait and try again.'); // Error line stays static (red with ✕ prefix) setPendingRetryErrorItem({ type: MessageType.ERROR, - text: t('Rate limit error: {{reason}}', { reason }), + text: retryReasonText, }); // Countdown line updates every second (dim/secondary color) @@ -253,7 +258,7 @@ export const useGeminiStream = ( updateCountdown(); retryCountdownTimerRef.current = setInterval(updateCountdown, 1000); }, - [stopRetryCountdownTimer], + [setPendingRetryCountdownItem, stopRetryCountdownTimer], ); useEffect(() => () => stopRetryCountdownTimer(), [stopRetryCountdownTimer]); @@ -947,7 +952,7 @@ export const useGeminiStream = ( // Show retry info if available (rate-limit / throttling errors) if (event.retryInfo) { startRetryCountdown(event.retryInfo); - } else { + } else if (!pendingRetryCountdownItemRef.current) { clearRetryCountdown(); } break; @@ -979,6 +984,7 @@ export const useGeminiStream = ( setThought, pendingHistoryItemRef, setPendingHistoryItem, + pendingRetryCountdownItemRef, ], ); diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 5ab7a57ef..1e68344ed 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -1055,12 +1055,11 @@ describe('GeminiChat', () => { expect(second.done).toBe(false); expect(second.value.type).toBe(StreamEventType.RETRY); - // Verify retryInfo contains the GLM error reason + // Verify retryInfo contains retry metadata if ( second.value.type === StreamEventType.RETRY && second.value.retryInfo ) { - expect(second.value.retryInfo.reason).toContain('速率限制'); expect(second.value.retryInfo.attempt).toBe(1); expect(second.value.retryInfo.maxRetries).toBe(10); expect(second.value.retryInfo.delayMs).toBe(60000); diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 01ae692a5..0bac7066f 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -17,12 +17,10 @@ import type { GenerateContentResponseUsageMetadata, } from '@google/genai'; import { createUserContent } from '@google/genai'; -import { - getErrorStatus, - retryWithBackoff, - getRateLimitRetryInfo, -} from '../utils/retry.js'; +import { getErrorStatus, retryWithBackoff } from '../utils/retry.js'; import { createDebugLogger } from '../utils/debugLogger.js'; +import { parseAndFormatApiError } from '../utils/errorParsing.js'; +import { isRateLimitError, type RetryInfo } from '../utils/rateLimit.js'; import type { Config } from '../config/config.js'; import { hasCycleInSchema } from '../tools/tools.js'; import type { StructuredError } from './turn.js'; @@ -47,17 +45,6 @@ export enum StreamEventType { RETRY = 'retry', } -export interface RetryInfo { - /** Human-readable error reason. */ - reason: string; - /** Current retry attempt (1-based). */ - attempt: number; - /** Max retries allowed. */ - maxRetries: number; - /** Delay in milliseconds before the retry happens. */ - delayMs: number; -} - export type StreamEvent = | { type: StreamEventType.CHUNK; value: GenerateContentResponse } | { type: StreamEventType.RETRY; retryInfo?: RetryInfo }; @@ -329,13 +316,16 @@ export class GeminiChat { // These arrive as StreamContentError with finish_reason="error_finish" // from the pipeline, containing the throttling message in the content. // Covers TPM throttling, GLM rate limits, and other provider throttling. - const rateLimitInfo = getRateLimitRetryInfo(error); + const isRateLimit = isRateLimitError(error); if ( - rateLimitInfo && + isRateLimit && rateLimitRetryCount < RATE_LIMIT_RETRY_OPTIONS.maxRetries ) { rateLimitRetryCount++; const delayMs = RATE_LIMIT_RETRY_OPTIONS.delayMs; + const message = parseAndFormatApiError( + error instanceof Error ? error.message : String(error), + ); debugLogger.warn( `Rate limit throttling detected (retry ${rateLimitRetryCount}/${RATE_LIMIT_RETRY_OPTIONS.maxRetries}). ` + `Waiting ${delayMs / 1000}s before retrying...`, @@ -343,7 +333,7 @@ export class GeminiChat { yield { type: StreamEventType.RETRY, retryInfo: { - reason: rateLimitInfo.reason, + message, attempt: rateLimitRetryCount, maxRetries: RATE_LIMIT_RETRY_OPTIONS.maxRetries, delayMs, diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 9b50a16b5..17c6c47de 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -26,7 +26,8 @@ import { UnauthorizedError, toFriendlyError, } from '../utils/errors.js'; -import type { GeminiChat, RetryInfo } from './geminiChat.js'; +import type { GeminiChat } from './geminiChat.js'; +import type { RetryInfo } from '../utils/rateLimit.js'; import { getThoughtText, parseThought, diff --git a/packages/core/src/utils/errorParsing.test.ts b/packages/core/src/utils/errorParsing.test.ts index bda1f86f9..8ae7395fb 100644 --- a/packages/core/src/utils/errorParsing.test.ts +++ b/packages/core/src/utils/errorParsing.test.ts @@ -60,6 +60,14 @@ describe('parseAndFormatApiError', () => { ); }); + it('should omit status when the API error has no status field', () => { + const errorMessage = + '{"error":{"code":1302,"message":"您的账户已达到速率限制,请您控制请求频率"}}'; + expect(parseAndFormatApiError(errorMessage)).toBe( + '[API Error: 您的账户已达到速率限制,请您控制请求频率]', + ); + }); + it('should format a nested API error', () => { const nestedErrorMessage = JSON.stringify({ error: { diff --git a/packages/core/src/utils/errorParsing.ts b/packages/core/src/utils/errorParsing.ts index ef1c009b6..21b845955 100644 --- a/packages/core/src/utils/errorParsing.ts +++ b/packages/core/src/utils/errorParsing.ts @@ -60,7 +60,10 @@ export function parseAndFormatApiError( } catch (_e) { // It's not a nested JSON error, so we just use the message as is. } - let text = `[API Error: ${finalMessage} (Status: ${parsedError.error.status})]`; + const statusText = parsedError.error.status + ? ` (Status: ${parsedError.error.status})` + : ''; + let text = `[API Error: ${finalMessage}${statusText}]`; if (parsedError.error.code === 429) { text += getRateLimitMessage(authType); } diff --git a/packages/core/src/utils/rateLimit.test.ts b/packages/core/src/utils/rateLimit.test.ts new file mode 100644 index 000000000..48605db20 --- /dev/null +++ b/packages/core/src/utils/rateLimit.test.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { isRateLimitError } from './rateLimit.js'; +import type { StructuredError } from '../core/turn.js'; +import type { HttpError } from './retry.js'; + +describe('isRateLimitError — detection paths', () => { + it('should detect rate-limit from ApiError.error.code in JSON message', () => { + const info = isRateLimitError( + new Error( + '{"error":{"code":"429","message":"Throttling: TPM(10680324/10000000)"}}', + ), + ); + expect(info).toBe(true); + }); + + it('should detect rate-limit from direct ApiError object', () => { + const info = isRateLimitError({ + error: { code: 429, message: 'Rate limit exceeded' }, + }); + expect(info).toBe(true); + }); + + it('should detect GLM 1302 code from ApiError', () => { + const info = isRateLimitError({ + error: { code: 1302, message: '您的账户已达到速率限制' }, + }); + expect(info).toBe(true); + }); + + it('should detect rate-limit from StructuredError.status', () => { + const error: StructuredError = { message: 'Rate limited', status: 429 }; + const info = isRateLimitError(error); + expect(info).toBe(true); + }); + + it('should detect rate-limit from HttpError.status', () => { + const error: HttpError = new Error('Too Many Requests'); + error.status = 429; + const info = isRateLimitError(error); + expect(info).toBe(true); + }); + + it('should return null for non-rate-limit codes', () => { + expect( + isRateLimitError({ error: { code: 400, message: 'Bad Request' } }), + ).toBe(false); + }); + + it('should return null for invalid inputs', () => { + expect(isRateLimitError(null)).toBe(false); + expect(isRateLimitError(undefined)).toBe(false); + expect(isRateLimitError('500')).toBe(false); + }); +}); + +describe('isRateLimitError — return shape', () => { + it('should detect GLM rate limit JSON string', () => { + const info = isRateLimitError( + '{"error":{"code":"1302","message":"您的账户已达到速率限制,请您控制请求频率"}}', + ); + expect(info).toBe(true); + }); + + it('should treat HTTP 503 as rate-limit', () => { + const error: HttpError = new Error('Service Unavailable'); + error.status = 503; + const info = isRateLimitError(error); + expect(info).toBe(true); + }); + + it('should return null for non-rate-limit errors', () => { + expect(isRateLimitError(new Error('Connection refused'))).toBe(false); + }); +}); diff --git a/packages/core/src/utils/rateLimit.ts b/packages/core/src/utils/rateLimit.ts new file mode 100644 index 000000000..559cb26fb --- /dev/null +++ b/packages/core/src/utils/rateLimit.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { isApiError, isStructuredError } from './quotaErrorDetection.js'; + +// Known rate-limit error codes across providers. +// 429 - Standard HTTP "Too Many Requests" (DashScope TPM, OpenAI, etc.) +// 503 - Provider throttling/overload (treated as rate-limit for retry UI) +// 1302 - Z.AI GLM rate limit (https://docs.z.ai/api-reference/api-code) +const RATE_LIMIT_ERROR_CODES = new Set([429, 503, 1302]); + +export interface RetryInfo { + /** Formatted error message for display, produced by parseAndFormatApiError. */ + message?: string; + /** Current retry attempt (1-based). */ + attempt: number; + /** Max retries allowed. */ + maxRetries: number; + /** Delay in milliseconds before the retry happens. */ + delayMs: number; +} + +/** + * Detects rate-limit / throttling errors and returns retry info. + */ +export function isRateLimitError(error: unknown): boolean { + const code = getErrorCode(error); + return code !== null && RATE_LIMIT_ERROR_CODES.has(code); +} + +/** + * Extracts the numeric error code from various error shapes. + * Mirrors the same parsing patterns used by parseAndFormatApiError. + */ +function getErrorCode(error: unknown): number | null { + if (isApiError(error)) return Number(error.error.code) || null; + + // JSON in string / Error.message — check BEFORE isStructuredError because + // Error instances also satisfy isStructuredError (both have .message). + const msg = + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : null; + if (msg) { + const i = msg.indexOf('{'); + if (i !== -1) { + try { + const p = JSON.parse(msg.substring(i)) as unknown; + if (isApiError(p)) return Number(p.error.code) || null; + } catch { + /* not valid JSON */ + } + } + } + + // StructuredError (.status) — plain objects from Gemini SDK + if (isStructuredError(error)) { + return typeof error.status === 'number' ? error.status : null; + } + + // HttpError (.status on Error) + if (error instanceof Error && 'status' in error) { + const s = (error as { status?: unknown }).status; + if (typeof s === 'number') return s; + } + + return null; +} diff --git a/packages/core/src/utils/retry.test.ts b/packages/core/src/utils/retry.test.ts index b290287d8..490f24448 100644 --- a/packages/core/src/utils/retry.test.ts +++ b/packages/core/src/utils/retry.test.ts @@ -7,11 +7,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { HttpError } from './retry.js'; -import { - getErrorStatus, - getRateLimitRetryInfo, - retryWithBackoff, -} from './retry.js'; +import { getErrorStatus, retryWithBackoff } from './retry.js'; import { setSimulate429 } from './testUtils.js'; import { AuthType } from '../core/contentGenerator.js'; @@ -536,28 +532,3 @@ describe('getErrorStatus', () => { expect(getErrorStatus({ error: {} })).toBeUndefined(); }); }); - -describe('getRateLimitRetryInfo', () => { - it('should extract reason from TPM throttling error', () => { - const info = getRateLimitRetryInfo( - new Error( - '{"error":{"code":"429","message":"Throttling: TPM(10680324/10000000)"}}', - ), - ); - expect(info).not.toBeNull(); - expect(info?.reason).toBe('Throttling: TPM(10680324/10000000)'); - }); - - it('should extract reason from GLM rate limit error', () => { - const info = getRateLimitRetryInfo( - '{"error":{"code":"1302","message":"您的账户已达到速率限制,请您控制请求频率"}}', - ); - expect(info).not.toBeNull(); - expect(info?.reason).toBe('您的账户已达到速率限制,请您控制请求频率'); - }); - - it('should return null for non-rate-limit errors', () => { - expect(getRateLimitRetryInfo(new Error('Connection refused'))).toBeNull(); - expect(getRateLimitRetryInfo('some error')).toBeNull(); - }); -}); diff --git a/packages/core/src/utils/retry.ts b/packages/core/src/utils/retry.ts index ccb9f7983..fd9b5c025 100644 --- a/packages/core/src/utils/retry.ts +++ b/packages/core/src/utils/retry.ts @@ -6,7 +6,7 @@ import type { GenerateContentResponse } from '@google/genai'; import { AuthType } from '../core/contentGenerator.js'; -import { isQwenQuotaExceededError, isApiError } from './quotaErrorDetection.js'; +import { isQwenQuotaExceededError } from './quotaErrorDetection.js'; import { createDebugLogger } from './debugLogger.js'; const debugLogger = createDebugLogger('RETRY'); @@ -24,10 +24,6 @@ export interface RetryOptions { authType?: string; } -export interface RateLimitRetryInfo { - reason: string; -} - const DEFAULT_RETRY_OPTIONS: RetryOptions = { maxAttempts: 7, initialDelayMs: 1500, @@ -35,11 +31,6 @@ const DEFAULT_RETRY_OPTIONS: RetryOptions = { shouldRetryOnError: defaultShouldRetry, }; -// Known rate-limit error codes across providers. -// 429 - Standard HTTP "Too Many Requests" (DashScope TPM, OpenAI, etc.) -// 1302 - Z.AI GLM rate limit (https://docs.z.ai/api-reference/api-code) -const RATE_LIMIT_ERROR_CODES = new Set(['429', '1302']); - /** * Default predicate function to determine if a retry should be attempted. * Retries on 429 (Too Many Requests) and 5xx server errors. @@ -156,117 +147,6 @@ export async function retryWithBackoff( throw new Error('Retry attempts exhausted'); } -/** - * Returns rate-limit retry info when an error is detected as rate-limited. - * Returns a human-readable reason for the UI. Retry delay is determined by - * the caller (e.g., fixed 60s in geminiChat.ts). - */ -export function getRateLimitRetryInfo( - error: unknown, -): RateLimitRetryInfo | null { - if (!getRateLimitCode(error)) { - return null; - } - return { reason: getRateLimitReason(error) }; -} - -// --------------------------------------------------------------------------- -// Private helpers for rate-limit detection -// --------------------------------------------------------------------------- - -/** Extracts the rate-limit code if present in the error. */ -function getRateLimitCode(error: unknown): string | undefined { - // Direct code on nested error object: { error: { code: "429" } } - if (isApiError(error)) { - const code = String(error.error.code); - if (RATE_LIMIT_ERROR_CODES.has(code)) { - return code; - } - } - - // Try to extract code from JSON embedded in error message string - const message = getErrorMessage(error); - if (message) { - const details = extractErrorDetailsFromString(message); - if (details?.code !== undefined) { - const code = String(details.code); - if (RATE_LIMIT_ERROR_CODES.has(code)) { - return code; - } - } - } - - // Fallback to HTTP status 429 - const status = getErrorStatus(error); - if (status === 429) { - return '429'; - } - - return undefined; -} - -function getRateLimitReason(error: unknown): string { - if (isApiError(error)) { - return error.error.message; - } - - if (error instanceof Error) { - return extractReasonFromString(error.message); - } - - if (typeof error === 'string') { - return extractReasonFromString(error); - } - - return String(error); -} - -function getErrorMessage(error: unknown): string | undefined { - if (typeof error === 'string') return error; - if (error instanceof Error) return error.message; - if (isApiError(error)) return error.error.message; - return undefined; -} - -function extractReasonFromString(message: string): string { - const parsed = extractErrorDetailsFromString(message); - if (parsed?.message) { - return parsed.message; - } - return message; -} - -function extractErrorDetailsFromString( - message: string, -): { code?: unknown; message?: string } | null { - const trimmed = message.trim().replace(/^data:\s*/i, ''); - if (!trimmed.startsWith('{')) { - return null; - } - try { - const parsed = JSON.parse(trimmed) as unknown; - if (!parsed || typeof parsed !== 'object') { - return null; - } - const errorObject = - 'error' in parsed && - typeof (parsed as { error?: unknown }).error === 'object' - ? (parsed as { error: Record }).error - : (parsed as Record); - const code = errorObject?.['code']; - const messageValue = - typeof errorObject?.['message'] === 'string' - ? errorObject['message'] - : undefined; - if (code === undefined && messageValue === undefined) { - return null; - } - return { code, message: messageValue }; - } catch { - return null; - } -} - /** * Extracts the HTTP status code from an error object. * From 5d939fdb832c8867be6e75bc9c9a1a01836f91eb Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Thu, 12 Feb 2026 18:58:27 +0800 Subject: [PATCH 38/81] feat: add --session-id support for CLI and SDK - Add --session-id flag to CLI for specifying custom session ID - Add sessionId option to SDK QueryOptions - Implement UUID validation for session IDs - Pass session ID from SDK to CLI via --session-id argument - Add integration tests for session-id functionality - Update unit tests for ProcessTransport Co-authored-by: Qwen-Coder --- .../sdk-typescript/session-id.test.ts | 462 ++++++++++++++++++ packages/cli/src/config/config.test.ts | 9 +- packages/cli/src/config/config.ts | 34 ++ .../control/controllers/systemController.ts | 1 + packages/sdk-typescript/README.md | 2 + packages/sdk-typescript/src/query/Query.ts | 3 +- .../sdk-typescript/src/query/createQuery.ts | 17 + .../src/transport/ProcessTransport.ts | 4 + .../src/types/queryOptionsSchema.ts | 1 + packages/sdk-typescript/src/types/types.ts | 14 + .../sdk-typescript/src/utils/validation.ts | 33 ++ .../test/unit/ProcessTransport.test.ts | 26 + 12 files changed, 603 insertions(+), 3 deletions(-) create mode 100644 integration-tests/sdk-typescript/session-id.test.ts create mode 100644 packages/sdk-typescript/src/utils/validation.ts diff --git a/integration-tests/sdk-typescript/session-id.test.ts b/integration-tests/sdk-typescript/session-id.test.ts new file mode 100644 index 000000000..e16ce794a --- /dev/null +++ b/integration-tests/sdk-typescript/session-id.test.ts @@ -0,0 +1,462 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * E2E tests for SDK session-id functionality: + * - sessionId option: Allows users to specify a custom session ID + * - Validation: Session ID must be a valid UUID + * - Integration: Session ID is passed to CLI via --session-id flag + * - Behavior: sessionId cannot be used with resume or continue + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { query, isSDKSystemMessage, type SDKMessage } from '@qwen-code/sdk'; +import { + SDKTestHelper, + createSharedTestOptions, + assertSuccessfulCompletion, +} from './test-helper.js'; + +const SHARED_TEST_OPTIONS = createSharedTestOptions(); + +describe('Session ID Support (E2E)', () => { + let helper: SDKTestHelper; + let testDir: string; + + beforeEach(async () => { + helper = new SDKTestHelper(); + testDir = await helper.setup('session-id'); + }); + + afterEach(async () => { + await helper.cleanup(); + }); + + describe('sessionId Option', () => { + it('should accept a valid UUID as sessionId', async () => { + // Valid UUID v4: 4 in position 14, 8/9/a/b in position 19 + const customSessionId = '12345678-1234-4234-8234-123456789abc'; + + const q = query({ + prompt: 'What is 1 + 1? Just the number.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + sessionId: customSessionId, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + assertSuccessfulCompletion(messages); + + // Verify the query used the custom session ID + expect(q.getSessionId()).toBe(customSessionId); + } finally { + await q.close(); + } + }); + + it('should use sessionId in system init message', async () => { + // Valid UUID v4: 4 in position 14, 8/9/a/b in position 19 + const customSessionId = 'abcdef12-3456-4234-abcd-ef1234567890'; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + sessionId: customSessionId, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + + // Stop after we get the system init message + if (isSDKSystemMessage(message) && message.subtype === 'init') { + expect(message.session_id).toBe(customSessionId); + break; + } + } + } finally { + await q.close(); + } + }); + + it('should pass sessionId to CLI via arguments', async () => { + // Valid UUID v4: 4 in position 14, 8/9/a/b in position 19 + const customSessionId = 'a1b2c3d4-e5f6-4234-abcd-ef1234567890'; + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'What is 2 + 2? Just the number.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + sessionId: customSessionId, + debug: true, + logLevel: 'debug', + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + try { + for await (const _message of q) { + // Consume all messages + } + + // Verify that CLI was spawned with --session-id argument + const hasSessionIdArg = stderrMessages.some((msg) => + msg.includes('--session-id'), + ); + expect(hasSessionIdArg).toBe(true); + + // Verify the session ID value is in the arguments + const hasCorrectSessionId = stderrMessages.some((msg) => + msg.includes(customSessionId), + ); + expect(hasCorrectSessionId).toBe(true); + } finally { + await q.close(); + } + }); + + it('should auto-generate sessionId when not provided', async () => { + const q = query({ + prompt: 'What is 3 + 3? Just the number.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + assertSuccessfulCompletion(messages); + + // Verify the query has a valid auto-generated session ID + const sessionId = q.getSessionId(); + expect(sessionId).toBeDefined(); + expect(sessionId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + } finally { + await q.close(); + } + }); + + it('should reject using sessionId with resume', async () => { + // Valid UUIDs: 4 in position 14, 8/9/a/b in position 19 + const customSessionId = '11111111-2222-4333-a444-555555555555'; + const resumeSessionId = '66666666-7777-4888-b999-000000000000'; + + // CLI rejects using --session-id with --resume + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + sessionId: customSessionId, + resume: resumeSessionId, + debug: false, + }, + }); + + try { + for await (const _message of q) { + // Consume messages + } + // Should not reach here - CLI should reject this combination + throw new Error( + 'Expected query to fail when using sessionId with resume', + ); + } catch (error) { + // Expected to fail - CLI rejects --session-id with --resume + expect(error).toBeDefined(); + } finally { + await q.close(); + } + }); + }); + + describe('Session ID Validation', () => { + it('should reject invalid sessionId format', async () => { + const invalidSessionId = 'not-a-valid-uuid'; + + expect(() => { + query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + sessionId: invalidSessionId, + }, + }); + }).toThrow(/Invalid sessionId/); + }); + + it('should reject sessionId with wrong UUID version', async () => { + // UUID version 6 (not valid - must be 1-5) + const invalidVersionSessionId = '12345678-1234-6789-8234-123456789abc'; + + expect(() => { + query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + sessionId: invalidVersionSessionId, + }, + }); + }).toThrow(/Invalid sessionId/); + }); + + it('should reject sessionId with invalid variant', async () => { + // Invalid variant (must be 8, 9, a, or b in position 19) + const invalidVariantSessionId = '12345678-1234-1234-c234-823456789abc'; + + expect(() => { + query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + sessionId: invalidVariantSessionId, + }, + }); + }).toThrow(/Invalid sessionId/); + }); + + it('should handle empty sessionId gracefully', async () => { + // Note: Empty string behavior - validation skips it but Query constructor may use it + // This test documents the current behavior + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + sessionId: '', + }, + }); + + try { + // When empty string is provided, the query should still be created + // The actual session ID behavior depends on implementation details + const sessionId = q.getSessionId(); + expect(sessionId).toBeDefined(); + + // If empty string is used, it's passed through; otherwise a UUID is generated + // Either way, the query should function + for await (const _message of q) { + // Consume messages + } + } finally { + await q.close(); + } + }); + + it('should accept various valid UUID formats', async () => { + const validUUIDs = [ + '12345678-1234-1234-8234-123456789abc', // version 1, variant 8 + '12345678-1234-1234-9234-123456789abc', // version 1, variant 9 + '12345678-1234-1234-a234-123456789abc', // version 1, variant a + '12345678-1234-1234-b234-123456789abc', // version 1, variant b + '12345678-1234-2234-8234-123456789abc', // version 2, variant 8 + '12345678-1234-3234-8234-123456789abc', // version 3, variant 8 + '12345678-1234-4234-8234-123456789abc', // version 4, variant 8 + '12345678-1234-5234-8234-123456789abc', // version 5, variant 8 + ]; + + for (const uuid of validUUIDs) { + const q = query({ + prompt: 'Say hi', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + sessionId: uuid, + debug: false, + }, + }); + + try { + // Just verify the query is created without throwing + expect(q.getSessionId()).toBe(uuid); + } finally { + await q.close(); + } + } + }); + }); + + describe('Multi-turn with Custom Session ID', () => { + it('should maintain custom sessionId across multiple turns', async () => { + // Valid UUID v4: 4 in position 14, 8/9/a/b in position 19 + const customSessionId = '99999999-8888-4777-a666-555555555555'; + + async function* createConversation(): AsyncIterable<{ + type: 'user'; + session_id: string; + message: { role: 'user'; content: string }; + parent_tool_use_id: null; + }> { + yield { + type: 'user', + session_id: customSessionId, + message: { + role: 'user', + content: 'What is 1 + 1?', + }, + parent_tool_use_id: null, + }; + + await new Promise((resolve) => setTimeout(resolve, 100)); + + yield { + type: 'user', + session_id: customSessionId, + message: { + role: 'user', + content: 'What is 2 + 2?', + }, + parent_tool_use_id: null, + }; + } + + const q = query({ + prompt: createConversation(), + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + sessionId: customSessionId, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + assertSuccessfulCompletion(messages); + + // Verify all system messages use the custom session ID + const systemMessages = messages.filter(isSDKSystemMessage); + for (const sysMsg of systemMessages) { + expect(sysMsg.session_id).toBe(customSessionId); + } + } finally { + await q.close(); + } + }); + }); + + describe('Session ID Consistency', () => { + it('should expose same sessionId via getSessionId() and messages', async () => { + // Valid UUID v4: 4 in position 14, 8/9/a/b in position 19 + const customSessionId = 'aaaaaaaa-bbbb-4ccc-adde-eeeeeeeeeeee'; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + sessionId: customSessionId, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Verify getSessionId() matches the option + expect(q.getSessionId()).toBe(customSessionId); + + // Verify system messages have the same session ID + const systemMessages = messages.filter(isSDKSystemMessage); + expect(systemMessages.length).toBeGreaterThan(0); + for (const sysMsg of systemMessages) { + expect(sysMsg.session_id).toBe(customSessionId); + } + } finally { + await q.close(); + } + }); + + it('should generate different session IDs for different queries', async () => { + const q1 = query({ + prompt: 'Say one', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + const q2 = query({ + prompt: 'Say two', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + try { + // Consume messages from both queries + for await (const _msg of q1) { + // consume + } + for await (const _msg of q2) { + // consume + } + + const sessionId1 = q1.getSessionId(); + const sessionId2 = q2.getSessionId(); + + // Session IDs should be different + expect(sessionId1).toBeDefined(); + expect(sessionId2).toBeDefined(); + expect(sessionId1).not.toBe(sessionId2); + + // Both should be valid UUIDs + expect(sessionId1).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + expect(sessionId2).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + } finally { + await q1.close(); + await q2.close(); + } + }); + }); +}); diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index dbe57fd42..5f08dd382 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -242,9 +242,14 @@ describe('parseArguments', () => { }); it('should allow -r flag as alias for --resume', async () => { - process.argv = ['node', 'script.js', '-r', 'session-123']; + process.argv = [ + 'node', + 'script.js', + '-r', + '123e4567-e89b-12d3-a456-426614174000', + ]; const argv = await parseArguments(); - expect(argv.resume).toBe('session-123'); + expect(argv.resume).toBe('123e4567-e89b-12d3-a456-426614174000'); }); it('should allow -c flag as alias for --continue', async () => { diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 4598a742b..00a54fb39 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -50,6 +50,19 @@ import { loadSandboxConfig } from './sandboxConfig.js'; import { appEvents } from '../utils/events.js'; import { mcpCommand } from '../commands/mcp.js'; +// UUID v4 regex pattern for validation +const UUID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +/** + * Validates if a string is a valid UUID format + * @param value - The string to validate + * @returns True if the string is a valid UUID, false otherwise + */ +function isValidUUID(value: string): boolean { + return UUID_REGEX.test(value); +} + import { isWorkspaceTrusted } from './trustedFolders.js'; import { buildWebSearchConfig } from './webSearch.js'; import { writeStderrLine } from '../utils/stdioHelpers.js'; @@ -137,6 +150,8 @@ export interface CliArgs { continue: boolean | undefined; /** Resume a specific session by its ID */ resume: string | undefined; + /** Specify a session ID without session resumption */ + 'session-id': string | undefined; maxSessionTurns: number | undefined; coreTools: string[] | undefined; excludeTools: string[] | undefined; @@ -449,6 +464,10 @@ export async function parseArguments(): Promise { description: 'Resume a specific session by its ID. Use without an ID to show session picker.', }) + .option('session-id', { + type: 'string', + description: 'Specify a session ID for this run.', + }) .option('max-session-turns', { type: 'number', description: 'Maximum number of session turns', @@ -535,6 +554,18 @@ export async function parseArguments(): Promise { if (argv['continue'] && argv['resume']) { return 'Cannot use both --continue and --resume together. Use --continue to resume the latest session, or --resume to resume a specific session.'; } + if (argv['session-id'] && (argv['continue'] || argv['resume'])) { + return 'Cannot use --session-id with --continue or --resume. Use --session-id to start a new session with a specific ID, or use --continue/--resume to resume an existing session.'; + } + if ( + argv['session-id'] && + !isValidUUID(argv['session-id'] as string) + ) { + return `Invalid --session-id: "${argv['session-id']}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`; + } + if (argv['resume'] && !isValidUUID(argv['resume'] as string)) { + return `Invalid --resume: "${argv['resume']}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`; + } return true; }), ) @@ -899,6 +930,9 @@ export async function loadCliConfig( process.exit(1); } } + } else if (argv['session-id']) { + // Use provided session ID without session resumption + sessionId = argv['session-id']; } const modelProvidersConfig = settings.modelProviders; diff --git a/packages/cli/src/nonInteractive/control/controllers/systemController.ts b/packages/cli/src/nonInteractive/control/controllers/systemController.ts index 06923e963..5a275344f 100644 --- a/packages/cli/src/nonInteractive/control/controllers/systemController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/systemController.ts @@ -193,6 +193,7 @@ export class SystemController extends BaseController { return { subtype: 'initialize', + session_id: this.context.config.getSessionId(), capabilities, }; } diff --git a/packages/sdk-typescript/README.md b/packages/sdk-typescript/README.md index a9699b02e..292a7550a 100644 --- a/packages/sdk-typescript/README.md +++ b/packages/sdk-typescript/README.md @@ -70,6 +70,8 @@ Creates a new query session with the Qwen Code. | `authType` | `'openai' \| 'qwen-oauth'` | `'openai'` | Authentication type for the AI service. Using `'qwen-oauth'` in SDK is not recommended as credentials are stored in `~/.qwen` and may need periodic refresh. | | `agents` | `SubagentConfig[]` | - | Configuration for subagents that can be invoked during the session. Subagents are specialized AI agents for specific tasks or domains. | | `includePartialMessages` | `boolean` | `false` | When `true`, the SDK emits incomplete messages as they are being generated, allowing real-time streaming of the AI's response. | +| `resume` | `string` | - | Resume a previous session by providing its session ID. Equivalent to CLI's `--resume` flag. | +| `sessionId` | `string` | - | Specify a session ID for the new session. Ensures SDK and CLI use the same ID without resuming history. Equivalent to CLI's `--session-id` flag. | ### Timeouts diff --git a/packages/sdk-typescript/src/query/Query.ts b/packages/sdk-typescript/src/query/Query.ts index 50c1db3bd..9d130e0a4 100644 --- a/packages/sdk-typescript/src/query/Query.ts +++ b/packages/sdk-typescript/src/query/Query.ts @@ -92,7 +92,8 @@ export class Query implements AsyncIterable { ) { this.transport = transport; this.options = options; - this.sessionId = options.resume ?? randomUUID(); + // Use sessionId from options if provided (for SDK-CLI alignment), otherwise generate one + this.sessionId = options.resume ?? options.sessionId ?? randomUUID(); this.inputStream = new Stream(); this.abortController = options.abortController ?? new AbortController(); this.isSingleTurn = singleTurn; diff --git a/packages/sdk-typescript/src/query/createQuery.ts b/packages/sdk-typescript/src/query/createQuery.ts index 2a9842d0c..b136e47f4 100644 --- a/packages/sdk-typescript/src/query/createQuery.ts +++ b/packages/sdk-typescript/src/query/createQuery.ts @@ -10,6 +10,8 @@ import { Query } from './Query.js'; import type { QueryOptions } from '../types/types.js'; import { QueryOptionsSchema } from '../types/queryOptionsSchema.js'; import { SdkLogger } from '../utils/logger.js'; +import { randomUUID } from 'node:crypto'; +import { validateSessionId } from '../utils/validation.js'; export type { QueryOptions }; @@ -40,6 +42,9 @@ export function query({ const abortController = options.abortController ?? new AbortController(); + // Generate or use provided session ID for SDK-CLI alignment + const sessionId = options.resume ?? options.sessionId ?? randomUUID(); + const transport = new ProcessTransport({ pathToQwenExecutable, spawnInfo, @@ -58,11 +63,13 @@ export function query({ authType: options.authType, includePartialMessages: options.includePartialMessages, resume: options.resume, + sessionId, }); const queryOptions: QueryOptions = { ...options, abortController, + sessionId, }; const queryInstance = new Query(transport, queryOptions, isSingleTurn); @@ -107,6 +114,16 @@ function validateOptions(options: QueryOptions): SpawnInfo | undefined { throw new Error(`Invalid QueryOptions: ${errors}`); } + // Validate sessionId format if provided + if (options.sessionId) { + validateSessionId(options.sessionId, 'sessionId'); + } + + // Validate resume format if provided + if (options.resume) { + validateSessionId(options.resume, 'resume'); + } + try { return prepareSpawnInfo(options.pathToQwenExecutable); } catch (error) { diff --git a/packages/sdk-typescript/src/transport/ProcessTransport.ts b/packages/sdk-typescript/src/transport/ProcessTransport.ts index 2b621d434..a763a519c 100644 --- a/packages/sdk-typescript/src/transport/ProcessTransport.ts +++ b/packages/sdk-typescript/src/transport/ProcessTransport.ts @@ -261,9 +261,13 @@ export class ProcessTransport implements Transport { } if (this.options.resume) { + // Resume existing session args.push('--resume', this.options.resume); } else if (this.options.continue) { args.push('--continue'); + } else if (this.options.sessionId) { + // Start new session with specific session ID (for SDK-CLI alignment) + args.push('--session-id', this.options.sessionId); } return args; diff --git a/packages/sdk-typescript/src/types/queryOptionsSchema.ts b/packages/sdk-typescript/src/types/queryOptionsSchema.ts index 3db52cb36..6781bb6dc 100644 --- a/packages/sdk-typescript/src/types/queryOptionsSchema.ts +++ b/packages/sdk-typescript/src/types/queryOptionsSchema.ts @@ -167,6 +167,7 @@ export const QueryOptionsSchema = z .optional(), includePartialMessages: z.boolean().optional(), resume: z.string().optional(), + sessionId: z.string().optional(), timeout: TimeoutConfigSchema.optional(), }) .strict(); diff --git a/packages/sdk-typescript/src/types/types.ts b/packages/sdk-typescript/src/types/types.ts index 225f2b5e3..e726f4a2c 100644 --- a/packages/sdk-typescript/src/types/types.ts +++ b/packages/sdk-typescript/src/types/types.ts @@ -38,6 +38,12 @@ export type TransportOptions = { * When provided, takes precedence over `continue`. */ resume?: string; + /** + * Session ID to use for this session. + * Passed to CLI via --session-id to ensure consistent session ID. + * When resume is provided, this should match the resume ID. + */ + sessionId?: string; }; type ToolInput = Record; @@ -422,6 +428,14 @@ export interface QueryOptions { */ resume?: string; + /** + * Specify a session ID for the new session. + * This ensures the SDK and CLI use the same session ID without resuming a previous session. + * Equivalent to CLI's `--session-id` flag. + * @example '123e4567-e89b-12d3-a456-426614174000' + */ + sessionId?: string; + /** * Timeout configuration for various SDK operations. * All values are in milliseconds. diff --git a/packages/sdk-typescript/src/utils/validation.ts b/packages/sdk-typescript/src/utils/validation.ts new file mode 100644 index 000000000..a4719c44b --- /dev/null +++ b/packages/sdk-typescript/src/utils/validation.ts @@ -0,0 +1,33 @@ +/** + * UUID validation utilities + */ + +// UUID v4 regex pattern +const UUID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +/** + * Validates if a string is a valid UUID format + * @param value - The string to validate + * @returns True if the string is a valid UUID, false otherwise + */ +export function isValidUUID(value: string): boolean { + return UUID_REGEX.test(value); +} + +/** + * Validates a session ID and throws an error if invalid + * @param sessionId - The session ID to validate + * @param paramName - The name of the parameter (for error messages) + * @throws Error if the session ID is not a valid UUID + */ +export function validateSessionId( + sessionId: string, + paramName: string = 'sessionId', +): void { + if (!isValidUUID(sessionId)) { + throw new Error( + `Invalid ${paramName}: "${sessionId}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`, + ); + } +} diff --git a/packages/sdk-typescript/test/unit/ProcessTransport.test.ts b/packages/sdk-typescript/test/unit/ProcessTransport.test.ts index 8b8b8cb42..327166528 100644 --- a/packages/sdk-typescript/test/unit/ProcessTransport.test.ts +++ b/packages/sdk-typescript/test/unit/ProcessTransport.test.ts @@ -222,6 +222,32 @@ describe('ProcessTransport', () => { ); }); + it('should include --session-id argument when sessionId is provided without resume', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + sessionId: '123e4567-e89b-12d3-a456-426614174000', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.arrayContaining([ + '--session-id', + '123e4567-e89b-12d3-a456-426614174000', + ]), + expect.any(Object), + ); + }); + it('should throw if aborted before initialization', () => { mockPrepareSpawnInfo.mockReturnValue({ command: 'qwen', From 51760fe3a6bf95375bc86e0532ab3f385e6bad38 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Thu, 12 Feb 2026 20:52:47 +0800 Subject: [PATCH 39/81] fix: ci errors --- packages/cli/src/gemini.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 039f0bef3..51630bfc5 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -496,6 +496,7 @@ describe('gemini.tsx main function kitty protocol', () => { experimentalLsp: undefined, channel: undefined, chatRecording: undefined, + 'session-id': undefined, }); await main(); From 82dc79629cbe7524ae82c175a444e9ebd1883101 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Fri, 13 Feb 2026 21:37:51 +0800 Subject: [PATCH 40/81] feat: enhance session ID handling and error propagation --- .../sdk-typescript/session-id.test.ts | 114 +++++++++++++++++- .../sdk-typescript/test-helper.ts | 10 +- packages/cli/src/config/config.ts | 8 ++ packages/sdk-typescript/src/query/Query.ts | 30 ++++- .../sdk-typescript/src/query/createQuery.ts | 9 +- packages/sdk-typescript/src/utils/Stream.ts | 6 +- 6 files changed, 168 insertions(+), 9 deletions(-) diff --git a/integration-tests/sdk-typescript/session-id.test.ts b/integration-tests/sdk-typescript/session-id.test.ts index e16ce794a..6b9136503 100644 --- a/integration-tests/sdk-typescript/session-id.test.ts +++ b/integration-tests/sdk-typescript/session-id.test.ts @@ -28,7 +28,8 @@ describe('Session ID Support (E2E)', () => { beforeEach(async () => { helper = new SDKTestHelper(); - testDir = await helper.setup('session-id'); + // Enable chat recording for session-id tests to allow duplicate session detection + testDir = await helper.setup('session-id', { chatRecording: true }); }); afterEach(async () => { @@ -374,6 +375,117 @@ describe('Session ID Support (E2E)', () => { }); }); + describe('Session ID Duplicate Detection', () => { + it('should reject duplicate sessionId with error', async () => { + // Valid UUID v4 + const customSessionId = 'dddddddd-eeee-4fff-aaaa-bbbbbbbbbbbb'; + + // First query: create a session with the custom session ID + const q1 = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + sessionId: customSessionId, + debug: false, + }, + }); + + // Consume the first query to completion and close it + try { + for await (const _msg of q1) { + // consume + } + } finally { + await q1.close(); + } + + // Second query: try to use the same session ID + // This should fail because the session ID is already in use + // CLI will exit with code 1 when detecting duplicate session ID + const q2 = query({ + prompt: 'Say hello again', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + sessionId: customSessionId, + debug: false, + }, + }); + + // The error should be propagated and the iteration should throw + // When iterating over messages, if CLI exits with code 1 (duplicate session ID), + // the error should be thrown during iteration + await expect(async () => { + for await (const _msg of q2) { + // consume + } + }).rejects.toThrow(/CLI process exited with code 1/); + + await q2.close(); + }); + + it('should throw error when CLI exits with non-zero code', async () => { + // Valid UUID v4 + const customSessionId = 'eeeeeeee-ffff-4aaa-bbbb-cccccccccccc'; + + // First query: create a session and properly close it after completion + const q1 = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + sessionId: customSessionId, + debug: false, + }, + }); + + try { + for await (const _msg of q1) { + // consume + } + } finally { + await q1.close(); + } + + // Second query with same session ID + // When using the same session ID, CLI will detect the duplicate and exit with code 1 + const q2 = query({ + prompt: 'Say hello again', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + sessionId: customSessionId, + debug: false, + }, + }); + + let errorCaught = false; + let errorMessage = ''; + + try { + // Iterate over messages - the error should be thrown during iteration + // because CLI exits with code 1 when detecting duplicate session ID + for await (const _msg of q2) { + // consume + } + } catch (error) { + errorCaught = true; + // CLI errors are written directly to console (stderr inherit mode) + // SDK only reports the exit status, not the error message + expect(error instanceof Error).toBe(true); + errorMessage = error instanceof Error ? error.message : String(error); + // Verify the error message contains the expected exit code + expect(errorMessage).toContain('CLI process exited with code 1'); + } finally { + await q2.close(); + } + + // Verify that an error was actually caught during message iteration + expect(errorCaught).toBe(true); + }); + }); + describe('Session ID Consistency', () => { it('should expose same sessionId via getSessionId() and messages', async () => { // Valid UUID v4: 4 in position 14, 8/9/a/b in position 19 diff --git a/integration-tests/sdk-typescript/test-helper.ts b/integration-tests/sdk-typescript/test-helper.ts index 07f44f890..c426f6725 100644 --- a/integration-tests/sdk-typescript/test-helper.ts +++ b/integration-tests/sdk-typescript/test-helper.ts @@ -41,6 +41,13 @@ export interface SDKTestHelperOptions { * Whether to create .qwen/settings.json */ createQwenConfig?: boolean; + /** + * Whether to enable chat recording for this test. + * - Set to `true` to enable recording (needed for session-id duplicate detection tests) + * - Set to `false` or leave undefined to disable recording (default for most tests) + * This sets chatRecording in general settings. + */ + chatRecording?: boolean; } /** @@ -91,7 +98,8 @@ export class SDKTestHelper { }, general: { ...generalSettings, - chatRecording: false, // SDK tests don't need chat recording + // Default to disabling chat recording unless explicitly enabled + ...(options.chatRecording !== true ? { chatRecording: false } : {}), }, }; diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 00a54fb39..aba726bdf 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -932,6 +932,14 @@ export async function loadCliConfig( } } else if (argv['session-id']) { // Use provided session ID without session resumption + // Check if session ID is already in use + const sessionService = new SessionService(cwd); + const exists = await sessionService.sessionExists(argv['session-id']); + if (exists) { + const message = `Error: Session Id ${argv['session-id']} is already in use.`; + writeStderrLine(message); + process.exit(1); + } sessionId = argv['session-id']; } diff --git a/packages/sdk-typescript/src/query/Query.ts b/packages/sdk-typescript/src/query/Query.ts index 9d130e0a4..261d4a48b 100644 --- a/packages/sdk-typescript/src/query/Query.ts +++ b/packages/sdk-typescript/src/query/Query.ts @@ -137,7 +137,22 @@ export class Query implements AsyncIterable { } this.initialized = this.initialize(); - this.initialized.catch(() => {}); + this.initialized.catch((error) => { + // Propagate initialization errors to inputStream so users can catch them + const errorMessage = + error instanceof Error ? error.message : String(error); + if ( + errorMessage.includes('Query is closed') && + this.transport.exitError + ) { + // If query was closed due to transport error, propagate the transport error + this.inputStream.error(this.transport.exitError); + } else { + this.inputStream.error( + error instanceof Error ? error : new Error(errorMessage), + ); + } + }); this.startMessageRouter(); } @@ -630,6 +645,11 @@ export class Query implements AsyncIterable { return Promise.reject(new Error('Query is closed')); } + // Check if transport has already exited with an error + if (this.transport.exitError) { + return Promise.reject(this.transport.exitError); + } + if (subtype !== ControlRequestType.INITIALIZE) { // Ensure all other control requests get processed after initialization await this.initialized; @@ -731,16 +751,20 @@ export class Query implements AsyncIterable { this.abortHandler = null; } + // Use transport's exit error if available, otherwise use generic error + const transportError = this.transport.exitError; + const rejectionError = transportError ?? new Error('Query is closed'); + for (const pending of this.pendingControlRequests.values()) { pending.abortController.abort(); clearTimeout(pending.timeout); - pending.reject(new Error('Query is closed')); + pending.reject(rejectionError); } this.pendingControlRequests.clear(); // Clean up pending MCP responses for (const pending of this.pendingMcpResponses.values()) { - pending.reject(new Error('Query is closed')); + pending.reject(rejectionError); } this.pendingMcpResponses.clear(); diff --git a/packages/sdk-typescript/src/query/createQuery.ts b/packages/sdk-typescript/src/query/createQuery.ts index b136e47f4..5ffcd1dda 100644 --- a/packages/sdk-typescript/src/query/createQuery.ts +++ b/packages/sdk-typescript/src/query/createQuery.ts @@ -89,9 +89,16 @@ export function query({ (async () => { try { await queryInstance.initialized; + // Skip writing if transport has already exited with an error + if (transport.exitError) { + return; + } transport.write(serializeJsonLine(message)); } catch (err) { - logger.error('Error sending single-turn prompt:', err); + // Only log error if it's not due to transport already being closed + if (!transport.exitError) { + logger.error('Error sending single-turn prompt:', err); + } } })(); } else { diff --git a/packages/sdk-typescript/src/utils/Stream.ts b/packages/sdk-typescript/src/utils/Stream.ts index 70caf82e1..5fcb43525 100644 --- a/packages/sdk-typescript/src/utils/Stream.ts +++ b/packages/sdk-typescript/src/utils/Stream.ts @@ -26,12 +26,12 @@ export class Stream implements AsyncIterable { value: this.queue.shift()!, }); } - if (this.isDone) { - return Promise.resolve({ done: true, value: undefined }); - } if (this.hasError) { return Promise.reject(this.hasError); } + if (this.isDone) { + return Promise.resolve({ done: true, value: undefined }); + } return new Promise>((resolve, reject) => { this.readResolve = resolve; this.readReject = reject; From 9daf20f3c7a83c8ef070ca95d0596703c89bdb93 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Fri, 13 Feb 2026 22:05:38 +0800 Subject: [PATCH 41/81] refactor: rename session-id to sessionId for consistency in CLI argument handling --- packages/cli/src/config/config.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index aba726bdf..c31ffa216 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -151,7 +151,7 @@ export interface CliArgs { /** Resume a specific session by its ID */ resume: string | undefined; /** Specify a session ID without session resumption */ - 'session-id': string | undefined; + sessionId: string | undefined; maxSessionTurns: number | undefined; coreTools: string[] | undefined; excludeTools: string[] | undefined; @@ -554,14 +554,11 @@ export async function parseArguments(): Promise { if (argv['continue'] && argv['resume']) { return 'Cannot use both --continue and --resume together. Use --continue to resume the latest session, or --resume to resume a specific session.'; } - if (argv['session-id'] && (argv['continue'] || argv['resume'])) { + if (argv['sessionId'] && (argv['continue'] || argv['resume'])) { return 'Cannot use --session-id with --continue or --resume. Use --session-id to start a new session with a specific ID, or use --continue/--resume to resume an existing session.'; } - if ( - argv['session-id'] && - !isValidUUID(argv['session-id'] as string) - ) { - return `Invalid --session-id: "${argv['session-id']}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`; + if (argv['sessionId'] && !isValidUUID(argv['sessionId'] as string)) { + return `Invalid --session-id: "${argv['sessionId']}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`; } if (argv['resume'] && !isValidUUID(argv['resume'] as string)) { return `Invalid --resume: "${argv['resume']}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`; @@ -930,17 +927,17 @@ export async function loadCliConfig( process.exit(1); } } - } else if (argv['session-id']) { + } else if (argv['sessionId']) { // Use provided session ID without session resumption // Check if session ID is already in use const sessionService = new SessionService(cwd); - const exists = await sessionService.sessionExists(argv['session-id']); + const exists = await sessionService.sessionExists(argv['sessionId']); if (exists) { - const message = `Error: Session Id ${argv['session-id']} is already in use.`; + const message = `Error: Session Id ${argv['sessionId']} is already in use.`; writeStderrLine(message); process.exit(1); } - sessionId = argv['session-id']; + sessionId = argv['sessionId']; } const modelProvidersConfig = settings.modelProviders; From 32af4c7157ef98db2d340dffc84f6a1927321090 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Fri, 13 Feb 2026 22:12:00 +0800 Subject: [PATCH 42/81] fix: ts error --- packages/cli/src/gemini.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 51630bfc5..44c4c29d7 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -496,7 +496,7 @@ describe('gemini.tsx main function kitty protocol', () => { experimentalLsp: undefined, channel: undefined, chatRecording: undefined, - 'session-id': undefined, + sessionId: undefined, }); await main(); From 1789b16914a67d9ff26eb79fd74ae3bd103eb7ca Mon Sep 17 00:00:00 2001 From: echoVic Date: Sat, 14 Feb 2026 15:07:03 +0800 Subject: [PATCH 43/81] feat: support MCP readOnlyHint annotation in plan mode (#1826) MCP tools annotated with readOnlyHint: true are now allowed to execute in plan mode without being blocked. Previously, all MCP tools were assigned Kind.Other and required confirmation, causing plan mode to block even read-only MCP tools. Changes: - Add McpToolAnnotations interface to mcp-tool.ts - Fetch tool annotations via mcpClient.listTools() during discovery - Pass annotations through to DiscoveredMCPTool and its invocation - Set Kind.Read for tools with readOnlyHint: true (instead of Kind.Other) - Skip confirmation for readOnlyHint: true tools in shouldConfirmExecute Fixes #1826 --- packages/core/src/tools/mcp-client.ts | 19 +++++++++++++++++++ packages/core/src/tools/mcp-tool.ts | 24 +++++++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index 0e8fc9cce..9335cd706 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -29,6 +29,7 @@ import { AuthProviderType, isSdkMcpServerConfig } from '../config/config.js'; import { GoogleCredentialProvider } from '../mcp/google-auth-provider.js'; import { ServiceAccountImpersonationProvider } from '../mcp/sa-impersonation-provider.js'; import { DiscoveredMCPTool } from './mcp-tool.js'; +import type { McpToolAnnotations } from './mcp-tool.js'; import { SdkControlClientTransport } from './sdk-control-client-transport.js'; import type { FunctionDeclaration } from '@google/genai'; @@ -638,6 +639,23 @@ export async function discoverTools( return []; } + // Fetch raw tool list from MCP client to get annotations (readOnlyHint, etc.) + // that are not preserved by mcpToTool's functionDeclarations conversion. + const annotationsMap = new Map(); + try { + const listToolsResult = await mcpClient.listTools(); + for (const mcpTool of listToolsResult.tools) { + if (mcpTool.annotations) { + annotationsMap.set(mcpTool.name, mcpTool.annotations); + } + } + } catch { + // If listTools fails, proceed without annotations — non-critical + debugLogger.error( + `Failed to fetch tool annotations from MCP server '${mcpServerName}'`, + ); + } + const mcpTimeout = mcpServerConfig.timeout ?? MCP_DEFAULT_TIMEOUT_MSEC; const discoveredTools: DiscoveredMCPTool[] = []; for (const funcDecl of tool.functionDeclarations) { @@ -658,6 +676,7 @@ export async function discoverTools( cliConfig, mcpClient, // raw MCP Client for direct callTool with progress mcpTimeout, + annotationsMap.get(funcDecl.name!), ), ); } catch (error) { diff --git a/packages/core/src/tools/mcp-tool.ts b/packages/core/src/tools/mcp-tool.ts index c89cfe47a..4ba6c6893 100644 --- a/packages/core/src/tools/mcp-tool.ts +++ b/packages/core/src/tools/mcp-tool.ts @@ -94,6 +94,18 @@ type McpContentBlock = | McpResourceBlock | McpResourceLinkBlock; +/** + * MCP Tool Annotations as defined in the MCP specification. + * These provide hints about a tool's behavior to help clients make decisions + * about tool approval and safety. + */ +export interface McpToolAnnotations { + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; +} + class DiscoveredMCPToolInvocation extends BaseToolInvocation< ToolParams, ToolResult @@ -110,6 +122,7 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation< private readonly cliConfig?: Config, private readonly mcpClient?: McpDirectClient, private readonly mcpTimeout?: number, + private readonly annotations?: McpToolAnnotations, ) { super(params); } @@ -124,6 +137,12 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation< return false; // server is trusted, no confirmation needed } + // MCP tools annotated with readOnlyHint: true are safe to execute + // without confirmation, especially important for plan mode support + if (this.annotations?.readOnlyHint === true) { + return false; + } + if ( DiscoveredMCPToolInvocation.allowlist.has(serverAllowListKey) || DiscoveredMCPToolInvocation.allowlist.has(toolAllowListKey) @@ -341,13 +360,14 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< private readonly cliConfig?: Config, private readonly mcpClient?: McpDirectClient, private readonly mcpTimeout?: number, + private readonly annotations?: McpToolAnnotations, ) { super( nameOverride ?? generateValidName(`mcp__${serverName}__${serverToolName}`), `${serverToolName} (${serverName} MCP Server)`, description, - Kind.Other, + annotations?.readOnlyHint === true ? Kind.Read : Kind.Other, parameterSchema, true, // isOutputMarkdown true, // canUpdateOutput — enables streaming progress for MCP tools @@ -366,6 +386,7 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< this.cliConfig, this.mcpClient, this.mcpTimeout, + this.annotations, ); } @@ -382,6 +403,7 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< this.cliConfig, this.mcpClient, this.mcpTimeout, + this.annotations, ); } } From 997fcbfaed885db531dfcf3f0b2a24270e2782c0 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Sat, 14 Feb 2026 21:34:42 +0800 Subject: [PATCH 44/81] feat: add terminal-capture for CLI screenshot automation - Add terminal-capture engine using node-pty + xterm.js + Playwright - Add scenario runner with TypeScript configuration - Add pre-built scenarios (/about, /context, /export, /auth) - Add Cursor skills for terminal-capture and pr-review workflow - Add motivation documentation Co-authored-by: Qwen-Coder --- .gitignore | 4 +- .qwen/skills/pr-review/SKILL.md | 104 +++ .qwen/skills/terminal-capture/SKILL.md | 197 ++++ .../terminal-capture/motivation.md | 117 +++ .../terminal-capture/package.json | 18 + integration-tests/terminal-capture/run.ts | 105 +++ .../terminal-capture/scenario-runner.ts | 304 +++++++ .../terminal-capture/scenarios/about.ts | 8 + .../terminal-capture/scenarios/all.ts | 46 + .../terminal-capture/terminal-capture.ts | 856 ++++++++++++++++++ 10 files changed, 1758 insertions(+), 1 deletion(-) create mode 100644 .qwen/skills/pr-review/SKILL.md create mode 100644 .qwen/skills/terminal-capture/SKILL.md create mode 100644 integration-tests/terminal-capture/motivation.md create mode 100644 integration-tests/terminal-capture/package.json create mode 100644 integration-tests/terminal-capture/run.ts create mode 100644 integration-tests/terminal-capture/scenario-runner.ts create mode 100644 integration-tests/terminal-capture/scenarios/about.ts create mode 100644 integration-tests/terminal-capture/scenarios/all.ts create mode 100644 integration-tests/terminal-capture/terminal-capture.ts diff --git a/.gitignore b/.gitignore index a923e9bc1..0e7dc1528 100644 --- a/.gitignore +++ b/.gitignore @@ -51,7 +51,7 @@ packages/core/src/generated/ packages/vscode-ide-companion/*.vsix # Qwen Code Configs -.qwen/ + logs/ # GHA credentials gha-creds-*.json @@ -70,6 +70,8 @@ __pycache__/ integration-tests/concurrent-runner/output/ integration-tests/concurrent-runner/task-* +integration-tests/terminal-capture/scenarios/screenshots/ + # storybook *storybook.log storybook-static diff --git a/.qwen/skills/pr-review/SKILL.md b/.qwen/skills/pr-review/SKILL.md new file mode 100644 index 000000000..52bf75427 --- /dev/null +++ b/.qwen/skills/pr-review/SKILL.md @@ -0,0 +1,104 @@ +--- +name: pr-review +description: Reviews pull requests with code analysis and terminal smoke testing. Applies when examining code changes, running CLI tests, or when 'PR review', 'code review', 'terminal screenshot', 'visual test' is mentioned. +--- + +# PR Review — Code Review + Terminal Smoke Testing + +## Workflow + +### 1. Fetch PR Information + +```bash +# List open PRs +gh pr list + +# View PR details +gh pr view + +# Get diff +gh pr diff +``` + +### 2. Code Review + +Analyze changes across the following dimensions: + +- **Correctness** — Is the logic correct? Are edge cases handled? +- **Code Style** — Does it follow existing code style and conventions? +- **Performance** — Are there any performance concerns? +- **Test Coverage** — Are there corresponding tests for the changes? +- **Security** — Does it introduce any security risks? + +Output format: + +- 🔴 **Critical** — Must fix +- 🟡 **Suggestion** — Suggested improvement +- 🟢 **Nice to have** — Optional optimization + +### 3. Terminal Smoke Testing (Run for Every PR) + +**Run terminal-capture for every PR review**, not just UI changes. Reasons: + +- **Smoke Test** — Verify the CLI starts correctly and responds to user input, ensuring the PR didn't break anything +- **Visual Verification** — If there are UI changes, screenshots provide the most intuitive review evidence +- **Documentation** — Attach screenshots to the PR comments so reviewers can see the results without building locally + +```bash +# Checkout branch & build +gh pr checkout +npm run build +``` + +#### Scenario Selection Strategy + +Choose appropriate scenarios based on the PR's scope of changes: + +| PR Type | Recommended Scenarios | Description | +| ------------------------------------- | ------------------------------------------------------------ | --------------------------------- | +| **Any PR** (default) | smoke test: send `hi`, verify startup & response | Minimal-cost smoke validation | +| Slash command changes | Corresponding command scenarios (`/about`, `/context`, etc.) | Verify command output correctness | +| Ink component / layout changes | Multiple scenarios + full-flow long screenshot | Verify visual effects | +| Large refactors / dependency upgrades | Run `scenarios/all.ts` fully | Full regression | + +#### Running Screenshots + +```bash +# Write scenario config to integration-tests/terminal-capture/scenarios/ +# See terminal-capture skill for FlowStep API reference + +# Single scenario +npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/.ts + + +# Check output in screenshots/ directory +``` + +#### Minimal Smoke Test Example + +No need to write a new scenario file — just use the existing `about.ts`. It sends "hi" then runs `/about`, covering startup + input + command response: + +```bash +npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/about.ts +``` + +### 4. Upload Screenshots to PR + +Use Playwright MCP browser to upload screenshots to the PR comments (images hosted at `github.com/user-attachments/assets/`, zero side effects): + +1. Open the PR page with Playwright: `https://github.com//pull/` +2. Click the comment text box and enter a comment title (e.g., `## 📷 Terminal Smoke Test Screenshots`) +3. Click the "Paste, drop, or click to add files" button to trigger the file picker +4. Upload screenshot PNG files via `browser_file_upload` (can upload multiple one by one) +5. Wait for GitHub to process (about 2-3 seconds) — image links auto-insert into the comment box +6. Click the "Comment" button to submit + +> **Prerequisite**: Playwright MCP needs `--user-data-dir` configured to persist GitHub login session. First time use requires manually logging into GitHub in the Playwright browser. + +### 5. Submit Review + +Submit code review comments via `gh pr review`: + +```bash +gh pr review --comment --body "review content" +``` diff --git a/.qwen/skills/terminal-capture/SKILL.md b/.qwen/skills/terminal-capture/SKILL.md new file mode 100644 index 000000000..adf8fff13 --- /dev/null +++ b/.qwen/skills/terminal-capture/SKILL.md @@ -0,0 +1,197 @@ +--- +name: terminal-capture +description: Automates terminal UI screenshot testing for CLI commands. Applies when reviewing PRs that affect CLI output, testing slash commands (/about, /context, /auth, /export), generating visual documentation, or when 'terminal screenshot', 'CLI test', 'visual test', or 'terminal-capture' is mentioned. +--- + +# Terminal Capture — CLI Terminal Screenshot Automation + +Drive terminal interactions and screenshots via TypeScript configuration, used for visual verification during PR reviews. + +## Prerequisites + +Ensure the following dependencies are installed before running: + +```bash +npm install # Install project dependencies (including node-pty, xterm, playwright, etc.) +npx playwright install chromium # Install Playwright browser +``` + +## Architecture + +``` +node-pty (pseudo-terminal) → ANSI byte stream → xterm.js (Playwright headless) → Screenshot +``` + +Core files: + +| File | Purpose | +| -------------------------------------------------------- | ------------------------------------------------------------------------ | +| `integration-tests/terminal-capture/terminal-capture.ts` | Low-level engine (PTY + xterm.js + Playwright) | +| `integration-tests/terminal-capture/scenario-runner.ts` | Scenario executor (parses config, drives interactions, auto-screenshots) | +| `integration-tests/terminal-capture/run.ts` | CLI entry point (batch run scenarios) | +| `integration-tests/terminal-capture/scenarios/*.ts` | Scenario configuration files | + +## Quick Start + +### 1. Write Scenario Configuration + +Create a `.ts` file under `integration-tests/terminal-capture/scenarios/`: + +```typescript +import type { ScenarioConfig } from '../scenario-runner.js'; + +export default { + name: '/about', + spawn: ['node', 'dist/cli.js', '--yolo'], + terminal: { title: 'qwen-code', cwd: '../../..' }, // Relative to this config file's location + flow: [ + { type: 'Hi, can you help me understand this codebase?' }, + { type: '/about' }, + ], +} satisfies ScenarioConfig; +``` + +### 2. Run + +```bash +# Single scenario +npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/about.ts + +# Batch (entire directory) +npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/ +``` + +### 3. Output + +Screenshots are saved to `integration-tests/terminal-capture/scenarios/screenshots/{name}/`: + +| File | Description | +| --------------- | ---------------------------------- | +| `01-01.png` | Step 1 input state | +| `01-02.png` | Step 1 execution result | +| `02-01.png` | Step 2 input state | +| `02-02.png` | Step 2 execution result | +| `full-flow.png` | Final state full-length screenshot | + +## FlowStep API + +Each flow step can contain the following fields: + +### `type: string` — Input Text + +Automatic behavior: Input text → Screenshot (01) → Press Enter → Wait for output to stabilize → Screenshot (02). + +```typescript +{ + type: 'Hello'; +} // Plain text +{ + type: '/about'; +} // Slash command (auto-completion handled automatically) +``` + +**Special rule**: If the next step is `key`, do not auto-press Enter (hand over control to the key sequence). + +### `key: string | string[]` — Send Key Press + +Used for menu selection, Tab completion, and other interactions. Does not auto-press Enter or auto-screenshot. + +Supported key names: `ArrowUp`, `ArrowDown`, `ArrowLeft`, `ArrowRight`, `Enter`, `Tab`, `Escape`, `Backspace`, `Space`, `Home`, `End`, `PageUp`, `PageDown`, `Delete` + +```typescript +{ + key: 'ArrowDown'; +} // Single key +{ + key: ['ArrowDown', 'ArrowDown', 'Enter']; +} // Multiple keys +``` + +Auto-screenshot is triggered after the key sequence ends (when the next step is not a `key`). + +### `capture` / `captureFull` — Explicit Screenshot + +Use as a standalone step, or override automatic naming: + +```typescript +{ + capture: 'initial.png'; +} // Screenshot current viewport only +{ + captureFull: 'all-output.png'; +} // Screenshot full scrollback buffer +``` + +## Scenario Examples + +### Basic: Input + Command + +```typescript +flow: [{ type: 'explain this project' }, { type: '/about' }]; +``` + +### Secondary Menu Selection (/auth) + +```typescript +flow: [ + { type: '/auth' }, + { key: 'ArrowDown' }, // Select API Key option + { key: 'Enter' }, // Confirm + { type: 'sk-xxx' }, // Input API key +]; +``` + +### Tab Completion Selection (/export) + +```typescript +flow: [ + { type: 'Tell me about yourself' }, + { type: '/export' }, // No auto-Enter (next step is key) + { key: 'Tab' }, // Pop format selection + { key: 'ArrowDown' }, // Select format + { key: 'Enter' }, // Confirm → auto-screenshot +]; +``` + +### Array Batch (Multiple Scenarios in One File) + +```typescript +export default [ + { name: '/about', spawn: [...], flow: [...] }, + { name: '/context', spawn: [...], flow: [...] }, +] satisfies ScenarioConfig[]; +``` + +## Integration with PR Review + +This tool is commonly used for visual verification during PR reviews. For the complete code review + screenshot workflow, see the [pr-review](../pr-review/SKILL.md) skill. + +## Troubleshooting + +| Issue | Cause | Solution | +| ------------------------------------ | ------------------------------------- | ---------------------------------------------------- | +| Playwright error `browser not found` | Browser not installed | `npx playwright install chromium` | +| Blank screenshot | Process starts slowly or build failed | Ensure `npm run build` succeeds, check spawn command | +| PTY-related errors | node-pty native module not compiled | `npm rebuild node-pty` | +| Unstable screenshot output | Terminal output not fully rendered | Check if the scenario needs additional wait time | + +## Full ScenarioConfig Type + +```typescript +interface ScenarioConfig { + name: string; // Scenario name (also used as screenshot subdirectory name) + spawn: string[]; // Launch command ["node", "dist/cli.js", "--yolo"] + flow: FlowStep[]; // Interaction steps + terminal?: { + // Terminal configuration (all optional) + cols?: number; // Number of columns, default 100 + rows?: number; // Number of rows, default 28 + theme?: string; // Theme: dracula|one-dark|github-dark|monokai|night-owl + chrome?: boolean; // macOS window decorations, default true + title?: string; // Window title, default "Terminal" + fontSize?: number; // Font size + cwd?: string; // Working directory (relative to config file) + }; + outputDir?: string; // Screenshot output directory (relative to config file) +} +``` diff --git a/integration-tests/terminal-capture/motivation.md b/integration-tests/terminal-capture/motivation.md new file mode 100644 index 000000000..388019369 --- /dev/null +++ b/integration-tests/terminal-capture/motivation.md @@ -0,0 +1,117 @@ +# terminal-capture — Motivation and Positioning + +## 1. Overview of Existing Testing System + +| Layer | Tools | Coverage | Status | +| ---------------------- | ----------------------------------------- | --------------------------------------- | --------------------------------------------------------- | +| Unit Tests | Vitest + ink-testing-library | Ink components, Core logic, utilities | Mature, extensive `.test.ts` / `.test.tsx` | +| Integration Tests | Vitest + TestRig / SDKTestHelper | CLI E2E, SDK multi-turn, MCP, auth | Mature, supports none/docker/podman sandboxes | +| Terminal UI Snapshots | `toMatchSnapshot()` + ink-testing-library | Ink component render output (ANSI) | Exists, covers Footer, InputPrompt, MarkdownDisplay, etc. | +| Web UI Regression | Chromatic + Storybook | `packages/webui` components | Exists, but only covers Web UI | +| **Terminal UI Visual** | **terminal-capture** | CLI terminal real rendering screenshots | ✅ Implemented | + +## 2. Problems Solved by terminal-capture + +### Limitations of Existing Ink Text Snapshots + +The project uses `toMatchSnapshot()` to compare Ink component ANSI text output, which validates **text content**, but cannot verify: + +- Whether colors are correct (red separators? green highlights? Logo gradients?) +- Whether layout is aligned (table borders? multi-column layout?) +- Overall visual feel (component spacing? blank areas? overflow?) + +These can only be seen by **actually rendering to a terminal emulator**. + +### Core Architecture + +``` +node-pty (pseudo-terminal) + ↓ raw ANSI byte stream +xterm.js (running inside Playwright headless Chromium) + ↓ perfect rendering: colors, bold, cursor, scrolling +Playwright element screenshot + ↓ pixel-perfect screenshots (optional macOS window decorations) +``` + +### Core Features + +| Feature | Description | +| -------------------- | ----------------------------------------------------------------------------------- | +| WYSIWYG | xterm.js fully renders ANSI, no manual output cleaning needed | +| Theme Support | Built-in 5 themes (Dracula, One Dark, GitHub Dark, Monokai, Night Owl) | +| Full-length | `captureFull()` supports capturing scrollback buffer content | +| Deterministic Naming | Screenshot filenames auto-generated by step sequence for easy regression comparison | +| Batch Execution | `run.ts` executes all scenarios in one command | + +## 3. Usage + +### TypeScript Configuration-Driven + +Scenario config files (`scenarios/*.ts`) only need to declare `type` (input) and `key` (keypress), Runner handles automatically: + +- Wait for CLI readiness +- Auto-complete interference handling (/ commands auto-send Escape) +- Auto-screenshot before/after input (01 = input state, 02 = result) +- Auto-capture full-length image at last step (full-flow.png) +- Special key interactions (Arrow keys / Tab / Enter, etc.) + +```typescript +// integration-tests/terminal-capture/scenarios/about.ts +import type { ScenarioConfig } from '../scenario-runner.js'; + +export default { + name: '/about', + spawn: ['node', 'dist/cli.js', '--yolo'], + terminal: { title: 'qwen-code', cwd: '../../..' }, + flow: [ + { type: 'Hi, can you help me understand this codebase?' }, + { type: '/about' }, + ], +} satisfies ScenarioConfig; +``` + +### Running + +```bash +# From project root +npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/ + +# Or inside terminal-capture directory +npm run capture +``` + +### Screenshot Output + +``` +scenarios/screenshots/ + about/ + 01-01.png # Step 1 input state + 01-02.png # Step 1 result + 02-01.png # Step 2 input state + 02-02.png # Step 2 result + full-flow.png # Final state full-length image + context/ + ... +``` + +## 4. Position in Testing System + +``` +┌─────────────────────────────────────┐ +│ Existing Testing System │ +├─────────────────────────────────────┤ +│ Unit Tests (Vitest) │ ← Function/Component level +│ Text Snapshots (ink-testing-lib) │ ← ANSI string comparison +│ Integration Tests (TestRig/SDK) │ ← E2E functionality +│ Web UI Regression (Chromatic) │ ← Only covers webui +├─────────────────────────────────────┤ +│ terminal-capture │ ← Terminal UI visual layer +│ (xterm.js + Playwright) │ Fills the gap +└─────────────────────────────────────┘ +``` + +## 5. Future Directions + +1. **Visual Regression** — Integrate Playwright `toHaveScreenshot()` for pixel-level baseline comparison, CI auto-detects terminal UI changes +2. **PR Workflow Integration** — Drive Agent via Cursor Skill to auto-checkout branch → build → screenshot → attach to review comment +3. **Complement to Chromatic** — Chromatic covers Web UI, terminal-capture covers CLI terminal UI diff --git a/integration-tests/terminal-capture/package.json b/integration-tests/terminal-capture/package.json new file mode 100644 index 000000000..ef55e63ef --- /dev/null +++ b/integration-tests/terminal-capture/package.json @@ -0,0 +1,18 @@ +{ + "name": "@qwen-code/terminal-capture", + "version": "0.1.0", + "private": true, + "description": "Terminal UI screenshot automation for CLI visual testing", + "type": "module", + "scripts": { + "capture": "npx tsx run.ts scenarios/", + "capture:about": "npx tsx run.ts scenarios/about.ts", + "capture:all": "npx tsx run.ts scenarios/all.ts" + }, + "dependencies": { + "@lydell/node-pty": "1.1.0", + "@xterm/xterm": "^5.5.0", + "playwright": "^1.50.0", + "strip-ansi": "^7.1.2" + } +} diff --git a/integration-tests/terminal-capture/run.ts b/integration-tests/terminal-capture/run.ts new file mode 100644 index 000000000..59b9ab547 --- /dev/null +++ b/integration-tests/terminal-capture/run.ts @@ -0,0 +1,105 @@ +#!/usr/bin/env npx tsx +/** + * Batch run terminal screenshot scenarios + * + * Usage: + * npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/about.ts + * npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/ # batch + * npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/*.ts # glob + */ + +import { + loadScenarios, + runScenario, + type RunResult, +} from './scenario-runner.js'; +import { readdirSync, statSync } from 'node:fs'; +import { resolve, extname, join } from 'node:path'; + +async function main() { + const args = process.argv.slice(2); + + if (args.length === 0) { + console.log( + ` +Usage: npx tsx integration-tests/terminal-capture/run.ts ... + +Examples: + npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/about.ts + npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/ + `.trim(), + ); + process.exit(1); + } + + // Collect all .ts scenario files from arguments + const scenarioFiles: string[] = []; + for (const arg of args) { + const abs = resolve(arg); + try { + const stat = statSync(abs); + if (stat.isDirectory()) { + const files = readdirSync(abs) + .filter((f) => extname(f) === '.ts') + .sort() + .map((f) => join(abs, f)); + scenarioFiles.push(...files); + } else { + scenarioFiles.push(abs); + } + } catch { + console.error(`❌ Not found: ${arg}`); + process.exit(1); + } + } + + if (scenarioFiles.length === 0) { + console.error('❌ No .ts scenario files found'); + process.exit(1); + } + + console.log(`🎬 Running ${scenarioFiles.length} scenario(s)...\n`); + + // Run scenarios sequentially (single file can export an array) + const results: RunResult[] = []; + for (const file of scenarioFiles) { + const { configs, basedir } = await loadScenarios(file); + for (const config of configs) { + const result = await runScenario(config, basedir); + results.push(result); + } + } + + // Summary + console.log(`\n${'═'.repeat(60)}`); + console.log('📊 Summary'); + console.log('═'.repeat(60)); + + const passed = results.filter((r) => r.success); + const failed = results.filter((r) => !r.success); + const totalScreenshots = results.reduce( + (sum, r) => sum + r.screenshots.length, + 0, + ); + const totalTime = results.reduce((sum, r) => sum + r.durationMs, 0); + + for (const r of results) { + const icon = r.success ? '✅' : '❌'; + const time = (r.durationMs / 1000).toFixed(1); + console.log( + ` ${icon} ${r.name} — ${r.screenshots.length} screenshots, ${time}s`, + ); + if (r.error) console.log(` ${r.error}`); + } + + console.log( + `\n Total: ${passed.length} passed, ${failed.length} failed, ${totalScreenshots} screenshots, ${(totalTime / 1000).toFixed(1)}s`, + ); + + if (failed.length > 0) process.exit(1); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/integration-tests/terminal-capture/scenario-runner.ts b/integration-tests/terminal-capture/scenario-runner.ts new file mode 100644 index 000000000..4bd858fd4 --- /dev/null +++ b/integration-tests/terminal-capture/scenario-runner.ts @@ -0,0 +1,304 @@ +/** + * Scenario Runner v3 — TypeScript Configuration-Driven Terminal Screenshots + * + * Configuration has only two core concepts: type (input) and capture (screenshot). + * All intelligent waiting is handled automatically by the Runner. + * + * Usage: + * npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/about.ts + * npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/ + */ + +import { TerminalCapture, THEMES } from './terminal-capture.js'; +import { dirname, resolve, isAbsolute } from 'node:path'; + +// ───────────────────────────────────────────── +// Schema — Minimal +// ───────────────────────────────────────────── + +export interface FlowStep { + /** Input text (auto-press Enter, auto-wait for output to stabilize, auto-screenshot before/after) */ + type?: string; + /** + * Send special key presses (no auto-Enter, no auto-screenshot) + * Supported: ArrowUp, ArrowDown, ArrowLeft, ArrowRight, Enter, Tab, Escape, Backspace, Space + * Can also pass ANSI escape sequence strings + */ + key?: string | string[]; + /** Explicit screenshot: current viewport (standalone capture when no type) */ + capture?: string; + /** Explicit screenshot: full scrollback buffer long image (standalone capture when no type) */ + captureFull?: string; +} + +export interface ScenarioConfig { + /** Scenario name */ + name: string; + /** Launch command, e.g., ["node", "dist/cli.js", "--yolo"] */ + spawn: string[]; + /** Execution flow: array, each item can contain type / capture / captureFull */ + flow: FlowStep[]; + /** Terminal configuration (all optional) */ + terminal?: { + cols?: number; + rows?: number; + theme?: string; + chrome?: boolean; + title?: string; + fontSize?: number; + cwd?: string; + }; + /** Screenshot output directory (relative to config file) */ + outputDir?: string; +} + +// ───────────────────────────────────────────── +// Runner +// ───────────────────────────────────────────── + +export interface RunResult { + name: string; + screenshots: string[]; + success: boolean; + error?: string; + durationMs: number; +} + +/** Dynamically load configuration from .ts file (supports single object or array) */ +export async function loadScenarios( + tsPath: string, +): Promise<{ configs: ScenarioConfig[]; basedir: string }> { + const absPath = isAbsolute(tsPath) ? tsPath : resolve(tsPath); + const mod = (await import(absPath)) as { + default: ScenarioConfig | ScenarioConfig[]; + }; + const raw = mod.default; + const configs = Array.isArray(raw) ? raw : [raw]; + + for (const config of configs) { + if (!config?.name) throw new Error(`Missing 'name': ${absPath}`); + if (!config.spawn?.length) throw new Error(`Missing 'spawn': ${absPath}`); + if (!config.flow?.length) throw new Error(`Missing 'flow': ${absPath}`); + } + + return { configs, basedir: dirname(absPath) }; +} + +/** Execute a single scenario */ +export async function runScenario( + config: ScenarioConfig, + basedir: string, +): Promise { + const startTime = Date.now(); + const screenshots: string[] = []; + const t = config.terminal ?? {}; + + const cwd = t.cwd ? resolve(basedir, t.cwd) : resolve(basedir, '..'); + // Use scenario name as subdirectory to isolate screenshot outputs from different scenarios + const scenarioDir = + config.name + .replace(/^\//, '') + .replace(/[^a-zA-Z0-9\u4e00-\u9fff_-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') || 'unnamed'; + const outputDir = config.outputDir + ? resolve(basedir, config.outputDir, scenarioDir) + : resolve(basedir, 'screenshots', scenarioDir); + + console.log(`\n${'═'.repeat(60)}`); + console.log(`▶ ${config.name}`); + console.log('═'.repeat(60)); + + const terminal = await TerminalCapture.create({ + cols: t.cols ?? 100, + rows: t.rows ?? 28, + theme: (t.theme ?? 'dracula') as keyof typeof THEMES, + chrome: t.chrome ?? true, + title: t.title ?? 'Terminal', + fontSize: t.fontSize, + cwd, + outputDir, + }); + + try { + // ── Spawn ── + const [command, ...args] = config.spawn; + console.log(` spawn: ${config.spawn.join(' ')}`); + await terminal.spawn(command, args); + + // ── Auto-wait for CLI readiness ── + console.log(' ⏳ waiting for ready...'); + await terminal.idle(1500, 30000); + console.log(' ✅ ready'); + + // ── Execute flow ── + let seq = 0; // Global screenshot sequence number + + for (let i = 0; i < config.flow.length; i++) { + const step = config.flow[i]; + const label = `[${i + 1}/${config.flow.length}]`; + + if (step.type) { + const display = + step.type.length > 60 ? step.type.slice(0, 60) + '...' : step.type; + + // If next step is key, there's more interaction to do, so don't auto-press Enter + const nextStep = config.flow[i + 1]; + const autoEnter = !nextStep?.key; + + console.log( + ` ${label} type: "${display}"${autoEnter ? '' : ' (no auto-enter)'}`, + ); + + const text = step.type.replace(/\n$/, ''); + await terminal.type(text); + await sleep(300); + + // Only send Escape for / commands to close auto-complete, not for regular text + if (text.startsWith('/') && autoEnter) { + await terminal.type('\x1b'); + await sleep(100); + } + + // ── 01: Text input complete ── + seq++; + const inputName = step.capture + ? step.capture.replace(/\.png$/, '-01.png') + : `${pad(seq)}-01.png`; + console.log(` ${label} 📸 input: ${inputName}`); + screenshots.push(await terminal.capture(inputName)); + + if (autoEnter) { + // ── Auto-press Enter → Wait for stabilization → 02 screenshot ── + await terminal.type('\n'); + console.log(` ⏳ waiting for output to settle...`); + await terminal.idle(2000, 60000); + console.log(` ✅ settled`); + + const resultName = step.capture ?? `${pad(seq)}-02.png`; + console.log(` ${label} 📸 result: ${resultName}`); + screenshots.push(await terminal.capture(resultName)); + + // full-flow: Only the last type step auto-captures full-length image + const isLastType = !config.flow.slice(i + 1).some((s) => s.type); + if (isLastType || step.captureFull) { + const fullName = step.captureFull ?? 'full-flow.png'; + console.log(` ${label} 📸 full: ${fullName}`); + screenshots.push(await terminal.captureFull(fullName)); + } + } + // When not autoEnter, only captured before state, subsequent key steps take over interaction + } else if (step.key) { + // ── key: Send special key presses (arrow keys, Tab, Enter, etc.) ── + const keys = Array.isArray(step.key) ? step.key : [step.key]; + console.log(` ${label} key: ${keys.join(', ')}`); + + for (const k of keys) { + await terminal.type(resolveKey(k)); + await sleep(150); + } + // Wait for UI response to key press + await terminal.idle(500, 5000); + + // If key step has explicit capture/captureFull + if (step.capture || step.captureFull) { + seq++; + if (step.capture) { + console.log(` ${label} 📸 capture: ${step.capture}`); + screenshots.push(await terminal.capture(step.capture)); + } + if (step.captureFull) { + console.log(` ${label} 📸 captureFull: ${step.captureFull}`); + screenshots.push(await terminal.captureFull(step.captureFull)); + } + } + + // After key sequence ends (next step is not key), auto-add result + full screenshots + const nextStep = config.flow[i + 1]; + if (!nextStep?.key) { + console.log(` ⏳ waiting for output to settle...`); + await terminal.idle(2000, 60000); + console.log(` ✅ settled`); + + const resultName = `${pad(seq)}-02.png`; + console.log(` ${label} 📸 result: ${resultName}`); + screenshots.push(await terminal.capture(resultName)); + + // If this is the last interaction step, add full-length image + const isLastType = !config.flow.slice(i + 1).some((s) => s.type); + if (isLastType) { + console.log(` ${label} 📸 full: full-flow.png`); + screenshots.push(await terminal.captureFull('full-flow.png')); + } + } + } else { + // ── Standalone screenshot step (no type/key) ── + seq++; + if (step.capture) { + console.log(` ${label} 📸 capture: ${step.capture}`); + screenshots.push(await terminal.capture(step.capture)); + } + if (step.captureFull) { + console.log(` ${label} 📸 captureFull: ${step.captureFull}`); + screenshots.push(await terminal.captureFull(step.captureFull)); + } + } + } + + const duration = Date.now() - startTime; + console.log( + `\n ✅ ${config.name} — ${screenshots.length} screenshots, ${(duration / 1000).toFixed(1)}s`, + ); + return { + name: config.name, + screenshots, + success: true, + durationMs: duration, + }; + } catch (err) { + const duration = Date.now() - startTime; + const msg = err instanceof Error ? err.message : String(err); + console.error(`\n ❌ ${config.name} — ${msg}`); + return { + name: config.name, + screenshots, + success: false, + error: msg, + durationMs: duration, + }; + } finally { + await terminal.close(); + } +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +/** Pad sequence number with zero: 1 → "01" */ +function pad(n: number): string { + return String(n).padStart(2, '0'); +} + +/** Key name → PTY escape sequence */ +const KEY_MAP: Record = { + ArrowUp: '\x1b[A', + ArrowDown: '\x1b[B', + ArrowRight: '\x1b[C', + ArrowLeft: '\x1b[D', + Enter: '\r', + Tab: '\t', + Escape: '\x1b', + Backspace: '\x7f', + Space: ' ', + Home: '\x1b[H', + End: '\x1b[F', + PageUp: '\x1b[5~', + PageDown: '\x1b[6~', + Delete: '\x1b[3~', +}; + +/** Parse key name to PTY-recognizable character sequence */ +function resolveKey(key: string): string { + return KEY_MAP[key] ?? key; +} diff --git a/integration-tests/terminal-capture/scenarios/about.ts b/integration-tests/terminal-capture/scenarios/about.ts new file mode 100644 index 000000000..6aae802ff --- /dev/null +++ b/integration-tests/terminal-capture/scenarios/about.ts @@ -0,0 +1,8 @@ +import type { ScenarioConfig } from '../scenario-runner.js'; + +export default { + name: '/about command', + spawn: ['node', 'dist/cli.js', '--yolo'], + terminal: { title: 'qwen-code', cwd: '../../..' }, + flow: [{ type: 'hi' }, { type: '/about' }], +} satisfies ScenarioConfig; diff --git a/integration-tests/terminal-capture/scenarios/all.ts b/integration-tests/terminal-capture/scenarios/all.ts new file mode 100644 index 000000000..a8bb8db81 --- /dev/null +++ b/integration-tests/terminal-capture/scenarios/all.ts @@ -0,0 +1,46 @@ +import type { ScenarioConfig } from '../scenario-runner.js'; + +export default [ + { + name: '/about', + spawn: ['node', 'dist/cli.js', '--yolo'], + terminal: { title: 'qwen-code', cwd: '../../..' }, + flow: [ + { type: 'Hi, can you help me understand this codebase?' }, + { type: '/about' }, + ], + }, + { + name: '/context', + spawn: ['node', 'dist/cli.js', '--yolo'], + terminal: { title: 'qwen-code', cwd: '../../..' }, + flow: [ + { type: 'How do you understand this project?' }, + { type: '/context' }, + ], + }, + + { + name: '/export (tab select)', + spawn: ['node', 'dist/cli.js', '--yolo'], + terminal: { title: 'qwen-code', cwd: '../../..' }, + flow: [ + { type: 'Please give me a brief introduction about yourself.' }, + { type: '/export' }, + { key: 'Tab' }, // Tab to open format selection + { key: 'ArrowDown' }, // Down arrow to switch options + { key: 'Enter' }, // Confirm selection + ], + }, + { + name: '/auth', + spawn: ['node', 'dist/cli.js', '--yolo'], + terminal: { title: 'qwen-code', cwd: '../../..' }, + flow: [ + { type: '/auth' }, + { key: 'ArrowDown' }, // Select API Key + { key: 'Enter' }, // Confirm + { type: 'sk-test-key-123' }, + ], + }, +] satisfies ScenarioConfig[]; diff --git a/integration-tests/terminal-capture/terminal-capture.ts b/integration-tests/terminal-capture/terminal-capture.ts new file mode 100644 index 000000000..1a2f27d63 --- /dev/null +++ b/integration-tests/terminal-capture/terminal-capture.ts @@ -0,0 +1,856 @@ +/** + * TerminalCapture - Terminal Screenshot Tool + * + * Terminal screenshot solution based on xterm.js + Playwright + node-pty. + * Core philosophy: WYSIWYG — let xterm.js complete terminal simulation and rendering + * inside the browser. Screenshots always capture the terminal's current real state, + * no manual output cleaning needed. + * + * Architecture: + * node-pty (pseudo-terminal) + * ↓ raw ANSI byte stream + * xterm.js (running inside Playwright headless Chromium) + * ↓ perfect rendering: colors, bold, cursor, scrolling + * Playwright element screenshot + * ↓ pixel-perfect screenshots (optional macOS window decorations) + */ + +import { chromium, type Browser, type Page } from 'playwright'; +import * as pty from '@lydell/node-pty'; +import stripAnsi from 'strip-ansi'; +import { mkdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { createRequire } from 'node:module'; + +const _require = createRequire(import.meta.url); + +// ───────────────────────────────────────────── +// Theme definitions +// ───────────────────────────────────────────── + +export interface XtermTheme { + background: string; + foreground: string; + cursor: string; + cursorAccent?: string; + selectionBackground?: string; + selectionForeground?: string; + black: string; + red: string; + green: string; + yellow: string; + blue: string; + magenta: string; + cyan: string; + white: string; + brightBlack: string; + brightRed: string; + brightGreen: string; + brightYellow: string; + brightBlue: string; + brightMagenta: string; + brightCyan: string; + brightWhite: string; +} + +export const THEMES: Record = { + dracula: { + background: '#282a36', + foreground: '#f8f8f2', + cursor: '#f8f8f2', + selectionBackground: '#44475a', + black: '#21222c', + red: '#ff5555', + green: '#50fa7b', + yellow: '#f1fa8c', + blue: '#bd93f9', + magenta: '#ff79c6', + cyan: '#8be9fd', + white: '#f8f8f2', + brightBlack: '#6272a4', + brightRed: '#ff6e6e', + brightGreen: '#69ff94', + brightYellow: '#ffffa5', + brightBlue: '#d6acff', + brightMagenta: '#ff92df', + brightCyan: '#a4ffff', + brightWhite: '#ffffff', + }, + + 'one-dark': { + background: '#282c34', + foreground: '#abb2bf', + cursor: '#528bff', + selectionBackground: '#3e4451', + black: '#545862', + red: '#e06c75', + green: '#98c379', + yellow: '#e5c07b', + blue: '#61afef', + magenta: '#c678dd', + cyan: '#56b6c2', + white: '#abb2bf', + brightBlack: '#545862', + brightRed: '#e06c75', + brightGreen: '#98c379', + brightYellow: '#e5c07b', + brightBlue: '#61afef', + brightMagenta: '#c678dd', + brightCyan: '#56b6c2', + brightWhite: '#c8ccd4', + }, + + 'github-dark': { + background: '#0d1117', + foreground: '#c9d1d9', + cursor: '#c9d1d9', + selectionBackground: '#264f78', + black: '#484f58', + red: '#ff7b72', + green: '#3fb950', + yellow: '#d29922', + blue: '#58a6ff', + magenta: '#bc8cff', + cyan: '#39c5cf', + white: '#b1bac4', + brightBlack: '#6e7681', + brightRed: '#ffa198', + brightGreen: '#56d364', + brightYellow: '#e3b341', + brightBlue: '#79c0ff', + brightMagenta: '#d2a8ff', + brightCyan: '#56d4dd', + brightWhite: '#f0f6fc', + }, + + monokai: { + background: '#272822', + foreground: '#f8f8f2', + cursor: '#f8f8f0', + selectionBackground: '#49483e', + black: '#272822', + red: '#f92672', + green: '#a6e22e', + yellow: '#f4bf75', + blue: '#66d9ef', + magenta: '#ae81ff', + cyan: '#a1efe4', + white: '#f8f8f2', + brightBlack: '#75715e', + brightRed: '#f92672', + brightGreen: '#a6e22e', + brightYellow: '#f4bf75', + brightBlue: '#66d9ef', + brightMagenta: '#ae81ff', + brightCyan: '#a1efe4', + brightWhite: '#f9f8f5', + }, + + 'night-owl': { + background: '#011627', + foreground: '#d6deeb', + cursor: '#80a4c2', + selectionBackground: '#1d3b53', + black: '#011627', + red: '#ef5350', + green: '#22da6e', + yellow: '#addb67', + blue: '#82aaff', + magenta: '#c792ea', + cyan: '#21c7a8', + white: '#d6deeb', + brightBlack: '#575656', + brightRed: '#ef5350', + brightGreen: '#22da6e', + brightYellow: '#ffeb95', + brightBlue: '#82aaff', + brightMagenta: '#c792ea', + brightCyan: '#7fdbca', + brightWhite: '#ffffff', + }, +}; + +// ───────────────────────────────────────────── +// Options +// ───────────────────────────────────────────── + +export interface TerminalCaptureOptions { + /** Number of terminal columns, default 120 */ + cols?: number; + /** Number of terminal rows, default 40 */ + rows?: number; + /** Working directory */ + cwd?: string; + /** Environment variables */ + env?: NodeJS.ProcessEnv; + /** Theme name or custom theme object, default 'dracula' */ + theme?: keyof typeof THEMES | XtermTheme; + /** Whether to show macOS window decorations (traffic lights + title bar), default true */ + chrome?: boolean; + /** Window title (only effective when chrome=true), default 'Terminal' */ + title?: string; + /** Font size, default 14 */ + fontSize?: number; + /** Font family, default system monospace font */ + fontFamily?: string; + /** Default screenshot output directory */ + outputDir?: string; +} + +// ───────────────────────────────────────────── +// Main class +// ───────────────────────────────────────────── + +export class TerminalCapture { + private browser: Browser | null = null; + private page: Page | null = null; + private ptyProcess: pty.IPty | null = null; + private rawOutput = ''; + private lastFlushedLength = 0; + + private readonly cols: number; + private readonly rows: number; + private readonly cwd: string; + private readonly env: NodeJS.ProcessEnv; + private readonly theme: XtermTheme; + private readonly showChrome: boolean; + private readonly windowTitle: string; + private readonly fontSize: number; + private readonly fontFamily: string; + private readonly outputDir: string; + + // ── Factory ────────────────────────────── + + /** + * Create and initialize a TerminalCapture instance + * + * @example + * ```ts + * const t = await TerminalCapture.create({ + * theme: 'dracula', + * chrome: true, + * title: 'qwen-code', + * }); + * ``` + */ + static async create( + options?: TerminalCaptureOptions, + ): Promise { + const instance = new TerminalCapture(options); + await instance.init(); + return instance; + } + + private constructor(options?: TerminalCaptureOptions) { + this.cols = options?.cols ?? 120; + this.rows = options?.rows ?? 40; + this.cwd = options?.cwd ?? process.cwd(); + // Build a clean env for optimal terminal rendering: + // - Remove NO_COLOR (conflicts with FORCE_COLOR, can crash gradient components) + // - Suppress Node.js warnings (noisy in screenshots) + // - Force color output and 256-color terminal + const baseEnv = { ...process.env }; + delete baseEnv['NO_COLOR']; + this.env = options?.env ?? { + ...baseEnv, + FORCE_COLOR: '1', + TERM: 'xterm-256color', + NODE_NO_WARNINGS: '1', + }; + this.showChrome = options?.chrome ?? true; + this.windowTitle = options?.title ?? 'Terminal'; + this.fontSize = options?.fontSize ?? 14; + this.fontFamily = + options?.fontFamily ?? + "'Menlo', 'Monaco', 'Consolas', 'Courier New', monospace"; + this.outputDir = options?.outputDir ?? join(process.cwd(), 'screenshots'); + + // Resolve theme + if (typeof options?.theme === 'string') { + this.theme = THEMES[options.theme] ?? THEMES['dracula']; + } else if (options?.theme && typeof options.theme === 'object') { + this.theme = options.theme; + } else { + this.theme = THEMES['dracula']; + } + } + + // ── Lifecycle ──────────────────────────── + + private async init(): Promise { + // 1. Launch browser + this.browser = await chromium.launch({ headless: true }); + this.page = await this.browser.newPage({ + viewport: { width: 1600, height: 1000 }, + }); + + // 2. Set base HTML (with chrome decoration, container, etc.) + await this.page.setContent(this.buildHTML()); + + // 3. Load xterm.js from node_modules + const xtermDir = this.resolveXtermDir(); + await this.page.addStyleTag({ path: join(xtermDir, 'css', 'xterm.css') }); + await this.page.addScriptTag({ path: join(xtermDir, 'lib', 'xterm.js') }); + + // 4. Create xterm Terminal instance inside the page + + await this.page.evaluate( + ({ cols, rows, theme, fontSize, fontFamily }) => { + const W = window as unknown as Record; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const Terminal = W['Terminal'] as new (opts: unknown) => any; + const term = new Terminal({ + cols, + rows, + theme, + fontFamily, + fontSize, + lineHeight: 1.2, + cursorBlink: false, + allowProposedApi: true, + scrollback: 1000, + }); + + const container = document.getElementById('xterm-container')!; + + term.open(container); + + // Expose to outer scope + W['term'] = term; + W['termReady'] = true; + }, + { + cols: this.cols, + rows: this.rows, + theme: this.theme as unknown as Record, + fontSize: this.fontSize, + fontFamily: this.fontFamily, + }, + ); + + // 5. Wait until terminal is ready + await this.page.waitForFunction( + () => + (window as unknown as Record)['termReady'] === true, + ); + } + + /** + * Spawn a command (via pseudo-terminal) + * + * @example + * ```ts + * await terminal.spawn('node', ['dist/cli.js', '--yolo']); + * ``` + */ + async spawn(command: string, args: string[] = []): Promise { + if (!this.page) { + throw new Error( + 'Not initialized. Use TerminalCapture.create() factory method.', + ); + } + + this.ptyProcess = pty.spawn(command, args, { + name: 'xterm-256color', + cols: this.cols, + rows: this.rows, + cwd: this.cwd, + env: this.env, + }); + + this.ptyProcess.onData((data) => { + this.rawOutput += data; + }); + } + + // ── Input ──────────────────────────────── + + /** + * Input text. Supports `\n` as Enter. + * + * @param text Text to input + * @param options.delay Delay after input (ms), default 10 + * @param options.slow Type character by character (simulate real typing), default false + * + * @example + * ```ts + * await terminal.type('Hello world\n'); // Input + Enter + * await terminal.type('ls -la\n', { slow: true, delay: 80 }); + * ``` + */ + async type( + text: string, + options?: { delay?: number; slow?: boolean }, + ): Promise { + if (!this.ptyProcess) { + throw new Error('No process running. Call spawn() first.'); + } + + // Convert \n to \r for PTY + const translated = text.replace(/\n/g, '\r'); + + if (options?.slow) { + for (const char of translated) { + this.ptyProcess.write(char); + await this.sleep(options.delay ?? 50); + } + } else { + this.ptyProcess.write(translated); + await this.sleep(options?.delay ?? 10); + } + } + + // ── Wait ───────────────────────────────── + + /** + * Wait for specific text to appear in terminal output + * + * @throws Error on timeout + * + * @example + * ```ts + * await terminal.waitFor('Type your message'); + * await terminal.waitFor('tokens', { timeout: 30000 }); + * ``` + */ + async waitFor(text: string, options?: { timeout?: number }): Promise { + const timeout = options?.timeout ?? 15000; + const start = Date.now(); + + while (Date.now() - start < timeout) { + if ( + stripAnsi(this.rawOutput).toLowerCase().includes(text.toLowerCase()) + ) { + return; + } + await this.sleep(200); + } + + throw new Error( + `Timeout (${timeout}ms) waiting for text: "${text}"\n` + + `Last 500 chars of output: ${stripAnsi(this.rawOutput).slice(-500)}`, + ); + } + + /** + * Wait for output to stabilize (no new output within specified time) + * + * @param stableMs Stability detection duration (ms), default 500 + * @param timeout Maximum wait time (ms), default 30000 + * + * @example + * ```ts + * await terminal.idle(); // Default: 500ms with no new output considered stable + * await terminal.idle(2000); // 2s with no new output + * ``` + */ + async idle(stableMs: number = 500, timeout: number = 30000): Promise { + const start = Date.now(); + let lastLength = this.rawOutput.length; + let lastChangeTime = Date.now(); + + while (Date.now() - start < timeout) { + await this.sleep(100); + if (this.rawOutput.length !== lastLength) { + lastLength = this.rawOutput.length; + lastChangeTime = Date.now(); + } else if (Date.now() - lastChangeTime >= stableMs) { + return; + } + } + // Timeout for idle() is not an error — just means output kept coming + } + + /** + * Wait for text to appear, then wait for output to stabilize (common combination) + */ + async waitForAndIdle( + text: string, + options?: { timeout?: number; stableMs?: number }, + ): Promise { + await this.waitFor(text, { timeout: options?.timeout }); + await this.idle(options?.stableMs ?? 300, 5000); + } + + // ── Capture ────────────────────────────── + + /** + * Capture and save a screenshot. Filenames are deterministic (no timestamps) for easy regression comparison. + * + * @param filename Filename, e.g., 'initial.png' + * @param outputDir Output directory, defaults to the outputDir from construction + * @returns Full path to the screenshot file + * + * @example + * ```ts + * await terminal.capture('01-initial.png'); + * await terminal.capture('02-output.png', '/tmp/screenshots'); + * ``` + */ + async capture(filename: string, outputDir?: string): Promise { + if (!this.page) { + throw new Error('Not initialized'); + } + + // 1. Flush all accumulated PTY data to xterm.js + await this.flush(); + + // 2. Wait for xterm.js rendering to complete + await this.sleep(150); + + // 3. Prepare output directory + const dir = outputDir ?? this.outputDir; + mkdirSync(dir, { recursive: true }); + const filepath = join(dir, filename); + + // 4. Screenshot the capture root (terminal + optional chrome) + const element = await this.page.$('#capture-root'); + if (element) { + await element.screenshot({ path: filepath }); + } else { + await this.page.screenshot({ path: filepath }); + } + + console.log(`📸 Captured: ${filepath}`); + return filepath; + } + + /** + * Capture full terminal output (including scrollback buffer) as a long image. + * Suitable for scenarios where output exceeds the visible area, e.g., detailed token lists from /context. + * + * Principle: Temporarily expand xterm.js rows to show complete scrollback, then restore original dimensions after screenshot. + * Note: Only resizes xterm.js inside the browser, not the PTY dimensions, so it won't trigger CLI re-render. + * + * @param filename Filename + * @param outputDir Output directory + * @returns Full path to the screenshot file + * + * @example + * ```ts + * // Regular screenshot (only current viewport) + * await terminal.capture('output.png'); + * // Full-length image (including scrollback buffer) + * await terminal.captureFull('output-full.png'); + * ``` + */ + async captureFull(filename: string, outputDir?: string): Promise { + if (!this.page) { + throw new Error('Not initialized'); + } + + // 1. Flush all accumulated PTY data to xterm.js + await this.flush(); + await this.sleep(150); + + // 2. Query xterm.js for the actual content height (skip trailing empty lines) + const contentLines = await this.page.evaluate(() => { + const W = window as unknown as Record; + const term = W['term'] as { + buffer: { + active: { + length: number; + getLine: (i: number) => + | { + translateToString: (trimRight?: boolean) => string; + } + | undefined; + }; + }; + }; + const buf = term.buffer.active; + let lastNonEmpty = 0; + for (let i = buf.length - 1; i >= 0; i--) { + const line = buf.getLine(i); + if (line && line.translateToString(true).trim().length > 0) { + lastNonEmpty = i; + break; + } + } + return lastNonEmpty + 1; + }); + + const expandedRows = Math.max(contentLines + 2, this.rows); + + // 3. Temporarily resize xterm.js only (NOT the PTY) to show all content + // This avoids sending SIGWINCH to the child process, so the CLI won't re-render + await this.page.evaluate( + ({ cols, rows }: { cols: number; rows: number }) => { + const W = window as unknown as Record; + const term = W['term'] as { + resize: (c: number, r: number) => void; + scrollToTop: () => void; + }; + term.resize(cols, rows); + // Scroll to top to ensure rendering starts from scrollback beginning position + term.scrollToTop(); + }, + { cols: this.cols, rows: expandedRows }, + ); + + // 4. Expand viewport to accommodate the taller terminal + await this.page.setViewportSize({ + width: 1600, + height: Math.max(expandedRows * 22, 1000), // ~22px per row (fontSize 14 * lineHeight 1.2 + padding) + }); + + await this.sleep(300); + + // 5. Screenshot the full content + const dir = outputDir ?? this.outputDir; + mkdirSync(dir, { recursive: true }); + const filepath = join(dir, filename); + + const element = await this.page.$('#capture-root'); + if (element) { + await element.screenshot({ path: filepath }); + } else { + await this.page.screenshot({ path: filepath, fullPage: true }); + } + + // 6. Restore original xterm.js dimensions and viewport + await this.page.evaluate( + ({ cols, rows }: { cols: number; rows: number }) => { + const W = window as unknown as Record; + const term = W['term'] as { resize: (c: number, r: number) => void }; + term.resize(cols, rows); + }, + { cols: this.cols, rows: this.rows }, + ); + + await this.page.setViewportSize({ width: 1600, height: 1000 }); + + console.log(`📸 Captured (full): ${filepath}`); + return filepath; + } + + // ── Output access ──────────────────────── + + /** + * Get cleaned terminal output (without ANSI escape sequences) + */ + getOutput(): string { + return stripAnsi(this.rawOutput); + } + + /** + * Get raw terminal output (with ANSI escape sequences) + */ + getRawOutput(): string { + return this.rawOutput; + } + + // ── Cleanup ────────────────────────────── + + /** + * Release all resources (PTY process, browser) + */ + async close(): Promise { + if (this.ptyProcess) { + try { + this.ptyProcess.kill(); + } catch { + // Process may have already exited + } + this.ptyProcess = null; + } + + if (this.browser) { + await this.browser.close(); + this.browser = null; + this.page = null; + } + } + + // ── Internal: flush PTY → xterm.js ────── + + /** + * Flush accumulated PTY raw output to xterm.js inside the browser. + * Uses xterm.js's write callback to ensure data is fully parsed, + * then waits one requestAnimationFrame to ensure rendering is complete. + */ + private async flush(): Promise { + if (!this.page || this.rawOutput.length <= this.lastFlushedLength) { + return; + } + + const newData = this.rawOutput.slice(this.lastFlushedLength); + this.lastFlushedLength = this.rawOutput.length; + + // Send data in chunks to avoid hitting string size limits + const CHUNK_SIZE = 64 * 1024; + for (let i = 0; i < newData.length; i += CHUNK_SIZE) { + const chunk = newData.slice(i, i + CHUNK_SIZE); + await this.page.evaluate((data: string) => { + return new Promise((resolve) => { + const W = window as unknown as Record; + const term = W['term'] as { + write: (d: string, cb: () => void) => void; + }; + term.write(data, () => { + // Data parsed → wait one frame for rendering + requestAnimationFrame(() => resolve()); + }); + }); + }, chunk); + } + } + + // ── Internal: resolve xterm.js path ───── + + private resolveXtermDir(): string { + try { + const pkgJsonPath = _require.resolve('@xterm/xterm/package.json'); + return dirname(pkgJsonPath); + } catch { + throw new Error( + '@xterm/xterm is not installed.\n' + + 'Run: npm install --save-dev @xterm/xterm', + ); + } + } + + // ── Internal: build HTML ──────────────── + + private buildHTML(): string { + const bg = this.theme.background; + + // Title bar color: slightly lighter than background + // Use a manual approximation instead of color-mix for compatibility + const titleBarBg = this.lighten(bg, 0.08); + + const chromeHTML = this.showChrome + ? ` +
+
+ + + +
+ ${this.escapeHtml(this.windowTitle)} +
+
` + : ''; + + return ` + + + + + + +
+ ${chromeHTML} +
+
+ +`; + } + + // ── Internal: utils ───────────────────── + + private escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + /** + * Lighten a hex color by a factor (0-1) + */ + private lighten(hex: string, factor: number): string { + const h = hex.replace('#', ''); + const r = Math.min( + 255, + parseInt(h.slice(0, 2), 16) + Math.round(255 * factor), + ); + const g = Math.min( + 255, + parseInt(h.slice(2, 4), 16) + Math.round(255 * factor), + ); + const b = Math.min( + 255, + parseInt(h.slice(4, 6), 16) + Math.round(255 * factor), + ); + return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} From ee5e47bb5fd9b034eac926643c801862271191ae Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Sun, 15 Feb 2026 11:02:32 +0800 Subject: [PATCH 45/81] fix: sandbox user permission in integration tests - Allow SANDBOX_SET_UID_GID to control user identity in integration tests - Fix project naming from gemini-cli to qwen-code - Use random UUID in tests to avoid conflicts Co-authored-by: Qwen-Coder --- .../sdk-typescript/session-id.test.ts | 24 ++++++++++++------- packages/cli/src/utils/sandbox.ts | 22 ++++++++++------- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/integration-tests/sdk-typescript/session-id.test.ts b/integration-tests/sdk-typescript/session-id.test.ts index 6b9136503..7a2ab435d 100644 --- a/integration-tests/sdk-typescript/session-id.test.ts +++ b/integration-tests/sdk-typescript/session-id.test.ts @@ -377,8 +377,8 @@ describe('Session ID Support (E2E)', () => { describe('Session ID Duplicate Detection', () => { it('should reject duplicate sessionId with error', async () => { - // Valid UUID v4 - const customSessionId = 'dddddddd-eeee-4fff-aaaa-bbbbbbbbbbbb'; + // Generate a unique UUID for this test + const customSessionId = crypto.randomUUID(); // First query: create a session with the custom session ID const q1 = query({ @@ -387,7 +387,9 @@ describe('Session ID Support (E2E)', () => { ...SHARED_TEST_OPTIONS, cwd: testDir, sessionId: customSessionId, - debug: false, + env: { + SANDBOX_SET_UID_GID: 'true', + }, }, }); @@ -409,7 +411,9 @@ describe('Session ID Support (E2E)', () => { ...SHARED_TEST_OPTIONS, cwd: testDir, sessionId: customSessionId, - debug: false, + env: { + SANDBOX_SET_UID_GID: 'true', + }, }, }); @@ -426,8 +430,8 @@ describe('Session ID Support (E2E)', () => { }); it('should throw error when CLI exits with non-zero code', async () => { - // Valid UUID v4 - const customSessionId = 'eeeeeeee-ffff-4aaa-bbbb-cccccccccccc'; + // Generate a unique UUID for this test + const customSessionId = crypto.randomUUID(); // First query: create a session and properly close it after completion const q1 = query({ @@ -436,7 +440,9 @@ describe('Session ID Support (E2E)', () => { ...SHARED_TEST_OPTIONS, cwd: testDir, sessionId: customSessionId, - debug: false, + env: { + SANDBOX_SET_UID_GID: 'true', + }, }, }); @@ -456,7 +462,9 @@ describe('Session ID Support (E2E)', () => { ...SHARED_TEST_OPTIONS, cwd: testDir, sessionId: customSessionId, - debug: false, + env: { + SANDBOX_SET_UID_GID: 'true', + }, }, }); diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index 23585dd3b..cddb25066 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -337,7 +337,7 @@ export async function start_sandbox( writeStderrLine(`hopping into sandbox (command: ${config.command}) ...`); - // determine full path for gemini-cli to distinguish linked vs installed setting + // determine full path for qwen-code to distinguish linked vs installed setting const gcPath = fs.realpathSync(process.argv[1]); const projectSandboxDockerfile = path.join( @@ -350,9 +350,9 @@ export async function start_sandbox( const workdir = path.resolve(process.cwd()); const containerWorkdir = getContainerPath(workdir); - // if BUILD_SANDBOX is set, then call scripts/build_sandbox.js under gemini-cli repo + // if BUILD_SANDBOX is set, then call scripts/build_sandbox.js under qwen-code repo // - // note this can only be done with binary linked from gemini-cli repo + // note this can only be done with binary linked from qwen-code repo if (process.env['BUILD_SANDBOX']) { if (!gcPath.includes('qwen-code/packages/')) { throw new FatalSandboxError( @@ -389,8 +389,8 @@ export async function start_sandbox( if (!(await ensureSandboxImageIsPresent(config.command, image))) { const remedy = image === LOCAL_DEV_SANDBOX_IMAGE_NAME - ? 'Try running `npm run build:all` or `npm run build:sandbox` under the gemini-cli repo to build it locally, or check the image name and your network connection.' - : 'Please check the image name, your network connection, or notify gemini-cli-dev@google.com if the issue persists.'; + ? 'Try running `npm run build:all` or `npm run build:sandbox` under the qwen-code repo to build it locally, or check the image name and your network connection.' + : 'Please check the image name, your network connection, or notify qwen-code-dev@alibaba-inc.com if the issue persists.'; throw new FatalSandboxError( `Sandbox image '${image}' is missing or could not be pulled. ${remedy}`, ); @@ -544,7 +544,7 @@ export async function start_sandbox( process.env['GEMINI_CLI_INTEGRATION_TEST'] === 'true'; let containerName; if (isIntegrationTest) { - containerName = `gemini-cli-integration-test-${randomBytes(4).toString( + containerName = `qwen-code-integration-test-${randomBytes(4).toString( 'hex', )}`; writeStderrLine(`ContainerName: ${containerName}`); @@ -716,10 +716,16 @@ export async function start_sandbox( let userFlag = ''; const finalEntrypoint = entrypoint(workdir, cliArgs); - if (process.env['GEMINI_CLI_INTEGRATION_TEST'] === 'true') { + // Check if we should use current user's UID/GID in sandbox + // In integration test mode, we still respect SANDBOX_SET_UID_GID to allow + // tests that need to access host's ~/.qwen (e.g., --resume functionality) + const useCurrentUser = await shouldUseCurrentUserInSandbox(); + + if (!useCurrentUser) { + // Use root user (default for integration tests or when explicitly disabled) args.push('--user', 'root'); userFlag = '--user root'; - } else if (await shouldUseCurrentUserInSandbox()) { + } else { // For the user-creation logic to work, the container must start as root. // The entrypoint script then handles dropping privileges to the correct user. args.push('--user', 'root'); From 020c78b43bdd58fd6557240b0e4ecb28811154bb Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Sun, 15 Feb 2026 11:45:35 +0800 Subject: [PATCH 46/81] fix: update mail --- packages/cli/src/utils/sandbox.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index cddb25066..c22bf94a5 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -390,7 +390,7 @@ export async function start_sandbox( const remedy = image === LOCAL_DEV_SANDBOX_IMAGE_NAME ? 'Try running `npm run build:all` or `npm run build:sandbox` under the qwen-code repo to build it locally, or check the image name and your network connection.' - : 'Please check the image name, your network connection, or notify qwen-code-dev@alibaba-inc.com if the issue persists.'; + : 'Please check the image name, your network connection, or notify qwen-code-dev@service.alibaba.com if the issue persists.'; throw new FatalSandboxError( `Sandbox image '${image}' is missing or could not be pulled. ${remedy}`, ); From 07b97282aaced97bdac76965f816bb2728e7dd9c Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Sun, 15 Feb 2026 11:51:04 +0800 Subject: [PATCH 47/81] chore: bump version to 0.10.2 Co-authored-by: Qwen-Coder --- package-lock.json | 14 +++++++------- package.json | 4 ++-- packages/cli/package.json | 4 ++-- packages/core/package.json | 2 +- packages/test-utils/package.json | 2 +- packages/vscode-ide-companion/package.json | 2 +- packages/webui/package.json | 2 +- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9a49d849e..df3264492 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qwen-code/qwen-code", - "version": "0.10.1", + "version": "0.10.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qwen-code/qwen-code", - "version": "0.10.1", + "version": "0.10.2", "workspaces": [ "packages/*" ], @@ -18655,7 +18655,7 @@ }, "packages/cli": { "name": "@qwen-code/qwen-code", - "version": "0.10.1", + "version": "0.10.2", "dependencies": { "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", @@ -19274,7 +19274,7 @@ }, "packages/core": { "name": "@qwen-code/qwen-code-core", - "version": "0.10.1", + "version": "0.10.2", "hasInstallScript": true, "dependencies": { "@anthropic-ai/sdk": "^0.36.1", @@ -22754,7 +22754,7 @@ }, "packages/test-utils": { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.10.1", + "version": "0.10.2", "dev": true, "license": "Apache-2.0", "devDependencies": { @@ -22766,7 +22766,7 @@ }, "packages/vscode-ide-companion": { "name": "qwen-code-vscode-ide-companion", - "version": "0.10.1", + "version": "0.10.2", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.1", @@ -23013,7 +23013,7 @@ }, "packages/webui": { "name": "@qwen-code/webui", - "version": "0.10.1", + "version": "0.10.2", "license": "MIT", "dependencies": { "markdown-it": "^14.1.0" diff --git a/package.json b/package.json index 1605e1aeb..7063aee04 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.10.1", + "version": "0.10.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.10.1" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.2" }, "scripts": { "start": "cross-env node scripts/start.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index 7ec5da972..b30ed7e48 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.10.1", + "version": "0.10.2", "description": "Qwen Code", "repository": { "type": "git", @@ -34,7 +34,7 @@ "dist" ], "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.1" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.2" }, "dependencies": { "@google/genai": "1.30.0", diff --git a/packages/core/package.json b/packages/core/package.json index 8320a946d..825c8f140 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-core", - "version": "0.10.1", + "version": "0.10.2", "description": "Qwen Code Core", "repository": { "type": "git", diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 299ddc611..6ea6d8435 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.10.1", + "version": "0.10.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 f9e9d040e..9482a7cf7 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.10.1", + "version": "0.10.2", "publisher": "qwenlm", "icon": "assets/icon.png", "repository": { diff --git a/packages/webui/package.json b/packages/webui/package.json index f4305e230..c777edd94 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/webui", - "version": "0.10.1", + "version": "0.10.2", "description": "Shared UI components for Qwen Code packages", "type": "module", "main": "./dist/index.cjs", From ad23df4021093f0e6fbd01697fa3cd6779cf87cf Mon Sep 17 00:00:00 2001 From: DragonnZhang <731557579@qq.com> Date: Sun, 15 Feb 2026 22:33:14 +0800 Subject: [PATCH 48/81] chore: exclude .qwen/commands/ and .qwen/skills/ from gitignore - Add negation rules to preserve Qwen commands and skills directories - This allows version control for custom commands and skills in .qwen/ Co-authored-by: Qwen-Coder --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index a923e9bc1..b49d7ae87 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,8 @@ packages/vscode-ide-companion/*.vsix # Qwen Code Configs .qwen/ +!.qwen/commands/ +!.qwen/skills/ logs/ # GHA credentials gha-creds-*.json From ed81e89620cb9b85561bd9370604405617244b25 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Mon, 16 Feb 2026 14:22:40 +0800 Subject: [PATCH 49/81] docs: improve settings.json configuration guide with quick setup examples - Add comprehensive quick setup section with 3-step guide for settings.json - Include multiple practical examples (Coding Plan, multi-provider, thinking mode) - Update README with settings.json quick reference table - Enhance auth.md with one-file setup recommendation - Clarify field descriptions and priority order for API key configuration Co-authored-by: Qwen-Coder --- README.md | 195 ++++++++++++++++++++++++++++++- docs/users/configuration/auth.md | 107 ++++++++++++++--- 2 files changed, 284 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 4c9d28179..5a395b019 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,183 @@ Use this if you want more flexibility over which provider and model to use. Supp - **Anthropic**: Claude models - **Google GenAI**: Gemini models -For full details (including `modelProviders` configuration, `.env` file loading, environment variable priorities, and security notes), see the [authentication guide](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/auth/). +The **recommended** way to configure models and providers is by editing `~/.qwen/settings.json` (create it if it doesn't exist). This file lets you define all available models, API keys, and default settings in one place. + +##### Quick Setup in 3 Steps + +**Step 1:** Create or edit `~/.qwen/settings.json` + +Here is a complete example: + +```json +{ + "modelProviders": { + "openai": [ + { + "id": "qwen3-coder-plus", + "name": "qwen3-coder-plus", + "baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "description": "Qwen3-Coder via Dashscope", + "envKey": "DASHSCOPE_API_KEY" + } + ] + }, + "env": { + "DASHSCOPE_API_KEY": "sk-xxxxxxxxxxxxx" + }, + "security": { + "auth": { + "selectedType": "openai" + } + }, + "model": { + "name": "qwen3-coder-plus" + } +} +``` + +**Step 2:** Understand each field + +| Field | What it does | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `modelProviders` | Declares which models are available and how to connect to them. Keys like `openai`, `anthropic`, `gemini` represent the API protocol. | +| `modelProviders[].id` | The model ID sent to the API (e.g. `qwen3-coder-plus`, `gpt-4o`). | +| `modelProviders[].envKey` | The name of the environment variable that holds your API key. | +| `modelProviders[].baseUrl` | The API endpoint URL (required for non-default endpoints). | +| `env` | A fallback place to store API keys (lowest priority; prefer `.env` files or `export` for sensitive keys). | +| `security.auth.selectedType` | The protocol to use on startup (`openai`, `anthropic`, `gemini`, `vertex-ai`). | +| `model.name` | The default model to use when Qwen Code starts. | + +**Step 3:** Start Qwen Code — your configuration takes effect automatically: + +```bash +qwen +``` + +Use the `/model` command at any time to switch between all configured models. + +##### More Examples + +
+Coding Plan (Alibaba Cloud Bailian) — fixed monthly fee, higher quotas + +```json +{ + "modelProviders": { + "openai": [ + { + "id": "qwen3-coder-plus", + "name": "qwen3-coder-plus (Coding Plan)", + "baseUrl": "https://coding.dashscope.aliyuncs.com/v1", + "description": "qwen3-coder-plus from Bailian Coding Plan", + "envKey": "BAILIAN_CODING_PLAN_API_KEY" + } + ] + }, + "env": { + "BAILIAN_CODING_PLAN_API_KEY": "sk-xxxxxxxxxxxxx" + }, + "security": { + "auth": { + "selectedType": "openai" + } + }, + "model": { + "name": "qwen3-coder-plus" + } +} +``` + +> Subscribe to the Coding Plan at [Alibaba Cloud Bailian](https://bailian.console.aliyun.com/cn-beijing/?tab=globalset#/efm/coding_plan). + +
+ +
+Multiple providers (OpenAI + Anthropic + Gemini) + +```json +{ + "modelProviders": { + "openai": [ + { + "id": "gpt-4o", + "name": "GPT-4o", + "envKey": "OPENAI_API_KEY", + "baseUrl": "https://api.openai.com/v1" + } + ], + "anthropic": [ + { + "id": "claude-sonnet-4-20250514", + "name": "Claude Sonnet 4", + "envKey": "ANTHROPIC_API_KEY" + } + ], + "gemini": [ + { + "id": "gemini-2.5-pro", + "name": "Gemini 2.5 Pro", + "envKey": "GEMINI_API_KEY" + } + ] + }, + "env": { + "OPENAI_API_KEY": "sk-xxxxxxxxxxxxx", + "ANTHROPIC_API_KEY": "sk-ant-xxxxxxxxxxxxx", + "GEMINI_API_KEY": "AIzaxxxxxxxxxxxxx" + }, + "security": { + "auth": { + "selectedType": "openai" + } + }, + "model": { + "name": "gpt-4o" + } +} +``` + +
+ +
+Enable thinking mode (for supported models like qwen3.5-plus) + +```json +{ + "modelProviders": { + "openai": [ + { + "id": "qwen3.5-plus", + "name": "qwen3.5-plus (thinking)", + "envKey": "DASHSCOPE_API_KEY", + "baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "generationConfig": { + "extra_body": { + "enable_thinking": true + } + } + } + ] + }, + "env": { + "DASHSCOPE_API_KEY": "sk-xxxxxxxxxxxxx" + }, + "security": { + "auth": { + "selectedType": "openai" + } + }, + "model": { + "name": "qwen3.5-plus" + } +} +``` + +
+ +> **Tip:** You can also set API keys via `export` in your shell or `.env` files, which take higher priority than `settings.json` → `env`. See the [authentication guide](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/auth/) for full details. + +> **Security note:** Never commit API keys to version control. The `~/.qwen/settings.json` file is in your home directory and should stay private. ## Usage @@ -191,10 +367,21 @@ Build on top of Qwen Code with the TypeScript SDK: Qwen Code can be configured via `settings.json`, environment variables, and CLI flags. -- **User settings**: `~/.qwen/settings.json` -- **Project settings**: `.qwen/settings.json` +| File | Scope | Description | +| ----------------------- | ------------- | --------------------------------------------------------------------------------------- | +| `~/.qwen/settings.json` | User (global) | Applies to all your Qwen Code sessions. **Recommended for `modelProviders` and `env`.** | +| `.qwen/settings.json` | Project | Applies only when running Qwen Code in this project. Overrides user settings. | -See [settings](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/) for available options and precedence. +The most commonly used top-level fields in `settings.json`: + +| Field | Description | +| ---------------------------- | ---------------------------------------------------------------------------------------------------- | +| `modelProviders` | Define available models per protocol (`openai`, `anthropic`, `gemini`, `vertex-ai`). | +| `env` | Fallback environment variables (e.g. API keys). Lower priority than shell `export` and `.env` files. | +| `security.auth.selectedType` | The protocol to use on startup (e.g. `openai`). | +| `model.name` | The default model to use when Qwen Code starts. | + +> See the [Authentication](#api-key-flexible) section above for complete `settings.json` examples, and the [settings reference](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/) for all available options. ## Benchmark Results diff --git a/docs/users/configuration/auth.md b/docs/users/configuration/auth.md index 0a5b700ea..2b56c1fb6 100644 --- a/docs/users/configuration/auth.md +++ b/docs/users/configuration/auth.md @@ -31,6 +31,52 @@ qwen Use this if you want more flexibility over which provider and model to use. Supports multiple protocols and providers, including OpenAI, Anthropic, Google GenAI, Alibaba Cloud Bailian, Azure OpenAI, OpenRouter, ModelScope, or a self-hosted compatible endpoint. +### Recommended: One-file setup via `settings.json` + +The simplest way to get started with API-KEY authentication is to put everything in a single `~/.qwen/settings.json` file. Here's a complete, ready-to-use example: + +```json +{ + "modelProviders": { + "openai": [ + { + "id": "qwen3-coder-plus", + "name": "qwen3-coder-plus", + "baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "description": "Qwen3-Coder via Dashscope", + "envKey": "DASHSCOPE_API_KEY" + } + ] + }, + "env": { + "DASHSCOPE_API_KEY": "sk-xxxxxxxxxxxxx" + }, + "security": { + "auth": { + "selectedType": "openai" + } + }, + "model": { + "name": "qwen3-coder-plus" + } +} +``` + +What each field does: + +| Field | Description | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `modelProviders` | Declares which models are available and how to connect to them. Keys (`openai`, `anthropic`, `gemini`, `vertex-ai`) represent the API protocol. | +| `env` | Stores API keys directly in `settings.json` as a fallback (lowest priority — shell `export` and `.env` files take precedence). | +| `security.auth.selectedType` | Tells Qwen Code which protocol to use on startup (e.g. `openai`, `anthropic`, `gemini`). Without this, you'd need to run `/auth` interactively. | +| `model.name` | The default model to activate when Qwen Code starts. Must match one of the `id` values in your `modelProviders`. | + +After saving the file, just run `qwen` — no interactive `/auth` setup needed. + +> [!tip] +> +> The sections below explain each part in more detail. If the quick example above works for you, feel free to skip ahead to [Security notes](#security-notes). + ### Option1: Coding Plan(Aliyun Bailian) Use this if you want predictable costs with higher usage quotas for the qwen3-coder-plus model. @@ -52,6 +98,41 @@ Enter your `sk-sp-xxxxxxxxx` key, then use the `/model` command to switch be ![](https://gw.alicdn.com/imgextra/i4/O1CN01fWArmf1kaCEgSmPln_!!6000000004699-2-tps-2304-1374.png) +**Alternative: configure Coding Plan via `settings.json`** + +If you prefer to skip the interactive `/auth` flow, add the following to `~/.qwen/settings.json`: + +```json +{ + "modelProviders": { + "openai": [ + { + "id": "qwen3-coder-plus", + "name": "qwen3-coder-plus (Coding Plan)", + "baseUrl": "https://coding.dashscope.aliyuncs.com/v1", + "description": "qwen3-coder-plus from Bailian Coding Plan", + "envKey": "BAILIAN_CODING_PLAN_API_KEY" + } + ] + }, + "env": { + "BAILIAN_CODING_PLAN_API_KEY": "sk-sp-xxxxxxxxx" + }, + "security": { + "auth": { + "selectedType": "openai" + } + }, + "model": { + "name": "qwen3-coder-plus" + } +} +``` + +> [!note] +> +> The Coding Plan uses a dedicated endpoint (`https://coding.dashscope.aliyuncs.com/v1`) that is different from the standard Dashscope endpoint. Make sure to use the correct `baseUrl`. + ### Option2: Third-party API-KEY Use this if you want to connect to third-party providers such as OpenAI, Anthropic, Google, Azure OpenAI, OpenRouter, ModelScope, or a self-hosted endpoint. @@ -67,7 +148,7 @@ The key concept is **Model Providers** (`modelProviders`): Qwen Code supports mu | Google GenAI | `gemini` | `GEMINI_API_KEY`, `GEMINI_MODEL` | Google Gemini | | Google Vertex AI | `vertex-ai` | `GOOGLE_API_KEY`, `GOOGLE_MODEL` | Google Vertex AI | -#### Step 1: Configure `modelProviders` in `~/.qwen/settings.json` +#### Step 1: Configure models and providers in `~/.qwen/settings.json` Define which models are available for each protocol. Each model entry requires at minimum an `id` and an `envKey` (the environment variable name that holds your API key). @@ -75,7 +156,7 @@ Define which models are available for each protocol. Each model entry requires a > > It is recommended to define `modelProviders` in the user-scope `~/.qwen/settings.json` to avoid merge conflicts between project and user settings. -Edit `~/.qwen/settings.json` (create it if it doesn't exist): +Edit `~/.qwen/settings.json` (create it if it doesn't exist). You can mix multiple protocols in a single file — here is a multi-provider example showing just the `modelProviders` section: ```json { @@ -106,7 +187,11 @@ Edit `~/.qwen/settings.json` (create it if it doesn't exist): } ``` -You can mix multiple protocols and models in a single configuration. The `ModelConfig` fields are: +> [!tip] +> +> Don't forget to also set `env`, `security.auth.selectedType`, and `model.name` alongside `modelProviders` — see the [complete example above](#recommended-one-file-setup-via-settingsjson) for reference. + +**`ModelConfig` fields (each entry inside `modelProviders`):** | Field | Required | Description | | ------------------ | -------- | -------------------------------------------------------------------- | @@ -118,7 +203,7 @@ You can mix multiple protocols and models in a single configuration. The `ModelC > [!note] > -> Credentials are **never** stored in `settings.json`. The runtime reads them from the environment variable specified in `envKey`. +> When using the `env` field in `settings.json`, credentials are stored in plain text. For better security, prefer `.env` files or shell `export` — see [Step 2](#step-2-set-environment-variables). For the full `modelProviders` schema and advanced options like `generationConfig`, `customHeaders`, and `extra_body`, see [Settings Reference → modelProviders](settings.md#modelproviders). @@ -165,25 +250,19 @@ If nothing is found, it falls back to your **home directory**: **3. `settings.json` → `env` field (lowest priority)** -You can also define environment variables directly in `~/.qwen/settings.json` under the `env` key. These are loaded as the **lowest-priority fallback** — only applied when a variable is not already set by the system environment or `.env` files. +You can also define API keys directly in `~/.qwen/settings.json` under the `env` key. These are loaded as the **lowest-priority fallback** — only applied when a variable is not already set by the system environment or `.env` files. ```json { "env": { - "DASHSCOPE_API_KEY":"sk-...", + "DASHSCOPE_API_KEY": "sk-...", "OPENAI_API_KEY": "sk-...", - "ANTHROPIC_API_KEY": "sk-ant-...", - "GEMINI_API_KEY": "AIza..." - }, - "modelProviders": { - ... + "ANTHROPIC_API_KEY": "sk-ant-..." } } ``` -> [!note] -> -> This is useful when you want to keep all configuration (providers + credentials) in a single file. However, be mindful that `settings.json` may be shared or synced — prefer `.env` files for sensitive secrets. +This is the approach used in the [one-file setup example](#recommended-one-file-setup-via-settingsjson) above. It's convenient for keeping everything in one place, but be mindful that `settings.json` may be shared or synced — prefer `.env` files for sensitive secrets. **Priority summary:** From 0f842f4733f965bffa6aa8a069a36820e649fab7 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Mon, 16 Feb 2026 16:52:42 +0800 Subject: [PATCH 50/81] feat(models): add Qwen 3.5 Plus model support with updated descriptions and token limits Co-authored-by: Qwen-Coder --- packages/cli/src/i18n/locales/de.js | 4 ++-- packages/cli/src/i18n/locales/en.js | 4 ++-- packages/cli/src/i18n/locales/ja.js | 4 ++-- packages/cli/src/i18n/locales/pt.js | 4 ++-- packages/cli/src/i18n/locales/ru.js | 4 ++-- packages/cli/src/i18n/locales/zh.js | 4 ++-- packages/cli/src/ui/models/availableModels.ts | 2 +- packages/core/src/core/tokenLimits.ts | 10 ++++++++-- packages/core/src/models/constants.ts | 3 ++- 9 files changed, 23 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 5757b135f..d000dc1f4 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1030,8 +1030,8 @@ export default { '(not set)': '(nicht gesetzt)', "Failed to switch model to '{{modelId}}'.\n\n{{error}}": "Modell konnte nicht auf '{{modelId}}' umgestellt werden.\n\n{{error}}", - 'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)': - 'Das neueste Qwen Coder Modell von Alibaba Cloud ModelStudio (Version: qwen3-coder-plus-2025-09-23)', + 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance': + 'Qwen 3.5 Plus — effizientes Hybridmodell mit führender Programmierleistung', 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)': 'Das neueste Qwen Vision Modell von Alibaba Cloud ModelStudio (Version: qwen3-vl-plus-2025-09-23)', diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index aaa18b0e1..88b376622 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1017,8 +1017,8 @@ export default { '(not set)': '(not set)', "Failed to switch model to '{{modelId}}'.\n\n{{error}}": "Failed to switch model to '{{modelId}}'.\n\n{{error}}", - 'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)': - 'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)', + 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance': + 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance', 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)': 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)', diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index f9d95b34a..6f9ffe12d 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -731,8 +731,8 @@ export default { // Dialogs - Model 'Select Model': 'モデルを選択', '(Press Esc to close)': '(Esc で閉じる)', - 'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)': - 'Alibaba Cloud ModelStudioの最新Qwen Coderモデル(バージョン: qwen3-coder-plus-2025-09-23)', + 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance': + 'Qwen 3.5 Plus — 効率的なハイブリッドモデル、業界トップクラスのコーディング性能', 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)': 'Alibaba Cloud ModelStudioの最新Qwen Visionモデル(バージョン: qwen3-vl-plus-2025-09-23)', // Dialogs - Permissions diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 8613c3076..08901262a 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1039,8 +1039,8 @@ export default { '(not set)': '(não definido)', "Failed to switch model to '{{modelId}}'.\n\n{{error}}": "Falha ao trocar o modelo para '{{modelId}}'.\n\n{{error}}", - 'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)': - 'O modelo Qwen Coder mais recente do Alibaba Cloud ModelStudio (versão: qwen3-coder-plus-2025-09-23)', + 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance': + 'Qwen 3.5 Plus — modelo híbrido eficiente com desempenho líder em programação', 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)': 'O modelo Qwen Vision mais recente do Alibaba Cloud ModelStudio (versão: qwen3-vl-plus-2025-09-23)', diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 92c9f8c50..3806807d6 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1032,8 +1032,8 @@ export default { '(not set)': '(не задано)', "Failed to switch model to '{{modelId}}'.\n\n{{error}}": "Не удалось переключиться на модель '{{modelId}}'.\n\n{{error}}", - 'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)': - 'Последняя модель Qwen Coder от Alibaba Cloud ModelStudio (версия: qwen3-coder-plus-2025-09-23)', + 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance': + 'Qwen 3.5 Plus — эффективная гибридная модель с лидирующей производительностью в программировании', 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)': 'Последняя модель Qwen Vision от Alibaba Cloud ModelStudio (версия: qwen3-vl-plus-2025-09-23)', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 09ba042ff..60c7551f2 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -958,8 +958,8 @@ export default { '(not set)': '(未设置)', "Failed to switch model to '{{modelId}}'.\n\n{{error}}": "无法切换到模型 '{{modelId}}'.\n\n{{error}}", - 'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)': - '来自阿里云 ModelStudio 的最新 Qwen Coder 模型(版本:qwen3-coder-plus-2025-09-23)', + 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance': + 'Qwen 3.5 Plus — 高效混合架构,编程性能业界领先', 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)': '来自阿里云 ModelStudio 的最新 Qwen Vision 模型(版本:qwen3-vl-plus-2025-09-23)', diff --git a/packages/cli/src/ui/models/availableModels.ts b/packages/cli/src/ui/models/availableModels.ts index 1cff984c8..0b9727642 100644 --- a/packages/cli/src/ui/models/availableModels.ts +++ b/packages/cli/src/ui/models/availableModels.ts @@ -28,7 +28,7 @@ export const AVAILABLE_MODELS_QWEN: AvailableModel[] = [ label: MAINLINE_CODER, get description() { return t( - 'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)', + 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance', ); }, }, diff --git a/packages/core/src/core/tokenLimits.ts b/packages/core/src/core/tokenLimits.ts index c20bd16a7..ae6cbd9e2 100644 --- a/packages/core/src/core/tokenLimits.ts +++ b/packages/core/src/core/tokenLimits.ts @@ -119,7 +119,10 @@ const PATTERNS: Array<[RegExp, TokenCount]> = [ // Commercial Qwen3-Coder-Flash: 1M token context [/^qwen3-coder-flash(-.*)?$/, LIMITS['1m']], // catches "qwen3-coder-flash" and date variants - // Generic coder-model: same as qwen3-coder-plus (1M token context) + // Commercial Qwen3.5-Plus: 1M token context + [/^qwen3\.5-plus(-.*)?$/, LIMITS['1m']], // catches "qwen3.5-plus" and date variants + + // Generic coder-model: same as qwen3.5-plus (1M token context) [/^coder-model$/, LIMITS['1m']], // Commercial Qwen3-Max-Preview: 256K token context @@ -199,7 +202,10 @@ const OUTPUT_PATTERNS: Array<[RegExp, TokenCount]> = [ // Qwen3-Coder-Plus: 65,536 max output tokens [/^qwen3-coder-plus(-.*)?$/, LIMITS['64k']], - // Generic coder-model: same as qwen3-coder-plus (64K max output tokens) + // Qwen3.5-Plus: 65,536 max output tokens + [/^qwen3\.5-plus(-.*)?$/, LIMITS['64k']], + + // Generic coder-model: same as qwen3.5-plus (64K max output tokens) [/^coder-model$/, LIMITS['64k']], // Qwen3-Max: 65,536 max output tokens diff --git a/packages/core/src/models/constants.ts b/packages/core/src/models/constants.ts index 9b4cc2ce7..9e5d15009 100644 --- a/packages/core/src/models/constants.ts +++ b/packages/core/src/models/constants.ts @@ -105,7 +105,8 @@ export const QWEN_OAUTH_MODELS: ModelConfig[] = [ { id: 'coder-model', name: 'coder-model', - description: 'The latest Qwen Coder model from Alibaba Cloud ModelStudio', + description: + 'Qwen 3.5 Plus — efficient hybrid model with leading coding performance', capabilities: { vision: false }, }, { From d235a711eeed7278f144ac58aa45d48105af2fdf Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Mon, 16 Feb 2026 17:00:34 +0800 Subject: [PATCH 51/81] docs(readme): add Qwen3.5-Plus launch announcement banner Co-authored-by: Qwen-Coder --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 5a395b019..79167b410 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ +> 🎉 **News (2026-02-16)**: Qwen3.5-Plus is now live! Sign in via Qwen OAuth to use it directly, or get an API key from [Alibaba Cloud ModelStudio](https://modelstudio.console.alibabacloud.com/ap-southeast-1/?tab=doc#/doc/?type=model&url=2840914_2&modelId=group-qwen3.5-plus) to access it through the OpenAI-compatible API. + Qwen Code is an open-source AI agent for the terminal, optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder). It helps you understand large codebases, automate tedious work, and ship faster. ![](https://gw.alicdn.com/imgextra/i1/O1CN01D2DviS1wwtEtMwIzJ_!!6000000006373-2-tps-1600-900.png) From 60c21750809e8e721b3d8b4a0441612bd0229fd6 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Mon, 16 Feb 2026 17:16:58 +0800 Subject: [PATCH 52/81] feat: update readme --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 79167b410..5a395b019 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,6 @@ -> 🎉 **News (2026-02-16)**: Qwen3.5-Plus is now live! Sign in via Qwen OAuth to use it directly, or get an API key from [Alibaba Cloud ModelStudio](https://modelstudio.console.alibabacloud.com/ap-southeast-1/?tab=doc#/doc/?type=model&url=2840914_2&modelId=group-qwen3.5-plus) to access it through the OpenAI-compatible API. - Qwen Code is an open-source AI agent for the terminal, optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder). It helps you understand large codebases, automate tedious work, and ship faster. ![](https://gw.alicdn.com/imgextra/i1/O1CN01D2DviS1wwtEtMwIzJ_!!6000000006373-2-tps-1600-900.png) From e7e2989ff6a48b21dbd119c6db6c2df7d6b61673 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Mon, 16 Feb 2026 21:08:24 +0800 Subject: [PATCH 53/81] docs: add news banner about Qwen3.5-Plus launch - Add news banner announcing Qwen3.5-Plus availability - Include OAuth sign-in option and Alibaba Cloud ModelStudio link for API key access Co-authored-by: Qwen-Coder --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 5a395b019..b1b99e94c 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ +> 🎉 **News (2026-02-16)**: Qwen3.5-Plus is now live! Sign in via Qwen OAuth to use it directly, or get an API key from [Alibaba Cloud ModelStudio](https://modelstudio.console.alibabacloud.com?tab=doc#/doc/?type=model&url=2840914_2&modelId=group-qwen3.5-plus) to access it through the OpenAI-compatible API. + Qwen Code is an open-source AI agent for the terminal, optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder). It helps you understand large codebases, automate tedious work, and ship faster. ![](https://gw.alicdn.com/imgextra/i1/O1CN01D2DviS1wwtEtMwIzJ_!!6000000006373-2-tps-1600-900.png) From caa9983e237c15c3396ac5056d97a1c26007e8c6 Mon Sep 17 00:00:00 2001 From: hobostay <110hqc@gmail.com> Date: Tue, 17 Feb 2026 19:56:12 +0800 Subject: [PATCH 54/81] fix(fs): Improve BOM detection with length check and codePointAt Improve the detectFileBOM method to handle edge cases better: 1. Add length check before accessing first character - Prevents potential issues with empty strings - Makes the intent explicit and defensive 2. Use codePointAt() instead of charCodeAt() - Better Unicode support for characters beyond BMP - More modern API for Unicode code point handling This change maintains the same functionality while being more robust and explicit about edge case handling. Co-Authored-By: Claude Sonnet 4.5 --- packages/cli/src/acp-integration/service/filesystem.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/acp-integration/service/filesystem.ts b/packages/cli/src/acp-integration/service/filesystem.ts index 17a0cdbcf..2afae0457 100644 --- a/packages/cli/src/acp-integration/service/filesystem.ts +++ b/packages/cli/src/acp-integration/service/filesystem.ts @@ -84,7 +84,8 @@ export class AcpFileSystemService implements FileSystemService { limit: 1, }); // Check if content starts with BOM character (U+FEFF) - return response.content.charCodeAt(0) === 0xfeff; + // Use codePointAt for better Unicode support and check content length first + return response.content.length > 0 && response.content.codePointAt(0) === 0xfeff; } catch { // Fall through to fallback if ACP read fails } From 39360dc058ec020503312e05c2e40ba6b42a68b5 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Tue, 17 Feb 2026 20:19:21 +0800 Subject: [PATCH 55/81] feat(cli): add Coding Plan Global/Intl region support Add support for Coding Plan international region with separate base URL: - Add CodingPlanRegion enum (CHINA, GLOBAL) for region management - Add CODING_PLAN_INTL_MODELS template with intl base URL - Add version storage for both regions (codingPlan.version/versionIntl) - Update AuthDialog to show both region options - Update useCodingPlanUpdates to handle region-specific updates - Add i18n translations for all supported languages - Fix and update unit tests Users can now choose between: - Coding Plan (Bailian, China) - https://coding.dashscope.aliyuncs.com/v1 - Coding Plan (Bailian, Global/Intl) - https://coding-intl.dashscope.aliyuncs.com/v1 Co-authored-by: Qwen-Coder --- packages/cli/src/constants/codingPlan.ts | 141 ++++++++- packages/cli/src/i18n/locales/de.js | 17 ++ packages/cli/src/i18n/locales/en.js | 17 ++ packages/cli/src/i18n/locales/ja.js | 18 ++ packages/cli/src/i18n/locales/pt.js | 17 ++ packages/cli/src/i18n/locales/ru.js | 18 ++ packages/cli/src/i18n/locales/zh.js | 17 ++ packages/cli/src/ui/auth/AuthDialog.tsx | 27 +- packages/cli/src/ui/auth/useAuth.ts | 56 ++-- .../cli/src/ui/components/ApiKeyInput.tsx | 15 +- .../cli/src/ui/contexts/UIActionsContext.tsx | 6 +- .../src/ui/hooks/useCodingPlanUpdates.test.ts | 275 ++++++++++++++--- .../cli/src/ui/hooks/useCodingPlanUpdates.ts | 279 +++++++++--------- 13 files changed, 684 insertions(+), 219 deletions(-) diff --git a/packages/cli/src/constants/codingPlan.ts b/packages/cli/src/constants/codingPlan.ts index e55aeb93d..845f85d19 100644 --- a/packages/cli/src/constants/codingPlan.ts +++ b/packages/cli/src/constants/codingPlan.ts @@ -7,6 +7,14 @@ import { createHash } from 'node:crypto'; import type { ProviderModelConfig as ModelConfig } from '@qwen-code/qwen-code-core'; +/** + * Coding plan regions + */ +export enum CodingPlanRegion { + CHINA = 'china', + GLOBAL = 'global', +} + /** * Coding plan template - array of model configurations * When user provides an api-key, these configs will be cloned with envKey pointing to the stored api-key @@ -14,18 +22,34 @@ import type { ProviderModelConfig as ModelConfig } from '@qwen-code/qwen-code-co export type CodingPlanTemplate = ModelConfig[]; /** - * Environment variable key for storing the coding plan API key + * Environment variable key for storing the coding plan API key (China/Bailian) */ export const CODING_PLAN_ENV_KEY = 'BAILIAN_CODING_PLAN_API_KEY'; /** - * CODING_PLAN_MODELS defines the model configurations for coding-plan mode. + * Environment variable key for storing the coding plan API key (Global/Intl) + */ +export const CODING_PLAN_INTL_ENV_KEY = 'BAILIAN_CODING_PLAN_INTL_API_KEY'; + +/** + * Base URL for China/Bailian Coding Plan + */ +export const CODING_PLAN_BASE_URL = 'https://coding.dashscope.aliyuncs.com/v1'; + +/** + * Base URL for Global/Intl Coding Plan + */ +export const CODING_PLAN_INTL_BASE_URL = + 'https://coding-intl.dashscope.aliyuncs.com/v1'; + +/** + * CODING_PLAN_MODELS defines the model configurations for coding-plan mode (China/Bailian). */ export const CODING_PLAN_MODELS: CodingPlanTemplate = [ { id: 'qwen3-coder-plus', name: 'qwen3-coder-plus', - baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + baseUrl: CODING_PLAN_BASE_URL, description: 'qwen3-coder-plus model from Bailian Coding Plan', envKey: CODING_PLAN_ENV_KEY, }, @@ -34,7 +58,7 @@ export const CODING_PLAN_MODELS: CodingPlanTemplate = [ name: 'qwen3-max-2026-01-23', description: 'qwen3-max model with thinking enabled from Bailian Coding Plan', - baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + baseUrl: CODING_PLAN_BASE_URL, envKey: CODING_PLAN_ENV_KEY, generationConfig: { extra_body: { @@ -44,18 +68,119 @@ export const CODING_PLAN_MODELS: CodingPlanTemplate = [ }, ]; +/** + * CODING_PLAN_INTL_MODELS defines the model configurations for coding-plan mode (Global/Intl). + */ +export const CODING_PLAN_INTL_MODELS: CodingPlanTemplate = [ + { + id: 'qwen3-coder-plus', + name: 'qwen3-coder-plus', + baseUrl: CODING_PLAN_INTL_BASE_URL, + description: 'qwen3-coder-plus model from Coding Plan (Global/Intl)', + envKey: CODING_PLAN_INTL_ENV_KEY, + }, + { + id: 'qwen3-max-2026-01-23', + name: 'qwen3-max-2026-01-23', + description: + 'qwen3-max model with thinking enabled from Coding Plan (Global/Intl)', + baseUrl: CODING_PLAN_INTL_BASE_URL, + envKey: CODING_PLAN_INTL_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + }, + }, +]; + /** * Computes the version hash for the coding plan template. * Uses SHA256 of the JSON-serialized template for deterministic versioning. + * @param template - The template to compute version for * @returns Hexadecimal string representing the template version */ -export function computeCodingPlanVersion(): string { - const templateString = JSON.stringify(CODING_PLAN_MODELS); +export function computeCodingPlanVersion(template: CodingPlanTemplate): string { + const templateString = JSON.stringify(template); return createHash('sha256').update(templateString).digest('hex'); } /** - * Current version of the coding plan template. + * Current version of the China/Bailian coding plan template. * Computed at runtime from the template content. */ -export const CODING_PLAN_VERSION = computeCodingPlanVersion(); +export const CODING_PLAN_VERSION = computeCodingPlanVersion(CODING_PLAN_MODELS); + +/** + * Current version of the Global/Intl coding plan template. + * Computed at runtime from the template content. + */ +export const CODING_PLAN_INTL_VERSION = computeCodingPlanVersion( + CODING_PLAN_INTL_MODELS, +); + +/** + * All coding plan templates for both regions. + * Used for update detection and filtering. + */ +export const ALL_CODING_PLAN_TEMPLATES: CodingPlanTemplate = [ + ...CODING_PLAN_MODELS, + ...CODING_PLAN_INTL_MODELS, +]; + +/** + * Check if a config belongs to any Coding Plan template (China or Intl). + * @param baseUrl - The baseUrl to check + * @param envKey - The envKey to check + * @param region - Optional region to limit the check to a specific region + * @returns true if the config matches any Coding Plan template + */ +export function isCodingPlanConfig( + baseUrl: string | undefined, + envKey: string | undefined, + region?: CodingPlanRegion, +): boolean { + if (!baseUrl || !envKey) { + return false; + } + + // If region is specified, only check that region's templates + if (region === CodingPlanRegion.GLOBAL) { + return CODING_PLAN_INTL_MODELS.some( + (template) => template.baseUrl === baseUrl && template.envKey === envKey, + ); + } else if (region === CodingPlanRegion.CHINA) { + return CODING_PLAN_MODELS.some( + (template) => template.baseUrl === baseUrl && template.envKey === envKey, + ); + } + + // No region specified, check all templates + return ALL_CODING_PLAN_TEMPLATES.some( + (template) => template.baseUrl === baseUrl && template.envKey === envKey, + ); +} + +/** + * Get the appropriate template and env key for the selected region. + * @param region - The region to use (default: CHINA) + * @returns Object containing template, envKey, version, and baseUrl + */ +export function getCodingPlanConfig( + region: CodingPlanRegion = CodingPlanRegion.CHINA, +) { + if (region === CodingPlanRegion.GLOBAL) { + return { + template: CODING_PLAN_INTL_MODELS, + envKey: CODING_PLAN_INTL_ENV_KEY, + version: CODING_PLAN_INTL_VERSION, + baseUrl: CODING_PLAN_INTL_BASE_URL, + }; + } + return { + template: CODING_PLAN_MODELS, + envKey: CODING_PLAN_ENV_KEY, + version: CODING_PLAN_VERSION, + baseUrl: CODING_PLAN_BASE_URL, + }; +} diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index d000dc1f4..291e14516 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1417,8 +1417,12 @@ export default { // Auth Dialog - View Titles and Labels // ============================================================================ 'Coding Plan': 'Coding Plan', + 'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, China)', + 'Coding Plan (Bailian, Global/Intl)': 'Coding Plan (Bailian, Global/Intl)', "Paste your api key of Bailian Coding Plan and you're all set!": 'Fügen Sie Ihren Bailian Coding Plan API-Schlüssel ein und Sie sind bereit!', + "Paste your api key of Coding Plan (Global/Intl) and you're all set!": + 'Fügen Sie Ihren Coding Plan (Global/Intl) API-Schlüssel ein und Sie sind bereit!', Custom: 'Benutzerdefiniert', 'More instructions about configuring `modelProviders` manually.': 'Weitere Anweisungen zur manuellen Konfiguration von `modelProviders`.', @@ -1428,4 +1432,17 @@ export default { '(Press Enter to submit, Escape to cancel)': '(Enter zum Absenden, Escape zum Abbrechen)', 'More instructions please check:': 'Weitere Anweisungen finden Sie unter:', + + // ============================================================================ + // Coding Plan International Updates + // ============================================================================ + 'New model configurations are available for Bailian Coding Plan (China). Update now?': + 'Neue Modellkonfigurationen sind für Bailian Coding Plan (China) verfügbar. Jetzt aktualisieren?', + 'New model configurations are available for Coding Plan (Global/Intl). Update now?': + 'Neue Modellkonfigurationen sind für Coding Plan (Global/Intl) verfügbar. Jetzt aktualisieren?', + '{{region}} configuration updated successfully. New models are now available.': + '{{region}}-Konfiguration erfolgreich aktualisiert. Neue Modelle sind jetzt verfügbar.', + 'Authenticated successfully with {{region}}. API key is stored in settings.env.': + 'Erfolgreich mit {{region}} authentifiziert. API-Schlüssel ist in settings.env gespeichert.', + 'Coding Plan (Global/Intl)': 'Coding Plan (Global/Intl)', }; diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 88b376622..a650d927f 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1418,8 +1418,12 @@ export default { // Auth Dialog - View Titles and Labels // ============================================================================ 'Coding Plan': 'Coding Plan', + 'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, China)', + 'Coding Plan (Bailian, Global/Intl)': 'Coding Plan (Bailian, Global/Intl)', "Paste your api key of Bailian Coding Plan and you're all set!": "Paste your api key of Bailian Coding Plan and you're all set!", + "Paste your api key of Coding Plan (Global/Intl) and you're all set!": + "Paste your api key of Coding Plan (Global/Intl) and you're all set!", Custom: 'Custom', 'More instructions about configuring `modelProviders` manually.': 'More instructions about configuring `modelProviders` manually.', @@ -1427,4 +1431,17 @@ export default { '(Press Escape to go back)': '(Press Escape to go back)', '(Press Enter to submit, Escape to cancel)': '(Press Enter to submit, Escape to cancel)', + + // ============================================================================ + // Coding Plan International Updates + // ============================================================================ + 'New model configurations are available for Bailian Coding Plan (China). Update now?': + 'New model configurations are available for Bailian Coding Plan (China). Update now?', + 'New model configurations are available for Coding Plan (Global/Intl). Update now?': + 'New model configurations are available for Coding Plan (Global/Intl). Update now?', + '{{region}} configuration updated successfully. New models are now available.': + '{{region}} configuration updated successfully. New models are now available.', + 'Authenticated successfully with {{region}}. API key is stored in settings.env.': + 'Authenticated successfully with {{region}}. API key is stored in settings.env.', + 'Coding Plan (Global/Intl)': 'Coding Plan (Global/Intl)', }; diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 6f9ffe12d..e20e33e55 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -928,8 +928,13 @@ export default { // Auth Dialog - View Titles and Labels // ============================================================================ 'Coding Plan': 'Coding Plan', + 'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, 中国)', + 'Coding Plan (Bailian, Global/Intl)': + 'Coding Plan (Bailian, グローバル/国際)', "Paste your api key of Bailian Coding Plan and you're all set!": 'Bailian Coding PlanのAPIキーを貼り付けるだけで準備完了です!', + "Paste your api key of Coding Plan (Global/Intl) and you're all set!": + 'Coding Plan (グローバル/国際) のAPIキーを貼り付けるだけで準備完了です!', Custom: 'カスタム', 'More instructions about configuring `modelProviders` manually.': '`modelProviders`を手動で設定する方法の詳細はこちら。', @@ -938,4 +943,17 @@ export default { '(Press Enter to submit, Escape to cancel)': '(Enterで送信、Escapeでキャンセル)', 'More instructions please check:': '詳細な手順はこちらをご確認ください:', + + // ============================================================================ + // Coding Plan International Updates + // ============================================================================ + 'New model configurations are available for Bailian Coding Plan (China). Update now?': + 'Bailian Coding Plan (中国) の新しいモデル設定が利用可能です。今すぐ更新しますか?', + 'New model configurations are available for Coding Plan (Global/Intl). Update now?': + 'Coding Plan (グローバル/国際) の新しいモデル設定が利用可能です。今すぐ更新しますか?', + '{{region}} configuration updated successfully. New models are now available.': + '{{region}} の設定が正常に更新されました。新しいモデルが利用可能になりました。', + 'Authenticated successfully with {{region}}. API key is stored in settings.env.': + '{{region}} での認証に成功しました。APIキーは settings.env に保存されています。', + 'Coding Plan (Global/Intl)': 'Coding Plan (グローバル/国際)', }; diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 08901262a..3519fadf2 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1431,8 +1431,12 @@ export default { // Auth Dialog - View Titles and Labels // ============================================================================ 'Coding Plan': 'Coding Plan', + 'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, China)', + 'Coding Plan (Bailian, Global/Intl)': 'Coding Plan (Bailian, Global/Intl)', "Paste your api key of Bailian Coding Plan and you're all set!": 'Cole sua chave de API do Bailian Coding Plan e pronto!', + "Paste your api key of Coding Plan (Global/Intl) and you're all set!": + 'Cole sua chave de API do Coding Plan (Global/Intl) e pronto!', Custom: 'Personalizado', 'More instructions about configuring `modelProviders` manually.': 'Mais instruções sobre como configurar `modelProviders` manualmente.', @@ -1442,4 +1446,17 @@ export default { '(Press Enter to submit, Escape to cancel)': '(Pressione Enter para enviar, Escape para cancelar)', 'More instructions please check:': 'Mais instruções, consulte:', + + // ============================================================================ + // Coding Plan International Updates + // ============================================================================ + 'New model configurations are available for Bailian Coding Plan (China). Update now?': + 'Novas configurações de modelo estão disponíveis para o Bailian Coding Plan (China). Atualizar agora?', + 'New model configurations are available for Coding Plan (Global/Intl). Update now?': + 'Novas configurações de modelo estão disponíveis para o Coding Plan (Global/Intl). Atualizar agora?', + '{{region}} configuration updated successfully. New models are now available.': + 'Configuração do {{region}} atualizada com sucesso. Novos modelos agora estão disponíveis.', + 'Authenticated successfully with {{region}}. API key is stored in settings.env.': + 'Autenticado com sucesso com {{region}}. A chave de API está armazenada em settings.env.', + 'Coding Plan (Global/Intl)': 'Coding Plan (Global/Intl)', }; diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 3806807d6..6854cb1e9 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1421,8 +1421,13 @@ export default { // Auth Dialog - View Titles and Labels // ============================================================================ 'Coding Plan': 'Coding Plan', + 'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, Китай)', + 'Coding Plan (Bailian, Global/Intl)': + 'Coding Plan (Bailian, Глобальный/Международный)', "Paste your api key of Bailian Coding Plan and you're all set!": 'Вставьте ваш API-ключ Bailian Coding Plan и всё готово!', + "Paste your api key of Coding Plan (Global/Intl) and you're all set!": + 'Вставьте ваш API-ключ Coding Plan (Глобальный/Международный) и всё готово!', Custom: 'Пользовательский', 'More instructions about configuring `modelProviders` manually.': 'Дополнительные инструкции по ручной настройке `modelProviders`.', @@ -1431,4 +1436,17 @@ export default { '(Press Enter to submit, Escape to cancel)': '(Нажмите Enter для отправки, Escape для отмены)', 'More instructions please check:': 'Дополнительные инструкции см.:', + + // ============================================================================ + // Coding Plan International Updates + // ============================================================================ + 'New model configurations are available for Bailian Coding Plan (China). Update now?': + 'Доступны новые конфигурации моделей для Bailian Coding Plan (Китай). Обновить сейчас?', + 'New model configurations are available for Coding Plan (Global/Intl). Update now?': + 'Доступны новые конфигурации моделей для Coding Plan (Глобальный/Международный). Обновить сейчас?', + '{{region}} configuration updated successfully. New models are now available.': + 'Конфигурация {{region}} успешно обновлена. Новые модели теперь доступны.', + 'Authenticated successfully with {{region}}. API key is stored in settings.env.': + 'Успешная аутентификация с {{region}}. API-ключ сохранён в settings.env.', + 'Coding Plan (Global/Intl)': 'Coding Plan (Глобальный/Международный)', }; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 60c7551f2..d8c434e4b 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1253,12 +1253,29 @@ export default { // ============================================================================ 'API-KEY': 'API-KEY', 'Coding Plan': 'Coding Plan', + 'Coding Plan (Bailian, China)': 'Coding Plan (百炼, 中国)', + 'Coding Plan (Bailian, Global/Intl)': 'Coding Plan (百炼, 全球/国际)', "Paste your api key of Bailian Coding Plan and you're all set!": '粘贴您的百炼 Coding Plan API Key,即可完成设置!', + "Paste your api key of Coding Plan (Global/Intl) and you're all set!": + '粘贴您的 Coding Plan (全球/国际) API Key,即可完成设置!', Custom: '自定义', 'More instructions about configuring `modelProviders` manually.': '关于手动配置 `modelProviders` 的更多说明。', 'Select API-KEY configuration mode:': '选择 API-KEY 配置模式:', '(Press Escape to go back)': '(按 Escape 键返回)', '(Press Enter to submit, Escape to cancel)': '(按 Enter 提交,Escape 取消)', + + // ============================================================================ + // Coding Plan International Updates + // ============================================================================ + 'New model configurations are available for Bailian Coding Plan (China). Update now?': + '百炼 Coding Plan (中国) 有新的模型配置可用。是否立即更新?', + 'New model configurations are available for Coding Plan (Global/Intl). Update now?': + 'Coding Plan (全球/国际) 有新的模型配置可用。是否立即更新?', + '{{region}} configuration updated successfully. New models are now available.': + '{{region}} 配置更新成功。新模型现已可用。', + 'Authenticated successfully with {{region}}. API key is stored in settings.env.': + '成功通过 {{region}} 认证。API Key 已存储在 settings.env 中。', + 'Coding Plan (Global/Intl)': 'Coding Plan (全球/国际)', }; diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index 17d464eed..24263f13a 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -17,6 +17,7 @@ import { useUIState } from '../contexts/UIStateContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { t } from '../../i18n/index.js'; +import { CodingPlanRegion } from '../../constants/codingPlan.js'; const MODEL_PROVIDERS_DOCUMENTATION_URL = 'https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/#modelproviders'; @@ -34,7 +35,7 @@ function parseDefaultAuthType( } // Sub-mode types for API-KEY authentication -type ApiKeySubMode = 'coding-plan' | 'custom'; +type ApiKeySubMode = 'coding-plan' | 'coding-plan-intl' | 'custom'; // View level for navigation type ViewLevel = 'main' | 'api-key-sub' | 'api-key-input' | 'custom-info'; @@ -52,6 +53,9 @@ export function AuthDialog(): React.JSX.Element { const [selectedIndex, setSelectedIndex] = useState(null); const [viewLevel, setViewLevel] = useState('main'); const [apiKeySubModeIndex, setApiKeySubModeIndex] = useState(0); + const [region, setRegion] = useState( + CodingPlanRegion.CHINA, + ); // Main authentication entries const mainItems = [ @@ -71,9 +75,14 @@ export function AuthDialog(): React.JSX.Element { const apiKeySubItems = [ { key: 'coding-plan', - label: t('Coding Plan (Bailian)'), + label: t('Coding Plan (Bailian, China)'), value: 'coding-plan' as ApiKeySubMode, }, + { + key: 'coding-plan-intl', + label: t('Coding Plan (Bailian, Global/Intl)'), + value: 'coding-plan-intl' as ApiKeySubMode, + }, { key: 'custom', label: t('Custom'), @@ -135,6 +144,10 @@ export function AuthDialog(): React.JSX.Element { onAuthError(null); if (subMode === 'coding-plan') { + setRegion(CodingPlanRegion.CHINA); + setViewLevel('api-key-input'); + } else if (subMode === 'coding-plan-intl') { + setRegion(CodingPlanRegion.GLOBAL); setViewLevel('api-key-input'); } else { setViewLevel('custom-info'); @@ -149,8 +162,8 @@ export function AuthDialog(): React.JSX.Element { return; } - // Submit to parent for processing - await handleCodingPlanSubmit(apiKey); + // Submit to parent for processing with region info + await handleCodingPlanSubmit(apiKey, region); }; const handleGoBack = () => { @@ -264,7 +277,11 @@ export function AuthDialog(): React.JSX.Element { // Render API key input for coding-plan mode const renderApiKeyInputView = () => ( - + ); diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index 0ea157af5..74cd2e8de 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -30,9 +30,9 @@ import { AuthState, MessageType } from '../types.js'; import type { HistoryItem } from '../types.js'; import { t } from '../../i18n/index.js'; import { - CODING_PLAN_MODELS, - CODING_PLAN_ENV_KEY, - CODING_PLAN_VERSION, + getCodingPlanConfig, + isCodingPlanConfig, + CodingPlanRegion, } from '../../constants/codingPlan.js'; export type { QwenAuthState } from '../hooks/useQwenAuth.js'; @@ -285,29 +285,36 @@ export const useAuthCommand = ( /** * Handle coding plan submission - generates configs from template and stores api-key + * @param apiKey - The API key to store + * @param region - The region to use (default: CHINA) */ const handleCodingPlanSubmit = useCallback( - async (apiKey: string) => { + async ( + apiKey: string, + region: CodingPlanRegion = CodingPlanRegion.CHINA, + ) => { try { setIsAuthenticating(true); setAuthError(null); - const envKeyName = CODING_PLAN_ENV_KEY; + // Get configuration based on region + const codingPlanConfig = getCodingPlanConfig(region); + const { template, envKey, version } = codingPlanConfig; // Get persist scope const persistScope = getPersistScopeForModelSelection(settings); // Store api-key in settings.env - settings.setValue(persistScope, `env.${envKeyName}`, apiKey); + settings.setValue(persistScope, `env.${envKey}`, apiKey); // Sync to process.env immediately so refreshAuth can read the apiKey - process.env[envKeyName] = apiKey; + process.env[envKey] = apiKey; // Generate model configs from template - const newConfigs: ProviderModelConfig[] = CODING_PLAN_MODELS.map( + const newConfigs: ProviderModelConfig[] = template.map( (templateConfig) => ({ ...templateConfig, - envKey: envKeyName, + envKey, }), ); @@ -317,17 +324,14 @@ export const useAuthCommand = ( settings.merged.modelProviders as ModelProvidersConfig | undefined )?.[AuthType.USE_OPENAI] || []; - // Identify Coding Plan configs by baseUrl + envKey + // Identify Coding Plan configs by baseUrl + envKey for the given region // Remove existing Coding Plan configs to ensure template changes are applied - const isCodingPlanConfig = (config: ProviderModelConfig) => - config.envKey === envKeyName && - CODING_PLAN_MODELS.some( - (template) => template.baseUrl === config.baseUrl, - ); + const checkIsCodingPlanConfig = (config: ProviderModelConfig) => + isCodingPlanConfig(config.baseUrl, config.envKey, region); - // Filter out existing Coding Plan configs, keep user custom configs + // Filter out existing Coding Plan configs for this region, keep user custom configs const nonCodingPlanConfigs = existingConfigs.filter( - (existing) => !isCodingPlanConfig(existing), + (existing) => !checkIsCodingPlanConfig(existing), ); // Add new Coding Plan configs at the beginning @@ -348,11 +352,12 @@ export const useAuthCommand = ( ); // Persist coding plan version for future update detection - settings.setValue( - persistScope, - 'codingPlan.version', - CODING_PLAN_VERSION, - ); + // Store version with region suffix to distinguish between China and Intl versions + const versionKey = + region === CodingPlanRegion.GLOBAL + ? 'codingPlan.versionIntl' + : 'codingPlan.version'; + settings.setValue(persistScope, versionKey, version); // If there are configs, use the first one as the model if (updatedConfigs.length > 0 && updatedConfigs[0]?.id) { @@ -382,11 +387,16 @@ export const useAuthCommand = ( onAuthChange?.(); // Add success message + const regionLabel = + region === CodingPlanRegion.GLOBAL + ? 'Coding Plan (Global/Intl)' + : 'Coding Plan'; addItem( { type: MessageType.INFO, text: t( - 'Authenticated successfully with Coding Plan. API key is stored in settings.env.', + 'Authenticated successfully with {{region}}. API key is stored in settings.env.', + { region: regionLabel }, ), }, Date.now(), diff --git a/packages/cli/src/ui/components/ApiKeyInput.tsx b/packages/cli/src/ui/components/ApiKeyInput.tsx index e4082be3a..a079e9956 100644 --- a/packages/cli/src/ui/components/ApiKeyInput.tsx +++ b/packages/cli/src/ui/components/ApiKeyInput.tsx @@ -11,23 +11,34 @@ import { TextInput } from './shared/TextInput.js'; import { theme } from '../semantic-colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { t } from '../../i18n/index.js'; +import { CodingPlanRegion } from '../../constants/codingPlan.js'; import Link from 'ink-link'; interface ApiKeyInputProps { onSubmit: (apiKey: string) => void; onCancel: () => void; + region?: CodingPlanRegion; } const CODING_PLAN_API_KEY_URL = 'https://bailian.console.aliyun.com/?tab=model#/efm/coding_plan'; +const CODING_PLAN_INTL_API_KEY_URL = + 'https://modelstudio.console.alibabacloud.com/ap-southeast-1/?tab=globalset#/efm/api_key'; + export function ApiKeyInput({ onSubmit, onCancel, + region = CodingPlanRegion.CHINA, }: ApiKeyInputProps): React.JSX.Element { const [apiKey, setApiKey] = useState(''); const [error, setError] = useState(null); + const apiKeyUrl = + region === CodingPlanRegion.GLOBAL + ? CODING_PLAN_INTL_API_KEY_URL + : CODING_PLAN_API_KEY_URL; + useKeypress( (key) => { if (key.name === 'escape') { @@ -59,9 +70,9 @@ export function ApiKeyInput({ {t('You can get your exclusive Coding Plan API-KEY here:')}
- + - {CODING_PLAN_API_KEY_URL} + {apiKeyUrl} diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index e4cb85003..7534b6d3a 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -15,6 +15,7 @@ import { type ApprovalMode, } from '@qwen-code/qwen-code-core'; import { type SettingScope } from '../../config/settings.js'; +import { type CodingPlanRegion } from '../../constants/codingPlan.js'; import type { AuthState } from '../types.js'; import { type VisionSwitchOutcome } from '../components/ModelSwitchDialog.js'; // OpenAICredentials type (previously imported from OpenAIKeyPrompt) @@ -40,7 +41,10 @@ export interface UIActions { authType: AuthType | undefined, credentials?: OpenAICredentials, ) => Promise; - handleCodingPlanSubmit: (apiKey: string) => Promise; + handleCodingPlanSubmit: ( + apiKey: string, + region?: CodingPlanRegion, + ) => Promise; setAuthState: (state: AuthState) => void; onAuthError: (error: string | null) => void; cancelAuthentication: () => void; diff --git a/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts index a004fbdcb..6a6a67ea6 100644 --- a/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts +++ b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts @@ -7,34 +7,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, waitFor } from '@testing-library/react'; import { useCodingPlanUpdates } from './useCodingPlanUpdates.js'; -import { CODING_PLAN_ENV_KEY } from '../../constants/codingPlan.js'; +import { + CODING_PLAN_ENV_KEY, + CODING_PLAN_INTL_ENV_KEY, + CODING_PLAN_BASE_URL, + CODING_PLAN_INTL_BASE_URL, + CODING_PLAN_VERSION, + CODING_PLAN_INTL_VERSION, +} from '../../constants/codingPlan.js'; import { AuthType } from '@qwen-code/qwen-code-core'; -// Mock the constants module -vi.mock('../../constants/codingPlan.js', async () => { - const actual = await vi.importActual('../../constants/codingPlan.js'); - return { - ...actual, - CODING_PLAN_VERSION: 'test-version-hash', - CODING_PLAN_MODELS: [ - { - id: 'test-model-1', - name: 'Test Model 1', - baseUrl: 'https://test.example.com/v1', - description: 'Test model 1', - envKey: 'BAILIAN_CODING_PLAN_API_KEY', - }, - { - id: 'test-model-2', - name: 'Test Model 2', - baseUrl: 'https://test.example.com/v1', - description: 'Test model 2', - envKey: 'BAILIAN_CODING_PLAN_API_KEY', - }, - ], - }; -}); - describe('useCodingPlanUpdates', () => { const mockSettings = { merged: { @@ -57,6 +39,7 @@ describe('useCodingPlanUpdates', () => { beforeEach(() => { vi.clearAllMocks(); delete process.env[CODING_PLAN_ENV_KEY]; + delete process.env[CODING_PLAN_INTL_ENV_KEY]; }); describe('version comparison', () => { @@ -74,8 +57,8 @@ describe('useCodingPlanUpdates', () => { expect(result.current.codingPlanUpdateRequest).toBeUndefined(); }); - it('should not show update prompt when versions match', () => { - mockSettings.merged.codingPlan = { version: 'test-version-hash' }; + it('should not show update prompt when China versions match', () => { + mockSettings.merged.codingPlan = { version: CODING_PLAN_VERSION }; const { result } = renderHook(() => useCodingPlanUpdates( @@ -88,7 +71,23 @@ describe('useCodingPlanUpdates', () => { expect(result.current.codingPlanUpdateRequest).toBeUndefined(); }); - it('should show update prompt when versions differ', async () => { + it('should not show update prompt when Global versions match', () => { + mockSettings.merged.codingPlan = { + versionIntl: CODING_PLAN_INTL_VERSION, + }; + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + expect(result.current.codingPlanUpdateRequest).toBeUndefined(); + }); + + it('should show update prompt when China versions differ', async () => { mockSettings.merged.codingPlan = { version: 'old-version-hash' }; const { result } = renderHook(() => @@ -103,21 +102,38 @@ describe('useCodingPlanUpdates', () => { expect(result.current.codingPlanUpdateRequest).toBeDefined(); }); + expect(result.current.codingPlanUpdateRequest?.prompt).toContain('China'); + }); + + it('should show update prompt when Global versions differ', async () => { + mockSettings.merged.codingPlan = { versionIntl: 'old-version-hash' }; + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.codingPlanUpdateRequest).toBeDefined(); + }); + expect(result.current.codingPlanUpdateRequest?.prompt).toContain( - 'New model configurations', + 'Global', ); }); }); describe('update execution', () => { - it('should execute update when user confirms', async () => { - process.env[CODING_PLAN_ENV_KEY] = 'test-api-key'; + it('should execute China region update when user confirms', async () => { mockSettings.merged.codingPlan = { version: 'old-version-hash' }; mockSettings.merged.modelProviders = { [AuthType.USE_OPENAI]: [ { - id: 'test-model-1', - baseUrl: 'https://test.example.com/v1', + id: 'test-model-china-1', + baseUrl: CODING_PLAN_BASE_URL, envKey: CODING_PLAN_ENV_KEY, }, { @@ -150,22 +166,81 @@ describe('useCodingPlanUpdates', () => { expect(mockSettings.setValue).toHaveBeenCalled(); }); - // Should update version + // Should update version with correct hash expect(mockSettings.setValue).toHaveBeenCalledWith( expect.anything(), 'codingPlan.version', - 'test-version-hash', + CODING_PLAN_VERSION, ); // Should reload and refresh auth expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled(); expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI); - // Should show success message + // Should show success message with region info expect(mockAddItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'info', - text: expect.stringContaining('updated successfully'), + text: expect.stringContaining('Coding Plan'), + }), + expect.any(Number), + ); + }); + + it('should execute Global region update when user confirms', async () => { + mockSettings.merged.codingPlan = { versionIntl: 'old-version-hash' }; + mockSettings.merged.modelProviders = { + [AuthType.USE_OPENAI]: [ + { + id: 'test-model-global-1', + baseUrl: CODING_PLAN_INTL_BASE_URL, + envKey: CODING_PLAN_INTL_ENV_KEY, + }, + { + id: 'custom-model', + baseUrl: 'https://custom.example.com', + envKey: 'CUSTOM_API_KEY', + }, + ], + }; + mockConfig.refreshAuth.mockResolvedValue(undefined); + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.codingPlanUpdateRequest).toBeDefined(); + }); + + // Confirm the update + await result.current.codingPlanUpdateRequest!.onConfirm(true); + + // Wait for async update to complete + await waitFor(() => { + expect(mockSettings.setValue).toHaveBeenCalled(); + }); + + // Should update versionIntl with correct hash + expect(mockSettings.setValue).toHaveBeenCalledWith( + expect.anything(), + 'codingPlan.versionIntl', + CODING_PLAN_INTL_VERSION, + ); + + // Should reload and refresh auth + expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled(); + expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI); + + // Should show success message with Global region info + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + text: expect.stringContaining('Global'), }), expect.any(Number), ); @@ -194,8 +269,82 @@ describe('useCodingPlanUpdates', () => { expect(mockConfig.reloadModelProvidersConfig).not.toHaveBeenCalled(); }); + it('should only update configs for the specific region', async () => { + mockSettings.merged.codingPlan = { version: 'old-version-hash' }; + const chinaConfig = { + id: 'test-model-china-1', + baseUrl: CODING_PLAN_BASE_URL, + envKey: CODING_PLAN_ENV_KEY, + }; + const globalConfig = { + id: 'test-model-global-1', + baseUrl: CODING_PLAN_INTL_BASE_URL, + envKey: CODING_PLAN_INTL_ENV_KEY, + }; + const customConfig = { + id: 'custom-model', + baseUrl: 'https://custom.example.com', + envKey: 'CUSTOM_API_KEY', + }; + mockSettings.merged.modelProviders = { + [AuthType.USE_OPENAI]: [chinaConfig, globalConfig, customConfig], + }; + mockConfig.refreshAuth.mockResolvedValue(undefined); + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.codingPlanUpdateRequest).toBeDefined(); + }); + + await result.current.codingPlanUpdateRequest!.onConfirm(true); + + // Wait for async update to complete + await waitFor(() => { + expect(mockSettings.setValue).toHaveBeenCalled(); + }); + + // Get the updated configs passed to setValue + const setValueCalls = mockSettings.setValue.mock.calls; + const modelProvidersCall = setValueCalls.find((call: unknown[]) => + (call[1] as string).includes('modelProviders'), + ); + + // Should preserve Global config and custom config, only update China configs + expect(modelProvidersCall).toBeDefined(); + const updatedConfigs = modelProvidersCall![2] as Array< + Record + >; + + // Should have new China configs + preserved Global config + custom config + expect(updatedConfigs.length).toBeGreaterThanOrEqual(3); + + // Should contain the Global config (not modified) + expect( + updatedConfigs.some( + (c: Record) => c['id'] === 'test-model-global-1', + ), + ).toBe(true); + + // Should contain the custom config + expect( + updatedConfigs.some( + (c: Record) => c['id'] === 'custom-model', + ), + ).toBe(true); + + // Should reload and refresh auth + expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled(); + expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI); + }); + it('should preserve non-Coding Plan configs during update', async () => { - process.env[CODING_PLAN_ENV_KEY] = 'test-api-key'; mockSettings.merged.codingPlan = { version: 'old-version-hash' }; const customConfig = { id: 'custom-model', @@ -205,8 +354,8 @@ describe('useCodingPlanUpdates', () => { mockSettings.merged.modelProviders = { [AuthType.USE_OPENAI]: [ { - id: 'test-model-1', - baseUrl: 'https://test.example.com/v1', + id: 'test-model-china-1', + baseUrl: CODING_PLAN_BASE_URL, envKey: CODING_PLAN_ENV_KEY, }, customConfig, @@ -233,10 +382,38 @@ describe('useCodingPlanUpdates', () => { // Should preserve custom config - verify setValue was called expect(mockSettings.setValue).toHaveBeenCalled(); }); + + // Get the updated configs passed to setValue + const setValueCalls = mockSettings.setValue.mock.calls; + const modelProvidersCall = setValueCalls.find((call: unknown[]) => + (call[1] as string).includes('modelProviders'), + ); + + // Should preserve custom config + expect(modelProvidersCall).toBeDefined(); + const updatedConfigs = modelProvidersCall![2] as Array< + Record + >; + expect( + updatedConfigs.some( + (c: Record) => c['id'] === 'custom-model', + ), + ).toBe(true); }); - it('should handle missing API key error', async () => { + it('should handle update errors gracefully', async () => { mockSettings.merged.codingPlan = { version: 'old-version-hash' }; + mockSettings.merged.modelProviders = { + [AuthType.USE_OPENAI]: [ + { + id: 'test-model-china-1', + baseUrl: CODING_PLAN_BASE_URL, + envKey: CODING_PLAN_ENV_KEY, + }, + ], + }; + // Simulate an error during refreshAuth + mockConfig.refreshAuth.mockRejectedValue(new Error('Network error')); const { result } = renderHook(() => useCodingPlanUpdates( @@ -253,12 +430,14 @@ describe('useCodingPlanUpdates', () => { await result.current.codingPlanUpdateRequest!.onConfirm(true); // Should show error message - expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'error', - }), - expect.any(Number), - ); + await waitFor(() => { + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + }), + expect.any(Number), + ); + }); }); }); diff --git a/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts b/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts index 85584def8..3d6e6da23 100644 --- a/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts +++ b/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts @@ -10,9 +10,11 @@ import { AuthType } from '@qwen-code/qwen-code-core'; import type { LoadedSettings } from '../../config/settings.js'; import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; import { - CODING_PLAN_MODELS, - CODING_PLAN_ENV_KEY, + isCodingPlanConfig, CODING_PLAN_VERSION, + CODING_PLAN_INTL_VERSION, + getCodingPlanConfig, + CodingPlanRegion, } from '../../constants/codingPlan.js'; import { t } from '../../i18n/index.js'; @@ -21,20 +23,6 @@ export interface CodingPlanUpdateRequest { onConfirm: (confirmed: boolean) => void; } -/** - * Checks if a config is a Coding Plan configuration by matching baseUrl and envKey. - * This ensures only configs from the Coding Plan provider are identified. - */ -function isCodingPlanConfig(config: { - baseUrl?: string; - envKey?: string; -}): boolean { - return ( - config.envKey === CODING_PLAN_ENV_KEY && - CODING_PLAN_MODELS.some((template) => template.baseUrl === config.baseUrl) - ); -} - /** * Hook for detecting and handling Coding Plan template updates. * Compares the persisted version with the current template version @@ -55,134 +43,161 @@ export function useCodingPlanUpdates( /** * Execute the Coding Plan configuration update. * Removes old Coding Plan configs and replaces them with new ones from the template. + * Automatically detects whether the user is using China or Intl version. */ - const executeUpdate = useCallback(async () => { - try { - const persistScope = getPersistScopeForModelSelection(settings); + const executeUpdate = useCallback( + async (region: CodingPlanRegion = CodingPlanRegion.CHINA) => { + try { + const persistScope = getPersistScopeForModelSelection(settings); - // Get current configs - const currentConfigs = - ( - settings.merged.modelProviders as - | Record>> - | undefined - )?.[AuthType.USE_OPENAI] || []; + // Get current configs + const currentConfigs = + ( + settings.merged.modelProviders as + | Record>> + | undefined + )?.[AuthType.USE_OPENAI] || []; - // Filter out Coding Plan configs (keep user custom configs) - const nonCodingPlanConfigs = currentConfigs.filter( - (cfg) => - !isCodingPlanConfig({ - baseUrl: cfg['baseUrl'] as string | undefined, - envKey: cfg['envKey'] as string | undefined, - }), - ); - - // Generate new configs from template with the stored API key - const apiKey = process.env[CODING_PLAN_ENV_KEY]; - if (!apiKey) { - throw new Error( - t( - 'Coding Plan API key not found. Please re-authenticate with Coding Plan.', - ), + // Filter out Coding Plan configs for the given region (keep user custom configs) + const nonCodingPlanConfigs = currentConfigs.filter( + (cfg) => + !isCodingPlanConfig( + cfg['baseUrl'] as string | undefined, + cfg['envKey'] as string | undefined, + region, + ), ); + + // Get the correct configuration based on region + const codingPlanConfig = getCodingPlanConfig(region); + const { template, envKey, version } = codingPlanConfig; + + // Generate new configs from template + const newConfigs = template.map((templateConfig) => ({ + ...templateConfig, + envKey, + })); + + // Combine: new Coding Plan configs at the front, user configs preserved + const updatedConfigs = [ + ...newConfigs, + ...(nonCodingPlanConfigs as Array>), + ] as Array>; + + // Persist updated model providers + settings.setValue( + persistScope, + `modelProviders.${AuthType.USE_OPENAI}`, + updatedConfigs, + ); + + // Update the version with region-specific key + const versionKey = + region === CodingPlanRegion.GLOBAL + ? 'codingPlan.versionIntl' + : 'codingPlan.version'; + settings.setValue(persistScope, versionKey, version); + + // Hot-reload model providers configuration + const updatedModelProviders = { + ...(settings.merged.modelProviders as + | Record + | undefined), + [AuthType.USE_OPENAI]: updatedConfigs, + }; + config.reloadModelProvidersConfig( + updatedModelProviders as unknown as ModelProvidersConfig, + ); + + // Refresh auth with the new configuration + await config.refreshAuth(AuthType.USE_OPENAI); + + const regionLabel = + region === CodingPlanRegion.GLOBAL + ? 'Coding Plan (Global/Intl)' + : 'Coding Plan'; + addItem( + { + type: 'info', + text: t( + '{{region}} configuration updated successfully. New models are now available.', + { region: regionLabel }, + ), + }, + Date.now(), + ); + + return true; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + addItem( + { + type: 'error', + text: t('Failed to update Coding Plan configuration: {{message}}', { + message: errorMessage, + }), + }, + Date.now(), + ); + return false; } - - const newConfigs = CODING_PLAN_MODELS.map((templateConfig) => ({ - ...templateConfig, - envKey: CODING_PLAN_ENV_KEY, - })); - - // Combine: new Coding Plan configs at the front, user configs preserved - const updatedConfigs = [ - ...newConfigs, - ...(nonCodingPlanConfigs as Array>), - ] as Array>; - - // Persist updated model providers - settings.setValue( - persistScope, - `modelProviders.${AuthType.USE_OPENAI}`, - updatedConfigs, - ); - - // Update the version - settings.setValue( - persistScope, - 'codingPlan.version', - CODING_PLAN_VERSION, - ); - - // Hot-reload model providers configuration - const updatedModelProviders = { - ...(settings.merged.modelProviders as - | Record - | undefined), - [AuthType.USE_OPENAI]: updatedConfigs, - }; - config.reloadModelProvidersConfig( - updatedModelProviders as unknown as ModelProvidersConfig, - ); - - // Refresh auth with the new configuration - await config.refreshAuth(AuthType.USE_OPENAI); - - addItem( - { - type: 'info', - text: t( - 'Coding Plan configuration updated successfully. New models are now available.', - ), - }, - Date.now(), - ); - - return true; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - addItem( - { - type: 'error', - text: t('Failed to update Coding Plan configuration: {{message}}', { - message: errorMessage, - }), - }, - Date.now(), - ); - return false; - } - }, [settings, config, addItem]); + }, + [settings, config, addItem], + ); /** * Check for version mismatch and prompt user for update if needed. */ const checkForUpdates = useCallback(() => { - const savedVersion = ( - settings.merged as { codingPlan?: { version?: string } } - ).codingPlan?.version; + const mergedSettings = settings.merged as { + codingPlan?: { version?: string; versionIntl?: string }; + }; + + const savedChinaVersion = mergedSettings.codingPlan?.version; + const savedIntlVersion = mergedSettings.codingPlan?.versionIntl; + + // Determine which version the user is using based on saved version + // Check China version first + if (savedChinaVersion) { + if (savedChinaVersion !== CODING_PLAN_VERSION) { + // China version mismatch - prompt for update + setUpdateRequest({ + prompt: t( + 'New model configurations are available for Bailian Coding Plan (China). Update now?', + ), + onConfirm: async (confirmed: boolean) => { + setUpdateRequest(undefined); + if (confirmed) { + await executeUpdate(CodingPlanRegion.CHINA); + } + }, + }); + return; + } + } + + // Check Intl version + if (savedIntlVersion) { + if (savedIntlVersion !== CODING_PLAN_INTL_VERSION) { + // Intl version mismatch - prompt for update + setUpdateRequest({ + prompt: t( + 'New model configurations are available for Coding Plan (Global/Intl). Update now?', + ), + onConfirm: async (confirmed: boolean) => { + setUpdateRequest(undefined); + if (confirmed) { + await executeUpdate(CodingPlanRegion.GLOBAL); + } + }, + }); + return; + } + } // If no version is stored, user hasn't used Coding Plan yet - skip check - if (!savedVersion) { - return; - } - - // If versions match, no update needed - if (savedVersion === CODING_PLAN_VERSION) { - return; - } - - // Version mismatch - prompt user for update - setUpdateRequest({ - prompt: t( - 'New model configurations are available for Bailian Coding Plan. Update now?', - ), - onConfirm: async (confirmed: boolean) => { - setUpdateRequest(undefined); - if (confirmed) { - await executeUpdate(); - } - }, - }); + return; }, [settings, executeUpdate]); // Check for updates on mount From c1789a04582c5966fea88ce26ae92f8a9ff3e3b9 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Wed, 18 Feb 2026 08:29:34 +0800 Subject: [PATCH 56/81] fix(cli): update Coding Plan Global/Intl labels and fix description logic - Fix AuthDialog to show correct description for coding-plan-intl mode - Update i18n keys from 'Coding Plan (Bailian, Global/Intl)' to 'Bailian Coding Plan (Global/Intl)' - Sync translations across all locales (en, zh, de, ja, pt, ru) Co-authored-by: Qwen-Coder --- packages/cli/src/constants/codingPlan.ts | 241 +++++++++--------- packages/cli/src/i18n/locales/de.js | 11 +- packages/cli/src/i18n/locales/en.js | 11 +- packages/cli/src/i18n/locales/ja.js | 12 +- packages/cli/src/i18n/locales/pt.js | 11 +- packages/cli/src/i18n/locales/ru.js | 13 +- packages/cli/src/i18n/locales/zh.js | 11 +- packages/cli/src/ui/auth/AuthDialog.tsx | 10 +- packages/cli/src/ui/auth/useAuth.ts | 39 +-- .../src/ui/hooks/useCodingPlanUpdates.test.ts | 204 ++++++++++----- .../cli/src/ui/hooks/useCodingPlanUpdates.ts | 107 ++++---- 11 files changed, 361 insertions(+), 309 deletions(-) diff --git a/packages/cli/src/constants/codingPlan.ts b/packages/cli/src/constants/codingPlan.ts index 845f85d19..0a3084658 100644 --- a/packages/cli/src/constants/codingPlan.ts +++ b/packages/cli/src/constants/codingPlan.ts @@ -22,78 +22,11 @@ export enum CodingPlanRegion { export type CodingPlanTemplate = ModelConfig[]; /** - * Environment variable key for storing the coding plan API key (China/Bailian) + * Environment variable key for storing the coding plan API key. + * Unified key for both regions since they are mutually exclusive. */ export const CODING_PLAN_ENV_KEY = 'BAILIAN_CODING_PLAN_API_KEY'; -/** - * Environment variable key for storing the coding plan API key (Global/Intl) - */ -export const CODING_PLAN_INTL_ENV_KEY = 'BAILIAN_CODING_PLAN_INTL_API_KEY'; - -/** - * Base URL for China/Bailian Coding Plan - */ -export const CODING_PLAN_BASE_URL = 'https://coding.dashscope.aliyuncs.com/v1'; - -/** - * Base URL for Global/Intl Coding Plan - */ -export const CODING_PLAN_INTL_BASE_URL = - 'https://coding-intl.dashscope.aliyuncs.com/v1'; - -/** - * CODING_PLAN_MODELS defines the model configurations for coding-plan mode (China/Bailian). - */ -export const CODING_PLAN_MODELS: CodingPlanTemplate = [ - { - id: 'qwen3-coder-plus', - name: 'qwen3-coder-plus', - baseUrl: CODING_PLAN_BASE_URL, - description: 'qwen3-coder-plus model from Bailian Coding Plan', - envKey: CODING_PLAN_ENV_KEY, - }, - { - id: 'qwen3-max-2026-01-23', - name: 'qwen3-max-2026-01-23', - description: - 'qwen3-max model with thinking enabled from Bailian Coding Plan', - baseUrl: CODING_PLAN_BASE_URL, - envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { - enable_thinking: true, - }, - }, - }, -]; - -/** - * CODING_PLAN_INTL_MODELS defines the model configurations for coding-plan mode (Global/Intl). - */ -export const CODING_PLAN_INTL_MODELS: CodingPlanTemplate = [ - { - id: 'qwen3-coder-plus', - name: 'qwen3-coder-plus', - baseUrl: CODING_PLAN_INTL_BASE_URL, - description: 'qwen3-coder-plus model from Coding Plan (Global/Intl)', - envKey: CODING_PLAN_INTL_ENV_KEY, - }, - { - id: 'qwen3-max-2026-01-23', - name: 'qwen3-max-2026-01-23', - description: - 'qwen3-max model with thinking enabled from Coding Plan (Global/Intl)', - baseUrl: CODING_PLAN_INTL_BASE_URL, - envKey: CODING_PLAN_INTL_ENV_KEY, - generationConfig: { - extra_body: { - enable_thinking: true, - }, - }, - }, -]; - /** * Computes the version hash for the coding plan template. * Uses SHA256 of the JSON-serialized template for deterministic versioning. @@ -106,81 +39,149 @@ export function computeCodingPlanVersion(template: CodingPlanTemplate): string { } /** - * Current version of the China/Bailian coding plan template. - * Computed at runtime from the template content. + * Generate the complete coding plan template for a specific region. + * China region uses legacy description to maintain backward compatibility. + * Global region uses new description with region indicator. + * @param region - The region to generate template for + * @returns Complete model configuration array for the region */ -export const CODING_PLAN_VERSION = computeCodingPlanVersion(CODING_PLAN_MODELS); +export function generateCodingPlanTemplate( + region: CodingPlanRegion, +): CodingPlanTemplate { + if (region === CodingPlanRegion.CHINA) { + // China region uses legacy fields to maintain backward compatibility + // This ensures existing users don't get prompted for unnecessary updates + return [ + { + id: 'qwen3-coder-plus', + name: 'qwen3-coder-plus', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + description: 'qwen3-coder-plus model from Bailian Coding Plan', + envKey: CODING_PLAN_ENV_KEY, + }, + { + id: 'qwen3-max-2026-01-23', + name: 'qwen3-max-2026-01-23', + description: + 'qwen3-max model with thinking enabled from Bailian Coding Plan', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + }, + }, + ]; + } + + // Global region uses new description with region indicator + return [ + { + id: 'qwen3-coder-plus', + name: 'qwen3-coder-plus', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + description: 'qwen3-coder-plus model from Coding Plan (Global/Intl)', + envKey: CODING_PLAN_ENV_KEY, + }, + { + id: 'qwen3-max-2026-01-23', + name: 'qwen3-max-2026-01-23', + description: + 'qwen3-max model with thinking enabled from Coding Plan (Global/Intl)', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + }, + }, + ]; +} /** - * Current version of the Global/Intl coding plan template. - * Computed at runtime from the template content. + * Get the complete configuration for a specific region. + * @param region - The region to use + * @returns Object containing template, baseUrl, and version */ -export const CODING_PLAN_INTL_VERSION = computeCodingPlanVersion( - CODING_PLAN_INTL_MODELS, -); +export function getCodingPlanConfig(region: CodingPlanRegion) { + const template = generateCodingPlanTemplate(region); + const baseUrl = + region === CodingPlanRegion.CHINA + ? 'https://coding.dashscope.aliyuncs.com/v1' + : 'https://coding-intl.dashscope.aliyuncs.com/v1'; + const regionName = + region === CodingPlanRegion.CHINA + ? 'Bailian Coding Plan (China)' + : 'Bailian Coding Plan (Global/Intl)'; + + return { + template, + baseUrl, + regionName, + version: computeCodingPlanVersion(template), + }; +} /** - * All coding plan templates for both regions. - * Used for update detection and filtering. + * Get all unique base URLs for coding plan (used for filtering/config detection). + * @returns Array of base URLs */ -export const ALL_CODING_PLAN_TEMPLATES: CodingPlanTemplate = [ - ...CODING_PLAN_MODELS, - ...CODING_PLAN_INTL_MODELS, -]; +export function getCodingPlanBaseUrls(): string[] { + return [ + 'https://coding.dashscope.aliyuncs.com/v1', + 'https://coding-intl.dashscope.aliyuncs.com/v1', + ]; +} /** - * Check if a config belongs to any Coding Plan template (China or Intl). + * Check if a config belongs to Coding Plan (any region). + * Returns the region if matched, or false if not a Coding Plan config. * @param baseUrl - The baseUrl to check * @param envKey - The envKey to check - * @param region - Optional region to limit the check to a specific region - * @returns true if the config matches any Coding Plan template + * @returns The region if matched, false otherwise */ export function isCodingPlanConfig( baseUrl: string | undefined, envKey: string | undefined, - region?: CodingPlanRegion, -): boolean { +): CodingPlanRegion | false { if (!baseUrl || !envKey) { return false; } - // If region is specified, only check that region's templates - if (region === CodingPlanRegion.GLOBAL) { - return CODING_PLAN_INTL_MODELS.some( - (template) => template.baseUrl === baseUrl && template.envKey === envKey, - ); - } else if (region === CodingPlanRegion.CHINA) { - return CODING_PLAN_MODELS.some( - (template) => template.baseUrl === baseUrl && template.envKey === envKey, - ); + // Must use the unified envKey + if (envKey !== CODING_PLAN_ENV_KEY) { + return false; } - // No region specified, check all templates - return ALL_CODING_PLAN_TEMPLATES.some( - (template) => template.baseUrl === baseUrl && template.envKey === envKey, - ); + // Check which region's baseUrl matches + if (baseUrl === 'https://coding.dashscope.aliyuncs.com/v1') { + return CodingPlanRegion.CHINA; + } + if (baseUrl === 'https://coding-intl.dashscope.aliyuncs.com/v1') { + return CodingPlanRegion.GLOBAL; + } + + return false; } /** - * Get the appropriate template and env key for the selected region. - * @param region - The region to use (default: CHINA) - * @returns Object containing template, envKey, version, and baseUrl + * Get region from baseUrl. + * @param baseUrl - The baseUrl to check + * @returns The region if matched, null otherwise */ -export function getCodingPlanConfig( - region: CodingPlanRegion = CodingPlanRegion.CHINA, -) { - if (region === CodingPlanRegion.GLOBAL) { - return { - template: CODING_PLAN_INTL_MODELS, - envKey: CODING_PLAN_INTL_ENV_KEY, - version: CODING_PLAN_INTL_VERSION, - baseUrl: CODING_PLAN_INTL_BASE_URL, - }; +export function getRegionFromBaseUrl( + baseUrl: string | undefined, +): CodingPlanRegion | null { + if (!baseUrl) return null; + + if (baseUrl === 'https://coding.dashscope.aliyuncs.com/v1') { + return CodingPlanRegion.CHINA; } - return { - template: CODING_PLAN_MODELS, - envKey: CODING_PLAN_ENV_KEY, - version: CODING_PLAN_VERSION, - baseUrl: CODING_PLAN_BASE_URL, - }; + if (baseUrl === 'https://coding-intl.dashscope.aliyuncs.com/v1') { + return CodingPlanRegion.GLOBAL; + } + + return null; } diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 291e14516..2a00a7b9e 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1418,11 +1418,11 @@ export default { // ============================================================================ 'Coding Plan': 'Coding Plan', 'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, China)', - 'Coding Plan (Bailian, Global/Intl)': 'Coding Plan (Bailian, Global/Intl)', + 'Bailian Coding Plan (Global/Intl)': 'Bailian Coding Plan (Global/Intl)', "Paste your api key of Bailian Coding Plan and you're all set!": 'Fügen Sie Ihren Bailian Coding Plan API-Schlüssel ein und Sie sind bereit!', - "Paste your api key of Coding Plan (Global/Intl) and you're all set!": - 'Fügen Sie Ihren Coding Plan (Global/Intl) API-Schlüssel ein und Sie sind bereit!', + "Paste your api key of Bailian Coding Plan (Global/Intl) and you're all set!": + 'Fügen Sie Ihren Bailian Coding Plan (Global/Intl) API-Schlüssel ein und Sie sind bereit!', Custom: 'Benutzerdefiniert', 'More instructions about configuring `modelProviders` manually.': 'Weitere Anweisungen zur manuellen Konfiguration von `modelProviders`.', @@ -1438,11 +1438,10 @@ export default { // ============================================================================ 'New model configurations are available for Bailian Coding Plan (China). Update now?': 'Neue Modellkonfigurationen sind für Bailian Coding Plan (China) verfügbar. Jetzt aktualisieren?', - 'New model configurations are available for Coding Plan (Global/Intl). Update now?': - 'Neue Modellkonfigurationen sind für Coding Plan (Global/Intl) verfügbar. Jetzt aktualisieren?', + 'New model configurations are available for Bailian Coding Plan (Global/Intl). Update now?': + 'Neue Modellkonfigurationen sind für Bailian Coding Plan (Global/Intl) verfügbar. Jetzt aktualisieren?', '{{region}} configuration updated successfully. New models are now available.': '{{region}}-Konfiguration erfolgreich aktualisiert. Neue Modelle sind jetzt verfügbar.', 'Authenticated successfully with {{region}}. API key is stored in settings.env.': 'Erfolgreich mit {{region}} authentifiziert. API-Schlüssel ist in settings.env gespeichert.', - 'Coding Plan (Global/Intl)': 'Coding Plan (Global/Intl)', }; diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index a650d927f..234ff3ab8 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1419,11 +1419,11 @@ export default { // ============================================================================ 'Coding Plan': 'Coding Plan', 'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, China)', - 'Coding Plan (Bailian, Global/Intl)': 'Coding Plan (Bailian, Global/Intl)', + 'Bailian Coding Plan (Global/Intl)': 'Bailian Coding Plan (Global/Intl)', "Paste your api key of Bailian Coding Plan and you're all set!": "Paste your api key of Bailian Coding Plan and you're all set!", - "Paste your api key of Coding Plan (Global/Intl) and you're all set!": - "Paste your api key of Coding Plan (Global/Intl) and you're all set!", + "Paste your api key of Bailian Coding Plan (Global/Intl) and you're all set!": + "Paste your api key of Bailian Coding Plan (Global/Intl) and you're all set!", Custom: 'Custom', 'More instructions about configuring `modelProviders` manually.': 'More instructions about configuring `modelProviders` manually.', @@ -1437,11 +1437,10 @@ export default { // ============================================================================ 'New model configurations are available for Bailian Coding Plan (China). Update now?': 'New model configurations are available for Bailian Coding Plan (China). Update now?', - 'New model configurations are available for Coding Plan (Global/Intl). Update now?': - 'New model configurations are available for Coding Plan (Global/Intl). Update now?', + 'New model configurations are available for Bailian Coding Plan (Global/Intl). Update now?': + 'New model configurations are available for Bailian Coding Plan (Global/Intl). Update now?', '{{region}} configuration updated successfully. New models are now available.': '{{region}} configuration updated successfully. New models are now available.', 'Authenticated successfully with {{region}}. API key is stored in settings.env.': 'Authenticated successfully with {{region}}. API key is stored in settings.env.', - 'Coding Plan (Global/Intl)': 'Coding Plan (Global/Intl)', }; diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index e20e33e55..e094dfb09 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -929,12 +929,11 @@ export default { // ============================================================================ 'Coding Plan': 'Coding Plan', 'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, 中国)', - 'Coding Plan (Bailian, Global/Intl)': - 'Coding Plan (Bailian, グローバル/国際)', + 'Bailian Coding Plan (Global/Intl)': 'Bailian Coding Plan (グローバル/国際)', "Paste your api key of Bailian Coding Plan and you're all set!": 'Bailian Coding PlanのAPIキーを貼り付けるだけで準備完了です!', - "Paste your api key of Coding Plan (Global/Intl) and you're all set!": - 'Coding Plan (グローバル/国際) のAPIキーを貼り付けるだけで準備完了です!', + "Paste your api key of Bailian Coding Plan (Global/Intl) and you're all set!": + 'Bailian Coding Plan (グローバル/国際) のAPIキーを貼り付けるだけで準備完了です!', Custom: 'カスタム', 'More instructions about configuring `modelProviders` manually.': '`modelProviders`を手動で設定する方法の詳細はこちら。', @@ -949,11 +948,10 @@ export default { // ============================================================================ 'New model configurations are available for Bailian Coding Plan (China). Update now?': 'Bailian Coding Plan (中国) の新しいモデル設定が利用可能です。今すぐ更新しますか?', - 'New model configurations are available for Coding Plan (Global/Intl). Update now?': - 'Coding Plan (グローバル/国際) の新しいモデル設定が利用可能です。今すぐ更新しますか?', + 'New model configurations are available for Bailian Coding Plan (Global/Intl). Update now?': + 'Bailian Coding Plan (グローバル/国際) の新しいモデル設定が利用可能です。今すぐ更新しますか?', '{{region}} configuration updated successfully. New models are now available.': '{{region}} の設定が正常に更新されました。新しいモデルが利用可能になりました。', 'Authenticated successfully with {{region}}. API key is stored in settings.env.': '{{region}} での認証に成功しました。APIキーは settings.env に保存されています。', - 'Coding Plan (Global/Intl)': 'Coding Plan (グローバル/国際)', }; diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 3519fadf2..1720a891a 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1432,11 +1432,11 @@ export default { // ============================================================================ 'Coding Plan': 'Coding Plan', 'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, China)', - 'Coding Plan (Bailian, Global/Intl)': 'Coding Plan (Bailian, Global/Intl)', + 'Bailian Coding Plan (Global/Intl)': 'Bailian Coding Plan (Global/Intl)', "Paste your api key of Bailian Coding Plan and you're all set!": 'Cole sua chave de API do Bailian Coding Plan e pronto!', - "Paste your api key of Coding Plan (Global/Intl) and you're all set!": - 'Cole sua chave de API do Coding Plan (Global/Intl) e pronto!', + "Paste your api key of Bailian Coding Plan (Global/Intl) and you're all set!": + 'Cole sua chave de API do Bailian Coding Plan (Global/Intl) e pronto!', Custom: 'Personalizado', 'More instructions about configuring `modelProviders` manually.': 'Mais instruções sobre como configurar `modelProviders` manualmente.', @@ -1452,11 +1452,10 @@ export default { // ============================================================================ 'New model configurations are available for Bailian Coding Plan (China). Update now?': 'Novas configurações de modelo estão disponíveis para o Bailian Coding Plan (China). Atualizar agora?', - 'New model configurations are available for Coding Plan (Global/Intl). Update now?': - 'Novas configurações de modelo estão disponíveis para o Coding Plan (Global/Intl). Atualizar agora?', + 'New model configurations are available for Bailian Coding Plan (Global/Intl). Update now?': + 'Novas configurações de modelo estão disponíveis para o Bailian Coding Plan (Global/Intl). Atualizar agora?', '{{region}} configuration updated successfully. New models are now available.': 'Configuração do {{region}} atualizada com sucesso. Novos modelos agora estão disponíveis.', 'Authenticated successfully with {{region}}. API key is stored in settings.env.': 'Autenticado com sucesso com {{region}}. A chave de API está armazenada em settings.env.', - 'Coding Plan (Global/Intl)': 'Coding Plan (Global/Intl)', }; diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 6854cb1e9..49a5c7226 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1422,12 +1422,12 @@ export default { // ============================================================================ 'Coding Plan': 'Coding Plan', 'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, Китай)', - 'Coding Plan (Bailian, Global/Intl)': - 'Coding Plan (Bailian, Глобальный/Международный)', + 'Bailian Coding Plan (Global/Intl)': + 'Bailian Coding Plan (Глобальный/Международный)', "Paste your api key of Bailian Coding Plan and you're all set!": 'Вставьте ваш API-ключ Bailian Coding Plan и всё готово!', - "Paste your api key of Coding Plan (Global/Intl) and you're all set!": - 'Вставьте ваш API-ключ Coding Plan (Глобальный/Международный) и всё готово!', + "Paste your api key of Bailian Coding Plan (Global/Intl) and you're all set!": + 'Вставьте ваш API-ключ Bailian Coding Plan (Глобальный/Международный) и всё готово!', Custom: 'Пользовательский', 'More instructions about configuring `modelProviders` manually.': 'Дополнительные инструкции по ручной настройке `modelProviders`.', @@ -1442,11 +1442,10 @@ export default { // ============================================================================ 'New model configurations are available for Bailian Coding Plan (China). Update now?': 'Доступны новые конфигурации моделей для Bailian Coding Plan (Китай). Обновить сейчас?', - 'New model configurations are available for Coding Plan (Global/Intl). Update now?': - 'Доступны новые конфигурации моделей для Coding Plan (Глобальный/Международный). Обновить сейчас?', + 'New model configurations are available for Bailian Coding Plan (Global/Intl). Update now?': + 'Доступны новые конфигурации моделей для Bailian Coding Plan (Глобальный/Международный). Обновить сейчас?', '{{region}} configuration updated successfully. New models are now available.': 'Конфигурация {{region}} успешно обновлена. Новые модели теперь доступны.', 'Authenticated successfully with {{region}}. API key is stored in settings.env.': 'Успешная аутентификация с {{region}}. API-ключ сохранён в settings.env.', - 'Coding Plan (Global/Intl)': 'Coding Plan (Глобальный/Международный)', }; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index d8c434e4b..a1d6651ef 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1254,11 +1254,11 @@ export default { 'API-KEY': 'API-KEY', 'Coding Plan': 'Coding Plan', 'Coding Plan (Bailian, China)': 'Coding Plan (百炼, 中国)', - 'Coding Plan (Bailian, Global/Intl)': 'Coding Plan (百炼, 全球/国际)', + 'Bailian Coding Plan (Global/Intl)': 'Bailian Coding Plan (百炼, 全球/国际)', "Paste your api key of Bailian Coding Plan and you're all set!": '粘贴您的百炼 Coding Plan API Key,即可完成设置!', - "Paste your api key of Coding Plan (Global/Intl) and you're all set!": - '粘贴您的 Coding Plan (全球/国际) API Key,即可完成设置!', + "Paste your api key of Bailian Coding Plan (Global/Intl) and you're all set!": + '粘贴您的 Bailian Coding Plan (全球/国际) API Key,即可完成设置!', Custom: '自定义', 'More instructions about configuring `modelProviders` manually.': '关于手动配置 `modelProviders` 的更多说明。', @@ -1271,11 +1271,10 @@ export default { // ============================================================================ 'New model configurations are available for Bailian Coding Plan (China). Update now?': '百炼 Coding Plan (中国) 有新的模型配置可用。是否立即更新?', - 'New model configurations are available for Coding Plan (Global/Intl). Update now?': - 'Coding Plan (全球/国际) 有新的模型配置可用。是否立即更新?', + 'New model configurations are available for Bailian Coding Plan (Global/Intl). Update now?': + 'Bailian Coding Plan (全球/国际) 有新的模型配置可用。是否立即更新?', '{{region}} configuration updated successfully. New models are now available.': '{{region}} 配置更新成功。新模型现已可用。', 'Authenticated successfully with {{region}}. API key is stored in settings.env.': '成功通过 {{region}} 认证。API Key 已存储在 settings.env 中。', - 'Coding Plan (Global/Intl)': 'Coding Plan (全球/国际)', }; diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index 24263f13a..8c9a0aba7 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -80,7 +80,7 @@ export function AuthDialog(): React.JSX.Element { }, { key: 'coding-plan-intl', - label: t('Coding Plan (Bailian, Global/Intl)'), + label: t('Bailian Coding Plan (Global/Intl)'), value: 'coding-plan-intl' as ApiKeySubMode, }, { @@ -259,10 +259,12 @@ export function AuthDialog(): React.JSX.Element { - {apiKeySubItems[apiKeySubModeIndex]?.value === 'coding-plan' - ? t("Paste your api key of Bailian Coding Plan and you're all set!") - : t( + {apiKeySubItems[apiKeySubModeIndex]?.value === 'custom' + ? t( 'More instructions about configuring `modelProviders` manually.', + ) + : t( + "Paste your api key of Bailian Coding Plan and you're all set!", )} diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index 74cd2e8de..bb05172aa 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -33,6 +33,7 @@ import { getCodingPlanConfig, isCodingPlanConfig, CodingPlanRegion, + CODING_PLAN_ENV_KEY, } from '../../constants/codingPlan.js'; export type { QwenAuthState } from '../hooks/useQwenAuth.js'; @@ -298,23 +299,22 @@ export const useAuthCommand = ( setAuthError(null); // Get configuration based on region - const codingPlanConfig = getCodingPlanConfig(region); - const { template, envKey, version } = codingPlanConfig; + const { template, version, regionName } = getCodingPlanConfig(region); // Get persist scope const persistScope = getPersistScopeForModelSelection(settings); - // Store api-key in settings.env - settings.setValue(persistScope, `env.${envKey}`, apiKey); + // Store api-key in settings.env (unified env key) + settings.setValue(persistScope, `env.${CODING_PLAN_ENV_KEY}`, apiKey); // Sync to process.env immediately so refreshAuth can read the apiKey - process.env[envKey] = apiKey; + process.env[CODING_PLAN_ENV_KEY] = apiKey; // Generate model configs from template const newConfigs: ProviderModelConfig[] = template.map( (templateConfig) => ({ ...templateConfig, - envKey, + envKey: CODING_PLAN_ENV_KEY, }), ); @@ -324,14 +324,9 @@ export const useAuthCommand = ( settings.merged.modelProviders as ModelProvidersConfig | undefined )?.[AuthType.USE_OPENAI] || []; - // Identify Coding Plan configs by baseUrl + envKey for the given region - // Remove existing Coding Plan configs to ensure template changes are applied - const checkIsCodingPlanConfig = (config: ProviderModelConfig) => - isCodingPlanConfig(config.baseUrl, config.envKey, region); - - // Filter out existing Coding Plan configs for this region, keep user custom configs + // Filter out all existing Coding Plan configs (mutually exclusive) const nonCodingPlanConfigs = existingConfigs.filter( - (existing) => !checkIsCodingPlanConfig(existing), + (existing) => !isCodingPlanConfig(existing.baseUrl, existing.envKey), ); // Add new Coding Plan configs at the beginning @@ -351,13 +346,11 @@ export const useAuthCommand = ( AuthType.USE_OPENAI, ); - // Persist coding plan version for future update detection - // Store version with region suffix to distinguish between China and Intl versions - const versionKey = - region === CodingPlanRegion.GLOBAL - ? 'codingPlan.versionIntl' - : 'codingPlan.version'; - settings.setValue(persistScope, versionKey, version); + // Persist coding plan region + settings.setValue(persistScope, 'codingPlan.region', region); + + // Persist coding plan version (single field for backward compatibility) + settings.setValue(persistScope, 'codingPlan.version', version); // If there are configs, use the first one as the model if (updatedConfigs.length > 0 && updatedConfigs[0]?.id) { @@ -387,16 +380,12 @@ export const useAuthCommand = ( onAuthChange?.(); // Add success message - const regionLabel = - region === CodingPlanRegion.GLOBAL - ? 'Coding Plan (Global/Intl)' - : 'Coding Plan'; addItem( { type: MessageType.INFO, text: t( 'Authenticated successfully with {{region}}. API key is stored in settings.env.', - { region: regionLabel }, + { region: regionName }, ), }, Date.now(), diff --git a/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts index 6a6a67ea6..b2fe21e98 100644 --- a/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts +++ b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts @@ -9,14 +9,15 @@ import { renderHook, waitFor } from '@testing-library/react'; import { useCodingPlanUpdates } from './useCodingPlanUpdates.js'; import { CODING_PLAN_ENV_KEY, - CODING_PLAN_INTL_ENV_KEY, - CODING_PLAN_BASE_URL, - CODING_PLAN_INTL_BASE_URL, - CODING_PLAN_VERSION, - CODING_PLAN_INTL_VERSION, + getCodingPlanConfig, + CodingPlanRegion, } from '../../constants/codingPlan.js'; import { AuthType } from '@qwen-code/qwen-code-core'; +// Get region configs for testing +const chinaConfig = getCodingPlanConfig(CodingPlanRegion.CHINA); +const globalConfig = getCodingPlanConfig(CodingPlanRegion.GLOBAL); + describe('useCodingPlanUpdates', () => { const mockSettings = { merged: { @@ -39,7 +40,6 @@ describe('useCodingPlanUpdates', () => { beforeEach(() => { vi.clearAllMocks(); delete process.env[CODING_PLAN_ENV_KEY]; - delete process.env[CODING_PLAN_INTL_ENV_KEY]; }); describe('version comparison', () => { @@ -57,23 +57,10 @@ describe('useCodingPlanUpdates', () => { expect(result.current.codingPlanUpdateRequest).toBeUndefined(); }); - it('should not show update prompt when China versions match', () => { - mockSettings.merged.codingPlan = { version: CODING_PLAN_VERSION }; - - const { result } = renderHook(() => - useCodingPlanUpdates( - mockSettings as never, - mockConfig as never, - mockAddItem, - ), - ); - - expect(result.current.codingPlanUpdateRequest).toBeUndefined(); - }); - - it('should not show update prompt when Global versions match', () => { + it('should not show update prompt when China region versions match', () => { mockSettings.merged.codingPlan = { - versionIntl: CODING_PLAN_INTL_VERSION, + region: CodingPlanRegion.CHINA, + version: chinaConfig.version, }; const { result } = renderHook(() => @@ -87,8 +74,28 @@ describe('useCodingPlanUpdates', () => { expect(result.current.codingPlanUpdateRequest).toBeUndefined(); }); - it('should show update prompt when China versions differ', async () => { - mockSettings.merged.codingPlan = { version: 'old-version-hash' }; + it('should not show update prompt when Global region versions match', () => { + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.GLOBAL, + version: globalConfig.version, + }; + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + expect(result.current.codingPlanUpdateRequest).toBeUndefined(); + }); + + it('should default to China region when region is not specified', async () => { + // No region specified, should default to China + mockSettings.merged.codingPlan = { + version: 'old-version-hash', + }; const { result } = renderHook(() => useCodingPlanUpdates( @@ -102,11 +109,17 @@ describe('useCodingPlanUpdates', () => { expect(result.current.codingPlanUpdateRequest).toBeDefined(); }); - expect(result.current.codingPlanUpdateRequest?.prompt).toContain('China'); + // Should prompt for China region since it defaults to China + expect(result.current.codingPlanUpdateRequest?.prompt).toContain( + chinaConfig.regionName, + ); }); - it('should show update prompt when Global versions differ', async () => { - mockSettings.merged.codingPlan = { versionIntl: 'old-version-hash' }; + it('should show update prompt when China region versions differ', async () => { + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.CHINA, + version: 'old-version-hash', + }; const { result } = renderHook(() => useCodingPlanUpdates( @@ -121,19 +134,45 @@ describe('useCodingPlanUpdates', () => { }); expect(result.current.codingPlanUpdateRequest?.prompt).toContain( - 'Global', + chinaConfig.regionName, + ); + }); + + it('should show update prompt when Global region versions differ', async () => { + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.GLOBAL, + version: 'old-version-hash', + }; + + const { result } = renderHook(() => + useCodingPlanUpdates( + mockSettings as never, + mockConfig as never, + mockAddItem, + ), + ); + + await waitFor(() => { + expect(result.current.codingPlanUpdateRequest).toBeDefined(); + }); + + expect(result.current.codingPlanUpdateRequest?.prompt).toContain( + globalConfig.regionName, ); }); }); describe('update execution', () => { it('should execute China region update when user confirms', async () => { - mockSettings.merged.codingPlan = { version: 'old-version-hash' }; + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.CHINA, + version: 'old-version-hash', + }; mockSettings.merged.modelProviders = { [AuthType.USE_OPENAI]: [ { id: 'test-model-china-1', - baseUrl: CODING_PLAN_BASE_URL, + baseUrl: chinaConfig.baseUrl, envKey: CODING_PLAN_ENV_KEY, }, { @@ -162,7 +201,7 @@ describe('useCodingPlanUpdates', () => { // Wait for async update to complete await waitFor(() => { - // Should update model providers (at least 2 calls: modelProviders + version) + // Should update model providers (at least 2 calls: modelProviders + version + region) expect(mockSettings.setValue).toHaveBeenCalled(); }); @@ -170,7 +209,14 @@ describe('useCodingPlanUpdates', () => { expect(mockSettings.setValue).toHaveBeenCalledWith( expect.anything(), 'codingPlan.version', - CODING_PLAN_VERSION, + chinaConfig.version, + ); + + // Should update region + expect(mockSettings.setValue).toHaveBeenCalledWith( + expect.anything(), + 'codingPlan.region', + CodingPlanRegion.CHINA, ); // Should reload and refresh auth @@ -181,20 +227,23 @@ describe('useCodingPlanUpdates', () => { expect(mockAddItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'info', - text: expect.stringContaining('Coding Plan'), + text: expect.stringContaining(chinaConfig.regionName), }), expect.any(Number), ); }); it('should execute Global region update when user confirms', async () => { - mockSettings.merged.codingPlan = { versionIntl: 'old-version-hash' }; + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.GLOBAL, + version: 'old-version-hash', + }; mockSettings.merged.modelProviders = { [AuthType.USE_OPENAI]: [ { id: 'test-model-global-1', - baseUrl: CODING_PLAN_INTL_BASE_URL, - envKey: CODING_PLAN_INTL_ENV_KEY, + baseUrl: globalConfig.baseUrl, + envKey: CODING_PLAN_ENV_KEY, }, { id: 'custom-model', @@ -225,11 +274,18 @@ describe('useCodingPlanUpdates', () => { expect(mockSettings.setValue).toHaveBeenCalled(); }); - // Should update versionIntl with correct hash + // Should update version with correct hash (single version field) expect(mockSettings.setValue).toHaveBeenCalledWith( expect.anything(), - 'codingPlan.versionIntl', - CODING_PLAN_INTL_VERSION, + 'codingPlan.version', + globalConfig.version, + ); + + // Should update region + expect(mockSettings.setValue).toHaveBeenCalledWith( + expect.anything(), + 'codingPlan.region', + CodingPlanRegion.GLOBAL, ); // Should reload and refresh auth @@ -240,14 +296,17 @@ describe('useCodingPlanUpdates', () => { expect(mockAddItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'info', - text: expect.stringContaining('Global'), + text: expect.stringContaining(globalConfig.regionName), }), expect.any(Number), ); }); it('should not execute update when user declines', async () => { - mockSettings.merged.codingPlan = { version: 'old-version-hash' }; + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.CHINA, + version: 'old-version-hash', + }; const { result } = renderHook(() => useCodingPlanUpdates( @@ -269,17 +328,22 @@ describe('useCodingPlanUpdates', () => { expect(mockConfig.reloadModelProvidersConfig).not.toHaveBeenCalled(); }); - it('should only update configs for the specific region', async () => { - mockSettings.merged.codingPlan = { version: 'old-version-hash' }; - const chinaConfig = { + it('should replace all Coding Plan configs during update (mutually exclusive)', async () => { + // Since regions are mutually exclusive, when updating one region, + // all Coding Plan configs should be replaced (not preserving other region configs) + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.CHINA, + version: 'old-version-hash', + }; + const chinaModelConfig = { id: 'test-model-china-1', - baseUrl: CODING_PLAN_BASE_URL, + baseUrl: chinaConfig.baseUrl, envKey: CODING_PLAN_ENV_KEY, }; - const globalConfig = { + const globalModelConfig = { id: 'test-model-global-1', - baseUrl: CODING_PLAN_INTL_BASE_URL, - envKey: CODING_PLAN_INTL_ENV_KEY, + baseUrl: globalConfig.baseUrl, + envKey: CODING_PLAN_ENV_KEY, }; const customConfig = { id: 'custom-model', @@ -287,7 +351,11 @@ describe('useCodingPlanUpdates', () => { envKey: 'CUSTOM_API_KEY', }; mockSettings.merged.modelProviders = { - [AuthType.USE_OPENAI]: [chinaConfig, globalConfig, customConfig], + [AuthType.USE_OPENAI]: [ + chinaModelConfig, + globalModelConfig, + customConfig, + ], }; mockConfig.refreshAuth.mockResolvedValue(undefined); @@ -316,21 +384,21 @@ describe('useCodingPlanUpdates', () => { (call[1] as string).includes('modelProviders'), ); - // Should preserve Global config and custom config, only update China configs expect(modelProvidersCall).toBeDefined(); const updatedConfigs = modelProvidersCall![2] as Array< Record >; - // Should have new China configs + preserved Global config + custom config - expect(updatedConfigs.length).toBeGreaterThanOrEqual(3); + // Should have new China configs + custom config only (global config removed since regions are mutually exclusive) + // The template has 2 models, so we expect 2 (from template) + 1 (custom) = 3 + expect(updatedConfigs.length).toBe(3); - // Should contain the Global config (not modified) + // Should NOT contain the Global config (mutually exclusive) expect( updatedConfigs.some( - (c: Record) => c['id'] === 'test-model-global-1', + (c: Record) => c['baseUrl'] === globalConfig.baseUrl, ), - ).toBe(true); + ).toBe(false); // Should contain the custom config expect( @@ -339,13 +407,23 @@ describe('useCodingPlanUpdates', () => { ), ).toBe(true); + // All configs should use the unified env key + updatedConfigs.forEach((config) => { + if (config['envKey'] === CODING_PLAN_ENV_KEY) { + expect(config['baseUrl']).toBe(chinaConfig.baseUrl); + } + }); + // Should reload and refresh auth expect(mockConfig.reloadModelProvidersConfig).toHaveBeenCalled(); expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI); }); it('should preserve non-Coding Plan configs during update', async () => { - mockSettings.merged.codingPlan = { version: 'old-version-hash' }; + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.CHINA, + version: 'old-version-hash', + }; const customConfig = { id: 'custom-model', baseUrl: 'https://custom.example.com', @@ -355,7 +433,7 @@ describe('useCodingPlanUpdates', () => { [AuthType.USE_OPENAI]: [ { id: 'test-model-china-1', - baseUrl: CODING_PLAN_BASE_URL, + baseUrl: chinaConfig.baseUrl, envKey: CODING_PLAN_ENV_KEY, }, customConfig, @@ -402,12 +480,15 @@ describe('useCodingPlanUpdates', () => { }); it('should handle update errors gracefully', async () => { - mockSettings.merged.codingPlan = { version: 'old-version-hash' }; + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.CHINA, + version: 'old-version-hash', + }; mockSettings.merged.modelProviders = { [AuthType.USE_OPENAI]: [ { id: 'test-model-china-1', - baseUrl: CODING_PLAN_BASE_URL, + baseUrl: chinaConfig.baseUrl, envKey: CODING_PLAN_ENV_KEY, }, ], @@ -443,7 +524,10 @@ describe('useCodingPlanUpdates', () => { describe('dismissUpdate', () => { it('should clear update request when dismissed', async () => { - mockSettings.merged.codingPlan = { version: 'old-version-hash' }; + mockSettings.merged.codingPlan = { + region: CodingPlanRegion.CHINA, + version: 'old-version-hash', + }; const { result } = renderHook(() => useCodingPlanUpdates( diff --git a/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts b/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts index 3d6e6da23..1646b5aef 100644 --- a/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts +++ b/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts @@ -11,10 +11,9 @@ import type { LoadedSettings } from '../../config/settings.js'; import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; import { isCodingPlanConfig, - CODING_PLAN_VERSION, - CODING_PLAN_INTL_VERSION, getCodingPlanConfig, CodingPlanRegion, + CODING_PLAN_ENV_KEY, } from '../../constants/codingPlan.js'; import { t } from '../../i18n/index.js'; @@ -43,7 +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. - * Automatically detects whether the user is using China or Intl version. + * Uses the region from settings.codingPlan.region (defaults to CHINA). */ const executeUpdate = useCallback( async (region: CodingPlanRegion = CodingPlanRegion.CHINA) => { @@ -58,24 +57,23 @@ export function useCodingPlanUpdates( | undefined )?.[AuthType.USE_OPENAI] || []; - // Filter out Coding Plan configs for the given region (keep user custom configs) + // Filter out all Coding Plan configs (since they are mutually exclusive) + // Keep only non-Coding-Plan user custom configs const nonCodingPlanConfigs = currentConfigs.filter( (cfg) => !isCodingPlanConfig( cfg['baseUrl'] as string | undefined, cfg['envKey'] as string | undefined, - region, ), ); - // Get the correct configuration based on region - const codingPlanConfig = getCodingPlanConfig(region); - const { template, envKey, version } = codingPlanConfig; + // Get the configuration for the current region + const { template, version, regionName } = getCodingPlanConfig(region); // Generate new configs from template const newConfigs = template.map((templateConfig) => ({ ...templateConfig, - envKey, + envKey: CODING_PLAN_ENV_KEY, })); // Combine: new Coding Plan configs at the front, user configs preserved @@ -91,12 +89,11 @@ export function useCodingPlanUpdates( updatedConfigs, ); - // Update the version with region-specific key - const versionKey = - region === CodingPlanRegion.GLOBAL - ? 'codingPlan.versionIntl' - : 'codingPlan.version'; - settings.setValue(persistScope, versionKey, version); + // Update the version (single version field for backward compatibility) + settings.setValue(persistScope, 'codingPlan.version', version); + + // Update the region + settings.setValue(persistScope, 'codingPlan.region', region); // Hot-reload model providers configuration const updatedModelProviders = { @@ -112,16 +109,12 @@ export function useCodingPlanUpdates( // Refresh auth with the new configuration await config.refreshAuth(AuthType.USE_OPENAI); - const regionLabel = - region === CodingPlanRegion.GLOBAL - ? 'Coding Plan (Global/Intl)' - : 'Coding Plan'; addItem( { type: 'info', text: t( '{{region}} configuration updated successfully. New models are now available.', - { region: regionLabel }, + { region: regionName }, ), }, Date.now(), @@ -148,56 +141,46 @@ export function useCodingPlanUpdates( /** * Check for version mismatch and prompt user for update if needed. + * Uses the region from settings.codingPlan.region (defaults to CHINA if not set). */ const checkForUpdates = useCallback(() => { const mergedSettings = settings.merged as { - codingPlan?: { version?: string; versionIntl?: string }; + codingPlan?: { + version?: string; + region?: CodingPlanRegion; + }; }; - const savedChinaVersion = mergedSettings.codingPlan?.version; - const savedIntlVersion = mergedSettings.codingPlan?.versionIntl; + // Get the region (default to CHINA if not set) + const region = mergedSettings.codingPlan?.region ?? CodingPlanRegion.CHINA; - // Determine which version the user is using based on saved version - // Check China version first - if (savedChinaVersion) { - if (savedChinaVersion !== CODING_PLAN_VERSION) { - // China version mismatch - prompt for update - setUpdateRequest({ - prompt: t( - 'New model configurations are available for Bailian Coding Plan (China). Update now?', - ), - onConfirm: async (confirmed: boolean) => { - setUpdateRequest(undefined); - if (confirmed) { - await executeUpdate(CodingPlanRegion.CHINA); - } - }, - }); - return; - } - } - - // Check Intl version - if (savedIntlVersion) { - if (savedIntlVersion !== CODING_PLAN_INTL_VERSION) { - // Intl version mismatch - prompt for update - setUpdateRequest({ - prompt: t( - 'New model configurations are available for Coding Plan (Global/Intl). Update now?', - ), - onConfirm: async (confirmed: boolean) => { - setUpdateRequest(undefined); - if (confirmed) { - await executeUpdate(CodingPlanRegion.GLOBAL); - } - }, - }); - return; - } - } + // Get the saved version for the current region + const savedVersion = mergedSettings.codingPlan?.version; // If no version is stored, user hasn't used Coding Plan yet - skip check - return; + if (!savedVersion) { + return; + } + + // Get current version for the region + const currentVersion = getCodingPlanConfig(region).version; + + // Check if version matches + if (savedVersion !== currentVersion) { + const { regionName } = getCodingPlanConfig(region); + setUpdateRequest({ + prompt: t( + 'New model configurations are available for {{region}}. Update now?', + { region: regionName }, + ), + onConfirm: async (confirmed: boolean) => { + setUpdateRequest(undefined); + if (confirmed) { + await executeUpdate(region); + } + }, + }); + } }, [settings, executeUpdate]); // Check for updates on mount From 78a4ab1b4852403ffecd5dfabf57c52a123f153e Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Wed, 18 Feb 2026 09:21:16 +0800 Subject: [PATCH 57/81] fix(cli): update regionName format from 'Bailian Coding Plan (Global/Intl)' to 'Coding Plan (Bailian, Global/Intl)' Co-authored-by: Qwen-Coder --- packages/cli/src/constants/codingPlan.ts | 4 +-- packages/cli/src/i18n/locales/de.js | 16 +++++---- packages/cli/src/i18n/locales/en.js | 16 +++++---- packages/cli/src/i18n/locales/ja.js | 17 +++++---- packages/cli/src/i18n/locales/pt.js | 16 +++++---- packages/cli/src/i18n/locales/ru.js | 18 +++++----- packages/cli/src/i18n/locales/zh.js | 16 +++++---- packages/cli/src/ui/auth/AuthDialog.tsx | 2 +- .../src/ui/hooks/useCodingPlanUpdates.test.ts | 1 + .../cli/src/ui/hooks/useCodingPlanUpdates.ts | 35 ++++++++++--------- 10 files changed, 79 insertions(+), 62 deletions(-) diff --git a/packages/cli/src/constants/codingPlan.ts b/packages/cli/src/constants/codingPlan.ts index 0a3084658..5393b75c0 100644 --- a/packages/cli/src/constants/codingPlan.ts +++ b/packages/cli/src/constants/codingPlan.ts @@ -113,8 +113,8 @@ export function getCodingPlanConfig(region: CodingPlanRegion) { : 'https://coding-intl.dashscope.aliyuncs.com/v1'; const regionName = region === CodingPlanRegion.CHINA - ? 'Bailian Coding Plan (China)' - : 'Bailian Coding Plan (Global/Intl)'; + ? 'Coding Plan (Bailian, China)' + : 'Coding Plan (Bailian, Global/Intl)'; return { template, diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 2a00a7b9e..4ff5a3c28 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1418,11 +1418,11 @@ export default { // ============================================================================ 'Coding Plan': 'Coding Plan', 'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, China)', - 'Bailian Coding Plan (Global/Intl)': 'Bailian Coding Plan (Global/Intl)', + 'Coding Plan (Bailian, Global/Intl)': 'Coding Plan (Bailian, Global/Intl)', "Paste your api key of Bailian Coding Plan and you're all set!": 'Fügen Sie Ihren Bailian Coding Plan API-Schlüssel ein und Sie sind bereit!', - "Paste your api key of Bailian Coding Plan (Global/Intl) and you're all set!": - 'Fügen Sie Ihren Bailian Coding Plan (Global/Intl) API-Schlüssel ein und Sie sind bereit!', + "Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!": + 'Fügen Sie Ihren Coding Plan (Bailian, Global/Intl) API-Schlüssel ein und Sie sind bereit!', Custom: 'Benutzerdefiniert', 'More instructions about configuring `modelProviders` manually.': 'Weitere Anweisungen zur manuellen Konfiguration von `modelProviders`.', @@ -1436,12 +1436,14 @@ export default { // ============================================================================ // Coding Plan International Updates // ============================================================================ + 'New model configurations are available for {{region}}. Update now?': + 'Neue Modellkonfigurationen sind für {{region}} verfügbar. Jetzt aktualisieren?', 'New model configurations are available for Bailian Coding Plan (China). Update now?': 'Neue Modellkonfigurationen sind für Bailian Coding Plan (China) verfügbar. Jetzt aktualisieren?', - 'New model configurations are available for Bailian Coding Plan (Global/Intl). Update now?': - 'Neue Modellkonfigurationen sind für Bailian Coding Plan (Global/Intl) verfügbar. Jetzt aktualisieren?', - '{{region}} configuration updated successfully. New models are now available.': - '{{region}}-Konfiguration erfolgreich aktualisiert. Neue Modelle sind jetzt verfügbar.', + 'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?': + 'Neue Modellkonfigurationen sind für Coding Plan (Bailian, Global/Intl) verfügbar. Jetzt aktualisieren?', + '{{region}} configuration updated successfully. Model switched to "{{model}}".': + '{{region}}-Konfiguration erfolgreich aktualisiert. Modell auf "{{model}}" umgeschaltet.', 'Authenticated successfully with {{region}}. API key is stored in settings.env.': 'Erfolgreich mit {{region}} authentifiziert. API-Schlüssel ist in settings.env gespeichert.', }; diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 234ff3ab8..d4dc217c9 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1419,11 +1419,11 @@ export default { // ============================================================================ 'Coding Plan': 'Coding Plan', 'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, China)', - 'Bailian Coding Plan (Global/Intl)': 'Bailian Coding Plan (Global/Intl)', + 'Coding Plan (Bailian, Global/Intl)': 'Coding Plan (Bailian, Global/Intl)', "Paste your api key of Bailian Coding Plan and you're all set!": "Paste your api key of Bailian Coding Plan and you're all set!", - "Paste your api key of Bailian Coding Plan (Global/Intl) and you're all set!": - "Paste your api key of Bailian Coding Plan (Global/Intl) and you're all set!", + "Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!": + "Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!", Custom: 'Custom', 'More instructions about configuring `modelProviders` manually.': 'More instructions about configuring `modelProviders` manually.', @@ -1435,12 +1435,14 @@ export default { // ============================================================================ // Coding Plan International Updates // ============================================================================ + 'New model configurations are available for {{region}}. Update now?': + 'New model configurations are available for {{region}}. Update now?', 'New model configurations are available for Bailian Coding Plan (China). Update now?': 'New model configurations are available for Bailian Coding Plan (China). Update now?', - 'New model configurations are available for Bailian Coding Plan (Global/Intl). Update now?': - 'New model configurations are available for Bailian Coding Plan (Global/Intl). Update now?', - '{{region}} configuration updated successfully. New models are now available.': - '{{region}} configuration updated successfully. New models are now available.', + 'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?': + 'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?', + '{{region}} configuration updated successfully. Model switched to "{{model}}".': + '{{region}} configuration updated successfully. Model switched to "{{model}}".', 'Authenticated successfully with {{region}}. API key is stored in settings.env.': 'Authenticated successfully with {{region}}. API key is stored in settings.env.', }; diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index e094dfb09..c00954858 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -929,11 +929,12 @@ export default { // ============================================================================ 'Coding Plan': 'Coding Plan', 'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, 中国)', - 'Bailian Coding Plan (Global/Intl)': 'Bailian Coding Plan (グローバル/国際)', + 'Coding Plan (Bailian, Global/Intl)': + 'Coding Plan (Bailian, グローバル/国際)', "Paste your api key of Bailian Coding Plan and you're all set!": 'Bailian Coding PlanのAPIキーを貼り付けるだけで準備完了です!', - "Paste your api key of Bailian Coding Plan (Global/Intl) and you're all set!": - 'Bailian Coding Plan (グローバル/国際) のAPIキーを貼り付けるだけで準備完了です!', + "Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!": + 'Coding Plan (Bailian, グローバル/国際) のAPIキーを貼り付けるだけで準備完了です!', Custom: 'カスタム', 'More instructions about configuring `modelProviders` manually.': '`modelProviders`を手動で設定する方法の詳細はこちら。', @@ -946,12 +947,14 @@ export default { // ============================================================================ // Coding Plan International Updates // ============================================================================ + 'New model configurations are available for {{region}}. Update now?': + '{{region}} の新しいモデル設定が利用可能です。今すぐ更新しますか?', 'New model configurations are available for Bailian Coding Plan (China). Update now?': 'Bailian Coding Plan (中国) の新しいモデル設定が利用可能です。今すぐ更新しますか?', - 'New model configurations are available for Bailian Coding Plan (Global/Intl). Update now?': - 'Bailian Coding Plan (グローバル/国際) の新しいモデル設定が利用可能です。今すぐ更新しますか?', - '{{region}} configuration updated successfully. New models are now available.': - '{{region}} の設定が正常に更新されました。新しいモデルが利用可能になりました。', + 'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?': + 'Coding Plan (Bailian, グローバル/国際) の新しいモデル設定が利用可能です。今すぐ更新しますか?', + '{{region}} configuration updated successfully. Model switched to "{{model}}".': + '{{region}} の設定が正常に更新されました。モデルが "{{model}}" に切り替わりました。', 'Authenticated successfully with {{region}}. API key is stored in settings.env.': '{{region}} での認証に成功しました。APIキーは settings.env に保存されています。', }; diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 1720a891a..a6130b2fb 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1432,11 +1432,11 @@ export default { // ============================================================================ 'Coding Plan': 'Coding Plan', 'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, China)', - 'Bailian Coding Plan (Global/Intl)': 'Bailian Coding Plan (Global/Intl)', + 'Coding Plan (Bailian, Global/Intl)': 'Coding Plan (Bailian, Global/Intl)', "Paste your api key of Bailian Coding Plan and you're all set!": 'Cole sua chave de API do Bailian Coding Plan e pronto!', - "Paste your api key of Bailian Coding Plan (Global/Intl) and you're all set!": - 'Cole sua chave de API do Bailian Coding Plan (Global/Intl) e pronto!', + "Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!": + 'Cole sua chave de API do Coding Plan (Bailian, Global/Intl) e pronto!', Custom: 'Personalizado', 'More instructions about configuring `modelProviders` manually.': 'Mais instruções sobre como configurar `modelProviders` manualmente.', @@ -1450,12 +1450,14 @@ export default { // ============================================================================ // Coding Plan International Updates // ============================================================================ + 'New model configurations are available for {{region}}. Update now?': + 'Novas configurações de modelo estão disponíveis para o {{region}}. Atualizar agora?', 'New model configurations are available for Bailian Coding Plan (China). Update now?': 'Novas configurações de modelo estão disponíveis para o Bailian Coding Plan (China). Atualizar agora?', - 'New model configurations are available for Bailian Coding Plan (Global/Intl). Update now?': - 'Novas configurações de modelo estão disponíveis para o Bailian Coding Plan (Global/Intl). Atualizar agora?', - '{{region}} configuration updated successfully. New models are now available.': - 'Configuração do {{region}} atualizada com sucesso. Novos modelos agora estão disponíveis.', + 'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?': + 'Novas configurações de modelo estão disponíveis para o Coding Plan (Bailian, Global/Intl). Atualizar agora?', + '{{region}} configuration updated successfully. Model switched to "{{model}}".': + 'Configuração do {{region}} atualizada com sucesso. Modelo alterado para "{{model}}".', 'Authenticated successfully with {{region}}. API key is stored in settings.env.': 'Autenticado com sucesso com {{region}}. A chave de API está armazenada em settings.env.', }; diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 49a5c7226..7534c54ed 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1422,12 +1422,12 @@ export default { // ============================================================================ 'Coding Plan': 'Coding Plan', 'Coding Plan (Bailian, China)': 'Coding Plan (Bailian, Китай)', - 'Bailian Coding Plan (Global/Intl)': - 'Bailian Coding Plan (Глобальный/Международный)', + 'Coding Plan (Bailian, Global/Intl)': + 'Coding Plan (Bailian, Глобальный/Международный)', "Paste your api key of Bailian Coding Plan and you're all set!": 'Вставьте ваш API-ключ Bailian Coding Plan и всё готово!', - "Paste your api key of Bailian Coding Plan (Global/Intl) and you're all set!": - 'Вставьте ваш API-ключ Bailian Coding Plan (Глобальный/Международный) и всё готово!', + "Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!": + 'Вставьте ваш API-ключ Coding Plan (Bailian, Глобальный/Международный) и всё готово!', Custom: 'Пользовательский', 'More instructions about configuring `modelProviders` manually.': 'Дополнительные инструкции по ручной настройке `modelProviders`.', @@ -1440,12 +1440,14 @@ export default { // ============================================================================ // Coding Plan International Updates // ============================================================================ + 'New model configurations are available for {{region}}. Update now?': + 'Доступны новые конфигурации моделей для {{region}}. Обновить сейчас?', 'New model configurations are available for Bailian Coding Plan (China). Update now?': 'Доступны новые конфигурации моделей для Bailian Coding Plan (Китай). Обновить сейчас?', - 'New model configurations are available for Bailian Coding Plan (Global/Intl). Update now?': - 'Доступны новые конфигурации моделей для Bailian Coding Plan (Глобальный/Международный). Обновить сейчас?', - '{{region}} configuration updated successfully. New models are now available.': - 'Конфигурация {{region}} успешно обновлена. Новые модели теперь доступны.', + 'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?': + 'Доступны новые конфигурации моделей для Coding Plan (Bailian, Глобальный/Международный). Обновить сейчас?', + '{{region}} configuration updated successfully. Model switched to "{{model}}".': + 'Конфигурация {{region}} успешно обновлена. Модель переключена на "{{model}}".', 'Authenticated successfully with {{region}}. API key is stored in settings.env.': 'Успешная аутентификация с {{region}}. API-ключ сохранён в settings.env.', }; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index a1d6651ef..1d6e6df68 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1254,11 +1254,11 @@ export default { 'API-KEY': 'API-KEY', 'Coding Plan': 'Coding Plan', 'Coding Plan (Bailian, China)': 'Coding Plan (百炼, 中国)', - 'Bailian Coding Plan (Global/Intl)': 'Bailian Coding Plan (百炼, 全球/国际)', + 'Coding Plan (Bailian, Global/Intl)': 'Coding Plan (百炼, 全球/国际)', "Paste your api key of Bailian Coding Plan and you're all set!": '粘贴您的百炼 Coding Plan API Key,即可完成设置!', - "Paste your api key of Bailian Coding Plan (Global/Intl) and you're all set!": - '粘贴您的 Bailian Coding Plan (全球/国际) API Key,即可完成设置!', + "Paste your api key of Coding Plan (Bailian, Global/Intl) and you're all set!": + '粘贴您的 Coding Plan (百炼, 全球/国际) API Key,即可完成设置!', Custom: '自定义', 'More instructions about configuring `modelProviders` manually.': '关于手动配置 `modelProviders` 的更多说明。', @@ -1269,12 +1269,14 @@ export default { // ============================================================================ // Coding Plan International Updates // ============================================================================ + 'New model configurations are available for {{region}}. Update now?': + '{{region}} 有新的模型配置可用。是否立即更新?', 'New model configurations are available for Bailian Coding Plan (China). Update now?': '百炼 Coding Plan (中国) 有新的模型配置可用。是否立即更新?', - 'New model configurations are available for Bailian Coding Plan (Global/Intl). Update now?': - 'Bailian Coding Plan (全球/国际) 有新的模型配置可用。是否立即更新?', - '{{region}} configuration updated successfully. New models are now available.': - '{{region}} 配置更新成功。新模型现已可用。', + 'New model configurations are available for Coding Plan (Bailian, Global/Intl). Update now?': + 'Coding Plan (百炼, 全球/国际) 有新的模型配置可用。是否立即更新?', + '{{region}} configuration updated successfully. Model switched to "{{model}}".': + '{{region}} 配置更新成功。模型已切换至 "{{model}}"。', 'Authenticated successfully with {{region}}. API key is stored in settings.env.': '成功通过 {{region}} 认证。API Key 已存储在 settings.env 中。', }; diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index 8c9a0aba7..7f43fa582 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -80,7 +80,7 @@ export function AuthDialog(): React.JSX.Element { }, { key: 'coding-plan-intl', - label: t('Bailian Coding Plan (Global/Intl)'), + label: t('Coding Plan (Bailian, Global/Intl)'), value: 'coding-plan-intl' as ApiKeySubMode, }, { diff --git a/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts index b2fe21e98..1e6a40722 100644 --- a/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts +++ b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts @@ -33,6 +33,7 @@ describe('useCodingPlanUpdates', () => { const mockConfig = { reloadModelProvidersConfig: vi.fn(), refreshAuth: vi.fn(), + getModel: vi.fn().mockReturnValue('qwen-max'), }; const mockAddItem = vi.fn(); diff --git a/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts b/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts index 1646b5aef..dee70e035 100644 --- a/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts +++ b/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts @@ -82,7 +82,22 @@ export function useCodingPlanUpdates( ...(nonCodingPlanConfigs as Array>), ] as Array>; - // Persist updated model providers + // Hot-reload model providers configuration first (in-memory only) + const updatedModelProviders = { + ...(settings.merged.modelProviders as + | Record + | undefined), + [AuthType.USE_OPENAI]: updatedConfigs, + }; + config.reloadModelProvidersConfig( + updatedModelProviders as unknown as ModelProvidersConfig, + ); + + // Refresh auth with the new configuration + // This validates the configuration before persisting + await config.refreshAuth(AuthType.USE_OPENAI); + + // Persist to settings only after successful auth refresh settings.setValue( persistScope, `modelProviders.${AuthType.USE_OPENAI}`, @@ -95,26 +110,14 @@ export function useCodingPlanUpdates( // Update the region settings.setValue(persistScope, 'codingPlan.region', region); - // Hot-reload model providers configuration - const updatedModelProviders = { - ...(settings.merged.modelProviders as - | Record - | undefined), - [AuthType.USE_OPENAI]: updatedConfigs, - }; - config.reloadModelProvidersConfig( - updatedModelProviders as unknown as ModelProvidersConfig, - ); - - // Refresh auth with the new configuration - await config.refreshAuth(AuthType.USE_OPENAI); + const activeModel = config.getModel(); addItem( { type: 'info', text: t( - '{{region}} configuration updated successfully. New models are now available.', - { region: regionName }, + '{{region}} configuration updated successfully. Model switched to "{{model}}".', + { region: regionName, model: activeModel }, ), }, Date.now(), From fca4d739c7c291cd07837839d4e99a8f7c859b7a Mon Sep 17 00:00:00 2001 From: qwen-code-ci-bot Date: Wed, 18 Feb 2026 12:28:48 +0800 Subject: [PATCH 58/81] chore: bump version to 0.10.3 (#1863) Co-authored-by: Qwen-Coder --- package-lock.json | 14 +++++++------- package.json | 4 ++-- packages/cli/package.json | 4 ++-- packages/core/package.json | 2 +- packages/test-utils/package.json | 2 +- packages/vscode-ide-companion/package.json | 2 +- packages/webui/package.json | 2 +- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index df3264492..11c90cafe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qwen-code/qwen-code", - "version": "0.10.2", + "version": "0.10.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qwen-code/qwen-code", - "version": "0.10.2", + "version": "0.10.3", "workspaces": [ "packages/*" ], @@ -18655,7 +18655,7 @@ }, "packages/cli": { "name": "@qwen-code/qwen-code", - "version": "0.10.2", + "version": "0.10.3", "dependencies": { "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", @@ -19274,7 +19274,7 @@ }, "packages/core": { "name": "@qwen-code/qwen-code-core", - "version": "0.10.2", + "version": "0.10.3", "hasInstallScript": true, "dependencies": { "@anthropic-ai/sdk": "^0.36.1", @@ -22754,7 +22754,7 @@ }, "packages/test-utils": { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.10.2", + "version": "0.10.3", "dev": true, "license": "Apache-2.0", "devDependencies": { @@ -22766,7 +22766,7 @@ }, "packages/vscode-ide-companion": { "name": "qwen-code-vscode-ide-companion", - "version": "0.10.2", + "version": "0.10.3", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.1", @@ -23013,7 +23013,7 @@ }, "packages/webui": { "name": "@qwen-code/webui", - "version": "0.10.2", + "version": "0.10.3", "license": "MIT", "dependencies": { "markdown-it": "^14.1.0" diff --git a/package.json b/package.json index 7063aee04..4bbb16cdc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.10.2", + "version": "0.10.3", "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.10.2" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.3" }, "scripts": { "start": "cross-env node scripts/start.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index b30ed7e48..7ce1796fe 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.10.2", + "version": "0.10.3", "description": "Qwen Code", "repository": { "type": "git", @@ -34,7 +34,7 @@ "dist" ], "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.2" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.3" }, "dependencies": { "@google/genai": "1.30.0", diff --git a/packages/core/package.json b/packages/core/package.json index 825c8f140..2deb38993 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-core", - "version": "0.10.2", + "version": "0.10.3", "description": "Qwen Code Core", "repository": { "type": "git", diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 6ea6d8435..78060511f 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.10.2", + "version": "0.10.3", "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 9482a7cf7..63ee94de7 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.10.2", + "version": "0.10.3", "publisher": "qwenlm", "icon": "assets/icon.png", "repository": { diff --git a/packages/webui/package.json b/packages/webui/package.json index c777edd94..e1bef1f39 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/webui", - "version": "0.10.2", + "version": "0.10.3", "description": "Shared UI components for Qwen Code packages", "type": "module", "main": "./dist/index.cjs", From 4cd42187b8dff89090b46787990d37f7b6f6bb17 Mon Sep 17 00:00:00 2001 From: qwen-code-ci-bot Date: Wed, 18 Feb 2026 12:46:25 +0800 Subject: [PATCH 59/81] chore: bump version to 0.10.4 (#1864) Co-authored-by: Qwen-Coder --- package-lock.json | 14 +++++++------- package.json | 4 ++-- packages/cli/package.json | 4 ++-- packages/core/package.json | 2 +- packages/test-utils/package.json | 2 +- packages/vscode-ide-companion/package.json | 2 +- packages/webui/package.json | 2 +- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index 11c90cafe..b3bc14146 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qwen-code/qwen-code", - "version": "0.10.3", + "version": "0.10.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qwen-code/qwen-code", - "version": "0.10.3", + "version": "0.10.4", "workspaces": [ "packages/*" ], @@ -18655,7 +18655,7 @@ }, "packages/cli": { "name": "@qwen-code/qwen-code", - "version": "0.10.3", + "version": "0.10.4", "dependencies": { "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", @@ -19274,7 +19274,7 @@ }, "packages/core": { "name": "@qwen-code/qwen-code-core", - "version": "0.10.3", + "version": "0.10.4", "hasInstallScript": true, "dependencies": { "@anthropic-ai/sdk": "^0.36.1", @@ -22754,7 +22754,7 @@ }, "packages/test-utils": { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.10.3", + "version": "0.10.4", "dev": true, "license": "Apache-2.0", "devDependencies": { @@ -22766,7 +22766,7 @@ }, "packages/vscode-ide-companion": { "name": "qwen-code-vscode-ide-companion", - "version": "0.10.3", + "version": "0.10.4", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.1", @@ -23013,7 +23013,7 @@ }, "packages/webui": { "name": "@qwen-code/webui", - "version": "0.10.3", + "version": "0.10.4", "license": "MIT", "dependencies": { "markdown-it": "^14.1.0" diff --git a/package.json b/package.json index 4bbb16cdc..0016ae0cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.10.3", + "version": "0.10.4", "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.10.3" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.4" }, "scripts": { "start": "cross-env node scripts/start.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index 7ce1796fe..b1dc25a3d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.10.3", + "version": "0.10.4", "description": "Qwen Code", "repository": { "type": "git", @@ -34,7 +34,7 @@ "dist" ], "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.3" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.4" }, "dependencies": { "@google/genai": "1.30.0", diff --git a/packages/core/package.json b/packages/core/package.json index 2deb38993..b9535b6ef 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-core", - "version": "0.10.3", + "version": "0.10.4", "description": "Qwen Code Core", "repository": { "type": "git", diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 78060511f..f1f8b3052 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.10.3", + "version": "0.10.4", "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 63ee94de7..9d0f037f3 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.10.3", + "version": "0.10.4", "publisher": "qwenlm", "icon": "assets/icon.png", "repository": { diff --git a/packages/webui/package.json b/packages/webui/package.json index e1bef1f39..f09355b53 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/webui", - "version": "0.10.3", + "version": "0.10.4", "description": "Shared UI components for Qwen Code packages", "type": "module", "main": "./dist/index.cjs", From 89c78c7bf3ab67500d51b8ba3326437d7fe3e4b7 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Wed, 18 Feb 2026 20:31:56 +0800 Subject: [PATCH 60/81] feat: add qwen3.5-plus model support for Coding Plan - Add qwen3.5-plus configuration for both China and Global regions - Update README with qwen3.5-plus setup instructions - Fix Coding Plan console URL for international users - Update tests for new model count Co-authored-by: Qwen-Coder --- README.md | 14 +++++++++- packages/cli/src/constants/codingPlan.ts | 26 +++++++++++++++++++ .../cli/src/ui/components/ApiKeyInput.tsx | 2 +- .../src/ui/hooks/useCodingPlanUpdates.test.ts | 4 +-- 4 files changed, 42 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b1b99e94c..0129d8302 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,18 @@ Use the `/model` command at any time to switch between all configured models. { "modelProviders": { "openai": [ + { + "id": "qwen3.5-plus", + "name": "qwen3.5-plus (Coding Plan)", + "baseUrl": "https://coding.dashscope.aliyuncs.com/v1", + "description": "qwen3.5-plus with thinking enabled from Bailian Coding Plan", + "envKey": "BAILIAN_CODING_PLAN_API_KEY", + "generationConfig": { + "extra_body": { + "enable_thinking": true + } + } + }, { "id": "qwen3-coder-plus", "name": "qwen3-coder-plus (Coding Plan)", @@ -212,7 +224,7 @@ Use the `/model` command at any time to switch between all configured models. } ``` -> Subscribe to the Coding Plan at [Alibaba Cloud Bailian](https://bailian.console.aliyun.com/cn-beijing/?tab=globalset#/efm/coding_plan). +> Subscribe to the Coding Plan and get your API key at [Alibaba Cloud Bailian](https://modelstudio.console.aliyun.com/?tab=dashboard#/efm/coding_plan). diff --git a/packages/cli/src/constants/codingPlan.ts b/packages/cli/src/constants/codingPlan.ts index 5393b75c0..5dfdc297f 100644 --- a/packages/cli/src/constants/codingPlan.ts +++ b/packages/cli/src/constants/codingPlan.ts @@ -52,6 +52,19 @@ export function generateCodingPlanTemplate( // China region uses legacy fields to maintain backward compatibility // This ensures existing users don't get prompted for unnecessary updates return [ + { + id: 'qwen3.5-plus', + name: 'qwen3.5-plus', + description: + 'qwen3.5-plus model with thinking enabled from Bailian Coding Plan', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + }, + }, { id: 'qwen3-coder-plus', name: 'qwen3-coder-plus', @@ -77,6 +90,19 @@ export function generateCodingPlanTemplate( // Global region uses new description with region indicator return [ + { + id: 'qwen3.5-plus', + name: 'qwen3.5-plus', + description: + 'qwen3.5-plus model with thinking enabled from Coding Plan (Global/Intl)', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + }, + }, { id: 'qwen3-coder-plus', name: 'qwen3-coder-plus', diff --git a/packages/cli/src/ui/components/ApiKeyInput.tsx b/packages/cli/src/ui/components/ApiKeyInput.tsx index a079e9956..a702c2d21 100644 --- a/packages/cli/src/ui/components/ApiKeyInput.tsx +++ b/packages/cli/src/ui/components/ApiKeyInput.tsx @@ -24,7 +24,7 @@ const CODING_PLAN_API_KEY_URL = 'https://bailian.console.aliyun.com/?tab=model#/efm/coding_plan'; const CODING_PLAN_INTL_API_KEY_URL = - 'https://modelstudio.console.alibabacloud.com/ap-southeast-1/?tab=globalset#/efm/api_key'; + 'https://modelstudio.console.alibabacloud.com/?tab=dashboard#/efm/coding_plan'; export function ApiKeyInput({ onSubmit, diff --git a/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts index 1e6a40722..d84e31540 100644 --- a/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts +++ b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts @@ -391,8 +391,8 @@ describe('useCodingPlanUpdates', () => { >; // Should have new China configs + custom config only (global config removed since regions are mutually exclusive) - // The template has 2 models, so we expect 2 (from template) + 1 (custom) = 3 - expect(updatedConfigs.length).toBe(3); + // The template has 3 models, so we expect 3 (from template) + 1 (custom) = 4 + expect(updatedConfigs.length).toBe(4); // Should NOT contain the Global config (mutually exclusive) expect( From b2b30c4c5db3f1d70d093ba9a7f3a1007da77375 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Thu, 19 Feb 2026 11:55:30 +0800 Subject: [PATCH 61/81] feat(runner): support auth_type for model configuration - Add ModelSpec dataclass to hold model name and optional auth_type - Update RunConfig.models to use List[ModelSpec] - Add auth_type field to RunRecord with serialization support - Parse models from config as string or {name, auth_type} dict - Pass --auth-type flag to qwen command when specified Co-authored-by: Qwen-Coder --- .../concurrent-runner/config.example.json | 6 ++- integration-tests/concurrent-runner/runner.py | 42 +++++++++++++++---- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/integration-tests/concurrent-runner/config.example.json b/integration-tests/concurrent-runner/config.example.json index 7042e7eb6..f1937fe07 100644 --- a/integration-tests/concurrent-runner/config.example.json +++ b/integration-tests/concurrent-runner/config.example.json @@ -31,5 +31,9 @@ ] } ], - "models": ["claude-3-5-sonnet-20241022", "qwen3-coder-plus"] + "models": [ + "qwen3-coder-plus", + { "name": "glm-4.7", "auth_type": "anthropic" }, + { "name": "claude-4-5-sonnet-20260219", "auth_type": "anthropic" } + ] } diff --git a/integration-tests/concurrent-runner/runner.py b/integration-tests/concurrent-runner/runner.py index c27a221e0..6eb2b8e0f 100644 --- a/integration-tests/concurrent-runner/runner.py +++ b/integration-tests/concurrent-runner/runner.py @@ -50,11 +50,18 @@ class Task: prompts: List[str] +@dataclass +class ModelSpec: + """One model to run: name and optional auth_type (e.g. anthropic).""" + name: str + auth_type: Optional[str] = None + + @dataclass class RunConfig: """Configuration for the concurrent execution.""" tasks: List[Task] - models: List[str] + models: List[ModelSpec] # name + optional auth_type per model concurrency: int = 4 yolo: bool = True source_repo: Path = field(default_factory=lambda: Path.cwd()) @@ -84,6 +91,7 @@ class RunRecord: task_name: str model: str status: RunStatus + auth_type: Optional[str] = None # e.g. "anthropic" for qwen --auth-type worktree_path: Optional[str] = None output_dir: Optional[str] = None logs_dir: Optional[str] = None @@ -104,6 +112,7 @@ class RunRecord: "task_name": self.task_name, "model": self.model, "status": self.status.value, + "auth_type": self.auth_type, "worktree_path": self.worktree_path, "output_dir": self.output_dir, "logs_dir": self.logs_dir, @@ -136,6 +145,7 @@ class RunRecord: task_name=data["task_name"], model=data["model"], status=RunStatus(data["status"]), + auth_type=data.get("auth_type"), worktree_path=data.get("worktree_path"), output_dir=data.get("output_dir"), logs_dir=data.get("logs_dir"), @@ -806,6 +816,10 @@ class QwenRunner: # Add model cmd.extend(["--model", run.model]) + # Add auth-type when model uses non-OpenAI protocol (e.g. anthropic for glm-4.7) + if run.auth_type: + cmd.extend(["--auth-type", run.auth_type]) + # Add yolo if enabled if self.config.yolo: cmd.append("--yolo") @@ -829,27 +843,41 @@ def generate_run_matrix(config: RunConfig) -> List[RunRecord]: runs = [] for task in config.tasks: for model in config.models: - run_id = str(uuid.uuid4())[:8] runs.append(RunRecord( - run_id=run_id, + run_id=str(uuid.uuid4())[:8], task_id=task.id, task_name=task.name, - model=model, + model=model.name, status=RunStatus.QUEUED, + auth_type=model.auth_type, )) return runs +def _parse_models(data_models: List[Any]) -> List[ModelSpec]: + """Parse models: string or {name, auth_type/authType}; returns list of ModelSpec.""" + specs: List[ModelSpec] = [] + for item in data_models or []: + if isinstance(item, str): + name, auth = item, None + elif isinstance(item, dict) and item.get("name"): + name = item["name"] + auth = item.get("auth_type") or item.get("authType") + else: + continue + specs.append(ModelSpec(name=name, auth_type=auth)) + return specs + + def load_config(config_path: Path) -> RunConfig: """Load configuration from JSON file.""" with open(config_path, 'r') as f: data = json.load(f) - tasks = [Task(**t) for t in data.get("tasks", [])] - + models = _parse_models(data.get("models", [])) return RunConfig( tasks=tasks, - models=data.get("models", []), + models=models, concurrency=data.get("concurrency", 4), yolo=data.get("yolo", True), source_repo=Path(data.get("source_repo", ".")).resolve(), From db56ba22cdf48ed1a02c4f19580687a2985994c2 Mon Sep 17 00:00:00 2001 From: DennisYu07 <617072224@qq.com> Date: Wed, 18 Feb 2026 23:52:37 -0800 Subject: [PATCH 62/81] fix chmod error for arch system and add curl and sudo check for shell script --- .../installation/install-qwen-with-source.sh | 159 +++++++++++++++++- 1 file changed, 150 insertions(+), 9 deletions(-) diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index 880ee89a7..f1e3d06b0 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -54,10 +54,109 @@ command_exists() { command -v "$1" >/dev/null 2>&1 } +# Global variable for download command +DOWNLOAD_CMD="curl -f -s -S -L" +WGET_CMD="wget -q -O -" + +# Function to ensure curl or wget is available +ensure_curl_or_wget() { + if command_exists curl; then + DOWNLOAD_CMD="curl -f -s -S -L" + WGET_CMD="wget -q -O -" + return 0 + fi + + if command_exists wget; then + echo "curl not found, using wget for downloads." + DOWNLOAD_CMD="wget -q -O -" + WGET_CMD="wget -q -O -" + return 0 + fi + + echo "Neither curl nor wget found. Attempting to install..." + + # Check if we're root or have sudo + if [[ "$(id -u)" -eq 0 ]]; then + # Running as root, no sudo needed + SUDO_CMD="" + elif command_exists sudo && sudo -n true 2>/dev/null; then + # Have sudo without password + SUDO_CMD="sudo" + else + echo "Error: Cannot install curl - sudo is not available and not running as root." + echo "Please install curl or wget manually and run this script again." + exit 1 + fi + + # Try to install curl based on OS + if command_exists apt-get; then + echo "Installing curl via apt-get..." + ${SUDO_CMD} apt-get update && ${SUDO_CMD} apt-get install -y curl + elif command_exists dnf; then + echo "Installing curl via dnf..." + ${SUDO_CMD} dnf install -y curl + elif command_exists pacman; then + echo "Installing curl via pacman..." + ${SUDO_CMD} pacman -Syu --noconfirm curl + elif command_exists zypper; then + echo "Installing curl via zypper..." + ${SUDO_CMD} zypper install -y curl + elif command_exists yum; then + echo "Installing curl via yum..." + ${SUDO_CMD} yum install -y curl + elif command_exists brew; then + echo "Installing curl via Homebrew..." + ${SUDO_CMD} brew install curl + elif command_exists /opt/homebrew/bin/brew; then + echo "Installing curl via Homebrew (ARM)..." + ${SUDO_CMD} /opt/homebrew/bin/brew install curl + else + echo "Error: Cannot install curl - no supported package manager found." + echo "Please install curl or wget manually and run this script again." + exit 1 + fi + + # Verify installation + if command_exists curl; then + echo "✓ curl installed successfully" + return 0 + else + echo "✗ Failed to install curl" + exit 1 + fi +} + +# Function to check if sudo is available +check_sudo_available() { + if command_exists sudo; then + # Check if sudo actually works (non-root user may have sudo but not configured) + if sudo -n true 2>/dev/null; then + return 0 + else + echo "Warning: sudo is installed but requires password." + return 1 + fi + fi + + # No sudo found - check if we're running as root + if [[ "$(id -u)" -eq 0 ]]; then + return 0 + fi + + echo "Error: sudo is not available and you are not running as root." + echo "" + echo "This script requires either:" + echo " 1. sudo access (run with a user in sudoers group)" + echo " 2. root access (run as root)" + echo "" + echo "Please run this script with proper permissions or install packages manually." + return 1 +} + # Function to fix npm global directory permissions fix_npm_permissions() { echo "Fixing npm global directory permissions..." - + # Get the actual npm global directory NPM_GLOBAL_DIR=$(npm config get prefix 2>/dev/null) if [[ -z "${NPM_GLOBAL_DIR}" ]] || [[ "${NPM_GLOBAL_DIR}" == *"error"* ]]; then @@ -65,7 +164,25 @@ fix_npm_permissions() { NPM_GLOBAL_DIR="${HOME}/.npm-global" echo "Warning: Could not determine npm prefix, using fallback: ${NPM_GLOBAL_DIR}" fi - + + # SAFETY CHECK: Never modify system directories + # This prevents catastrophic failures like breaking sudo setuid binaries + case "${NPM_GLOBAL_DIR}" in + /|/usr|/usr/local|/bin|/sbin|/lib|/lib64|/opt|/snap|/var|/etc) + echo "Warning: npm prefix is a system directory (${NPM_GLOBAL_DIR})." + echo "Skipping permission fix to avoid breaking system binaries." + echo "" + echo "This is likely a system-wide npm installation." + echo "Consider using a user-owned npm prefix instead:" + echo " npm config set prefix ~/.npm-global" + echo "" + echo "Alternatively, you can manually fix permissions for your user directory:" + echo " mkdir -p ~/.npm-global" + echo " npm config set prefix ~/.npm-global" + return 0 + ;; + esac + # 1. Change ownership of the entire npm global directory to current user # Using only user ownership without specifying a group for cross-platform compatibility sudo chown -R "$(whoami)" "${NPM_GLOBAL_DIR}" 2>/dev/null || true @@ -207,9 +324,9 @@ uninstall_nvm() { install_npm_only() { echo "Installing npm separately..." - if command_exists curl; then - echo "Attempting to install npm using: curl -qL https://www.npmjs.com/install.sh | sh" - if curl -qL https://www.npmjs.com/install.sh | sh; then + if command_exists curl || command_exists wget; then + echo "Attempting to install npm using: npmjs.com/install.sh" + if ${DOWNLOAD_CMD} https://www.npmjs.com/install.sh | sh; then NPM_VERSION_TMP=$(npm --version 2>/dev/null) if command_exists npm && [[ -n "${NPM_VERSION_TMP}" ]]; then echo "✓ npm v${NPM_VERSION_TMP} installed via direct install script" @@ -217,9 +334,9 @@ install_npm_only() { fi fi else - echo "curl command not found, proceeding with alternative methods" + echo "No download tool (curl/wget) available" fi - + return 1 } @@ -280,7 +397,7 @@ install_nodejs_via_nvm() { # Ensure cleanup on exit trap 'rm -f "${TMP_INSTALL_SCRIPT}"' EXIT - if curl -f -s -S -o "${TMP_INSTALL_SCRIPT}" "https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install_nvm.sh"; then + if ${DOWNLOAD_CMD} "https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install_nvm.sh" > "${TMP_INSTALL_SCRIPT}"; then if bash "${TMP_INSTALL_SCRIPT}"; then rm -f "${TMP_INSTALL_SCRIPT}" trap - EXIT @@ -481,7 +598,31 @@ EOF main() { # Initialize variables RESTORE_NPMRC=false - + + # Validate HOME variable + if [[ -z "${HOME}" ]]; then + echo "Warning: HOME environment variable is not set." + if [[ "$(id -u)" -eq 0 ]]; then + export HOME="/root" + echo "Using HOME=/root for root user." + else + export HOME=$(eval echo ~$(whoami)) + echo "Using HOME=${HOME}." + fi + fi + + # Validate HOME directory exists + if [[ ! -d "${HOME}" ]]; then + echo "Error: HOME directory (${HOME}) does not exist." + exit 1 + fi + + # Check sudo availability first (basic permission check) + check_sudo_available + + # Ensure curl/wget is available (needed to download NVM) + ensure_curl_or_wget + # Step 1: Check and install Node.js install_nodejs echo "" From 9ac8d9cfb1ca04fbb34e08bb04ceb476b4bdd683 Mon Sep 17 00:00:00 2001 From: DennisYu07 <617072224@qq.com> Date: Thu, 19 Feb 2026 01:16:18 -0800 Subject: [PATCH 63/81] fix issues for debian --- .../installation/install-qwen-with-source.sh | 140 ++++++++++++++++-- 1 file changed, 127 insertions(+), 13 deletions(-) diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index f1e3d06b0..d59bdc9a8 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -6,6 +6,13 @@ # Usage: install-qwen-with-source.sh --source [github|npm|internal|local-build] # install-qwen-with-source.sh -s [github|npm|internal|local-build] +# Check if running with sh (which doesn't support pipefail) +if [ -z "$BASH_VERSION" ]; then + # Re-execute with bash + exec bash "$0" "$@" +fi + + # Disable pagers to prevent interactive prompts export GIT_PAGER=cat export PAGER=cat @@ -344,6 +351,37 @@ install_npm_only() { install_nodejs_via_nvm() { export NVM_DIR="${HOME}/.nvm" + # Check glibc version before attempting installation + # Node.js 20+ requires glibc 2.27+ + GLIBC_VERSION=$(ldd --version 2>&1 | head -n1 | grep -oE '[0-9]+\.[0-9]+' | head -1) + + # Handle empty version + if [[ -z "${GLIBC_VERSION}" ]]; then + # Try alternative method + GLIBC_VERSION=$(ldd -v 2>&1 | grep -oP 'Version\s+\K[0-9.]+' | head -1 || echo "0") + fi + + # Ensure GLIBC_VERSION is a clean value (remove any newlines) + GLIBC_VERSION=$(echo "${GLIBC_VERSION}" | tr -d '\n\r' | sed 's/[[:space:]]//g') + + # Extract major and minor version + GLIBC_MAJOR=$(echo "${GLIBC_VERSION}" | cut -d. -f1) + GLIBC_MINOR=$(echo "${GLIBC_VERSION}" | cut -d. -f2) + GLIBC_MAJOR=${GLIBC_MAJOR:-0} + GLIBC_MINOR=${GLIBC_MINOR:-0} + + if [[ "${GLIBC_MAJOR}" -lt 2 ]] || \ + [[ "${GLIBC_MAJOR}" -eq 2 && "${GLIBC_MINOR}" -lt 27 ]]; then + echo "✗ Error: Detected glibc ${GLIBC_VERSION}" + echo "" + echo "Qwen Code requires Node.js 20+, which needs glibc 2.27+." + echo "Your system (CentOS 7 with glibc 2.17) is not compatible." + echo "" + echo "Please upgrade your OS or use Docker." + echo "" + exit 1 + fi + # Check NVM completeness if [[ -d "${NVM_DIR}" ]]; then if ! check_nvm_complete; then @@ -441,13 +479,25 @@ install_nodejs_via_nvm() { # Install Node.js 20 echo "Installing Node.js 20..." if nvm install 20 >/dev/null 2>&1; then - nvm use 20 >/dev/null 2>&1 - nvm alias default 20 >/dev/null 2>&1 + nvm use 20 >/dev/null 2>&1 || true + nvm alias default 20 >/dev/null 2>&1 || true else echo "✗ Failed to install Node.js 20" exit 1 fi - + + # Add NVM node to PATH for this script execution + # Find the actual installed Node.js version directory + NVM_NODE_PATH="" + if [[ -d "${NVM_DIR}/versions/node" ]]; then + # Find the v20.x.x directory + NVM_NODE_PATH=$(ls -d "${NVM_DIR}"/versions/node/v20.* 2>/dev/null | head -1)/bin + fi + + if [[ -n "${NVM_NODE_PATH}" ]] && [[ -d "${NVM_NODE_PATH}" ]]; then + export PATH="${NVM_NODE_PATH}:${PATH}" + fi + # Verify Node.js if ! command_exists node; then echo "✗ Node.js installation verification failed" @@ -492,6 +542,19 @@ install_nodejs_via_nvm() { # Function to check and install Qwen Code install_qwen_code() { + # Ensure NVM node is in PATH + export NVM_DIR="${HOME}/.nvm" + if [[ -s "${NVM_DIR}/nvm.sh" ]]; then + # shellcheck source=/dev/null + \. "${NVM_DIR}/nvm.sh" 2>/dev/null || true + fi + + # Also add npm global bin to PATH + NPM_GLOBAL_BIN=$(npm bin -g 2>/dev/null || echo "") + if [[ -n "${NPM_GLOBAL_BIN}" ]]; then + export PATH="${NPM_GLOBAL_BIN}:${PATH}" + fi + if command_exists qwen; then QWEN_VERSION=$(qwen --version 2>/dev/null || echo "unknown") echo "✓ Qwen Code is already installed: ${QWEN_VERSION}" @@ -559,16 +622,6 @@ install_qwen_code() { else echo " (Skipping source.json creation - no source specified)" fi - - # Verify installation - if command_exists qwen; then - QWEN_VERSION=$(qwen --version 2>/dev/null || echo "unknown") - echo "✓ Qwen Code is available as 'qwen' command" - echo " Installed version: ${QWEN_VERSION}" - else - echo "⚠ Qwen Code installed but not in PATH" - echo " You may need to restart your terminal" - fi } # Function to create source.json @@ -636,6 +689,17 @@ main() { echo "===========================================" echo "" + # Ensure NVM and npm global bin are in PATH before final check + export NVM_DIR="${HOME}/.nvm" + if [[ -s "${NVM_DIR}/nvm.sh" ]]; then + # shellcheck source=/dev/null + \. "${NVM_DIR}/nvm.sh" 2>/dev/null || true + fi + NPM_GLOBAL_BIN=$(npm bin -g 2>/dev/null || echo "") + if [[ -n "${NPM_GLOBAL_BIN}" ]]; then + export PATH="${NPM_GLOBAL_BIN}:${PATH}" + fi + # Check if qwen is immediately available if command_exists qwen; then echo "✓ Qwen Code is ready to use!" @@ -666,6 +730,56 @@ main() { echo "" echo "Or simply restart your terminal, then run: qwen" fi + + # Auto-configure PATH in shell config files + NPM_GLOBAL_BIN=$(npm bin -g 2>/dev/null || echo "") + NVM_DIR="${HOME}/.nvm" + + # Determine which config file to use + SHELL_CONFIG="" + if [[ -f "${HOME}/.bashrc" ]]; then + SHELL_CONFIG="${HOME}/.bashrc" + elif [[ -f "${HOME}/.bash_profile" ]]; then + SHELL_CONFIG="${HOME}/.bash_profile" + elif [[ -f "${HOME}/.profile" ]]; then + SHELL_CONFIG="${HOME}/.profile" + fi + + if [[ -n "${SHELL_CONFIG}" ]]; then + # Check if already configured + NEEDS_CONFIG=false + if [[ -n "${NPM_GLOBAL_BIN}" ]]; then + if ! grep -q "npm bin -g" "${SHELL_CONFIG}" 2>/dev/null && \ + ! grep -q "${NPM_GLOBAL_BIN}" "${SHELL_CONFIG}" 2>/dev/null; then + NEEDS_CONFIG=true + fi + fi + + if [[ "${NEEDS_CONFIG}" == "true" ]]; then + echo "" + echo "Adding Qwen Code to PATH in ${SHELL_CONFIG}..." + + # Append NVM configuration + if [[ -d "${NVM_DIR}" ]]; then + echo "" >> "${SHELL_CONFIG}" + echo "# NVM configuration (added by Qwen Code installer)" >> "${SHELL_CONFIG}" + echo "export NVM_DIR=\"${NVM_DIR}\"" >> "${SHELL_CONFIG}" + echo "[ -s \"\$NVM_DIR/nvm.sh\" ] && \\. \"\$NVM_DIR/nvm.sh\"" >> "${SHELL_CONFIG}" + echo "[ -s \"\$NVM_DIR/bash_completion\" ] && \\. \"\$NVM_DIR/bash_completion\"" >> "${SHELL_CONFIG}" + fi + + # Append npm global bin to PATH + if [[ -n "${NPM_GLOBAL_BIN}" ]]; then + echo "" >> "${SHELL_CONFIG}" + echo "# NPM global bin (added by Qwen Code installer)" >> "${SHELL_CONFIG}" + echo "export PATH=\"${NPM_GLOBAL_BIN}:\$PATH\"" >> "${SHELL_CONFIG}" + fi + + echo "✓ Configuration added to ${SHELL_CONFIG}" + echo "" + echo "Please run: source ${SHELL_CONFIG}" + fi + fi } # Run main function From bae1ba2d5dddb4482484c953feb826c0721dadce Mon Sep 17 00:00:00 2001 From: DennisYu07 <617072224@qq.com> Date: Thu, 19 Feb 2026 01:33:55 -0800 Subject: [PATCH 64/81] fix warning for shell script --- .../installation/install-qwen-with-source.sh | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index d59bdc9a8..c3d846955 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -7,9 +7,9 @@ # install-qwen-with-source.sh -s [github|npm|internal|local-build] # Check if running with sh (which doesn't support pipefail) -if [ -z "$BASH_VERSION" ]; then +if [[ -z "${BASH_VERSION}" ]]; then # Re-execute with bash - exec bash "$0" "$@" + exec bash "${0}" "${@}" fi @@ -63,20 +63,17 @@ command_exists() { # Global variable for download command DOWNLOAD_CMD="curl -f -s -S -L" -WGET_CMD="wget -q -O -" # Function to ensure curl or wget is available ensure_curl_or_wget() { if command_exists curl; then DOWNLOAD_CMD="curl -f -s -S -L" - WGET_CMD="wget -q -O -" return 0 fi if command_exists wget; then echo "curl not found, using wget for downloads." DOWNLOAD_CMD="wget -q -O -" - WGET_CMD="wget -q -O -" return 0 fi @@ -98,25 +95,25 @@ ensure_curl_or_wget() { # Try to install curl based on OS if command_exists apt-get; then echo "Installing curl via apt-get..." - ${SUDO_CMD} apt-get update && ${SUDO_CMD} apt-get install -y curl + ${SUDO_CMD} apt-get update && ${SUDO_CMD} apt-get install -y curl || true elif command_exists dnf; then echo "Installing curl via dnf..." - ${SUDO_CMD} dnf install -y curl + ${SUDO_CMD} dnf install -y curl || true elif command_exists pacman; then echo "Installing curl via pacman..." - ${SUDO_CMD} pacman -Syu --noconfirm curl + ${SUDO_CMD} pacman -Syu --noconfirm curl || true elif command_exists zypper; then echo "Installing curl via zypper..." - ${SUDO_CMD} zypper install -y curl + ${SUDO_CMD} zypper install -y curl || true elif command_exists yum; then echo "Installing curl via yum..." - ${SUDO_CMD} yum install -y curl + ${SUDO_CMD} yum install -y curl || true elif command_exists brew; then echo "Installing curl via Homebrew..." - ${SUDO_CMD} brew install curl + ${SUDO_CMD} brew install curl || true elif command_exists /opt/homebrew/bin/brew; then echo "Installing curl via Homebrew (ARM)..." - ${SUDO_CMD} /opt/homebrew/bin/brew install curl + ${SUDO_CMD} /opt/homebrew/bin/brew install curl || true else echo "Error: Cannot install curl - no supported package manager found." echo "Please install curl or wget manually and run this script again." @@ -188,6 +185,9 @@ fix_npm_permissions() { echo " npm config set prefix ~/.npm-global" return 0 ;; + *) + # Safe to proceed with non-system directory + ;; esac # 1. Change ownership of the entire npm global directory to current user From d0acea2185b87784aac4b398eaf1a6ff08f88392 Mon Sep 17 00:00:00 2001 From: DennisYu07 <617072224@qq.com> Date: Thu, 19 Feb 2026 01:47:36 -0800 Subject: [PATCH 65/81] fix warning --- .../installation/install-qwen-with-source.sh | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index c3d846955..399cbc1f8 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -80,13 +80,18 @@ ensure_curl_or_wget() { echo "Neither curl nor wget found. Attempting to install..." # Check if we're root or have sudo - if [[ "$(id -u)" -eq 0 ]]; then + CURRENT_UID=$(id -u) || true + if [[ "${CURRENT_UID}" -eq 0 ]]; then # Running as root, no sudo needed SUDO_CMD="" - elif command_exists sudo && sudo -n true 2>/dev/null; then - # Have sudo without password - SUDO_CMD="sudo" - else + elif command_exists sudo; then + if sudo -n true 2>/dev/null || true; then + # Have sudo without password + SUDO_CMD="sudo" + fi + fi + + if [[ -z "${SUDO_CMD}" ]] && [[ "${CURRENT_UID}" -ne 0 ]]; then echo "Error: Cannot install curl - sudo is not available and not running as root." echo "Please install curl or wget manually and run this script again." exit 1 @@ -143,7 +148,8 @@ check_sudo_available() { fi # No sudo found - check if we're running as root - if [[ "$(id -u)" -eq 0 ]]; then + CHECK_UID=$(id -u) || true + if [[ "${CHECK_UID}" -eq 0 ]]; then return 0 fi @@ -491,7 +497,7 @@ install_nodejs_via_nvm() { NVM_NODE_PATH="" if [[ -d "${NVM_DIR}/versions/node" ]]; then # Find the v20.x.x directory - NVM_NODE_PATH=$(ls -d "${NVM_DIR}"/versions/node/v20.* 2>/dev/null | head -1)/bin + NVM_NODE_PATH=$(find "${NVM_DIR}/versions/node" -maxdepth 1 -type d -name 'v20.*' 2>/dev/null | head -1)/bin fi if [[ -n "${NVM_NODE_PATH}" ]] && [[ -d "${NVM_NODE_PATH}" ]]; then @@ -655,11 +661,14 @@ main() { # Validate HOME variable if [[ -z "${HOME}" ]]; then echo "Warning: HOME environment variable is not set." - if [[ "$(id -u)" -eq 0 ]]; then + MAIN_UID=$(id -u) || true + if [[ "${MAIN_UID}" -eq 0 ]]; then export HOME="/root" echo "Using HOME=/root for root user." else - export HOME=$(eval echo ~$(whoami)) + CURRENT_USER=$(whoami) || true + DEFAULT_HOME="$(eval echo "~${CURRENT_USER}")" || true + export HOME="${DEFAULT_HOME}" echo "Using HOME=${HOME}." fi fi @@ -695,7 +704,7 @@ main() { # shellcheck source=/dev/null \. "${NVM_DIR}/nvm.sh" 2>/dev/null || true fi - NPM_GLOBAL_BIN=$(npm bin -g 2>/dev/null || echo "") + NPM_GLOBAL_BIN="$(npm bin -g 2>/dev/null)" || true if [[ -n "${NPM_GLOBAL_BIN}" ]]; then export PATH="${NPM_GLOBAL_BIN}:${PATH}" fi From 8fe304c19b0bcbb019fad5f35e21f3aaafe9d444 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Fri, 20 Feb 2026 21:57:53 +0800 Subject: [PATCH 66/81] fix(workflows): standardize release notes generation and add prerelease labels - Fix release-sdk.yml: Use file-based approach with --notes-file instead of complex inline string interpolation to avoid shell parsing errors with backticks and special characters - Fix release.yml: Add --prerelease flag for nightly and preview releases to properly mark them as pre-releases on GitHub Co-authored-by: Qwen-Coder --- .github/workflows/release-sdk.yml | 26 ++++++++++++++++++++++---- .github/workflows/release.yml | 11 ++++++++++- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml index ccdc24b77..f37d44da7 100644 --- a/.github/workflows/release-sdk.yml +++ b/.github/workflows/release-sdk.yml @@ -348,15 +348,33 @@ jobs: CLI_SOURCE_DESC="CLI built from source (same branch/ref as SDK)" fi - # Create release notes with CLI version info - NOTES="## Bundled CLI Version\n\nThis SDK release bundles CLI version: \`${CLI_VERSION}\`\n\nSource: ${CLI_SOURCE_DESC}\n\n---\n\n" + # Create release notes file + NOTES_FILE=$(mktemp) + { + echo "## Bundled CLI Version" + echo "" + echo "This SDK release bundles CLI version: ${CLI_VERSION}" + echo "" + echo "Source: ${CLI_SOURCE_DESC}" + echo "" + echo "---" + echo "" + } > "${NOTES_FILE}" + # Get previous release notes if available + PREVIOUS_NOTES=$(gh release view "sdk-typescript-${PREVIOUS_RELEASE_TAG}" --json body -q '.body' 2>/dev/null || echo 'See commit history for changes.') + echo "${PREVIOUS_NOTES}" >> "${NOTES_FILE}" + + # Create GitHub release gh release create "sdk-typescript-${RELEASE_TAG}" \ --target "${TARGET}" \ --title "SDK TypeScript Release ${RELEASE_TAG}" \ --notes-start-tag "sdk-typescript-${PREVIOUS_RELEASE_TAG}" \ - --notes "${NOTES}$(gh release view "sdk-typescript-${PREVIOUS_RELEASE_TAG}" --json body -q '.body' 2>/dev/null || echo 'See commit history for changes.')" \ - "${PRERELEASE_FLAG}" + --notes-file "${NOTES_FILE}" \ + ${PRERELEASE_FLAG} + + # Cleanup + rm -f "${NOTES_FILE}" - name: 'Create PR to merge release branch into main' if: |- diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ffcda3dc0..617cf9553 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -206,13 +206,22 @@ jobs: RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}' RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' PREVIOUS_RELEASE_TAG: '${{ steps.version.outputs.PREVIOUS_RELEASE_TAG }}' + IS_NIGHTLY: '${{ steps.vars.outputs.is_nightly }}' + IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}' run: |- + # Set prerelease flag for nightly and preview releases + PRERELEASE_FLAG="" + if [[ "${IS_NIGHTLY}" == "true" || "${IS_PREVIEW}" == "true" ]]; then + PRERELEASE_FLAG="--prerelease" + fi + gh release create "${RELEASE_TAG}" \ dist/cli.js \ --target "$RELEASE_BRANCH" \ --title "Release ${RELEASE_TAG}" \ --notes-start-tag "$PREVIOUS_RELEASE_TAG" \ - --generate-notes + --generate-notes \ + ${PRERELEASE_FLAG} - name: 'Create Issue on Failure' if: |- From fc04ba1ece6924bef1e173ff73524e7c1ba0f4d7 Mon Sep 17 00:00:00 2001 From: qwen-code-ci-bot Date: Fri, 20 Feb 2026 22:04:14 +0800 Subject: [PATCH 67/81] chore: bump version to 0.10.5 (#1886) Co-authored-by: Qwen-Coder --- package-lock.json | 14 +++++++------- package.json | 4 ++-- packages/cli/package.json | 4 ++-- packages/core/package.json | 2 +- packages/test-utils/package.json | 2 +- packages/vscode-ide-companion/package.json | 2 +- packages/webui/package.json | 2 +- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index b3bc14146..bed6b59d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qwen-code/qwen-code", - "version": "0.10.4", + "version": "0.10.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qwen-code/qwen-code", - "version": "0.10.4", + "version": "0.10.5", "workspaces": [ "packages/*" ], @@ -18655,7 +18655,7 @@ }, "packages/cli": { "name": "@qwen-code/qwen-code", - "version": "0.10.4", + "version": "0.10.5", "dependencies": { "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", @@ -19274,7 +19274,7 @@ }, "packages/core": { "name": "@qwen-code/qwen-code-core", - "version": "0.10.4", + "version": "0.10.5", "hasInstallScript": true, "dependencies": { "@anthropic-ai/sdk": "^0.36.1", @@ -22754,7 +22754,7 @@ }, "packages/test-utils": { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.10.4", + "version": "0.10.5", "dev": true, "license": "Apache-2.0", "devDependencies": { @@ -22766,7 +22766,7 @@ }, "packages/vscode-ide-companion": { "name": "qwen-code-vscode-ide-companion", - "version": "0.10.4", + "version": "0.10.5", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.1", @@ -23013,7 +23013,7 @@ }, "packages/webui": { "name": "@qwen-code/webui", - "version": "0.10.4", + "version": "0.10.5", "license": "MIT", "dependencies": { "markdown-it": "^14.1.0" diff --git a/package.json b/package.json index 0016ae0cc..f6b3fa51c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.10.4", + "version": "0.10.5", "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.10.4" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.5" }, "scripts": { "start": "cross-env node scripts/start.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index b1dc25a3d..14cfde268 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.10.4", + "version": "0.10.5", "description": "Qwen Code", "repository": { "type": "git", @@ -34,7 +34,7 @@ "dist" ], "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.4" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.10.5" }, "dependencies": { "@google/genai": "1.30.0", diff --git a/packages/core/package.json b/packages/core/package.json index b9535b6ef..0a49c509d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-core", - "version": "0.10.4", + "version": "0.10.5", "description": "Qwen Code Core", "repository": { "type": "git", diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index f1f8b3052..38514db0e 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.10.4", + "version": "0.10.5", "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 9d0f037f3..a71c08ade 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.10.4", + "version": "0.10.5", "publisher": "qwenlm", "icon": "assets/icon.png", "repository": { diff --git a/packages/webui/package.json b/packages/webui/package.json index f09355b53..8895c470d 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/webui", - "version": "0.10.4", + "version": "0.10.5", "description": "Shared UI components for Qwen Code packages", "type": "module", "main": "./dist/index.cjs", From bef3755ac28c89ce08f9bed03d24928bf6843709 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Sun, 22 Feb 2026 12:36:45 +0800 Subject: [PATCH 68/81] fix(workflows): improve release notes handling in release-sdk.yml - Use printf instead of echo for safer string output - Remove --notes-start-tag as we use --notes-file for custom release notes Co-authored-by: Qwen-Coder Co-authored-by: Qwen-Coder --- .github/workflows/release-sdk.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml index f37d44da7..c7e2e3619 100644 --- a/.github/workflows/release-sdk.yml +++ b/.github/workflows/release-sdk.yml @@ -363,13 +363,12 @@ jobs: # Get previous release notes if available PREVIOUS_NOTES=$(gh release view "sdk-typescript-${PREVIOUS_RELEASE_TAG}" --json body -q '.body' 2>/dev/null || echo 'See commit history for changes.') - echo "${PREVIOUS_NOTES}" >> "${NOTES_FILE}" + printf '%s\n' "${PREVIOUS_NOTES}" >> "${NOTES_FILE}" # Create GitHub release gh release create "sdk-typescript-${RELEASE_TAG}" \ --target "${TARGET}" \ --title "SDK TypeScript Release ${RELEASE_TAG}" \ - --notes-start-tag "sdk-typescript-${PREVIOUS_RELEASE_TAG}" \ --notes-file "${NOTES_FILE}" \ ${PRERELEASE_FLAG} From b0e8c665238afea1ccfe3e45cff9385053d21689 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Mon, 23 Feb 2026 10:17:44 +0800 Subject: [PATCH 69/81] feat: add qwen3-coder-next, glm-4.7, and kimi-k2.5 to Coding Plan (China region) - Added three new third-party models to the Bailian Coding Plan China region template - All models configured with thinking enabled (enable_thinking: true) - Updated README.md with example configurations for new models - Updated documentation to list all available Coding Plan models Co-authored-by: Qwen-Coder --- README.md | 36 ++++++++++++++++++++++ docs/users/configuration/auth.md | 2 +- packages/cli/src/constants/codingPlan.ts | 39 ++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0129d8302..ab598666c 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,42 @@ Use the `/model` command at any time to switch between all configured models. "baseUrl": "https://coding.dashscope.aliyuncs.com/v1", "description": "qwen3-coder-plus from Bailian Coding Plan", "envKey": "BAILIAN_CODING_PLAN_API_KEY" + }, + { + "id": "qwen3-coder-next", + "name": "qwen3-coder-next (Coding Plan)", + "baseUrl": "https://coding.dashscope.aliyuncs.com/v1", + "description": "qwen3-coder-next with thinking enabled from Bailian Coding Plan", + "envKey": "BAILIAN_CODING_PLAN_API_KEY", + "generationConfig": { + "extra_body": { + "enable_thinking": true + } + } + }, + { + "id": "glm-4.7", + "name": "glm-4.7 (Coding Plan)", + "baseUrl": "https://coding.dashscope.aliyuncs.com/v1", + "description": "glm-4.7 with thinking enabled from Bailian Coding Plan", + "envKey": "BAILIAN_CODING_PLAN_API_KEY", + "generationConfig": { + "extra_body": { + "enable_thinking": true + } + } + }, + { + "id": "kimi-k2.5", + "name": "kimi-k2.5 (Coding Plan)", + "baseUrl": "https://coding.dashscope.aliyuncs.com/v1", + "description": "kimi-k2.5 with thinking enabled from Bailian Coding Plan", + "envKey": "BAILIAN_CODING_PLAN_API_KEY", + "generationConfig": { + "extra_body": { + "enable_thinking": true + } + } } ] }, diff --git a/docs/users/configuration/auth.md b/docs/users/configuration/auth.md index 2b56c1fb6..7e9397345 100644 --- a/docs/users/configuration/auth.md +++ b/docs/users/configuration/auth.md @@ -94,7 +94,7 @@ After entering, select `Coding Plan`: ![](https://gw.alicdn.com/imgextra/i4/O1CN01Irk0AD1ebfop69o0r_!!6000000003890-2-tps-2308-830.png) -Enter your `sk-sp-xxxxxxxxx` key, then use the `/model` command to switch between all Bailian `Coding Plan` supported models: +Enter your `sk-sp-xxxxxxxxx` key, then use the `/model` command to switch between all Bailian `Coding Plan` supported models (including qwen3.5-plus, qwen3-coder-plus, qwen3-coder-next, qwen3-max, glm-4.7, and kimi-k2.5): ![](https://gw.alicdn.com/imgextra/i4/O1CN01fWArmf1kaCEgSmPln_!!6000000004699-2-tps-2304-1374.png) diff --git a/packages/cli/src/constants/codingPlan.ts b/packages/cli/src/constants/codingPlan.ts index 5dfdc297f..a8a95b9cb 100644 --- a/packages/cli/src/constants/codingPlan.ts +++ b/packages/cli/src/constants/codingPlan.ts @@ -72,6 +72,19 @@ export function generateCodingPlanTemplate( description: 'qwen3-coder-plus model from Bailian Coding Plan', envKey: CODING_PLAN_ENV_KEY, }, + { + id: 'qwen3-coder-next', + name: 'qwen3-coder-next', + description: + 'qwen3-coder-next model with thinking enabled from Bailian Coding Plan', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + }, + }, { id: 'qwen3-max-2026-01-23', name: 'qwen3-max-2026-01-23', @@ -85,6 +98,32 @@ export function generateCodingPlanTemplate( }, }, }, + { + id: 'glm-4.7', + name: 'glm-4.7', + description: + 'glm-4.7 model with thinking enabled from Bailian Coding Plan', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + }, + }, + { + id: 'kimi-k2.5', + name: 'kimi-k2.5', + description: + 'kimi-k2.5 model with thinking enabled from Bailian Coding Plan', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + }, + }, ]; } From 7ebae58a8cfef14fadbcd0925f11d13ce0e174ce Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Mon, 23 Feb 2026 10:18:57 +0800 Subject: [PATCH 70/81] feat: add glm-4.7 and kimi-k2.5 to Coding Plan (Global/Intl region) - Added glm-4.7 and kimi-k2.5 to the Global/Intl region template - Excluded qwen3-coder-next as it's not yet supported internationally - Both models configured with thinking enabled Co-authored-by: Qwen-Coder --- packages/cli/src/constants/codingPlan.ts | 26 ++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/cli/src/constants/codingPlan.ts b/packages/cli/src/constants/codingPlan.ts index a8a95b9cb..7b01f3ba4 100644 --- a/packages/cli/src/constants/codingPlan.ts +++ b/packages/cli/src/constants/codingPlan.ts @@ -162,6 +162,32 @@ export function generateCodingPlanTemplate( }, }, }, + { + id: 'glm-4.7', + name: 'glm-4.7', + description: + 'glm-4.7 model with thinking enabled from Coding Plan (Global/Intl)', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + }, + }, + { + id: 'kimi-k2.5', + name: 'kimi-k2.5', + description: + 'kimi-k2.5 model with thinking enabled from Coding Plan (Global/Intl)', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + }, + }, ]; } From 24ea2b696431be7c8da2ce15eeaf60b183aec9b3 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Mon, 23 Feb 2026 10:51:00 +0800 Subject: [PATCH 71/81] feat: add qwen3-coder-next to Coding Plan (Global/Intl region) - Added qwen3-coder-next model to the Global/Intl Coding Plan template - Removed thinking mode from qwen3-coder-next (both China and Global regions) - Updated test expectations to reflect the new model count Co-authored-by: Qwen-Coder --- packages/cli/src/constants/codingPlan.ts | 15 ++++++++------- .../cli/src/ui/hooks/useCodingPlanUpdates.test.ts | 4 ++-- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/constants/codingPlan.ts b/packages/cli/src/constants/codingPlan.ts index 7b01f3ba4..2f8a10a6a 100644 --- a/packages/cli/src/constants/codingPlan.ts +++ b/packages/cli/src/constants/codingPlan.ts @@ -75,15 +75,9 @@ export function generateCodingPlanTemplate( { id: 'qwen3-coder-next', name: 'qwen3-coder-next', - description: - 'qwen3-coder-next model with thinking enabled from Bailian Coding Plan', + description: 'qwen3-coder-next model from Bailian Coding Plan', baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', envKey: CODING_PLAN_ENV_KEY, - generationConfig: { - extra_body: { - enable_thinking: true, - }, - }, }, { id: 'qwen3-max-2026-01-23', @@ -149,6 +143,13 @@ export function generateCodingPlanTemplate( description: 'qwen3-coder-plus model from Coding Plan (Global/Intl)', envKey: CODING_PLAN_ENV_KEY, }, + { + id: 'qwen3-coder-next', + name: 'qwen3-coder-next', + description: 'qwen3-coder-next model from Coding Plan (Global/Intl)', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + }, { id: 'qwen3-max-2026-01-23', name: 'qwen3-max-2026-01-23', diff --git a/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts index d84e31540..1707b17e8 100644 --- a/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts +++ b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts @@ -391,8 +391,8 @@ describe('useCodingPlanUpdates', () => { >; // Should have new China configs + custom config only (global config removed since regions are mutually exclusive) - // The template has 3 models, so we expect 3 (from template) + 1 (custom) = 4 - expect(updatedConfigs.length).toBe(4); + // The China template has 6 models, so we expect 6 (from template) + 1 (custom) = 7 + expect(updatedConfigs.length).toBe(7); // Should NOT contain the Global config (mutually exclusive) expect( From ce3fc8f46202eb1cd668a23aa4bf54dd4d59f3b1 Mon Sep 17 00:00:00 2001 From: DennisYu07 <617072224@qq.com> Date: Mon, 23 Feb 2026 19:29:55 -0800 Subject: [PATCH 72/81] refactor installation script --- .../installation/install-qwen-with-source.sh | 1060 +++++++---------- 1 file changed, 408 insertions(+), 652 deletions(-) diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index 399cbc1f8..15164b391 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -1,194 +1,371 @@ #!/bin/bash -# Script to install Node.js and Qwen Code with source information -# This script handles the installation process and sets the installation source +# Qwen Code Installation Script +# This script installs Node.js (via NVM) and Qwen Code CLI +# Supports Linux and macOS # # Usage: install-qwen-with-source.sh --source [github|npm|internal|local-build] # install-qwen-with-source.sh -s [github|npm|internal|local-build] -# Check if running with sh (which doesn't support pipefail) -if [[ -z "${BASH_VERSION}" ]]; then - # Re-execute with bash - exec bash "${0}" "${@}" +# Re-execute with bash if running with sh or other shells +# Skip re-exec if already in bash and avoid double-fork in git hooks +if [[ -z "${BASH_VERSION}" ]] && [[ -z "${__QWEN_INSTALL_REEXEC:-}" ]]; then + # Check if we're in a git hook environment + if [[ "${0}" == *".git/hooks/"* ]] || [[ -n "${GIT_DIR:-}" ]]; then + export __QWEN_IN_GIT_HOOK=1 + fi + + # Try to find bash + if command -v bash >/dev/null 2>&1; then + export __QWEN_INSTALL_REEXEC=1 + # Use exec only if not in git hook to avoid nested shell issues + if [[ -n "${__QWEN_IN_GIT_HOOK:-}" ]]; then + exec bash "${0}" "${@}" + else + exec bash "${0}" "${@}" + fi + else + echo "Error: This script requires bash. Please install bash first." + exit 1 + fi fi +set -eo pipefail -# Disable pagers to prevent interactive prompts -export GIT_PAGER=cat -export PAGER=cat +# ============================================ +# Color definitions +# ============================================ +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color -# Enable pipefail to catch errors in pipelines -set -o pipefail - -# Function to display usage -usage() { - echo "Usage: $0 [OPTIONS]" - echo "" - echo "Options:" - echo " -s, --source SOURCE Specify the installation source (e.g., github, npm, internal)" - echo " -h, --help Show this help message" - echo "" - exit 1 +# ============================================ +# Log functions +# ============================================ +log_info() { + echo -e "${BLUE}ℹ️ $1${NC}" } +log_success() { + echo -e "${GREEN}✅ $1${NC}" +} + +log_warning() { + echo -e "${YELLOW}⚠️ $1${NC}" +} + +log_error() { + echo -e "${RED}❌ $1${NC}" +} + +# ============================================ +# Utility functions +# ============================================ +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +get_shell_profile() { + local current_shell + current_shell=$(basename "${SHELL}") + case "${current_shell}" in + bash) + echo "${HOME}/.bashrc" + ;; + zsh) + echo "${HOME}/.zshrc" + ;; + fish) + echo "${HOME}/.config/fish/config.fish" + ;; + *) + echo "${HOME}/.profile" + ;; + esac +} + +# ============================================ # Parse command line arguments +# ============================================ SOURCE="unknown" while [[ $# -gt 0 ]]; do case $1 in -s|--source) if [[ -z "$2" ]] || [[ "$2" == -* ]]; then - echo "Error: --source requires a value" - usage + log_error "--source requires a value" + exit 1 fi SOURCE="$2" shift 2 ;; -h|--help) - usage + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " -s, --source SOURCE Specify the installation source (e.g., github, npm, internal)" + echo " -h, --help Show this help message" + echo "" + exit 0 ;; *) - usage + log_error "Unknown option: $1" + exit 1 ;; esac done -echo "===========================================" -echo "Qwen Code Installation Script with Source Tracking" -echo "===========================================" +# ============================================ +# Print header +# ============================================ +echo "==========================================" +echo " Qwen Code Installation Script" +echo "==========================================" +echo "" +log_info "System: $(uname -s) $(uname -r)" || true +log_info "Shell: $(basename "${SHELL}")" +echo "" -# Function to check if a command exists -command_exists() { - command -v "$1" >/dev/null 2>&1 -} - -# Global variable for download command -DOWNLOAD_CMD="curl -f -s -S -L" - -# Function to ensure curl or wget is available -ensure_curl_or_wget() { +# ============================================ +# Ensure download tool is available +# ============================================ +ensure_download_tool() { if command_exists curl; then - DOWNLOAD_CMD="curl -f -s -S -L" + DOWNLOAD_CMD="curl" + DOWNLOAD_ARGS="-fsSL" return 0 fi if command_exists wget; then - echo "curl not found, using wget for downloads." - DOWNLOAD_CMD="wget -q -O -" + DOWNLOAD_CMD="wget" + DOWNLOAD_ARGS="-qO -" return 0 fi - echo "Neither curl nor wget found. Attempting to install..." + log_error "Neither curl nor wget found" + log_info "Please install curl or wget manually:" + echo " - macOS: brew install curl" + echo " - Ubuntu/Debian: sudo apt-get install curl" + echo " - CentOS/RHEL: sudo yum install curl" + exit 1 +} - # Check if we're root or have sudo - CURRENT_UID=$(id -u) || true - if [[ "${CURRENT_UID}" -eq 0 ]]; then - # Running as root, no sudo needed - SUDO_CMD="" - elif command_exists sudo; then - if sudo -n true 2>/dev/null || true; then - # Have sudo without password - SUDO_CMD="sudo" - fi - fi - - if [[ -z "${SUDO_CMD}" ]] && [[ "${CURRENT_UID}" -ne 0 ]]; then - echo "Error: Cannot install curl - sudo is not available and not running as root." - echo "Please install curl or wget manually and run this script again." - exit 1 - fi - - # Try to install curl based on OS - if command_exists apt-get; then - echo "Installing curl via apt-get..." - ${SUDO_CMD} apt-get update && ${SUDO_CMD} apt-get install -y curl || true - elif command_exists dnf; then - echo "Installing curl via dnf..." - ${SUDO_CMD} dnf install -y curl || true - elif command_exists pacman; then - echo "Installing curl via pacman..." - ${SUDO_CMD} pacman -Syu --noconfirm curl || true - elif command_exists zypper; then - echo "Installing curl via zypper..." - ${SUDO_CMD} zypper install -y curl || true - elif command_exists yum; then - echo "Installing curl via yum..." - ${SUDO_CMD} yum install -y curl || true - elif command_exists brew; then - echo "Installing curl via Homebrew..." - ${SUDO_CMD} brew install curl || true - elif command_exists /opt/homebrew/bin/brew; then - echo "Installing curl via Homebrew (ARM)..." - ${SUDO_CMD} /opt/homebrew/bin/brew install curl || true - else - echo "Error: Cannot install curl - no supported package manager found." - echo "Please install curl or wget manually and run this script again." - exit 1 - fi - - # Verify installation - if command_exists curl; then - echo "✓ curl installed successfully" - return 0 - else - echo "✗ Failed to install curl" - exit 1 +# ============================================ +# Clean npm configuration conflicts +# ============================================ +clean_npmrc_conflict() { + local npmrc="${HOME}/.npmrc" + if [[ -f "${npmrc}" ]]; then + log_info "Cleaning npmrc conflicts..." + grep -Ev '^(prefix|globalconfig) *= *' "${npmrc}" > "${npmrc}.tmp" || true + mv -f "${npmrc}.tmp" "${npmrc}" || true fi } -# Function to check if sudo is available -check_sudo_available() { - if command_exists sudo; then - # Check if sudo actually works (non-root user may have sudo but not configured) - if sudo -n true 2>/dev/null; then +# ============================================ +# Install NVM +# ============================================ +install_nvm() { + local NVM_DIR="${NVM_DIR:-${HOME}/.nvm}" + local NVM_VERSION="${NVM_VERSION:-v0.40.3}" + + if [[ -s "${NVM_DIR}/nvm.sh" ]]; then + log_info "NVM is already installed at ${NVM_DIR}" + return 0 + fi + + log_info "Installing NVM ${NVM_VERSION}..." + + # Download and install NVM from Aliyun OSS + # Use temporary file instead of pipe to avoid potential subshell issues + local NVM_INSTALL_TEMP + NVM_INSTALL_TEMP=$(mktemp) + if "${DOWNLOAD_CMD}" "${DOWNLOAD_ARGS}" "https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install_nvm.sh" > "${NVM_INSTALL_TEMP}"; then + # Run the script in current shell environment + # shellcheck source=/dev/null + . "${NVM_INSTALL_TEMP}" + rm -f "${NVM_INSTALL_TEMP}" + log_success "NVM installed successfully" + else + rm -f "${NVM_INSTALL_TEMP}" + log_error "Failed to install NVM" + log_info "Please install NVM manually: https://github.com/nvm-sh/nvm#install--update-script" + exit 1 + fi + + # Configure shell profile + local PROFILE_FILE + PROFILE_FILE=$(get_shell_profile) + + # Check if profile file is writable + if [[ -f "${PROFILE_FILE}" ]] && [[ ! -w "${PROFILE_FILE}" ]]; then + log_warning "Cannot write to ${PROFILE_FILE} (permission denied)" + log_info "Skipping shell profile configuration" + log_info "You may need to manually add NVM configuration to your shell profile" + elif ! grep -q 'NVM_DIR' "${PROFILE_FILE}" 2>/dev/null; then + # shellcheck disable=SC2016 + # The following echo statements intentionally use single quotes to write literal strings + { + echo "" + echo "# NVM configuration (added by Qwen Code installer)" + echo "export NVM_DIR=\"\$HOME/.nvm\"" + echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' + echo '[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"' + } >> "${PROFILE_FILE}" 2>/dev/null || { + log_warning "Failed to write to ${PROFILE_FILE}" + log_info "Skipping shell profile configuration" return 0 - else - echo "Warning: sudo is installed but requires password." - return 1 - fi + } + log_info "Added NVM config to ${PROFILE_FILE}" fi - # No sudo found - check if we're running as root - CHECK_UID=$(id -u) || true - if [[ "${CHECK_UID}" -eq 0 ]]; then - return 0 - fi + # Load NVM for current session + export NVM_DIR="${NVM_DIR}" + # shellcheck source=/dev/null + [[ -s "${NVM_DIR}/nvm.sh" ]] && \. "${NVM_DIR}/nvm.sh" - echo "Error: sudo is not available and you are not running as root." - echo "" - echo "This script requires either:" - echo " 1. sudo access (run with a user in sudoers group)" - echo " 2. root access (run as root)" - echo "" - echo "Please run this script with proper permissions or install packages manually." - return 1 + log_success "NVM configured successfully" + return 0 } -# Function to fix npm global directory permissions -fix_npm_permissions() { - echo "Fixing npm global directory permissions..." +# ============================================ +# Install Node.js via NVM +# ============================================ +install_nodejs_with_nvm() { + local NODE_VERSION="${NODE_VERSION:-20}" + local NVM_DIR="${NVM_DIR:-${HOME}/.nvm}" - # Get the actual npm global directory - NPM_GLOBAL_DIR=$(npm config get prefix 2>/dev/null) + # Ensure NVM is loaded + export NVM_DIR="${NVM_DIR}" + # shellcheck source=/dev/null + [[ -s "${NVM_DIR}/nvm.sh" ]] && \. "${NVM_DIR}/nvm.sh" + + if ! command_exists nvm; then + log_error "NVM not loaded properly" + return 1 + fi + + # Set Node.js mirror source for faster downloads in China + export NVM_NODEJS_ORG_MIRROR="https://npmmirror.com/mirrors/node" + + # Install Node.js + log_info "Installing Node.js v${NODE_VERSION}..." + if nvm install "${NODE_VERSION}"; then + nvm alias default "${NODE_VERSION}" || true + nvm use default || true + log_success "Node.js v${NODE_VERSION} installed successfully" + + # Verify installation + log_info "Node.js version: $(node -v)" || true + log_info "npm version: $(npm -v)" || true + + return 0 + else + log_error "Failed to install Node.js" + return 1 + fi +} + +# ============================================ +# Check Node.js version +# ============================================ +check_node_version() { + if ! command_exists node; then + return 1 + fi + + local current_version + current_version=$(node -v | sed 's/v//') + local major_version + major_version=$(echo "${current_version}" | cut -d. -f1) + + if [[ "${major_version}" -ge 20 ]]; then + log_success "Node.js v${current_version} is already installed (>= 20)" + return 0 + else + log_warning "Node.js v${current_version} is installed but version < 20" + return 1 + fi +} + +# ============================================ +# Install Node.js +# ============================================ +install_nodejs() { + local platform + platform=$(uname -s) + + case "${platform}" in + Linux|Darwin) + log_info "Installing Node.js on ${platform}..." + + # Install NVM + if ! install_nvm; then + log_error "Failed to install NVM" + return 1 + fi + + # Load NVM + export NVM_DIR="${HOME}/.nvm" + # shellcheck source=/dev/null + [[ -s "${NVM_DIR}/nvm.sh" ]] && \. "${NVM_DIR}/nvm.sh" + + # Install Node.js + if ! install_nodejs_with_nvm; then + log_error "Failed to install Node.js" + return 1 + fi + ;; + MINGW*|CYGWIN*|MSYS*) + log_error "Windows platform detected. Please use Windows installer or WSL." + log_info "Visit: https://nodejs.org/en/download/" + exit 1 + ;; + *) + log_error "Unsupported platform: ${platform}" + exit 1 + ;; + esac +} + +# ============================================ +# Check and install Node.js +# ============================================ +check_and_install_nodejs() { + if check_node_version; then + log_info "Using existing Node.js installation" + clean_npmrc_conflict + else + log_warning "Installing or upgrading Node.js..." + install_nodejs + fi +} + +# ============================================ +# Fix npm permissions (without using sudo) +# ============================================ +fix_npm_permissions() { + log_info "Checking npm permissions..." + + local NPM_GLOBAL_DIR + NPM_GLOBAL_DIR=$(npm config get prefix 2>/dev/null) || true if [[ -z "${NPM_GLOBAL_DIR}" ]] || [[ "${NPM_GLOBAL_DIR}" == *"error"* ]]; then - # Fallback to default if npm config fails NPM_GLOBAL_DIR="${HOME}/.npm-global" - echo "Warning: Could not determine npm prefix, using fallback: ${NPM_GLOBAL_DIR}" + npm config set prefix "${NPM_GLOBAL_DIR}" + log_info "Set npm prefix to user directory: ${NPM_GLOBAL_DIR}" + return 0 fi # SAFETY CHECK: Never modify system directories # This prevents catastrophic failures like breaking sudo setuid binaries case "${NPM_GLOBAL_DIR}" in /|/usr|/usr/local|/bin|/sbin|/lib|/lib64|/opt|/snap|/var|/etc) - echo "Warning: npm prefix is a system directory (${NPM_GLOBAL_DIR})." - echo "Skipping permission fix to avoid breaking system binaries." - echo "" - echo "This is likely a system-wide npm installation." - echo "Consider using a user-owned npm prefix instead:" - echo " npm config set prefix ~/.npm-global" - echo "" - echo "Alternatively, you can manually fix permissions for your user directory:" - echo " mkdir -p ~/.npm-global" - echo " npm config set prefix ~/.npm-global" + log_warning "npm prefix is a system directory (${NPM_GLOBAL_DIR})." + log_info "Using user directory instead to avoid breaking system binaries." + NPM_GLOBAL_DIR="${HOME}/.npm-global" + npm config set prefix "${NPM_GLOBAL_DIR}" + log_success "npm prefix set to: ${NPM_GLOBAL_DIR}" return 0 ;; *) @@ -196,600 +373,179 @@ fix_npm_permissions() { ;; esac - # 1. Change ownership of the entire npm global directory to current user - # Using only user ownership without specifying a group for cross-platform compatibility - sudo chown -R "$(whoami)" "${NPM_GLOBAL_DIR}" 2>/dev/null || true - - # 2. Fix directory permissions (ensure user has full read/write/execute permissions) - chmod -R u+rwX "${NPM_GLOBAL_DIR}" 2>/dev/null || true - - # 3. Specifically fix parent directory permissions (to prevent mkdir failures) - chmod u+rwx "${NPM_GLOBAL_DIR}" "${NPM_GLOBAL_DIR}/lib" "${NPM_GLOBAL_DIR}/lib/node_modules" 2>/dev/null || true -} - -# Function to check and install Node.js -install_nodejs() { - if command_exists node; then - NODE_VERSION=$(node --version) - # Extract major version number (remove 'v' prefix and get first number) - NODE_MAJOR_VERSION=$(echo "${NODE_VERSION}" | sed 's/v//' | cut -d'.' -f1) || true - - # Check if NODE_MAJOR_VERSION is a valid number - if ! [[ "${NODE_MAJOR_VERSION}" =~ ^[0-9]+$ ]]; then - echo "⚠ Could not parse Node.js version: ${NODE_VERSION}" - echo "Installing Node.js 20+..." - install_nodejs_via_nvm - elif [[ "${NODE_MAJOR_VERSION}" -ge 20 ]]; then - echo "✓ Node.js is already installed: ${NODE_VERSION}" - - # Check npm after confirming Node.js exists - if ! command_exists npm; then - echo "⚠ npm not found, installing npm..." - if install_npm_only; then - echo "✓ npm installation completed" - else - echo "✗ Failed to install npm" - echo "Please install npm manually or reinstall Node.js from: https://nodejs.org/" - exit 1 - fi - else - if NPM_VERSION=$(npm --version 2>/dev/null) && [[ -n "${NPM_VERSION}" ]]; then - echo "✓ npm v${NPM_VERSION} is available" - else - echo "⚠ npm exists but cannot execute, reinstalling..." - if install_npm_only; then - echo "✓ npm installation fixed" - else - echo "✗ Failed to fix npm" - exit 1 - fi - fi - fi - - # Check if npm global directory has permission issues - if ! npm config get prefix >/dev/null 2>&1; then - fix_npm_permissions - fi - - return 0 - else - echo "⚠ Node.js ${NODE_VERSION} is installed, but Qwen Code requires Node.js 20+" - echo "Installing Node.js 20+..." - install_nodejs_via_nvm - fi - else - echo "Installing Node.js 20+..." - install_nodejs_via_nvm - fi -} - -# Function to check if NVM installation is complete -check_nvm_complete() { - export NVM_DIR="${HOME}/.nvm" - - if [[ ! -d "${NVM_DIR}" ]]; then - return 1 + # Check if npm global directory is writable + if [[ -w "${NPM_GLOBAL_DIR}" ]]; then + log_info "npm global directory is writable" + return 0 fi - if [[ ! -s "${NVM_DIR}/nvm.sh" ]]; then - echo "⚠ Incomplete NVM: nvm.sh missing" - return 1 - fi + # If not writable, use user directory + log_warning "npm global directory is not writable: ${NPM_GLOBAL_DIR}" + log_info "Setting npm prefix to user directory..." - # shellcheck source=/dev/null - if ! \. "${NVM_DIR}/nvm.sh" 2>/dev/null; then - echo "⚠ Corrupted NVM: cannot load nvm.sh" - return 1 - fi + NPM_GLOBAL_DIR="${HOME}/.npm-global" + mkdir -p "${NPM_GLOBAL_DIR}" + npm config set prefix "${NPM_GLOBAL_DIR}" - if ! command_exists nvm; then - echo "⚠ Incomplete NVM: nvm command unavailable" - return 1 + log_success "npm prefix set to: ${NPM_GLOBAL_DIR}" + + # Add to PATH in shell profile + local PROFILE_FILE + PROFILE_FILE=$(get_shell_profile) + if ! grep -q '.npm-global/bin' "${PROFILE_FILE}" 2>/dev/null; then + { + echo "" + echo "# NPM global bin (added by Qwen Code installer)" + echo "export PATH=\"\$HOME/.npm-global/bin:\$PATH\"" + } >> "${PROFILE_FILE}" + log_info "Added npm global bin to PATH in ${PROFILE_FILE}" fi return 0 } -# Function to uninstall NVM -uninstall_nvm() { - echo "Uninstalling NVM..." - export NVM_DIR="${HOME}/.nvm" - - if [[ -d "${NVM_DIR}" ]]; then - # Try to remove the directory, check for errors - if ! rm -rf "${NVM_DIR}" 2>/dev/null; then - echo "⚠ Failed to remove NVM directory (permission denied or files in use)" - echo " Attempting with elevated permissions..." - # Try with sudo if available - if command -v sudo >/dev/null 2>&1; then - sudo rm -rf "${NVM_DIR}" 2>/dev/null || true - fi - fi - - # Verify removal - if [[ -d "${NVM_DIR}" ]]; then - echo "⚠ Warning: Could not fully remove NVM directory at ${NVM_DIR}" - echo " Some files may be in use by other processes." - echo " Continuing anyway, but installation may fail..." - else - echo "✓ Removed NVM directory" - fi - fi - - # Clean shell configs - for config in "${HOME}/.bashrc" "${HOME}/.bash_profile" "${HOME}/.zshrc" "${HOME}/.profile"; do - if [[ -f "${config}" ]]; then - # shellcheck disable=SC2312 - cp "${config}" "${config}.bak.$(date +%s)" 2>/dev/null - sed -i.tmp '/NVM_DIR/d; /nvm.sh/d; /bash_completion/d' "${config}" 2>/dev/null || \ - sed -i '' '/NVM_DIR/d; /nvm.sh/d; /bash_completion/d' "${config}" 2>/dev/null - rm -f "${config}.tmp" 2>/dev/null || true - fi - done - - # Unset nvm function to avoid conflicts with reinstallation - unset -f nvm 2>/dev/null || true - - echo "✓ Cleaned NVM configuration" -} - -# Function to install npm only -install_npm_only() { - echo "Installing npm separately..." - - if command_exists curl || command_exists wget; then - echo "Attempting to install npm using: npmjs.com/install.sh" - if ${DOWNLOAD_CMD} https://www.npmjs.com/install.sh | sh; then - NPM_VERSION_TMP=$(npm --version 2>/dev/null) - if command_exists npm && [[ -n "${NPM_VERSION_TMP}" ]]; then - echo "✓ npm v${NPM_VERSION_TMP} installed via direct install script" - return 0 - fi - fi - else - echo "No download tool (curl/wget) available" - fi - - return 1 -} - -# Function to install Node.js via nvm -install_nodejs_via_nvm() { - export NVM_DIR="${HOME}/.nvm" - - # Check glibc version before attempting installation - # Node.js 20+ requires glibc 2.27+ - GLIBC_VERSION=$(ldd --version 2>&1 | head -n1 | grep -oE '[0-9]+\.[0-9]+' | head -1) - - # Handle empty version - if [[ -z "${GLIBC_VERSION}" ]]; then - # Try alternative method - GLIBC_VERSION=$(ldd -v 2>&1 | grep -oP 'Version\s+\K[0-9.]+' | head -1 || echo "0") - fi - - # Ensure GLIBC_VERSION is a clean value (remove any newlines) - GLIBC_VERSION=$(echo "${GLIBC_VERSION}" | tr -d '\n\r' | sed 's/[[:space:]]//g') - - # Extract major and minor version - GLIBC_MAJOR=$(echo "${GLIBC_VERSION}" | cut -d. -f1) - GLIBC_MINOR=$(echo "${GLIBC_VERSION}" | cut -d. -f2) - GLIBC_MAJOR=${GLIBC_MAJOR:-0} - GLIBC_MINOR=${GLIBC_MINOR:-0} - - if [[ "${GLIBC_MAJOR}" -lt 2 ]] || \ - [[ "${GLIBC_MAJOR}" -eq 2 && "${GLIBC_MINOR}" -lt 27 ]]; then - echo "✗ Error: Detected glibc ${GLIBC_VERSION}" - echo "" - echo "Qwen Code requires Node.js 20+, which needs glibc 2.27+." - echo "Your system (CentOS 7 with glibc 2.17) is not compatible." - echo "" - echo "Please upgrade your OS or use Docker." - echo "" - exit 1 - fi - - # Check NVM completeness - if [[ -d "${NVM_DIR}" ]]; then - if ! check_nvm_complete; then - echo "Detected incomplete NVM installation" - uninstall_nvm - # If directory still exists after uninstall (partial removal), try to clean it - if [[ -d "${NVM_DIR}" ]]; then - echo " Cleaning up residual NVM files..." - # Remove everything except we can't delete (probably in use) - find "${NVM_DIR}" -mindepth 1 -delete 2>/dev/null || true - # If still can't remove the directory itself, warn but continue - if [[ -d "${NVM_DIR}" ]]; then - echo " Note: Some NVM files are locked by running processes." - echo " Will attempt to install NVM over existing directory..." - fi - fi - else - echo "✓ NVM already installed" - fi - fi - - # Install NVM if needed (either no dir or partial/corrupted) - if [[ ! -d "${NVM_DIR}" ]] || [[ ! -s "${NVM_DIR}/nvm.sh" ]]; then - echo "Downloading NVM..." - - # Use mktemp for secure temporary file creation - # Remove trailing slash from TMPDIR to avoid double slashes - TEMP_DIR="${TMPDIR:-/tmp}" - TEMP_DIR="${TEMP_DIR%/}" - - # Retry mktemp a few times if it fails - TMP_INSTALL_SCRIPT="" - for _ in 1 2 3; do - TMP_INSTALL_SCRIPT=$(mktemp "${TEMP_DIR}/nvm_install.XXXXXXXXXX.sh" 2>/dev/null) - if [[ -n "${TMP_INSTALL_SCRIPT}" ]] && [[ -f "${TMP_INSTALL_SCRIPT}" ]]; then - break - fi - # Wait a bit before retry - sleep 0.1 - done - - # Fallback if mktemp still fails - if [[ -z "${TMP_INSTALL_SCRIPT}" ]]; then - TMP_INSTALL_SCRIPT="${TEMP_DIR}/nvm_install_$$_$(date +%s%N).sh" - touch "${TMP_INSTALL_SCRIPT}" 2>/dev/null || { - echo "✗ Failed to create temporary file" - exit 1 - } - fi - - # Ensure cleanup on exit - trap 'rm -f "${TMP_INSTALL_SCRIPT}"' EXIT - - if ${DOWNLOAD_CMD} "https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install_nvm.sh" > "${TMP_INSTALL_SCRIPT}"; then - if bash "${TMP_INSTALL_SCRIPT}"; then - rm -f "${TMP_INSTALL_SCRIPT}" - trap - EXIT - echo "✓ NVM installed" - else - echo "✗ NVM installation failed" - rm -f "${TMP_INSTALL_SCRIPT}" - trap - EXIT - echo "Please install Node.js manually from: https://nodejs.org/" - exit 1 - fi - else - echo "✗ Failed to download NVM" - rm -f "${TMP_INSTALL_SCRIPT}" - trap - EXIT - echo "Please check your internet connection or install Node.js manually from https://nodejs.org/" - exit 1 - fi - fi - - # Load NVM - if [[ -s "${NVM_DIR}/nvm.sh" ]]; then - # shellcheck source=/dev/null - \. "${NVM_DIR}/nvm.sh" - else - echo "✗ NVM installation failed - nvm.sh not found" - echo "Please install Node.js manually from https://nodejs.org/" - exit 1 - fi - - # shellcheck source=/dev/null - [[ -s "${NVM_DIR}/bash_completion" ]] && \. "${NVM_DIR}/bash_completion" - - # Verify NVM loaded - if ! command_exists nvm; then - echo "✗ Failed to load NVM" - echo "Please manually load NVM or install Node.js from https://nodejs.org/" - exit 1 - fi - - # Install Node.js 20 - echo "Installing Node.js 20..." - if nvm install 20 >/dev/null 2>&1; then - nvm use 20 >/dev/null 2>&1 || true - nvm alias default 20 >/dev/null 2>&1 || true - else - echo "✗ Failed to install Node.js 20" - exit 1 - fi - - # Add NVM node to PATH for this script execution - # Find the actual installed Node.js version directory - NVM_NODE_PATH="" - if [[ -d "${NVM_DIR}/versions/node" ]]; then - # Find the v20.x.x directory - NVM_NODE_PATH=$(find "${NVM_DIR}/versions/node" -maxdepth 1 -type d -name 'v20.*' 2>/dev/null | head -1)/bin - fi - - if [[ -n "${NVM_NODE_PATH}" ]] && [[ -d "${NVM_NODE_PATH}" ]]; then - export PATH="${NVM_NODE_PATH}:${PATH}" - fi - - # Verify Node.js - if ! command_exists node; then - echo "✗ Node.js installation verification failed" - exit 1 - fi - - if ! NODE_VERSION=$(node --version 2>/dev/null) || [[ -z "${NODE_VERSION}" ]]; then - echo "✗ Node.js cannot execute properly" - exit 1 - fi - - echo "✓ Node.js ${NODE_VERSION} installed" - - # Check npm separately - if ! command_exists npm; then - echo "⚠ npm not found" - - if install_npm_only; then - echo "✓ npm installation fixed" - else - echo "✗ Failed to install npm" - echo "Please try:" - echo " 1. Run this script again" - echo " 2. Install Node.js from: https://nodejs.org/" - exit 1 - fi - else - if NPM_VERSION=$(npm --version 2>/dev/null) && [[ -n "${NPM_VERSION}" ]]; then - echo "✓ npm v${NPM_VERSION} installed" - else - echo "⚠ npm exists but cannot execute" - - if install_npm_only; then - echo "✓ npm installation fixed" - else - echo "✗ Failed to fix npm" - exit 1 - fi - fi - fi -} - -# Function to check and install Qwen Code +# ============================================ +# Install Qwen Code +# ============================================ install_qwen_code() { # Ensure NVM node is in PATH export NVM_DIR="${HOME}/.nvm" - if [[ -s "${NVM_DIR}/nvm.sh" ]]; then - # shellcheck source=/dev/null - \. "${NVM_DIR}/nvm.sh" 2>/dev/null || true - fi + # shellcheck source=/dev/null + [[ -s "${NVM_DIR}/nvm.sh" ]] && \. "${NVM_DIR}/nvm.sh" 2>/dev/null || true - # Also add npm global bin to PATH - NPM_GLOBAL_BIN=$(npm bin -g 2>/dev/null || echo "") + # Add npm global bin to PATH + local NPM_GLOBAL_BIN + NPM_GLOBAL_BIN=$(npm bin -g 2>/dev/null) || true if [[ -n "${NPM_GLOBAL_BIN}" ]]; then export PATH="${NPM_GLOBAL_BIN}:${PATH}" fi if command_exists qwen; then - QWEN_VERSION=$(qwen --version 2>/dev/null || echo "unknown") - echo "✓ Qwen Code is already installed: ${QWEN_VERSION}" - echo " Upgrading to the latest version..." + local QWEN_VERSION + QWEN_VERSION=$(qwen --version 2>/dev/null) || echo "unknown" + log_success "Qwen Code is already installed: ${QWEN_VERSION}" + log_info "Upgrading to the latest version..." fi - # Check if .npmrc contains incompatible settings for nvm - if [[ -f "${HOME}/.npmrc" ]]; then - if grep -q "prefix\|globalconfig" "${HOME}/.npmrc"; then - echo "⚠ Found incompatible settings in ~/.npmrc for NVM" - echo " Creating temporary backup and removing incompatible settings..." - - # Backup .npmrc file - cp "${HOME}/.npmrc" "${HOME}/.npmrc.backup.before.qwen.install" - - # Create temporary .npmrc without incompatible settings - grep -v -E '^(prefix|globalconfig)' "${HOME}/.npmrc" > "${HOME}/.npmrc.temp.for.qwen.install" - - # Use the temporary .npmrc - mv "${HOME}/.npmrc" "${HOME}/.npmrc.original" - mv "${HOME}/.npmrc.temp.for.qwen.install" "${HOME}/.npmrc" - - # Remember to restore later - RESTORE_NPMRC=true + # Clean npmrc conflicts + clean_npmrc_conflict + + # Fix npm permissions if needed + fix_npm_permissions + + # Configure npm registry for faster downloads in China + npm config set registry https://registry.npmmirror.com + log_info "npm registry set to npmmirror" + + # Install Qwen Code + log_info "Installing Qwen Code..." + if npm install -g @qwen-code/qwen-code@latest; then + log_success "Qwen Code installed successfully!" + + # Verify installation + if command_exists qwen; then + local qwen_version + qwen_version=$(qwen --version 2>/dev/null) || qwen_version="unknown" + log_info "Qwen Code version: ${qwen_version}" fi - fi - - echo " Attempting to install Qwen Code with current user permissions..." - if npm install -g @qwen-code/qwen-code@latest 2>/dev/null; then - echo "✓ Qwen Code installed/upgraded successfully!" else - # Installation failed, likely due to permissions - echo " Installation failed with user permissions, attempting to fix permissions..." - - # Fix npm global directory permissions - fix_npm_permissions - - # Try again after fixing permissions - if npm install -g @qwen-code/qwen-code@latest 2>/dev/null; then - echo "✓ Qwen Code installed/upgraded successfully after permission fix!" - else - # Both attempts failed - echo "✗ Failed to install Qwen Code even after permission fix" - echo " Please check your system permissions or contact support" - # Restore .npmrc if we backed it up - if [[ "${RESTORE_NPMRC}" = true ]]; then - mv "${HOME}/.npmrc" "${HOME}/.npmrc.temp.after.failed.install" - mv "${HOME}/.npmrc.original" "${HOME}/.npmrc" - echo " Restored original ~/.npmrc file" - fi - exit 1 - fi + log_error "Failed to install Qwen Code!" + log_info "Please check your internet connection and try again" + exit 1 fi - # Restore original .npmrc file if we modified it - if [[ "${RESTORE_NPMRC}" = true ]]; then - mv "${HOME}/.npmrc" "${HOME}/.npmrc.temp.after.successful.install" - mv "${HOME}/.npmrc.original" "${HOME}/.npmrc" - echo " Restored original ~/.npmrc file" - fi - - # Create/Update source.json only if source parameter was provided + # Create source.json if source parameter was provided if [[ "${SOURCE}" != "unknown" ]]; then create_source_json - else - echo " (Skipping source.json creation - no source specified)" fi } -# Function to create source.json +# ============================================ +# Create source.json +# ============================================ create_source_json() { - QWEN_DIR="${HOME}/.qwen" + local QWEN_DIR="${HOME}/.qwen" - # Create .qwen directory if it doesn't exist - if [[ ! -d "${QWEN_DIR}" ]]; then - mkdir -p "${QWEN_DIR}" - fi + mkdir -p "${QWEN_DIR}" # Escape special characters in SOURCE for JSON - # Replace backslashes first, then quotes + local ESCAPED_SOURCE ESCAPED_SOURCE=$(printf '%s' "${SOURCE}" | sed 's/\\/\\\\/g; s/"/\\"/g') - # Create source.json file cat > "${QWEN_DIR}/source.json" </dev/null || true - fi - NPM_GLOBAL_BIN="$(npm bin -g 2>/dev/null)" || true + # shellcheck source=/dev/null + [[ -s "${NVM_DIR}/nvm.sh" ]] && \. "${NVM_DIR}/nvm.sh" 2>/dev/null || true + local NPM_GLOBAL_BIN + NPM_GLOBAL_BIN=$(npm bin -g 2>/dev/null) || true if [[ -n "${NPM_GLOBAL_BIN}" ]]; then export PATH="${NPM_GLOBAL_BIN}:${PATH}" fi # Check if qwen is immediately available if command_exists qwen; then - echo "✓ Qwen Code is ready to use!" + log_success "Qwen Code is ready to use!" echo "" echo "You can now run: qwen" else - echo "⚠ To start using Qwen Code, please run one of the following commands:" + log_warning "To start using Qwen Code, please run:" echo "" - - # Detect user's shell - USER_SHELL=$(basename "${SHELL}") - - if [[ "${USER_SHELL}" = "zsh" ]] && [[ -f "${HOME}/.zshrc" ]]; then - echo " source ~/.zshrc" - elif [[ "${USER_SHELL}" = "bash" ]]; then - if [[ -f "${HOME}/.bash_profile" ]]; then - echo " source ~/.bash_profile" - elif [[ -f "${HOME}/.bashrc" ]]; then - echo " source ~/.bashrc" - fi - else - # Fallback: show all possible options - [[ -f "${HOME}/.zshrc" ]] && echo " source ~/.zshrc" - [[ -f "${HOME}/.bashrc" ]] && echo " source ~/.bashrc" - [[ -f "${HOME}/.bash_profile" ]] && echo " source ~/.bash_profile" - fi - + local PROFILE_FILE + PROFILE_FILE=$(get_shell_profile) + echo " source ${PROFILE_FILE}" echo "" echo "Or simply restart your terminal, then run: qwen" fi - - # Auto-configure PATH in shell config files - NPM_GLOBAL_BIN=$(npm bin -g 2>/dev/null || echo "") - NVM_DIR="${HOME}/.nvm" - - # Determine which config file to use - SHELL_CONFIG="" - if [[ -f "${HOME}/.bashrc" ]]; then - SHELL_CONFIG="${HOME}/.bashrc" - elif [[ -f "${HOME}/.bash_profile" ]]; then - SHELL_CONFIG="${HOME}/.bash_profile" - elif [[ -f "${HOME}/.profile" ]]; then - SHELL_CONFIG="${HOME}/.profile" - fi - - if [[ -n "${SHELL_CONFIG}" ]]; then - # Check if already configured - NEEDS_CONFIG=false - if [[ -n "${NPM_GLOBAL_BIN}" ]]; then - if ! grep -q "npm bin -g" "${SHELL_CONFIG}" 2>/dev/null && \ - ! grep -q "${NPM_GLOBAL_BIN}" "${SHELL_CONFIG}" 2>/dev/null; then - NEEDS_CONFIG=true - fi - fi - - if [[ "${NEEDS_CONFIG}" == "true" ]]; then - echo "" - echo "Adding Qwen Code to PATH in ${SHELL_CONFIG}..." - - # Append NVM configuration - if [[ -d "${NVM_DIR}" ]]; then - echo "" >> "${SHELL_CONFIG}" - echo "# NVM configuration (added by Qwen Code installer)" >> "${SHELL_CONFIG}" - echo "export NVM_DIR=\"${NVM_DIR}\"" >> "${SHELL_CONFIG}" - echo "[ -s \"\$NVM_DIR/nvm.sh\" ] && \\. \"\$NVM_DIR/nvm.sh\"" >> "${SHELL_CONFIG}" - echo "[ -s \"\$NVM_DIR/bash_completion\" ] && \\. \"\$NVM_DIR/bash_completion\"" >> "${SHELL_CONFIG}" - fi - - # Append npm global bin to PATH - if [[ -n "${NPM_GLOBAL_BIN}" ]]; then - echo "" >> "${SHELL_CONFIG}" - echo "# NPM global bin (added by Qwen Code installer)" >> "${SHELL_CONFIG}" - echo "export PATH=\"${NPM_GLOBAL_BIN}:\$PATH\"" >> "${SHELL_CONFIG}" - fi - - echo "✓ Configuration added to ${SHELL_CONFIG}" - echo "" - echo "Please run: source ${SHELL_CONFIG}" - fi - fi } # Run main function -main "$@" \ No newline at end of file +main "$@" From 45530e2e416479d3d3efe099ae2c6781cb0b8b48 Mon Sep 17 00:00:00 2001 From: DennisYu07 <617072224@qq.com> Date: Mon, 23 Feb 2026 20:03:03 -0800 Subject: [PATCH 73/81] add bash for debain --- .../installation/install-qwen-with-source.sh | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index 15164b391..0991ec485 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -8,29 +8,35 @@ # install-qwen-with-source.sh -s [github|npm|internal|local-build] # Re-execute with bash if running with sh or other shells -# Skip re-exec if already in bash and avoid double-fork in git hooks -if [[ -z "${BASH_VERSION}" ]] && [[ -z "${__QWEN_INSTALL_REEXEC:-}" ]]; then +# This block must use POSIX-compliant syntax ([ not [[) since it runs before we know bash is available +if [ -z "${BASH_VERSION}" ] && [ -z "${__QWEN_INSTALL_REEXEC:-}" ]; then # Check if we're in a git hook environment - if [[ "${0}" == *".git/hooks/"* ]] || [[ -n "${GIT_DIR:-}" ]]; then + case "${0}" in + *.git/hooks/*) export __QWEN_IN_GIT_HOOK=1 ;; + esac + if [ -n "${GIT_DIR:-}" ]; then export __QWEN_IN_GIT_HOOK=1 fi # Try to find bash if command -v bash >/dev/null 2>&1; then export __QWEN_INSTALL_REEXEC=1 - # Use exec only if not in git hook to avoid nested shell issues - if [[ -n "${__QWEN_IN_GIT_HOOK:-}" ]]; then - exec bash "${0}" "${@}" - else - exec bash "${0}" "${@}" - fi + # Re-exec with bash, preserving all arguments + exec bash -- "${0}" "$@" else echo "Error: This script requires bash. Please install bash first." exit 1 fi fi -set -eo pipefail +# Enable strict mode (bash-specific options) +# pipefail requires bash 3+; check before setting +if [ -n "${BASH_VERSION:-}" ]; then + # shellcheck disable=SC3040 + set -eo pipefail +else + set -e +fi # ============================================ # Color definitions From ee2d68b43d2dc5370af38fee72cf3d84e9e92981 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Tue, 24 Feb 2026 14:22:47 +0800 Subject: [PATCH 74/81] fix: update security vulnerability reporting channel - Update SECURITY.md with proper security reporting portal - Change reporting link to Alibaba Cloud security console - Add clear guidance for security vs non-security issues Fixes #1883 Co-authored-by: Qwen-Coder --- SECURITY.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 4e7d8ce79..d4ae9df9e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,9 @@ -# Reporting Security Issues +# Security Policy -Please report any security issue or Higress crash report to [ASRC](https://security.alibaba.com/) (Alibaba Security Response Center) where the issue will be triaged appropriately. +## Reporting a Vulnerability -Thank you for helping keep our project secure. +If you believe you have discovered a security vulnerability, please report it to us through the following portal: [Report Security Issue](https://yundun.console.aliyun.com/?p=xznew#/taskmanagement/tasks/detail/151) + +> **Note:** This channel is strictly for reporting security-related issues. Non-security vulnerabilities or general bug reports will not be addressed here. + +We sincerely appreciate your responsible disclosure and your contribution to helping us keep our project secure. From 1c9ff66c765c10e665e274531ac14b778c5276dc Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Tue, 24 Feb 2026 22:18:50 +0800 Subject: [PATCH 75/81] docs: enhance modelProviders configuration documentation - Add comprehensive configuration examples for all auth types (openai, anthropic, gemini, vertex-ai) - Add local self-hosted model examples (vLLM, Ollama, LM Studio) - Clarify generation config layering with impermeable provider layer concept - Add Provider Model vs Runtime Model explanation - Document duplicate model ID limitation - Deprecate security.auth.apiKey and security.auth.baseUrl settings - Add notes about extra_body parameter support limitations Co-authored-by: Qwen-Coder --- docs/users/configuration/settings.md | 367 ++++++++++++++++++++++++--- 1 file changed, 327 insertions(+), 40 deletions(-) diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 0094f411d..aff42545d 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -184,7 +184,17 @@ The `extra_body` field allows you to add custom parameters to the request body s Use `modelProviders` to declare curated model lists per auth type that the `/model` picker can switch between. Keys must be valid auth types (`openai`, `anthropic`, `gemini`, `vertex-ai`, etc.). Each entry requires an `id` and **must include `envKey`**, with optional `name`, `description`, `baseUrl`, and `generationConfig`. Credentials are never persisted in settings; the runtime reads them from `process.env[envKey]`. Qwen OAuth models remain hard-coded and cannot be overridden. -##### Example +> [!note] +> Only the `/model` command exposes non-default auth types. Anthropic, Gemini, Vertex AI, etc., must be defined via `modelProviders`. The `/auth` command intentionally lists only the built-in Qwen OAuth and OpenAI flows. + +> [!warning] +> **Duplicate model IDs within the same authType:** Defining multiple models with the same `id` under a single `authType` (e.g., two entries with `"id": "gpt-4o"` in `openai`) is currently not supported. If duplicates exist, **the first occurrence wins** and subsequent duplicates are skipped with a warning. Note that the `id` field is used both as the configuration identifier and as the actual model name sent to the API, so using unique IDs (e.g., `gpt-4o-creative`, `gpt-4o-balanced`) is not a viable workaround. This is a known limitation that we plan to address in a future release. + +##### Configuration Examples by Auth Type + +Below are comprehensive configuration examples for different authentication types, showing the available parameters and their combinations: + +**OpenAI-compatible providers** (`openai`): ```json { @@ -198,47 +208,213 @@ Use `modelProviders` to declare curated model lists per auth type that the `/mod "generationConfig": { "timeout": 60000, "maxRetries": 3, + "enableCacheControl": true, + "contextWindowSize": 128000, "customHeaders": { - "X-Model-Version": "v1.0", - "X-Request-Priority": "high" + "X-Request-ID": "req-123", + "X-User-ID": "user-456" }, "extra_body": { - "enable_thinking": true + "enable_thinking": true, + "service_tier": "priority" }, - "samplingParams": { "temperature": 0.2 } + "samplingParams": { + "temperature": 0.2, + "top_p": 0.8, + "max_tokens": 4096, + "presence_penalty": 0.1, + "frequency_penalty": 0.1 + } } - } - ], - "anthropic": [ + }, { - "id": "claude-3-5-sonnet", - "envKey": "ANTHROPIC_API_KEY", - "baseUrl": "https://api.anthropic.com/v1" - } - ], - "gemini": [ - { - "id": "gemini-2.0-flash", - "name": "Gemini 2.0 Flash", - "envKey": "GEMINI_API_KEY", - "baseUrl": "https://generativelanguage.googleapis.com" - } - ], - "vertex-ai": [ - { - "id": "gemini-1.5-pro-vertex", - "envKey": "GOOGLE_API_KEY", - "baseUrl": "https://generativelanguage.googleapis.com" + "id": "gpt-4o-mini", + "name": "GPT-4o Mini", + "envKey": "OPENAI_API_KEY", + "baseUrl": "https://api.openai.com/v1", + "generationConfig": { + "timeout": 30000, + "samplingParams": { + "temperature": 0.5, + "max_tokens": 2048 + } + } } ] } } ``` -> [!note] -> Only the `/model` command exposes non-default auth types. Anthropic, Gemini, Vertex AI, etc., must be defined via `modelProviders`. The `/auth` command intentionally lists only the built-in Qwen OAuth and OpenAI flows. +**Anthropic** (`anthropic`): -##### Resolution layers and atomicity +```json +{ + "modelProviders": { + "anthropic": [ + { + "id": "claude-3-5-sonnet", + "name": "Claude 3.5 Sonnet", + "envKey": "ANTHROPIC_API_KEY", + "baseUrl": "https://api.anthropic.com/v1", + "generationConfig": { + "timeout": 120000, + "maxRetries": 3, + "contextWindowSize": 200000, + "customHeaders": { + "anthropic-version": "2023-06-01" + }, + "samplingParams": { + "temperature": 0.7, + "max_tokens": 8192, + "top_p": 0.9 + } + } + }, + { + "id": "claude-3-opus", + "name": "Claude 3 Opus", + "envKey": "ANTHROPIC_API_KEY", + "baseUrl": "https://api.anthropic.com/v1", + "generationConfig": { + "timeout": 180000, + "samplingParams": { + "temperature": 0.3, + "max_tokens": 4096 + } + } + } + ] + } +} +``` + +**Google Gemini** (`gemini`): + +```json +{ + "modelProviders": { + "gemini": [ + { + "id": "gemini-2.0-flash", + "name": "Gemini 2.0 Flash", + "envKey": "GEMINI_API_KEY", + "baseUrl": "https://generativelanguage.googleapis.com", + "capabilities": { + "vision": true + }, + "generationConfig": { + "timeout": 60000, + "maxRetries": 2, + "contextWindowSize": 1000000, + "schemaCompliance": "auto", + "samplingParams": { + "temperature": 0.4, + "top_p": 0.95, + "max_tokens": 8192, + "top_k": 40 + } + } + } + ] + } +} +``` + +**Google Vertex AI** (`vertex-ai`): + +```json +{ + "modelProviders": { + "vertex-ai": [ + { + "id": "gemini-1.5-pro-vertex", + "name": "Gemini 1.5 Pro (Vertex AI)", + "envKey": "GOOGLE_API_KEY", + "baseUrl": "https://generativelanguage.googleapis.com", + "generationConfig": { + "timeout": 90000, + "contextWindowSize": 2000000, + "samplingParams": { + "temperature": 0.2, + "max_tokens": 8192 + } + } + } + ] + } +} +``` + +**Local Self-Hosted Models (via OpenAI-compatible API)**: + +Most local inference servers (vLLM, Ollama, LM Studio, etc.) provide an OpenAI-compatible API endpoint. Configure them using the `openai` auth type with a local `baseUrl`: + +```json +{ + "modelProviders": { + "openai": [ + { + "id": "qwen2.5-7b", + "name": "Qwen2.5 7B (Ollama)", + "envKey": "OLLAMA_API_KEY", + "baseUrl": "http://localhost:11434/v1", + "generationConfig": { + "timeout": 300000, + "maxRetries": 1, + "contextWindowSize": 32768, + "samplingParams": { + "temperature": 0.7, + "top_p": 0.9, + "max_tokens": 4096 + } + } + }, + { + "id": "llama-3.1-8b", + "name": "Llama 3.1 8B (vLLM)", + "envKey": "VLLM_API_KEY", + "baseUrl": "http://localhost:8000/v1", + "generationConfig": { + "timeout": 120000, + "maxRetries": 2, + "contextWindowSize": 128000, + "samplingParams": { + "temperature": 0.6, + "max_tokens": 8192 + } + } + }, + { + "id": "local-model", + "name": "Local Model (LM Studio)", + "envKey": "LMSTUDIO_API_KEY", + "baseUrl": "http://localhost:1234/v1", + "generationConfig": { + "timeout": 60000, + "samplingParams": { + "temperature": 0.5 + } + } + } + ] + } +} +``` + +For local servers that don't require authentication, you can use any placeholder value for the API key: + +```bash +# For Ollama (no auth required) +export OLLAMA_API_KEY="ollama" + +# For vLLM (if no auth is configured) +export VLLM_API_KEY="not-needed" +``` + +> [!note] +> The `extra_body` parameter is **only supported for OpenAI-compatible providers** (`openai`, `qwen-oauth`). It is ignored for Anthropic, Gemini, and Vertex AI providers. + +##### Resolution Layers and Atomicity The effective auth/model/credential values are chosen per field using the following precedence (first present wins). You can combine `--auth-type` with `--model` to point directly at a provider entry; these CLI flags run before other layers. @@ -253,28 +429,139 @@ The effective auth/model/credential values are chosen per field using the follow \*When present, CLI auth flags override settings. Otherwise, `security.auth.selectedType` or the implicit default determine the auth type. Qwen OAuth and OpenAI are the only auth types surfaced without extra configuration. -Model-provider sourced values are applied atomically: once a provider model is active, every field it defines is protected from lower layers until you manually clear credentials via `/auth`. The final `generationConfig` is the projection across all layers—lower layers only fill gaps left by higher ones, and the provider layer remains impenetrable. +> [!warning] +> **Deprecation of `security.auth.apiKey` and `security.auth.baseUrl`:** Directly configuring API credentials via `security.auth.apiKey` and `security.auth.baseUrl` in `settings.json` is deprecated. These settings were used in historical versions for credentials entered through the UI, but the credential input flow was removed in version 0.10.1. These fields will be fully removed in a future release. **It is strongly recommended to migrate to `modelProviders`** for all model and credential configurations. Use `envKey` in `modelProviders` to reference environment variables for secure credential management instead of hardcoding credentials in settings files. -The merge strategy for `modelProviders` is REPLACE: the entire `modelProviders` from project settings will override the corresponding section in user settings, rather than merging the two. +##### Generation Config Layering: The Impermeable Provider Layer -##### Generation config layering +The configuration resolution follows a strict layering model with one crucial rule: **the modelProvider layer is impermeable**. -Per-field precedence for `generationConfig`: +**How it works:** -1. Programmatic overrides (e.g. runtime `/model`, `/auth` changes) -2. `modelProviders[authType][].generationConfig` -3. `settings.model.generationConfig` -4. Content-generator defaults (`getDefaultGenerationConfig` for OpenAI, `getParameterValue` for Gemini, etc.) +1. **When a modelProvider model IS selected** (e.g., via `/model` command choosing a provider-configured model): + - The entire `generationConfig` from the provider is applied **atomically** + - **The provider layer is completely impermeable** — lower layers (CLI, env, settings) do not participate in generationConfig resolution at all + - All fields defined in `modelProviders[].generationConfig` use the provider's values + - All fields **not defined** by the provider are set to `undefined` (not inherited from settings) + - This ensures provider configurations act as a complete, self-contained "sealed package" -`samplingParams`, `customHeaders`, and `extra_body` are all treated atomically; provider values replace the entire object. If `modelProviders[].generationConfig` defines these fields, they are used directly; otherwise, values from `model.generationConfig` are used. No merging occurs between provider and global configuration levels. Defaults from the content generator apply last so each provider retains its tuned baseline. +2. **When NO modelProvider model is selected** (e.g., using `--model` with a raw model ID, or using CLI/env/settings directly): + - The resolution falls through to lower layers + - Fields are populated from CLI → env → settings → defaults + - This creates a **Runtime Model** (see next section) -##### Selection persistence and recommendations +**Per-field precedence for `generationConfig`:** + +| Priority | Source | Behavior | +|----------|--------|----------| +| 1 | Programmatic overrides | Runtime `/model`, `/auth` changes | +| 2 | `modelProviders[authType][].generationConfig` | **Impermeable layer** - completely replaces all generationConfig fields; lower layers do not participate | +| 3 | `settings.model.generationConfig` | Only used for **Runtime Models** (when no provider model is selected) | +| 4 | Content-generator defaults | Provider-specific defaults (e.g., OpenAI vs Gemini) - only for Runtime Models | + +**Atomic field treatment:** + +The following fields are treated as atomic objects - provider values completely replace the entire object, no merging occurs: + +- `samplingParams` - Temperature, top_p, max_tokens, etc. +- `customHeaders` - Custom HTTP headers +- `extra_body` - Extra request body parameters + +**Example:** + +```json +// User settings (~/.qwen/settings.json) +{ + "model": { + "generationConfig": { + "timeout": 30000, + "samplingParams": { "temperature": 0.5, "max_tokens": 1000 } + } + } +} + +// modelProviders configuration +{ + "modelProviders": { + "openai": [{ + "id": "gpt-4o", + "envKey": "OPENAI_API_KEY", + "generationConfig": { + "timeout": 60000, + "samplingParams": { "temperature": 0.2 } + } + }] + } +} +``` + +When `gpt-4o` is selected from modelProviders: +- `timeout` = 60000 (from provider, overrides settings) +- `samplingParams.temperature` = 0.2 (from provider, completely replaces settings object) +- `samplingParams.max_tokens` = **undefined** (not defined in provider, and provider layer does not inherit from settings — fields are explicitly set to undefined if not provided) + +When using a raw model via `--model gpt-4` (not from modelProviders, creates a Runtime Model): +- `timeout` = 30000 (from settings) +- `samplingParams.temperature` = 0.5 (from settings) +- `samplingParams.max_tokens` = 1000 (from settings) + +The merge strategy for `modelProviders` itself is REPLACE: the entire `modelProviders` from project settings will override the corresponding section in user settings, rather than merging the two. + +##### Provider Models vs Runtime Models + +Qwen Code distinguishes between two types of model configurations: + +**Provider Model**: +- Defined in `modelProviders` configuration +- Has a complete, atomic configuration package +- When selected, its configuration is applied as an impermeable layer +- Appears in `/model` command list with full metadata (name, description, capabilities) +- Recommended for multi-model workflows and team consistency + +**Runtime Model**: +- Created dynamically when using raw model IDs via CLI (`--model`), environment variables, or settings +- Not defined in `modelProviders` +- Configuration is built by "projecting" through resolution layers (CLI → env → settings → defaults) +- Automatically captured as a **RuntimeModelSnapshot** when a complete configuration is detected +- Allows reuse without re-entering credentials + +**RuntimeModelSnapshot lifecycle:** + +When you configure a model without using `modelProviders`, Qwen Code automatically creates a RuntimeModelSnapshot to preserve your configuration: + +```bash +# This creates a RuntimeModelSnapshot with ID: $runtime|openai|my-custom-model +qwen --auth-type openai --model my-custom-model --openaiApiKey $KEY --openaiBaseUrl https://api.example.com/v1 +``` + +The snapshot: +- Captures model ID, API key, base URL, and generation config +- Persists across sessions (stored in memory during runtime) +- Appears in the `/model` command list as a runtime option +- Can be switched to using `/model $runtime|openai|my-custom-model` + +**Key differences:** + +| Aspect | Provider Model | Runtime Model | +|--------|---------------|---------------| +| Configuration source | `modelProviders` in settings | CLI, env, settings layers | +| Configuration atomicity | Complete, impermeable package | Layered, each field resolved independently | +| Reusability | Always available in `/model` list | Captured as snapshot, appears if complete | +| Team sharing | Yes (via committed settings) | No (user-local) | +| Credential storage | Reference via `envKey` only | May capture actual key in snapshot | + +**When to use each:** + +- **Use Provider Models** when: You have standard models shared across a team, need consistent configurations, or want to prevent accidental overrides +- **Use Runtime Models** when: Quickly testing a new model, using temporary credentials, or working with ad-hoc endpoints + +##### Selection Persistence and Recommendations > [!important] > Define `modelProviders` in the user-scope `~/.qwen/settings.json` whenever possible and avoid persisting credential overrides in any scope. Keeping the provider catalog in user settings prevents merge/override conflicts between project and user scopes and ensures `/auth` and `/model` updates always write back to a consistent scope. - `/model` and `/auth` persist `model.name` (where applicable) and `security.auth.selectedType` to the closest writable scope that already defines `modelProviders`; otherwise they fall back to the user scope. This keeps workspace/user files in sync with the active provider catalog. -- Without `modelProviders`, the resolver mixes CLI/env/settings layers, which is fine for single-provider setups but cumbersome when frequently switching. Define provider catalogs whenever multi-model workflows are common so that switches stay atomic, source-attributed, and debuggable. +- Without `modelProviders`, the resolver mixes CLI/env/settings layers, creating Runtime Models. This is fine for single-provider setups but cumbersome when frequently switching. Define provider catalogs whenever multi-model workflows are common so that switches stay atomic, source-attributed, and debuggable. #### context From 672067eba42d075132845a636cddb5f6b2358279 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Tue, 24 Feb 2026 23:36:56 +0800 Subject: [PATCH 76/81] feat: add bailian coding plan models --- packages/cli/src/constants/codingPlan.ts | 90 ++++++++++++------- .../src/ui/hooks/useCodingPlanUpdates.test.ts | 5 +- 2 files changed, 60 insertions(+), 35 deletions(-) diff --git a/packages/cli/src/constants/codingPlan.ts b/packages/cli/src/constants/codingPlan.ts index 2f8a10a6a..72e7fc1b0 100644 --- a/packages/cli/src/constants/codingPlan.ts +++ b/packages/cli/src/constants/codingPlan.ts @@ -54,9 +54,7 @@ export function generateCodingPlanTemplate( return [ { id: 'qwen3.5-plus', - name: 'qwen3.5-plus', - description: - 'qwen3.5-plus model with thinking enabled from Bailian Coding Plan', + name: '[Bailian Coding Plan] qwen3.5-plus', baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', envKey: CODING_PLAN_ENV_KEY, generationConfig: { @@ -67,23 +65,19 @@ export function generateCodingPlanTemplate( }, { id: 'qwen3-coder-plus', - name: 'qwen3-coder-plus', + name: '[Bailian Coding Plan] qwen3-coder-plus', baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', - description: 'qwen3-coder-plus model from Bailian Coding Plan', envKey: CODING_PLAN_ENV_KEY, }, { id: 'qwen3-coder-next', - name: 'qwen3-coder-next', - description: 'qwen3-coder-next model from Bailian Coding Plan', + name: '[Bailian Coding Plan] qwen3-coder-next', baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', envKey: CODING_PLAN_ENV_KEY, }, { id: 'qwen3-max-2026-01-23', - name: 'qwen3-max-2026-01-23', - description: - 'qwen3-max model with thinking enabled from Bailian Coding Plan', + name: '[Bailian Coding Plan] qwen3-max-2026-01-23', baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', envKey: CODING_PLAN_ENV_KEY, generationConfig: { @@ -94,9 +88,29 @@ export function generateCodingPlanTemplate( }, { id: 'glm-4.7', - name: 'glm-4.7', - description: - 'glm-4.7 model with thinking enabled from Bailian Coding Plan', + name: '[Bailian Coding Plan] glm-4.7', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + }, + }, + { + 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, + }, + }, + }, + { + id: 'MiniMax-M2.5', + name: '[Bailian Coding Plan] MiniMax-M2.5', baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', envKey: CODING_PLAN_ENV_KEY, generationConfig: { @@ -107,9 +121,7 @@ export function generateCodingPlanTemplate( }, { id: 'kimi-k2.5', - name: 'kimi-k2.5', - description: - 'kimi-k2.5 model with thinking enabled from Bailian Coding Plan', + name: '[Bailian Coding Plan] kimi-k2.5', baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', envKey: CODING_PLAN_ENV_KEY, generationConfig: { @@ -121,13 +133,11 @@ export function generateCodingPlanTemplate( ]; } - // Global region uses new description with region indicator + // Global region uses Bailian Coding Plan branding for Global/Intl return [ { id: 'qwen3.5-plus', - name: 'qwen3.5-plus', - description: - 'qwen3.5-plus model with thinking enabled from Coding Plan (Global/Intl)', + name: '[Bailian Coding Plan for Global/Intl] qwen3.5-plus', baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', envKey: CODING_PLAN_ENV_KEY, generationConfig: { @@ -138,23 +148,19 @@ export function generateCodingPlanTemplate( }, { id: 'qwen3-coder-plus', - name: 'qwen3-coder-plus', + name: '[Bailian Coding Plan for Global/Intl] qwen3-coder-plus', baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', - description: 'qwen3-coder-plus model from Coding Plan (Global/Intl)', envKey: CODING_PLAN_ENV_KEY, }, { id: 'qwen3-coder-next', - name: 'qwen3-coder-next', - description: 'qwen3-coder-next model from Coding Plan (Global/Intl)', + name: '[Bailian Coding Plan for Global/Intl] qwen3-coder-next', baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', envKey: CODING_PLAN_ENV_KEY, }, { id: 'qwen3-max-2026-01-23', - name: 'qwen3-max-2026-01-23', - description: - 'qwen3-max model with thinking enabled from Coding Plan (Global/Intl)', + name: '[Bailian Coding Plan for Global/Intl] qwen3-max-2026-01-23', baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', envKey: CODING_PLAN_ENV_KEY, generationConfig: { @@ -165,9 +171,29 @@ export function generateCodingPlanTemplate( }, { id: 'glm-4.7', - name: 'glm-4.7', - description: - 'glm-4.7 model with thinking enabled from Coding Plan (Global/Intl)', + name: '[Bailian Coding Plan for Global/Intl] glm-4.7', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + }, + }, + { + id: 'glm-5', + name: '[Bailian Coding Plan for Global/Intl] glm-5', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { + enable_thinking: true, + }, + }, + }, + { + id: 'MiniMax-M2.5', + name: '[Bailian Coding Plan for Global/Intl] MiniMax-M2.5', baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', envKey: CODING_PLAN_ENV_KEY, generationConfig: { @@ -178,9 +204,7 @@ export function generateCodingPlanTemplate( }, { id: 'kimi-k2.5', - name: 'kimi-k2.5', - description: - 'kimi-k2.5 model with thinking enabled from Coding Plan (Global/Intl)', + name: '[Bailian Coding Plan for Global/Intl] kimi-k2.5', baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', envKey: CODING_PLAN_ENV_KEY, generationConfig: { diff --git a/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts index 1707b17e8..3ddaf42e6 100644 --- a/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts +++ b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts @@ -391,8 +391,9 @@ describe('useCodingPlanUpdates', () => { >; // Should have new China configs + custom config only (global config removed since regions are mutually exclusive) - // The China template has 6 models, so we expect 6 (from template) + 1 (custom) = 7 - expect(updatedConfigs.length).toBe(7); + // The China template has 8 models, so we expect 8 (from template) + 1 (custom) = 9 + // Note: description field has been removed, only name field contains the branding + expect(updatedConfigs.length).toBe(9); // Should NOT contain the Global config (mutually exclusive) expect( From 9cb624f79f0f648a6fc6ffe9da8d691162ecc378 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Wed, 25 Feb 2026 10:07:41 +0800 Subject: [PATCH 77/81] docs: extract modelProviders section to standalone model-providers.md document - Create new model-providers.md with complete model provider configuration guide - Add Bailian Coding Plan documentation with setup and auto-update details - Remove modelProviders content from settings.md to avoid duplication - Document reserved envKey BAILIAN_CODING_PLAN_API_KEY and security recommendations Co-authored-by: Qwen-Coder --- docs/users/configuration/model-providers.md | 521 ++++++++++++++++++++ docs/users/configuration/settings.md | 386 +-------------- 2 files changed, 522 insertions(+), 385 deletions(-) create mode 100644 docs/users/configuration/model-providers.md diff --git a/docs/users/configuration/model-providers.md b/docs/users/configuration/model-providers.md new file mode 100644 index 000000000..2e6265917 --- /dev/null +++ b/docs/users/configuration/model-providers.md @@ -0,0 +1,521 @@ +# Model Providers + +Qwen Code allows you to configure multiple model providers through the `modelProviders` setting in your `settings.json`. This enables you to switch between different AI models and providers using the `/model` command. + +## Overview + +Use `modelProviders` to declare curated model lists per auth type that the `/model` picker can switch between. Keys must be valid auth types (`openai`, `anthropic`, `gemini`, `vertex-ai`, etc.). Each entry requires an `id` and **must include `envKey`**, with optional `name`, `description`, `baseUrl`, and `generationConfig`. Credentials are never persisted in settings; the runtime reads them from `process.env[envKey]`. Qwen OAuth models remain hard-coded and cannot be overridden. + +> [!note] +> Only the `/model` command exposes non-default auth types. Anthropic, Gemini, Vertex AI, etc., must be defined via `modelProviders`. The `/auth` command intentionally lists only the built-in Qwen OAuth and OpenAI flows. + +> [!warning] +> **Duplicate model IDs within the same authType:** Defining multiple models with the same `id` under a single `authType` (e.g., two entries with `"id": "gpt-4o"` in `openai`) is currently not supported. If duplicates exist, **the first occurrence wins** and subsequent duplicates are skipped with a warning. Note that the `id` field is used both as the configuration identifier and as the actual model name sent to the API, so using unique IDs (e.g., `gpt-4o-creative`, `gpt-4o-balanced`) is not a viable workaround. This is a known limitation that we plan to address in a future release. + +## Configuration Examples by Auth Type + +Below are comprehensive configuration examples for different authentication types, showing the available parameters and their combinations. + +### Supported Auth Types + +The `modelProviders` object keys must be valid `authType` values. Currently supported auth types are: + +| Auth Type | Description | +| ------------ | --------------------------------------------------------------------------------------- | +| `openai` | OpenAI-compatible APIs (OpenAI, Azure OpenAI, local inference servers like vLLM/Ollama) | +| `anthropic` | Anthropic Claude API | +| `gemini` | Google Gemini API | +| `vertex-ai` | Google Vertex AI | +| `qwen-oauth` | Qwen OAuth (hard-coded, cannot be overridden in `modelProviders`) | + +> [!warning] +> If an invalid auth type key is used (e.g., a typo like `"openai-custom"`), the configuration will be **silently skipped** and the models will not appear in the `/model` picker. Always use one of the supported auth type values listed above. + +### SDKs Used for API Requests + +Qwen Code uses the following official SDKs to send requests to each provider: + +| Auth Type | SDK Package | +| ---------------------- | ----------------------------------------------------------------------------------------------- | +| `openai` | [`openai`](https://www.npmjs.com/package/openai) - Official OpenAI Node.js SDK | +| `anthropic` | [`@anthropic-ai/sdk`](https://www.npmjs.com/package/@anthropic-ai/sdk) - Official Anthropic SDK | +| `gemini` / `vertex-ai` | [`@google/genai`](https://www.npmjs.com/package/@google/genai) - Official Google GenAI SDK | +| `qwen-oauth` | [`openai`](https://www.npmjs.com/package/openai) with custom provider (DashScope-compatible) | + +This means the `baseUrl` you configure should be compatible with the corresponding SDK's expected API format. For example, when using `openai` auth type, the endpoint must accept OpenAI API format requests. + +### OpenAI-compatible providers (`openai`) + +This auth type supports not only OpenAI's official API but also any OpenAI-compatible endpoint, including aggregated model providers like OpenRouter. + +```json +{ + "modelProviders": { + "openai": [ + { + "id": "gpt-4o", + "name": "GPT-4o", + "envKey": "OPENAI_API_KEY", + "baseUrl": "https://api.openai.com/v1", + "generationConfig": { + "timeout": 60000, + "maxRetries": 3, + "enableCacheControl": true, + "contextWindowSize": 128000, + "customHeaders": { + "X-Client-Request-ID": "req-123" + }, + "extra_body": { + "enable_thinking": true, + "service_tier": "priority" + }, + "samplingParams": { + "temperature": 0.2, + "top_p": 0.8, + "max_tokens": 4096, + "presence_penalty": 0.1, + "frequency_penalty": 0.1 + } + } + }, + { + "id": "gpt-4o-mini", + "name": "GPT-4o Mini", + "envKey": "OPENAI_API_KEY", + "baseUrl": "https://api.openai.com/v1", + "generationConfig": { + "timeout": 30000, + "samplingParams": { + "temperature": 0.5, + "max_tokens": 2048 + } + } + }, + { + "id": "openai/gpt-4o", + "name": "GPT-4o (via OpenRouter)", + "envKey": "OPENROUTER_API_KEY", + "baseUrl": "https://openrouter.ai/api/v1", + "generationConfig": { + "timeout": 120000, + "maxRetries": 3, + "samplingParams": { + "temperature": 0.7 + } + } + } + ] + } +} +``` + +### Anthropic (`anthropic`) + +```json +{ + "modelProviders": { + "anthropic": [ + { + "id": "claude-3-5-sonnet", + "name": "Claude 3.5 Sonnet", + "envKey": "ANTHROPIC_API_KEY", + "baseUrl": "https://api.anthropic.com/v1", + "generationConfig": { + "timeout": 120000, + "maxRetries": 3, + "contextWindowSize": 200000, + "samplingParams": { + "temperature": 0.7, + "max_tokens": 8192, + "top_p": 0.9 + } + } + }, + { + "id": "claude-3-opus", + "name": "Claude 3 Opus", + "envKey": "ANTHROPIC_API_KEY", + "baseUrl": "https://api.anthropic.com/v1", + "generationConfig": { + "timeout": 180000, + "samplingParams": { + "temperature": 0.3, + "max_tokens": 4096 + } + } + } + ] + } +} +``` + +### Google Gemini (`gemini`) + +```json +{ + "modelProviders": { + "gemini": [ + { + "id": "gemini-2.0-flash", + "name": "Gemini 2.0 Flash", + "envKey": "GEMINI_API_KEY", + "baseUrl": "https://generativelanguage.googleapis.com", + "capabilities": { + "vision": true + }, + "generationConfig": { + "timeout": 60000, + "maxRetries": 2, + "contextWindowSize": 1000000, + "schemaCompliance": "auto", + "samplingParams": { + "temperature": 0.4, + "top_p": 0.95, + "max_tokens": 8192, + "top_k": 40 + } + } + } + ] + } +} +``` + +### Google Vertex AI (`vertex-ai`) + +```json +{ + "modelProviders": { + "vertex-ai": [ + { + "id": "gemini-1.5-pro-vertex", + "name": "Gemini 1.5 Pro (Vertex AI)", + "envKey": "GOOGLE_API_KEY", + "baseUrl": "https://generativelanguage.googleapis.com", + "generationConfig": { + "timeout": 90000, + "contextWindowSize": 2000000, + "samplingParams": { + "temperature": 0.2, + "max_tokens": 8192 + } + } + } + ] + } +} +``` + +### Local Self-Hosted Models (via OpenAI-compatible API) + +Most local inference servers (vLLM, Ollama, LM Studio, etc.) provide an OpenAI-compatible API endpoint. Configure them using the `openai` auth type with a local `baseUrl`: + +```json +{ + "modelProviders": { + "openai": [ + { + "id": "qwen2.5-7b", + "name": "Qwen2.5 7B (Ollama)", + "envKey": "OLLAMA_API_KEY", + "baseUrl": "http://localhost:11434/v1", + "generationConfig": { + "timeout": 300000, + "maxRetries": 1, + "contextWindowSize": 32768, + "samplingParams": { + "temperature": 0.7, + "top_p": 0.9, + "max_tokens": 4096 + } + } + }, + { + "id": "llama-3.1-8b", + "name": "Llama 3.1 8B (vLLM)", + "envKey": "VLLM_API_KEY", + "baseUrl": "http://localhost:8000/v1", + "generationConfig": { + "timeout": 120000, + "maxRetries": 2, + "contextWindowSize": 128000, + "samplingParams": { + "temperature": 0.6, + "max_tokens": 8192 + } + } + }, + { + "id": "local-model", + "name": "Local Model (LM Studio)", + "envKey": "LMSTUDIO_API_KEY", + "baseUrl": "http://localhost:1234/v1", + "generationConfig": { + "timeout": 60000, + "samplingParams": { + "temperature": 0.5 + } + } + } + ] + } +} +``` + +For local servers that don't require authentication, you can use any placeholder value for the API key: + +```bash +# For Ollama (no auth required) +export OLLAMA_API_KEY="ollama" + +# For vLLM (if no auth is configured) +export VLLM_API_KEY="not-needed" +``` + +> [!note] +> The `extra_body` parameter is **only supported for OpenAI-compatible providers** (`openai`, `qwen-oauth`). It is ignored for Anthropic, Gemini, and Vertex AI providers. + +## Bailian Coding Plan + +Bailian Coding Plan provides a pre-configured set of Qwen models optimized for coding tasks. This feature is available for users with Bailian API access and offers a simplified setup experience with automatic model configuration updates. + +### Overview + +When you authenticate with a Bailian Coding Plan API key using the `/auth` command, Qwen Code automatically configures the following models: + +| Model ID | Name | Description | +| ---------------------- | -------------------- | -------------------------------------- | +| `qwen3.5-plus` | qwen3.5-plus | Advanced model with thinking enabled | +| `qwen3-coder-plus` | qwen3-coder-plus | Optimized for coding tasks | +| `qwen3-max-2026-01-23` | qwen3-max-2026-01-23 | Latest max model with thinking enabled | + +### Setup + +1. Obtain a Bailian Coding Plan API key: + - **China**: + - **International**: +2. Run the `/auth` command in Qwen Code +3. Select the API-KEY authentication method +4. Select your region (China or Global/International) +5. Enter your API key when prompted + +The models will be automatically configured and added to your `/model` picker. + +### Regions + +Bailian Coding Plan supports two regions: + +| Region | Endpoint | Description | +| -------------------- | ----------------------------------------------- | ----------------------- | +| China | `https://coding.dashscope.aliyuncs.com/v1` | Mainland China endpoint | +| Global/International | `https://coding-intl.dashscope.aliyuncs.com/v1` | International endpoint | + +The region is selected during authentication and stored in `settings.json` under `codingPlan.region`. To switch regions, re-run the `/auth` command and select a different region. + +### API Key Storage + +When you configure Coding Plan through the `/auth` command, the API key is stored using the reserved environment variable name `BAILIAN_CODING_PLAN_API_KEY`. By default, it is stored in the `settings.env` field of your `settings.json` file. + +> [!warning] +> **Security Recommendation**: For better security, it is recommended to move the API key from `settings.json` to a separate `.env` file and load it as an environment variable. For example: +> +> ```bash +> # ~/.qwen/.env +> BAILIAN_CODING_PLAN_API_KEY=your-api-key-here +> ``` +> +> Then ensure this file is added to your `.gitignore` if you're using project-level settings. + +### Automatic Updates + +Coding Plan model configurations are versioned. When Qwen Code detects a newer version of the model template, you will be prompted to update. Accepting the update will: + +- Replace the existing Coding Plan model configurations with the latest versions +- Preserve any custom model configurations you've added manually +- Automatically switch to the first model in the updated configuration + +The update process ensures you always have access to the latest model configurations and features without manual intervention. + +### Manual Configuration (Advanced) + +If you prefer to manually configure Coding Plan models, you can add them to your `settings.json` like any OpenAI-compatible provider: + +```json +{ + "modelProviders": { + "openai": [ + { + "id": "qwen3-coder-plus", + "name": "qwen3-coder-plus", + "description": "Qwen3-Coder via Bailian Coding Plan", + "envKey": "YOUR_CUSTOM_ENV_KEY", + "baseUrl": "https://coding.dashscope.aliyuncs.com/v1" + } + ] + } +} +``` + +> [!note] +> When using manual configuration: + +> - You can use any environment variable name for `envKey` +> - You do not need to configure `codingPlan.*` +> - **Automatic updates will not apply** to manually configured Coding Plan models + +> [!warning] +> If you also use automatic Coding Plan configuration, automatic updates may overwrite your manual configurations if they use the same `envKey` and `baseUrl` as the automatic configuration. To avoid this, ensure your manual configuration uses a different `envKey` if possible. + +## Resolution Layers and Atomicity + +The effective auth/model/credential values are chosen per field using the following precedence (first present wins). You can combine `--auth-type` with `--model` to point directly at a provider entry; these CLI flags run before other layers. + +| Layer (highest → lowest) | authType | model | apiKey | baseUrl | apiKeyEnvKey | proxy | +| -------------------------- | ----------------------------------- | ----------------------------------------------- | --------------------------------------------------- | ---------------------------------------------------- | ---------------------- | --------------------------------- | +| Programmatic overrides | `/auth` | `/auth` input | `/auth` input | `/auth` input | — | — | +| Model provider selection | — | `modelProvider.id` | `env[modelProvider.envKey]` | `modelProvider.baseUrl` | `modelProvider.envKey` | — | +| CLI arguments | `--auth-type` | `--model` | `--openaiApiKey` (or provider-specific equivalents) | `--openaiBaseUrl` (or provider-specific equivalents) | — | — | +| Environment variables | — | Provider-specific mapping (e.g. `OPENAI_MODEL`) | Provider-specific mapping (e.g. `OPENAI_API_KEY`) | Provider-specific mapping (e.g. `OPENAI_BASE_URL`) | — | — | +| Settings (`settings.json`) | `security.auth.selectedType` | `model.name` | `security.auth.apiKey` | `security.auth.baseUrl` | — | — | +| Default / computed | Falls back to `AuthType.QWEN_OAUTH` | Built-in default (OpenAI ⇒ `qwen3-coder-plus`) | — | — | — | `Config.getProxy()` if configured | + +\*When present, CLI auth flags override settings. Otherwise, `security.auth.selectedType` or the implicit default determine the auth type. Qwen OAuth and OpenAI are the only auth types surfaced without extra configuration. + +> [!warning] +> **Deprecation of `security.auth.apiKey` and `security.auth.baseUrl`:** Directly configuring API credentials via `security.auth.apiKey` and `security.auth.baseUrl` in `settings.json` is deprecated. These settings were used in historical versions for credentials entered through the UI, but the credential input flow was removed in version 0.10.1. These fields will be fully removed in a future release. **It is strongly recommended to migrate to `modelProviders`** for all model and credential configurations. Use `envKey` in `modelProviders` to reference environment variables for secure credential management instead of hardcoding credentials in settings files. + +## Generation Config Layering: The Impermeable Provider Layer + +The configuration resolution follows a strict layering model with one crucial rule: **the modelProvider layer is impermeable**. + +### How it works + +1. **When a modelProvider model IS selected** (e.g., via `/model` command choosing a provider-configured model): + - The entire `generationConfig` from the provider is applied **atomically** + - **The provider layer is completely impermeable** — lower layers (CLI, env, settings) do not participate in generationConfig resolution at all + - All fields defined in `modelProviders[].generationConfig` use the provider's values + - All fields **not defined** by the provider are set to `undefined` (not inherited from settings) + - This ensures provider configurations act as a complete, self-contained "sealed package" + +2. **When NO modelProvider model is selected** (e.g., using `--model` with a raw model ID, or using CLI/env/settings directly): + - The resolution falls through to lower layers + - Fields are populated from CLI → env → settings → defaults + - This creates a **Runtime Model** (see next section) + +### Per-field precedence for `generationConfig` + +| Priority | Source | Behavior | +| -------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| 1 | Programmatic overrides | Runtime `/model`, `/auth` changes | +| 2 | `modelProviders[authType][].generationConfig` | **Impermeable layer** - completely replaces all generationConfig fields; lower layers do not participate | +| 3 | `settings.model.generationConfig` | Only used for **Runtime Models** (when no provider model is selected) | +| 4 | Content-generator defaults | Provider-specific defaults (e.g., OpenAI vs Gemini) - only for Runtime Models | + +### Atomic field treatment + +The following fields are treated as atomic objects - provider values completely replace the entire object, no merging occurs: + +- `samplingParams` - Temperature, top_p, max_tokens, etc. +- `customHeaders` - Custom HTTP headers +- `extra_body` - Extra request body parameters + +### Example + +```json +// User settings (~/.qwen/settings.json) +{ + "model": { + "generationConfig": { + "timeout": 30000, + "samplingParams": { "temperature": 0.5, "max_tokens": 1000 } + } + } +} + +// modelProviders configuration +{ + "modelProviders": { + "openai": [{ + "id": "gpt-4o", + "envKey": "OPENAI_API_KEY", + "generationConfig": { + "timeout": 60000, + "samplingParams": { "temperature": 0.2 } + } + }] + } +} +``` + +When `gpt-4o` is selected from modelProviders: + +- `timeout` = 60000 (from provider, overrides settings) +- `samplingParams.temperature` = 0.2 (from provider, completely replaces settings object) +- `samplingParams.max_tokens` = **undefined** (not defined in provider, and provider layer does not inherit from settings — fields are explicitly set to undefined if not provided) + +When using a raw model via `--model gpt-4` (not from modelProviders, creates a Runtime Model): + +- `timeout` = 30000 (from settings) +- `samplingParams.temperature` = 0.5 (from settings) +- `samplingParams.max_tokens` = 1000 (from settings) + +The merge strategy for `modelProviders` itself is REPLACE: the entire `modelProviders` from project settings will override the corresponding section in user settings, rather than merging the two. + +## Provider Models vs Runtime Models + +Qwen Code distinguishes between two types of model configurations: + +### Provider Model + +- Defined in `modelProviders` configuration +- Has a complete, atomic configuration package +- When selected, its configuration is applied as an impermeable layer +- Appears in `/model` command list with full metadata (name, description, capabilities) +- Recommended for multi-model workflows and team consistency + +### Runtime Model + +- Created dynamically when using raw model IDs via CLI (`--model`), environment variables, or settings +- Not defined in `modelProviders` +- Configuration is built by "projecting" through resolution layers (CLI → env → settings → defaults) +- Automatically captured as a **RuntimeModelSnapshot** when a complete configuration is detected +- Allows reuse without re-entering credentials + +### RuntimeModelSnapshot lifecycle + +When you configure a model without using `modelProviders`, Qwen Code automatically creates a RuntimeModelSnapshot to preserve your configuration: + +```bash +# This creates a RuntimeModelSnapshot with ID: $runtime|openai|my-custom-model +qwen --auth-type openai --model my-custom-model --openaiApiKey $KEY --openaiBaseUrl https://api.example.com/v1 +``` + +The snapshot: + +- Captures model ID, API key, base URL, and generation config +- Persists across sessions (stored in memory during runtime) +- Appears in the `/model` command list as a runtime option +- Can be switched to using `/model $runtime|openai|my-custom-model` + +### Key differences + +| Aspect | Provider Model | Runtime Model | +| ----------------------- | --------------------------------- | ------------------------------------------ | +| Configuration source | `modelProviders` in settings | CLI, env, settings layers | +| Configuration atomicity | Complete, impermeable package | Layered, each field resolved independently | +| Reusability | Always available in `/model` list | Captured as snapshot, appears if complete | +| Team sharing | Yes (via committed settings) | No (user-local) | +| Credential storage | Reference via `envKey` only | May capture actual key in snapshot | + +### When to use each + +- **Use Provider Models** when: You have standard models shared across a team, need consistent configurations, or want to prevent accidental overrides +- **Use Runtime Models** when: Quickly testing a new model, using temporary credentials, or working with ad-hoc endpoints + +## Selection Persistence and Recommendations + +> [!important] +> Define `modelProviders` in the user-scope `~/.qwen/settings.json` whenever possible and avoid persisting credential overrides in any scope. Keeping the provider catalog in user settings prevents merge/override conflicts between project and user scopes and ensures `/auth` and `/model` updates always write back to a consistent scope. + +- `/model` and `/auth` persist `model.name` (where applicable) and `security.auth.selectedType` to the closest writable scope that already defines `modelProviders`; otherwise they fall back to the user scope. This keeps workspace/user files in sync with the active provider catalog. +- Without `modelProviders`, the resolver mixes CLI/env/settings layers, creating Runtime Models. This is fine for single-provider setups but cumbersome when frequently switching. Define provider catalogs whenever multi-model workflows are common so that switches stay atomic, source-attributed, and debuggable. diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index aff42545d..82db2b319 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -148,8 +148,7 @@ Settings are organized into categories. All settings should be placed within the "contextWindowSize": 128000, "enableCacheControl": true, "customHeaders": { - "X-Request-ID": "req-123", - "X-User-ID": "user-456" + "X-Client-Request-ID": "req-123" }, "extra_body": { "enable_thinking": true @@ -180,389 +179,6 @@ The `extra_body` field allows you to add custom parameters to the request body s - `"./custom-logs"` - Logs to `./custom-logs` relative to current directory - `"/tmp/openai-logs"` - Logs to absolute path `/tmp/openai-logs` -#### modelProviders - -Use `modelProviders` to declare curated model lists per auth type that the `/model` picker can switch between. Keys must be valid auth types (`openai`, `anthropic`, `gemini`, `vertex-ai`, etc.). Each entry requires an `id` and **must include `envKey`**, with optional `name`, `description`, `baseUrl`, and `generationConfig`. Credentials are never persisted in settings; the runtime reads them from `process.env[envKey]`. Qwen OAuth models remain hard-coded and cannot be overridden. - -> [!note] -> Only the `/model` command exposes non-default auth types. Anthropic, Gemini, Vertex AI, etc., must be defined via `modelProviders`. The `/auth` command intentionally lists only the built-in Qwen OAuth and OpenAI flows. - -> [!warning] -> **Duplicate model IDs within the same authType:** Defining multiple models with the same `id` under a single `authType` (e.g., two entries with `"id": "gpt-4o"` in `openai`) is currently not supported. If duplicates exist, **the first occurrence wins** and subsequent duplicates are skipped with a warning. Note that the `id` field is used both as the configuration identifier and as the actual model name sent to the API, so using unique IDs (e.g., `gpt-4o-creative`, `gpt-4o-balanced`) is not a viable workaround. This is a known limitation that we plan to address in a future release. - -##### Configuration Examples by Auth Type - -Below are comprehensive configuration examples for different authentication types, showing the available parameters and their combinations: - -**OpenAI-compatible providers** (`openai`): - -```json -{ - "modelProviders": { - "openai": [ - { - "id": "gpt-4o", - "name": "GPT-4o", - "envKey": "OPENAI_API_KEY", - "baseUrl": "https://api.openai.com/v1", - "generationConfig": { - "timeout": 60000, - "maxRetries": 3, - "enableCacheControl": true, - "contextWindowSize": 128000, - "customHeaders": { - "X-Request-ID": "req-123", - "X-User-ID": "user-456" - }, - "extra_body": { - "enable_thinking": true, - "service_tier": "priority" - }, - "samplingParams": { - "temperature": 0.2, - "top_p": 0.8, - "max_tokens": 4096, - "presence_penalty": 0.1, - "frequency_penalty": 0.1 - } - } - }, - { - "id": "gpt-4o-mini", - "name": "GPT-4o Mini", - "envKey": "OPENAI_API_KEY", - "baseUrl": "https://api.openai.com/v1", - "generationConfig": { - "timeout": 30000, - "samplingParams": { - "temperature": 0.5, - "max_tokens": 2048 - } - } - } - ] - } -} -``` - -**Anthropic** (`anthropic`): - -```json -{ - "modelProviders": { - "anthropic": [ - { - "id": "claude-3-5-sonnet", - "name": "Claude 3.5 Sonnet", - "envKey": "ANTHROPIC_API_KEY", - "baseUrl": "https://api.anthropic.com/v1", - "generationConfig": { - "timeout": 120000, - "maxRetries": 3, - "contextWindowSize": 200000, - "customHeaders": { - "anthropic-version": "2023-06-01" - }, - "samplingParams": { - "temperature": 0.7, - "max_tokens": 8192, - "top_p": 0.9 - } - } - }, - { - "id": "claude-3-opus", - "name": "Claude 3 Opus", - "envKey": "ANTHROPIC_API_KEY", - "baseUrl": "https://api.anthropic.com/v1", - "generationConfig": { - "timeout": 180000, - "samplingParams": { - "temperature": 0.3, - "max_tokens": 4096 - } - } - } - ] - } -} -``` - -**Google Gemini** (`gemini`): - -```json -{ - "modelProviders": { - "gemini": [ - { - "id": "gemini-2.0-flash", - "name": "Gemini 2.0 Flash", - "envKey": "GEMINI_API_KEY", - "baseUrl": "https://generativelanguage.googleapis.com", - "capabilities": { - "vision": true - }, - "generationConfig": { - "timeout": 60000, - "maxRetries": 2, - "contextWindowSize": 1000000, - "schemaCompliance": "auto", - "samplingParams": { - "temperature": 0.4, - "top_p": 0.95, - "max_tokens": 8192, - "top_k": 40 - } - } - } - ] - } -} -``` - -**Google Vertex AI** (`vertex-ai`): - -```json -{ - "modelProviders": { - "vertex-ai": [ - { - "id": "gemini-1.5-pro-vertex", - "name": "Gemini 1.5 Pro (Vertex AI)", - "envKey": "GOOGLE_API_KEY", - "baseUrl": "https://generativelanguage.googleapis.com", - "generationConfig": { - "timeout": 90000, - "contextWindowSize": 2000000, - "samplingParams": { - "temperature": 0.2, - "max_tokens": 8192 - } - } - } - ] - } -} -``` - -**Local Self-Hosted Models (via OpenAI-compatible API)**: - -Most local inference servers (vLLM, Ollama, LM Studio, etc.) provide an OpenAI-compatible API endpoint. Configure them using the `openai` auth type with a local `baseUrl`: - -```json -{ - "modelProviders": { - "openai": [ - { - "id": "qwen2.5-7b", - "name": "Qwen2.5 7B (Ollama)", - "envKey": "OLLAMA_API_KEY", - "baseUrl": "http://localhost:11434/v1", - "generationConfig": { - "timeout": 300000, - "maxRetries": 1, - "contextWindowSize": 32768, - "samplingParams": { - "temperature": 0.7, - "top_p": 0.9, - "max_tokens": 4096 - } - } - }, - { - "id": "llama-3.1-8b", - "name": "Llama 3.1 8B (vLLM)", - "envKey": "VLLM_API_KEY", - "baseUrl": "http://localhost:8000/v1", - "generationConfig": { - "timeout": 120000, - "maxRetries": 2, - "contextWindowSize": 128000, - "samplingParams": { - "temperature": 0.6, - "max_tokens": 8192 - } - } - }, - { - "id": "local-model", - "name": "Local Model (LM Studio)", - "envKey": "LMSTUDIO_API_KEY", - "baseUrl": "http://localhost:1234/v1", - "generationConfig": { - "timeout": 60000, - "samplingParams": { - "temperature": 0.5 - } - } - } - ] - } -} -``` - -For local servers that don't require authentication, you can use any placeholder value for the API key: - -```bash -# For Ollama (no auth required) -export OLLAMA_API_KEY="ollama" - -# For vLLM (if no auth is configured) -export VLLM_API_KEY="not-needed" -``` - -> [!note] -> The `extra_body` parameter is **only supported for OpenAI-compatible providers** (`openai`, `qwen-oauth`). It is ignored for Anthropic, Gemini, and Vertex AI providers. - -##### Resolution Layers and Atomicity - -The effective auth/model/credential values are chosen per field using the following precedence (first present wins). You can combine `--auth-type` with `--model` to point directly at a provider entry; these CLI flags run before other layers. - -| Layer (highest → lowest) | authType | model | apiKey | baseUrl | apiKeyEnvKey | proxy | -| -------------------------- | ----------------------------------- | ----------------------------------------------- | --------------------------------------------------- | ---------------------------------------------------- | ---------------------- | --------------------------------- | -| Programmatic overrides | `/auth ` | `/auth` input | `/auth` input | `/auth` input | — | — | -| Model provider selection | — | `modelProvider.id` | `env[modelProvider.envKey]` | `modelProvider.baseUrl` | `modelProvider.envKey` | — | -| CLI arguments | `--auth-type` | `--model` | `--openaiApiKey` (or provider-specific equivalents) | `--openaiBaseUrl` (or provider-specific equivalents) | — | — | -| Environment variables | — | Provider-specific mapping (e.g. `OPENAI_MODEL`) | Provider-specific mapping (e.g. `OPENAI_API_KEY`) | Provider-specific mapping (e.g. `OPENAI_BASE_URL`) | — | — | -| Settings (`settings.json`) | `security.auth.selectedType` | `model.name` | `security.auth.apiKey` | `security.auth.baseUrl` | — | — | -| Default / computed | Falls back to `AuthType.QWEN_OAUTH` | Built-in default (OpenAI ⇒ `qwen3-coder-plus`) | — | — | — | `Config.getProxy()` if configured | - -\*When present, CLI auth flags override settings. Otherwise, `security.auth.selectedType` or the implicit default determine the auth type. Qwen OAuth and OpenAI are the only auth types surfaced without extra configuration. - -> [!warning] -> **Deprecation of `security.auth.apiKey` and `security.auth.baseUrl`:** Directly configuring API credentials via `security.auth.apiKey` and `security.auth.baseUrl` in `settings.json` is deprecated. These settings were used in historical versions for credentials entered through the UI, but the credential input flow was removed in version 0.10.1. These fields will be fully removed in a future release. **It is strongly recommended to migrate to `modelProviders`** for all model and credential configurations. Use `envKey` in `modelProviders` to reference environment variables for secure credential management instead of hardcoding credentials in settings files. - -##### Generation Config Layering: The Impermeable Provider Layer - -The configuration resolution follows a strict layering model with one crucial rule: **the modelProvider layer is impermeable**. - -**How it works:** - -1. **When a modelProvider model IS selected** (e.g., via `/model` command choosing a provider-configured model): - - The entire `generationConfig` from the provider is applied **atomically** - - **The provider layer is completely impermeable** — lower layers (CLI, env, settings) do not participate in generationConfig resolution at all - - All fields defined in `modelProviders[].generationConfig` use the provider's values - - All fields **not defined** by the provider are set to `undefined` (not inherited from settings) - - This ensures provider configurations act as a complete, self-contained "sealed package" - -2. **When NO modelProvider model is selected** (e.g., using `--model` with a raw model ID, or using CLI/env/settings directly): - - The resolution falls through to lower layers - - Fields are populated from CLI → env → settings → defaults - - This creates a **Runtime Model** (see next section) - -**Per-field precedence for `generationConfig`:** - -| Priority | Source | Behavior | -|----------|--------|----------| -| 1 | Programmatic overrides | Runtime `/model`, `/auth` changes | -| 2 | `modelProviders[authType][].generationConfig` | **Impermeable layer** - completely replaces all generationConfig fields; lower layers do not participate | -| 3 | `settings.model.generationConfig` | Only used for **Runtime Models** (when no provider model is selected) | -| 4 | Content-generator defaults | Provider-specific defaults (e.g., OpenAI vs Gemini) - only for Runtime Models | - -**Atomic field treatment:** - -The following fields are treated as atomic objects - provider values completely replace the entire object, no merging occurs: - -- `samplingParams` - Temperature, top_p, max_tokens, etc. -- `customHeaders` - Custom HTTP headers -- `extra_body` - Extra request body parameters - -**Example:** - -```json -// User settings (~/.qwen/settings.json) -{ - "model": { - "generationConfig": { - "timeout": 30000, - "samplingParams": { "temperature": 0.5, "max_tokens": 1000 } - } - } -} - -// modelProviders configuration -{ - "modelProviders": { - "openai": [{ - "id": "gpt-4o", - "envKey": "OPENAI_API_KEY", - "generationConfig": { - "timeout": 60000, - "samplingParams": { "temperature": 0.2 } - } - }] - } -} -``` - -When `gpt-4o` is selected from modelProviders: -- `timeout` = 60000 (from provider, overrides settings) -- `samplingParams.temperature` = 0.2 (from provider, completely replaces settings object) -- `samplingParams.max_tokens` = **undefined** (not defined in provider, and provider layer does not inherit from settings — fields are explicitly set to undefined if not provided) - -When using a raw model via `--model gpt-4` (not from modelProviders, creates a Runtime Model): -- `timeout` = 30000 (from settings) -- `samplingParams.temperature` = 0.5 (from settings) -- `samplingParams.max_tokens` = 1000 (from settings) - -The merge strategy for `modelProviders` itself is REPLACE: the entire `modelProviders` from project settings will override the corresponding section in user settings, rather than merging the two. - -##### Provider Models vs Runtime Models - -Qwen Code distinguishes between two types of model configurations: - -**Provider Model**: -- Defined in `modelProviders` configuration -- Has a complete, atomic configuration package -- When selected, its configuration is applied as an impermeable layer -- Appears in `/model` command list with full metadata (name, description, capabilities) -- Recommended for multi-model workflows and team consistency - -**Runtime Model**: -- Created dynamically when using raw model IDs via CLI (`--model`), environment variables, or settings -- Not defined in `modelProviders` -- Configuration is built by "projecting" through resolution layers (CLI → env → settings → defaults) -- Automatically captured as a **RuntimeModelSnapshot** when a complete configuration is detected -- Allows reuse without re-entering credentials - -**RuntimeModelSnapshot lifecycle:** - -When you configure a model without using `modelProviders`, Qwen Code automatically creates a RuntimeModelSnapshot to preserve your configuration: - -```bash -# This creates a RuntimeModelSnapshot with ID: $runtime|openai|my-custom-model -qwen --auth-type openai --model my-custom-model --openaiApiKey $KEY --openaiBaseUrl https://api.example.com/v1 -``` - -The snapshot: -- Captures model ID, API key, base URL, and generation config -- Persists across sessions (stored in memory during runtime) -- Appears in the `/model` command list as a runtime option -- Can be switched to using `/model $runtime|openai|my-custom-model` - -**Key differences:** - -| Aspect | Provider Model | Runtime Model | -|--------|---------------|---------------| -| Configuration source | `modelProviders` in settings | CLI, env, settings layers | -| Configuration atomicity | Complete, impermeable package | Layered, each field resolved independently | -| Reusability | Always available in `/model` list | Captured as snapshot, appears if complete | -| Team sharing | Yes (via committed settings) | No (user-local) | -| Credential storage | Reference via `envKey` only | May capture actual key in snapshot | - -**When to use each:** - -- **Use Provider Models** when: You have standard models shared across a team, need consistent configurations, or want to prevent accidental overrides -- **Use Runtime Models** when: Quickly testing a new model, using temporary credentials, or working with ad-hoc endpoints - -##### Selection Persistence and Recommendations - -> [!important] -> Define `modelProviders` in the user-scope `~/.qwen/settings.json` whenever possible and avoid persisting credential overrides in any scope. Keeping the provider catalog in user settings prevents merge/override conflicts between project and user scopes and ensures `/auth` and `/model` updates always write back to a consistent scope. - -- `/model` and `/auth` persist `model.name` (where applicable) and `security.auth.selectedType` to the closest writable scope that already defines `modelProviders`; otherwise they fall back to the user scope. This keeps workspace/user files in sync with the active provider catalog. -- Without `modelProviders`, the resolver mixes CLI/env/settings layers, creating Runtime Models. This is fine for single-provider setups but cumbersome when frequently switching. Define provider catalogs whenever multi-model workflows are common so that switches stay atomic, source-attributed, and debuggable. - #### context | Setting | Type | Description | Default | From c4f5c82468b8eef6485acebefd98748acc1840ec Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Wed, 25 Feb 2026 10:11:33 +0800 Subject: [PATCH 78/81] docs: update link to model-providers.md in auth.md Fix broken link from settings.md#modelproviders to new model-providers.md Co-authored-by: Qwen-Coder --- docs/users/configuration/auth.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/users/configuration/auth.md b/docs/users/configuration/auth.md index 2b56c1fb6..0d51a5715 100644 --- a/docs/users/configuration/auth.md +++ b/docs/users/configuration/auth.md @@ -205,7 +205,7 @@ Edit `~/.qwen/settings.json` (create it if it doesn't exist). You can mix multip > > When using the `env` field in `settings.json`, credentials are stored in plain text. For better security, prefer `.env` files or shell `export` — see [Step 2](#step-2-set-environment-variables). -For the full `modelProviders` schema and advanced options like `generationConfig`, `customHeaders`, and `extra_body`, see [Settings Reference → modelProviders](settings.md#modelproviders). +For the full `modelProviders` schema and advanced options like `generationConfig`, `customHeaders`, and `extra_body`, see [Model Providers Reference](model-providers.md). #### Step 2: Set environment variables From d12a593aa8969260ae0c69cd5f5662d0c04c4d06 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Wed, 25 Feb 2026 10:12:25 +0800 Subject: [PATCH 79/81] fix(openrouter): correct header name for OpenRouter provider Change X-Title to X-OpenRouter-Title for proper OpenRouter API compatibility Co-authored-by: Qwen-Coder --- .../core/src/core/openaiContentGenerator/provider/openrouter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/core/openaiContentGenerator/provider/openrouter.ts b/packages/core/src/core/openaiContentGenerator/provider/openrouter.ts index 7eb9d55af..9bf8716f2 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/openrouter.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/openrouter.ts @@ -25,7 +25,7 @@ export class OpenRouterOpenAICompatibleProvider extends DefaultOpenAICompatibleP return { ...baseHeaders, 'HTTP-Referer': 'https://github.com/QwenLM/qwen-code.git', - 'X-Title': 'Qwen Code', + 'X-OpenRouter-Title': 'Qwen Code', }; } } From b749ef302e12b1f40885dec1376c260993a1b488 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Wed, 25 Feb 2026 10:41:39 +0800 Subject: [PATCH 80/81] test(openrouter): update test expectations for X-OpenRouter-Title header Co-authored-by: Qwen-Coder --- .../openaiContentGenerator/provider/openrouter.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/core/openaiContentGenerator/provider/openrouter.test.ts b/packages/core/src/core/openaiContentGenerator/provider/openrouter.test.ts index cfd9c59d7..385cdd563 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/openrouter.test.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/openrouter.test.ts @@ -105,7 +105,7 @@ describe('OpenRouterOpenAICompatibleProvider', () => { expect(headers).toEqual({ 'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`, 'HTTP-Referer': 'https://github.com/QwenLM/qwen-code.git', - 'X-Title': 'Qwen Code', + 'X-OpenRouter-Title': 'Qwen Code', }); }); @@ -125,7 +125,7 @@ describe('OpenRouterOpenAICompatibleProvider', () => { expect(headers).toEqual({ 'User-Agent': 'ParentAgent/1.0.0', 'HTTP-Referer': 'https://github.com/QwenLM/qwen-code.git', // OpenRouter-specific value should override - 'X-Title': 'Qwen Code', + 'X-OpenRouter-Title': 'Qwen Code', }); parentBuildHeaders.mockRestore(); @@ -142,7 +142,7 @@ describe('OpenRouterOpenAICompatibleProvider', () => { expect(headers['HTTP-Referer']).toBe( 'https://github.com/QwenLM/qwen-code.git', ); - expect(headers['X-Title']).toBe('Qwen Code'); + expect(headers['X-OpenRouter-Title']).toBe('Qwen Code'); }); }); @@ -215,7 +215,7 @@ describe('OpenRouterOpenAICompatibleProvider', () => { expect(headers['HTTP-Referer']).toBe( 'https://github.com/QwenLM/qwen-code.git', ); // OpenRouter-specific - expect(headers['X-Title']).toBe('Qwen Code'); // OpenRouter-specific + expect(headers['X-OpenRouter-Title']).toBe('Qwen Code'); // OpenRouter-specific }); }); }); From 4013c7c5dbb5b98bf5c2e9121ce5997ee479a9fd Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Wed, 25 Feb 2026 11:58:01 +0800 Subject: [PATCH 81/81] docs: update roadmap.md --- docs/developers/roadmap.md | 90 +++++++++++++++++++++----------------- 1 file changed, 49 insertions(+), 41 deletions(-) diff --git a/docs/developers/roadmap.md b/docs/developers/roadmap.md index 125a4d36e..83cd42355 100644 --- a/docs/developers/roadmap.md +++ b/docs/developers/roadmap.md @@ -2,13 +2,13 @@ > **Objective**: Catch up with Claude Code's product functionality, continuously refine details, and enhance user experience. -| Category | Phase 1 | Phase 2 | -| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -| User Experience | ✅ Terminal UI
✅ Support OpenAI Protocol
✅ Settings
✅ OAuth
✅ Cache Control
✅ Memory
✅ Compress
✅ Theme | Better UI
OnBoarding
LogView
✅ Session
Permission
🔄 Cross-platform Compatibility | -| Coding Workflow | ✅ Slash Commands
✅ MCP
✅ PlanMode
✅ TodoWrite
✅ SubAgent
✅ Multi Model
✅ Chat Management
✅ Tools (WebFetch, Bash, TextSearch, FileReadFile, EditFile) | 🔄 Hooks
SubAgent (enhanced)
✅ Skill
✅ Headless Mode
✅ Tools (WebSearch) | -| Building Open Capabilities | ✅ Custom Commands | ✅ QwenCode SDK
Extension | -| Integrating Community Ecosystem | | ✅ VSCode Plugin
🔄 ACP/Zed
✅ GHA | -| Administrative Capabilities | ✅ Stats
✅ Feedback | Costs
Dashboard | +| Category | Phase 1 | Phase 2 | +| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| User Experience | ✅ Terminal UI
✅ Support OpenAI Protocol
✅ Settings
✅ OAuth
✅ Cache Control
✅ Memory
✅ Compress
✅ Theme | Better UI
OnBoarding
LogView
✅ Session
Permission
🔄 Cross-platform Compatibility
✅ Coding Plan
✅ Anthropic Provider
✅ Multimodal Input
✅ Unified WebUI | +| Coding Workflow | ✅ Slash Commands
✅ MCP
✅ PlanMode
✅ TodoWrite
✅ SubAgent
✅ Multi Model
✅ Chat Management
✅ Tools (WebFetch, Bash, TextSearch, FileReadFile, EditFile) | 🔄 Hooks
✅ Skill
✅ Headless Mode
✅ Tools (WebSearch)
✅ LSP Support
✅ Concurrent Runner | +| Building Open Capabilities | ✅ Custom Commands | ✅ QwenCode SDK
✅ Extension System | +| Integrating Community Ecosystem | | ✅ VSCode Plugin
✅ ACP/Zed
✅ GHA | +| Administrative Capabilities | ✅ Stats
✅ Feedback | Costs
Dashboard
✅ User Feedback Dialog | > For more details, please see the list below. @@ -16,39 +16,48 @@ #### Completed Features -| Feature | Version | Description | Category | -| ----------------------- | --------- | ------------------------------------------------------- | ------------------------------- | -| Skill | `V0.6.0` | Extensible custom AI skills | Coding Workflow | -| Github Actions | `V0.5.0` | qwen-code-action and automation | Integrating Community Ecosystem | -| VSCode Plugin | `V0.5.0` | VSCode extension plugin | Integrating Community Ecosystem | -| QwenCode SDK | `V0.4.0` | Open SDK for third-party integration | Building Open Capabilities | -| Session | `V0.4.0` | Enhanced session management | User Experience | -| i18n | `V0.3.0` | Internationalization and multilingual support | User Experience | -| Headless Mode | `V0.3.0` | Headless mode (non-interactive) | Coding Workflow | -| ACP/Zed | `V0.2.0` | ACP and Zed editor integration | Integrating Community Ecosystem | -| Terminal UI | `V0.1.0+` | Interactive terminal user interface | User Experience | -| Settings | `V0.1.0+` | Configuration management system | User Experience | -| Theme | `V0.1.0+` | Multi-theme support | User Experience | -| Support OpenAI Protocol | `V0.1.0+` | Support for OpenAI API protocol | User Experience | -| Chat Management | `V0.1.0+` | Session management (save, restore, browse) | Coding Workflow | -| MCP | `V0.1.0+` | Model Context Protocol integration | Coding Workflow | -| Multi Model | `V0.1.0+` | Multi-model support and switching | Coding Workflow | -| Slash Commands | `V0.1.0+` | Slash command system | Coding Workflow | -| Tool: Bash | `V0.1.0+` | Shell command execution tool (with is_background param) | Coding Workflow | -| Tool: FileRead/EditFile | `V0.1.0+` | File read/write and edit tools | Coding Workflow | -| Custom Commands | `V0.1.0+` | Custom command loading | Building Open Capabilities | -| Feedback | `V0.1.0+` | Feedback mechanism (/bug command) | Administrative Capabilities | -| Stats | `V0.1.0+` | Usage statistics and quota display | Administrative Capabilities | -| Memory | `V0.0.9+` | Project-level and global memory management | User Experience | -| Cache Control | `V0.0.9+` | Prompt caching control (Anthropic, DashScope) | User Experience | -| PlanMode | `V0.0.14` | Task planning mode | Coding Workflow | -| Compress | `V0.0.11` | Chat compression mechanism | User Experience | -| SubAgent | `V0.0.11` | Dedicated sub-agent system | Coding Workflow | -| TodoWrite | `V0.0.10` | Task management and progress tracking | Coding Workflow | -| Tool: TextSearch | `V0.0.8+` | Text search tool (grep, supports .qwenignore) | Coding Workflow | -| Tool: WebFetch | `V0.0.7+` | Web content fetching tool | Coding Workflow | -| Tool: WebSearch | `V0.0.7+` | Web search tool (using Tavily API) | Coding Workflow | -| OAuth | `V0.0.5+` | OAuth login authentication (Qwen OAuth) | User Experience | +| Feature | Version | Description | Category | Phase | +| ----------------------- | --------- | ------------------------------------------------------- | ------------------------------- | ----- | +| **Coding Plan** | `V0.10.0` | Bailian Coding Plan authentication & models | User Experience | 2 | +| Unified WebUI | `V0.9.0` | Shared WebUI component library for VSCode/CLI | User Experience | 2 | +| Export Chat | `V0.8.0` | Export sessions to Markdown/HTML/JSON/JSONL | User Experience | 2 | +| Extension System | `V0.8.0` | Full extension management with slash commands | Building Open Capabilities | 2 | +| LSP Support | `V0.7.0` | Experimental LSP service (`--experimental-lsp`) | Coding Workflow | 2 | +| Anthropic Provider | `V0.7.0` | Anthropic API provider support | User Experience | 2 | +| User Feedback Dialog | `V0.7.0` | In-app feedback collection with fatigue mechanism | Administrative Capabilities | 2 | +| Concurrent Runner | `V0.6.0` | Batch CLI execution with Git integration | Coding Workflow | 2 | +| Multimodal Input | `V0.6.0` | Image, PDF, audio, video input support | User Experience | 2 | +| Skill | `V0.6.0` | Extensible custom AI skills (experimental) | Coding Workflow | 2 | +| Github Actions | `V0.5.0` | qwen-code-action and automation | Integrating Community Ecosystem | 1 | +| VSCode Plugin | `V0.5.0` | VSCode extension plugin | Integrating Community Ecosystem | 1 | +| QwenCode SDK | `V0.4.0` | Open SDK for third-party integration | Building Open Capabilities | 1 | +| Session | `V0.4.0` | Enhanced session management | User Experience | 1 | +| i18n | `V0.3.0` | Internationalization and multilingual support | User Experience | 1 | +| Headless Mode | `V0.3.0` | Headless mode (non-interactive) | Coding Workflow | 1 | +| ACP/Zed | `V0.2.0` | ACP and Zed editor integration | Integrating Community Ecosystem | 1 | +| Terminal UI | `V0.1.0+` | Interactive terminal user interface | User Experience | 1 | +| Settings | `V0.1.0+` | Configuration management system | User Experience | 1 | +| Theme | `V0.1.0+` | Multi-theme support | User Experience | 1 | +| Support OpenAI Protocol | `V0.1.0+` | Support for OpenAI API protocol | User Experience | 1 | +| Chat Management | `V0.1.0+` | Session management (save, restore, browse) | Coding Workflow | 1 | +| MCP | `V0.1.0+` | Model Context Protocol integration | Coding Workflow | 1 | +| Multi Model | `V0.1.0+` | Multi-model support and switching | Coding Workflow | 1 | +| Slash Commands | `V0.1.0+` | Slash command system | Coding Workflow | 1 | +| Tool: Bash | `V0.1.0+` | Shell command execution tool (with is_background param) | Coding Workflow | 1 | +| Tool: FileRead/EditFile | `V0.1.0+` | File read/write and edit tools | Coding Workflow | 1 | +| Custom Commands | `V0.1.0+` | Custom command loading | Building Open Capabilities | 1 | +| Feedback | `V0.1.0+` | Feedback mechanism (/bug command) | Administrative Capabilities | 1 | +| Stats | `V0.1.0+` | Usage statistics and quota display | Administrative Capabilities | 1 | +| Memory | `V0.0.9+` | Project-level and global memory management | User Experience | 1 | +| Cache Control | `V0.0.9+` | Prompt caching control (Anthropic, DashScope) | User Experience | 1 | +| PlanMode | `V0.0.14` | Task planning mode | Coding Workflow | 1 | +| Compress | `V0.0.11` | Chat compression mechanism | User Experience | 1 | +| SubAgent | `V0.0.11` | Dedicated sub-agent system | Coding Workflow | 1 | +| TodoWrite | `V0.0.10` | Task management and progress tracking | Coding Workflow | 1 | +| Tool: TextSearch | `V0.0.8+` | Text search tool (grep, supports .qwenignore) | Coding Workflow | 1 | +| Tool: WebFetch | `V0.0.7+` | Web content fetching tool | Coding Workflow | 1 | +| Tool: WebSearch | `V0.0.7+` | Web search tool (using Tavily API) | Coding Workflow | 1 | +| OAuth | `V0.0.5+` | OAuth login authentication (Qwen OAuth) | User Experience | 1 | #### Features to Develop @@ -60,7 +69,6 @@ | Cross-platform Compatibility | P1 | In Progress | Windows/Linux/macOS compatibility | User Experience | | LogView | P2 | Planned | Log viewing and debugging feature | User Experience | | Hooks | P2 | In Progress | Extension hooks system | Coding Workflow | -| Extension | P2 | Planned | Extension system | Building Open Capabilities | | Costs | P2 | Planned | Cost tracking and analysis | Administrative Capabilities | | Dashboard | P2 | Planned | Management dashboard | Administrative Capabilities |