From d9928eab664a7280315c4c59867d525cf37b5eed Mon Sep 17 00:00:00 2001 From: xuewenjie Date: Thu, 4 Dec 2025 15:20:45 +0800 Subject: [PATCH 001/142] fix: improve windows background process handling and cleanup --- .gitignore | 3 + .../src/services/shellExecutionService.ts | 55 +++++++++++++- packages/core/src/tools/shell.ts | 74 ++++++++++++++++++- 3 files changed, 125 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 2c3156b96..bf3aa4211 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,6 @@ gha-creds-*.json # Log files patch_output.log + +# test files +demo-app \ No newline at end of file diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index be0c26ff7..a9ad273bf 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -7,7 +7,7 @@ import stripAnsi from 'strip-ansi'; import type { PtyImplementation } from '../utils/getPty.js'; import { getPty } from '../utils/getPty.js'; -import { spawn as cpSpawn } from 'node:child_process'; +import { spawn as cpSpawn, spawnSync } from 'node:child_process'; import { TextDecoder } from 'node:util'; import os from 'node:os'; import type { IPty } from '@lydell/node-pty'; @@ -106,6 +106,51 @@ const getFullBufferText = (terminal: pkg.Terminal): string => { export class ShellExecutionService { private static activePtys = new Map(); + private static activeChildProcesses = new Set(); + + static { + const cleanup = () => { + // Cleanup PTYs + for (const [pid, pty] of this.activePtys) { + try { + if (os.platform() === 'win32') { + pty.ptyProcess.kill(); + } else { + process.kill(-pid, 'SIGKILL'); + } + } catch { + // ignore + } + } + + // Cleanup child processes + for (const pid of this.activeChildProcesses) { + try { + if (os.platform() === 'win32') { + spawnSync('taskkill', ['/pid', pid.toString(), '/f', '/t']); + } else { + process.kill(-pid, 'SIGKILL'); + } + } catch { + // ignore + } + } + }; + + process.on('exit', cleanup); + + // Ensure cleanup happens on SIGINT/SIGTERM + const signalHandler = () => { + process.exit(); + }; + + // We only attach these if we are in a node environment where we can control the process + if (typeof process !== 'undefined' && process.on) { + process.on('SIGINT', signalHandler); + process.on('SIGTERM', signalHandler); + } + } + /** * Executes a shell command using `node-pty`, capturing all output and lifecycle events. * @@ -281,9 +326,13 @@ export class ShellExecutionService { abortSignal.addEventListener('abort', abortHandler, { once: true }); + if (child.pid) { + this.activeChildProcesses.add(child.pid); + } + child.on('exit', (code, signal) => { if (child.pid) { - this.activePtys.delete(child.pid); + this.activeChildProcesses.delete(child.pid); } handleExit(code, signal); }); @@ -310,7 +359,7 @@ export class ShellExecutionService { } }); - return { pid: undefined, result }; + return { pid: child.pid, result }; } catch (e) { const error = e as Error; return { diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 17e40dbe7..b303b0fac 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -29,6 +29,7 @@ import { summarizeToolOutput } from '../utils/summarizer.js'; import type { ShellExecutionConfig, ShellOutputEvent, + ShellExecutionResult, } from '../services/shellExecutionService.js'; import { ShellExecutionService } from '../services/shellExecutionService.js'; import { formatMemoryUsage } from '../utils/formatters.js'; @@ -47,6 +48,7 @@ export interface ShellToolParams { is_background: boolean; description?: string; directory?: string; + timeout?: number; } export class ShellToolInvocation extends BaseToolInvocation< @@ -132,6 +134,14 @@ export class ShellToolInvocation extends BaseToolInvocation< .toString('hex')}.tmp`; const tempFilePath = path.join(os.tmpdir(), tempFileName); + const timeoutMs = this.params.timeout ?? 3600000; + const abortController = new AbortController(); + const onAbort = () => abortController.abort(); + signal.addEventListener('abort', onAbort); + const timeoutId = setTimeout(() => { + abortController.abort(); + }, timeoutMs); + try { // Add co-author to git commit commands const processedCommand = this.addCoAuthorToGitCommit(strippedCommand); @@ -139,11 +149,30 @@ export class ShellToolInvocation extends BaseToolInvocation< const shouldRunInBackground = this.params.is_background; let finalCommand = processedCommand; - // If explicitly marked as background and doesn't already end with &, add it - if (shouldRunInBackground && !finalCommand.trim().endsWith('&')) { + // On non-Windows, use & to run in background. + // On Windows, we don't use start /B because it creates a detached process that + // doesn't die when the parent dies. Instead, we rely on the race logic below + // to return early while keeping the process attached (detached: false). + if ( + !isWindows && + shouldRunInBackground && + !finalCommand.trim().endsWith('&') + ) { finalCommand = finalCommand.trim() + ' &'; } + // On Windows, append a keep-alive command to ensure the shell process + // stays alive even if the main command exits (e.g. spawns a detached child). + // This ensures we always have a valid PID for cleanup. + if (isWindows && shouldRunInBackground) { + // Remove trailing & if present to avoid syntax errors (e.g. "cmd & & ping") + let cmd = finalCommand.trim(); + while (cmd.endsWith('&')) { + cmd = cmd.slice(0, -1).trim(); + } + finalCommand = cmd + ' & ping -n 86400 127.0.0.1 >nul'; + } + // pgrep is not available on Windows, so we can't get background PIDs const commandToExecute = isWindows ? finalCommand @@ -206,7 +235,7 @@ export class ShellToolInvocation extends BaseToolInvocation< lastUpdateTime = Date.now(); } }, - signal, + abortController.signal, this.config.getShouldUseNodePtyShell(), shellExecutionConfig ?? {}, ); @@ -215,7 +244,34 @@ export class ShellToolInvocation extends BaseToolInvocation< setPidCallback(pid); } - const result = await resultPromise; + let result: ShellExecutionResult; + if (shouldRunInBackground && isWindows) { + // For Windows background tasks, we wait a short time to catch immediate errors. + // If it's still running, we return early. + const startupDelay = 1000; + const raceResult = await Promise.race([ + resultPromise, + new Promise((resolve) => + setTimeout(() => resolve(null), startupDelay), + ), + ]); + + if (raceResult === null) { + // Timeout reached, process is still running. + const pidMsg = pid ? ` PID: ${pid}` : ''; + const winHint = isWindows + ? ' (Note: Use taskkill /F /T /PID to stop)' + : ''; + return { + llmContent: `Background command started.${pidMsg}${winHint}`, + returnDisplay: `Background command started.${pidMsg}${winHint}`, + }; + } else { + result = raceResult; + } + } else { + result = await resultPromise; + } const backgroundPIDs: number[] = []; if (os.platform() !== 'win32') { @@ -321,6 +377,8 @@ export class ShellToolInvocation extends BaseToolInvocation< ...executionError, }; } finally { + clearTimeout(timeoutId); + signal.removeEventListener('abort', onAbort); if (fs.existsSync(tempFilePath)) { fs.unlinkSync(tempFilePath); } @@ -454,6 +512,11 @@ export class ShellTool extends BaseDeclarativeTool< description: '(OPTIONAL) The absolute path of the directory to run the command in. If not provided, the project root directory is used. Must be a directory within the workspace and must already exist.', }, + timeout: { + type: 'number', + description: + '(OPTIONAL) The timeout in milliseconds for the command. If not provided, a default timeout (1 hour) is applied.', + }, }, required: ['command', 'is_background'], }, @@ -478,6 +541,9 @@ export class ShellTool extends BaseDeclarativeTool< if (!params.command.trim()) { return 'Command cannot be empty.'; } + if (params.timeout !== undefined && params.timeout <= 0) { + return 'Timeout must be a positive number.'; + } if (getCommandRoots(params.command).length === 0) { return 'Could not identify command root to obtain permission from user.'; } From 403fd061171be14d6e738e600f394b4f0a663dc5 Mon Sep 17 00:00:00 2001 From: xuewenjie Date: Thu, 4 Dec 2025 15:51:08 +0800 Subject: [PATCH 002/142] chore: update .gitignore --- .gitignore | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitignore b/.gitignore index bf3aa4211..2c3156b96 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,3 @@ gha-creds-*.json # Log files patch_output.log - -# test files -demo-app \ No newline at end of file From 4c69d536acfe9b04a08695d5dd1804f9712ccafc Mon Sep 17 00:00:00 2001 From: xuewenjie Date: Fri, 5 Dec 2025 10:47:06 +0800 Subject: [PATCH 003/142] test: fix shell tool tests by updating pid expectation and AbortSignal matching --- .../services/shellExecutionService.test.ts | 2 +- packages/core/src/tools/shell.test.ts | 30 +++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/core/src/services/shellExecutionService.test.ts b/packages/core/src/services/shellExecutionService.test.ts index 4dfc48918..c5a6e0776 100644 --- a/packages/core/src/services/shellExecutionService.test.ts +++ b/packages/core/src/services/shellExecutionService.test.ts @@ -589,7 +589,7 @@ describe('ShellExecutionService child_process fallback', () => { expect(result.error).toBeNull(); expect(result.aborted).toBe(false); expect(result.output).toBe('file1.txt\na warning'); - expect(handle.pid).toBe(undefined); + expect(handle.pid).toBe(12345); expect(onOutputEventMock).toHaveBeenCalledWith({ type: 'data', diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index 043ab0c64..bde508370 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -210,7 +210,7 @@ describe('ShellTool', () => { wrappedCommand, '/test/dir', expect.any(Function), - mockAbortSignal, + expect.any(AbortSignal), false, {}, ); @@ -237,7 +237,7 @@ describe('ShellTool', () => { wrappedCommand, expect.any(String), expect.any(Function), - mockAbortSignal, + expect.any(AbortSignal), false, {}, ); @@ -262,7 +262,7 @@ describe('ShellTool', () => { wrappedCommand, expect.any(String), expect.any(Function), - mockAbortSignal, + expect.any(AbortSignal), false, {}, ); @@ -287,7 +287,7 @@ describe('ShellTool', () => { wrappedCommand, expect.any(String), expect.any(Function), - mockAbortSignal, + expect.any(AbortSignal), false, {}, ); @@ -312,7 +312,7 @@ describe('ShellTool', () => { wrappedCommand, '/test/dir/subdir', expect.any(Function), - mockAbortSignal, + expect.any(AbortSignal), false, {}, ); @@ -340,7 +340,7 @@ describe('ShellTool', () => { 'dir', '/test/dir', expect.any(Function), - mockAbortSignal, + expect.any(AbortSignal), false, {}, ); @@ -433,7 +433,7 @@ describe('ShellTool', () => { expect(summarizer.summarizeToolOutput).toHaveBeenCalledWith( expect.any(String), mockConfig.getGeminiClient(), - mockAbortSignal, + expect.any(AbortSignal), 1000, ); expect(result.llmContent).toBe('summarized output'); @@ -542,7 +542,7 @@ describe('ShellTool', () => { ), expect.any(String), expect.any(Function), - mockAbortSignal, + expect.any(AbortSignal), false, {}, ); @@ -572,7 +572,7 @@ describe('ShellTool', () => { ), expect.any(String), expect.any(Function), - mockAbortSignal, + expect.any(AbortSignal), false, {}, ); @@ -602,7 +602,7 @@ describe('ShellTool', () => { ), expect.any(String), expect.any(Function), - mockAbortSignal, + expect.any(AbortSignal), false, {}, ); @@ -631,7 +631,7 @@ describe('ShellTool', () => { expect.stringContaining('npm install'), expect.any(String), expect.any(Function), - mockAbortSignal, + expect.any(AbortSignal), false, {}, ); @@ -660,7 +660,7 @@ describe('ShellTool', () => { expect.stringContaining('git commit'), expect.any(String), expect.any(Function), - mockAbortSignal, + expect.any(AbortSignal), false, {}, ); @@ -690,7 +690,7 @@ describe('ShellTool', () => { ), expect.any(String), expect.any(Function), - mockAbortSignal, + expect.any(AbortSignal), false, {}, ); @@ -726,7 +726,7 @@ describe('ShellTool', () => { expect.stringContaining('git commit -m "Initial commit"'), expect.any(String), expect.any(Function), - mockAbortSignal, + expect.any(AbortSignal), false, {}, ); @@ -763,7 +763,7 @@ describe('ShellTool', () => { ), expect.any(String), expect.any(Function), - mockAbortSignal, + expect.any(AbortSignal), false, {}, ); From 28d178b5c1878570fa193e5342675b1f251db0ce Mon Sep 17 00:00:00 2001 From: xuewenjie Date: Tue, 9 Dec 2025 11:24:30 +0800 Subject: [PATCH 004/142] fix: handle windows background execution errors and add tests --- .../src/services/shellExecutionService.ts | 67 ++++++++----------- packages/core/src/tools/shell.test.ts | 29 ++++++++ packages/core/src/tools/shell.ts | 38 +++++++++-- 3 files changed, 90 insertions(+), 44 deletions(-) diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index a9ad273bf..e501e6ecd 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -108,47 +108,38 @@ export class ShellExecutionService { private static activePtys = new Map(); private static activeChildProcesses = new Set(); - static { - const cleanup = () => { - // Cleanup PTYs - for (const [pid, pty] of this.activePtys) { - try { - if (os.platform() === 'win32') { - pty.ptyProcess.kill(); - } else { - process.kill(-pid, 'SIGKILL'); - } - } catch { - // ignore + static cleanup() { + // Cleanup PTYs + for (const [pid, pty] of this.activePtys) { + try { + if (os.platform() === 'win32') { + pty.ptyProcess.kill(); + } else { + process.kill(-pid, 'SIGKILL'); } + } catch { + // ignore } - - // Cleanup child processes - for (const pid of this.activeChildProcesses) { - try { - if (os.platform() === 'win32') { - spawnSync('taskkill', ['/pid', pid.toString(), '/f', '/t']); - } else { - process.kill(-pid, 'SIGKILL'); - } - } catch { - // ignore - } - } - }; - - process.on('exit', cleanup); - - // Ensure cleanup happens on SIGINT/SIGTERM - const signalHandler = () => { - process.exit(); - }; - - // We only attach these if we are in a node environment where we can control the process - if (typeof process !== 'undefined' && process.on) { - process.on('SIGINT', signalHandler); - process.on('SIGTERM', signalHandler); } + + // Cleanup child processes + for (const pid of this.activeChildProcesses) { + try { + if (os.platform() === 'win32') { + spawnSync('taskkill', ['/pid', pid.toString(), '/f', '/t']); + } else { + process.kill(-pid, 'SIGKILL'); + } + } catch { + // ignore + } + } + } + + static { + process.on('exit', () => { + ShellExecutionService.cleanup(); + }); } /** diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index bde508370..b431f494f 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -831,4 +831,33 @@ describe('ShellTool', () => { expect(shellTool.description).toMatchSnapshot(); }); }); + + describe('Windows background execution', () => { + it('should detect immediate failure in Windows background task', async () => { + vi.mocked(os.platform).mockReturnValue('win32'); + const mockAbortSignal = new AbortController().signal; + + const invocation = shellTool.build({ + command: 'invalid_command', + is_background: true, + }); + + const promise = invocation.execute(mockAbortSignal); + + // Wait a tick to ensure mockShellOutputCallback is assigned + await new Promise((resolve) => setTimeout(resolve, 0)); + + if (mockShellOutputCallback) { + mockShellOutputCallback({ + type: 'data', + chunk: + "'invalid_command' is not recognized as an internal or external command,\r\noperable program or batch file.\r\n", + }); + } + + const result = await promise; + expect(result.error).toBeDefined(); + expect(result.llmContent).toContain('Command failed to start'); + }); + }); }); diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index b303b0fac..4ee7e79c6 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -194,16 +194,16 @@ export class ShellToolInvocation extends BaseToolInvocation< commandToExecute, cwd, (event: ShellOutputEvent) => { - if (!updateOutput) { - return; - } - let shouldUpdate = false; switch (event.type) { case 'data': if (isBinaryStream) break; - cumulativeOutput = event.chunk; + if (typeof cumulativeOutput === 'string') { + cumulativeOutput += event.chunk; + } else { + cumulativeOutput = event.chunk; + } shouldUpdate = true; break; case 'binary_detected': @@ -226,7 +226,7 @@ export class ShellToolInvocation extends BaseToolInvocation< } } - if (shouldUpdate) { + if (shouldUpdate && updateOutput) { updateOutput( typeof cumulativeOutput === 'string' ? cumulativeOutput @@ -258,6 +258,32 @@ export class ShellToolInvocation extends BaseToolInvocation< if (raceResult === null) { // Timeout reached, process is still running. + // throw new Error(`DEBUG: raceResult is null. Output: ${JSON.stringify(cumulativeOutput)}`); + + // Check for common Windows error messages in the output + const outputStr = + typeof cumulativeOutput === 'string' + ? cumulativeOutput + : JSON.stringify(cumulativeOutput); + console.log('DEBUG: outputStr:', outputStr); + const errorPatterns = [ + 'is not recognized as an internal or external command', + 'The system cannot find the path specified', + 'Access is denied', + ]; + + if (errorPatterns.some((pattern) => outputStr.includes(pattern))) { + abortController.abort(); + return { + llmContent: `Command failed to start: ${outputStr}`, + returnDisplay: `Command failed to start: ${outputStr}`, + error: { + type: ToolErrorType.EXECUTION_FAILED, + message: `Command failed to start: ${outputStr}`, + }, + }; + } + const pidMsg = pid ? ` PID: ${pid}` : ''; const winHint = isWindows ? ' (Note: Use taskkill /F /T /PID to stop)' From 6fc09a82fb6f3cc9e7dd50ea706bddd6e346724b Mon Sep 17 00:00:00 2001 From: xuewenjie Date: Tue, 9 Dec 2025 13:33:42 +0800 Subject: [PATCH 005/142] fix: use && for windows background keep-alive ping and add test --- packages/core/src/tools/shell.test.ts | 35 +++++++++++++++++++++++++++ packages/core/src/tools/shell.ts | 2 +- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index b431f494f..b98e71584 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -833,6 +833,41 @@ describe('ShellTool', () => { }); describe('Windows background execution', () => { + it('should append keep-alive ping with && on Windows for background tasks', async () => { + vi.mocked(os.platform).mockReturnValue('win32'); + const mockAbortSignal = new AbortController().signal; + + const invocation = shellTool.build({ + command: 'npm start', + is_background: true, + }); + + const promise = invocation.execute(mockAbortSignal); + + // Simulate immediate success (process started) + resolveExecutionPromise({ + rawOutput: Buffer.from(''), + output: '', + exitCode: 0, + signal: null, + error: null, + aborted: false, + pid: 12345, + executionMethod: 'child_process', + }); + + await promise; + + expect(mockShellExecutionService).toHaveBeenCalledWith( + expect.stringContaining('npm start && ping -n 86400 127.0.0.1 >nul'), + expect.any(String), + expect.any(Function), + expect.any(AbortSignal), + false, + {}, + ); + }); + it('should detect immediate failure in Windows background task', async () => { vi.mocked(os.platform).mockReturnValue('win32'); const mockAbortSignal = new AbortController().signal; diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 55bc4df02..8d7610d46 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -174,7 +174,7 @@ export class ShellToolInvocation extends BaseToolInvocation< while (cmd.endsWith('&')) { cmd = cmd.slice(0, -1).trim(); } - finalCommand = cmd + ' & ping -n 86400 127.0.0.1 >nul'; + finalCommand = cmd + ' && ping -n 86400 127.0.0.1 >nul'; } // pgrep is not available on Windows, so we can't get background PIDs From 16939c0bc8cbe2ee0e7743e6fd96fced51f35a10 Mon Sep 17 00:00:00 2001 From: xuewenjie Date: Wed, 10 Dec 2025 13:49:51 +0800 Subject: [PATCH 006/142] Refactor ShellTool: remove ping hack and timeout, optimize cleanup --- .../src/services/shellExecutionService.ts | 26 +++++++++++----- packages/core/src/tools/shell.test.ts | 6 ++-- packages/core/src/tools/shell.ts | 31 +++---------------- 3 files changed, 26 insertions(+), 37 deletions(-) diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index e501e6ecd..4a638c620 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -123,15 +123,25 @@ export class ShellExecutionService { } // Cleanup child processes - for (const pid of this.activeChildProcesses) { - try { - if (os.platform() === 'win32') { - spawnSync('taskkill', ['/pid', pid.toString(), '/f', '/t']); - } else { - process.kill(-pid, 'SIGKILL'); + if (os.platform() === 'win32') { + if (this.activeChildProcesses.size > 0) { + try { + const args = ['/f', '/t']; + for (const pid of this.activeChildProcesses) { + args.push('/pid', pid.toString()); + } + spawnSync('taskkill', args); + } catch { + // ignore + } + } + } else { + for (const pid of this.activeChildProcesses) { + try { + process.kill(-pid, 'SIGKILL'); + } catch { + // ignore } - } catch { - // ignore } } } diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index b98e71584..3484c53b2 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -833,12 +833,12 @@ describe('ShellTool', () => { }); describe('Windows background execution', () => { - it('should append keep-alive ping with && on Windows for background tasks', async () => { + it('should clean up trailing ampersand on Windows for background tasks', async () => { vi.mocked(os.platform).mockReturnValue('win32'); const mockAbortSignal = new AbortController().signal; const invocation = shellTool.build({ - command: 'npm start', + command: 'npm start &', is_background: true, }); @@ -859,7 +859,7 @@ describe('ShellTool', () => { await promise; expect(mockShellExecutionService).toHaveBeenCalledWith( - expect.stringContaining('npm start && ping -n 86400 127.0.0.1 >nul'), + 'npm start', expect.any(String), expect.any(Function), expect.any(AbortSignal), diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 8d7610d46..a886010d9 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -49,7 +49,6 @@ export interface ShellToolParams { is_background: boolean; description?: string; directory?: string; - timeout?: number; } export class ShellToolInvocation extends BaseToolInvocation< @@ -138,14 +137,6 @@ export class ShellToolInvocation extends BaseToolInvocation< .toString('hex')}.tmp`; const tempFilePath = path.join(os.tmpdir(), tempFileName); - const timeoutMs = this.params.timeout ?? 3600000; - const abortController = new AbortController(); - const onAbort = () => abortController.abort(); - signal.addEventListener('abort', onAbort); - const timeoutId = setTimeout(() => { - abortController.abort(); - }, timeoutMs); - try { // Add co-author to git commit commands const processedCommand = this.addCoAuthorToGitCommit(strippedCommand); @@ -165,16 +156,15 @@ export class ShellToolInvocation extends BaseToolInvocation< finalCommand = finalCommand.trim() + ' &'; } - // On Windows, append a keep-alive command to ensure the shell process - // stays alive even if the main command exits (e.g. spawns a detached child). - // This ensures we always have a valid PID for cleanup. + // On Windows, we rely on the race logic below to handle background tasks. + // We just ensure the command string is clean. if (isWindows && shouldRunInBackground) { - // Remove trailing & if present to avoid syntax errors (e.g. "cmd & & ping") let cmd = finalCommand.trim(); + // Remove trailing & (common Linux habit, invalid on Windows at end of line) while (cmd.endsWith('&')) { cmd = cmd.slice(0, -1).trim(); } - finalCommand = cmd + ' && ping -n 86400 127.0.0.1 >nul'; + finalCommand = cmd; } // pgrep is not available on Windows, so we can't get background PIDs @@ -239,7 +229,7 @@ export class ShellToolInvocation extends BaseToolInvocation< lastUpdateTime = Date.now(); } }, - abortController.signal, + signal, this.config.getShouldUseNodePtyShell(), shellExecutionConfig ?? {}, ); @@ -277,7 +267,6 @@ export class ShellToolInvocation extends BaseToolInvocation< ]; if (errorPatterns.some((pattern) => outputStr.includes(pattern))) { - abortController.abort(); return { llmContent: `Command failed to start: ${outputStr}`, returnDisplay: `Command failed to start: ${outputStr}`, @@ -407,8 +396,6 @@ export class ShellToolInvocation extends BaseToolInvocation< ...executionError, }; } finally { - clearTimeout(timeoutId); - signal.removeEventListener('abort', onAbort); if (fs.existsSync(tempFilePath)) { fs.unlinkSync(tempFilePath); } @@ -542,11 +529,6 @@ export class ShellTool extends BaseDeclarativeTool< description: '(OPTIONAL) The absolute path of the directory to run the command in. If not provided, the project root directory is used. Must be a directory within the workspace and must already exist.', }, - timeout: { - type: 'number', - description: - '(OPTIONAL) The timeout in milliseconds for the command. If not provided, a default timeout (1 hour) is applied.', - }, }, required: ['command', 'is_background'], }, @@ -571,9 +553,6 @@ export class ShellTool extends BaseDeclarativeTool< if (!params.command.trim()) { return 'Command cannot be empty.'; } - if (params.timeout !== undefined && params.timeout <= 0) { - return 'Timeout must be a positive number.'; - } if (getCommandRoots(params.command).length === 0) { return 'Could not identify command root to obtain permission from user.'; } From 574d89da14cf50ad458b76850c7bec5069063ef7 Mon Sep 17 00:00:00 2001 From: xuewenjie Date: Fri, 12 Dec 2025 17:03:04 +0800 Subject: [PATCH 007/142] Refactor ShellExecutionService cleanup to use strategy pattern --- .../src/services/shellExecutionService.ts | 70 ++++++++++++------- 1 file changed, 44 insertions(+), 26 deletions(-) diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index 4a638c620..f78094329 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -98,6 +98,48 @@ const getFullBufferText = (terminal: pkg.Terminal): string => { return lines.join('\n').trimEnd(); }; +interface ProcessCleanupStrategy { + killPty(pid: number, pty: ActivePty): void; + killChildProcesses(pids: Set): void; +} + +const windowsStrategy: ProcessCleanupStrategy = { + killPty: (_pid, pty) => { + pty.ptyProcess.kill(); + }, + killChildProcesses: (pids) => { + if (pids.size > 0) { + try { + const args = ['/f', '/t']; + for (const pid of pids) { + args.push('/pid', pid.toString()); + } + spawnSync('taskkill', args); + } catch { + // ignore + } + } + }, +}; + +const posixStrategy: ProcessCleanupStrategy = { + killPty: (pid, _pty) => { + process.kill(-pid, 'SIGKILL'); + }, + killChildProcesses: (pids) => { + for (const pid of pids) { + try { + process.kill(-pid, 'SIGKILL'); + } catch { + // ignore + } + } + }, +}; + +const cleanupStrategy = + os.platform() === 'win32' ? windowsStrategy : posixStrategy; + /** * A centralized service for executing shell commands with robust process * management, cross-platform compatibility, and streaming output capabilities. @@ -112,38 +154,14 @@ export class ShellExecutionService { // Cleanup PTYs for (const [pid, pty] of this.activePtys) { try { - if (os.platform() === 'win32') { - pty.ptyProcess.kill(); - } else { - process.kill(-pid, 'SIGKILL'); - } + cleanupStrategy.killPty(pid, pty); } catch { // ignore } } // Cleanup child processes - if (os.platform() === 'win32') { - if (this.activeChildProcesses.size > 0) { - try { - const args = ['/f', '/t']; - for (const pid of this.activeChildProcesses) { - args.push('/pid', pid.toString()); - } - spawnSync('taskkill', args); - } catch { - // ignore - } - } - } else { - for (const pid of this.activeChildProcesses) { - try { - process.kill(-pid, 'SIGKILL'); - } catch { - // ignore - } - } - } + cleanupStrategy.killChildProcesses(this.activeChildProcesses); } static { From b272ac0119e930c568eb1869770dfa0b9d3b9204 Mon Sep 17 00:00:00 2001 From: xuewenjie Date: Fri, 12 Dec 2025 17:47:03 +0800 Subject: [PATCH 008/142] Fix: Make cleanup strategy dynamic to support testing mocks --- packages/core/src/services/shellExecutionService.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index f78094329..853a4c89f 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -137,7 +137,7 @@ const posixStrategy: ProcessCleanupStrategy = { }, }; -const cleanupStrategy = +const getCleanupStrategy = () => os.platform() === 'win32' ? windowsStrategy : posixStrategy; /** @@ -151,17 +151,18 @@ export class ShellExecutionService { private static activeChildProcesses = new Set(); static cleanup() { + const strategy = getCleanupStrategy(); // Cleanup PTYs for (const [pid, pty] of this.activePtys) { try { - cleanupStrategy.killPty(pid, pty); + strategy.killPty(pid, pty); } catch { // ignore } } // Cleanup child processes - cleanupStrategy.killChildProcesses(this.activeChildProcesses); + strategy.killChildProcesses(this.activeChildProcesses); } static { From 8673426d5c2847a1bf4a3392ce7fa2816263e077 Mon Sep 17 00:00:00 2001 From: xuewenjie Date: Tue, 16 Dec 2025 10:26:20 +0800 Subject: [PATCH 009/142] fix(core): use current chunk for shell output update instead of cumulative --- packages/core/src/tools/shell.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index a886010d9..194de4e86 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -193,11 +193,7 @@ export class ShellToolInvocation extends BaseToolInvocation< switch (event.type) { case 'data': if (isBinaryStream) break; - if (typeof cumulativeOutput === 'string') { - cumulativeOutput += event.chunk; - } else { - cumulativeOutput = event.chunk; - } + cumulativeOutput = event.chunk; shouldUpdate = true; break; case 'binary_detected': From 6ca54beba2b92930328a5c6d5a01d4ad98c04d7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E4=BC=9F=E5=85=89?= Date: Wed, 17 Dec 2025 13:38:38 +0800 Subject: [PATCH 010/142] feat: Optimize the issue where an error message indicating unfriendliness occurs after executing the ideinstall command in the sandbox environment --- packages/cli/src/ui/IdeIntegrationNudge.tsx | 17 ++++++++++------- packages/cli/src/ui/commands/ideCommand.ts | 11 +++++++++++ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/ui/IdeIntegrationNudge.tsx b/packages/cli/src/ui/IdeIntegrationNudge.tsx index 8ab350064..d6cbc11f5 100644 --- a/packages/cli/src/ui/IdeIntegrationNudge.tsx +++ b/packages/cli/src/ui/IdeIntegrationNudge.tsx @@ -38,6 +38,7 @@ export function IdeIntegrationNudge({ ); const { displayName: ideName } = ide; + const isInSandbox = !!process.env['SANDBOX']; // Assume extension is already installed if the env variables are set. const isExtensionPreInstalled = !!process.env['QWEN_CODE_IDE_SERVER_PORT'] && @@ -70,13 +71,15 @@ export function IdeIntegrationNudge({ }, ]; - const installText = isExtensionPreInstalled - ? `If you select Yes, the CLI will have access to your open files and display diffs directly in ${ - ideName ?? 'your editor' - }.` - : `If you select Yes, we'll install an extension that allows the CLI to access your open files and display diffs directly in ${ - ideName ?? 'your editor' - }.`; + const installText = isInSandbox + ? `Note: In sandbox environments, IDE integration requires manual setup on the host system. If you select Yes, you'll receive instructions on how to set this up.` + : isExtensionPreInstalled + ? `If you select Yes, the CLI will have access to your open files and display diffs directly in ${ + ideName ?? 'your editor' + }.` + : `If you select Yes, we'll install an extension that allows the CLI to access your open files and display diffs directly in ${ + ideName ?? 'your editor' + }.`; return ( => { kind: CommandKind.BUILT_IN, action: async (context) => { const installer = getIdeInstaller(currentIDE); + const isSandBox = !!process.env['SANDBOX']; + if (isSandBox) { + context.ui.addItem( + { + type: 'info', + text: `IDE integration needs to be installed on the host. If you have already installed it, you can directly connect the ide`, + }, + Date.now(), + ); + return; + } if (!installer) { context.ui.addItem( { From b3b2bc6ad5bc9e3bb08c9e31cb959d2f8244145f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E4=BC=9F=E5=85=89?= Date: Fri, 19 Dec 2025 10:39:05 +0800 Subject: [PATCH 011/142] =?UTF-8?q?feat:=20=E5=85=BC=E5=AE=B9=E5=AE=BF?= =?UTF-8?q?=E4=B8=BB=E6=9C=BA=E5=9C=A8=E4=B8=8D=E5=90=8Cide=E4=B8=8A?= =?UTF-8?q?=E7=9A=84instal=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli/src/ui/commands/ideCommand.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/commands/ideCommand.ts b/packages/cli/src/ui/commands/ideCommand.ts index d05ce8127..556fa08ea 100644 --- a/packages/cli/src/ui/commands/ideCommand.ts +++ b/packages/cli/src/ui/commands/ideCommand.ts @@ -203,10 +203,23 @@ export const ideCommand = async (): Promise => { return; } if (!installer) { + const ideName = ideClient.getDetectedIdeDisplayName(); + const isVSCode = currentIDE.name === 'vscode'; + let type: 'error' | 'info' = 'error'; + let message: string; + if (isVSCode) { + // VS Code + message = `No installer is available for ${ideName}. Please install the '${QWEN_CODE_COMPANION_EXTENSION_NAME}' extension manually from the marketplace.`; + } else { + // NO VS Code + type = 'info'; + message = `Automatic installation is not supported for ${ideName}. Please install '${QWEN_CODE_COMPANION_EXTENSION_NAME}' in VS Code. If you have installed it before, please ignore the reminder and directly connect the ide extension`; + } + context.ui.addItem( { - type: 'error', - text: `No installer is available for ${ideClient.getDetectedIdeDisplayName()}. Please install the '${QWEN_CODE_COMPANION_EXTENSION_NAME}' extension manually from the marketplace.`, + type, + text: message, }, Date.now(), ); From 34d8dbf9b2530081db9e7612de0a8112902c77f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E4=BC=9F=E5=85=89?= Date: Fri, 19 Dec 2025 11:07:33 +0800 Subject: [PATCH 012/142] =?UTF-8?q?feat:=20=E5=85=BC=E5=AE=B9=E5=AE=BF?= =?UTF-8?q?=E4=B8=BB=E6=9C=BA=E5=9C=A8=E4=B8=8D=E5=90=8Cide=E4=B8=8A?= =?UTF-8?q?=E7=9A=84instal=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli/src/ui/commands/ideCommand.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/ui/commands/ideCommand.ts b/packages/cli/src/ui/commands/ideCommand.ts index 556fa08ea..2440ca852 100644 --- a/packages/cli/src/ui/commands/ideCommand.ts +++ b/packages/cli/src/ui/commands/ideCommand.ts @@ -213,7 +213,7 @@ export const ideCommand = async (): Promise => { } else { // NO VS Code type = 'info'; - message = `Automatic installation is not supported for ${ideName}. Please install '${QWEN_CODE_COMPANION_EXTENSION_NAME}' in VS Code. If you have installed it before, please ignore the reminder and directly connect the ide extension`; + message = `Automatic installation is not supported for ${ideName}. Please install the extension manually or install '${QWEN_CODE_COMPANION_EXTENSION_NAME}' in VS Code. If you have installed it before, please ignore the reminder and directly connect the ide extension`; } context.ui.addItem( From 43e0815def04170de30a7c78338158ca59371adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E4=BC=9F=E5=85=89?= Date: Mon, 22 Dec 2025 11:22:51 +0800 Subject: [PATCH 013/142] =?UTF-8?q?feat:=20=E4=BF=AE=E6=94=B9=E9=93=BE?= =?UTF-8?q?=E6=8E=A5ide=E4=B9=8B=E5=89=8D=E7=9A=84=E5=88=A4=E6=96=AD?= =?UTF-8?q?=E9=80=BB=E8=BE=91,=E6=A3=80=E6=B5=8B=E6=98=AF=E5=90=A6?= =?UTF-8?q?=E5=AE=89=E8=A3=85=E8=BF=87ide=E6=89=A9=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli/src/ui/AppContainer.tsx | 7 ++++++- packages/cli/src/ui/IdeIntegrationNudge.tsx | 6 +++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index e70c0446b..6c9242c02 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -938,7 +938,12 @@ export const AppContainer = (props: AppContainerProps) => { const handleIdePromptComplete = useCallback( (result: IdeIntegrationNudgeResult) => { if (result.userSelection === 'yes') { - handleSlashCommand('/ide install'); + // Check whether the extension has been pre-installed + if (result.isExtensionPreInstalled) { + handleSlashCommand('/ide enable'); + } else { + handleSlashCommand('/ide install'); + } settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true); } else if (result.userSelection === 'dismiss') { settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true); diff --git a/packages/cli/src/ui/IdeIntegrationNudge.tsx b/packages/cli/src/ui/IdeIntegrationNudge.tsx index d6cbc11f5..a53f59b98 100644 --- a/packages/cli/src/ui/IdeIntegrationNudge.tsx +++ b/packages/cli/src/ui/IdeIntegrationNudge.tsx @@ -74,9 +74,9 @@ export function IdeIntegrationNudge({ const installText = isInSandbox ? `Note: In sandbox environments, IDE integration requires manual setup on the host system. If you select Yes, you'll receive instructions on how to set this up.` : isExtensionPreInstalled - ? `If you select Yes, the CLI will have access to your open files and display diffs directly in ${ - ideName ?? 'your editor' - }.` + ? `The IDE extension appears to be already installed. If you select Yes, the CLI will connect to your ${ + ideName ?? 'editor' + } and have access to your open files and display diffs directly.` : `If you select Yes, we'll install an extension that allows the CLI to access your open files and display diffs directly in ${ ideName ?? 'your editor' }.`; From 5779f7ab1d037a430a69921f5a2cad056d82ae52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B9=BE=E7=A6=BB?= Date: Tue, 23 Dec 2025 17:20:12 +0800 Subject: [PATCH 014/142] project initialize --- packages/sdk-java/.editorconfig | 24 ++++ packages/sdk-java/.gitignore | 13 ++ packages/sdk-java/checkstyle.xml | 131 ++++++++++++++++++ packages/sdk-java/pom.xml | 65 +++++++++ .../code/cli/transport/PermissionMode.java | 27 ++++ .../code/cli/transport/ProcessTransport.java | 9 ++ .../code/cli/transport/TransportOptions.java | 23 +++ .../cli/transport/PermissionModeTest.java | 16 +++ 8 files changed, 308 insertions(+) create mode 100644 packages/sdk-java/.editorconfig create mode 100644 packages/sdk-java/.gitignore create mode 100644 packages/sdk-java/checkstyle.xml create mode 100644 packages/sdk-java/pom.xml create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/PermissionMode.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/ProcessTransport.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java create mode 100644 packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/PermissionModeTest.java diff --git a/packages/sdk-java/.editorconfig b/packages/sdk-java/.editorconfig new file mode 100644 index 000000000..53a4241f9 --- /dev/null +++ b/packages/sdk-java/.editorconfig @@ -0,0 +1,24 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 +tab_width = 4 +ij_continuation_indent_size = 8 + +[*.java] +ij_java_doc_align_exception_comments = false +ij_java_doc_align_param_comments = false + +[*.{yaml, yml, sh, ps1}] +indent_size = 2 + +[*.{md, mkd, markdown}] +trim_trailing_whitespace = false + +[{**/res/**.xml, **/AndroidManifest.xml}] +ij_continuation_indent_size = 4 diff --git a/packages/sdk-java/.gitignore b/packages/sdk-java/.gitignore new file mode 100644 index 000000000..bb45e2790 --- /dev/null +++ b/packages/sdk-java/.gitignore @@ -0,0 +1,13 @@ +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +# Mac +.DS_Store + +# Maven +log/ +target/ + diff --git a/packages/sdk-java/checkstyle.xml b/packages/sdk-java/checkstyle.xml new file mode 100644 index 000000000..fa316ec72 --- /dev/null +++ b/packages/sdk-java/checkstyle.xml @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/sdk-java/pom.xml b/packages/sdk-java/pom.xml new file mode 100644 index 000000000..0438427ce --- /dev/null +++ b/packages/sdk-java/pom.xml @@ -0,0 +1,65 @@ + + 4.0.0 + com.alibaba + qwencode-sdk-java + jar + 0.0.1 + qwencode-sdk-java + https://maven.apache.org + + + Apache 2 + https://www.apache.org/licenses/LICENSE-2.0.txt + repo + A business-friendly OSS license + + + + https://github.com/QwenLM/qwen-code + scm:git:https://github.com/QwenLM/qwen-code.git + + + 1.8 + 1.8 + UTF-8 + 3.6.0 + + + + junit + junit + 4.13.2 + test + + + ch.qos.logback + logback-classic + 1.5.23 + compile + + + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + ${checkstyle-maven-plugin.version} + + checkstyle.xml + + + + + check + + + + + + + + diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/PermissionMode.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/PermissionMode.java new file mode 100644 index 000000000..3db5782c6 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/PermissionMode.java @@ -0,0 +1,27 @@ +package com.alibaba.qwen.code.cli.transport; + +public enum PermissionMode { + DEFAULT("default"), + PLAN("plan"), + AUTO_EDIT("auto-edit"), + YOLO("yolo"); + + private final String value; + + PermissionMode(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + public static PermissionMode fromValue(String value) { + for (PermissionMode mode : PermissionMode.values()) { + if (mode.value.equals(value)) { + return mode; + } + } + throw new IllegalArgumentException("Unknown permission mode: " + value); + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/ProcessTransport.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/ProcessTransport.java new file mode 100644 index 000000000..73584ef21 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/ProcessTransport.java @@ -0,0 +1,9 @@ +package com.alibaba.qwen.code.cli.transport; + +public class ProcessTransport { + Process process; + + public ProcessTransport(Process process) { + this.process = process; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java new file mode 100644 index 000000000..cef5b1813 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java @@ -0,0 +1,23 @@ +package com.alibaba.qwen.code.cli.transport; + +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +public class TransportOptions { + private String pathToQwenExecutable; + private String cwd; + private String model; + private PermissionMode permissionMode; + private Map env; + private Object abortController; // AbortController in JavaScript does not have a direct Java equivalent + private Boolean debug; + private Consumer stderr; // Equivalent to (message: string) => void + private String logLevel; // Can be 'debug', 'info', 'warn', or 'error' + private Integer maxSessionTurns; + private List coreTools; + private List excludeTools; + private List allowedTools; + private String authType; + private Boolean includePartialMessages; +} diff --git a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/PermissionModeTest.java b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/PermissionModeTest.java new file mode 100644 index 000000000..31cf692fc --- /dev/null +++ b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/PermissionModeTest.java @@ -0,0 +1,16 @@ +package com.alibaba.qwen.code.cli.transport; + +import org.junit.Assert; +import org.junit.Test; + +public class PermissionModeTest { + + @Test + public void shouldBeReturnQwenPermissionModeValue() { + Assert.assertEquals("default", PermissionMode.DEFAULT.getValue()); + Assert.assertEquals("plan", PermissionMode.PLAN.getValue()); + Assert.assertEquals("auto-edit", PermissionMode.AUTO_EDIT.getValue()); + Assert.assertEquals("yolo", PermissionMode.YOLO.getValue()); + } + +} From 2ef8b6f350c60a3cf3ea7c754fe256d68e6604ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B9=BE=E7=A6=BB?= Date: Tue, 23 Dec 2025 17:44:28 +0800 Subject: [PATCH 015/142] ProcessTransport stru init --- .../code/cli/transport/ProcessTransport.java | 5 +- .../code/cli/transport/TransportOptions.java | 120 ++++++++++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/ProcessTransport.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/ProcessTransport.java index 73584ef21..89f03f1b1 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/ProcessTransport.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/ProcessTransport.java @@ -2,8 +2,9 @@ package com.alibaba.qwen.code.cli.transport; public class ProcessTransport { Process process; + TransportOptions transportOptions; - public ProcessTransport(Process process) { - this.process = process; + public ProcessTransport(TransportOptions transportOptions) { + this.transportOptions = transportOptions; } } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java index cef5b1813..ca4cb83f9 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java @@ -20,4 +20,124 @@ public class TransportOptions { private List allowedTools; private String authType; private Boolean includePartialMessages; + + public String getPathToQwenExecutable() { + return pathToQwenExecutable; + } + + public void setPathToQwenExecutable(String pathToQwenExecutable) { + this.pathToQwenExecutable = pathToQwenExecutable; + } + + public String getCwd() { + return cwd; + } + + public void setCwd(String cwd) { + this.cwd = cwd; + } + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + + public PermissionMode getPermissionMode() { + return permissionMode; + } + + public void setPermissionMode(PermissionMode permissionMode) { + this.permissionMode = permissionMode; + } + + public Map getEnv() { + return env; + } + + public void setEnv(Map env) { + this.env = env; + } + + public Object getAbortController() { + return abortController; + } + + public void setAbortController(Object abortController) { + this.abortController = abortController; + } + + public Boolean getDebug() { + return debug; + } + + public void setDebug(Boolean debug) { + this.debug = debug; + } + + public Consumer getStderr() { + return stderr; + } + + public void setStderr(Consumer stderr) { + this.stderr = stderr; + } + + public String getLogLevel() { + return logLevel; + } + + public void setLogLevel(String logLevel) { + this.logLevel = logLevel; + } + + public Integer getMaxSessionTurns() { + return maxSessionTurns; + } + + public void setMaxSessionTurns(Integer maxSessionTurns) { + this.maxSessionTurns = maxSessionTurns; + } + + public List getCoreTools() { + return coreTools; + } + + public void setCoreTools(List coreTools) { + this.coreTools = coreTools; + } + + public List getExcludeTools() { + return excludeTools; + } + + public void setExcludeTools(List excludeTools) { + this.excludeTools = excludeTools; + } + + public List getAllowedTools() { + return allowedTools; + } + + public void setAllowedTools(List allowedTools) { + this.allowedTools = allowedTools; + } + + public String getAuthType() { + return authType; + } + + public void setAuthType(String authType) { + this.authType = authType; + } + + public Boolean getIncludePartialMessages() { + return includePartialMessages; + } + + public void setIncludePartialMessages(Boolean includePartialMessages) { + this.includePartialMessages = includePartialMessages; + } } From 24d11179d881022915f2fef908207419bb951ee3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B9=BE=E7=A6=BB?= Date: Tue, 23 Dec 2025 20:04:58 +0800 Subject: [PATCH 016/142] modify junit version to 5 and add org developers --- packages/sdk-java/pom.xml | 56 +++++++++++++++---- .../cli/transport/PermissionModeTest.java | 13 +++-- 2 files changed, 53 insertions(+), 16 deletions(-) diff --git a/packages/sdk-java/pom.xml b/packages/sdk-java/pom.xml index 0438427ce..0ec6bb888 100644 --- a/packages/sdk-java/pom.xml +++ b/packages/sdk-java/pom.xml @@ -25,21 +25,33 @@ 1.8 UTF-8 3.6.0 + 5.14.1 + 1.5.23 + + + + + org.junit + junit-bom + pom + ${junit5.version} + import + + + - - junit - junit - 4.13.2 - test - ch.qos.logback logback-classic - 1.5.23 - compile + ${logback-classic.version} + provided + + + org.junit.jupiter + junit-jupiter + test - @@ -60,6 +72,30 @@ - + + + Alibaba Group + https://github.com/alibaba + + + + skyfire + skyfire + gengwei.gw(at)alibaba-inc.com + + Developer + Designer + + +8 + https://github.com/gwinthis + + + + + + central + https://central.sonatype.com/repository/maven-snapshots/ + + diff --git a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/PermissionModeTest.java b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/PermissionModeTest.java index 31cf692fc..7707c5fda 100644 --- a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/PermissionModeTest.java +++ b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/PermissionModeTest.java @@ -1,16 +1,17 @@ package com.alibaba.qwen.code.cli.transport; -import org.junit.Assert; -import org.junit.Test; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; public class PermissionModeTest { @Test public void shouldBeReturnQwenPermissionModeValue() { - Assert.assertEquals("default", PermissionMode.DEFAULT.getValue()); - Assert.assertEquals("plan", PermissionMode.PLAN.getValue()); - Assert.assertEquals("auto-edit", PermissionMode.AUTO_EDIT.getValue()); - Assert.assertEquals("yolo", PermissionMode.YOLO.getValue()); + assertEquals("default", PermissionMode.DEFAULT.getValue()); + assertEquals("plan", PermissionMode.PLAN.getValue()); + assertEquals("auto-edit", PermissionMode.AUTO_EDIT.getValue()); + assertEquals("yolo", PermissionMode.YOLO.getValue()); } } From e09bb5f5c0fd7dfd03dde229f036b09946d30943 Mon Sep 17 00:00:00 2001 From: skyfire Date: Tue, 23 Dec 2025 20:14:11 +0800 Subject: [PATCH 017/142] modify junit version to 5 and add org developers --- packages/sdk-java/pom.xml | 56 +++++++++++++++---- .../cli/transport/PermissionModeTest.java | 13 +++-- 2 files changed, 53 insertions(+), 16 deletions(-) diff --git a/packages/sdk-java/pom.xml b/packages/sdk-java/pom.xml index 0438427ce..0ec6bb888 100644 --- a/packages/sdk-java/pom.xml +++ b/packages/sdk-java/pom.xml @@ -25,21 +25,33 @@ 1.8 UTF-8 3.6.0 + 5.14.1 + 1.5.23 + + + + + org.junit + junit-bom + pom + ${junit5.version} + import + + + - - junit - junit - 4.13.2 - test - ch.qos.logback logback-classic - 1.5.23 - compile + ${logback-classic.version} + provided + + + org.junit.jupiter + junit-jupiter + test - @@ -60,6 +72,30 @@ - + + + Alibaba Group + https://github.com/alibaba + + + + skyfire + skyfire + gengwei.gw(at)alibaba-inc.com + + Developer + Designer + + +8 + https://github.com/gwinthis + + + + + + central + https://central.sonatype.com/repository/maven-snapshots/ + + diff --git a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/PermissionModeTest.java b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/PermissionModeTest.java index 31cf692fc..7707c5fda 100644 --- a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/PermissionModeTest.java +++ b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/PermissionModeTest.java @@ -1,16 +1,17 @@ package com.alibaba.qwen.code.cli.transport; -import org.junit.Assert; -import org.junit.Test; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; public class PermissionModeTest { @Test public void shouldBeReturnQwenPermissionModeValue() { - Assert.assertEquals("default", PermissionMode.DEFAULT.getValue()); - Assert.assertEquals("plan", PermissionMode.PLAN.getValue()); - Assert.assertEquals("auto-edit", PermissionMode.AUTO_EDIT.getValue()); - Assert.assertEquals("yolo", PermissionMode.YOLO.getValue()); + assertEquals("default", PermissionMode.DEFAULT.getValue()); + assertEquals("plan", PermissionMode.PLAN.getValue()); + assertEquals("auto-edit", PermissionMode.AUTO_EDIT.getValue()); + assertEquals("yolo", PermissionMode.YOLO.getValue()); } } From 68628bf9520d450b7281ec40b319d9c133004d31 Mon Sep 17 00:00:00 2001 From: skyfire Date: Wed, 24 Dec 2025 20:45:17 +0800 Subject: [PATCH 018/142] add ProcessTransport --- packages/sdk-java/checkstyle.xml | 12 +- packages/sdk-java/pom.xml | 8 +- .../code/cli/transport/ProcessTransport.java | 10 - .../code/cli/transport/TransportOptions.java | 66 +++---- .../transport/process/ProcessTransport.java | 182 ++++++++++++++++++ .../process/TransportOptionsAdapter.java | 107 ++++++++++ .../process/ProcessTransportTest.java | 18 ++ 7 files changed, 347 insertions(+), 56 deletions(-) delete mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/ProcessTransport.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransport.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java create mode 100644 packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransportTest.java diff --git a/packages/sdk-java/checkstyle.xml b/packages/sdk-java/checkstyle.xml index fa316ec72..c67c1319f 100644 --- a/packages/sdk-java/checkstyle.xml +++ b/packages/sdk-java/checkstyle.xml @@ -96,12 +96,12 @@ - - - - - - + + + + + + diff --git a/packages/sdk-java/pom.xml b/packages/sdk-java/pom.xml index 0ec6bb888..1d1c7c25a 100644 --- a/packages/sdk-java/pom.xml +++ b/packages/sdk-java/pom.xml @@ -26,7 +26,7 @@ UTF-8 3.6.0 5.14.1 - 1.5.23 + 1.3.16 @@ -45,7 +45,11 @@ ch.qos.logback logback-classic ${logback-classic.version} - provided + + + org.apache.commons + commons-lang3 + 3.20.0 org.junit.jupiter diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/ProcessTransport.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/ProcessTransport.java deleted file mode 100644 index 89f03f1b1..000000000 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/ProcessTransport.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.alibaba.qwen.code.cli.transport; - -public class ProcessTransport { - Process process; - TransportOptions transportOptions; - - public ProcessTransport(TransportOptions transportOptions) { - this.transportOptions = transportOptions; - } -} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java index ca4cb83f9..b64df2ec6 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java @@ -2,24 +2,21 @@ package com.alibaba.qwen.code.cli.transport; import java.util.List; import java.util.Map; -import java.util.function.Consumer; -public class TransportOptions { +public class TransportOptions implements Cloneable { private String pathToQwenExecutable; private String cwd; private String model; private PermissionMode permissionMode; private Map env; - private Object abortController; // AbortController in JavaScript does not have a direct Java equivalent - private Boolean debug; - private Consumer stderr; // Equivalent to (message: string) => void - private String logLevel; // Can be 'debug', 'info', 'warn', or 'error' private Integer maxSessionTurns; private List coreTools; private List excludeTools; private List allowedTools; private String authType; private Boolean includePartialMessages; + private Long turnTimeoutMs; + private Long messageTimeoutMs; public String getPathToQwenExecutable() { return pathToQwenExecutable; @@ -61,38 +58,6 @@ public class TransportOptions { this.env = env; } - public Object getAbortController() { - return abortController; - } - - public void setAbortController(Object abortController) { - this.abortController = abortController; - } - - public Boolean getDebug() { - return debug; - } - - public void setDebug(Boolean debug) { - this.debug = debug; - } - - public Consumer getStderr() { - return stderr; - } - - public void setStderr(Consumer stderr) { - this.stderr = stderr; - } - - public String getLogLevel() { - return logLevel; - } - - public void setLogLevel(String logLevel) { - this.logLevel = logLevel; - } - public Integer getMaxSessionTurns() { return maxSessionTurns; } @@ -140,4 +105,29 @@ public class TransportOptions { public void setIncludePartialMessages(Boolean includePartialMessages) { this.includePartialMessages = includePartialMessages; } + + public Long getTurnTimeoutMs() { + return turnTimeoutMs; + } + + public void setTurnTimeoutMs(Long turnTimeoutMs) { + this.turnTimeoutMs = turnTimeoutMs; + } + + public Long getMessageTimeoutMs() { + return messageTimeoutMs; + } + + public void setMessageTimeoutMs(Long messageTimeoutMs) { + this.messageTimeoutMs = messageTimeoutMs; + } + + @Override + public TransportOptions clone() { + try { + return (TransportOptions) super.clone(); + } catch (CloneNotSupportedException e) { + throw new AssertionError(); + } + } } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransport.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransport.java new file mode 100644 index 000000000..3adc1072b --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransport.java @@ -0,0 +1,182 @@ +package com.alibaba.qwen.code.cli.transport.process; + +import com.alibaba.qwen.code.cli.transport.TransportOptions; + +import org.apache.commons.lang3.exception.ContextedRuntimeException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.lang.ProcessBuilder.Redirect; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Function; + +public class ProcessTransport { + private static final Logger log = LoggerFactory.getLogger(ProcessTransport.class); + TransportOptionsAdapter transportOptionsAdapter; + + protected final Long turnTimeoutMs; + protected final Long messageTimeoutMs; + + protected Process process; + protected BufferedWriter processInput; + protected BufferedReader processOutput; + protected BufferedReader processError; + + public ProcessTransport(TransportOptions transportOptions) throws IOException { + this.transportOptionsAdapter = new TransportOptionsAdapter(transportOptions); + turnTimeoutMs = transportOptionsAdapter.getHandledTransportOptions().getTurnTimeoutMs(); + messageTimeoutMs = transportOptionsAdapter.getHandledTransportOptions().getMessageTimeoutMs(); + start(); + } + + protected void start() throws IOException { + String[] commandArgs = transportOptionsAdapter.buildCommandArgs(); + log.debug("trans to command args: {}", transportOptionsAdapter); + + ProcessBuilder processBuilder = new ProcessBuilder(commandArgs) + .redirectOutput(Redirect.PIPE) + .redirectInput(Redirect.PIPE) + .redirectError(Redirect.PIPE) + .redirectErrorStream(false) + .directory(new File(transportOptionsAdapter.getCwd())); + + process = processBuilder.start(); + processInput = new BufferedWriter(new OutputStreamWriter(process.getOutputStream())); + processOutput = new BufferedReader(new InputStreamReader(process.getInputStream())); + processError = new BufferedReader(new InputStreamReader(process.getErrorStream())); + startErrorReading(); + } + + public void close() throws IOException { + if (processInput != null) { + processInput.close(); + } + if (processOutput != null) { + processOutput.close(); + } + if (processError != null) { + processError.close(); + } + if (process != null) { + process.destroy(); + } + } + + public String inputWaitForOneLine(String message) throws IOException, ExecutionException, InterruptedException, TimeoutException { + return inputWaitForOneLine(message, turnTimeoutMs); + } + + private String inputWaitForOneLine(String message, long timeOutInMs) + throws IOException, TimeoutException, InterruptedException, ExecutionException { + inputNoWaitResponse(message); + CompletableFuture future = CompletableFuture.supplyAsync(() -> { + try { + return processOutput.readLine(); + } catch (IOException e) { + throw new ContextedRuntimeException("read line error", e) + .addContextValue("message", message); + } + }); + + try { + String line = future.get(timeOutInMs, TimeUnit.MILLISECONDS); + log.info("inputWaitForOneLine result: {}", line); + return line; + } catch (TimeoutException e) { + future.cancel(true); + log.warn("read message timeout {}, canceled readOneLine task", timeOutInMs, e); + throw e; + } catch (InterruptedException e) { + future.cancel(true); + log.warn("interrupted task, canceled task", e); + throw e; + } catch (ExecutionException e) { + future.cancel(true); + log.warn("the readOneLine task execute error", e); + throw e; + } + } + + public void inputWaitForMultiLine(String message, Function callBackFunction) throws IOException { + inputWaitForMultiLine(message, callBackFunction, turnTimeoutMs); + } + + private void inputWaitForMultiLine(String message, Function callBackFunction, long timeOutInMs) throws IOException { + log.debug("input message for multiLine: {}", message); + inputNoWaitResponse(message); + + CompletableFuture future = CompletableFuture.runAsync(() -> iterateOutput(callBackFunction)); + try { + future.get(timeOutInMs, TimeUnit.MILLISECONDS); + } catch (TimeoutException e) { + future.cancel(true); + log.warn("read message timeout {}, canceled readMultiMessages task", timeOutInMs, e); + } catch (InterruptedException e) { + future.cancel(true); + log.warn("interrupted task, canceled task", e); + } catch (ExecutionException e) { + future.cancel(true); + log.warn("the readMultiMessages task execute error", e); + } catch (Exception e) { + future.cancel(true); + log.warn("other error"); + } + } + + public void inputNoWaitResponse(String message) throws IOException { + log.debug("input message to agent: {}", message); + processInput.write(message); + processInput.newLine(); + processInput.flush(); + } + + private void startErrorReading() { + CompletableFuture.runAsync(() -> { + try { + String line; + while ((line = processError.readLine()) != null) { + System.err.println("错误: " + line); + } + } catch (Exception e) { + System.err.println("错误: " + e.getMessage()); + } + }); + } + + private void iterateOutput(Function callBackFunction) { + CompletableFuture future = CompletableFuture.runAsync(() -> { + try { + for (String line = processOutput.readLine(); line != null; line = processOutput.readLine()) { + log.debug("read a message from agent {}", line); + if (callBackFunction.apply(line)) { + break; + } + } + } catch (IOException e) { + throw new RuntimeException("read process output error", e); + } + }); + + try { + future.get(messageTimeoutMs, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + log.warn("read message task interrupted", e); + future.cancel(true); + } catch (TimeoutException e) { + log.warn("Operation timed out", e); + future.cancel(true); + } catch (Exception e) { + future.cancel(true); + log.warn("Operation error", e); + } + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java new file mode 100644 index 000000000..e22f2fd27 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java @@ -0,0 +1,107 @@ +package com.alibaba.qwen.code.cli.transport.process; + +import com.alibaba.qwen.code.cli.transport.TransportOptions; + +import org.apache.commons.lang3.StringUtils; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +class TransportOptionsAdapter { + TransportOptions transportOptions; + private static final Long DEFAULT_TURN_TIMEOUT_MS = 1000 * 60 * 30L; + private static final Long DEFAULT_MESSAGE_TIMEOUT_MS = 1000 * 60 * 3L; + + TransportOptionsAdapter(TransportOptions userTransportOptions) { + transportOptions = addDefaultTransportOptions(userTransportOptions); + } + + TransportOptions getHandledTransportOptions() { + return transportOptions; + } + + String getCwd() { + return transportOptions.getCwd(); + } + + String[] buildCommandArgs() { + List args = new ArrayList<>( + Arrays.asList(transportOptions.getPathToQwenExecutable(), "--input-format", "stream-json", "--output-format", + "stream-json", "--channel=SDK")); + + if (StringUtils.isNotBlank(transportOptions.getModel())) { + args.add("--model"); + args.add(transportOptions.getModel()); + } + + if (StringUtils.isNotBlank(transportOptions.getCwd())) { + args.add("--cwd"); + args.add(transportOptions.getCwd()); + } + + if (transportOptions.getPermissionMode() != null) { + args.add("--permission-mode"); + args.add(transportOptions.getPermissionMode().getValue()); + } + + if (transportOptions.getMaxSessionTurns() != null) { + args.add("--max-session-turns"); + args.add(transportOptions.getMaxSessionTurns().toString()); + } + + if (transportOptions.getCoreTools() != null && !transportOptions.getCoreTools().isEmpty()) { + args.add("--core-tools"); + args.add(String.join(",", transportOptions.getCoreTools())); + } + + if (transportOptions.getExcludeTools() != null && !transportOptions.getExcludeTools().isEmpty()) { + args.add("--exclude-tools"); + args.add(String.join(",", transportOptions.getExcludeTools())); + } + + if (transportOptions.getAllowedTools() != null && !transportOptions.getAllowedTools().isEmpty()) { + args.add("--allowed-tools"); + args.add(String.join(",", transportOptions.getAllowedTools())); + } + + if (StringUtils.isNotBlank(transportOptions.getAuthType())) { + args.add("--auth-type"); + args.add(transportOptions.getAuthType()); + } + + if (transportOptions.getIncludePartialMessages() != null && transportOptions.getIncludePartialMessages()) { + args.add("--include-partial-messages"); + } + return args.toArray(new String[] {}); + } + + private TransportOptions addDefaultTransportOptions(TransportOptions userTransportOptions) { + TransportOptions transportOptions = userTransportOptions.clone(); + + if (StringUtils.isBlank(transportOptions.getPathToQwenExecutable())) { + transportOptions.setPathToQwenExecutable("qwen"); + } + + if (StringUtils.isBlank(transportOptions.getCwd())) { + transportOptions.setCwd(new File("").getAbsolutePath()); + } + + Map env = new HashMap<>(System.getenv()); + Optional.ofNullable(transportOptions.getEnv()).ifPresent(env::putAll); + transportOptions.setEnv(env); + + if (transportOptions.getTurnTimeoutMs() == null) { + transportOptions.setTurnTimeoutMs(DEFAULT_TURN_TIMEOUT_MS); + } + + if (transportOptions.getMessageTimeoutMs() == null) { + transportOptions.setMessageTimeoutMs(DEFAULT_MESSAGE_TIMEOUT_MS); + } + return transportOptions; + } +} diff --git a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransportTest.java b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransportTest.java new file mode 100644 index 000000000..af2a8d363 --- /dev/null +++ b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransportTest.java @@ -0,0 +1,18 @@ +package com.alibaba.qwen.code.cli.transport.process; + +import java.io.IOException; + +import com.alibaba.qwen.code.cli.transport.TransportOptions; + +import org.junit.jupiter.api.Test; + +class ProcessTransportTest { + + @Test + void shouldStartAndCloseSuccessfully() throws IOException { + TransportOptions transportOptions = new TransportOptions(); + ProcessTransport processTransport = new ProcessTransport(transportOptions); + processTransport.close(); + } + +} From 422998d7f080fdcb2045bbed80290196e11c6d8d Mon Sep 17 00:00:00 2001 From: skyfire Date: Wed, 24 Dec 2025 21:20:47 +0800 Subject: [PATCH 019/142] add ProcessTransport unitTest and fix bug --- .../transport/process/TransportOptionsAdapter.java | 5 ----- .../cli/transport/process/ProcessTransportTest.java | 11 +++++++++++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java index e22f2fd27..66113e8cd 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java @@ -39,11 +39,6 @@ class TransportOptionsAdapter { args.add(transportOptions.getModel()); } - if (StringUtils.isNotBlank(transportOptions.getCwd())) { - args.add("--cwd"); - args.add(transportOptions.getCwd()); - } - if (transportOptions.getPermissionMode() != null) { args.add("--permission-mode"); args.add(transportOptions.getPermissionMode().getValue()); diff --git a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransportTest.java b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransportTest.java index af2a8d363..bdedf3047 100644 --- a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransportTest.java +++ b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransportTest.java @@ -1,6 +1,8 @@ package com.alibaba.qwen.code.cli.transport.process; import java.io.IOException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; import com.alibaba.qwen.code.cli.transport.TransportOptions; @@ -15,4 +17,13 @@ class ProcessTransportTest { processTransport.close(); } + @Test + void shouldInputWaitForOneLineSuccessfully() throws IOException, ExecutionException, InterruptedException, TimeoutException { + TransportOptions transportOptions = new TransportOptions(); + ProcessTransport processTransport = new ProcessTransport(transportOptions); + + String message = "{\"type\": \"control_request\", \"request_id\": \"1\", \"request\": {\"subtype\": \"initialize\"} }"; + System.out.println(processTransport.inputWaitForOneLine(message)); + } + } From f24bda3d7b5b43f1ce67b4b50f8983cbdae851ae Mon Sep 17 00:00:00 2001 From: xwj02155382 Date: Fri, 26 Dec 2025 10:17:52 +0800 Subject: [PATCH 020/142] fix: resolve editor launch issue on macOS for subagent editing - Fixed ENOENT error when launching external editors (VS Code, etc.) - Added proper editor command mapping (vscode -> code, neovim -> nvim, etc.) - Implemented command existence check to find available editor executable - Supports both macOS and Windows platform-specific commands Fixes #1180 --- packages/cli/src/ui/hooks/useLaunchEditor.ts | 53 +++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/hooks/useLaunchEditor.ts b/packages/cli/src/ui/hooks/useLaunchEditor.ts index 85ee5ccf1..1e791291d 100644 --- a/packages/cli/src/ui/hooks/useLaunchEditor.ts +++ b/packages/cli/src/ui/hooks/useLaunchEditor.ts @@ -7,15 +7,64 @@ import { useCallback } from 'react'; import { useStdin } from 'ink'; import type { EditorType } from '@qwen-code/qwen-code-core'; -import { spawnSync } from 'child_process'; +import { spawnSync, execSync } from 'child_process'; import { useSettings } from '../contexts/SettingsContext.js'; +/** + * Editor command configurations for different platforms. + * Each editor can have multiple possible command names, listed in order of preference. + */ +const editorCommands: Record< + EditorType, + { win32: string[]; default: string[] } +> = { + vscode: { win32: ['code.cmd'], default: ['code'] }, + vscodium: { win32: ['codium.cmd'], default: ['codium'] }, + windsurf: { win32: ['windsurf'], default: ['windsurf'] }, + cursor: { win32: ['cursor'], default: ['cursor'] }, + vim: { win32: ['vim'], default: ['vim'] }, + neovim: { win32: ['nvim'], default: ['nvim'] }, + zed: { win32: ['zed'], default: ['zed', 'zeditor'] }, + emacs: { win32: ['emacs.exe'], default: ['emacs'] }, + trae: { win32: ['trae'], default: ['trae'] }, +}; + +/** + * Check if a command exists in the system. + */ +function commandExists(cmd: string): boolean { + try { + execSync( + process.platform === 'win32' ? `where.exe ${cmd}` : `command -v ${cmd}`, + { stdio: 'ignore' }, + ); + return true; + } catch { + return false; + } +} + +/** + * Get the actual executable command for an editor type. + */ +function getExecutableCommand(editorType: EditorType): string { + const commandConfig = editorCommands[editorType]; + const commands = + process.platform === 'win32' ? commandConfig.win32 : commandConfig.default; + + // Try to find the first available command + const availableCommand = commands.find((cmd) => commandExists(cmd)); + + // Return the first available command, or fall back to the last one in the list + return availableCommand || commands[commands.length - 1]; +} + /** * Determines the editor command to use based on user preferences and platform. */ function getEditorCommand(preferredEditor?: EditorType): string { if (preferredEditor) { - return preferredEditor; + return getExecutableCommand(preferredEditor); } // Platform-specific defaults with UI preference for macOS From e9204ecba9cbf38f871be30b3414f32ed690df44 Mon Sep 17 00:00:00 2001 From: xwj02155382 Date: Fri, 26 Dec 2025 10:17:52 +0800 Subject: [PATCH 021/142] fix: resolve editor launch issue on macOS for subagent editing - Fixed ENOENT error when launching external editors (VS Code, etc.) - Added proper editor command mapping (vscode -> code, neovim -> nvim, etc.) - Implemented command existence check to find available editor executable - Supports both macOS and Windows platform-specific commands Fixes #1180 --- packages/cli/src/ui/hooks/useLaunchEditor.ts | 59 +++++++++++++++++++- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/hooks/useLaunchEditor.ts b/packages/cli/src/ui/hooks/useLaunchEditor.ts index 85ee5ccf1..14e2f56e8 100644 --- a/packages/cli/src/ui/hooks/useLaunchEditor.ts +++ b/packages/cli/src/ui/hooks/useLaunchEditor.ts @@ -7,15 +7,64 @@ import { useCallback } from 'react'; import { useStdin } from 'ink'; import type { EditorType } from '@qwen-code/qwen-code-core'; -import { spawnSync } from 'child_process'; +import { spawnSync, execSync } from 'child_process'; import { useSettings } from '../contexts/SettingsContext.js'; +/** + * Editor command configurations for different platforms. + * Each editor can have multiple possible command names, listed in order of preference. + */ +const editorCommands: Record< + EditorType, + { win32: string[]; default: string[] } +> = { + vscode: { win32: ['code.cmd'], default: ['code'] }, + vscodium: { win32: ['codium.cmd'], default: ['codium'] }, + windsurf: { win32: ['windsurf'], default: ['windsurf'] }, + cursor: { win32: ['cursor'], default: ['cursor'] }, + vim: { win32: ['vim'], default: ['vim'] }, + neovim: { win32: ['nvim'], default: ['nvim'] }, + zed: { win32: ['zed'], default: ['zed', 'zeditor'] }, + emacs: { win32: ['emacs.exe'], default: ['emacs'] }, + trae: { win32: ['trae'], default: ['trae'] }, +}; + +/** + * Check if a command exists in the system. + */ +function commandExists(cmd: string): boolean { + try { + execSync( + process.platform === 'win32' ? `where.exe ${cmd}` : `command -v ${cmd}`, + { stdio: 'ignore' }, + ); + return true; + } catch { + return false; + } +} + +/** + * Get the actual executable command for an editor type. + */ +function getExecutableCommand(editorType: EditorType): string { + const commandConfig = editorCommands[editorType]; + const commands = + process.platform === 'win32' ? commandConfig.win32 : commandConfig.default; + + // Try to find the first available command + const availableCommand = commands.find((cmd) => commandExists(cmd)); + + // Return the first available command, or fall back to the last one in the list + return availableCommand || commands[commands.length - 1]; +} + /** * Determines the editor command to use based on user preferences and platform. */ function getEditorCommand(preferredEditor?: EditorType): string { if (preferredEditor) { - return preferredEditor; + return getExecutableCommand(preferredEditor); } // Platform-specific defaults with UI preference for macOS @@ -63,8 +112,14 @@ export function useLaunchEditor() { try { setRawMode?.(false); + // On Windows, .cmd and .bat files need shell: true + const needsShell = + process.platform === 'win32' && + (editorCommand.endsWith('.cmd') || editorCommand.endsWith('.bat')); + const { status, error } = spawnSync(editorCommand, editorArgs, { stdio: 'inherit', + shell: needsShell, }); if (error) throw error; From 48bc0f35d78e1c7230f6105cd66555ba4a66796e Mon Sep 17 00:00:00 2001 From: xwj02155382 Date: Fri, 26 Dec 2025 13:52:37 +0800 Subject: [PATCH 022/142] perf: add cache for commandExists to fix CI timeout - Add commandExistsCache Map to avoid repeated execSync calls - Cache command existence check results to improve test performance - Fix CI test timeout issue (was timing out after 7m) The commandExists() function was being called frequently during tests, causing slow test execution due to repeated system command calls. By caching the results, we significantly improve performance in test environments while maintaining the same functionality. --- packages/cli/src/ui/hooks/useLaunchEditor.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/cli/src/ui/hooks/useLaunchEditor.ts b/packages/cli/src/ui/hooks/useLaunchEditor.ts index 14e2f56e8..d3aeb1837 100644 --- a/packages/cli/src/ui/hooks/useLaunchEditor.ts +++ b/packages/cli/src/ui/hooks/useLaunchEditor.ts @@ -29,17 +29,29 @@ const editorCommands: Record< trae: { win32: ['trae'], default: ['trae'] }, }; +/** + * Cache for command existence checks to avoid repeated execSync calls. + */ +const commandExistsCache = new Map(); + /** * Check if a command exists in the system. + * Results are cached to improve performance in test environments. */ function commandExists(cmd: string): boolean { + if (commandExistsCache.has(cmd)) { + return commandExistsCache.get(cmd)!; + } + try { execSync( process.platform === 'win32' ? `where.exe ${cmd}` : `command -v ${cmd}`, { stdio: 'ignore' }, ); + commandExistsCache.set(cmd, true); return true; } catch { + commandExistsCache.set(cmd, false); return false; } } From fd41309ed288ef1d348f0f0a40f43b2b95654973 Mon Sep 17 00:00:00 2001 From: xwj02155382 Date: Fri, 26 Dec 2025 16:03:05 +0800 Subject: [PATCH 023/142] refactor: share editorCommands between core and cli packages - Export editorCommands from @qwen-code/qwen-code-core - Remove duplicate editorCommands definition in useLaunchEditor - Import shared editorCommands configuration in CLI package - Reduces code duplication and ensures consistency This change makes the editor configuration a single source of truth, making it easier to maintain and add new editors in the future. --- packages/cli/src/ui/hooks/useLaunchEditor.ts | 20 +------------------- packages/core/src/utils/editor.ts | 2 +- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/ui/hooks/useLaunchEditor.ts b/packages/cli/src/ui/hooks/useLaunchEditor.ts index d3aeb1837..f4c79e384 100644 --- a/packages/cli/src/ui/hooks/useLaunchEditor.ts +++ b/packages/cli/src/ui/hooks/useLaunchEditor.ts @@ -7,28 +7,10 @@ import { useCallback } from 'react'; import { useStdin } from 'ink'; import type { EditorType } from '@qwen-code/qwen-code-core'; +import { editorCommands } from '@qwen-code/qwen-code-core'; import { spawnSync, execSync } from 'child_process'; import { useSettings } from '../contexts/SettingsContext.js'; -/** - * Editor command configurations for different platforms. - * Each editor can have multiple possible command names, listed in order of preference. - */ -const editorCommands: Record< - EditorType, - { win32: string[]; default: string[] } -> = { - vscode: { win32: ['code.cmd'], default: ['code'] }, - vscodium: { win32: ['codium.cmd'], default: ['codium'] }, - windsurf: { win32: ['windsurf'], default: ['windsurf'] }, - cursor: { win32: ['cursor'], default: ['cursor'] }, - vim: { win32: ['vim'], default: ['vim'] }, - neovim: { win32: ['nvim'], default: ['nvim'] }, - zed: { win32: ['zed'], default: ['zed', 'zeditor'] }, - emacs: { win32: ['emacs.exe'], default: ['emacs'] }, - trae: { win32: ['trae'], default: ['trae'] }, -}; - /** * Cache for command existence checks to avoid repeated execSync calls. */ diff --git a/packages/core/src/utils/editor.ts b/packages/core/src/utils/editor.ts index b63289250..f64351ee5 100644 --- a/packages/core/src/utils/editor.ts +++ b/packages/core/src/utils/editor.ts @@ -52,7 +52,7 @@ function commandExists(cmd: string): boolean { * Editor command configurations for different platforms. * Each editor can have multiple possible command names, listed in order of preference. */ -const editorCommands: Record< +export const editorCommands: Record< EditorType, { win32: string[]; default: string[] } > = { From fe7ff5b148b26ba4044bc1a7d8dd5e9951767c41 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Fri, 26 Dec 2025 17:09:16 +0800 Subject: [PATCH 024/142] feat: stable-acp-flag --- .gitignore | 1 + docs/users/configuration/settings.md | 2 +- docs/users/integration-zed.md | 2 +- integration-tests/acp-integration.test.ts | 105 +++++++++++++++++- packages/cli/src/config/config.ts | 26 ++++- packages/cli/src/gemini.test.tsx | 1 + .../src/services/acpConnection.ts | 2 +- 7 files changed, 130 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index fac00d412..705216c80 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ package-lock.json .idea *.iml .cursor +.qoder # OS metadata .DS_Store diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 3e87985b8..9cf704dae 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -381,7 +381,7 @@ Arguments passed directly when running the CLI can override other configurations | `--telemetry-otlp-protocol` | | Sets the OTLP protocol for telemetry (`grpc` or `http`). | | Defaults to `grpc`. See [telemetry](../../developers/development/telemetry) for more information. | | `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. | | `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | | -| `--experimental-acp` | | Enables ACP mode (Agent Control Protocol). Useful for IDE/editor integrations like [Zed](../integration-zed). | | Experimental. | +| `--acp` | | Enables ACP mode (Agent Control Protocol). Useful for IDE/editor integrations like [Zed](../integration-zed). | | Stable. Replaces the deprecated `--experimental-acp` flag. | | `--experimental-skills` | | Enables experimental [Agent Skills](../features/skills) (registers the `skill` tool and loads Skills from `.qwen/skills/` and `~/.qwen/skills/`). | | Experimental. | | `--extensions` | `-e` | Specifies a list of extensions to use for the session. | Extension names | If not provided, all available extensions are used. Use the special term `qwen -e none` to disable all extensions. Example: `qwen -e my-extension -e my-other-extension` | | `--list-extensions` | `-l` | Lists all available extensions and exits. | | | diff --git a/docs/users/integration-zed.md b/docs/users/integration-zed.md index cd4cb2ae4..663e23e80 100644 --- a/docs/users/integration-zed.md +++ b/docs/users/integration-zed.md @@ -32,7 +32,7 @@ "Qwen Code": { "type": "custom", "command": "qwen", - "args": ["--experimental-acp"], + "args": ["--acp"], "env": {} } ``` diff --git a/integration-tests/acp-integration.test.ts b/integration-tests/acp-integration.test.ts index 31e32da76..b89292d87 100644 --- a/integration-tests/acp-integration.test.ts +++ b/integration-tests/acp-integration.test.ts @@ -80,10 +80,11 @@ type PermissionHandler = ( /** * Sets up an ACP test environment with all necessary utilities. + * @param useNewFlag - If true, uses --acp; if false, uses --experimental-acp (for backward compatibility testing) */ function setupAcpTest( rig: TestRig, - options?: { permissionHandler?: PermissionHandler }, + options?: { permissionHandler?: PermissionHandler; useNewFlag?: boolean }, ) { const pending = new Map(); let nextRequestId = 1; @@ -95,9 +96,13 @@ function setupAcpTest( const permissionHandler = options?.permissionHandler ?? (() => ({ optionId: 'proceed_once' })); + // Use --acp by default, but allow testing with --experimental-acp for backward compatibility + const acpFlag = + options?.useNewFlag !== false ? '--acp' : '--experimental-acp'; + const agent = spawn( 'node', - [rig.bundlePath, '--experimental-acp', '--no-chat-recording'], + [rig.bundlePath, acpFlag, '--no-chat-recording'], { cwd: rig.testDir!, stdio: ['pipe', 'pipe', 'pipe'], @@ -621,3 +626,99 @@ function setupAcpTest( } }); }); + +(IS_SANDBOX ? describe.skip : describe)( + 'acp flag backward compatibility', + () => { + it('should work with deprecated --experimental-acp flag and show warning', async () => { + const rig = new TestRig(); + rig.setup('acp backward compatibility'); + + const { sendRequest, cleanup, stderr } = setupAcpTest(rig, { + useNewFlag: false, + }); + + try { + const initResult = await sendRequest('initialize', { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + }, + }); + expect(initResult).toBeDefined(); + + // Verify deprecation warning is shown + const stderrOutput = stderr.join(''); + expect(stderrOutput).toContain('--experimental-acp is deprecated'); + expect(stderrOutput).toContain('Please use --acp instead'); + + await sendRequest('authenticate', { methodId: 'openai' }); + + const newSession = (await sendRequest('session/new', { + cwd: rig.testDir!, + mcpServers: [], + })) as { sessionId: string }; + expect(newSession.sessionId).toBeTruthy(); + + // Verify functionality still works + const promptResult = await sendRequest('session/prompt', { + sessionId: newSession.sessionId, + prompt: [{ type: 'text', text: 'Say hello.' }], + }); + expect(promptResult).toBeDefined(); + } catch (e) { + if (stderr.length) { + console.error('Agent stderr:', stderr.join('')); + } + throw e; + } finally { + await cleanup(); + } + }); + + it('should work with new --acp flag without warnings', async () => { + const rig = new TestRig(); + rig.setup('acp new flag'); + + const { sendRequest, cleanup, stderr } = setupAcpTest(rig, { + useNewFlag: true, + }); + + try { + const initResult = await sendRequest('initialize', { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + }, + }); + expect(initResult).toBeDefined(); + + // Verify no deprecation warning is shown + const stderrOutput = stderr.join(''); + expect(stderrOutput).not.toContain('--experimental-acp is deprecated'); + + await sendRequest('authenticate', { methodId: 'openai' }); + + const newSession = (await sendRequest('session/new', { + cwd: rig.testDir!, + mcpServers: [], + })) as { sessionId: string }; + expect(newSession.sessionId).toBeTruthy(); + + // Verify functionality works + const promptResult = await sendRequest('session/prompt', { + sessionId: newSession.sessionId, + prompt: [{ type: 'text', text: 'Say hello.' }], + }); + expect(promptResult).toBeDefined(); + } catch (e) { + if (stderr.length) { + console.error('Agent stderr:', stderr.join('')); + } + throw e; + } finally { + await cleanup(); + } + }); + }, +); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 7cd7d685a..e45ec2a3b 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -111,6 +111,7 @@ export interface CliArgs { telemetryOutfile: string | undefined; allowedMcpServerNames: string[] | undefined; allowedTools: string[] | undefined; + acp: boolean | undefined; experimentalAcp: boolean | undefined; experimentalSkills: boolean | undefined; extensions: string[] | undefined; @@ -304,10 +305,16 @@ export async function parseArguments(settings: Settings): Promise { description: 'Enables checkpointing of file edits', default: false, }) - .option('experimental-acp', { + .option('acp', { type: 'boolean', description: 'Starts the agent in ACP mode', }) + .option('experimental-acp', { + type: 'boolean', + description: + 'Starts the agent in ACP mode (deprecated, use --acp instead)', + hidden: true, + }) .option('experimental-skills', { type: 'boolean', description: 'Enable experimental Skills feature', @@ -589,8 +596,19 @@ export async function parseArguments(settings: Settings): Promise { // The import format is now only controlled by settings.memoryImportFormat // We no longer accept it as a CLI argument - // Apply ACP fallback: if experimental-acp is present but no explicit --channel, treat as ACP - if (result['experimentalAcp'] && !result['channel']) { + // Handle deprecated --experimental-acp flag + if (result['experimentalAcp']) { + console.warn( + '\x1b[33m⚠ Warning: --experimental-acp is deprecated and will be removed in a future release. Please use --acp instead.\x1b[0m', + ); + // Map experimental-acp to acp if acp is not explicitly set + if (!result['acp']) { + (result as Record)['acp'] = true; + } + } + + // Apply ACP fallback: if acp or experimental-acp is present but no explicit --channel, treat as ACP + if ((result['acp'] || result['experimentalAcp']) && !result['channel']) { (result as Record)['channel'] = 'ACP'; } @@ -981,7 +999,7 @@ export async function loadCliConfig( sessionTokenLimit: settings.model?.sessionTokenLimit ?? -1, maxSessionTurns: argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1, - experimentalZedIntegration: argv.experimentalAcp || false, + experimentalZedIntegration: argv.acp || argv.experimentalAcp || false, experimentalSkills: argv.experimentalSkills || false, listExtensions: argv.listExtensions || false, extensions: allExtensions, diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 9fa0b8261..064b67fc5 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -460,6 +460,7 @@ describe('gemini.tsx main function kitty protocol', () => { telemetryOutfile: undefined, allowedMcpServerNames: undefined, allowedTools: undefined, + acp: undefined, experimentalAcp: undefined, experimentalSkills: undefined, extensions: undefined, diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts index 4b2c4028b..219f7809f 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -95,7 +95,7 @@ export class AcpConnection { const spawnCommand: string = process.execPath; const spawnArgs: string[] = [ cliEntryPath, - '--experimental-acp', + '--acp', '--channel=VSCode', ...extraArgs, ]; From f610133660f2ff6d2c5006bf30bbe2108eca1d29 Mon Sep 17 00:00:00 2001 From: cris Date: Sun, 28 Dec 2025 22:14:16 +0800 Subject: [PATCH 025/142] improve ad hoc method for windows background terminal task --- .../src/services/shellExecutionService.ts | 2 +- packages/core/src/tools/shell.ts | 89 ++++++++----------- 2 files changed, 37 insertions(+), 54 deletions(-) diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index 853a4c89f..c870b5f4e 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -229,7 +229,7 @@ export class ShellExecutionService { stdio: ['ignore', 'pipe', 'pipe'], windowsVerbatimArguments: true, shell: isWindows ? true : 'bash', - detached: !isWindows, + detached: true, env: { ...process.env, QWEN_CODE: '1', diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index c223d0e5f..b4cbb195b 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -30,7 +30,6 @@ import { summarizeToolOutput } from '../utils/summarizer.js'; import type { ShellExecutionConfig, ShellOutputEvent, - ShellExecutionResult, } from '../services/shellExecutionService.js'; import { ShellExecutionService } from '../services/shellExecutionService.js'; import { formatMemoryUsage } from '../utils/formatters.js'; @@ -159,12 +158,7 @@ export class ShellToolInvocation extends BaseToolInvocation< // On Windows, we rely on the race logic below to handle background tasks. // We just ensure the command string is clean. if (isWindows && shouldRunInBackground) { - let cmd = finalCommand.trim(); - // Remove trailing & (common Linux habit, invalid on Windows at end of line) - while (cmd.endsWith('&')) { - cmd = cmd.slice(0, -1).trim(); - } - finalCommand = cmd; + finalCommand = finalCommand.trim().replace(/&+$/, '').trim(); } // pgrep is not available on Windows, so we can't get background PIDs @@ -234,60 +228,49 @@ export class ShellToolInvocation extends BaseToolInvocation< setPidCallback(pid); } - let result: ShellExecutionResult; - if (shouldRunInBackground && isWindows) { - // For Windows background tasks, we wait a short time to catch immediate errors. - // If it's still running, we return early. - const startupDelay = 1000; - const raceResult = await Promise.race([ - resultPromise, - new Promise((resolve) => - setTimeout(() => resolve(null), startupDelay), - ), - ]); + if (shouldRunInBackground) { + // Check for obvious startup errors from captured output + const outputStr = + typeof cumulativeOutput === 'string' + ? cumulativeOutput + : JSON.stringify(cumulativeOutput); - if (raceResult === null) { - // Timeout reached, process is still running. - // throw new Error(`DEBUG: raceResult is null. Output: ${JSON.stringify(cumulativeOutput)}`); + const errorPatterns = [ + 'is not recognized as an internal or external command', + 'The system cannot find the path specified', + 'Access is denied', + 'command not found', + 'No such file or directory', + 'Permission denied', + ]; - // Check for common Windows error messages in the output - const outputStr = - typeof cumulativeOutput === 'string' - ? cumulativeOutput - : JSON.stringify(cumulativeOutput); - console.log('DEBUG: outputStr:', outputStr); - const errorPatterns = [ - 'is not recognized as an internal or external command', - 'The system cannot find the path specified', - 'Access is denied', - ]; + const hasEarlyError = errorPatterns.some((pat) => + outputStr.includes(pat), + ); - if (errorPatterns.some((pattern) => outputStr.includes(pattern))) { - return { - llmContent: `Command failed to start: ${outputStr}`, - returnDisplay: `Command failed to start: ${outputStr}`, - error: { - type: ToolErrorType.EXECUTION_FAILED, - message: `Command failed to start: ${outputStr}`, - }, - }; - } - - const pidMsg = pid ? ` PID: ${pid}` : ''; - const winHint = isWindows - ? ' (Note: Use taskkill /F /T /PID to stop)' - : ''; + if (hasEarlyError) { return { - llmContent: `Background command started.${pidMsg}${winHint}`, - returnDisplay: `Background command started.${pidMsg}${winHint}`, + llmContent: `Command failed to start: ${outputStr}`, + returnDisplay: `Command failed to start: ${outputStr}`, + error: { + type: ToolErrorType.EXECUTION_FAILED, + message: `Command failed to start: ${outputStr}`, + }, }; - } else { - result = raceResult; } - } else { - result = await resultPromise; + + const pidMsg = pid ? ` PID: ${pid}` : ''; + const winHint = isWindows + ? ' (Note: Use taskkill /F /T /PID to stop)' + : ''; + return { + llmContent: `Background command started.${pidMsg}${winHint}`, + returnDisplay: `Background command started.${pidMsg}${winHint}`, + }; } + const result = await resultPromise; + const backgroundPIDs: number[] = []; if (os.platform() !== 'win32') { if (fs.existsSync(tempFilePath)) { From 98c043bf50a969d8d4b4d46fff4878149a9c9a0e Mon Sep 17 00:00:00 2001 From: xuewenjie Date: Mon, 29 Dec 2025 11:37:54 +0800 Subject: [PATCH 026/142] test: update tests for detached process changes --- .../services/shellExecutionService.test.ts | 2 +- packages/core/src/tools/shell.test.ts | 27 ------------------- 2 files changed, 1 insertion(+), 28 deletions(-) diff --git a/packages/core/src/services/shellExecutionService.test.ts b/packages/core/src/services/shellExecutionService.test.ts index c5a6e0776..e63fba28d 100644 --- a/packages/core/src/services/shellExecutionService.test.ts +++ b/packages/core/src/services/shellExecutionService.test.ts @@ -829,7 +829,7 @@ describe('ShellExecutionService child_process fallback', () => { [], expect.objectContaining({ shell: true, - detached: false, + detached: true, }), ); }); diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index eb8a17418..8db33b563 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -960,32 +960,5 @@ spanning multiple lines"`; {}, ); }); - - it('should detect immediate failure in Windows background task', async () => { - vi.mocked(os.platform).mockReturnValue('win32'); - const mockAbortSignal = new AbortController().signal; - - const invocation = shellTool.build({ - command: 'invalid_command', - is_background: true, - }); - - const promise = invocation.execute(mockAbortSignal); - - // Wait a tick to ensure mockShellOutputCallback is assigned - await new Promise((resolve) => setTimeout(resolve, 0)); - - if (mockShellOutputCallback) { - mockShellOutputCallback({ - type: 'data', - chunk: - "'invalid_command' is not recognized as an internal or external command,\r\noperable program or batch file.\r\n", - }); - } - - const result = await promise; - expect(result.error).toBeDefined(); - expect(result.llmContent).toContain('Command failed to start'); - }); }); }); From 61aad5a16253167d05d96ec42e18d420766614a7 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Mon, 29 Dec 2025 16:59:09 +0800 Subject: [PATCH 027/142] fix: missing whitespaces for stream-json/json output format via GLM 4.7 model --- .../io/BaseJsonOutputAdapter.test.ts | 61 ++++++++++++++++++ .../io/BaseJsonOutputAdapter.ts | 15 ++++- .../io/StreamJsonOutputAdapter.test.ts | 62 +++++++++++++++++++ 3 files changed, 135 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts index 0ba94cbb2..be04b7f2b 100644 --- a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts +++ b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts @@ -630,6 +630,67 @@ describe('BaseJsonOutputAdapter', () => { expect(state.blocks).toHaveLength(0); }); + + it('should preserve whitespace in thinking content', () => { + const state = adapter.exposeCreateMessageState(); + adapter.startAssistantMessage(); + + adapter.exposeAppendThinking( + state, + '', + 'The user just said "Hello"', + null, + ); + + expect(state.blocks).toHaveLength(1); + expect(state.blocks[0]).toMatchObject({ + type: 'thinking', + thinking: 'The user just said "Hello"', + }); + // Verify spaces are preserved + const block = state.blocks[0] as { thinking: string }; + expect(block.thinking).toContain('user just'); + expect(block.thinking).not.toContain('userjust'); + }); + + it('should preserve whitespace when appending multiple thinking fragments', () => { + const state = adapter.exposeCreateMessageState(); + adapter.startAssistantMessage(); + + // Simulate streaming thinking content in fragments + adapter.exposeAppendThinking(state, '', 'The user just', null); + adapter.exposeAppendThinking(state, '', ' said "Hello"', null); + adapter.exposeAppendThinking( + state, + '', + '. This is a simple greeting', + null, + ); + + expect(state.blocks).toHaveLength(1); + const block = state.blocks[0] as { thinking: string }; + // Verify the complete text with all spaces preserved + expect(block.thinking).toBe( + 'The user just said "Hello". This is a simple greeting', + ); + // Verify specific space preservation + expect(block.thinking).toContain('user just '); + expect(block.thinking).toContain(' said'); + expect(block.thinking).toContain('". This'); + expect(block.thinking).not.toContain('userjust'); + expect(block.thinking).not.toContain('justsaid'); + }); + + it('should preserve leading and trailing whitespace in description', () => { + const state = adapter.exposeCreateMessageState(); + adapter.startAssistantMessage(); + + adapter.exposeAppendThinking(state, '', ' content with spaces ', null); + + expect(state.blocks).toHaveLength(1); + const block = state.blocks[0] as { thinking: string }; + expect(block.thinking).toBe(' content with spaces '); + }); }); describe('appendToolUse', () => { diff --git a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts index ef6655370..072497000 100644 --- a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts +++ b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts @@ -816,9 +816,18 @@ export abstract class BaseJsonOutputAdapter { parentToolUseId?: string | null, ): void { const actualParentToolUseId = parentToolUseId ?? null; - const fragment = [subject?.trim(), description?.trim()] - .filter((value) => value && value.length > 0) - .join(': '); + + // Build fragment without trimming to preserve whitespace in streaming content + // Only filter out null/undefined/empty values + const parts: string[] = []; + if (subject && subject.length > 0) { + parts.push(subject); + } + if (description && description.length > 0) { + parts.push(description); + } + + const fragment = parts.join(': '); if (!fragment) { return; } diff --git a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts index d0bd23255..ff3aa1f5d 100644 --- a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts +++ b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts @@ -323,6 +323,68 @@ describe('StreamJsonOutputAdapter', () => { }); }); + it('should preserve whitespace in thinking content (issue #1356)', () => { + adapter.processEvent({ + type: GeminiEventType.Thought, + value: { + subject: '', + description: 'The user just said "Hello"', + }, + }); + + const message = adapter.finalizeAssistantMessage(); + expect(message.message.content).toHaveLength(1); + const block = message.message.content[0] as { + type: string; + thinking: string; + }; + expect(block.type).toBe('thinking'); + expect(block.thinking).toBe('The user just said "Hello"'); + // Verify spaces are preserved + expect(block.thinking).toContain('user just'); + expect(block.thinking).not.toContain('userjust'); + }); + + it('should preserve whitespace when streaming multiple thinking fragments (issue #1356)', () => { + // Simulate streaming thinking content in multiple events + adapter.processEvent({ + type: GeminiEventType.Thought, + value: { + subject: '', + description: 'The user just', + }, + }); + adapter.processEvent({ + type: GeminiEventType.Thought, + value: { + subject: '', + description: ' said "Hello"', + }, + }); + adapter.processEvent({ + type: GeminiEventType.Thought, + value: { + subject: '', + description: '. This is a simple greeting', + }, + }); + + const message = adapter.finalizeAssistantMessage(); + expect(message.message.content).toHaveLength(1); + const block = message.message.content[0] as { + type: string; + thinking: string; + }; + expect(block.thinking).toBe( + 'The user just said "Hello". This is a simple greeting', + ); + // Verify specific spaces are preserved + expect(block.thinking).toContain('user just '); + expect(block.thinking).toContain(' said'); + expect(block.thinking).not.toContain('userjust'); + expect(block.thinking).not.toContain('justsaid'); + }); + it('should append tool use from ToolCallRequest events', () => { adapter.processEvent({ type: GeminiEventType.ToolCallRequest, From 4154493640559ffbcc052ba5ef65347c3ebf0f2c Mon Sep 17 00:00:00 2001 From: skyfire Date: Mon, 29 Dec 2025 21:44:02 +0800 Subject: [PATCH 028/142] message and session use --- packages/sdk-java/pom.xml | 6 + .../com/alibaba/qwen/code/cli/Options.java | 6 + .../com/alibaba/qwen/code/cli/QwenCli.java | 54 ++ .../protocol/data/CLIPermissionDenial.java | 38 ++ .../code/cli/protocol/data/Capabilities.java | 60 ++ .../code/cli/protocol/data/ExtendedUsage.java | 67 ++ .../cli/protocol/data/InitializeConfig.java | 40 ++ .../code/cli/protocol/data/ModelUsage.java | 58 ++ .../qwen/code/cli/protocol/data/Usage.java | 56 ++ .../code/cli/protocol/message/Message.java | 5 + .../cli/protocol/message/MessageBase.java | 22 + .../protocol/message/SDKResultMessage.java | 151 +++++ .../protocol/message/SDKSystemMessage.java | 213 +++++++ .../cli/protocol/message/SDKUserMessage.java | 90 +++ .../assistant/APIAssistantMessage.java | 76 +++ .../assistant/SDKAssistantMessage.java | 49 ++ .../message/assistant/block/Annotation.java | 28 + .../message/assistant/block/ContentBlock.java | 32 + .../message/assistant/block/TextBlock.java | 16 + .../assistant/block/ThinkingBlock.java | 25 + .../assistant/block/ToolResultBlock.java | 40 ++ .../message/assistant/block/ToolUseBlock.java | 49 ++ .../control/CLIControlInitializeRequest.java | 28 + .../control/CLIControlInitializeResponse.java | 24 + .../message/control/CLIControlRequest.java | 44 ++ .../message/control/CLIControlResponse.java | 54 ++ .../qwen/code/cli/protocol/protocol.ts | 594 ++++++++++++++++++ .../qwen/code/cli/session/Session.java | 89 +++ .../session/event/SessionEventConsumers.java | 15 + .../event/SessionEventSimpleConsumers.java | 23 + .../exception/SessionCloseException.java | 22 + .../exception/SessionSendPromptException.java | 22 + .../exception/SessionStartException.java | 22 + .../qwen/code/cli/transport/Transport.java | 18 + .../transport/process/ProcessTransport.java | 16 +- .../process/TransportOptionsAdapter.java | 4 +- .../alibaba/qwen/code/cli/QwenCliTest.java | 23 + .../qwen/code/cli/session/SessionTest.java | 58 ++ .../process/ProcessTransportTest.java | 65 +- 39 files changed, 2296 insertions(+), 6 deletions(-) create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/Options.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCli.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/CLIPermissionDenial.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Capabilities.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ExtendedUsage.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/InitializeConfig.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ModelUsage.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Usage.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/Message.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/MessageBase.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKResultMessage.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKSystemMessage.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKUserMessage.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/APIAssistantMessage.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKAssistantMessage.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/Annotation.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ContentBlock.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/TextBlock.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ThinkingBlock.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolResultBlock.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolUseBlock.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInitializeRequest.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInitializeResponse.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlRequest.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlResponse.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/protocol.ts create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/Session.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventConsumers.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventSimpleConsumers.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionCloseException.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionSendPromptException.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionStartException.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/Transport.java create mode 100644 packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/QwenCliTest.java create mode 100644 packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java diff --git a/packages/sdk-java/pom.xml b/packages/sdk-java/pom.xml index 1d1c7c25a..45c9ea895 100644 --- a/packages/sdk-java/pom.xml +++ b/packages/sdk-java/pom.xml @@ -27,6 +27,7 @@ 3.6.0 5.14.1 1.3.16 + 2.0.60 @@ -51,6 +52,11 @@ commons-lang3 3.20.0 + + com.alibaba.fastjson2 + fastjson2 + ${fastjson2.version} + org.junit.jupiter junit-jupiter diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/Options.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/Options.java new file mode 100644 index 000000000..82b0a4652 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/Options.java @@ -0,0 +1,6 @@ +package com.alibaba.qwen.code.cli; + +import com.alibaba.qwen.code.cli.transport.TransportOptions; + +public class Options extends TransportOptions { +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCli.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCli.java new file mode 100644 index 000000000..065ff9e73 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCli.java @@ -0,0 +1,54 @@ +package com.alibaba.qwen.code.cli; + +import java.util.ArrayList; +import java.util.List; + +import com.alibaba.qwen.code.cli.protocol.message.Message; +import com.alibaba.qwen.code.cli.protocol.message.SDKSystemMessage; +import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKAssistantMessage; +import com.alibaba.qwen.code.cli.session.Session; +import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; +import com.alibaba.qwen.code.cli.transport.Transport; +import com.alibaba.qwen.code.cli.transport.process.ProcessTransport; + +public class QwenCli { + public static List query(String prompt) { + Transport transport; + try { + transport = new ProcessTransport(); + } catch (Exception e) { + throw new RuntimeException("initialized ProcessTransport error!", e); + } + + Session session; + try { + session = new Session(transport); + } catch (Exception e) { + throw new RuntimeException("initialized Session error!", e); + } + + final List response = new ArrayList<>(); + try { + session.sendPrompt(prompt, new SessionEventSimpleConsumers() { + @Override + public void onSystemMessage(SDKSystemMessage systemMessage) { + response.add(systemMessage); + } + + @Override + public void onAssistantMessage(SDKAssistantMessage assistantMessage) { + response.add(assistantMessage); + } + }); + } catch (Exception e) { + throw new RuntimeException("sendPrompt error!", e); + } + + try { + session.close(); + } catch (Exception e) { + throw new RuntimeException("close Session error!", e); + } + return response; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/CLIPermissionDenial.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/CLIPermissionDenial.java new file mode 100644 index 000000000..4abd68bc3 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/CLIPermissionDenial.java @@ -0,0 +1,38 @@ +package com.alibaba.qwen.code.cli.protocol.data; + +import com.alibaba.fastjson2.annotation.JSONField; + +public class CLIPermissionDenial { + @JSONField(name = "tool_name") + private String toolName; + + @JSONField(name = "tool_use_id") + private String toolUseId; + + @JSONField(name = "tool_input") + private Object toolInput; + + public String getToolName() { + return toolName; + } + + public void setToolName(String toolName) { + this.toolName = toolName; + } + + public String getToolUseId() { + return toolUseId; + } + + public void setToolUseId(String toolUseId) { + this.toolUseId = toolUseId; + } + + public Object getToolInput() { + return toolInput; + } + + public void setToolInput(Object toolInput) { + this.toolInput = toolInput; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Capabilities.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Capabilities.java new file mode 100644 index 000000000..13200b654 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Capabilities.java @@ -0,0 +1,60 @@ +package com.alibaba.qwen.code.cli.protocol.data; + +import com.alibaba.fastjson2.annotation.JSONField; + +public class Capabilities { + @JSONField(name = "can_handle_can_use_tool") + boolean canHandleCanUseTool; + + @JSONField(name = "can_handle_hook_callback") + boolean canHandleHookCallback; + + @JSONField(name = "can_set_permission_mode") + boolean canSetPermissionMode; + + @JSONField(name = "can_set_model") + boolean canSetModel; + + @JSONField(name = "can_handle_mcp_message") + boolean canHandleMcpMessage; + + public boolean isCanHandleCanUseTool() { + return canHandleCanUseTool; + } + + public void setCanHandleCanUseTool(boolean canHandleCanUseTool) { + this.canHandleCanUseTool = canHandleCanUseTool; + } + + public boolean isCanHandleHookCallback() { + return canHandleHookCallback; + } + + public void setCanHandleHookCallback(boolean canHandleHookCallback) { + this.canHandleHookCallback = canHandleHookCallback; + } + + public boolean isCanSetPermissionMode() { + return canSetPermissionMode; + } + + public void setCanSetPermissionMode(boolean canSetPermissionMode) { + this.canSetPermissionMode = canSetPermissionMode; + } + + public boolean isCanSetModel() { + return canSetModel; + } + + public void setCanSetModel(boolean canSetModel) { + this.canSetModel = canSetModel; + } + + public boolean isCanHandleMcpMessage() { + return canHandleMcpMessage; + } + + public void setCanHandleMcpMessage(boolean canHandleMcpMessage) { + this.canHandleMcpMessage = canHandleMcpMessage; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ExtendedUsage.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ExtendedUsage.java new file mode 100644 index 000000000..4965f4b8c --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ExtendedUsage.java @@ -0,0 +1,67 @@ +package com.alibaba.qwen.code.cli.protocol.data; + +import com.alibaba.fastjson2.annotation.JSONField; + +public class ExtendedUsage extends Usage { + @JSONField(name = "server_tool_use") + private ServerToolUse serverToolUse; + + @JSONField(name = "service_tier") + private String serviceTier; + + @JSONField(name = "cache_creation") + private CacheCreation cacheCreation; + + public ServerToolUse getServerToolUse() { + return serverToolUse; + } + + public void setServerToolUse(ServerToolUse serverToolUse) { + this.serverToolUse = serverToolUse; + } + + public String getServiceTier() { + return serviceTier; + } + + public void setServiceTier(String serviceTier) { + this.serviceTier = serviceTier; + } + + public CacheCreation getCacheCreation() { + return cacheCreation; + } + + public void setCacheCreation(CacheCreation cacheCreation) { + this.cacheCreation = cacheCreation; + } + + public static class ServerToolUse { + @JSONField(name = "web_search_requests") + private int webSearchRequests; + } + + public static class CacheCreation { + @JSONField(name = "ephemeral_1h_input_tokens") + private int ephemeral1hInputTokens; + + @JSONField(name = "ephemeral_5m_input_tokens") + private int ephemeral5mInputTokens; + + public int getEphemeral1hInputTokens() { + return ephemeral1hInputTokens; + } + + public void setEphemeral1hInputTokens(int ephemeral1hInputTokens) { + this.ephemeral1hInputTokens = ephemeral1hInputTokens; + } + + public int getEphemeral5mInputTokens() { + return ephemeral5mInputTokens; + } + + public void setEphemeral5mInputTokens(int ephemeral5mInputTokens) { + this.ephemeral5mInputTokens = ephemeral5mInputTokens; + } + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/InitializeConfig.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/InitializeConfig.java new file mode 100644 index 000000000..ccafed4f0 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/InitializeConfig.java @@ -0,0 +1,40 @@ +package com.alibaba.qwen.code.cli.protocol.data; + +public class InitializeConfig { + String hooks; + String sdkMcpServers; + String mcpServers; + String agents; + + public String getHooks() { + return hooks; + } + + public void setHooks(String hooks) { + this.hooks = hooks; + } + + public String getSdkMcpServers() { + return sdkMcpServers; + } + + public void setSdkMcpServers(String sdkMcpServers) { + this.sdkMcpServers = sdkMcpServers; + } + + public String getMcpServers() { + return mcpServers; + } + + public void setMcpServers(String mcpServers) { + this.mcpServers = mcpServers; + } + + public String getAgents() { + return agents; + } + + public void setAgents(String agents) { + this.agents = agents; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ModelUsage.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ModelUsage.java new file mode 100644 index 000000000..22787f232 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ModelUsage.java @@ -0,0 +1,58 @@ +package com.alibaba.qwen.code.cli.protocol.data; + +public class ModelUsage { + private int inputTokens; + private int outputTokens; + private int cacheReadInputTokens; + private int cacheCreationInputTokens; + private int webSearchRequests; + private int contextWindow; + + public int getInputTokens() { + return inputTokens; + } + + public void setInputTokens(int inputTokens) { + this.inputTokens = inputTokens; + } + + public int getOutputTokens() { + return outputTokens; + } + + public void setOutputTokens(int outputTokens) { + this.outputTokens = outputTokens; + } + + public int getCacheReadInputTokens() { + return cacheReadInputTokens; + } + + public void setCacheReadInputTokens(int cacheReadInputTokens) { + this.cacheReadInputTokens = cacheReadInputTokens; + } + + public int getCacheCreationInputTokens() { + return cacheCreationInputTokens; + } + + public void setCacheCreationInputTokens(int cacheCreationInputTokens) { + this.cacheCreationInputTokens = cacheCreationInputTokens; + } + + public int getWebSearchRequests() { + return webSearchRequests; + } + + public void setWebSearchRequests(int webSearchRequests) { + this.webSearchRequests = webSearchRequests; + } + + public int getContextWindow() { + return contextWindow; + } + + public void setContextWindow(int contextWindow) { + this.contextWindow = contextWindow; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Usage.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Usage.java new file mode 100644 index 000000000..1222b16f2 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Usage.java @@ -0,0 +1,56 @@ +package com.alibaba.qwen.code.cli.protocol.data; + +import com.alibaba.fastjson2.annotation.JSONField; + +public class Usage { + @JSONField(name = "input_tokens") + private Integer inputTokens; + @JSONField(name = "output_tokens") + private Integer outputTokens; + @JSONField(name = "cache_creation_input_tokens") + private Integer cacheCreationInputTokens; + @JSONField(name = "cache_read_input_tokens") + private Integer cacheReadInputTokens; + @JSONField(name = "total_tokens") + private Integer totalTokens; + + public Integer getInputTokens() { + return inputTokens; + } + + public void setInputTokens(Integer inputTokens) { + this.inputTokens = inputTokens; + } + + public Integer getOutputTokens() { + return outputTokens; + } + + public void setOutputTokens(Integer outputTokens) { + this.outputTokens = outputTokens; + } + + public Integer getCacheCreationInputTokens() { + return cacheCreationInputTokens; + } + + public void setCacheCreationInputTokens(Integer cacheCreationInputTokens) { + this.cacheCreationInputTokens = cacheCreationInputTokens; + } + + public Integer getCacheReadInputTokens() { + return cacheReadInputTokens; + } + + public void setCacheReadInputTokens(Integer cacheReadInputTokens) { + this.cacheReadInputTokens = cacheReadInputTokens; + } + + public Integer getTotalTokens() { + return totalTokens; + } + + public void setTotalTokens(Integer totalTokens) { + this.totalTokens = totalTokens; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/Message.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/Message.java new file mode 100644 index 000000000..de43924df --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/Message.java @@ -0,0 +1,5 @@ +package com.alibaba.qwen.code.cli.protocol.message; + +public interface Message { + String getType(); +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/MessageBase.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/MessageBase.java new file mode 100644 index 000000000..c66df12c4 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/MessageBase.java @@ -0,0 +1,22 @@ +package com.alibaba.qwen.code.cli.protocol.message; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.annotation.JSONType; + +@JSONType(alphabetic = false, typeKey = "type", typeName = "MessageBase") +public class MessageBase implements Message{ + protected String type; + + public String toString() { + return JSON.toJSONString(this); + } + + @Override + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKResultMessage.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKResultMessage.java new file mode 100644 index 000000000..dfa2275ff --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKResultMessage.java @@ -0,0 +1,151 @@ +package com.alibaba.qwen.code.cli.protocol.message; + +import java.util.List; +import java.util.Map; + +import com.alibaba.fastjson2.annotation.JSONField; +import com.alibaba.fastjson2.annotation.JSONType; +import com.alibaba.qwen.code.cli.protocol.data.CLIPermissionDenial; +import com.alibaba.qwen.code.cli.protocol.data.ExtendedUsage; +import com.alibaba.qwen.code.cli.protocol.data.Usage; + +@JSONType(typeKey = "type", typeName = "result") +public class SDKResultMessage extends MessageBase { + private String subtype; // 'error_max_turns' | 'error_during_execution' + private String uuid; + + @JSONField(name = "session_id") + private String sessionId; + + @JSONField(name = "is_error") + private boolean isError = true; + + @JSONField(name = "duration_ms") + private Long durationMs; + + @JSONField(name = "duration_api_ms") + private Long durationApiMs; + + @JSONField(name = "num_turns") + private Integer numTurns; + private ExtendedUsage usage; + private Map modelUsage; + + @JSONField(name = "permission_denials") + private List permissionDenials; + private Error error; + + public SDKResultMessage() { + super(); + this.type = "result"; + } + + public String getSubtype() { + return subtype; + } + + public void setSubtype(String subtype) { + this.subtype = subtype; + } + + public String getUuid() { + return uuid; + } + + public void setUuid(String uuid) { + this.uuid = uuid; + } + + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public boolean isError() { + return isError; + } + + public void setError(boolean error) { + isError = error; + } + + public Long getDurationMs() { + return durationMs; + } + + public void setDurationMs(Long durationMs) { + this.durationMs = durationMs; + } + + public Long getDurationApiMs() { + return durationApiMs; + } + + public void setDurationApiMs(Long durationApiMs) { + this.durationApiMs = durationApiMs; + } + + public Integer getNumTurns() { + return numTurns; + } + + public void setNumTurns(Integer numTurns) { + this.numTurns = numTurns; + } + + public ExtendedUsage getUsage() { + return usage; + } + + public void setUsage(ExtendedUsage usage) { + this.usage = usage; + } + + public Map getModelUsage() { + return modelUsage; + } + + public void setModelUsage(Map modelUsage) { + this.modelUsage = modelUsage; + } + + public List getPermissionDenials() { + return permissionDenials; + } + + public void setPermissionDenials(List permissionDenials) { + this.permissionDenials = permissionDenials; + } + + public Error getError() { + return error; + } + + public void setError(Error error) { + this.error = error; + } + + public static class Error { + private String type; + private String message; + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKSystemMessage.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKSystemMessage.java new file mode 100644 index 000000000..22870cb85 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKSystemMessage.java @@ -0,0 +1,213 @@ +package com.alibaba.qwen.code.cli.protocol.message; + +import java.util.List; +import java.util.Map; + +import com.alibaba.fastjson2.annotation.JSONField; +import com.alibaba.fastjson2.annotation.JSONType; + +@JSONType(typeKey = "type", typeName = "system") +public class SDKSystemMessage extends MessageBase { + private String subtype; + private String uuid; + @JSONField(name = "session_id") + private String sessionId; + private Object data; + private String cwd; + private List tools; + @JSONField(name = "mcp_servers") + private List mcpServers; + private String model; + @JSONField(name = "permission_mode") + private String permissionMode; + @JSONField(name = "slash_commands") + private List slashCommands; + @JSONField(name = "qwen_code_version") + private String qwenCodeVersion; + @JSONField(name = "output_style") + private String outputStyle; + private List agents; + private List skills; + private Map capabilities; + @JSONField(name = "compact_metadata") + private CompactMetadata compactMetadata; + + public SDKSystemMessage() { + super(); + this.type = "system"; + } + + public String getSubtype() { + return subtype; + } + + public void setSubtype(String subtype) { + this.subtype = subtype; + } + + public String getUuid() { + return uuid; + } + + public void setUuid(String uuid) { + this.uuid = uuid; + } + + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public Object getData() { + return data; + } + + public void setData(Object data) { + this.data = data; + } + + public String getCwd() { + return cwd; + } + + public void setCwd(String cwd) { + this.cwd = cwd; + } + + public List getTools() { + return tools; + } + + public void setTools(List tools) { + this.tools = tools; + } + + public List getMcpServers() { + return mcpServers; + } + + public void setMcpServers(List mcpServers) { + this.mcpServers = mcpServers; + } + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + + public String getPermissionMode() { + return permissionMode; + } + + public void setPermissionMode(String permissionMode) { + this.permissionMode = permissionMode; + } + + public List getSlashCommands() { + return slashCommands; + } + + public void setSlashCommands(List slashCommands) { + this.slashCommands = slashCommands; + } + + public String getQwenCodeVersion() { + return qwenCodeVersion; + } + + public void setQwenCodeVersion(String qwenCodeVersion) { + this.qwenCodeVersion = qwenCodeVersion; + } + + public String getOutputStyle() { + return outputStyle; + } + + public void setOutputStyle(String outputStyle) { + this.outputStyle = outputStyle; + } + + public List getAgents() { + return agents; + } + + public void setAgents(List agents) { + this.agents = agents; + } + + public List getSkills() { + return skills; + } + + public void setSkills(List skills) { + this.skills = skills; + } + + public Map getCapabilities() { + return capabilities; + } + + public void setCapabilities(Map capabilities) { + this.capabilities = capabilities; + } + + public CompactMetadata getCompactMetadata() { + return compactMetadata; + } + + public void setCompactMetadata(CompactMetadata compactMetadata) { + this.compactMetadata = compactMetadata; + } + + public static class McpServer { + private String name; + private String status; + + // Getters and setters + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + } + + public static class CompactMetadata { + private String trigger; + + @JSONField(name = "pre_tokens") + private Integer preTokens; + + // Getters and setters + public String getTrigger() { + return trigger; + } + + public void setTrigger(String trigger) { + this.trigger = trigger; + } + + public Integer getPreTokens() { + return preTokens; + } + + public void setPreTokens(Integer preTokens) { + this.preTokens = preTokens; + } + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKUserMessage.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKUserMessage.java new file mode 100644 index 000000000..e896b08c4 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKUserMessage.java @@ -0,0 +1,90 @@ +package com.alibaba.qwen.code.cli.protocol.message; + +import java.util.Map; + +import com.alibaba.fastjson2.annotation.JSONField; +import com.alibaba.fastjson2.annotation.JSONType; + +@JSONType(typeKey = "type", typeName = "user") +public class SDKUserMessage extends MessageBase { + private String uuid; + + @JSONField(name = "session_id") + private String sessionId; + private final APIUserMessage message = new APIUserMessage(); + + @JSONField(name = "parent_tool_use_id") + private String parentToolUseId; + private Map options; + + public SDKUserMessage() { + super(); + this.setType("user"); + } + + public String getUuid() { + return uuid; + } + + public void setUuid(String uuid) { + this.uuid = uuid; + } + + public String getSessionId() { + return sessionId; + } + + public SDKUserMessage setSessionId(String sessionId) { + this.sessionId = sessionId; + return this; + } + + public SDKUserMessage setContent(String content) { + message.setContent(content); + return this; + } + + public String getContent() { + return message.getContent(); + } + + public String getParentToolUseId() { + return parentToolUseId; + } + + public SDKUserMessage setParentToolUseId(String parentToolUseId) { + this.parentToolUseId = parentToolUseId; + return this; + } + + public Map getOptions() { + return options; + } + + public SDKUserMessage setOptions(Map options) { + this.options = options; + return this; + } + + public static class APIUserMessage { + private String role = "user"; + private String content; + + // Getters and Setters + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/APIAssistantMessage.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/APIAssistantMessage.java new file mode 100644 index 000000000..5a0b3776c --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/APIAssistantMessage.java @@ -0,0 +1,76 @@ +package com.alibaba.qwen.code.cli.protocol.message.assistant; + +import java.util.List; + +import com.alibaba.fastjson2.annotation.JSONField; +import com.alibaba.qwen.code.cli.protocol.data.Usage; +import com.alibaba.qwen.code.cli.protocol.message.assistant.block.ContentBlock; + +public class APIAssistantMessage { + private String id; + private String type = "message"; + private String role = "assistant"; + private String model; + private List content; + + @JSONField(name = "stop_reason") + private String stopReason; + private Usage usage; + + // Getters and setters + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + + public String getStopReason() { + return stopReason; + } + + public void setStopReason(String stopReason) { + this.stopReason = stopReason; + } + + public Usage getUsage() { + return usage; + } + + public void setUsage(Usage usage) { + this.usage = usage; + } + + public List getContent() { + return content; + } + + public void setContent(List content) { + this.content = content; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKAssistantMessage.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKAssistantMessage.java new file mode 100644 index 000000000..7e906fc44 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKAssistantMessage.java @@ -0,0 +1,49 @@ +package com.alibaba.qwen.code.cli.protocol.message.assistant; + +import com.alibaba.fastjson2.annotation.JSONField; +import com.alibaba.fastjson2.annotation.JSONType; +import com.alibaba.qwen.code.cli.protocol.message.MessageBase; + +@JSONType(typeKey = "type", typeName = "assistant") +public class SDKAssistantMessage extends MessageBase { + private String uuid; + + @JSONField(name = "session_id") + private String sessionId; + private APIAssistantMessage message; + + @JSONField(name = "parent_tool_use_id") + private String parentToolUseId; + + public String getUuid() { + return uuid; + } + + public void setUuid(String uuid) { + this.uuid = uuid; + } + + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public APIAssistantMessage getMessage() { + return message; + } + + public void setMessage(APIAssistantMessage message) { + this.message = message; + } + + public String getParentToolUseId() { + return parentToolUseId; + } + + public void setParentToolUseId(String parentToolUseId) { + this.parentToolUseId = parentToolUseId; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/Annotation.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/Annotation.java new file mode 100644 index 000000000..5e8b9a2b5 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/Annotation.java @@ -0,0 +1,28 @@ +package com.alibaba.qwen.code.cli.protocol.message.assistant.block; + +import com.alibaba.fastjson2.annotation.JSONField; + +public class Annotation { + @JSONField(name = "type") + private String type; + + @JSONField(name = "value") + private String value; + + // Getters and setters + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ContentBlock.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ContentBlock.java new file mode 100644 index 000000000..3e72ad7d0 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ContentBlock.java @@ -0,0 +1,32 @@ +package com.alibaba.qwen.code.cli.protocol.message.assistant.block; + +import java.util.List; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.annotation.JSONType; + +@JSONType(typeKey = "type", typeName = "ContentBlock", seeAlso = { TextBlock.class, ToolResultBlock.class, ThinkingBlock.class, ToolUseBlock.class }) +public class ContentBlock { + protected String type; + protected List annotations; + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public List getAnnotations() { + return annotations; + } + + public void setAnnotations(List annotations) { + this.annotations = annotations; + } + + public String toString() { + return JSON.toJSONString(this); + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/TextBlock.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/TextBlock.java new file mode 100644 index 000000000..86e5513d3 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/TextBlock.java @@ -0,0 +1,16 @@ +package com.alibaba.qwen.code.cli.protocol.message.assistant.block; + +import com.alibaba.fastjson2.annotation.JSONType; + +@JSONType(typeKey = "type", typeName = "text") +public class TextBlock extends ContentBlock { + private String text; + + public String getText() { + return text; + } + + public void setText(String text) { + this.text = text; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ThinkingBlock.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ThinkingBlock.java new file mode 100644 index 000000000..fa479563f --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ThinkingBlock.java @@ -0,0 +1,25 @@ +package com.alibaba.qwen.code.cli.protocol.message.assistant.block; + +import com.alibaba.fastjson2.annotation.JSONType; + +@JSONType(typeKey = "type", typeName = "thinking") +public class ThinkingBlock extends ContentBlock{ + private String thinking; + private String signature; + + public String getThinking() { + return thinking; + } + + public void setThinking(String thinking) { + this.thinking = thinking; + } + + public String getSignature() { + return signature; + } + + public void setSignature(String signature) { + this.signature = signature; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolResultBlock.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolResultBlock.java new file mode 100644 index 000000000..3d7acfea2 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolResultBlock.java @@ -0,0 +1,40 @@ +package com.alibaba.qwen.code.cli.protocol.message.assistant.block; + +import com.alibaba.fastjson2.annotation.JSONField; +import com.alibaba.fastjson2.annotation.JSONType; + +@JSONType(typeKey = "type", typeName = "tool_result") +public class ToolResultBlock extends ContentBlock { + @JSONField(name = "tool_use_id") + private String toolUseId; + + @JSONField(name = "content") + private Object content; // Can be String or List + + @JSONField(name = "is_error") + private Boolean isError; + + public String getToolUseId() { + return toolUseId; + } + + public void setToolUseId(String toolUseId) { + this.toolUseId = toolUseId; + } + + public Object getContent() { + return content; + } + + public void setContent(Object content) { + this.content = content; + } + + public Boolean getIsError() { + return isError; + } + + public void setIsError(Boolean isError) { + this.isError = isError; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolUseBlock.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolUseBlock.java new file mode 100644 index 000000000..58a3bd4fc --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolUseBlock.java @@ -0,0 +1,49 @@ +package com.alibaba.qwen.code.cli.protocol.message.assistant.block; + +import java.util.List; +import java.util.Map; + +import com.alibaba.fastjson2.annotation.JSONType; + +@JSONType(typeKey = "type", typeName = "tool_use") +public class ToolUseBlock extends ContentBlock { + private String id; + private String name; + private Map input; + private List annotations; + + // 构造函数 + public ToolUseBlock() {} + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Map getInput() { + return input; + } + + public void setInput(Map input) { + this.input = input; + } + + public List getAnnotations() { + return annotations; + } + + public void setAnnotations(List annotations) { + this.annotations = annotations; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInitializeRequest.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInitializeRequest.java new file mode 100644 index 000000000..3d217289c --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInitializeRequest.java @@ -0,0 +1,28 @@ +package com.alibaba.qwen.code.cli.protocol.message.control; + +import com.alibaba.fastjson2.annotation.JSONField; +import com.alibaba.qwen.code.cli.protocol.data.InitializeConfig; + +public class CLIControlInitializeRequest { + String subtype = "initialize"; + + @JSONField(unwrapped = true) + InitializeConfig initializeConfig = new InitializeConfig(); + + public String getSubtype() { + return subtype; + } + + public void setSubtype(String subtype) { + this.subtype = subtype; + } + + public InitializeConfig getInitializeConfig() { + return initializeConfig; + } + + public CLIControlInitializeRequest setInitializeConfig(InitializeConfig initializeConfig) { + this.initializeConfig = initializeConfig; + return this; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInitializeResponse.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInitializeResponse.java new file mode 100644 index 000000000..284781a76 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInitializeResponse.java @@ -0,0 +1,24 @@ +package com.alibaba.qwen.code.cli.protocol.message.control; + +import com.alibaba.qwen.code.cli.protocol.data.Capabilities; + +public class CLIControlInitializeResponse { + String subtype = "initialize"; + Capabilities capabilities; + + public String getSubtype() { + return subtype; + } + + public void setSubtype(String subtype) { + this.subtype = subtype; + } + + public Capabilities getCapabilities() { + return capabilities; + } + + public void setCapabilities(Capabilities capabilities) { + this.capabilities = capabilities; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlRequest.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlRequest.java new file mode 100644 index 000000000..e6ea7b956 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlRequest.java @@ -0,0 +1,44 @@ +package com.alibaba.qwen.code.cli.protocol.message.control; + +import java.util.UUID; + +import com.alibaba.fastjson2.annotation.JSONField; +import com.alibaba.fastjson2.annotation.JSONType; +import com.alibaba.qwen.code.cli.protocol.message.MessageBase; + +@JSONType(typeKey = "type", typeName = "control_request") +public class CLIControlRequest extends MessageBase { + @JSONField(name = "request_id") + private String requestId = UUID.randomUUID().toString(); + + private R request; + + public CLIControlRequest() { + super(); + type = "control_request"; + } + + public static CLIControlRequest create(T request) { + CLIControlRequest controlRequest = new CLIControlRequest<>(); + controlRequest.setRequest(request); + return controlRequest; + } + + public String getRequestId() { + return requestId; + } + + public CLIControlRequest setRequestId(String requestId) { + this.requestId = requestId; + return this; + } + + public R getRequest() { + return request; + } + + public CLIControlRequest setRequest(R request) { + this.request = request; + return this; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlResponse.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlResponse.java new file mode 100644 index 000000000..5e193e2cd --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlResponse.java @@ -0,0 +1,54 @@ +package com.alibaba.qwen.code.cli.protocol.message.control; + +import com.alibaba.fastjson2.annotation.JSONField; +import com.alibaba.fastjson2.annotation.JSONType; +import com.alibaba.qwen.code.cli.protocol.message.MessageBase; + +@JSONType(typeKey = "type", typeName = "control_response") +public class CLIControlResponse extends MessageBase { + private Response response; + + public CLIControlResponse() { + super(); + this.type = "control_response"; + } + + public Response getResponse() { + return response; + } + + public void setResponse(Response response) { + this.response = response; + } + + public static class Response { + @JSONField(name = "request_id") + private String requestId; + private String subtype; + R response; + + public String getRequestId() { + return requestId; + } + + public void setRequestId(String requestId) { + this.requestId = requestId; + } + + public String getSubtype() { + return subtype; + } + + public void setSubtype(String subtype) { + this.subtype = subtype; + } + + public R getResponse() { + return response; + } + + public void setResponse(R response) { + this.response = response; + } + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/protocol.ts b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/protocol.ts new file mode 100644 index 000000000..e5eeb1212 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/protocol.ts @@ -0,0 +1,594 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +export interface Annotation { + type: string; + value: string; +} + +export interface Usage { + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; + total_tokens?: number; +} + +export interface ExtendedUsage extends Usage { + server_tool_use?: { + web_search_requests: number; + }; + service_tier?: string; + cache_creation?: { + ephemeral_1h_input_tokens: number; + ephemeral_5m_input_tokens: number; + }; +} + +export interface ModelUsage { + inputTokens: number; + outputTokens: number; + cacheReadInputTokens: number; + cacheCreationInputTokens: number; + webSearchRequests: number; + contextWindow: number; +} + +export interface CLIPermissionDenial { + tool_name: string; + tool_use_id: string; + tool_input: unknown; +} + +export interface TextBlock { + type: 'text'; + text: string; + annotations?: Annotation[]; +} + +export interface ThinkingBlock { + type: 'thinking'; + thinking: string; + signature?: string; + annotations?: Annotation[]; +} + +export interface ToolUseBlock { + type: 'tool_use'; + id: string; + name: string; + input: unknown; + annotations?: Annotation[]; +} + +export interface ToolResultBlock { + type: 'tool_result'; + tool_use_id: string; + content?: string | ContentBlock[]; + is_error?: boolean; + annotations?: Annotation[]; +} + +export type ContentBlock = + | TextBlock + | ThinkingBlock + | ToolUseBlock + | ToolResultBlock; + +export interface APIUserMessage { + role: 'user'; + content: string | ContentBlock[]; +} + +export interface APIAssistantMessage { + id: string; + type: 'message'; + role: 'assistant'; + model: string; + content: ContentBlock[]; + stop_reason?: string | null; + usage: Usage; +} + +export interface SDKUserMessage { + type: 'user'; + uuid?: string; + session_id: string; + message: APIUserMessage; + parent_tool_use_id: string | null; + options?: Record; +} + +export interface SDKAssistantMessage { + type: 'assistant'; + uuid: string; + session_id: string; + message: APIAssistantMessage; + parent_tool_use_id: string | null; +} + +export interface SDKSystemMessage { + type: 'system'; + subtype: string; + uuid: string; + session_id: string; + data?: unknown; + cwd?: string; + tools?: string[]; + mcp_servers?: Array<{ + name: string; + status: string; + }>; + model?: string; + permission_mode?: string; + slash_commands?: string[]; + qwen_code_version?: string; + output_style?: string; + agents?: string[]; + skills?: string[]; + capabilities?: Record; + compact_metadata?: { + trigger: 'manual' | 'auto'; + pre_tokens: number; + }; +} + +export interface SDKResultMessageSuccess { + type: 'result'; + subtype: 'success'; + uuid: string; + session_id: string; + is_error: false; + duration_ms: number; + duration_api_ms: number; + num_turns: number; + result: string; + usage: ExtendedUsage; + modelUsage?: Record; + permission_denials: CLIPermissionDenial[]; + [key: string]: unknown; +} + +export interface SDKResultMessageError { + type: 'result'; + subtype: 'error_max_turns' | 'error_during_execution'; + uuid: string; + session_id: string; + is_error: true; + duration_ms: number; + duration_api_ms: number; + num_turns: number; + usage: ExtendedUsage; + modelUsage?: Record; + permission_denials: CLIPermissionDenial[]; + error?: { + type?: string; + message: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +export type SDKResultMessage = SDKResultMessageSuccess | SDKResultMessageError; + +export interface MessageStartStreamEvent { + type: 'message_start'; + message: { + id: string; + role: 'assistant'; + model: string; + }; +} + +export interface ContentBlockStartEvent { + type: 'content_block_start'; + index: number; + content_block: ContentBlock; +} + +export type ContentBlockDelta = + | { + type: 'text_delta'; + text: string; + } + | { + type: 'thinking_delta'; + thinking: string; + } + | { + type: 'input_json_delta'; + partial_json: string; + }; + +export interface ContentBlockDeltaEvent { + type: 'content_block_delta'; + index: number; + delta: ContentBlockDelta; +} + +export interface ContentBlockStopEvent { + type: 'content_block_stop'; + index: number; +} + +export interface MessageStopStreamEvent { + type: 'message_stop'; +} + +export type StreamEvent = + | MessageStartStreamEvent + | ContentBlockStartEvent + | ContentBlockDeltaEvent + | ContentBlockStopEvent + | MessageStopStreamEvent; + +export interface SDKPartialAssistantMessage { + type: 'stream_event'; + uuid: string; + session_id: string; + event: StreamEvent; + parent_tool_use_id: string | null; +} + +export type PermissionMode = 'default' | 'plan' | 'auto-edit' | 'yolo'; + +/** + * TODO: Align with `ToolCallConfirmationDetails` + */ +export interface PermissionSuggestion { + type: 'allow' | 'deny' | 'modify'; + label: string; + description?: string; + modifiedInput?: unknown; +} + +export interface HookRegistration { + event: string; + callback_id: string; +} + +export interface HookCallbackResult { + shouldSkip?: boolean; + shouldInterrupt?: boolean; + suppressOutput?: boolean; + message?: string; +} + +export interface CLIControlInterruptRequest { + subtype: 'interrupt'; +} + +export interface CLIControlPermissionRequest { + subtype: 'can_use_tool'; + tool_name: string; + tool_use_id: string; + input: unknown; + permission_suggestions: PermissionSuggestion[] | null; + blocked_path: string | null; +} + +export enum AuthProviderType { + DYNAMIC_DISCOVERY = 'dynamic_discovery', + GOOGLE_CREDENTIALS = 'google_credentials', + SERVICE_ACCOUNT_IMPERSONATION = 'service_account_impersonation', +} + +export interface MCPServerConfig { + command?: string; + args?: string[]; + env?: Record; + cwd?: string; + url?: string; + httpUrl?: string; + headers?: Record; + tcp?: string; + timeout?: number; + trust?: boolean; + description?: string; + includeTools?: string[]; + excludeTools?: string[]; + extensionName?: string; + oauth?: Record; + authProviderType?: AuthProviderType; + targetAudience?: string; + targetServiceAccount?: string; +} + +/** + * SDK MCP Server configuration + * + * SDK MCP servers run in the SDK process and are connected via in-memory transport. + * Tool calls are routed through the control plane between SDK and CLI. + */ +export interface SDKMcpServerConfig { + /** + * Type identifier for SDK MCP servers + */ + type: 'sdk'; + /** + * Server name for identification and routing + */ + name: string; + /** + * The MCP Server instance created by createSdkMcpServer() + */ + instance: McpServer; +} + +/** + * Wire format for SDK MCP servers sent to the CLI + */ +export type WireSDKMcpServerConfig = Omit; + +export interface CLIControlInitializeRequest { + subtype: 'initialize'; + hooks?: HookRegistration[] | null; + /** + * SDK MCP servers config + * These are MCP servers running in the SDK process, connected via control plane. + * External MCP servers are configured separately in settings, not via initialization. + */ + sdkMcpServers?: Record; + /** + * External MCP servers that should be managed by the CLI. + */ + mcpServers?: Record; + agents?: SubagentConfig[]; +} + +export interface CLIControlSetPermissionModeRequest { + subtype: 'set_permission_mode'; + mode: PermissionMode; +} + +export interface CLIHookCallbackRequest { + subtype: 'hook_callback'; + callback_id: string; + input: unknown; + tool_use_id: string | null; +} + +export interface CLIControlMcpMessageRequest { + subtype: 'mcp_message'; + server_name: string; + message: { + jsonrpc?: string; + method: string; + params?: Record; + id?: string | number | null; + }; +} + +export interface CLIControlSetModelRequest { + subtype: 'set_model'; + model: string; +} + +export interface CLIControlMcpStatusRequest { + subtype: 'mcp_server_status'; +} + +export interface CLIControlSupportedCommandsRequest { + subtype: 'supported_commands'; +} + +export type ControlRequestPayload = + | CLIControlInterruptRequest + | CLIControlPermissionRequest + | CLIControlInitializeRequest + | CLIControlSetPermissionModeRequest + | CLIHookCallbackRequest + | CLIControlMcpMessageRequest + | CLIControlSetModelRequest + | CLIControlMcpStatusRequest + | CLIControlSupportedCommandsRequest; + +export interface CLIControlRequest { + type: 'control_request'; + request_id: string; + request: ControlRequestPayload; +} + +export interface PermissionApproval { + allowed: boolean; + reason?: string; + modifiedInput?: unknown; +} + +export interface ControlResponse { + subtype: 'success'; + request_id: string; + response: unknown; +} + +export interface ControlErrorResponse { + subtype: 'error'; + request_id: string; + error: string | { message: string; [key: string]: unknown }; +} + +export interface CLIControlResponse { + type: 'control_response'; + response: ControlResponse | ControlErrorResponse; +} + +export interface ControlCancelRequest { + type: 'control_cancel_request'; + request_id?: string; +} + +export type ControlMessage = + | CLIControlRequest + | CLIControlResponse + | ControlCancelRequest; + +/** + * Union of all SDK message types + */ +export type SDKMessage = + | SDKUserMessage + | SDKAssistantMessage + | SDKSystemMessage + | SDKResultMessage + | SDKPartialAssistantMessage; + +export function isSDKUserMessage(msg: any): msg is SDKUserMessage { + return ( + msg && typeof msg === 'object' && msg.type === 'user' && 'message' in msg + ); +} + +export function isSDKAssistantMessage(msg: any): msg is SDKAssistantMessage { + return ( + msg && + typeof msg === 'object' && + msg.type === 'assistant' && + 'uuid' in msg && + 'message' in msg && + 'session_id' in msg && + 'parent_tool_use_id' in msg + ); +} + +export function isSDKSystemMessage(msg: any): msg is SDKSystemMessage { + return ( + msg && + typeof msg === 'object' && + msg.type === 'system' && + 'subtype' in msg && + 'uuid' in msg && + 'session_id' in msg + ); +} + +export function isSDKResultMessage(msg: any): msg is SDKResultMessage { + return ( + msg && + typeof msg === 'object' && + msg.type === 'result' && + 'subtype' in msg && + 'duration_ms' in msg && + 'is_error' in msg && + 'uuid' in msg && + 'session_id' in msg + ); +} + +export function isSDKPartialAssistantMessage( + msg: any, +): msg is SDKPartialAssistantMessage { + return ( + msg && + typeof msg === 'object' && + msg.type === 'stream_event' && + 'uuid' in msg && + 'session_id' in msg && + 'event' in msg && + 'parent_tool_use_id' in msg + ); +} + +export function isControlRequest(msg: any): msg is CLIControlRequest { + return ( + msg && + typeof msg === 'object' && + msg.type === 'control_request' && + 'request_id' in msg && + 'request' in msg + ); +} + +export function isControlResponse(msg: any): msg is CLIControlResponse { + return ( + msg && + typeof msg === 'object' && + msg.type === 'control_response' && + 'response' in msg + ); +} + +export function isControlCancel(msg: any): msg is ControlCancelRequest { + return ( + msg && + typeof msg === 'object' && + msg.type === 'control_cancel_request' && + 'request_id' in msg + ); +} + +export function isTextBlock(block: any): block is TextBlock { + return block && typeof block === 'object' && block.type === 'text'; +} + +export function isThinkingBlock(block: any): block is ThinkingBlock { + return block && typeof block === 'object' && block.type === 'thinking'; +} + +export function isToolUseBlock(block: any): block is ToolUseBlock { + return block && typeof block === 'object' && block.type === 'tool_use'; +} + +export function isToolResultBlock(block: any): block is ToolResultBlock { + return block && typeof block === 'object' && block.type === 'tool_result'; +} + +export type SubagentLevel = 'session'; + +export interface ModelConfig { + model?: string; + temp?: number; + top_p?: number; +} + +export interface RunConfig { + max_time_minutes?: number; + max_turns?: number; +} + +export interface SubagentConfig { + name: string; + description: string; + tools?: string[]; + systemPrompt: string; + level: SubagentLevel; + filePath?: string; + modelConfig?: Partial; + runConfig?: Partial; + color?: string; + readonly isBuiltin?: boolean; +} + +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Control Request Types + * + * Centralized enum for all control request subtypes supported by the CLI. + * This enum should be kept in sync with the controllers in: + * - packages/cli/src/services/control/controllers/systemController.ts + * - packages/cli/src/services/control/controllers/permissionController.ts + * - packages/cli/src/services/control/controllers/mcpController.ts + * - packages/cli/src/services/control/controllers/hookController.ts + */ +export enum ControlRequestType { + // SystemController requests + INITIALIZE = 'initialize', + INTERRUPT = 'interrupt', + SET_MODEL = 'set_model', + SUPPORTED_COMMANDS = 'supported_commands', + + // PermissionController requests + CAN_USE_TOOL = 'can_use_tool', + SET_PERMISSION_MODE = 'set_permission_mode', + + // MCPController requests + MCP_MESSAGE = 'mcp_message', + MCP_SERVER_STATUS = 'mcp_server_status', + + // HookController requests + HOOK_CALLBACK = 'hook_callback', +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/Session.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/Session.java new file mode 100644 index 000000000..346b0f1f2 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/Session.java @@ -0,0 +1,89 @@ +package com.alibaba.qwen.code.cli.session; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.alibaba.fastjson2.TypeReference; +import com.alibaba.qwen.code.cli.protocol.data.Capabilities; +import com.alibaba.qwen.code.cli.protocol.message.SDKResultMessage; +import com.alibaba.qwen.code.cli.protocol.message.SDKSystemMessage; +import com.alibaba.qwen.code.cli.protocol.message.SDKUserMessage; +import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKAssistantMessage; +import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlInitializeRequest; +import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlInitializeResponse; +import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlRequest; +import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlResponse; +import com.alibaba.qwen.code.cli.session.event.SessionEventConsumers; +import com.alibaba.qwen.code.cli.session.exception.SessionCloseException; +import com.alibaba.qwen.code.cli.session.exception.SessionSendPromptException; +import com.alibaba.qwen.code.cli.session.exception.SessionStartException; +import com.alibaba.qwen.code.cli.transport.Transport; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class Session { + private final Transport transport; + private Capabilities capabilities; + private static final Logger log = LoggerFactory.getLogger(Session.class); + + public Session(Transport transport) throws SessionStartException { + if (transport == null || !transport.isAvailable()) { + throw new SessionStartException("Transport is not available"); + } + this.transport = transport; + start(); + } + + private void start() throws SessionStartException { + try { + String response = transport.inputWaitForOneLine(CLIControlRequest.create(new CLIControlInitializeRequest()).toString()); + CLIControlResponse cliControlResponse = JSON.parseObject(response, new TypeReference>() {}); + this.capabilities = cliControlResponse.getResponse().getResponse().getCapabilities(); + } catch (Exception e) { + throw new SessionStartException("Failed to initialize the session", e); + } + } + + public void close() throws SessionCloseException { + try { + transport.close(); + } catch (Exception e) { + throw new SessionCloseException("Failed to close the session", e); + } + } + + public Capabilities getCapabilities() { + return capabilities; + } + + public void sendPrompt(String prompt, SessionEventConsumers sessionEventConsumers) throws SessionSendPromptException { + if (!transport.isAvailable()) { + throw new SessionSendPromptException("Session is not available"); + } + + try { + transport.inputWaitForMultiLine(new SDKUserMessage().setContent(prompt).toString(), (line) -> { + log.debug("read a message from agent {}", line); + JSONObject jsonObject = JSON.parseObject(line); + + String messageType = jsonObject.getString("type"); + if ("system".equals(messageType)) { + sessionEventConsumers.onSystemMessage(JSON.parseObject(line, SDKSystemMessage.class)); + return false; + } else if ("assistant".equals(messageType)) { + sessionEventConsumers.onAssistantMessage(JSON.parseObject(line, SDKAssistantMessage.class)); + return false; + } else if ("result".equals(messageType)) { + sessionEventConsumers.onResultMessage(JSON.parseObject(line, SDKResultMessage.class)); + return true; + } else { + log.warn("unknown message type: {}", messageType); + sessionEventConsumers.onOtherMessage(line); + return false; + } + }); + } catch (Exception e) { + throw new SessionSendPromptException("Failed to send prompt", e); + } + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventConsumers.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventConsumers.java new file mode 100644 index 000000000..9f9bb6fc1 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventConsumers.java @@ -0,0 +1,15 @@ +package com.alibaba.qwen.code.cli.session.event; + +import com.alibaba.qwen.code.cli.protocol.message.SDKResultMessage; +import com.alibaba.qwen.code.cli.protocol.message.SDKSystemMessage; +import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKAssistantMessage; + +public interface SessionEventConsumers { + void onSystemMessage(SDKSystemMessage systemMessage); + + void onResultMessage(SDKResultMessage resultMessage); + + void onAssistantMessage(SDKAssistantMessage assistantMessage); + + void onOtherMessage(String message); +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventSimpleConsumers.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventSimpleConsumers.java new file mode 100644 index 000000000..584354d43 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventSimpleConsumers.java @@ -0,0 +1,23 @@ +package com.alibaba.qwen.code.cli.session.event; + +import com.alibaba.qwen.code.cli.protocol.message.SDKResultMessage; +import com.alibaba.qwen.code.cli.protocol.message.SDKSystemMessage; +import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKAssistantMessage; + +public class SessionEventSimpleConsumers implements SessionEventConsumers { + @Override + public void onSystemMessage(SDKSystemMessage systemMessage) { + } + + @Override + public void onResultMessage(SDKResultMessage resultMessage) { + } + + @Override + public void onAssistantMessage(SDKAssistantMessage assistantMessage) { + } + + @Override + public void onOtherMessage(String message) { + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionCloseException.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionCloseException.java new file mode 100644 index 000000000..3db39e793 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionCloseException.java @@ -0,0 +1,22 @@ +package com.alibaba.qwen.code.cli.session.exception; + +public class SessionCloseException extends Exception { + public SessionCloseException() { + } + + public SessionCloseException(String message) { + super(message); + } + + public SessionCloseException(String message, Throwable cause) { + super(message, cause); + } + + public SessionCloseException(Throwable cause) { + super(cause); + } + + public SessionCloseException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionSendPromptException.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionSendPromptException.java new file mode 100644 index 000000000..74de3bba7 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionSendPromptException.java @@ -0,0 +1,22 @@ +package com.alibaba.qwen.code.cli.session.exception; + +public class SessionSendPromptException extends Exception { + public SessionSendPromptException() { + } + + public SessionSendPromptException(String message) { + super(message); + } + + public SessionSendPromptException(String message, Throwable cause) { + super(message, cause); + } + + public SessionSendPromptException(Throwable cause) { + super(cause); + } + + public SessionSendPromptException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionStartException.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionStartException.java new file mode 100644 index 000000000..9d30f2367 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionStartException.java @@ -0,0 +1,22 @@ +package com.alibaba.qwen.code.cli.session.exception; + +public class SessionStartException extends Exception { + public SessionStartException() { + } + + public SessionStartException(String message) { + super(message); + } + + public SessionStartException(String message, Throwable cause) { + super(message, cause); + } + + public SessionStartException(Throwable cause) { + super(cause); + } + + public SessionStartException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/Transport.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/Transport.java new file mode 100644 index 000000000..5b66cbc90 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/Transport.java @@ -0,0 +1,18 @@ +package com.alibaba.qwen.code.cli.transport; + +import java.io.IOException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; +import java.util.function.Function; + +public interface Transport { + void close() throws IOException; + + boolean isAvailable(); + + String inputWaitForOneLine(String message) throws IOException, ExecutionException, InterruptedException, TimeoutException; + + void inputWaitForMultiLine(String message, Function callBackFunction) throws IOException; + + void inputNoWaitResponse(String message) throws IOException; +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransport.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransport.java index 3adc1072b..b7d62b5d7 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransport.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransport.java @@ -1,5 +1,6 @@ package com.alibaba.qwen.code.cli.transport.process; +import com.alibaba.qwen.code.cli.transport.Transport; import com.alibaba.qwen.code.cli.transport.TransportOptions; import org.apache.commons.lang3.exception.ContextedRuntimeException; @@ -19,7 +20,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.function.Function; -public class ProcessTransport { +public class ProcessTransport implements Transport { private static final Logger log = LoggerFactory.getLogger(ProcessTransport.class); TransportOptionsAdapter transportOptionsAdapter; @@ -31,6 +32,10 @@ public class ProcessTransport { protected BufferedReader processOutput; protected BufferedReader processError; + public ProcessTransport() throws IOException { + this(new TransportOptions()); + } + public ProcessTransport(TransportOptions transportOptions) throws IOException { this.transportOptionsAdapter = new TransportOptionsAdapter(transportOptions); turnTimeoutMs = transportOptionsAdapter.getHandledTransportOptions().getTurnTimeoutMs(); @@ -56,6 +61,7 @@ public class ProcessTransport { startErrorReading(); } + @Override public void close() throws IOException { if (processInput != null) { processInput.close(); @@ -71,6 +77,12 @@ public class ProcessTransport { } } + @Override + public boolean isAvailable() { + return process != null && process.isAlive(); + } + + @Override public String inputWaitForOneLine(String message) throws IOException, ExecutionException, InterruptedException, TimeoutException { return inputWaitForOneLine(message, turnTimeoutMs); } @@ -106,6 +118,7 @@ public class ProcessTransport { } } + @Override public void inputWaitForMultiLine(String message, Function callBackFunction) throws IOException { inputWaitForMultiLine(message, callBackFunction, turnTimeoutMs); } @@ -132,6 +145,7 @@ public class ProcessTransport { } } + @Override public void inputNoWaitResponse(String message) throws IOException { log.debug("input message to agent: {}", message); processInput.write(message); diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java index 66113e8cd..4c6b7d48d 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java @@ -76,7 +76,9 @@ class TransportOptionsAdapter { } private TransportOptions addDefaultTransportOptions(TransportOptions userTransportOptions) { - TransportOptions transportOptions = userTransportOptions.clone(); + TransportOptions transportOptions = Optional.ofNullable(userTransportOptions) + .map(TransportOptions::clone) + .orElse(new TransportOptions()); if (StringUtils.isBlank(transportOptions.getPathToQwenExecutable())) { transportOptions.setPathToQwenExecutable("qwen"); diff --git a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/QwenCliTest.java b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/QwenCliTest.java new file mode 100644 index 000000000..01295ce6c --- /dev/null +++ b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/QwenCliTest.java @@ -0,0 +1,23 @@ +package com.alibaba.qwen.code.cli; + +import java.util.List; + +import com.alibaba.qwen.code.cli.protocol.message.Message; +import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKAssistantMessage; + +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.junit.jupiter.api.Assertions.*; + +class QwenCliTest { + + private static final Logger log = LoggerFactory.getLogger(QwenCliTest.class); + @Test + void query() { + List result = QwenCli.query("hello world"); + log.info("result: {}", result); + assertNotNull(result); + } +} diff --git a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java new file mode 100644 index 000000000..69898b948 --- /dev/null +++ b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java @@ -0,0 +1,58 @@ +package com.alibaba.qwen.code.cli.session; + +import java.io.IOException; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.qwen.code.cli.protocol.message.SDKResultMessage; +import com.alibaba.qwen.code.cli.protocol.message.SDKSystemMessage; +import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKAssistantMessage; +import com.alibaba.qwen.code.cli.session.event.SessionEventConsumers; +import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; +import com.alibaba.qwen.code.cli.session.exception.SessionCloseException; +import com.alibaba.qwen.code.cli.session.exception.SessionSendPromptException; +import com.alibaba.qwen.code.cli.session.exception.SessionStartException; +import com.alibaba.qwen.code.cli.transport.Transport; +import com.alibaba.qwen.code.cli.transport.process.ProcessTransport; + +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class SessionTest { + + private static final Logger log = LoggerFactory.getLogger(SessionTest.class); + @Test + void sendPrompt() throws IOException, SessionStartException, SessionSendPromptException, SessionCloseException { + Transport transport = new ProcessTransport(); + Session session = new Session(transport); + session.sendPrompt("hello world", new SessionEventSimpleConsumers() { + @Override + public void onSystemMessage(SDKSystemMessage systemMessage) { + log.info("systemMessage: {}", systemMessage); + } + + @Override + public void onResultMessage(SDKResultMessage resultMessage) { + log.info("resultMessage: {}", resultMessage); + } + + @Override + public void onAssistantMessage(SDKAssistantMessage assistantMessage) { + log.info("assistantMessage: {}", assistantMessage); + } + + @Override + public void onOtherMessage(String message) { + log.info("otherMessage: {}", message); + } + }); + session.close(); + } + + @Test + void testJSON() { + String json = "{\"type\":\"assistant\",\"uuid\":\"ed8374fe-a4eb-4fc0-9780-9bd2fd831cda\",\"session_id\":\"166badc0-e6d3-4978-ae47-4ccd51c468ef\",\"message\":{\"content\":[{\"text\":\"Hello! How can I help you with the Qwen Code SDK for Java today?\",\"type\":\"text\"}],\"id\":\"ed8374fe-a4eb-4fc0-9780-9bd2fd831cda\",\"model\":\"qwen3-coder-plus\",\"role\":\"assistant\",\"type\":\"message\",\"usage\":{\"cache_read_input_tokens\":12766,\"input_tokens\":12770,\"output_tokens\":17,\"total_tokens\":12787}}}"; + SDKAssistantMessage assistantMessage = JSON.parseObject(json, SDKAssistantMessage.class); + log.info("the assistantMessage: {}", assistantMessage); + } +} diff --git a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransportTest.java b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransportTest.java index bdedf3047..721b203db 100644 --- a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransportTest.java +++ b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransportTest.java @@ -1,29 +1,86 @@ package com.alibaba.qwen.code.cli.transport.process; import java.io.IOException; +import java.util.UUID; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeoutException; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.TypeReference; +import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlInitializeRequest; +import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlInitializeResponse; +import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlRequest; +import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlResponse; +import com.alibaba.qwen.code.cli.protocol.message.SDKUserMessage; +import com.alibaba.qwen.code.cli.transport.Transport; import com.alibaba.qwen.code.cli.transport.TransportOptions; import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; class ProcessTransportTest { + private static final Logger logger = LoggerFactory.getLogger(ProcessTransportTest.class); + @Test void shouldStartAndCloseSuccessfully() throws IOException { TransportOptions transportOptions = new TransportOptions(); - ProcessTransport processTransport = new ProcessTransport(transportOptions); - processTransport.close(); + Transport transport = new ProcessTransport(transportOptions); + transport.close(); } @Test void shouldInputWaitForOneLineSuccessfully() throws IOException, ExecutionException, InterruptedException, TimeoutException { TransportOptions transportOptions = new TransportOptions(); - ProcessTransport processTransport = new ProcessTransport(transportOptions); + Transport transport = new ProcessTransport(transportOptions); String message = "{\"type\": \"control_request\", \"request_id\": \"1\", \"request\": {\"subtype\": \"initialize\"} }"; - System.out.println(processTransport.inputWaitForOneLine(message)); + System.out.println(transport.inputWaitForOneLine(message)); + } + + @Test + void shouldInitializeSuccessfully() throws IOException, ExecutionException, InterruptedException, TimeoutException { + Transport transport = new ProcessTransport(); + + String message = CLIControlRequest.create(new CLIControlInitializeRequest()).toString(); + String responseMsg = transport.inputWaitForOneLine(message); + logger.info("responseMsg: {}", responseMsg); + CLIControlResponse response = JSON.parseObject(responseMsg, + new TypeReference>() {}); + logger.info("response: {}", response); + } + + @Test + void shouldSdkMessageSuccessfully() throws IOException, ExecutionException, InterruptedException, TimeoutException { + Transport transport = new ProcessTransport(); + String message = CLIControlRequest.create(new CLIControlInitializeRequest()).toString(); + transport.inputWaitForOneLine(message); + + String sessionId = "session-" + UUID.randomUUID().toString(); + String userMessage = new SDKUserMessage().setSessionId(sessionId).setContent("hello world").toString(); + transport.inputWaitForMultiLine(userMessage, line -> { + return "result".equals(JSON.parseObject(line).getString("type")); + }); + + String userMessage2 = new SDKUserMessage().setSessionId(sessionId).setContent("请使用中文").toString(); + transport.inputWaitForMultiLine(userMessage2, line -> { + return "result".equals(JSON.parseObject(line).getString("type")); + }); + + String userMessage3 = new SDKUserMessage().setSessionId(sessionId).setContent("当前工作区有多少个文件").toString(); + transport.inputWaitForMultiLine(userMessage3, line -> { + return "result".equals(JSON.parseObject(line).getString("type")); + }); + + String userMessage4 = new SDKUserMessage().setSessionId("session-sec" + UUID.randomUUID()).setContent("有多少个xml文件").toString(); + transport.inputWaitForMultiLine(userMessage4, line -> { + return "result".equals(JSON.parseObject(line).getString("type")); + }); + + transport.inputWaitForOneLine(CLIControlRequest.create(new CLIControlInitializeRequest()).toString()); + transport.inputWaitForMultiLine(new SDKUserMessage().setContent("您好").toString(), + line -> "result".equals(JSON.parseObject(line).getString("type"))); } } From 4db50d415805c017fea86e8079256e907478f687 Mon Sep 17 00:00:00 2001 From: cris Date: Tue, 30 Dec 2025 16:00:55 +0800 Subject: [PATCH 029/142] fix resume unwork on windows --- .../cli/src/ui/components/StandaloneSessionPicker.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/components/StandaloneSessionPicker.tsx b/packages/cli/src/ui/components/StandaloneSessionPicker.tsx index bac7f23df..c81531159 100644 --- a/packages/cli/src/ui/components/StandaloneSessionPicker.tsx +++ b/packages/cli/src/ui/components/StandaloneSessionPicker.tsx @@ -87,7 +87,13 @@ export async function showResumeSessionPicker( let selectedId: string | undefined; const { unmount, waitUntilExit } = render( - + { @@ -115,7 +121,6 @@ export async function showResumeSessionPicker( if (process.stdin.isTTY && !wasRaw && !selectedId) { process.stdin.setRawMode(false); } - resolve(selectedId); }); }); From e3c20b03bddf073b63157e284eea4400133b0b78 Mon Sep 17 00:00:00 2001 From: cris Date: Tue, 30 Dec 2025 16:03:11 +0800 Subject: [PATCH 030/142] reslove blank --- packages/cli/src/ui/components/StandaloneSessionPicker.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/ui/components/StandaloneSessionPicker.tsx b/packages/cli/src/ui/components/StandaloneSessionPicker.tsx index c81531159..13a176c62 100644 --- a/packages/cli/src/ui/components/StandaloneSessionPicker.tsx +++ b/packages/cli/src/ui/components/StandaloneSessionPicker.tsx @@ -121,6 +121,7 @@ export async function showResumeSessionPicker( if (process.stdin.isTTY && !wasRaw && !selectedId) { process.stdin.setRawMode(false); } + resolve(selectedId); }); }); From 15912892f245f14a1001387288f04a8e5bcc849a Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Tue, 30 Dec 2025 19:40:24 +0800 Subject: [PATCH 031/142] fix: missing error throw in non-Interactive mode --- packages/cli/src/nonInteractiveCli.test.ts | 46 ++++++++++++++++++++++ packages/cli/src/nonInteractiveCli.ts | 2 + 2 files changed, 48 insertions(+) diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 07fd168fc..b45d509f9 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -771,6 +771,52 @@ describe('runNonInteractive', () => { ); }); + it('should handle API errors in text mode and exit with error code', async () => { + (mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.TEXT); + setupMetricsMock(); + + // Simulate an API error event (like 401 unauthorized) + const apiErrorEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.Error, + value: { + error: { + message: '401 Incorrect API key provided', + status: 401, + }, + }, + }; + + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents([apiErrorEvent]), + ); + + let thrownError: Error | null = null; + try { + await runNonInteractive( + mockConfig, + mockSettings, + 'Test input', + 'prompt-id-api-error', + ); + // Should not reach here + expect.fail('Expected error to be thrown'); + } catch (error) { + thrownError = error as Error; + } + + // Should throw with the API error message + expect(thrownError).toBeTruthy(); + expect(thrownError?.message).toContain('401'); + expect(thrownError?.message).toContain('Incorrect API key provided'); + + // Verify error was written to stderr + expect(processStderrSpy).toHaveBeenCalled(); + const stderrCalls = processStderrSpy.mock.calls; + const errorOutput = stderrCalls.map((call) => call[0]).join(''); + expect(errorOutput).toContain('401'); + expect(errorOutput).toContain('Incorrect API key provided'); + }); + it('should handle FatalInputError with custom exit code in JSON format', async () => { (mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON); setupMetricsMock(); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 067f190b9..3eba267c8 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -308,6 +308,8 @@ export async function runNonInteractive( config.getContentGeneratorConfig()?.authType, ); process.stderr.write(`${errorText}\n`); + // Throw error to exit with non-zero code + throw new Error(errorText); } } } From ac7ba95d65fd3e26c0292b6bf821c63fe2a6ad8d Mon Sep 17 00:00:00 2001 From: skyfire Date: Tue, 30 Dec 2025 20:08:05 +0800 Subject: [PATCH 032/142] add permission --- .../com/alibaba/qwen/code/cli/QwenCli.java | 4 +- .../data}/PermissionMode.java | 2 +- .../cli/protocol/data/behavior/Allow.java | 23 +++ .../cli/protocol/data/behavior/Behavior.java | 25 +++ .../code/cli/protocol/data/behavior/Deny.java | 22 +++ .../control/CLIControlInterruptRequest.java | 13 ++ .../control/CLIControlPermissionRequest.java | 112 +++++++++++ .../control/CLIControlPermissionResponse.java | 28 +++ .../message/control/CLIControlResponse.java | 17 +- .../control/CLIControlSetModelRequest.java | 22 +++ .../CLIControlSetPermissionModeRequest.java | 23 +++ .../qwen/code/cli/session/Session.java | 178 ++++++++++++++++-- .../session/event/SessionEventConsumers.java | 22 ++- .../event/SessionEventSimpleConsumers.java | 32 +++- .../exception/SessionCloseException.java | 22 --- .../exception/SessionControlException.java | 22 +++ .../exception/SessionStartException.java | 22 --- .../qwen/code/cli/transport/Transport.java | 4 + .../code/cli/transport/TransportOptions.java | 11 ++ .../transport/process/ProcessTransport.java | 23 ++- .../process/TransportOptionsAdapter.java | 5 + .../qwen/code/cli/session/SessionTest.java | 101 +++++++++- .../cli/transport/PermissionModeTest.java | 2 + packages/sdk-java/todo | 6 + 24 files changed, 648 insertions(+), 93 deletions(-) rename packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/{transport => protocol/data}/PermissionMode.java (92%) create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Allow.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Behavior.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Deny.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInterruptRequest.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlPermissionRequest.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlPermissionResponse.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetModelRequest.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetPermissionModeRequest.java delete mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionCloseException.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionControlException.java delete mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionStartException.java create mode 100644 packages/sdk-java/todo diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCli.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCli.java index 065ff9e73..0471ab692 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCli.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCli.java @@ -31,12 +31,12 @@ public class QwenCli { try { session.sendPrompt(prompt, new SessionEventSimpleConsumers() { @Override - public void onSystemMessage(SDKSystemMessage systemMessage) { + public void onSystemMessage(Session session, SDKSystemMessage systemMessage) { response.add(systemMessage); } @Override - public void onAssistantMessage(SDKAssistantMessage assistantMessage) { + public void onAssistantMessage(Session session, SDKAssistantMessage assistantMessage) { response.add(assistantMessage); } }); diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/PermissionMode.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/PermissionMode.java similarity index 92% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/PermissionMode.java rename to packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/PermissionMode.java index 3db5782c6..d960a396e 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/PermissionMode.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/PermissionMode.java @@ -1,4 +1,4 @@ -package com.alibaba.qwen.code.cli.transport; +package com.alibaba.qwen.code.cli.protocol.data; public enum PermissionMode { DEFAULT("default"), diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Allow.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Allow.java new file mode 100644 index 000000000..14adf7a2f --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Allow.java @@ -0,0 +1,23 @@ +package com.alibaba.qwen.code.cli.protocol.data.behavior; + +import java.util.Map; + +import com.alibaba.fastjson2.annotation.JSONType; + +@JSONType(typeKey = "operation", typeName = "allow") +public class Allow extends Behavior { + public Allow() { + super(); + this.behavior = Operation.allow; + } + Map updatedInput; + + public Map getUpdatedInput() { + return updatedInput; + } + + public Allow setUpdatedInput(Map updatedInput) { + this.updatedInput = updatedInput; + return this; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Behavior.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Behavior.java new file mode 100644 index 000000000..1f54f2341 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Behavior.java @@ -0,0 +1,25 @@ +package com.alibaba.qwen.code.cli.protocol.data.behavior; + +import com.alibaba.fastjson2.annotation.JSONType; + +@JSONType(typeKey = "operation", typeName = "Behavior", seeAlso = {Allow.class, Deny.class}) +public class Behavior { + Operation behavior; + + public Operation getBehavior() { + return behavior; + } + + public void setBehavior(Operation behavior) { + this.behavior = behavior; + } + + public enum Operation { + allow, + deny + } + + public static Behavior defaultBehavior() { + return new Deny().setMessage("Default Behavior Permission denied"); + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Deny.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Deny.java new file mode 100644 index 000000000..17d37ca05 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Deny.java @@ -0,0 +1,22 @@ +package com.alibaba.qwen.code.cli.protocol.data.behavior; + +import com.alibaba.fastjson2.annotation.JSONType; + +@JSONType(typeKey = "operation", typeName = "deny") +public class Deny extends Behavior { + public Deny() { + super(); + this.behavior = Operation.deny; + } + + String message; + + public String getMessage() { + return message; + } + + public Deny setMessage(String message) { + this.message = message; + return this; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInterruptRequest.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInterruptRequest.java new file mode 100644 index 000000000..f4a052697 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInterruptRequest.java @@ -0,0 +1,13 @@ +package com.alibaba.qwen.code.cli.protocol.message.control; + +public class CLIControlInterruptRequest { + String subtype = "interrupt"; + + public String getSubtype() { + return subtype; + } + + public void setSubtype(String subtype) { + this.subtype = subtype; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlPermissionRequest.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlPermissionRequest.java new file mode 100644 index 000000000..ac3e43e79 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlPermissionRequest.java @@ -0,0 +1,112 @@ +package com.alibaba.qwen.code.cli.protocol.message.control; + +import java.util.List; +import java.util.Map; + +import com.alibaba.fastjson2.annotation.JSONField; + +public class CLIControlPermissionRequest { + private String subtype; + + @JSONField(name = "tool_name") + private String toolName; + + @JSONField(name = "tool_use_id") + private String toolUseId; + + private Map input; + + @JSONField(name = "permission_suggestions") + private List permissionSuggestions; + + @JSONField(name = "blocked_path") + private String blockedPath; + + public String getSubtype() { + return subtype; + } + + public void setSubtype(String subtype) { + this.subtype = subtype; + } + + public String getToolName() { + return toolName; + } + + public void setToolName(String toolName) { + this.toolName = toolName; + } + + public String getToolUseId() { + return toolUseId; + } + + public void setToolUseId(String toolUseId) { + this.toolUseId = toolUseId; + } + + public Map getInput() { + return input; + } + + public void setInput(Map input) { + this.input = input; + } + + public List getPermissionSuggestions() { + return permissionSuggestions; + } + + public void setPermissionSuggestions( + List permissionSuggestions) { + this.permissionSuggestions = permissionSuggestions; + } + + public String getBlockedPath() { + return blockedPath; + } + + public void setBlockedPath(String blockedPath) { + this.blockedPath = blockedPath; + } + + public static class PermissionSuggestion { + private String type; // 'allow' | 'deny' | 'modify' + private String label; + private String description; + private Object modifiedInput; + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Object getModifiedInput() { + return modifiedInput; + } + + public void setModifiedInput(Object modifiedInput) { + this.modifiedInput = modifiedInput; + } + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlPermissionResponse.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlPermissionResponse.java new file mode 100644 index 000000000..66c199632 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlPermissionResponse.java @@ -0,0 +1,28 @@ +package com.alibaba.qwen.code.cli.protocol.message.control; + +import com.alibaba.fastjson2.annotation.JSONField; +import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior; + +public class CLIControlPermissionResponse { + private String subtype = "can_use_tool"; + + @JSONField(unwrapped = true) + Behavior behavior; + + public String getSubtype() { + return subtype; + } + + public void setSubtype(String subtype) { + this.subtype = subtype; + } + + public Behavior getBehavior() { + return behavior; + } + + public CLIControlPermissionResponse setBehavior(Behavior behavior) { + this.behavior = behavior; + return this; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlResponse.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlResponse.java index 5e193e2cd..bce0c03cc 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlResponse.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlResponse.java @@ -21,34 +21,43 @@ public class CLIControlResponse extends MessageBase { this.response = response; } + public Response createResponse() { + Response response = new Response<>(); + this.setResponse(response); + return response; + } + public static class Response { @JSONField(name = "request_id") private String requestId; - private String subtype; + private String subtype = "success"; R response; public String getRequestId() { return requestId; } - public void setRequestId(String requestId) { + public Response setRequestId(String requestId) { this.requestId = requestId; + return this; } public String getSubtype() { return subtype; } - public void setSubtype(String subtype) { + public Response setSubtype(String subtype) { this.subtype = subtype; + return this; } public R getResponse() { return response; } - public void setResponse(R response) { + public Response setResponse(R response) { this.response = response; + return this; } } } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetModelRequest.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetModelRequest.java new file mode 100644 index 000000000..d93a6fb6d --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetModelRequest.java @@ -0,0 +1,22 @@ +package com.alibaba.qwen.code.cli.protocol.message.control; + +public class CLIControlSetModelRequest { + String subtype = "set_model"; + String model; + + public String getSubtype() { + return subtype; + } + + public void setSubtype(String subtype) { + this.subtype = subtype; + } + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetPermissionModeRequest.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetPermissionModeRequest.java new file mode 100644 index 000000000..ea1ad9698 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetPermissionModeRequest.java @@ -0,0 +1,23 @@ +package com.alibaba.qwen.code.cli.protocol.message.control; + +public class CLIControlSetPermissionModeRequest { + String subtype = "set_permission_mode"; + + String mode; + + public String getSubtype() { + return subtype; + } + + public void setSubtype(String subtype) { + this.subtype = subtype; + } + + public String getMode() { + return mode; + } + + public void setMode(String mode) { + this.mode = mode; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/Session.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/Session.java index 346b0f1f2..79a210742 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/Session.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/Session.java @@ -1,59 +1,141 @@ package com.alibaba.qwen.code.cli.session; +import java.io.IOException; +import java.util.Optional; + import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; +import com.alibaba.fastjson2.JSONReader.Feature; import com.alibaba.fastjson2.TypeReference; import com.alibaba.qwen.code.cli.protocol.data.Capabilities; +import com.alibaba.qwen.code.cli.protocol.data.PermissionMode; +import com.alibaba.qwen.code.cli.protocol.data.behavior.Allow; +import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior; import com.alibaba.qwen.code.cli.protocol.message.SDKResultMessage; import com.alibaba.qwen.code.cli.protocol.message.SDKSystemMessage; import com.alibaba.qwen.code.cli.protocol.message.SDKUserMessage; import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKAssistantMessage; import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlInitializeRequest; import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlInitializeResponse; +import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlInterruptRequest; +import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlPermissionRequest; +import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlPermissionResponse; import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlRequest; import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlResponse; +import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlSetModelRequest; +import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlSetPermissionModeRequest; import com.alibaba.qwen.code.cli.session.event.SessionEventConsumers; -import com.alibaba.qwen.code.cli.session.exception.SessionCloseException; +import com.alibaba.qwen.code.cli.session.exception.SessionControlException; import com.alibaba.qwen.code.cli.session.exception.SessionSendPromptException; -import com.alibaba.qwen.code.cli.session.exception.SessionStartException; import com.alibaba.qwen.code.cli.transport.Transport; +import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class Session { private final Transport transport; - private Capabilities capabilities; + private CLIControlInitializeResponse lastCliControlInitializeResponse; + private SDKSystemMessage lastSdkSystemMessage; private static final Logger log = LoggerFactory.getLogger(Session.class); - public Session(Transport transport) throws SessionStartException { + public Session(Transport transport) throws SessionControlException { if (transport == null || !transport.isAvailable()) { - throw new SessionStartException("Transport is not available"); + throw new SessionControlException("Transport is not available"); } this.transport = transport; start(); } - private void start() throws SessionStartException { + public void start() throws SessionControlException { try { + if (!transport.isAvailable()) { + transport.start(); + } String response = transport.inputWaitForOneLine(CLIControlRequest.create(new CLIControlInitializeRequest()).toString()); - CLIControlResponse cliControlResponse = JSON.parseObject(response, new TypeReference>() {}); - this.capabilities = cliControlResponse.getResponse().getResponse().getCapabilities(); + CLIControlResponse cliControlResponse = JSON.parseObject(response, + new TypeReference>() {}); + this.lastCliControlInitializeResponse = cliControlResponse.getResponse().getResponse(); } catch (Exception e) { - throw new SessionStartException("Failed to initialize the session", e); + throw new SessionControlException("Failed to initialize the session", e); } } - public void close() throws SessionCloseException { + public void interrupt() throws SessionControlException { + if (!isAvailable()) { + throw new SessionControlException("Session is not available"); + } + + try { + transport.inputNoWaitResponse( + new CLIControlRequest().setRequest(new CLIControlInterruptRequest()).toString()); + } catch (Exception e) { + throw new SessionControlException("Failed to interrupt the session", e); + } + } + + public void setModel(String modelName) throws SessionControlException { + if (!isAvailable()) { + throw new SessionControlException("Session is not available"); + } + + CLIControlSetModelRequest cliControlSetModelRequest = new CLIControlSetModelRequest(); + cliControlSetModelRequest.setModel(modelName); + try { + transport.inputNoWaitResponse(new CLIControlRequest().setRequest(cliControlSetModelRequest).toString()); + } catch (Exception e) { + throw new SessionControlException("Failed to set model", e); + } + } + + public void setPermissionMode(PermissionMode permissionMode) throws SessionControlException { + if (!isAvailable()) { + throw new SessionControlException("Session is not available"); + } + + CLIControlSetPermissionModeRequest cliControlSetPermissionModeRequest = new CLIControlSetPermissionModeRequest(); + cliControlSetPermissionModeRequest.setMode(permissionMode.getValue()); + try { + transport.inputNoWaitResponse( + new CLIControlRequest().setRequest(cliControlSetPermissionModeRequest).toString()); + } catch (Exception e) { + throw new SessionControlException("Failed to set model", e); + } + } + + public void continueSession() throws SessionControlException { + resumeSession(getSessionId()); + } + + public void resumeSession(String sessionId) throws SessionControlException { + if (!isAvailable()) { + throw new SessionControlException("Session is not available"); + } + + if (StringUtils.isNotBlank(sessionId)) { + transport.getTransportOptions().setResumeSessionId(sessionId); + } + this.start(); + } + + public String getSessionId() { + return Optional.ofNullable(lastSdkSystemMessage).map(SDKSystemMessage::getSessionId).orElse(null); + } + + public void close() throws SessionControlException { try { transport.close(); } catch (Exception e) { - throw new SessionCloseException("Failed to close the session", e); + throw new SessionControlException("Failed to close the session", e); } } + public boolean isAvailable() { + return transport.isAvailable(); + } + public Capabilities getCapabilities() { - return capabilities; + return Optional.ofNullable(lastCliControlInitializeResponse).map(CLIControlInitializeResponse::getCapabilities).orElse(new Capabilities()); } public void sendPrompt(String prompt, SessionEventConsumers sessionEventConsumers) throws SessionSendPromptException { @@ -65,20 +147,33 @@ public class Session { transport.inputWaitForMultiLine(new SDKUserMessage().setContent(prompt).toString(), (line) -> { log.debug("read a message from agent {}", line); JSONObject jsonObject = JSON.parseObject(line); - String messageType = jsonObject.getString("type"); if ("system".equals(messageType)) { - sessionEventConsumers.onSystemMessage(JSON.parseObject(line, SDKSystemMessage.class)); + lastSdkSystemMessage = jsonObject.to(SDKSystemMessage.class); + sessionEventConsumers.onSystemMessage(this, lastSdkSystemMessage); return false; } else if ("assistant".equals(messageType)) { - sessionEventConsumers.onAssistantMessage(JSON.parseObject(line, SDKAssistantMessage.class)); + sessionEventConsumers.onAssistantMessage(this, jsonObject.to(SDKAssistantMessage.class)); + return false; + } else if ("user".equals(messageType)) { + sessionEventConsumers.onUserMessage(this, jsonObject.to(SDKUserMessage.class, Feature.FieldBased)); return false; } else if ("result".equals(messageType)) { - sessionEventConsumers.onResultMessage(JSON.parseObject(line, SDKResultMessage.class)); + sessionEventConsumers.onResultMessage(this, jsonObject.to(SDKResultMessage.class)); return true; + } else if ("control_response".equals(messageType)) { + sessionEventConsumers.onControlResponse(this, jsonObject.to(CLIControlResponse.class)); + if (!"error".equals(jsonObject.getString("subtype"))) { + return false; + } else { + log.info("control_response error: {}", jsonObject.toJSONString()); + return "error".equals(jsonObject.getString("subtype")); + } + } else if ("control_request".equals(messageType)) { + return processControlRequest(jsonObject, sessionEventConsumers); } else { log.warn("unknown message type: {}", messageType); - sessionEventConsumers.onOtherMessage(line); + sessionEventConsumers.onOtherMessage(this, line); return false; } }); @@ -86,4 +181,53 @@ public class Session { throw new SessionSendPromptException("Failed to send prompt", e); } } + + private boolean processControlRequest(JSONObject jsonObject, SessionEventConsumers sessionEventConsumers) { + String subType = Optional.of(jsonObject) + .map(cr -> cr.getJSONObject("request")) + .map(r -> r.getString("subtype")) + .orElse(""); + if ("can_use_tool".equals(subType)) { + try { + return processPermissionResponse(jsonObject, sessionEventConsumers); + } catch (IOException e) { + log.error("Failed to process permission response", e); + return false; + } + } else { + CLIControlResponse cliControlResponse = sessionEventConsumers.onControlRequest(this, + jsonObject.to(new TypeReference>() {})); + if (cliControlResponse != null) { + try { + transport.inputNoWaitResponse(cliControlResponse.toString()); + } catch (Exception e) { + log.error("Failed to process control response", e); + return false; + } + } + return false; + } + } + + private boolean processPermissionResponse(JSONObject jsonObject, SessionEventConsumers sessionEventConsumers) throws IOException { + CLIControlRequest permissionRequest = jsonObject.to(new TypeReference>() {}); + Behavior behavior = Optional.ofNullable(sessionEventConsumers.onPermissionRequest(this, permissionRequest)) + .map(b -> { + if (b instanceof Allow) { + Allow allow = (Allow) b; + if (allow.getUpdatedInput() == null) { + allow.setUpdatedInput(permissionRequest.getRequest().getInput()); + } + } + return b; + }) + .orElse(Behavior.defaultBehavior()); + CLIControlResponse permissionResponse = new CLIControlResponse<>(); + permissionResponse.createResponse().setResponse(new CLIControlPermissionResponse().setBehavior(behavior)).setRequestId(permissionRequest.getRequestId()); + String permissionMessage = permissionResponse.toString(); + log.debug("send permission message to agent: {}", permissionMessage); + transport.inputNoWaitResponse(permissionMessage); + + return false; + } } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventConsumers.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventConsumers.java index 9f9bb6fc1..e2100b5cc 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventConsumers.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventConsumers.java @@ -1,15 +1,29 @@ package com.alibaba.qwen.code.cli.session.event; +import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior; import com.alibaba.qwen.code.cli.protocol.message.SDKResultMessage; import com.alibaba.qwen.code.cli.protocol.message.SDKSystemMessage; +import com.alibaba.qwen.code.cli.protocol.message.SDKUserMessage; import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKAssistantMessage; +import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlPermissionRequest; +import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlRequest; +import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlResponse; +import com.alibaba.qwen.code.cli.session.Session; public interface SessionEventConsumers { - void onSystemMessage(SDKSystemMessage systemMessage); + void onSystemMessage(Session session, SDKSystemMessage systemMessage); - void onResultMessage(SDKResultMessage resultMessage); + void onResultMessage(Session session, SDKResultMessage resultMessage); - void onAssistantMessage(SDKAssistantMessage assistantMessage); + void onAssistantMessage(Session session, SDKAssistantMessage assistantMessage); - void onOtherMessage(String message); + void onUserMessage(Session session, SDKUserMessage userMessage); + + void onOtherMessage(Session session, String message); + + void onControlResponse(Session session, CLIControlResponse cliControlResponse); + + CLIControlResponse onControlRequest(Session session, CLIControlRequest cliControlRequest); + + Behavior onPermissionRequest(Session session, CLIControlRequest permissionRequest); } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventSimpleConsumers.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventSimpleConsumers.java index 584354d43..9c685e755 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventSimpleConsumers.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventSimpleConsumers.java @@ -1,23 +1,47 @@ package com.alibaba.qwen.code.cli.session.event; +import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior; import com.alibaba.qwen.code.cli.protocol.message.SDKResultMessage; import com.alibaba.qwen.code.cli.protocol.message.SDKSystemMessage; +import com.alibaba.qwen.code.cli.protocol.message.SDKUserMessage; import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKAssistantMessage; +import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlPermissionRequest; +import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlRequest; +import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlResponse; +import com.alibaba.qwen.code.cli.session.Session; public class SessionEventSimpleConsumers implements SessionEventConsumers { @Override - public void onSystemMessage(SDKSystemMessage systemMessage) { + public void onSystemMessage(Session session, SDKSystemMessage systemMessage) { } @Override - public void onResultMessage(SDKResultMessage resultMessage) { + public void onResultMessage(Session session, SDKResultMessage resultMessage) { } @Override - public void onAssistantMessage(SDKAssistantMessage assistantMessage) { + public void onAssistantMessage(Session session, SDKAssistantMessage assistantMessage) { } @Override - public void onOtherMessage(String message) { + public void onUserMessage(Session session, SDKUserMessage userMessage) { + } + + @Override + public void onOtherMessage(Session session, String message) { + } + + @Override + public void onControlResponse(Session session, CLIControlResponse cliControlResponse) { + } + + @Override + public CLIControlResponse onControlRequest(Session session, CLIControlRequest cliControlRequest) { + return new CLIControlResponse<>(); + } + + @Override + public Behavior onPermissionRequest(Session session, CLIControlRequest permissionRequest) { + return Behavior.defaultBehavior(); } } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionCloseException.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionCloseException.java deleted file mode 100644 index 3db39e793..000000000 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionCloseException.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.alibaba.qwen.code.cli.session.exception; - -public class SessionCloseException extends Exception { - public SessionCloseException() { - } - - public SessionCloseException(String message) { - super(message); - } - - public SessionCloseException(String message, Throwable cause) { - super(message, cause); - } - - public SessionCloseException(Throwable cause) { - super(cause); - } - - public SessionCloseException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { - super(message, cause, enableSuppression, writableStackTrace); - } -} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionControlException.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionControlException.java new file mode 100644 index 000000000..770d5982c --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionControlException.java @@ -0,0 +1,22 @@ +package com.alibaba.qwen.code.cli.session.exception; + +public class SessionControlException extends Exception { + public SessionControlException() { + } + + public SessionControlException(String message) { + super(message); + } + + public SessionControlException(String message, Throwable cause) { + super(message, cause); + } + + public SessionControlException(Throwable cause) { + super(cause); + } + + public SessionControlException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionStartException.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionStartException.java deleted file mode 100644 index 9d30f2367..000000000 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionStartException.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.alibaba.qwen.code.cli.session.exception; - -public class SessionStartException extends Exception { - public SessionStartException() { - } - - public SessionStartException(String message) { - super(message); - } - - public SessionStartException(String message, Throwable cause) { - super(message, cause); - } - - public SessionStartException(Throwable cause) { - super(cause); - } - - public SessionStartException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { - super(message, cause, enableSuppression, writableStackTrace); - } -} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/Transport.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/Transport.java index 5b66cbc90..b3d69ee28 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/Transport.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/Transport.java @@ -6,6 +6,10 @@ import java.util.concurrent.TimeoutException; import java.util.function.Function; public interface Transport { + TransportOptions getTransportOptions(); + + void start() throws IOException; + void close() throws IOException; boolean isAvailable(); diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java index b64df2ec6..b5e6ada6f 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java @@ -3,6 +3,8 @@ package com.alibaba.qwen.code.cli.transport; import java.util.List; import java.util.Map; +import com.alibaba.qwen.code.cli.protocol.data.PermissionMode; + public class TransportOptions implements Cloneable { private String pathToQwenExecutable; private String cwd; @@ -17,6 +19,7 @@ public class TransportOptions implements Cloneable { private Boolean includePartialMessages; private Long turnTimeoutMs; private Long messageTimeoutMs; + private String resumeSessionId; public String getPathToQwenExecutable() { return pathToQwenExecutable; @@ -122,6 +125,14 @@ public class TransportOptions implements Cloneable { this.messageTimeoutMs = messageTimeoutMs; } + public String getResumeSessionId() { + return resumeSessionId; + } + + public void setResumeSessionId(String resumeSessionId) { + this.resumeSessionId = resumeSessionId; + } + @Override public TransportOptions clone() { try { diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransport.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransport.java index b7d62b5d7..14a0eb0ff 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransport.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransport.java @@ -22,10 +22,9 @@ import java.util.function.Function; public class ProcessTransport implements Transport { private static final Logger log = LoggerFactory.getLogger(ProcessTransport.class); - TransportOptionsAdapter transportOptionsAdapter; - - protected final Long turnTimeoutMs; - protected final Long messageTimeoutMs; + private final TransportOptions transportOptions; + protected Long turnTimeoutMs; + protected Long messageTimeoutMs; protected Process process; protected BufferedWriter processInput; @@ -37,13 +36,21 @@ public class ProcessTransport implements Transport { } public ProcessTransport(TransportOptions transportOptions) throws IOException { - this.transportOptionsAdapter = new TransportOptionsAdapter(transportOptions); - turnTimeoutMs = transportOptionsAdapter.getHandledTransportOptions().getTurnTimeoutMs(); - messageTimeoutMs = transportOptionsAdapter.getHandledTransportOptions().getMessageTimeoutMs(); + this.transportOptions = transportOptions; start(); } - protected void start() throws IOException { + @Override + public TransportOptions getTransportOptions() { + return transportOptions; + } + + @Override + public void start() throws IOException { + TransportOptionsAdapter transportOptionsAdapter = new TransportOptionsAdapter(transportOptions); + this.turnTimeoutMs = transportOptionsAdapter.getHandledTransportOptions().getTurnTimeoutMs(); + this.messageTimeoutMs = transportOptionsAdapter.getHandledTransportOptions().getMessageTimeoutMs(); + String[] commandArgs = transportOptionsAdapter.buildCommandArgs(); log.debug("trans to command args: {}", transportOptionsAdapter); diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java index 4c6b7d48d..1f179f93e 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java @@ -72,6 +72,11 @@ class TransportOptionsAdapter { if (transportOptions.getIncludePartialMessages() != null && transportOptions.getIncludePartialMessages()) { args.add("--include-partial-messages"); } + + if (StringUtils.isNotBlank(transportOptions.getResumeSessionId())) { + args.add("--resume"); + args.add(transportOptions.getResumeSessionId()); + } return args.toArray(new String[] {}); } diff --git a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java index 69898b948..51c37c6c8 100644 --- a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java +++ b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java @@ -3,17 +3,23 @@ package com.alibaba.qwen.code.cli.session; import java.io.IOException; import com.alibaba.fastjson2.JSON; +import com.alibaba.qwen.code.cli.protocol.data.PermissionMode; +import com.alibaba.qwen.code.cli.protocol.data.behavior.Allow; +import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior; import com.alibaba.qwen.code.cli.protocol.message.SDKResultMessage; import com.alibaba.qwen.code.cli.protocol.message.SDKSystemMessage; import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKAssistantMessage; +import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlPermissionRequest; +import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlRequest; +import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlResponse; import com.alibaba.qwen.code.cli.session.event.SessionEventConsumers; import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; -import com.alibaba.qwen.code.cli.session.exception.SessionCloseException; +import com.alibaba.qwen.code.cli.session.exception.SessionControlException; import com.alibaba.qwen.code.cli.session.exception.SessionSendPromptException; -import com.alibaba.qwen.code.cli.session.exception.SessionStartException; import com.alibaba.qwen.code.cli.transport.Transport; import com.alibaba.qwen.code.cli.transport.process.ProcessTransport; +import org.apache.commons.lang3.StringUtils; import org.junit.jupiter.api.Test; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -21,34 +27,111 @@ import org.slf4j.LoggerFactory; class SessionTest { private static final Logger log = LoggerFactory.getLogger(SessionTest.class); + @Test - void sendPrompt() throws IOException, SessionStartException, SessionSendPromptException, SessionCloseException { + void setPermissionModeSuccessfully() throws IOException, SessionControlException, SessionSendPromptException { Transport transport = new ProcessTransport(); Session session = new Session(transport); - session.sendPrompt("hello world", new SessionEventSimpleConsumers() { + + session.setPermissionMode(PermissionMode.YOLO); + session.sendPrompt("in the dir src/test/temp/, create file empty file test.touch", new SessionEventSimpleConsumers()); + + session.setPermissionMode(PermissionMode.PLAN); + session.sendPrompt("rename test.touch to test_rename.touch", new SessionEventSimpleConsumers()); + + session.setPermissionMode(PermissionMode.AUTO_EDIT); + session.sendPrompt("rename test.touch to test_rename.touch", new SessionEventSimpleConsumers()); + + session.sendPrompt("rename test.touch to test_rename.touch again user will allow", new SessionEventSimpleConsumers() { + public Behavior onPermissionRequest(Session session, CLIControlRequest permissionRequest) { + log.info("permissionRequest: {}", permissionRequest); + return new Allow().setUpdatedInput(permissionRequest.getRequest().getInput()); + } + }); + + session.close(); + } + + @Test + void sendPromptAndSetModelSuccessfully() throws IOException, SessionControlException, SessionSendPromptException { + Transport transport = new ProcessTransport(); + Session session = new Session(transport); + + session.setModel("qwen3-coder-flash"); + writeSplitLine("setModel 1 end"); + + session.sendPrompt("hello world", new SessionEventSimpleConsumers()); + writeSplitLine("prompt 1 end"); + + session.setModel("qwen3-coder-plus"); + writeSplitLine("setModel 1 end"); + + session.sendPrompt("查看下当前目录有多少个文件", new SessionEventSimpleConsumers()); + writeSplitLine("prompt 2 end"); + + session.setModel("qwen3-max"); + writeSplitLine("setModel 1 end"); + + session.sendPrompt("查看下当前目录有多少个xml文件", new SessionEventSimpleConsumers()); + writeSplitLine("prompt 3 end"); + + session.close(); + } + + @Test + void sendPromptAndInterruptContinueSuccessfully() throws IOException, SessionControlException, SessionSendPromptException { + Transport transport = new ProcessTransport(); + Session session = new Session(transport); + + SessionEventConsumers sessionEventConsumers = new SessionEventSimpleConsumers() { @Override - public void onSystemMessage(SDKSystemMessage systemMessage) { + public void onSystemMessage(Session session, SDKSystemMessage systemMessage) { log.info("systemMessage: {}", systemMessage); } @Override - public void onResultMessage(SDKResultMessage resultMessage) { + public void onResultMessage(Session session, SDKResultMessage resultMessage) { log.info("resultMessage: {}", resultMessage); } @Override - public void onAssistantMessage(SDKAssistantMessage assistantMessage) { + public void onAssistantMessage(Session session, SDKAssistantMessage assistantMessage) { log.info("assistantMessage: {}", assistantMessage); + try { + session.interrupt(); + } catch (SessionControlException e) { + log.error("interrupt error", e); + } } @Override - public void onOtherMessage(String message) { + public void onControlResponse(Session session, CLIControlResponse cliControlResponse) { + log.info("cliControlResponse: {}", cliControlResponse); + } + + @Override + public void onOtherMessage(Session session, String message) { log.info("otherMessage: {}", message); } - }); + }; + session.sendPrompt("查看下当前目录有多少个文件", sessionEventConsumers); + writeSplitLine("prompt 1 end"); + + session.continueSession(); + session.sendPrompt("hello world", sessionEventConsumers); + writeSplitLine("prompt 2 end"); + + session.continueSession(); + session.sendPrompt("当前目录有多少个java文件", sessionEventConsumers); + writeSplitLine("prompt 3 end"); + session.close(); } + public void writeSplitLine(String line) { + log.info("{} {}",line, StringUtils.repeat("=", 300)); + } + @Test void testJSON() { String json = "{\"type\":\"assistant\",\"uuid\":\"ed8374fe-a4eb-4fc0-9780-9bd2fd831cda\",\"session_id\":\"166badc0-e6d3-4978-ae47-4ccd51c468ef\",\"message\":{\"content\":[{\"text\":\"Hello! How can I help you with the Qwen Code SDK for Java today?\",\"type\":\"text\"}],\"id\":\"ed8374fe-a4eb-4fc0-9780-9bd2fd831cda\",\"model\":\"qwen3-coder-plus\",\"role\":\"assistant\",\"type\":\"message\",\"usage\":{\"cache_read_input_tokens\":12766,\"input_tokens\":12770,\"output_tokens\":17,\"total_tokens\":12787}}}"; diff --git a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/PermissionModeTest.java b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/PermissionModeTest.java index 7707c5fda..97e6fe0d1 100644 --- a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/PermissionModeTest.java +++ b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/PermissionModeTest.java @@ -1,5 +1,7 @@ package com.alibaba.qwen.code.cli.transport; +import com.alibaba.qwen.code.cli.protocol.data.PermissionMode; + import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; diff --git a/packages/sdk-java/todo b/packages/sdk-java/todo new file mode 100644 index 000000000..656489715 --- /dev/null +++ b/packages/sdk-java/todo @@ -0,0 +1,6 @@ +1、event timeout +2、mcp servers +3、errorHandle +4、review QwenCli +https://github.com/QwenLM/qwen-code/tree/main/packages/sdk-typescript#custom-permission-handler + From 5a5dae19874707cfc4ddea5fdcf4213c8e12ed1e Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Tue, 30 Dec 2025 16:35:34 +0100 Subject: [PATCH 033/142] Add German language support and remove a misleading witty phrase --- packages/cli/src/config/settingsSchema.ts | 1 + packages/cli/src/i18n/locales/de.js | 1073 +++++++++++++++++++++ packages/cli/src/i18n/locales/en.js | 1 - packages/cli/src/i18n/locales/ru.js | 1 - 4 files changed, 1074 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/i18n/locales/de.js diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 2fe467ba9..5159613b6 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -202,6 +202,7 @@ const SETTINGS_SCHEMA = { { value: 'en', label: 'English' }, { value: 'zh', label: '中文 (Chinese)' }, { value: 'ru', label: 'Русский (Russian)' }, + { value: 'de', label: 'Deutsch (German)' }, ], }, terminalBell: { diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js new file mode 100644 index 000000000..4ddcf4c3d --- /dev/null +++ b/packages/cli/src/i18n/locales/de.js @@ -0,0 +1,1073 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +// German translations for Qwen Code CLI +// Deutsche Ubersetzungen fur Qwen Code CLI + +export default { + // ============================================================================ + // Help / UI Components + // ============================================================================ + 'Basics:': 'Grundlagen:', + 'Add context': 'Kontext hinzufugen', + 'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.': + 'Verwenden Sie {{symbol}}, um Dateien als Kontext anzugeben (z.B. {{example}}), um bestimmte Dateien oder Ordner auszuwahlen.', + '@': '@', + '@src/myFile.ts': '@src/myFile.ts', + 'Shell mode': 'Shell-Modus', + 'YOLO mode': 'YOLO-Modus', + 'plan mode': 'Planungsmodus', + 'auto-accept edits': 'Anderungen automatisch akzeptieren', + 'Accepting edits': 'Anderungen werden akzeptiert', + '(shift + tab to cycle)': '(Umschalt + Tab zum Wechseln)', + 'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).': + 'Shell-Befehle uber {{symbol}} ausfuhren (z.B. {{example1}}) oder naturliche Sprache verwenden (z.B. {{example2}}).', + '!': '!', + '!npm run start': '!npm run start', + 'start server': 'Server starten', + 'Commands:': 'Befehle:', + 'shell command': 'Shell-Befehl', + 'Model Context Protocol command (from external servers)': + 'Model Context Protocol Befehl (von externen Servern)', + 'Keyboard Shortcuts:': 'Tastenkurzel:', + 'Jump through words in the input': 'Worter in der Eingabe uberspringen', + 'Close dialogs, cancel requests, or quit application': + 'Dialoge schliessen, Anfragen abbrechen oder Anwendung beenden', + 'New line': 'Neue Zeile', + 'New line (Alt+Enter works for certain linux distros)': + 'Neue Zeile (Alt+Enter funktioniert bei bestimmten Linux-Distributionen)', + 'Clear the screen': 'Bildschirm loschen', + 'Open input in external editor': 'Eingabe in externem Editor offnen', + 'Send message': 'Nachricht senden', + 'Initializing...': 'Initialisierung...', + 'Connecting to MCP servers... ({{connected}}/{{total}})': + 'Verbindung zu MCP-Servern wird hergestellt... ({{connected}}/{{total}})', + 'Type your message or @path/to/file': 'Nachricht eingeben oder @Pfad/zur/Datei', + "Press 'i' for INSERT mode and 'Esc' for NORMAL mode.": + "Drucken Sie 'i' fur den EINFUGE-Modus und 'Esc' fur den NORMAL-Modus.", + 'Cancel operation / Clear input (double press)': + 'Vorgang abbrechen / Eingabe loschen (doppelt drucken)', + 'Cycle approval modes': 'Genehmigungsmodi durchschalten', + 'Cycle through your prompt history': 'Eingabeverlauf durchblattern', + 'For a full list of shortcuts, see {{docPath}}': + 'Eine vollstandige Liste der Tastenkurzel finden Sie unter {{docPath}}', + 'docs/keyboard-shortcuts.md': 'docs/keyboard-shortcuts.md', + 'for help on Qwen Code': 'fur Hilfe zu Qwen Code', + 'show version info': 'Versionsinformationen anzeigen', + 'submit a bug report': 'Fehlerbericht einreichen', + 'About Qwen Code': 'Uber Qwen Code', + + // ============================================================================ + // System Information Fields + // ============================================================================ + 'CLI Version': 'CLI-Version', + 'Git Commit': 'Git-Commit', + Model: 'Modell', + Sandbox: 'Sandbox', + 'OS Platform': 'Betriebssystem', + 'OS Arch': 'OS-Architektur', + 'OS Release': 'OS-Version', + 'Node.js Version': 'Node.js-Version', + 'NPM Version': 'NPM-Version', + 'Session ID': 'Sitzungs-ID', + 'Auth Method': 'Authentifizierungsmethode', + 'Base URL': 'Basis-URL', + 'Memory Usage': 'Speichernutzung', + 'IDE Client': 'IDE-Client', + + // ============================================================================ + // Commands - General + // ============================================================================ + 'Analyzes the project and creates a tailored QWEN.md file.': + 'Analysiert das Projekt und erstellt eine massgeschneiderte QWEN.md-Datei.', + 'list available Qwen Code tools. Usage: /tools [desc]': + 'Verfugbare Qwen Code Werkzeuge auflisten. Verwendung: /tools [desc]', + 'Available Qwen Code CLI tools:': 'Verfugbare Qwen Code CLI-Werkzeuge:', + 'No tools available': 'Keine Werkzeuge verfugbar', + 'View or change the approval mode for tool usage': + 'Genehmigungsmodus fur Werkzeugnutzung anzeigen oder andern', + 'View or change the language setting': 'Spracheinstellung anzeigen oder andern', + 'change the theme': 'Design andern', + 'Select Theme': 'Design auswahlen', + Preview: 'Vorschau', + '(Use Enter to select, Tab to configure scope)': + '(Enter zum Auswahlen, Tab zum Konfigurieren des Bereichs)', + '(Use Enter to apply scope, Tab to select theme)': + '(Enter zum Anwenden des Bereichs, Tab zum Auswahlen des Designs)', + 'Theme configuration unavailable due to NO_COLOR env variable.': + 'Design-Konfiguration aufgrund der NO_COLOR-Umgebungsvariable nicht verfugbar.', + 'Theme "{{themeName}}" not found.': 'Design "{{themeName}}" nicht gefunden.', + 'Theme "{{themeName}}" not found in selected scope.': + 'Design "{{themeName}}" im ausgewahlten Bereich nicht gefunden.', + 'Clear conversation history and free up context': + 'Gesprachsverlauf loschen und Kontext freigeben', + 'Compresses the context by replacing it with a summary.': + 'Komprimiert den Kontext durch Ersetzen mit einer Zusammenfassung.', + 'open full Qwen Code documentation in your browser': + 'Vollstandige Qwen Code Dokumentation im Browser offnen', + 'Configuration not available.': 'Konfiguration nicht verfugbar.', + 'change the auth method': 'Authentifizierungsmethode andern', + 'Copy the last result or code snippet to clipboard': + 'Letztes Ergebnis oder Codeausschnitt in die Zwischenablage kopieren', + + // ============================================================================ + // Commands - Agents + // ============================================================================ + 'Manage subagents for specialized task delegation.': + 'Unteragenten fur spezialisierte Aufgabendelegation verwalten.', + 'Manage existing subagents (view, edit, delete).': + 'Bestehende Unteragenten verwalten (anzeigen, bearbeiten, loschen).', + 'Create a new subagent with guided setup.': + 'Neuen Unteragenten mit gefuhrter Einrichtung erstellen.', + + // ============================================================================ + // Agents - Management Dialog + // ============================================================================ + Agents: 'Agenten', + 'Choose Action': 'Aktion wahlen', + 'Edit {{name}}': '{{name}} bearbeiten', + 'Edit Tools: {{name}}': 'Werkzeuge bearbeiten: {{name}}', + 'Edit Color: {{name}}': 'Farbe bearbeiten: {{name}}', + 'Delete {{name}}': '{{name}} loschen', + 'Unknown Step': 'Unbekannter Schritt', + 'Esc to close': 'Esc zum Schliessen', + 'Enter to select, ↑↓ to navigate, Esc to close': + 'Enter zum Auswahlen, ↑↓ zum Navigieren, Esc zum Schliessen', + 'Esc to go back': 'Esc zum Zuruckgehen', + 'Enter to confirm, Esc to cancel': 'Enter zum Bestatigen, Esc zum Abbrechen', + 'Enter to select, ↑↓ to navigate, Esc to go back': + 'Enter zum Auswahlen, ↑↓ zum Navigieren, Esc zum Zuruckgehen', + 'Invalid step: {{step}}': 'Ungultiger Schritt: {{step}}', + 'No subagents found.': 'Keine Unteragenten gefunden.', + "Use '/agents create' to create your first subagent.": + "Verwenden Sie '/agents create', um Ihren ersten Unteragenten zu erstellen.", + '(built-in)': '(integriert)', + '(overridden by project level agent)': '(uberschrieben durch Projektagent)', + 'Project Level ({{path}})': 'Projektebene ({{path}})', + 'User Level ({{path}})': 'Benutzerebene ({{path}})', + 'Built-in Agents': 'Integrierte Agenten', + 'Using: {{count}} agents': 'Verwendet: {{count}} Agenten', + 'View Agent': 'Agent anzeigen', + 'Edit Agent': 'Agent bearbeiten', + 'Delete Agent': 'Agent loschen', + Back: 'Zuruck', + 'No agent selected': 'Kein Agent ausgewahlt', + 'File Path: ': 'Dateipfad: ', + 'Tools: ': 'Werkzeuge: ', + 'Color: ': 'Farbe: ', + 'Description:': 'Beschreibung:', + 'System Prompt:': 'System-Prompt:', + 'Open in editor': 'Im Editor offnen', + 'Edit tools': 'Werkzeuge bearbeiten', + 'Edit color': 'Farbe bearbeiten', + '❌ Error:': '❌ Fehler:', + 'Are you sure you want to delete agent "{{name}}"?': + 'Sind Sie sicher, dass Sie den Agenten "{{name}}" loschen mochten?', + // ============================================================================ + // Agents - Creation Wizard + // ============================================================================ + 'Project Level (.qwen/agents/)': 'Projektebene (.qwen/agents/)', + 'User Level (~/.qwen/agents/)': 'Benutzerebene (~/.qwen/agents/)', + '✅ Subagent Created Successfully!': '✅ Unteragent erfolgreich erstellt!', + 'Subagent "{{name}}" has been saved to {{level}} level.': + 'Unteragent "{{name}}" wurde auf {{level}}-Ebene gespeichert.', + 'Name: ': 'Name: ', + 'Location: ': 'Speicherort: ', + '❌ Error saving subagent:': '❌ Fehler beim Speichern des Unteragenten:', + 'Warnings:': 'Warnungen:', + 'Name "{{name}}" already exists at {{level}} level - will overwrite existing subagent': + 'Name "{{name}}" existiert bereits auf {{level}}-Ebene - bestehender Unteragent wird uberschrieben', + 'Name "{{name}}" exists at user level - project level will take precedence': + 'Name "{{name}}" existiert auf Benutzerebene - Projektebene hat Vorrang', + 'Name "{{name}}" exists at project level - existing subagent will take precedence': + 'Name "{{name}}" existiert auf Projektebene - bestehender Unteragent hat Vorrang', + 'Description is over {{length}} characters': + 'Beschreibung ist uber {{length}} Zeichen', + 'System prompt is over {{length}} characters': + 'System-Prompt ist uber {{length}} Zeichen', + // Agents - Creation Wizard Steps + 'Step {{n}}: Choose Location': 'Schritt {{n}}: Speicherort wahlen', + 'Step {{n}}: Choose Generation Method': + 'Schritt {{n}}: Generierungsmethode wahlen', + 'Generate with Qwen Code (Recommended)': + 'Mit Qwen Code generieren (Empfohlen)', + 'Manual Creation': 'Manuelle Erstellung', + 'Describe what this subagent should do and when it should be used. (Be comprehensive for best results)': + 'Beschreiben Sie, was dieser Unteragent tun soll und wann er verwendet werden soll. (Ausfuhrliche Beschreibung fur beste Ergebnisse)', + 'e.g., Expert code reviewer that reviews code based on best practices...': + 'z.B. Experte fur Code-Reviews, der Code nach Best Practices uberpruft...', + 'Generating subagent configuration...': + 'Unteragent-Konfiguration wird generiert...', + 'Failed to generate subagent: {{error}}': + 'Fehler beim Generieren des Unteragenten: {{error}}', + 'Step {{n}}: Describe Your Subagent': 'Schritt {{n}}: Unteragent beschreiben', + 'Step {{n}}: Enter Subagent Name': 'Schritt {{n}}: Unteragent-Name eingeben', + 'Step {{n}}: Enter System Prompt': 'Schritt {{n}}: System-Prompt eingeben', + 'Step {{n}}: Enter Description': 'Schritt {{n}}: Beschreibung eingeben', + // Agents - Tool Selection + 'Step {{n}}: Select Tools': 'Schritt {{n}}: Werkzeuge auswahlen', + 'All Tools (Default)': 'Alle Werkzeuge (Standard)', + 'All Tools': 'Alle Werkzeuge', + 'Read-only Tools': 'Nur-Lese-Werkzeuge', + 'Read & Edit Tools': 'Lese- und Bearbeitungswerkzeuge', + 'Read & Edit & Execution Tools': 'Lese-, Bearbeitungs- und Ausfuhrungswerkzeuge', + 'All tools selected, including MCP tools': + 'Alle Werkzeuge ausgewahlt, einschliesslich MCP-Werkzeuge', + 'Selected tools:': 'Ausgewahlte Werkzeuge:', + 'Read-only tools:': 'Nur-Lese-Werkzeuge:', + 'Edit tools:': 'Bearbeitungswerkzeuge:', + 'Execution tools:': 'Ausfuhrungswerkzeuge:', + 'Step {{n}}: Choose Background Color': 'Schritt {{n}}: Hintergrundfarbe wahlen', + 'Step {{n}}: Confirm and Save': 'Schritt {{n}}: Bestatigen und Speichern', + // Agents - Navigation & Instructions + 'Esc to cancel': 'Esc zum Abbrechen', + 'Press Enter to save, e to save and edit, Esc to go back': + 'Enter zum Speichern, e zum Speichern und Bearbeiten, Esc zum Zuruckgehen', + 'Press Enter to continue, {{navigation}}Esc to {{action}}': + 'Enter zum Fortfahren, {{navigation}}Esc zum {{action}}', + cancel: 'Abbrechen', + 'go back': 'Zuruckgehen', + '↑↓ to navigate, ': '↑↓ zum Navigieren, ', + 'Enter a clear, unique name for this subagent.': + 'Geben Sie einen eindeutigen Namen fur diesen Unteragenten ein.', + 'e.g., Code Reviewer': 'z.B. Code-Reviewer', + 'Name cannot be empty.': 'Name darf nicht leer sein.', + "Write the system prompt that defines this subagent's behavior. Be comprehensive for best results.": + 'Schreiben Sie den System-Prompt, der das Verhalten dieses Unteragenten definiert. Ausfuhrlich fur beste Ergebnisse.', + 'e.g., You are an expert code reviewer...': + 'z.B. Sie sind ein Experte fur Code-Reviews...', + 'System prompt cannot be empty.': 'System-Prompt darf nicht leer sein.', + 'Describe when and how this subagent should be used.': + 'Beschreiben Sie, wann und wie dieser Unteragent verwendet werden soll.', + 'e.g., Reviews code for best practices and potential bugs.': + 'z.B. Uberpruft Code auf Best Practices und mogliche Fehler.', + 'Description cannot be empty.': 'Beschreibung darf nicht leer sein.', + 'Failed to launch editor: {{error}}': 'Fehler beim Starten des Editors: {{error}}', + 'Failed to save and edit subagent: {{error}}': + 'Fehler beim Speichern und Bearbeiten des Unteragenten: {{error}}', + + // ============================================================================ + // Commands - General (continued) + // ============================================================================ + 'View and edit Qwen Code settings': 'Qwen Code Einstellungen anzeigen und bearbeiten', + Settings: 'Einstellungen', + '(Use Enter to select{{tabText}})': '(Enter zum Auswahlen{{tabText}})', + ', Tab to change focus': ', Tab zum Fokuswechsel', + 'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.': + 'Um Anderungen zu sehen, muss Qwen Code neu gestartet werden. Drucken Sie r, um jetzt zu beenden und Anderungen anzuwenden.', + 'The command "/{{command}}" is not supported in non-interactive mode.': + 'Der Befehl "/{{command}}" wird im nicht-interaktiven Modus nicht unterstutzt.', + // ============================================================================ + // Settings Labels + // ============================================================================ + 'Vim Mode': 'Vim-Modus', + 'Disable Auto Update': 'Automatische Updates deaktivieren', + 'Enable Prompt Completion': 'Eingabevervollstandigung aktivieren', + 'Debug Keystroke Logging': 'Debug-Protokollierung von Tastatureingaben', + Language: 'Sprache', + 'Output Format': 'Ausgabeformat', + 'Hide Window Title': 'Fenstertitel ausblenden', + 'Show Status in Title': 'Status im Titel anzeigen', + 'Hide Tips': 'Tipps ausblenden', + 'Hide Banner': 'Banner ausblenden', + 'Hide Context Summary': 'Kontextzusammenfassung ausblenden', + 'Hide CWD': 'Arbeitsverzeichnis ausblenden', + 'Hide Sandbox Status': 'Sandbox-Status ausblenden', + 'Hide Model Info': 'Modellinformationen ausblenden', + 'Hide Footer': 'Fusszeile ausblenden', + 'Show Memory Usage': 'Speichernutzung anzeigen', + 'Show Line Numbers': 'Zeilennummern anzeigen', + 'Show Citations': 'Quellenangaben anzeigen', + 'Custom Witty Phrases': 'Benutzerdefinierte Witzige Spruche', + 'Enable Welcome Back': 'Willkommen-zuruck aktivieren', + 'Disable Loading Phrases': 'Ladespruche deaktivieren', + 'Screen Reader Mode': 'Bildschirmleser-Modus', + 'IDE Mode': 'IDE-Modus', + 'Max Session Turns': 'Maximale Sitzungsrunden', + 'Skip Next Speaker Check': 'Nachste-Sprecher-Prufung uberspringen', + 'Skip Loop Detection': 'Schleifenerkennung uberspringen', + 'Skip Startup Context': 'Startkontext uberspringen', + 'Enable OpenAI Logging': 'OpenAI-Protokollierung aktivieren', + 'OpenAI Logging Directory': 'OpenAI-Protokollierungsverzeichnis', + Timeout: 'Zeitlimit', + 'Max Retries': 'Maximale Wiederholungen', + 'Disable Cache Control': 'Cache-Steuerung deaktivieren', + 'Memory Discovery Max Dirs': 'Maximale Verzeichnisse fur Speichererkennung', + 'Load Memory From Include Directories': + 'Speicher aus Include-Verzeichnissen laden', + 'Respect .gitignore': '.gitignore beachten', + 'Respect .qwenignore': '.qwenignore beachten', + 'Enable Recursive File Search': 'Rekursive Dateisuche aktivieren', + 'Disable Fuzzy Search': 'Unscharfe Suche deaktivieren', + 'Enable Interactive Shell': 'Interaktive Shell aktivieren', + 'Show Color': 'Farbe anzeigen', + 'Auto Accept': 'Automatisch akzeptieren', + 'Use Ripgrep': 'Ripgrep verwenden', + 'Use Builtin Ripgrep': 'Integriertes Ripgrep verwenden', + 'Enable Tool Output Truncation': 'Werkzeugausgabe-Kurzung aktivieren', + 'Tool Output Truncation Threshold': 'Schwellenwert fur Werkzeugausgabe-Kurzung', + 'Tool Output Truncation Lines': 'Zeilen fur Werkzeugausgabe-Kurzung', + 'Folder Trust': 'Ordnervertrauen', + 'Vision Model Preview': 'Vision-Modell-Vorschau', + 'Tool Schema Compliance': 'Werkzeug-Schema-Konformitat', + // Settings enum options + 'Auto (detect from system)': 'Automatisch (vom System erkennen)', + Text: 'Text', + JSON: 'JSON', + Plan: 'Plan', + Default: 'Standard', + 'Auto Edit': 'Automatisch bearbeiten', + YOLO: 'YOLO', + 'toggle vim mode on/off': 'Vim-Modus ein-/ausschalten', + 'check session stats. Usage: /stats [model|tools]': + 'Sitzungsstatistiken prufen. Verwendung: /stats [model|tools]', + 'Show model-specific usage statistics.': + 'Modellspezifische Nutzungsstatistiken anzeigen.', + 'Show tool-specific usage statistics.': + 'Werkzeugspezifische Nutzungsstatistiken anzeigen.', + 'exit the cli': 'CLI beenden', + 'list configured MCP servers and tools, or authenticate with OAuth-enabled servers': + 'Konfigurierte MCP-Server und Werkzeuge auflisten oder mit OAuth-fahigen Servern authentifizieren', + 'Manage workspace directories': 'Arbeitsbereichsverzeichnisse verwalten', + 'Add directories to the workspace. Use comma to separate multiple paths': + 'Verzeichnisse zum Arbeitsbereich hinzufugen. Komma zum Trennen mehrerer Pfade verwenden', + 'Show all directories in the workspace': + 'Alle Verzeichnisse im Arbeitsbereich anzeigen', + 'set external editor preference': 'Externen Editor festlegen', + 'Manage extensions': 'Erweiterungen verwalten', + 'List active extensions': 'Aktive Erweiterungen auflisten', + 'Update extensions. Usage: update |--all': + 'Erweiterungen aktualisieren. Verwendung: update |--all', + 'manage IDE integration': 'IDE-Integration verwalten', + 'check status of IDE integration': 'Status der IDE-Integration prufen', + 'install required IDE companion for {{ideName}}': + 'Erforderlichen IDE-Begleiter fur {{ideName}} installieren', + 'enable IDE integration': 'IDE-Integration aktivieren', + 'disable IDE integration': 'IDE-Integration deaktivieren', + 'IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: VS Code or VS Code forks.': + 'IDE-Integration wird in Ihrer aktuellen Umgebung nicht unterstutzt. Um diese Funktion zu nutzen, fuhren Sie Qwen Code in einer dieser unterstutzten IDEs aus: VS Code oder VS Code-Forks.', + 'Set up GitHub Actions': 'GitHub Actions einrichten', + 'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf, Trae)': + 'Terminal-Tastenbelegungen fur mehrzeilige Eingabe konfigurieren (VS Code, Cursor, Windsurf, Trae)', + 'Please restart your terminal for the changes to take effect.': + 'Bitte starten Sie Ihr Terminal neu, damit die Anderungen wirksam werden.', + 'Failed to configure terminal: {{error}}': + 'Fehler beim Konfigurieren des Terminals: {{error}}', + 'Could not determine {{terminalName}} config path on Windows: APPDATA environment variable is not set.': + 'Konnte {{terminalName}}-Konfigurationspfad unter Windows nicht ermitteln: APPDATA-Umgebungsvariable ist nicht gesetzt.', + '{{terminalName}} keybindings.json exists but is not a valid JSON array. Please fix the file manually or delete it to allow automatic configuration.': + '{{terminalName}} keybindings.json existiert, ist aber kein gultiges JSON-Array. Bitte korrigieren Sie die Datei manuell oder loschen Sie sie, um automatische Konfiguration zu ermoglichen.', + 'File: {{file}}': 'Datei: {{file}}', + 'Failed to parse {{terminalName}} keybindings.json. The file contains invalid JSON. Please fix the file manually or delete it to allow automatic configuration.': + 'Fehler beim Parsen von {{terminalName}} keybindings.json. Die Datei enthalt ungultiges JSON. Bitte korrigieren Sie die Datei manuell oder loschen Sie sie, um automatische Konfiguration zu ermoglichen.', + 'Error: {{error}}': 'Fehler: {{error}}', + 'Shift+Enter binding already exists': 'Umschalt+Enter-Belegung existiert bereits', + 'Ctrl+Enter binding already exists': 'Strg+Enter-Belegung existiert bereits', + 'Existing keybindings detected. Will not modify to avoid conflicts.': + 'Bestehende Tastenbelegungen erkannt. Keine Anderungen, um Konflikte zu vermeiden.', + 'Please check and modify manually if needed: {{file}}': + 'Bitte prufen und bei Bedarf manuell andern: {{file}}', + 'Added Shift+Enter and Ctrl+Enter keybindings to {{terminalName}}.': + 'Umschalt+Enter und Strg+Enter Tastenbelegungen zu {{terminalName}} hinzugefugt.', + 'Modified: {{file}}': 'Geandert: {{file}}', + '{{terminalName}} keybindings already configured.': + '{{terminalName}}-Tastenbelegungen bereits konfiguriert.', + 'Failed to configure {{terminalName}}.': + 'Fehler beim Konfigurieren von {{terminalName}}.', + 'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).': + 'Ihr Terminal ist bereits fur optimale Erfahrung mit mehrzeiliger Eingabe konfiguriert (Umschalt+Enter und Strg+Enter).', + 'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.': + 'Terminal-Typ konnte nicht erkannt werden. Unterstutzte Terminals: VS Code, Cursor, Windsurf und Trae.', + 'Terminal "{{terminal}}" is not supported yet.': + 'Terminal "{{terminal}}" wird noch nicht unterstutzt.', + + // ============================================================================ + // Commands - Language + // ============================================================================ + 'Invalid language. Available: en-US, zh-CN': + 'Ungultige Sprache. Verfugbar: en-US, zh-CN', + 'Language subcommands do not accept additional arguments.': + 'Sprach-Unterbefehle akzeptieren keine zusatzlichen Argumente.', + 'Current UI language: {{lang}}': 'Aktuelle UI-Sprache: {{lang}}', + 'Current LLM output language: {{lang}}': + 'Aktuelle LLM-Ausgabesprache: {{lang}}', + 'LLM output language not set': 'LLM-Ausgabesprache nicht festgelegt', + 'Set UI language': 'UI-Sprache festlegen', + 'Set LLM output language': 'LLM-Ausgabesprache festlegen', + 'Usage: /language ui [zh-CN|en-US]': 'Verwendung: /language ui [zh-CN|en-US]', + 'Usage: /language output ': 'Verwendung: /language output ', + 'Example: /language output 中文': 'Beispiel: /language output Deutsch', + 'Example: /language output English': 'Beispiel: /language output English', + 'Example: /language output 日本語': 'Beispiel: /language output Japanisch', + 'UI language changed to {{lang}}': 'UI-Sprache geandert zu {{lang}}', + 'LLM output language rule file generated at {{path}}': + 'LLM-Ausgabesprach-Regeldatei generiert unter {{path}}', + 'Please restart the application for the changes to take effect.': + 'Bitte starten Sie die Anwendung neu, damit die Anderungen wirksam werden.', + 'Failed to generate LLM output language rule file: {{error}}': + 'Fehler beim Generieren der LLM-Ausgabesprach-Regeldatei: {{error}}', + 'Invalid command. Available subcommands:': + 'Ungultiger Befehl. Verfugbare Unterbefehle:', + 'Available subcommands:': 'Verfugbare Unterbefehle:', + 'To request additional UI language packs, please open an issue on GitHub.': + 'Um zusatzliche UI-Sprachpakete anzufordern, offnen Sie bitte ein Issue auf GitHub.', + 'Available options:': 'Verfugbare Optionen:', + ' - zh-CN: Simplified Chinese': ' - zh-CN: Vereinfachtes Chinesisch', + ' - en-US: English': ' - en-US: Englisch', + 'Set UI language to Simplified Chinese (zh-CN)': + 'UI-Sprache auf Vereinfachtes Chinesisch (zh-CN) setzen', + 'Set UI language to English (en-US)': 'UI-Sprache auf Englisch (en-US) setzen', + + // ============================================================================ + // Commands - Approval Mode + // ============================================================================ + 'Approval Mode': 'Genehmigungsmodus', + 'Current approval mode: {{mode}}': 'Aktueller Genehmigungsmodus: {{mode}}', + 'Available approval modes:': 'Verfugbare Genehmigungsmodi:', + 'Approval mode changed to: {{mode}}': 'Genehmigungsmodus geandert zu: {{mode}}', + 'Approval mode changed to: {{mode}} (saved to {{scope}} settings{{location}})': + 'Genehmigungsmodus geandert zu: {{mode}} (gespeichert in {{scope}} Einstellungen{{location}})', + 'Usage: /approval-mode [--session|--user|--project]': + 'Verwendung: /approval-mode [--session|--user|--project]', + + 'Scope subcommands do not accept additional arguments.': + 'Bereichs-Unterbefehle akzeptieren keine zusatzlichen Argumente.', + 'Plan mode - Analyze only, do not modify files or execute commands': + 'Planungsmodus - Nur analysieren, keine Dateien andern oder Befehle ausfuhren', + 'Default mode - Require approval for file edits or shell commands': + 'Standardmodus - Genehmigung fur Dateibearbeitungen oder Shell-Befehle erforderlich', + 'Auto-edit mode - Automatically approve file edits': + 'Automatischer Bearbeitungsmodus - Dateibearbeitungen automatisch genehmigen', + 'YOLO mode - Automatically approve all tools': + 'YOLO-Modus - Alle Werkzeuge automatisch genehmigen', + '{{mode}} mode': '{{mode}}-Modus', + 'Settings service is not available; unable to persist the approval mode.': + 'Einstellungsdienst nicht verfugbar; Genehmigungsmodus kann nicht gespeichert werden.', + 'Failed to save approval mode: {{error}}': + 'Fehler beim Speichern des Genehmigungsmodus: {{error}}', + 'Failed to change approval mode: {{error}}': + 'Fehler beim Andern des Genehmigungsmodus: {{error}}', + 'Apply to current session only (temporary)': + 'Nur auf aktuelle Sitzung anwenden (temporar)', + 'Persist for this project/workspace': 'Fur dieses Projekt/Arbeitsbereich speichern', + 'Persist for this user on this machine': + 'Fur diesen Benutzer auf diesem Computer speichern', + 'Analyze only, do not modify files or execute commands': + 'Nur analysieren, keine Dateien andern oder Befehle ausfuhren', + 'Require approval for file edits or shell commands': + 'Genehmigung fur Dateibearbeitungen oder Shell-Befehle erforderlich', + 'Automatically approve file edits': 'Dateibearbeitungen automatisch genehmigen', + 'Automatically approve all tools': 'Alle Werkzeuge automatisch genehmigen', + 'Workspace approval mode exists and takes priority. User-level change will have no effect.': + 'Arbeitsbereich-Genehmigungsmodus existiert und hat Vorrang. Benutzerebene-Anderung hat keine Wirkung.', + '(Use Enter to select, Tab to change focus)': + '(Enter zum Auswahlen, Tab zum Fokuswechsel)', + 'Apply To': 'Anwenden auf', + 'User Settings': 'Benutzereinstellungen', + 'Workspace Settings': 'Arbeitsbereich-Einstellungen', + + // ============================================================================ + // Commands - Memory + // ============================================================================ + 'Commands for interacting with memory.': + 'Befehle fur die Interaktion mit dem Speicher.', + 'Show the current memory contents.': 'Aktuellen Speicherinhalt anzeigen.', + 'Show project-level memory contents.': 'Projektebene-Speicherinhalt anzeigen.', + 'Show global memory contents.': 'Globalen Speicherinhalt anzeigen.', + 'Add content to project-level memory.': + 'Inhalt zum Projektebene-Speicher hinzufugen.', + 'Add content to global memory.': 'Inhalt zum globalen Speicher hinzufugen.', + 'Refresh the memory from the source.': 'Speicher aus der Quelle aktualisieren.', + 'Usage: /memory add --project ': + 'Verwendung: /memory add --project ', + 'Usage: /memory add --global ': + 'Verwendung: /memory add --global ', + 'Attempting to save to project memory: "{{text}}"': + 'Versuche im Projektspeicher zu speichern: "{{text}}"', + 'Attempting to save to global memory: "{{text}}"': + 'Versuche im globalen Speicher zu speichern: "{{text}}"', + 'Current memory content from {{count}} file(s):': + 'Aktueller Speicherinhalt aus {{count}} Datei(en):', + 'Memory is currently empty.': 'Speicher ist derzeit leer.', + 'Project memory file not found or is currently empty.': + 'Projektspeicherdatei nicht gefunden oder derzeit leer.', + 'Global memory file not found or is currently empty.': + 'Globale Speicherdatei nicht gefunden oder derzeit leer.', + 'Global memory is currently empty.': 'Globaler Speicher ist derzeit leer.', + 'Global memory content:\n\n---\n{{content}}\n---': + 'Globaler Speicherinhalt:\n\n---\n{{content}}\n---', + 'Project memory content from {{path}}:\n\n---\n{{content}}\n---': + 'Projektspeicherinhalt von {{path}}:\n\n---\n{{content}}\n---', + 'Project memory is currently empty.': 'Projektspeicher ist derzeit leer.', + 'Refreshing memory from source files...': + 'Speicher wird aus Quelldateien aktualisiert...', + 'Add content to the memory. Use --global for global memory or --project for project memory.': + 'Inhalt zum Speicher hinzufugen. --global fur globalen Speicher oder --project fur Projektspeicher verwenden.', + 'Usage: /memory add [--global|--project] ': + 'Verwendung: /memory add [--global|--project] ', + 'Attempting to save to memory {{scope}}: "{{fact}}"': + 'Versuche im Speicher {{scope}} zu speichern: "{{fact}}"', + + // ============================================================================ + // Commands - MCP + // ============================================================================ + 'Authenticate with an OAuth-enabled MCP server': + 'Mit einem OAuth-fahigen MCP-Server authentifizieren', + 'List configured MCP servers and tools': + 'Konfigurierte MCP-Server und Werkzeuge auflisten', + 'Restarts MCP servers.': 'MCP-Server neu starten.', + 'Config not loaded.': 'Konfiguration nicht geladen.', + 'Could not retrieve tool registry.': 'Werkzeugregister konnte nicht abgerufen werden.', + 'No MCP servers configured with OAuth authentication.': + 'Keine MCP-Server mit OAuth-Authentifizierung konfiguriert.', + 'MCP servers with OAuth authentication:': + 'MCP-Server mit OAuth-Authentifizierung:', + 'Use /mcp auth to authenticate.': + 'Verwenden Sie /mcp auth zur Authentifizierung.', + "MCP server '{{name}}' not found.": "MCP-Server '{{name}}' nicht gefunden.", + "Successfully authenticated and refreshed tools for '{{name}}'.": + "Erfolgreich authentifiziert und Werkzeuge fur '{{name}}' aktualisiert.", + "Failed to authenticate with MCP server '{{name}}': {{error}}": + "Authentifizierung mit MCP-Server '{{name}}' fehlgeschlagen: {{error}}", + "Re-discovering tools from '{{name}}'...": + "Werkzeuge von '{{name}}' werden neu erkannt...", + + // ============================================================================ + // Commands - Chat + // ============================================================================ + 'Manage conversation history.': 'Gesprachsverlauf verwalten.', + 'List saved conversation checkpoints': 'Gespeicherte Gesprachspruefpunkte auflisten', + 'No saved conversation checkpoints found.': + 'Keine gespeicherten Gesprachsprufpunkte gefunden.', + 'List of saved conversations:': 'Liste gespeicherter Gesprache:', + 'Note: Newest last, oldest first': 'Hinweis: Neueste zuletzt, alteste zuerst', + 'Save the current conversation as a checkpoint. Usage: /chat save ': + 'Aktuelles Gesprach als Prufpunkt speichern. Verwendung: /chat save ', + 'Missing tag. Usage: /chat save ': + 'Tag fehlt. Verwendung: /chat save ', + 'Delete a conversation checkpoint. Usage: /chat delete ': + 'Gesprachsprufpunkt loschen. Verwendung: /chat delete ', + 'Missing tag. Usage: /chat delete ': + 'Tag fehlt. Verwendung: /chat delete ', + "Conversation checkpoint '{{tag}}' has been deleted.": + "Gesprachsprufpunkt '{{tag}}' wurde geloscht.", + "Error: No checkpoint found with tag '{{tag}}'.": + "Fehler: Kein Prufpunkt mit Tag '{{tag}}' gefunden.", + 'Resume a conversation from a checkpoint. Usage: /chat resume ': + 'Gesprach von einem Prufpunkt fortsetzen. Verwendung: /chat resume ', + 'Missing tag. Usage: /chat resume ': + 'Tag fehlt. Verwendung: /chat resume ', + 'No saved checkpoint found with tag: {{tag}}.': + 'Kein gespeicherter Prufpunkt mit Tag gefunden: {{tag}}.', + 'A checkpoint with the tag {{tag}} already exists. Do you want to overwrite it?': + 'Ein Prufpunkt mit dem Tag {{tag}} existiert bereits. Mochten Sie ihn uberschreiben?', + 'No chat client available to save conversation.': + 'Kein Chat-Client verfugbar, um Gesprach zu speichern.', + 'Conversation checkpoint saved with tag: {{tag}}.': + 'Gesprachsprufpunkt gespeichert mit Tag: {{tag}}.', + 'No conversation found to save.': 'Kein Gesprach zum Speichern gefunden.', + 'No chat client available to share conversation.': + 'Kein Chat-Client verfugbar, um Gesprach zu teilen.', + 'Invalid file format. Only .md and .json are supported.': + 'Ungultiges Dateiformat. Nur .md und .json werden unterstutzt.', + 'Error sharing conversation: {{error}}': + 'Fehler beim Teilen des Gesprachs: {{error}}', + 'Conversation shared to {{filePath}}': 'Gesprach geteilt nach {{filePath}}', + 'No conversation found to share.': 'Kein Gesprach zum Teilen gefunden.', + 'Share the current conversation to a markdown or json file. Usage: /chat share ': + 'Aktuelles Gesprach in eine Markdown- oder JSON-Datei teilen. Verwendung: /chat share ', + + // ============================================================================ + // Commands - Summary + // ============================================================================ + 'Generate a project summary and save it to .qwen/PROJECT_SUMMARY.md': + 'Projektzusammenfassung generieren und in .qwen/PROJECT_SUMMARY.md speichern', + 'No chat client available to generate summary.': + 'Kein Chat-Client verfugbar, um Zusammenfassung zu generieren.', + 'Already generating summary, wait for previous request to complete': + 'Zusammenfassung wird bereits generiert, warten Sie auf Abschluss der vorherigen Anfrage', + 'No conversation found to summarize.': 'Kein Gesprach zum Zusammenfassen gefunden.', + 'Failed to generate project context summary: {{error}}': + 'Fehler beim Generieren der Projektkontextzusammenfassung: {{error}}', + 'Saved project summary to {{filePathForDisplay}}.': + 'Projektzusammenfassung gespeichert unter {{filePathForDisplay}}.', + 'Saving project summary...': 'Projektzusammenfassung wird gespeichert...', + 'Generating project summary...': 'Projektzusammenfassung wird generiert...', + 'Failed to generate summary - no text content received from LLM response': + 'Fehler beim Generieren der Zusammenfassung - kein Textinhalt von LLM-Antwort erhalten', + + // ============================================================================ + // Commands - Model + // ============================================================================ + 'Switch the model for this session': 'Modell fur diese Sitzung wechseln', + 'Content generator configuration not available.': + 'Inhaltsgenerator-Konfiguration nicht verfugbar.', + 'Authentication type not available.': 'Authentifizierungstyp nicht verfugbar.', + 'No models available for the current authentication type ({{authType}}).': + 'Keine Modelle fur den aktuellen Authentifizierungstyp ({{authType}}) verfugbar.', + + // ============================================================================ + // Commands - Clear + // ============================================================================ + 'Starting a new session, resetting chat, and clearing terminal.': + 'Neue Sitzung wird gestartet, Chat wird zuruckgesetzt und Terminal wird geloscht.', + 'Starting a new session and clearing.': + 'Neue Sitzung wird gestartet und geloscht.', + + // ============================================================================ + // Commands - Compress + // ============================================================================ + 'Already compressing, wait for previous request to complete': + 'Komprimierung lauft bereits, warten Sie auf Abschluss der vorherigen Anfrage', + 'Failed to compress chat history.': 'Fehler beim Komprimieren des Chatverlaufs.', + 'Failed to compress chat history: {{error}}': + 'Fehler beim Komprimieren des Chatverlaufs: {{error}}', + 'Compressing chat history': 'Chatverlauf wird komprimiert', + 'Chat history compressed from {{originalTokens}} to {{newTokens}} tokens.': + 'Chatverlauf komprimiert von {{originalTokens}} auf {{newTokens}} Token.', + 'Compression was not beneficial for this history size.': + 'Komprimierung war fur diese Verlaufsgross nicht vorteilhaft.', + 'Chat history compression did not reduce size. This may indicate issues with the compression prompt.': + 'Chatverlauf-Komprimierung hat die Grosse nicht reduziert. Dies kann auf Probleme mit dem Komprimierungs-Prompt hindeuten.', + 'Could not compress chat history due to a token counting error.': + 'Chatverlauf konnte aufgrund eines Token-Zahlfehlers nicht komprimiert werden.', + 'Chat history is already compressed.': 'Chatverlauf ist bereits komprimiert.', + + // ============================================================================ + // Commands - Directory + // ============================================================================ + 'Configuration is not available.': 'Konfiguration ist nicht verfugbar.', + 'Please provide at least one path to add.': + 'Bitte geben Sie mindestens einen Pfad zum Hinzufugen an.', + 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.': + 'Der Befehl /directory add wird in restriktiven Sandbox-Profilen nicht unterstutzt. Bitte verwenden Sie --include-directories beim Starten der Sitzung.', + "Error adding '{{path}}': {{error}}": "Fehler beim Hinzufugen von '{{path}}': {{error}}", + 'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}': + 'QWEN.md-Dateien aus folgenden Verzeichnissen erfolgreich hinzugefugt, falls vorhanden:\n- {{directories}}', + 'Error refreshing memory: {{error}}': 'Fehler beim Aktualisieren des Speichers: {{error}}', + 'Successfully added directories:\n- {{directories}}': + 'Verzeichnisse erfolgreich hinzugefugt:\n- {{directories}}', + 'Current workspace directories:\n{{directories}}': + 'Aktuelle Arbeitsbereichsverzeichnisse:\n{{directories}}', + + // ============================================================================ + // Commands - Docs + // ============================================================================ + 'Please open the following URL in your browser to view the documentation:\n{{url}}': + 'Bitte offnen Sie folgende URL in Ihrem Browser, um die Dokumentation anzusehen:\n{{url}}', + 'Opening documentation in your browser: {{url}}': + 'Dokumentation wird in Ihrem Browser geoffnet: {{url}}', + + // ============================================================================ + // Dialogs - Tool Confirmation + // ============================================================================ + 'Do you want to proceed?': 'Mochten Sie fortfahren?', + 'Yes, allow once': 'Ja, einmal erlauben', + 'Allow always': 'Immer erlauben', + No: 'Nein', + 'No (esc)': 'Nein (Esc)', + 'Yes, allow always for this session': 'Ja, fur diese Sitzung immer erlauben', + 'Modify in progress:': 'Anderung in Bearbeitung:', + 'Save and close external editor to continue': + 'Speichern und externen Editor schliessen, um fortzufahren', + 'Apply this change?': 'Diese Anderung anwenden?', + 'Yes, allow always': 'Ja, immer erlauben', + 'Modify with external editor': 'Mit externem Editor bearbeiten', + 'No, suggest changes (esc)': 'Nein, Anderungen vorschlagen (Esc)', + "Allow execution of: '{{command}}'?": "Ausfuhrung erlauben von: '{{command}}'?", + 'Yes, allow always ...': 'Ja, immer erlauben ...', + 'Yes, and auto-accept edits': 'Ja, und Anderungen automatisch akzeptieren', + 'Yes, and manually approve edits': 'Ja, und Anderungen manuell genehmigen', + 'No, keep planning (esc)': 'Nein, weiter planen (Esc)', + 'URLs to fetch:': 'Abzurufende URLs:', + 'MCP Server: {{server}}': 'MCP-Server: {{server}}', + 'Tool: {{tool}}': 'Werkzeug: {{tool}}', + 'Allow execution of MCP tool "{{tool}}" from server "{{server}}"?': + 'Ausfuhrung des MCP-Werkzeugs "{{tool}}" von Server "{{server}}" erlauben?', + 'Yes, always allow tool "{{tool}}" from server "{{server}}"': + 'Ja, Werkzeug "{{tool}}" von Server "{{server}}" immer erlauben', + 'Yes, always allow all tools from server "{{server}}"': + 'Ja, alle Werkzeuge von Server "{{server}}" immer erlauben', + + // ============================================================================ + // Dialogs - Shell Confirmation + // ============================================================================ + 'Shell Command Execution': 'Shell-Befehlsausfuhrung', + 'A custom command wants to run the following shell commands:': + 'Ein benutzerdefinierter Befehl mochte folgende Shell-Befehle ausfuhren:', + + // ============================================================================ + // Dialogs - Pro Quota + // ============================================================================ + 'Pro quota limit reached for {{model}}.': + 'Pro-Kontingentlimit fur {{model}} erreicht.', + 'Change auth (executes the /auth command)': + 'Authentifizierung andern (fuhrt den /auth-Befehl aus)', + 'Continue with {{model}}': 'Mit {{model}} fortfahren', + + // ============================================================================ + // Dialogs - Welcome Back + // ============================================================================ + 'Current Plan:': 'Aktueller Plan:', + 'Progress: {{done}}/{{total}} tasks completed': + 'Fortschritt: {{done}}/{{total}} Aufgaben abgeschlossen', + ', {{inProgress}} in progress': ', {{inProgress}} in Bearbeitung', + 'Pending Tasks:': 'Ausstehende Aufgaben:', + 'What would you like to do?': 'Was mochten Sie tun?', + 'Choose how to proceed with your session:': + 'Wahlen Sie, wie Sie mit Ihrer Sitzung fortfahren mochten:', + 'Start new chat session': 'Neue Chat-Sitzung starten', + 'Continue previous conversation': 'Vorheriges Gesprach fortsetzen', + '👋 Welcome back! (Last updated: {{timeAgo}})': + '👋 Willkommen zuruck! (Zuletzt aktualisiert: {{timeAgo}})', + '🎯 Overall Goal:': '🎯 Gesamtziel:', + + // ============================================================================ + // Dialogs - Auth + // ============================================================================ + 'Get started': 'Loslegen', + 'How would you like to authenticate for this project?': + 'Wie mochten Sie sich fur dieses Projekt authentifizieren?', + 'OpenAI API key is required to use OpenAI authentication.': + 'OpenAI API-Schlussel ist fur die OpenAI-Authentifizierung erforderlich.', + 'You must select an auth method to proceed. Press Ctrl+C again to exit.': + 'Sie mussen eine Authentifizierungsmethode wahlen, um fortzufahren. Drucken Sie erneut Strg+C zum Beenden.', + '(Use Enter to Set Auth)': '(Enter zum Festlegen der Authentifizierung)', + 'Terms of Services and Privacy Notice for Qwen Code': + 'Nutzungsbedingungen und Datenschutzhinweis fur Qwen Code', + 'Qwen OAuth': 'Qwen OAuth', + OpenAI: 'OpenAI', + 'Failed to login. Message: {{message}}': + 'Anmeldung fehlgeschlagen. Meldung: {{message}}', + 'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.': + 'Authentifizierung ist auf {{enforcedType}} festgelegt, aber Sie verwenden derzeit {{currentType}}.', + 'Qwen OAuth authentication timed out. Please try again.': + 'Qwen OAuth-Authentifizierung abgelaufen. Bitte versuchen Sie es erneut.', + 'Qwen OAuth authentication cancelled.': + 'Qwen OAuth-Authentifizierung abgebrochen.', + 'Qwen OAuth Authentication': 'Qwen OAuth-Authentifizierung', + 'Please visit this URL to authorize:': 'Bitte besuchen Sie diese URL zur Autorisierung:', + 'Or scan the QR code below:': 'Oder scannen Sie den QR-Code unten:', + 'Waiting for authorization': 'Warten auf Autorisierung', + 'Time remaining:': 'Verbleibende Zeit:', + '(Press ESC or CTRL+C to cancel)': '(ESC oder STRG+C zum Abbrechen drucken)', + 'Qwen OAuth Authentication Timeout': 'Qwen OAuth-Authentifizierung abgelaufen', + 'OAuth token expired (over {{seconds}} seconds). Please select authentication method again.': + 'OAuth-Token abgelaufen (uber {{seconds}} Sekunden). Bitte wahlen Sie erneut eine Authentifizierungsmethode.', + 'Press any key to return to authentication type selection.': + 'Drucken Sie eine beliebige Taste, um zur Authentifizierungstypauswahl zuruckzukehren.', + 'Waiting for Qwen OAuth authentication...': + 'Warten auf Qwen OAuth-Authentifizierung...', + 'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.': + 'Hinweis: Ihr bestehender API-Schlussel in settings.json wird bei Verwendung von Qwen OAuth nicht geloscht. Sie konnen spater bei Bedarf zur OpenAI-Authentifizierung zuruckwechseln.', + 'Authentication timed out. Please try again.': + 'Authentifizierung abgelaufen. Bitte versuchen Sie es erneut.', + 'Waiting for auth... (Press ESC or CTRL+C to cancel)': + 'Warten auf Authentifizierung... (ESC oder STRG+C zum Abbrechen drucken)', + 'Failed to authenticate. Message: {{message}}': + 'Authentifizierung fehlgeschlagen. Meldung: {{message}}', + 'Authenticated successfully with {{authType}} credentials.': + 'Erfolgreich mit {{authType}}-Anmeldedaten authentifiziert.', + 'Invalid QWEN_DEFAULT_AUTH_TYPE value: "{{value}}". Valid values are: {{validValues}}': + 'Ungultiger QWEN_DEFAULT_AUTH_TYPE-Wert: "{{value}}". Gultige Werte sind: {{validValues}}', + 'OpenAI Configuration Required': 'OpenAI-Konfiguration erforderlich', + 'Please enter your OpenAI configuration. You can get an API key from': + 'Bitte geben Sie Ihre OpenAI-Konfiguration ein. Sie konnen einen API-Schlussel erhalten von', + 'API Key:': 'API-Schlussel:', + 'Invalid credentials: {{errorMessage}}': + 'Ungultige Anmeldedaten: {{errorMessage}}', + 'Failed to validate credentials': 'Anmeldedaten konnten nicht validiert werden', + 'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel': + 'Enter zum Fortfahren, Tab/↑↓ zum Navigieren, Esc zum Abbrechen', + + // ============================================================================ + // Dialogs - Model + // ============================================================================ + 'Select Model': 'Modell auswahlen', + '(Press Esc to close)': '(Esc zum Schliessen drucken)', + '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)', + '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)', + + // ============================================================================ + // Dialogs - Permissions + // ============================================================================ + 'Manage folder trust settings': 'Ordnervertrauenseinstellungen verwalten', + + // ============================================================================ + // Status Bar + // ============================================================================ + 'Using:': 'Verwendet:', + '{{count}} open file': '{{count}} geoffnete Datei', + '{{count}} open files': '{{count}} geoffnete Dateien', + '(ctrl+g to view)': '(Strg+G zum Anzeigen)', + '{{count}} {{name}} file': '{{count}} {{name}}-Datei', + '{{count}} {{name}} files': '{{count}} {{name}}-Dateien', + '{{count}} MCP server': '{{count}} MCP-Server', + '{{count}} MCP servers': '{{count}} MCP-Server', + '{{count}} Blocked': '{{count}} blockiert', + '(ctrl+t to view)': '(Strg+T zum Anzeigen)', + '(ctrl+t to toggle)': '(Strg+T zum Umschalten)', + 'Press Ctrl+C again to exit.': 'Drucken Sie erneut Strg+C zum Beenden.', + 'Press Ctrl+D again to exit.': 'Drucken Sie erneut Strg+D zum Beenden.', + 'Press Esc again to clear.': 'Drucken Sie erneut Esc zum Loschen.', + + // ============================================================================ + // MCP Status + // ============================================================================ + 'No MCP servers configured.': 'Keine MCP-Server konfiguriert.', + 'Please view MCP documentation in your browser:': + 'Bitte sehen Sie die MCP-Dokumentation in Ihrem Browser:', + 'or use the cli /docs command': 'oder verwenden Sie den CLI-Befehl /docs', + '⏳ MCP servers are starting up ({{count}} initializing)...': + '⏳ MCP-Server werden gestartet ({{count}} werden initialisiert)...', + 'Note: First startup may take longer. Tool availability will update automatically.': + 'Hinweis: Der erste Start kann langer dauern. Werkzeugverfugbarkeit wird automatisch aktualisiert.', + 'Configured MCP servers:': 'Konfigurierte MCP-Server:', + Ready: 'Bereit', + 'Starting... (first startup may take longer)': + 'Wird gestartet... (erster Start kann langer dauern)', + Disconnected: 'Getrennt', + '{{count}} tool': '{{count}} Werkzeug', + '{{count}} tools': '{{count}} Werkzeuge', + '{{count}} prompt': '{{count}} Prompt', + '{{count}} prompts': '{{count}} Prompts', + '(from {{extensionName}})': '(von {{extensionName}})', + OAuth: 'OAuth', + 'OAuth expired': 'OAuth abgelaufen', + 'OAuth not authenticated': 'OAuth nicht authentifiziert', + 'tools and prompts will appear when ready': + 'Werkzeuge und Prompts werden angezeigt, wenn bereit', + '{{count}} tools cached': '{{count}} Werkzeuge zwischengespeichert', + 'Tools:': 'Werkzeuge:', + 'Parameters:': 'Parameter:', + 'Prompts:': 'Prompts:', + Blocked: 'Blockiert', + '💡 Tips:': '💡 Tipps:', + Use: 'Verwenden', + 'to show server and tool descriptions': + 'um Server- und Werkzeugbeschreibungen anzuzeigen', + 'to show tool parameter schemas': 'um Werkzeug-Parameter-Schemas anzuzeigen', + 'to hide descriptions': 'um Beschreibungen auszublenden', + 'to authenticate with OAuth-enabled servers': + 'um sich bei OAuth-fahigen Servern zu authentifizieren', + Press: 'Drucken Sie', + 'to toggle tool descriptions on/off': + 'um Werkzeugbeschreibungen ein-/auszuschalten', + "Starting OAuth authentication for MCP server '{{name}}'...": + "OAuth-Authentifizierung fur MCP-Server '{{name}}' wird gestartet...", + 'Restarting MCP servers...': 'MCP-Server werden neu gestartet...', + + // ============================================================================ + // Startup Tips + // ============================================================================ + 'Tips for getting started:': 'Tipps zum Einstieg:', + '1. Ask questions, edit files, or run commands.': + '1. Stellen Sie Fragen, bearbeiten Sie Dateien oder fuhren Sie Befehle aus.', + '2. Be specific for the best results.': + '2. Seien Sie spezifisch fur die besten Ergebnisse.', + 'files to customize your interactions with Qwen Code.': + 'Dateien, um Ihre Interaktionen mit Qwen Code anzupassen.', + 'for more information.': 'fur weitere Informationen.', + + // ============================================================================ + // Exit Screen / Stats + // ============================================================================ + 'Agent powering down. Goodbye!': 'Agent wird heruntergefahren. Auf Wiedersehen!', + 'To continue this session, run': 'Um diese Sitzung fortzusetzen, fuhren Sie aus', + 'Interaction Summary': 'Interaktionszusammenfassung', + 'Session ID:': 'Sitzungs-ID:', + 'Tool Calls:': 'Werkzeugaufrufe:', + 'Success Rate:': 'Erfolgsrate:', + 'User Agreement:': 'Benutzerzustimmung:', + reviewed: 'uberpruft', + 'Code Changes:': 'Codeanderungen:', + Performance: 'Leistung', + 'Wall Time:': 'Gesamtzeit:', + 'Agent Active:': 'Agent aktiv:', + 'API Time:': 'API-Zeit:', + 'Tool Time:': 'Werkzeugzeit:', + 'Session Stats': 'Sitzungsstatistiken', + 'Model Usage': 'Modellnutzung', + Reqs: 'Anfragen', + 'Input Tokens': 'Eingabe-Token', + 'Output Tokens': 'Ausgabe-Token', + 'Savings Highlight:': 'Einsparungen:', + 'of input tokens were served from the cache, reducing costs.': + 'der Eingabe-Token wurden aus dem Cache bedient, was die Kosten reduziert.', + 'Tip: For a full token breakdown, run `/stats model`.': + 'Tipp: Fur eine vollstandige Token-Aufschlusselung fuhren Sie `/stats model` aus.', + 'Model Stats For Nerds': 'Modellstatistiken fur Nerds', + 'Tool Stats For Nerds': 'Werkzeugstatistiken fur Nerds', + Metric: 'Metrik', + API: 'API', + Requests: 'Anfragen', + Errors: 'Fehler', + 'Avg Latency': 'Durchschn. Latenz', + Tokens: 'Token', + Total: 'Gesamt', + Prompt: 'Prompt', + Cached: 'Zwischengespeichert', + Thoughts: 'Gedanken', + Tool: 'Werkzeug', + Output: 'Ausgabe', + 'No API calls have been made in this session.': + 'In dieser Sitzung wurden keine API-Aufrufe gemacht.', + 'Tool Name': 'Werkzeugname', + Calls: 'Aufrufe', + 'Success Rate': 'Erfolgsrate', + 'Avg Duration': 'Durchschn. Dauer', + 'User Decision Summary': 'Benutzerentscheidungs-Zusammenfassung', + 'Total Reviewed Suggestions:': 'Insgesamt uberprufter Vorschlage:', + ' » Accepted:': ' » Akzeptiert:', + ' » Rejected:': ' » Abgelehnt:', + ' » Modified:': ' » Geandert:', + ' Overall Agreement Rate:': ' Gesamtzustimmungsrate:', + 'No tool calls have been made in this session.': + 'In dieser Sitzung wurden keine Werkzeugaufrufe gemacht.', + 'Session start time is unavailable, cannot calculate stats.': + 'Sitzungsstartzeit nicht verfugbar, Statistiken konnen nicht berechnet werden.', + + // ============================================================================ + // Loading Phrases + // ============================================================================ + 'Waiting for user confirmation...': 'Warten auf Benutzerbestatigung...', + '(esc to cancel, {{time}})': '(Esc zum Abbrechen, {{time}})', + + // ============================================================================ + // Loading Phrases + // ============================================================================ + WITTY_LOADING_PHRASES: [ + 'Auf gut Gluck!', + 'Genialitat wird ausgeliefert...', + 'Die Serifen werden aufgemalt...', + 'Durch den Schleimpilz navigieren...', + 'Die digitalen Geister werden befragt...', + 'Splines werden retikuliert...', + 'Die KI-Hamster werden aufgewarmt...', + 'Die Zaubermuschel wird befragt...', + 'Witzige Erwiderung wird generiert...', + 'Die Algorithmen werden poliert...', + 'Perfektion braucht Zeit (mein Code auch)...', + 'Frische Bytes werden gebruht...', + 'Elektronen werden gezahlt...', + 'Kognitive Prozessoren werden aktiviert...', + 'Auf Syntaxfehler im Universum wird gepruft...', + 'Einen Moment, Humor wird optimiert...', + 'Pointen werden gemischt...', + 'Neuronale Netze werden entwirrt...', + 'Brillanz wird kompiliert...', + 'wit.exe wird geladen...', + 'Die Wolke der Weisheit wird beschworen...', + 'Eine witzige Antwort wird vorbereitet...', + 'Einen Moment, ich debugge die Realitat...', + 'Die Optionen werden verwirrt...', + 'Kosmische Frequenzen werden eingestellt...', + 'Eine Antwort wird erstellt, die Ihrer Geduld wurdig ist...', + 'Die Einsen und Nullen werden kompiliert...', + 'Abhangigkeiten werden aufgelost... und existenzielle Krisen...', + 'Erinnerungen werden defragmentiert... sowohl RAM als auch personliche...', + 'Das Humor-Modul wird neu gestartet...', + 'Das Wesentliche wird zwischengespeichert (hauptsachlich Katzen-Memes)...', + 'Fur lacherliche Geschwindigkeit wird optimiert', + 'Bits werden getauscht... sagen Sie es nicht den Bytes...', + 'Garbage Collection lauft... bin gleich zuruck...', + 'Das Internet wird zusammengebaut...', + 'Kaffee wird in Code umgewandelt...', + 'Die Syntax der Realitat wird aktualisiert...', + 'Die Synapsen werden neu verdrahtet...', + 'Ein verlegtes Semikolon wird gesucht...', + 'Die Zahnrader werden geschmiert...', + 'Die Server werden vorgeheizt...', + 'Der Fluxkompensator wird kalibriert...', + 'Der Unwahrscheinlichkeitsantrieb wird aktiviert...', + 'Die Macht wird kanalisiert...', + 'Die Sterne werden fur optimale Antwort ausgerichtet...', + 'So sagen wir alle...', + 'Die nachste grosse Idee wird geladen...', + 'Einen Moment, ich bin in der Zone...', + 'Bereite mich vor, Sie mit Brillanz zu blenden...', + 'Einen Augenblick, ich poliere meinen Witz...', + 'Halten Sie durch, ich erschaffe ein Meisterwerk...', + 'Einen Moment, ich debugge das Universum...', + 'Einen Moment, ich richte die Pixel aus...', + 'Einen Moment, ich optimiere den Humor...', + 'Einen Moment, ich tune die Algorithmen...', + 'Warp-Geschwindigkeit aktiviert...', + 'Mehr Dilithium-Kristalle werden gesucht...', + 'Keine Panik...', + 'Dem weissen Kaninchen wird gefolgt...', + 'Die Wahrheit ist hier drin... irgendwo...', + 'Auf die Kassette wird gepustet...', + 'Ladevorgang... Machen Sie eine Fassrolle!', + 'Auf den Respawn wird gewartet...', + 'Der Kessel-Flug wird in weniger als 12 Parsec beendet...', + 'Der Kuchen ist keine Luge, er ladt nur noch...', + 'Am Charaktererstellungsbildschirm wird herumgefummelt...', + 'Einen Moment, ich suche das richtige Meme...', + "'A' wird zum Fortfahren gedruckt...", + 'Digitale Katzen werden gehuttert...', + 'Die Pixel werden poliert...', + 'Ein passender Ladebildschirm-Witz wird gesucht...', + 'Ich lenke Sie mit diesem witzigen Spruch ab...', + 'Fast da... wahrscheinlich...', + 'Unsere Hamster arbeiten so schnell sie konnen...', + 'Cloudy wird am Kopf gestreichelt...', + 'Die Katze wird gestreichelt...', + 'Meinen Chef rickrollen...', + 'Never gonna give you up, never gonna let you down...', + 'Auf den Bass wird geschlagen...', + 'Die Schnozbeeren werden probiert...', + "I'm going the distance, I'm going for speed...", + 'Ist dies das wahre Leben? Ist dies nur Fantasie?...', + 'Ich habe ein gutes Gefuhl dabei...', + 'Den Baren wird gestupst...', + 'Recherche zu den neuesten Memes...', + 'Uberlege, wie ich das witziger machen kann...', + 'Hmmm... lassen Sie mich nachdenken...', + 'Wie nennt man einen Fisch ohne Augen? Ein Fsh...', + 'Warum ging der Computer zur Therapie? Er hatte zu viele Bytes...', + 'Warum mogen Programmierer keine Natur? Sie hat zu viele Bugs...', + 'Warum bevorzugen Programmierer den Dunkelmodus? Weil Licht Bugs anzieht...', + 'Warum ging der Entwickler pleite? Er hat seinen ganzen Cache aufgebraucht...', + 'Was kann man mit einem kaputten Bleistift machen? Nichts, er ist sinnlos...', + 'Perkussive Wartung wird angewendet...', + 'Die richtige USB-Ausrichtung wird gesucht...', + 'Es wird sichergestellt, dass der magische Rauch in den Kabeln bleibt...', + 'Versuche Vim zu beenden...', + 'Das Hamsterrad wird angeworfen...', + 'Das ist kein Bug, das ist ein undokumentiertes Feature...', + 'Engage.', + 'Ich komme wieder... mit einer Antwort.', + 'Mein anderer Prozess ist eine TARDIS...', + 'Mit dem Maschinengeist wird kommuniziert...', + 'Die Gedanken marinieren lassen...', + 'Gerade erinnert, wo ich meine Schlussel hingelegt habe...', + 'Uber die Kugel wird nachgedacht...', + 'Ich habe Dinge gesehen, die Sie nicht glauben wurden... wie einen Benutzer, der Lademeldungen liest.', + 'Nachdenklicher Blick wird initiiert...', + 'Was ist der Lieblingssnack eines Computers? Mikrochips.', + 'Warum tragen Java-Entwickler Brillen? Weil sie nicht C#.', + 'Der Laser wird aufgeladen... pew pew!', + 'Durch Null wird geteilt... nur Spass!', + 'Suche nach einem erwachsenen Aufseh... ich meine, Verarbeitung.', + 'Es piept und boopt.', + 'Pufferung... weil auch KIs einen Moment brauchen.', + 'Quantenteilchen werden fur schnellere Antwort verschrankt...', + 'Das Chrom wird poliert... an den Algorithmen.', + 'Sind Sie nicht unterhalten? (Arbeite daran!)', + 'Die Code-Gremlins werden beschworen... zum Helfen, naturlich.', + 'Warte nur auf das Einwahlton-Ende...', + 'Das Humor-O-Meter wird neu kalibriert.', + 'Mein anderer Ladebildschirm ist noch lustiger.', + 'Ziemlich sicher, dass irgendwo eine Katze uber die Tastatur lauft...', + 'Verbessern... Verbessern... Ladt noch.', + 'Das ist kein Bug, das ist ein Feature... dieses Ladebildschirms.', + 'Haben Sie versucht, es aus- und wieder einzuschalten? (Den Ladebildschirm, nicht mich.)', + 'Zusatzliche Pylonen werden gebaut...', + ], +}; diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index fb9475426..25fe74ece 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1037,7 +1037,6 @@ export default { 'Applying percussive maintenance...', 'Searching for the correct USB orientation...', 'Ensuring the magic smoke stays inside the wires...', - 'Rewriting in Rust for no particular reason...', 'Trying to exit Vim...', 'Spinning up the hamster wheel...', "That's not a bug, it's an undocumented feature...", diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index ee583e0f9..8db55e331 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1056,7 +1056,6 @@ export default { 'Провожу настройку методом тыка...', 'Ищем, какой стороной вставлять флешку...', 'Следим, чтобы волшебный дым не вышел из проводов...', - 'Переписываем всё на Rust без особой причины...', 'Пытаемся выйти из Vim...', 'Раскручиваем колесо для хомяка...', 'Это не баг, а фича...', From 0ae59b900c2d6b0ac118281c460df5097de9bccb Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Tue, 30 Dec 2025 16:50:23 +0100 Subject: [PATCH 034/142] Add German umlauts --- packages/cli/src/i18n/locales/de.js | 528 ++++++++++++++-------------- 1 file changed, 264 insertions(+), 264 deletions(-) diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 4ddcf4c3d..832dd1333 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -5,26 +5,26 @@ */ // German translations for Qwen Code CLI -// Deutsche Ubersetzungen fur Qwen Code CLI +// Deutsche Übersetzungen für Qwen Code CLI export default { // ============================================================================ // Help / UI Components // ============================================================================ 'Basics:': 'Grundlagen:', - 'Add context': 'Kontext hinzufugen', + 'Add context': 'Kontext hinzufügen', 'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.': - 'Verwenden Sie {{symbol}}, um Dateien als Kontext anzugeben (z.B. {{example}}), um bestimmte Dateien oder Ordner auszuwahlen.', + 'Verwenden Sie {{symbol}}, um Dateien als Kontext anzugeben (z.B. {{example}}), um bestimmte Dateien oder Ordner auszuwählen.', '@': '@', '@src/myFile.ts': '@src/myFile.ts', 'Shell mode': 'Shell-Modus', 'YOLO mode': 'YOLO-Modus', 'plan mode': 'Planungsmodus', - 'auto-accept edits': 'Anderungen automatisch akzeptieren', - 'Accepting edits': 'Anderungen werden akzeptiert', + 'auto-accept edits': 'Änderungen automatisch akzeptieren', + 'Accepting edits': 'Änderungen werden akzeptiert', '(shift + tab to cycle)': '(Umschalt + Tab zum Wechseln)', 'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).': - 'Shell-Befehle uber {{symbol}} ausfuhren (z.B. {{example1}}) oder naturliche Sprache verwenden (z.B. {{example2}}).', + 'Shell-Befehle über {{symbol}} ausführen (z.B. {{example1}}) oder natürliche Sprache verwenden (z.B. {{example2}}).', '!': '!', '!npm run start': '!npm run start', 'start server': 'Server starten', @@ -32,33 +32,33 @@ export default { 'shell command': 'Shell-Befehl', 'Model Context Protocol command (from external servers)': 'Model Context Protocol Befehl (von externen Servern)', - 'Keyboard Shortcuts:': 'Tastenkurzel:', - 'Jump through words in the input': 'Worter in der Eingabe uberspringen', + 'Keyboard Shortcuts:': 'Tastenkürzel:', + 'Jump through words in the input': 'Wörter in der Eingabe überspringen', 'Close dialogs, cancel requests, or quit application': - 'Dialoge schliessen, Anfragen abbrechen oder Anwendung beenden', + 'Dialoge schließen, Anfragen abbrechen oder Anwendung beenden', 'New line': 'Neue Zeile', 'New line (Alt+Enter works for certain linux distros)': 'Neue Zeile (Alt+Enter funktioniert bei bestimmten Linux-Distributionen)', - 'Clear the screen': 'Bildschirm loschen', - 'Open input in external editor': 'Eingabe in externem Editor offnen', + 'Clear the screen': 'Bildschirm löschen', + 'Open input in external editor': 'Eingabe in externem Editor öffnen', 'Send message': 'Nachricht senden', 'Initializing...': 'Initialisierung...', 'Connecting to MCP servers... ({{connected}}/{{total}})': 'Verbindung zu MCP-Servern wird hergestellt... ({{connected}}/{{total}})', 'Type your message or @path/to/file': 'Nachricht eingeben oder @Pfad/zur/Datei', "Press 'i' for INSERT mode and 'Esc' for NORMAL mode.": - "Drucken Sie 'i' fur den EINFUGE-Modus und 'Esc' fur den NORMAL-Modus.", + "Drücken Sie 'i' für den EINFÜGE-Modus und 'Esc' für den NORMAL-Modus.", 'Cancel operation / Clear input (double press)': - 'Vorgang abbrechen / Eingabe loschen (doppelt drucken)', + 'Vorgang abbrechen / Eingabe löschen (doppelt drücken)', 'Cycle approval modes': 'Genehmigungsmodi durchschalten', - 'Cycle through your prompt history': 'Eingabeverlauf durchblattern', + 'Cycle through your prompt history': 'Eingabeverlauf durchblättern', 'For a full list of shortcuts, see {{docPath}}': - 'Eine vollstandige Liste der Tastenkurzel finden Sie unter {{docPath}}', + 'Eine vollständige Liste der Tastenkürzel finden Sie unter {{docPath}}', 'docs/keyboard-shortcuts.md': 'docs/keyboard-shortcuts.md', - 'for help on Qwen Code': 'fur Hilfe zu Qwen Code', + 'for help on Qwen Code': 'für Hilfe zu Qwen Code', 'show version info': 'Versionsinformationen anzeigen', 'submit a bug report': 'Fehlerbericht einreichen', - 'About Qwen Code': 'Uber Qwen Code', + 'About Qwen Code': 'Über Qwen Code', // ============================================================================ // System Information Fields @@ -82,34 +82,34 @@ export default { // Commands - General // ============================================================================ 'Analyzes the project and creates a tailored QWEN.md file.': - 'Analysiert das Projekt und erstellt eine massgeschneiderte QWEN.md-Datei.', + 'Analysiert das Projekt und erstellt eine maßgeschneiderte QWEN.md-Datei.', 'list available Qwen Code tools. Usage: /tools [desc]': - 'Verfugbare Qwen Code Werkzeuge auflisten. Verwendung: /tools [desc]', - 'Available Qwen Code CLI tools:': 'Verfugbare Qwen Code CLI-Werkzeuge:', - 'No tools available': 'Keine Werkzeuge verfugbar', + 'Verfügbare Qwen Code Werkzeuge auflisten. Verwendung: /tools [desc]', + 'Available Qwen Code CLI tools:': 'Verfügbare Qwen Code CLI-Werkzeuge:', + 'No tools available': 'Keine Werkzeuge verfügbar', 'View or change the approval mode for tool usage': - 'Genehmigungsmodus fur Werkzeugnutzung anzeigen oder andern', - 'View or change the language setting': 'Spracheinstellung anzeigen oder andern', - 'change the theme': 'Design andern', - 'Select Theme': 'Design auswahlen', + 'Genehmigungsmodus für Werkzeugnutzung anzeigen oder ändern', + 'View or change the language setting': 'Spracheinstellung anzeigen oder ändern', + 'change the theme': 'Design ändern', + 'Select Theme': 'Design auswählen', Preview: 'Vorschau', '(Use Enter to select, Tab to configure scope)': - '(Enter zum Auswahlen, Tab zum Konfigurieren des Bereichs)', + '(Enter zum Auswählen, Tab zum Konfigurieren des Bereichs)', '(Use Enter to apply scope, Tab to select theme)': - '(Enter zum Anwenden des Bereichs, Tab zum Auswahlen des Designs)', + '(Enter zum Anwenden des Bereichs, Tab zum Auswählen des Designs)', 'Theme configuration unavailable due to NO_COLOR env variable.': - 'Design-Konfiguration aufgrund der NO_COLOR-Umgebungsvariable nicht verfugbar.', + 'Design-Konfiguration aufgrund der NO_COLOR-Umgebungsvariable nicht verfügbar.', 'Theme "{{themeName}}" not found.': 'Design "{{themeName}}" nicht gefunden.', 'Theme "{{themeName}}" not found in selected scope.': - 'Design "{{themeName}}" im ausgewahlten Bereich nicht gefunden.', + 'Design "{{themeName}}" im ausgewählten Bereich nicht gefunden.', 'Clear conversation history and free up context': - 'Gesprachsverlauf loschen und Kontext freigeben', + 'Gesprächsverlauf löschen und Kontext freigeben', 'Compresses the context by replacing it with a summary.': 'Komprimiert den Kontext durch Ersetzen mit einer Zusammenfassung.', 'open full Qwen Code documentation in your browser': - 'Vollstandige Qwen Code Dokumentation im Browser offnen', - 'Configuration not available.': 'Konfiguration nicht verfugbar.', - 'change the auth method': 'Authentifizierungsmethode andern', + 'Vollständige Qwen Code Dokumentation im Browser öffnen', + 'Configuration not available.': 'Konfiguration nicht verfügbar.', + 'change the auth method': 'Authentifizierungsmethode ändern', 'Copy the last result or code snippet to clipboard': 'Letztes Ergebnis oder Codeausschnitt in die Zwischenablage kopieren', @@ -117,55 +117,55 @@ export default { // Commands - Agents // ============================================================================ 'Manage subagents for specialized task delegation.': - 'Unteragenten fur spezialisierte Aufgabendelegation verwalten.', + 'Unteragenten für spezialisierte Aufgabendelegation verwalten.', 'Manage existing subagents (view, edit, delete).': - 'Bestehende Unteragenten verwalten (anzeigen, bearbeiten, loschen).', + 'Bestehende Unteragenten verwalten (anzeigen, bearbeiten, löschen).', 'Create a new subagent with guided setup.': - 'Neuen Unteragenten mit gefuhrter Einrichtung erstellen.', + 'Neuen Unteragenten mit geführter Einrichtung erstellen.', // ============================================================================ // Agents - Management Dialog // ============================================================================ Agents: 'Agenten', - 'Choose Action': 'Aktion wahlen', + 'Choose Action': 'Aktion wählen', 'Edit {{name}}': '{{name}} bearbeiten', 'Edit Tools: {{name}}': 'Werkzeuge bearbeiten: {{name}}', 'Edit Color: {{name}}': 'Farbe bearbeiten: {{name}}', - 'Delete {{name}}': '{{name}} loschen', + 'Delete {{name}}': '{{name}} löschen', 'Unknown Step': 'Unbekannter Schritt', - 'Esc to close': 'Esc zum Schliessen', + 'Esc to close': 'Esc zum Schließen', 'Enter to select, ↑↓ to navigate, Esc to close': - 'Enter zum Auswahlen, ↑↓ zum Navigieren, Esc zum Schliessen', - 'Esc to go back': 'Esc zum Zuruckgehen', - 'Enter to confirm, Esc to cancel': 'Enter zum Bestatigen, Esc zum Abbrechen', + 'Enter zum Auswählen, ↑↓ zum Navigieren, Esc zum Schließen', + 'Esc to go back': 'Esc zum Zurückgehen', + 'Enter to confirm, Esc to cancel': 'Enter zum Bestätigen, Esc zum Abbrechen', 'Enter to select, ↑↓ to navigate, Esc to go back': - 'Enter zum Auswahlen, ↑↓ zum Navigieren, Esc zum Zuruckgehen', - 'Invalid step: {{step}}': 'Ungultiger Schritt: {{step}}', + 'Enter zum Auswählen, ↑↓ zum Navigieren, Esc zum Zurückgehen', + 'Invalid step: {{step}}': 'Ungültiger Schritt: {{step}}', 'No subagents found.': 'Keine Unteragenten gefunden.', "Use '/agents create' to create your first subagent.": "Verwenden Sie '/agents create', um Ihren ersten Unteragenten zu erstellen.", '(built-in)': '(integriert)', - '(overridden by project level agent)': '(uberschrieben durch Projektagent)', + '(overridden by project level agent)': '(überschrieben durch Projektagent)', 'Project Level ({{path}})': 'Projektebene ({{path}})', 'User Level ({{path}})': 'Benutzerebene ({{path}})', 'Built-in Agents': 'Integrierte Agenten', 'Using: {{count}} agents': 'Verwendet: {{count}} Agenten', 'View Agent': 'Agent anzeigen', 'Edit Agent': 'Agent bearbeiten', - 'Delete Agent': 'Agent loschen', - Back: 'Zuruck', - 'No agent selected': 'Kein Agent ausgewahlt', + 'Delete Agent': 'Agent löschen', + Back: 'Zurück', + 'No agent selected': 'Kein Agent ausgewählt', 'File Path: ': 'Dateipfad: ', 'Tools: ': 'Werkzeuge: ', 'Color: ': 'Farbe: ', 'Description:': 'Beschreibung:', 'System Prompt:': 'System-Prompt:', - 'Open in editor': 'Im Editor offnen', + 'Open in editor': 'Im Editor öffnen', 'Edit tools': 'Werkzeuge bearbeiten', 'Edit color': 'Farbe bearbeiten', '❌ Error:': '❌ Fehler:', 'Are you sure you want to delete agent "{{name}}"?': - 'Sind Sie sicher, dass Sie den Agenten "{{name}}" loschen mochten?', + 'Sind Sie sicher, dass Sie den Agenten "{{name}}" löschen möchten?', // ============================================================================ // Agents - Creation Wizard // ============================================================================ @@ -179,26 +179,26 @@ export default { '❌ Error saving subagent:': '❌ Fehler beim Speichern des Unteragenten:', 'Warnings:': 'Warnungen:', 'Name "{{name}}" already exists at {{level}} level - will overwrite existing subagent': - 'Name "{{name}}" existiert bereits auf {{level}}-Ebene - bestehender Unteragent wird uberschrieben', + 'Name "{{name}}" existiert bereits auf {{level}}-Ebene - bestehender Unteragent wird überschrieben', 'Name "{{name}}" exists at user level - project level will take precedence': 'Name "{{name}}" existiert auf Benutzerebene - Projektebene hat Vorrang', 'Name "{{name}}" exists at project level - existing subagent will take precedence': 'Name "{{name}}" existiert auf Projektebene - bestehender Unteragent hat Vorrang', 'Description is over {{length}} characters': - 'Beschreibung ist uber {{length}} Zeichen', + 'Beschreibung ist über {{length}} Zeichen', 'System prompt is over {{length}} characters': - 'System-Prompt ist uber {{length}} Zeichen', + 'System-Prompt ist über {{length}} Zeichen', // Agents - Creation Wizard Steps - 'Step {{n}}: Choose Location': 'Schritt {{n}}: Speicherort wahlen', + 'Step {{n}}: Choose Location': 'Schritt {{n}}: Speicherort wählen', 'Step {{n}}: Choose Generation Method': - 'Schritt {{n}}: Generierungsmethode wahlen', + 'Schritt {{n}}: Generierungsmethode wählen', 'Generate with Qwen Code (Recommended)': 'Mit Qwen Code generieren (Empfohlen)', 'Manual Creation': 'Manuelle Erstellung', 'Describe what this subagent should do and when it should be used. (Be comprehensive for best results)': - 'Beschreiben Sie, was dieser Unteragent tun soll und wann er verwendet werden soll. (Ausfuhrliche Beschreibung fur beste Ergebnisse)', + 'Beschreiben Sie, was dieser Unteragent tun soll und wann er verwendet werden soll. (Ausführliche Beschreibung für beste Ergebnisse)', 'e.g., Expert code reviewer that reviews code based on best practices...': - 'z.B. Experte fur Code-Reviews, der Code nach Best Practices uberpruft...', + 'z.B. Experte für Code-Reviews, der Code nach Best Practices überprüft...', 'Generating subagent configuration...': 'Unteragent-Konfiguration wird generiert...', 'Failed to generate subagent: {{error}}': @@ -208,42 +208,42 @@ export default { 'Step {{n}}: Enter System Prompt': 'Schritt {{n}}: System-Prompt eingeben', 'Step {{n}}: Enter Description': 'Schritt {{n}}: Beschreibung eingeben', // Agents - Tool Selection - 'Step {{n}}: Select Tools': 'Schritt {{n}}: Werkzeuge auswahlen', + 'Step {{n}}: Select Tools': 'Schritt {{n}}: Werkzeuge auswählen', 'All Tools (Default)': 'Alle Werkzeuge (Standard)', 'All Tools': 'Alle Werkzeuge', 'Read-only Tools': 'Nur-Lese-Werkzeuge', 'Read & Edit Tools': 'Lese- und Bearbeitungswerkzeuge', - 'Read & Edit & Execution Tools': 'Lese-, Bearbeitungs- und Ausfuhrungswerkzeuge', + 'Read & Edit & Execution Tools': 'Lese-, Bearbeitungs- und Ausführungswerkzeuge', 'All tools selected, including MCP tools': - 'Alle Werkzeuge ausgewahlt, einschliesslich MCP-Werkzeuge', - 'Selected tools:': 'Ausgewahlte Werkzeuge:', + 'Alle Werkzeuge ausgewählt, einschließlich MCP-Werkzeuge', + 'Selected tools:': 'Ausgewählte Werkzeuge:', 'Read-only tools:': 'Nur-Lese-Werkzeuge:', 'Edit tools:': 'Bearbeitungswerkzeuge:', - 'Execution tools:': 'Ausfuhrungswerkzeuge:', - 'Step {{n}}: Choose Background Color': 'Schritt {{n}}: Hintergrundfarbe wahlen', - 'Step {{n}}: Confirm and Save': 'Schritt {{n}}: Bestatigen und Speichern', + 'Execution tools:': 'Ausführungswerkzeuge:', + 'Step {{n}}: Choose Background Color': 'Schritt {{n}}: Hintergrundfarbe wählen', + 'Step {{n}}: Confirm and Save': 'Schritt {{n}}: Bestätigen und Speichern', // Agents - Navigation & Instructions 'Esc to cancel': 'Esc zum Abbrechen', 'Press Enter to save, e to save and edit, Esc to go back': - 'Enter zum Speichern, e zum Speichern und Bearbeiten, Esc zum Zuruckgehen', + 'Enter zum Speichern, e zum Speichern und Bearbeiten, Esc zum Zurückgehen', 'Press Enter to continue, {{navigation}}Esc to {{action}}': 'Enter zum Fortfahren, {{navigation}}Esc zum {{action}}', cancel: 'Abbrechen', - 'go back': 'Zuruckgehen', + 'go back': 'Zurückgehen', '↑↓ to navigate, ': '↑↓ zum Navigieren, ', 'Enter a clear, unique name for this subagent.': - 'Geben Sie einen eindeutigen Namen fur diesen Unteragenten ein.', + 'Geben Sie einen eindeutigen Namen für diesen Unteragenten ein.', 'e.g., Code Reviewer': 'z.B. Code-Reviewer', 'Name cannot be empty.': 'Name darf nicht leer sein.', "Write the system prompt that defines this subagent's behavior. Be comprehensive for best results.": - 'Schreiben Sie den System-Prompt, der das Verhalten dieses Unteragenten definiert. Ausfuhrlich fur beste Ergebnisse.', + 'Schreiben Sie den System-Prompt, der das Verhalten dieses Unteragenten definiert. Ausführlich für beste Ergebnisse.', 'e.g., You are an expert code reviewer...': - 'z.B. Sie sind ein Experte fur Code-Reviews...', + 'z.B. Sie sind ein Experte für Code-Reviews...', 'System prompt cannot be empty.': 'System-Prompt darf nicht leer sein.', 'Describe when and how this subagent should be used.': 'Beschreiben Sie, wann und wie dieser Unteragent verwendet werden soll.', 'e.g., Reviews code for best practices and potential bugs.': - 'z.B. Uberpruft Code auf Best Practices und mogliche Fehler.', + 'z.B. Überprüft Code auf Best Practices und mögliche Fehler.', 'Description cannot be empty.': 'Beschreibung darf nicht leer sein.', 'Failed to launch editor: {{error}}': 'Fehler beim Starten des Editors: {{error}}', 'Failed to save and edit subagent: {{error}}': @@ -254,18 +254,18 @@ export default { // ============================================================================ 'View and edit Qwen Code settings': 'Qwen Code Einstellungen anzeigen und bearbeiten', Settings: 'Einstellungen', - '(Use Enter to select{{tabText}})': '(Enter zum Auswahlen{{tabText}})', + '(Use Enter to select{{tabText}})': '(Enter zum Auswählen{{tabText}})', ', Tab to change focus': ', Tab zum Fokuswechsel', 'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.': - 'Um Anderungen zu sehen, muss Qwen Code neu gestartet werden. Drucken Sie r, um jetzt zu beenden und Anderungen anzuwenden.', + 'Um Änderungen zu sehen, muss Qwen Code neu gestartet werden. Drücken Sie r, um jetzt zu beenden und Änderungen anzuwenden.', 'The command "/{{command}}" is not supported in non-interactive mode.': - 'Der Befehl "/{{command}}" wird im nicht-interaktiven Modus nicht unterstutzt.', + 'Der Befehl "/{{command}}" wird im nicht-interaktiven Modus nicht unterstützt.', // ============================================================================ // Settings Labels // ============================================================================ 'Vim Mode': 'Vim-Modus', 'Disable Auto Update': 'Automatische Updates deaktivieren', - 'Enable Prompt Completion': 'Eingabevervollstandigung aktivieren', + 'Enable Prompt Completion': 'Eingabevervollständigung aktivieren', 'Debug Keystroke Logging': 'Debug-Protokollierung von Tastatureingaben', Language: 'Sprache', 'Output Format': 'Ausgabeformat', @@ -277,25 +277,25 @@ export default { 'Hide CWD': 'Arbeitsverzeichnis ausblenden', 'Hide Sandbox Status': 'Sandbox-Status ausblenden', 'Hide Model Info': 'Modellinformationen ausblenden', - 'Hide Footer': 'Fusszeile ausblenden', + 'Hide Footer': 'Fußzeile ausblenden', 'Show Memory Usage': 'Speichernutzung anzeigen', 'Show Line Numbers': 'Zeilennummern anzeigen', 'Show Citations': 'Quellenangaben anzeigen', - 'Custom Witty Phrases': 'Benutzerdefinierte Witzige Spruche', - 'Enable Welcome Back': 'Willkommen-zuruck aktivieren', - 'Disable Loading Phrases': 'Ladespruche deaktivieren', + 'Custom Witty Phrases': 'Benutzerdefinierte Witzige Sprüche', + 'Enable Welcome Back': 'Willkommen-zurück aktivieren', + 'Disable Loading Phrases': 'Ladesprüche deaktivieren', 'Screen Reader Mode': 'Bildschirmleser-Modus', 'IDE Mode': 'IDE-Modus', 'Max Session Turns': 'Maximale Sitzungsrunden', - 'Skip Next Speaker Check': 'Nachste-Sprecher-Prufung uberspringen', - 'Skip Loop Detection': 'Schleifenerkennung uberspringen', - 'Skip Startup Context': 'Startkontext uberspringen', + 'Skip Next Speaker Check': 'Nächste-Sprecher-Prüfung überspringen', + 'Skip Loop Detection': 'Schleifenerkennung überspringen', + 'Skip Startup Context': 'Startkontext überspringen', 'Enable OpenAI Logging': 'OpenAI-Protokollierung aktivieren', 'OpenAI Logging Directory': 'OpenAI-Protokollierungsverzeichnis', Timeout: 'Zeitlimit', 'Max Retries': 'Maximale Wiederholungen', 'Disable Cache Control': 'Cache-Steuerung deaktivieren', - 'Memory Discovery Max Dirs': 'Maximale Verzeichnisse fur Speichererkennung', + 'Memory Discovery Max Dirs': 'Maximale Verzeichnisse für Speichererkennung', 'Load Memory From Include Directories': 'Speicher aus Include-Verzeichnissen laden', 'Respect .gitignore': '.gitignore beachten', @@ -307,12 +307,12 @@ export default { 'Auto Accept': 'Automatisch akzeptieren', 'Use Ripgrep': 'Ripgrep verwenden', 'Use Builtin Ripgrep': 'Integriertes Ripgrep verwenden', - 'Enable Tool Output Truncation': 'Werkzeugausgabe-Kurzung aktivieren', - 'Tool Output Truncation Threshold': 'Schwellenwert fur Werkzeugausgabe-Kurzung', - 'Tool Output Truncation Lines': 'Zeilen fur Werkzeugausgabe-Kurzung', + 'Enable Tool Output Truncation': 'Werkzeugausgabe-Kürzung aktivieren', + 'Tool Output Truncation Threshold': 'Schwellenwert für Werkzeugausgabe-Kürzung', + 'Tool Output Truncation Lines': 'Zeilen für Werkzeugausgabe-Kürzung', 'Folder Trust': 'Ordnervertrauen', 'Vision Model Preview': 'Vision-Modell-Vorschau', - 'Tool Schema Compliance': 'Werkzeug-Schema-Konformitat', + 'Tool Schema Compliance': 'Werkzeug-Schema-Konformität', // Settings enum options 'Auto (detect from system)': 'Automatisch (vom System erkennen)', Text: 'Text', @@ -323,17 +323,17 @@ export default { YOLO: 'YOLO', 'toggle vim mode on/off': 'Vim-Modus ein-/ausschalten', 'check session stats. Usage: /stats [model|tools]': - 'Sitzungsstatistiken prufen. Verwendung: /stats [model|tools]', + 'Sitzungsstatistiken prüfen. Verwendung: /stats [model|tools]', 'Show model-specific usage statistics.': 'Modellspezifische Nutzungsstatistiken anzeigen.', 'Show tool-specific usage statistics.': 'Werkzeugspezifische Nutzungsstatistiken anzeigen.', 'exit the cli': 'CLI beenden', 'list configured MCP servers and tools, or authenticate with OAuth-enabled servers': - 'Konfigurierte MCP-Server und Werkzeuge auflisten oder mit OAuth-fahigen Servern authentifizieren', + 'Konfigurierte MCP-Server und Werkzeuge auflisten oder mit OAuth-fähigen Servern authentifizieren', 'Manage workspace directories': 'Arbeitsbereichsverzeichnisse verwalten', 'Add directories to the workspace. Use comma to separate multiple paths': - 'Verzeichnisse zum Arbeitsbereich hinzufugen. Komma zum Trennen mehrerer Pfade verwenden', + 'Verzeichnisse zum Arbeitsbereich hinzufügen. Komma zum Trennen mehrerer Pfade verwenden', 'Show all directories in the workspace': 'Alle Verzeichnisse im Arbeitsbereich anzeigen', 'set external editor preference': 'Externen Editor festlegen', @@ -342,55 +342,55 @@ export default { 'Update extensions. Usage: update |--all': 'Erweiterungen aktualisieren. Verwendung: update |--all', 'manage IDE integration': 'IDE-Integration verwalten', - 'check status of IDE integration': 'Status der IDE-Integration prufen', + 'check status of IDE integration': 'Status der IDE-Integration prüfen', 'install required IDE companion for {{ideName}}': - 'Erforderlichen IDE-Begleiter fur {{ideName}} installieren', + 'Erforderlichen IDE-Begleiter für {{ideName}} installieren', 'enable IDE integration': 'IDE-Integration aktivieren', 'disable IDE integration': 'IDE-Integration deaktivieren', 'IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: VS Code or VS Code forks.': - 'IDE-Integration wird in Ihrer aktuellen Umgebung nicht unterstutzt. Um diese Funktion zu nutzen, fuhren Sie Qwen Code in einer dieser unterstutzten IDEs aus: VS Code oder VS Code-Forks.', + 'IDE-Integration wird in Ihrer aktuellen Umgebung nicht unterstützt. Um diese Funktion zu nutzen, führen Sie Qwen Code in einer dieser unterstützten IDEs aus: VS Code oder VS Code-Forks.', 'Set up GitHub Actions': 'GitHub Actions einrichten', 'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf, Trae)': - 'Terminal-Tastenbelegungen fur mehrzeilige Eingabe konfigurieren (VS Code, Cursor, Windsurf, Trae)', + 'Terminal-Tastenbelegungen für mehrzeilige Eingabe konfigurieren (VS Code, Cursor, Windsurf, Trae)', 'Please restart your terminal for the changes to take effect.': - 'Bitte starten Sie Ihr Terminal neu, damit die Anderungen wirksam werden.', + 'Bitte starten Sie Ihr Terminal neu, damit die Änderungen wirksam werden.', 'Failed to configure terminal: {{error}}': 'Fehler beim Konfigurieren des Terminals: {{error}}', 'Could not determine {{terminalName}} config path on Windows: APPDATA environment variable is not set.': 'Konnte {{terminalName}}-Konfigurationspfad unter Windows nicht ermitteln: APPDATA-Umgebungsvariable ist nicht gesetzt.', '{{terminalName}} keybindings.json exists but is not a valid JSON array. Please fix the file manually or delete it to allow automatic configuration.': - '{{terminalName}} keybindings.json existiert, ist aber kein gultiges JSON-Array. Bitte korrigieren Sie die Datei manuell oder loschen Sie sie, um automatische Konfiguration zu ermoglichen.', + '{{terminalName}} keybindings.json existiert, ist aber kein gültiges JSON-Array. Bitte korrigieren Sie die Datei manuell oder löschen Sie sie, um automatische Konfiguration zu ermöglichen.', 'File: {{file}}': 'Datei: {{file}}', 'Failed to parse {{terminalName}} keybindings.json. The file contains invalid JSON. Please fix the file manually or delete it to allow automatic configuration.': - 'Fehler beim Parsen von {{terminalName}} keybindings.json. Die Datei enthalt ungultiges JSON. Bitte korrigieren Sie die Datei manuell oder loschen Sie sie, um automatische Konfiguration zu ermoglichen.', + 'Fehler beim Parsen von {{terminalName}} keybindings.json. Die Datei enthält ungültiges JSON. Bitte korrigieren Sie die Datei manuell oder löschen Sie sie, um automatische Konfiguration zu ermöglichen.', 'Error: {{error}}': 'Fehler: {{error}}', 'Shift+Enter binding already exists': 'Umschalt+Enter-Belegung existiert bereits', 'Ctrl+Enter binding already exists': 'Strg+Enter-Belegung existiert bereits', 'Existing keybindings detected. Will not modify to avoid conflicts.': - 'Bestehende Tastenbelegungen erkannt. Keine Anderungen, um Konflikte zu vermeiden.', + 'Bestehende Tastenbelegungen erkannt. Keine Änderungen, um Konflikte zu vermeiden.', 'Please check and modify manually if needed: {{file}}': - 'Bitte prufen und bei Bedarf manuell andern: {{file}}', + 'Bitte prüfen und bei Bedarf manuell ändern: {{file}}', 'Added Shift+Enter and Ctrl+Enter keybindings to {{terminalName}}.': - 'Umschalt+Enter und Strg+Enter Tastenbelegungen zu {{terminalName}} hinzugefugt.', - 'Modified: {{file}}': 'Geandert: {{file}}', + 'Umschalt+Enter und Strg+Enter Tastenbelegungen zu {{terminalName}} hinzugefügt.', + 'Modified: {{file}}': 'Geändert: {{file}}', '{{terminalName}} keybindings already configured.': '{{terminalName}}-Tastenbelegungen bereits konfiguriert.', 'Failed to configure {{terminalName}}.': 'Fehler beim Konfigurieren von {{terminalName}}.', 'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).': - 'Ihr Terminal ist bereits fur optimale Erfahrung mit mehrzeiliger Eingabe konfiguriert (Umschalt+Enter und Strg+Enter).', + 'Ihr Terminal ist bereits für optimale Erfahrung mit mehrzeiliger Eingabe konfiguriert (Umschalt+Enter und Strg+Enter).', 'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.': - 'Terminal-Typ konnte nicht erkannt werden. Unterstutzte Terminals: VS Code, Cursor, Windsurf und Trae.', + 'Terminal-Typ konnte nicht erkannt werden. Unterstützte Terminals: VS Code, Cursor, Windsurf und Trae.', 'Terminal "{{terminal}}" is not supported yet.': - 'Terminal "{{terminal}}" wird noch nicht unterstutzt.', + 'Terminal "{{terminal}}" wird noch nicht unterstützt.', // ============================================================================ // Commands - Language // ============================================================================ 'Invalid language. Available: en-US, zh-CN': - 'Ungultige Sprache. Verfugbar: en-US, zh-CN', + 'Ungültige Sprache. Verfügbar: en-US, zh-CN', 'Language subcommands do not accept additional arguments.': - 'Sprach-Unterbefehle akzeptieren keine zusatzlichen Argumente.', + 'Sprach-Unterbefehle akzeptieren keine zusätzlichen Argumente.', 'Current UI language: {{lang}}': 'Aktuelle UI-Sprache: {{lang}}', 'Current LLM output language: {{lang}}': 'Aktuelle LLM-Ausgabesprache: {{lang}}', @@ -402,19 +402,19 @@ export default { 'Example: /language output 中文': 'Beispiel: /language output Deutsch', 'Example: /language output English': 'Beispiel: /language output English', 'Example: /language output 日本語': 'Beispiel: /language output Japanisch', - 'UI language changed to {{lang}}': 'UI-Sprache geandert zu {{lang}}', + 'UI language changed to {{lang}}': 'UI-Sprache geändert zu {{lang}}', 'LLM output language rule file generated at {{path}}': 'LLM-Ausgabesprach-Regeldatei generiert unter {{path}}', 'Please restart the application for the changes to take effect.': - 'Bitte starten Sie die Anwendung neu, damit die Anderungen wirksam werden.', + 'Bitte starten Sie die Anwendung neu, damit die Änderungen wirksam werden.', 'Failed to generate LLM output language rule file: {{error}}': 'Fehler beim Generieren der LLM-Ausgabesprach-Regeldatei: {{error}}', 'Invalid command. Available subcommands:': - 'Ungultiger Befehl. Verfugbare Unterbefehle:', - 'Available subcommands:': 'Verfugbare Unterbefehle:', + 'Ungültiger Befehl. Verfügbare Unterbefehle:', + 'Available subcommands:': 'Verfügbare Unterbefehle:', 'To request additional UI language packs, please open an issue on GitHub.': - 'Um zusatzliche UI-Sprachpakete anzufordern, offnen Sie bitte ein Issue auf GitHub.', - 'Available options:': 'Verfugbare Optionen:', + 'Um zusätzliche UI-Sprachpakete anzufordern, öffnen Sie bitte ein Issue auf GitHub.', + 'Available options:': 'Verfügbare Optionen:', ' - zh-CN: Simplified Chinese': ' - zh-CN: Vereinfachtes Chinesisch', ' - en-US: English': ' - en-US: Englisch', 'Set UI language to Simplified Chinese (zh-CN)': @@ -426,45 +426,45 @@ export default { // ============================================================================ 'Approval Mode': 'Genehmigungsmodus', 'Current approval mode: {{mode}}': 'Aktueller Genehmigungsmodus: {{mode}}', - 'Available approval modes:': 'Verfugbare Genehmigungsmodi:', - 'Approval mode changed to: {{mode}}': 'Genehmigungsmodus geandert zu: {{mode}}', + 'Available approval modes:': 'Verfügbare Genehmigungsmodi:', + 'Approval mode changed to: {{mode}}': 'Genehmigungsmodus geändert zu: {{mode}}', 'Approval mode changed to: {{mode}} (saved to {{scope}} settings{{location}})': - 'Genehmigungsmodus geandert zu: {{mode}} (gespeichert in {{scope}} Einstellungen{{location}})', + 'Genehmigungsmodus geändert zu: {{mode}} (gespeichert in {{scope}} Einstellungen{{location}})', 'Usage: /approval-mode [--session|--user|--project]': 'Verwendung: /approval-mode [--session|--user|--project]', 'Scope subcommands do not accept additional arguments.': - 'Bereichs-Unterbefehle akzeptieren keine zusatzlichen Argumente.', + 'Bereichs-Unterbefehle akzeptieren keine zusätzlichen Argumente.', 'Plan mode - Analyze only, do not modify files or execute commands': - 'Planungsmodus - Nur analysieren, keine Dateien andern oder Befehle ausfuhren', + 'Planungsmodus - Nur analysieren, keine Dateien ändern oder Befehle ausführen', 'Default mode - Require approval for file edits or shell commands': - 'Standardmodus - Genehmigung fur Dateibearbeitungen oder Shell-Befehle erforderlich', + 'Standardmodus - Genehmigung für Dateibearbeitungen oder Shell-Befehle erforderlich', 'Auto-edit mode - Automatically approve file edits': 'Automatischer Bearbeitungsmodus - Dateibearbeitungen automatisch genehmigen', 'YOLO mode - Automatically approve all tools': 'YOLO-Modus - Alle Werkzeuge automatisch genehmigen', '{{mode}} mode': '{{mode}}-Modus', 'Settings service is not available; unable to persist the approval mode.': - 'Einstellungsdienst nicht verfugbar; Genehmigungsmodus kann nicht gespeichert werden.', + 'Einstellungsdienst nicht verfügbar; Genehmigungsmodus kann nicht gespeichert werden.', 'Failed to save approval mode: {{error}}': 'Fehler beim Speichern des Genehmigungsmodus: {{error}}', 'Failed to change approval mode: {{error}}': - 'Fehler beim Andern des Genehmigungsmodus: {{error}}', + 'Fehler beim Ändern des Genehmigungsmodus: {{error}}', 'Apply to current session only (temporary)': - 'Nur auf aktuelle Sitzung anwenden (temporar)', - 'Persist for this project/workspace': 'Fur dieses Projekt/Arbeitsbereich speichern', + 'Nur auf aktuelle Sitzung anwenden (temporär)', + 'Persist for this project/workspace': 'Für dieses Projekt/Arbeitsbereich speichern', 'Persist for this user on this machine': - 'Fur diesen Benutzer auf diesem Computer speichern', + 'Für diesen Benutzer auf diesem Computer speichern', 'Analyze only, do not modify files or execute commands': - 'Nur analysieren, keine Dateien andern oder Befehle ausfuhren', + 'Nur analysieren, keine Dateien ändern oder Befehle ausführen', 'Require approval for file edits or shell commands': - 'Genehmigung fur Dateibearbeitungen oder Shell-Befehle erforderlich', + 'Genehmigung für Dateibearbeitungen oder Shell-Befehle erforderlich', 'Automatically approve file edits': 'Dateibearbeitungen automatisch genehmigen', 'Automatically approve all tools': 'Alle Werkzeuge automatisch genehmigen', 'Workspace approval mode exists and takes priority. User-level change will have no effect.': - 'Arbeitsbereich-Genehmigungsmodus existiert und hat Vorrang. Benutzerebene-Anderung hat keine Wirkung.', + 'Arbeitsbereich-Genehmigungsmodus existiert und hat Vorrang. Benutzerebene-Änderung hat keine Wirkung.', '(Use Enter to select, Tab to change focus)': - '(Enter zum Auswahlen, Tab zum Fokuswechsel)', + '(Enter zum Auswählen, Tab zum Fokuswechsel)', 'Apply To': 'Anwenden auf', 'User Settings': 'Benutzereinstellungen', 'Workspace Settings': 'Arbeitsbereich-Einstellungen', @@ -473,13 +473,13 @@ export default { // Commands - Memory // ============================================================================ 'Commands for interacting with memory.': - 'Befehle fur die Interaktion mit dem Speicher.', + 'Befehle für die Interaktion mit dem Speicher.', 'Show the current memory contents.': 'Aktuellen Speicherinhalt anzeigen.', 'Show project-level memory contents.': 'Projektebene-Speicherinhalt anzeigen.', 'Show global memory contents.': 'Globalen Speicherinhalt anzeigen.', 'Add content to project-level memory.': - 'Inhalt zum Projektebene-Speicher hinzufugen.', - 'Add content to global memory.': 'Inhalt zum globalen Speicher hinzufugen.', + 'Inhalt zum Projektebene-Speicher hinzufügen.', + 'Add content to global memory.': 'Inhalt zum globalen Speicher hinzufügen.', 'Refresh the memory from the source.': 'Speicher aus der Quelle aktualisieren.', 'Usage: /memory add --project ': 'Verwendung: /memory add --project ', @@ -505,7 +505,7 @@ export default { 'Refreshing memory from source files...': 'Speicher wird aus Quelldateien aktualisiert...', 'Add content to the memory. Use --global for global memory or --project for project memory.': - 'Inhalt zum Speicher hinzufugen. --global fur globalen Speicher oder --project fur Projektspeicher verwenden.', + 'Inhalt zum Speicher hinzufügen. --global für globalen Speicher oder --project für Projektspeicher verwenden.', 'Usage: /memory add [--global|--project] ': 'Verwendung: /memory add [--global|--project] ', 'Attempting to save to memory {{scope}}: "{{fact}}"': @@ -515,7 +515,7 @@ export default { // Commands - MCP // ============================================================================ 'Authenticate with an OAuth-enabled MCP server': - 'Mit einem OAuth-fahigen MCP-Server authentifizieren', + 'Mit einem OAuth-fähigen MCP-Server authentifizieren', 'List configured MCP servers and tools': 'Konfigurierte MCP-Server und Werkzeuge auflisten', 'Restarts MCP servers.': 'MCP-Server neu starten.', @@ -529,7 +529,7 @@ export default { 'Verwenden Sie /mcp auth zur Authentifizierung.', "MCP server '{{name}}' not found.": "MCP-Server '{{name}}' nicht gefunden.", "Successfully authenticated and refreshed tools for '{{name}}'.": - "Erfolgreich authentifiziert und Werkzeuge fur '{{name}}' aktualisiert.", + "Erfolgreich authentifiziert und Werkzeuge für '{{name}}' aktualisiert.", "Failed to authenticate with MCP server '{{name}}': {{error}}": "Authentifizierung mit MCP-Server '{{name}}' fehlgeschlagen: {{error}}", "Re-discovering tools from '{{name}}'...": @@ -538,47 +538,47 @@ export default { // ============================================================================ // Commands - Chat // ============================================================================ - 'Manage conversation history.': 'Gesprachsverlauf verwalten.', - 'List saved conversation checkpoints': 'Gespeicherte Gesprachspruefpunkte auflisten', + 'Manage conversation history.': 'Gesprächsverlauf verwalten.', + 'List saved conversation checkpoints': 'Gespeicherte Gesprächsprüfpunkte auflisten', 'No saved conversation checkpoints found.': - 'Keine gespeicherten Gesprachsprufpunkte gefunden.', - 'List of saved conversations:': 'Liste gespeicherter Gesprache:', - 'Note: Newest last, oldest first': 'Hinweis: Neueste zuletzt, alteste zuerst', + 'Keine gespeicherten Gesprächsprüfpunkte gefunden.', + 'List of saved conversations:': 'Liste gespeicherter Gespräche:', + 'Note: Newest last, oldest first': 'Hinweis: Neueste zuletzt, älteste zuerst', 'Save the current conversation as a checkpoint. Usage: /chat save ': - 'Aktuelles Gesprach als Prufpunkt speichern. Verwendung: /chat save ', + 'Aktuelles Gespräch als Prüfpunkt speichern. Verwendung: /chat save ', 'Missing tag. Usage: /chat save ': 'Tag fehlt. Verwendung: /chat save ', 'Delete a conversation checkpoint. Usage: /chat delete ': - 'Gesprachsprufpunkt loschen. Verwendung: /chat delete ', + 'Gesprächsprüfpunkt löschen. Verwendung: /chat delete ', 'Missing tag. Usage: /chat delete ': 'Tag fehlt. Verwendung: /chat delete ', "Conversation checkpoint '{{tag}}' has been deleted.": - "Gesprachsprufpunkt '{{tag}}' wurde geloscht.", + "Gesprächsprüfpunkt '{{tag}}' wurde gelöscht.", "Error: No checkpoint found with tag '{{tag}}'.": - "Fehler: Kein Prufpunkt mit Tag '{{tag}}' gefunden.", + "Fehler: Kein Prüfpunkt mit Tag '{{tag}}' gefunden.", 'Resume a conversation from a checkpoint. Usage: /chat resume ': - 'Gesprach von einem Prufpunkt fortsetzen. Verwendung: /chat resume ', + 'Gespräch von einem Prüfpunkt fortsetzen. Verwendung: /chat resume ', 'Missing tag. Usage: /chat resume ': 'Tag fehlt. Verwendung: /chat resume ', 'No saved checkpoint found with tag: {{tag}}.': - 'Kein gespeicherter Prufpunkt mit Tag gefunden: {{tag}}.', + 'Kein gespeicherter Prüfpunkt mit Tag gefunden: {{tag}}.', 'A checkpoint with the tag {{tag}} already exists. Do you want to overwrite it?': - 'Ein Prufpunkt mit dem Tag {{tag}} existiert bereits. Mochten Sie ihn uberschreiben?', + 'Ein Prüfpunkt mit dem Tag {{tag}} existiert bereits. Möchten Sie ihn überschreiben?', 'No chat client available to save conversation.': - 'Kein Chat-Client verfugbar, um Gesprach zu speichern.', + 'Kein Chat-Client verfügbar, um Gespräch zu speichern.', 'Conversation checkpoint saved with tag: {{tag}}.': - 'Gesprachsprufpunkt gespeichert mit Tag: {{tag}}.', - 'No conversation found to save.': 'Kein Gesprach zum Speichern gefunden.', + 'Gesprächsprüfpunkt gespeichert mit Tag: {{tag}}.', + 'No conversation found to save.': 'Kein Gespräch zum Speichern gefunden.', 'No chat client available to share conversation.': - 'Kein Chat-Client verfugbar, um Gesprach zu teilen.', + 'Kein Chat-Client verfügbar, um Gespräch zu teilen.', 'Invalid file format. Only .md and .json are supported.': - 'Ungultiges Dateiformat. Nur .md und .json werden unterstutzt.', + 'Ungültiges Dateiformat. Nur .md und .json werden unterstützt.', 'Error sharing conversation: {{error}}': - 'Fehler beim Teilen des Gesprachs: {{error}}', - 'Conversation shared to {{filePath}}': 'Gesprach geteilt nach {{filePath}}', - 'No conversation found to share.': 'Kein Gesprach zum Teilen gefunden.', + 'Fehler beim Teilen des Gesprächs: {{error}}', + 'Conversation shared to {{filePath}}': 'Gespräch geteilt nach {{filePath}}', + 'No conversation found to share.': 'Kein Gespräch zum Teilen gefunden.', 'Share the current conversation to a markdown or json file. Usage: /chat share ': - 'Aktuelles Gesprach in eine Markdown- oder JSON-Datei teilen. Verwendung: /chat share ', + 'Aktuelles Gespräch in eine Markdown- oder JSON-Datei teilen. Verwendung: /chat share ', // ============================================================================ // Commands - Summary @@ -586,10 +586,10 @@ export default { 'Generate a project summary and save it to .qwen/PROJECT_SUMMARY.md': 'Projektzusammenfassung generieren und in .qwen/PROJECT_SUMMARY.md speichern', 'No chat client available to generate summary.': - 'Kein Chat-Client verfugbar, um Zusammenfassung zu generieren.', + 'Kein Chat-Client verfügbar, um Zusammenfassung zu generieren.', 'Already generating summary, wait for previous request to complete': 'Zusammenfassung wird bereits generiert, warten Sie auf Abschluss der vorherigen Anfrage', - 'No conversation found to summarize.': 'Kein Gesprach zum Zusammenfassen gefunden.', + 'No conversation found to summarize.': 'Kein Gespräch zum Zusammenfassen gefunden.', 'Failed to generate project context summary: {{error}}': 'Fehler beim Generieren der Projektkontextzusammenfassung: {{error}}', 'Saved project summary to {{filePathForDisplay}}.': @@ -602,26 +602,26 @@ export default { // ============================================================================ // Commands - Model // ============================================================================ - 'Switch the model for this session': 'Modell fur diese Sitzung wechseln', + 'Switch the model for this session': 'Modell für diese Sitzung wechseln', 'Content generator configuration not available.': - 'Inhaltsgenerator-Konfiguration nicht verfugbar.', - 'Authentication type not available.': 'Authentifizierungstyp nicht verfugbar.', + 'Inhaltsgenerator-Konfiguration nicht verfügbar.', + 'Authentication type not available.': 'Authentifizierungstyp nicht verfügbar.', 'No models available for the current authentication type ({{authType}}).': - 'Keine Modelle fur den aktuellen Authentifizierungstyp ({{authType}}) verfugbar.', + 'Keine Modelle für den aktuellen Authentifizierungstyp ({{authType}}) verfügbar.', // ============================================================================ // Commands - Clear // ============================================================================ 'Starting a new session, resetting chat, and clearing terminal.': - 'Neue Sitzung wird gestartet, Chat wird zuruckgesetzt und Terminal wird geloscht.', + 'Neue Sitzung wird gestartet, Chat wird zurückgesetzt und Terminal wird gelöscht.', 'Starting a new session and clearing.': - 'Neue Sitzung wird gestartet und geloscht.', + 'Neue Sitzung wird gestartet und gelöscht.', // ============================================================================ // Commands - Compress // ============================================================================ 'Already compressing, wait for previous request to complete': - 'Komprimierung lauft bereits, warten Sie auf Abschluss der vorherigen Anfrage', + 'Komprimierung läuft bereits, warten Sie auf Abschluss der vorherigen Anfrage', 'Failed to compress chat history.': 'Fehler beim Komprimieren des Chatverlaufs.', 'Failed to compress chat history: {{error}}': 'Fehler beim Komprimieren des Chatverlaufs: {{error}}', @@ -629,27 +629,27 @@ export default { 'Chat history compressed from {{originalTokens}} to {{newTokens}} tokens.': 'Chatverlauf komprimiert von {{originalTokens}} auf {{newTokens}} Token.', 'Compression was not beneficial for this history size.': - 'Komprimierung war fur diese Verlaufsgross nicht vorteilhaft.', + 'Komprimierung war für diese Verlaufsgröße nicht vorteilhaft.', 'Chat history compression did not reduce size. This may indicate issues with the compression prompt.': - 'Chatverlauf-Komprimierung hat die Grosse nicht reduziert. Dies kann auf Probleme mit dem Komprimierungs-Prompt hindeuten.', + 'Chatverlauf-Komprimierung hat die Größe nicht reduziert. Dies kann auf Probleme mit dem Komprimierungs-Prompt hindeuten.', 'Could not compress chat history due to a token counting error.': - 'Chatverlauf konnte aufgrund eines Token-Zahlfehlers nicht komprimiert werden.', + 'Chatverlauf konnte aufgrund eines Token-Zählfehlers nicht komprimiert werden.', 'Chat history is already compressed.': 'Chatverlauf ist bereits komprimiert.', // ============================================================================ // Commands - Directory // ============================================================================ - 'Configuration is not available.': 'Konfiguration ist nicht verfugbar.', + 'Configuration is not available.': 'Konfiguration ist nicht verfügbar.', 'Please provide at least one path to add.': - 'Bitte geben Sie mindestens einen Pfad zum Hinzufugen an.', + 'Bitte geben Sie mindestens einen Pfad zum Hinzufügen an.', 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.': - 'Der Befehl /directory add wird in restriktiven Sandbox-Profilen nicht unterstutzt. Bitte verwenden Sie --include-directories beim Starten der Sitzung.', - "Error adding '{{path}}': {{error}}": "Fehler beim Hinzufugen von '{{path}}': {{error}}", + 'Der Befehl /directory add wird in restriktiven Sandbox-Profilen nicht unterstützt. Bitte verwenden Sie --include-directories beim Starten der Sitzung.', + "Error adding '{{path}}': {{error}}": "Fehler beim Hinzufügen von '{{path}}': {{error}}", 'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}': - 'QWEN.md-Dateien aus folgenden Verzeichnissen erfolgreich hinzugefugt, falls vorhanden:\n- {{directories}}', + 'QWEN.md-Dateien aus folgenden Verzeichnissen erfolgreich hinzugefügt, falls vorhanden:\n- {{directories}}', 'Error refreshing memory: {{error}}': 'Fehler beim Aktualisieren des Speichers: {{error}}', 'Successfully added directories:\n- {{directories}}': - 'Verzeichnisse erfolgreich hinzugefugt:\n- {{directories}}', + 'Verzeichnisse erfolgreich hinzugefügt:\n- {{directories}}', 'Current workspace directories:\n{{directories}}': 'Aktuelle Arbeitsbereichsverzeichnisse:\n{{directories}}', @@ -657,36 +657,36 @@ export default { // Commands - Docs // ============================================================================ 'Please open the following URL in your browser to view the documentation:\n{{url}}': - 'Bitte offnen Sie folgende URL in Ihrem Browser, um die Dokumentation anzusehen:\n{{url}}', + 'Bitte öffnen Sie folgende URL in Ihrem Browser, um die Dokumentation anzusehen:\n{{url}}', 'Opening documentation in your browser: {{url}}': - 'Dokumentation wird in Ihrem Browser geoffnet: {{url}}', + 'Dokumentation wird in Ihrem Browser geöffnet: {{url}}', // ============================================================================ // Dialogs - Tool Confirmation // ============================================================================ - 'Do you want to proceed?': 'Mochten Sie fortfahren?', + 'Do you want to proceed?': 'Möchten Sie fortfahren?', 'Yes, allow once': 'Ja, einmal erlauben', 'Allow always': 'Immer erlauben', No: 'Nein', 'No (esc)': 'Nein (Esc)', - 'Yes, allow always for this session': 'Ja, fur diese Sitzung immer erlauben', - 'Modify in progress:': 'Anderung in Bearbeitung:', + 'Yes, allow always for this session': 'Ja, für diese Sitzung immer erlauben', + 'Modify in progress:': 'Änderung in Bearbeitung:', 'Save and close external editor to continue': - 'Speichern und externen Editor schliessen, um fortzufahren', - 'Apply this change?': 'Diese Anderung anwenden?', + 'Speichern und externen Editor schließen, um fortzufahren', + 'Apply this change?': 'Diese Änderung anwenden?', 'Yes, allow always': 'Ja, immer erlauben', 'Modify with external editor': 'Mit externem Editor bearbeiten', - 'No, suggest changes (esc)': 'Nein, Anderungen vorschlagen (Esc)', - "Allow execution of: '{{command}}'?": "Ausfuhrung erlauben von: '{{command}}'?", + 'No, suggest changes (esc)': 'Nein, Änderungen vorschlagen (Esc)', + "Allow execution of: '{{command}}'?": "Ausführung erlauben von: '{{command}}'?", 'Yes, allow always ...': 'Ja, immer erlauben ...', - 'Yes, and auto-accept edits': 'Ja, und Anderungen automatisch akzeptieren', - 'Yes, and manually approve edits': 'Ja, und Anderungen manuell genehmigen', + 'Yes, and auto-accept edits': 'Ja, und Änderungen automatisch akzeptieren', + 'Yes, and manually approve edits': 'Ja, und Änderungen manuell genehmigen', 'No, keep planning (esc)': 'Nein, weiter planen (Esc)', 'URLs to fetch:': 'Abzurufende URLs:', 'MCP Server: {{server}}': 'MCP-Server: {{server}}', 'Tool: {{tool}}': 'Werkzeug: {{tool}}', 'Allow execution of MCP tool "{{tool}}" from server "{{server}}"?': - 'Ausfuhrung des MCP-Werkzeugs "{{tool}}" von Server "{{server}}" erlauben?', + 'Ausführung des MCP-Werkzeugs "{{tool}}" von Server "{{server}}" erlauben?', 'Yes, always allow tool "{{tool}}" from server "{{server}}"': 'Ja, Werkzeug "{{tool}}" von Server "{{server}}" immer erlauben', 'Yes, always allow all tools from server "{{server}}"': @@ -695,17 +695,17 @@ export default { // ============================================================================ // Dialogs - Shell Confirmation // ============================================================================ - 'Shell Command Execution': 'Shell-Befehlsausfuhrung', + 'Shell Command Execution': 'Shell-Befehlsausführung', 'A custom command wants to run the following shell commands:': - 'Ein benutzerdefinierter Befehl mochte folgende Shell-Befehle ausfuhren:', + 'Ein benutzerdefinierter Befehl möchte folgende Shell-Befehle ausführen:', // ============================================================================ // Dialogs - Pro Quota // ============================================================================ 'Pro quota limit reached for {{model}}.': - 'Pro-Kontingentlimit fur {{model}} erreicht.', + 'Pro-Kontingentlimit für {{model}} erreicht.', 'Change auth (executes the /auth command)': - 'Authentifizierung andern (fuhrt den /auth-Befehl aus)', + 'Authentifizierung ändern (führt den /auth-Befehl aus)', 'Continue with {{model}}': 'Mit {{model}} fortfahren', // ============================================================================ @@ -716,13 +716,13 @@ export default { 'Fortschritt: {{done}}/{{total}} Aufgaben abgeschlossen', ', {{inProgress}} in progress': ', {{inProgress}} in Bearbeitung', 'Pending Tasks:': 'Ausstehende Aufgaben:', - 'What would you like to do?': 'Was mochten Sie tun?', + 'What would you like to do?': 'Was möchten Sie tun?', 'Choose how to proceed with your session:': - 'Wahlen Sie, wie Sie mit Ihrer Sitzung fortfahren mochten:', + 'Wählen Sie, wie Sie mit Ihrer Sitzung fortfahren möchten:', 'Start new chat session': 'Neue Chat-Sitzung starten', - 'Continue previous conversation': 'Vorheriges Gesprach fortsetzen', + 'Continue previous conversation': 'Vorheriges Gespräch fortsetzen', '👋 Welcome back! (Last updated: {{timeAgo}})': - '👋 Willkommen zuruck! (Zuletzt aktualisiert: {{timeAgo}})', + '👋 Willkommen zurück! (Zuletzt aktualisiert: {{timeAgo}})', '🎯 Overall Goal:': '🎯 Gesamtziel:', // ============================================================================ @@ -730,14 +730,14 @@ export default { // ============================================================================ 'Get started': 'Loslegen', 'How would you like to authenticate for this project?': - 'Wie mochten Sie sich fur dieses Projekt authentifizieren?', + 'Wie möchten Sie sich für dieses Projekt authentifizieren?', 'OpenAI API key is required to use OpenAI authentication.': - 'OpenAI API-Schlussel ist fur die OpenAI-Authentifizierung erforderlich.', + 'OpenAI API-Schlüssel ist für die OpenAI-Authentifizierung erforderlich.', 'You must select an auth method to proceed. Press Ctrl+C again to exit.': - 'Sie mussen eine Authentifizierungsmethode wahlen, um fortzufahren. Drucken Sie erneut Strg+C zum Beenden.', + 'Sie müssen eine Authentifizierungsmethode wählen, um fortzufahren. Drücken Sie erneut Strg+C zum Beenden.', '(Use Enter to Set Auth)': '(Enter zum Festlegen der Authentifizierung)', 'Terms of Services and Privacy Notice for Qwen Code': - 'Nutzungsbedingungen und Datenschutzhinweis fur Qwen Code', + 'Nutzungsbedingungen und Datenschutzhinweis für Qwen Code', 'Qwen OAuth': 'Qwen OAuth', OpenAI: 'OpenAI', 'Failed to login. Message: {{message}}': @@ -753,32 +753,32 @@ export default { 'Or scan the QR code below:': 'Oder scannen Sie den QR-Code unten:', 'Waiting for authorization': 'Warten auf Autorisierung', 'Time remaining:': 'Verbleibende Zeit:', - '(Press ESC or CTRL+C to cancel)': '(ESC oder STRG+C zum Abbrechen drucken)', + '(Press ESC or CTRL+C to cancel)': '(ESC oder STRG+C zum Abbrechen drücken)', 'Qwen OAuth Authentication Timeout': 'Qwen OAuth-Authentifizierung abgelaufen', 'OAuth token expired (over {{seconds}} seconds). Please select authentication method again.': - 'OAuth-Token abgelaufen (uber {{seconds}} Sekunden). Bitte wahlen Sie erneut eine Authentifizierungsmethode.', + 'OAuth-Token abgelaufen (über {{seconds}} Sekunden). Bitte wählen Sie erneut eine Authentifizierungsmethode.', 'Press any key to return to authentication type selection.': - 'Drucken Sie eine beliebige Taste, um zur Authentifizierungstypauswahl zuruckzukehren.', + 'Drücken Sie eine beliebige Taste, um zur Authentifizierungstypauswahl zurückzukehren.', 'Waiting for Qwen OAuth authentication...': 'Warten auf Qwen OAuth-Authentifizierung...', 'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.': - 'Hinweis: Ihr bestehender API-Schlussel in settings.json wird bei Verwendung von Qwen OAuth nicht geloscht. Sie konnen spater bei Bedarf zur OpenAI-Authentifizierung zuruckwechseln.', + 'Hinweis: Ihr bestehender API-Schlüssel in settings.json wird bei Verwendung von Qwen OAuth nicht gelöscht. Sie können später bei Bedarf zur OpenAI-Authentifizierung zurückwechseln.', 'Authentication timed out. Please try again.': 'Authentifizierung abgelaufen. Bitte versuchen Sie es erneut.', 'Waiting for auth... (Press ESC or CTRL+C to cancel)': - 'Warten auf Authentifizierung... (ESC oder STRG+C zum Abbrechen drucken)', + 'Warten auf Authentifizierung... (ESC oder STRG+C zum Abbrechen drücken)', 'Failed to authenticate. Message: {{message}}': 'Authentifizierung fehlgeschlagen. Meldung: {{message}}', 'Authenticated successfully with {{authType}} credentials.': 'Erfolgreich mit {{authType}}-Anmeldedaten authentifiziert.', 'Invalid QWEN_DEFAULT_AUTH_TYPE value: "{{value}}". Valid values are: {{validValues}}': - 'Ungultiger QWEN_DEFAULT_AUTH_TYPE-Wert: "{{value}}". Gultige Werte sind: {{validValues}}', + 'Ungültiger QWEN_DEFAULT_AUTH_TYPE-Wert: "{{value}}". Gültige Werte sind: {{validValues}}', 'OpenAI Configuration Required': 'OpenAI-Konfiguration erforderlich', 'Please enter your OpenAI configuration. You can get an API key from': - 'Bitte geben Sie Ihre OpenAI-Konfiguration ein. Sie konnen einen API-Schlussel erhalten von', - 'API Key:': 'API-Schlussel:', + 'Bitte geben Sie Ihre OpenAI-Konfiguration ein. Sie können einen API-Schlüssel erhalten von', + 'API Key:': 'API-Schlüssel:', 'Invalid credentials: {{errorMessage}}': - 'Ungultige Anmeldedaten: {{errorMessage}}', + 'Ungültige Anmeldedaten: {{errorMessage}}', 'Failed to validate credentials': 'Anmeldedaten konnten nicht validiert werden', 'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel': 'Enter zum Fortfahren, Tab/↑↓ zum Navigieren, Esc zum Abbrechen', @@ -786,8 +786,8 @@ export default { // ============================================================================ // Dialogs - Model // ============================================================================ - 'Select Model': 'Modell auswahlen', - '(Press Esc to close)': '(Esc zum Schliessen drucken)', + 'Select Model': 'Modell auswählen', + '(Press Esc to close)': '(Esc zum Schließen drücken)', '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)', 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)': @@ -802,8 +802,8 @@ export default { // Status Bar // ============================================================================ 'Using:': 'Verwendet:', - '{{count}} open file': '{{count}} geoffnete Datei', - '{{count}} open files': '{{count}} geoffnete Dateien', + '{{count}} open file': '{{count}} geöffnete Datei', + '{{count}} open files': '{{count}} geöffnete Dateien', '(ctrl+g to view)': '(Strg+G zum Anzeigen)', '{{count}} {{name}} file': '{{count}} {{name}}-Datei', '{{count}} {{name}} files': '{{count}} {{name}}-Dateien', @@ -812,9 +812,9 @@ export default { '{{count}} Blocked': '{{count}} blockiert', '(ctrl+t to view)': '(Strg+T zum Anzeigen)', '(ctrl+t to toggle)': '(Strg+T zum Umschalten)', - 'Press Ctrl+C again to exit.': 'Drucken Sie erneut Strg+C zum Beenden.', - 'Press Ctrl+D again to exit.': 'Drucken Sie erneut Strg+D zum Beenden.', - 'Press Esc again to clear.': 'Drucken Sie erneut Esc zum Loschen.', + 'Press Ctrl+C again to exit.': 'Drücken Sie erneut Strg+C zum Beenden.', + 'Press Ctrl+D again to exit.': 'Drücken Sie erneut Strg+D zum Beenden.', + 'Press Esc again to clear.': 'Drücken Sie erneut Esc zum Löschen.', // ============================================================================ // MCP Status @@ -826,11 +826,11 @@ export default { '⏳ MCP servers are starting up ({{count}} initializing)...': '⏳ MCP-Server werden gestartet ({{count}} werden initialisiert)...', 'Note: First startup may take longer. Tool availability will update automatically.': - 'Hinweis: Der erste Start kann langer dauern. Werkzeugverfugbarkeit wird automatisch aktualisiert.', + 'Hinweis: Der erste Start kann länger dauern. Werkzeugverfügbarkeit wird automatisch aktualisiert.', 'Configured MCP servers:': 'Konfigurierte MCP-Server:', Ready: 'Bereit', 'Starting... (first startup may take longer)': - 'Wird gestartet... (erster Start kann langer dauern)', + 'Wird gestartet... (erster Start kann länger dauern)', Disconnected: 'Getrennt', '{{count}} tool': '{{count}} Werkzeug', '{{count}} tools': '{{count}} Werkzeuge', @@ -854,12 +854,12 @@ export default { 'to show tool parameter schemas': 'um Werkzeug-Parameter-Schemas anzuzeigen', 'to hide descriptions': 'um Beschreibungen auszublenden', 'to authenticate with OAuth-enabled servers': - 'um sich bei OAuth-fahigen Servern zu authentifizieren', - Press: 'Drucken Sie', + 'um sich bei OAuth-fähigen Servern zu authentifizieren', + Press: 'Drücken Sie', 'to toggle tool descriptions on/off': 'um Werkzeugbeschreibungen ein-/auszuschalten', "Starting OAuth authentication for MCP server '{{name}}'...": - "OAuth-Authentifizierung fur MCP-Server '{{name}}' wird gestartet...", + "OAuth-Authentifizierung für MCP-Server '{{name}}' wird gestartet...", 'Restarting MCP servers...': 'MCP-Server werden neu gestartet...', // ============================================================================ @@ -867,25 +867,25 @@ export default { // ============================================================================ 'Tips for getting started:': 'Tipps zum Einstieg:', '1. Ask questions, edit files, or run commands.': - '1. Stellen Sie Fragen, bearbeiten Sie Dateien oder fuhren Sie Befehle aus.', + '1. Stellen Sie Fragen, bearbeiten Sie Dateien oder führen Sie Befehle aus.', '2. Be specific for the best results.': - '2. Seien Sie spezifisch fur die besten Ergebnisse.', + '2. Seien Sie spezifisch für die besten Ergebnisse.', 'files to customize your interactions with Qwen Code.': 'Dateien, um Ihre Interaktionen mit Qwen Code anzupassen.', - 'for more information.': 'fur weitere Informationen.', + 'for more information.': 'für weitere Informationen.', // ============================================================================ // Exit Screen / Stats // ============================================================================ 'Agent powering down. Goodbye!': 'Agent wird heruntergefahren. Auf Wiedersehen!', - 'To continue this session, run': 'Um diese Sitzung fortzusetzen, fuhren Sie aus', + 'To continue this session, run': 'Um diese Sitzung fortzusetzen, führen Sie aus', 'Interaction Summary': 'Interaktionszusammenfassung', 'Session ID:': 'Sitzungs-ID:', 'Tool Calls:': 'Werkzeugaufrufe:', 'Success Rate:': 'Erfolgsrate:', 'User Agreement:': 'Benutzerzustimmung:', - reviewed: 'uberpruft', - 'Code Changes:': 'Codeanderungen:', + reviewed: 'überprüft', + 'Code Changes:': 'Codeänderungen:', Performance: 'Leistung', 'Wall Time:': 'Gesamtzeit:', 'Agent Active:': 'Agent aktiv:', @@ -900,9 +900,9 @@ export default { 'of input tokens were served from the cache, reducing costs.': 'der Eingabe-Token wurden aus dem Cache bedient, was die Kosten reduziert.', 'Tip: For a full token breakdown, run `/stats model`.': - 'Tipp: Fur eine vollstandige Token-Aufschlusselung fuhren Sie `/stats model` aus.', - 'Model Stats For Nerds': 'Modellstatistiken fur Nerds', - 'Tool Stats For Nerds': 'Werkzeugstatistiken fur Nerds', + 'Tipp: Für eine vollständige Token-Aufschlüsselung führen Sie `/stats model` aus.', + 'Model Stats For Nerds': 'Modellstatistiken für Nerds', + 'Tool Stats For Nerds': 'Werkzeugstatistiken für Nerds', Metric: 'Metrik', API: 'API', Requests: 'Anfragen', @@ -922,41 +922,41 @@ export default { 'Success Rate': 'Erfolgsrate', 'Avg Duration': 'Durchschn. Dauer', 'User Decision Summary': 'Benutzerentscheidungs-Zusammenfassung', - 'Total Reviewed Suggestions:': 'Insgesamt uberprufter Vorschlage:', + 'Total Reviewed Suggestions:': 'Insgesamt überprüfter Vorschläge:', ' » Accepted:': ' » Akzeptiert:', ' » Rejected:': ' » Abgelehnt:', - ' » Modified:': ' » Geandert:', + ' » Modified:': ' » Geändert:', ' Overall Agreement Rate:': ' Gesamtzustimmungsrate:', 'No tool calls have been made in this session.': 'In dieser Sitzung wurden keine Werkzeugaufrufe gemacht.', 'Session start time is unavailable, cannot calculate stats.': - 'Sitzungsstartzeit nicht verfugbar, Statistiken konnen nicht berechnet werden.', + 'Sitzungsstartzeit nicht verfügbar, Statistiken können nicht berechnet werden.', // ============================================================================ // Loading Phrases // ============================================================================ - 'Waiting for user confirmation...': 'Warten auf Benutzerbestatigung...', + 'Waiting for user confirmation...': 'Warten auf Benutzerbestätigung...', '(esc to cancel, {{time}})': '(Esc zum Abbrechen, {{time}})', // ============================================================================ // Loading Phrases // ============================================================================ WITTY_LOADING_PHRASES: [ - 'Auf gut Gluck!', - 'Genialitat wird ausgeliefert...', + 'Auf gut Glück!', + 'Genialität wird ausgeliefert...', 'Die Serifen werden aufgemalt...', 'Durch den Schleimpilz navigieren...', 'Die digitalen Geister werden befragt...', 'Splines werden retikuliert...', - 'Die KI-Hamster werden aufgewarmt...', + 'Die KI-Hamster werden aufgewärmt...', 'Die Zaubermuschel wird befragt...', 'Witzige Erwiderung wird generiert...', 'Die Algorithmen werden poliert...', 'Perfektion braucht Zeit (mein Code auch)...', - 'Frische Bytes werden gebruht...', - 'Elektronen werden gezahlt...', + 'Frische Bytes werden gebrüht...', + 'Elektronen werden gezählt...', 'Kognitive Prozessoren werden aktiviert...', - 'Auf Syntaxfehler im Universum wird gepruft...', + 'Auf Syntaxfehler im Universum wird geprüft...', 'Einen Moment, Humor wird optimiert...', 'Pointen werden gemischt...', 'Neuronale Netze werden entwirrt...', @@ -964,31 +964,31 @@ export default { 'wit.exe wird geladen...', 'Die Wolke der Weisheit wird beschworen...', 'Eine witzige Antwort wird vorbereitet...', - 'Einen Moment, ich debugge die Realitat...', + 'Einen Moment, ich debugge die Realität...', 'Die Optionen werden verwirrt...', 'Kosmische Frequenzen werden eingestellt...', - 'Eine Antwort wird erstellt, die Ihrer Geduld wurdig ist...', + 'Eine Antwort wird erstellt, die Ihrer Geduld würdig ist...', 'Die Einsen und Nullen werden kompiliert...', - 'Abhangigkeiten werden aufgelost... und existenzielle Krisen...', - 'Erinnerungen werden defragmentiert... sowohl RAM als auch personliche...', + 'Abhängigkeiten werden aufgelöst... und existenzielle Krisen...', + 'Erinnerungen werden defragmentiert... sowohl RAM als auch persönliche...', 'Das Humor-Modul wird neu gestartet...', - 'Das Wesentliche wird zwischengespeichert (hauptsachlich Katzen-Memes)...', - 'Fur lacherliche Geschwindigkeit wird optimiert', + 'Das Wesentliche wird zwischengespeichert (hauptsächlich Katzen-Memes)...', + 'Für lächerliche Geschwindigkeit wird optimiert', 'Bits werden getauscht... sagen Sie es nicht den Bytes...', - 'Garbage Collection lauft... bin gleich zuruck...', + 'Garbage Collection läuft... bin gleich zurück...', 'Das Internet wird zusammengebaut...', 'Kaffee wird in Code umgewandelt...', - 'Die Syntax der Realitat wird aktualisiert...', + 'Die Syntax der Realität wird aktualisiert...', 'Die Synapsen werden neu verdrahtet...', 'Ein verlegtes Semikolon wird gesucht...', - 'Die Zahnrader werden geschmiert...', + 'Die Zahnräder werden geschmiert...', 'Die Server werden vorgeheizt...', 'Der Fluxkompensator wird kalibriert...', 'Der Unwahrscheinlichkeitsantrieb wird aktiviert...', 'Die Macht wird kanalisiert...', - 'Die Sterne werden fur optimale Antwort ausgerichtet...', + 'Die Sterne werden für optimale Antwort ausgerichtet...', 'So sagen wir alle...', - 'Die nachste grosse Idee wird geladen...', + 'Die nächste große Idee wird geladen...', 'Einen Moment, ich bin in der Zone...', 'Bereite mich vor, Sie mit Brillanz zu blenden...', 'Einen Augenblick, ich poliere meinen Witz...', @@ -1000,22 +1000,22 @@ export default { 'Warp-Geschwindigkeit aktiviert...', 'Mehr Dilithium-Kristalle werden gesucht...', 'Keine Panik...', - 'Dem weissen Kaninchen wird gefolgt...', + 'Dem weißen Kaninchen wird gefolgt...', 'Die Wahrheit ist hier drin... irgendwo...', 'Auf die Kassette wird gepustet...', 'Ladevorgang... Machen Sie eine Fassrolle!', 'Auf den Respawn wird gewartet...', 'Der Kessel-Flug wird in weniger als 12 Parsec beendet...', - 'Der Kuchen ist keine Luge, er ladt nur noch...', + 'Der Kuchen ist keine Lüge, er lädt nur noch...', 'Am Charaktererstellungsbildschirm wird herumgefummelt...', 'Einen Moment, ich suche das richtige Meme...', - "'A' wird zum Fortfahren gedruckt...", - 'Digitale Katzen werden gehuttert...', + "'A' wird zum Fortfahren gedrückt...", + 'Digitale Katzen werden gehütet...', 'Die Pixel werden poliert...', 'Ein passender Ladebildschirm-Witz wird gesucht...', 'Ich lenke Sie mit diesem witzigen Spruch ab...', 'Fast da... wahrscheinlich...', - 'Unsere Hamster arbeiten so schnell sie konnen...', + 'Unsere Hamster arbeiten so schnell sie können...', 'Cloudy wird am Kopf gestreichelt...', 'Die Katze wird gestreichelt...', 'Meinen Chef rickrollen...', @@ -1024,14 +1024,14 @@ export default { 'Die Schnozbeeren werden probiert...', "I'm going the distance, I'm going for speed...", 'Ist dies das wahre Leben? Ist dies nur Fantasie?...', - 'Ich habe ein gutes Gefuhl dabei...', - 'Den Baren wird gestupst...', + 'Ich habe ein gutes Gefühl dabei...', + 'Den Bären wird gestupst...', 'Recherche zu den neuesten Memes...', - 'Uberlege, wie ich das witziger machen kann...', + 'Überlege, wie ich das witziger machen kann...', 'Hmmm... lassen Sie mich nachdenken...', 'Wie nennt man einen Fisch ohne Augen? Ein Fsh...', 'Warum ging der Computer zur Therapie? Er hatte zu viele Bytes...', - 'Warum mogen Programmierer keine Natur? Sie hat zu viele Bugs...', + 'Warum mögen Programmierer keine Natur? Sie hat zu viele Bugs...', 'Warum bevorzugen Programmierer den Dunkelmodus? Weil Licht Bugs anzieht...', 'Warum ging der Entwickler pleite? Er hat seinen ganzen Cache aufgebraucht...', 'Was kann man mit einem kaputten Bleistift machen? Nichts, er ist sinnlos...', @@ -1046,28 +1046,28 @@ export default { 'Mein anderer Prozess ist eine TARDIS...', 'Mit dem Maschinengeist wird kommuniziert...', 'Die Gedanken marinieren lassen...', - 'Gerade erinnert, wo ich meine Schlussel hingelegt habe...', - 'Uber die Kugel wird nachgedacht...', - 'Ich habe Dinge gesehen, die Sie nicht glauben wurden... wie einen Benutzer, der Lademeldungen liest.', + 'Gerade erinnert, wo ich meine Schlüssel hingelegt habe...', + 'Über die Kugel wird nachgedacht...', + 'Ich habe Dinge gesehen, die Sie nicht glauben würden... wie einen Benutzer, der Lademeldungen liest.', 'Nachdenklicher Blick wird initiiert...', 'Was ist der Lieblingssnack eines Computers? Mikrochips.', 'Warum tragen Java-Entwickler Brillen? Weil sie nicht C#.', 'Der Laser wird aufgeladen... pew pew!', - 'Durch Null wird geteilt... nur Spass!', + 'Durch Null wird geteilt... nur Spaß!', 'Suche nach einem erwachsenen Aufseh... ich meine, Verarbeitung.', 'Es piept und boopt.', 'Pufferung... weil auch KIs einen Moment brauchen.', - 'Quantenteilchen werden fur schnellere Antwort verschrankt...', + 'Quantenteilchen werden für schnellere Antwort verschränkt...', 'Das Chrom wird poliert... an den Algorithmen.', 'Sind Sie nicht unterhalten? (Arbeite daran!)', - 'Die Code-Gremlins werden beschworen... zum Helfen, naturlich.', + 'Die Code-Gremlins werden beschworen... zum Helfen, natürlich.', 'Warte nur auf das Einwahlton-Ende...', 'Das Humor-O-Meter wird neu kalibriert.', 'Mein anderer Ladebildschirm ist noch lustiger.', - 'Ziemlich sicher, dass irgendwo eine Katze uber die Tastatur lauft...', - 'Verbessern... Verbessern... Ladt noch.', + 'Ziemlich sicher, dass irgendwo eine Katze über die Tastatur läuft...', + 'Verbessern... Verbessern... Lädt noch.', 'Das ist kein Bug, das ist ein Feature... dieses Ladebildschirms.', 'Haben Sie versucht, es aus- und wieder einzuschalten? (Den Ladebildschirm, nicht mich.)', - 'Zusatzliche Pylonen werden gebaut...', + 'Zusätzliche Pylonen werden gebaut...', ], }; From aaa66b3172b851efda4101bd860aa3db96728f05 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 31 Dec 2025 17:38:33 +0800 Subject: [PATCH 035/142] fix: add tool result and deny warning in text mode --- packages/cli/src/nonInteractiveCli.test.ts | 84 ++++++++++++++++++- packages/cli/src/nonInteractiveCli.ts | 45 +++++++--- packages/cli/src/utils/errors.test.ts | 95 +++++++++++++++++++++- packages/cli/src/utils/errors.ts | 19 ++++- 4 files changed, 229 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 07fd168fc..74f30b342 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -298,7 +298,9 @@ describe('runNonInteractive', () => { mockConfig, expect.objectContaining({ name: 'testTool' }), expect.any(AbortSignal), - undefined, + expect.objectContaining({ + outputUpdateHandler: expect.any(Function), + }), ); // Verify first call has isContinuation: false expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( @@ -1777,4 +1779,84 @@ describe('runNonInteractive', () => { { isContinuation: false }, ); }); + + it('should print tool output to console in text mode (non-Task tools)', async () => { + // Test that tool output is printed to stdout in text mode + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-1', + name: 'run_in_terminal', + args: { command: 'npm outdated' }, + isClientInitiated: false, + prompt_id: 'prompt-id-tool-output', + }, + }; + + // Mock tool execution with outputUpdateHandler being called + mockCoreExecuteToolCall.mockImplementation( + async (_config, _request, _signal, options) => { + // Simulate tool calling outputUpdateHandler with output chunks + if (options?.outputUpdateHandler) { + options.outputUpdateHandler('tool-1', 'Package outdated\n'); + options.outputUpdateHandler('tool-1', 'npm@1.0.0 -> npm@2.0.0\n'); + } + return { + responseParts: [ + { + functionResponse: { + id: 'tool-1', + name: 'run_in_terminal', + response: { + output: 'Package outdated\nnpm@1.0.0 -> npm@2.0.0', + }, + }, + }, + ], + }; + }, + ); + + const firstCallEvents: ServerGeminiStreamEvent[] = [ + toolCallEvent, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, + }, + ]; + + const secondCallEvents: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Dependencies checked' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 3 } }, + }, + ]; + + mockGeminiClient.sendMessageStream + .mockReturnValueOnce(createStreamFromEvents(firstCallEvents)) + .mockReturnValueOnce(createStreamFromEvents(secondCallEvents)); + + await runNonInteractive( + mockConfig, + mockSettings, + 'Check dependencies', + 'prompt-id-tool-output', + ); + + // Verify that executeToolCall was called with outputUpdateHandler + expect(mockCoreExecuteToolCall).toHaveBeenCalledWith( + mockConfig, + expect.objectContaining({ name: 'run_in_terminal' }), + expect.any(AbortSignal), + expect.objectContaining({ + outputUpdateHandler: expect.any(Function), + }), + ); + + // Verify tool output was written to stdout + expect(processStdoutSpy).toHaveBeenCalledWith('Package outdated\n'); + expect(processStdoutSpy).toHaveBeenCalledWith('npm@1.0.0 -> npm@2.0.0\n'); + expect(processStdoutSpy).toHaveBeenCalledWith('Dependencies checked'); + }); }); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 067f190b9..17ac30eae 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -4,7 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Config, ToolCallRequestInfo } from '@qwen-code/qwen-code-core'; +import type { + Config, + ToolCallRequestInfo, + ToolResultDisplay, +} from '@qwen-code/qwen-code-core'; import { isSlashCommand } from './ui/utils/commandUtils.js'; import type { LoadedSettings } from './config/settings.js'; import { @@ -333,7 +337,7 @@ export async function runNonInteractive( ? options.controlService.permission.getToolCallUpdateCallback() : undefined; - // Only pass outputUpdateHandler for Task tool + // Create output handler for Task tool (for subagent execution) const isTaskTool = finalRequestInfo.name === 'task'; const taskToolProgress = isTaskTool ? createTaskToolProgressHandler( @@ -343,20 +347,41 @@ export async function runNonInteractive( ) : undefined; const taskToolProgressHandler = taskToolProgress?.handler; + + // Create output handler for non-Task tools in text mode (for console output) + const nonTaskOutputHandler = + !isTaskTool && !adapter + ? (callId: string, outputChunk: ToolResultDisplay) => { + // Print tool output to console in text mode + if (typeof outputChunk === 'string') { + process.stdout.write(outputChunk); + } else if ( + outputChunk && + typeof outputChunk === 'object' && + 'ansiOutput' in outputChunk + ) { + // Handle ANSI output - just print as string for now + process.stdout.write(String(outputChunk.ansiOutput)); + } + } + : undefined; + + // Combine output handlers + const outputUpdateHandler = + taskToolProgressHandler || nonTaskOutputHandler; + const toolResponse = await executeToolCall( config, finalRequestInfo, abortController.signal, - isTaskTool && taskToolProgressHandler + outputUpdateHandler || toolCallUpdateCallback ? { - outputUpdateHandler: taskToolProgressHandler, - onToolCallsUpdate: toolCallUpdateCallback, - } - : toolCallUpdateCallback - ? { + ...(outputUpdateHandler && { outputUpdateHandler }), + ...(toolCallUpdateCallback && { onToolCallsUpdate: toolCallUpdateCallback, - } - : undefined, + }), + } + : undefined, ); // Note: In JSON mode, subagent messages are automatically added to the main diff --git a/packages/cli/src/utils/errors.test.ts b/packages/cli/src/utils/errors.test.ts index 818c3ac39..e3a27bd42 100644 --- a/packages/cli/src/utils/errors.test.ts +++ b/packages/cli/src/utils/errors.test.ts @@ -6,7 +6,11 @@ import { vi, type Mock, type MockInstance } from 'vitest'; import type { Config } from '@qwen-code/qwen-code-core'; -import { OutputFormat, FatalInputError } from '@qwen-code/qwen-code-core'; +import { + OutputFormat, + FatalInputError, + ToolErrorType, +} from '@qwen-code/qwen-code-core'; import { getErrorMessage, handleError, @@ -65,6 +69,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { describe('errors', () => { let mockConfig: Config; let processExitSpy: MockInstance; + let processStderrWriteSpy: MockInstance; let consoleErrorSpy: MockInstance; beforeEach(() => { @@ -74,6 +79,11 @@ describe('errors', () => { // Mock console.error consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + // Mock process.stderr.write + processStderrWriteSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + // Mock process.exit to throw instead of actually exiting processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { throw new Error(`process.exit called with code: ${code}`); @@ -84,11 +94,13 @@ describe('errors', () => { getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT), getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'test' }), getDebugMode: vi.fn().mockReturnValue(true), + isInteractive: vi.fn().mockReturnValue(false), } as unknown as Config; }); afterEach(() => { consoleErrorSpy.mockRestore(); + processStderrWriteSpy.mockRestore(); processExitSpy.mockRestore(); }); @@ -432,6 +444,87 @@ describe('errors', () => { expect(processExitSpy).not.toHaveBeenCalled(); }); }); + + describe('permission denied warnings', () => { + it('should show warning when EXECUTION_DENIED in non-interactive text mode', () => { + (mockConfig.getDebugMode as Mock).mockReturnValue(false); + (mockConfig.isInteractive as Mock).mockReturnValue(false); + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.TEXT); + + handleToolError( + toolName, + toolError, + mockConfig, + ToolErrorType.EXECUTION_DENIED, + ); + + expect(processStderrWriteSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Warning: Tool "test-tool" requires user approval', + ), + ); + expect(processStderrWriteSpy).toHaveBeenCalledWith( + expect.stringContaining('use the -y flag (YOLO mode)'), + ); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('should not show warning when EXECUTION_DENIED in interactive mode', () => { + (mockConfig.getDebugMode as Mock).mockReturnValue(false); + (mockConfig.isInteractive as Mock).mockReturnValue(true); + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.TEXT); + + handleToolError( + toolName, + toolError, + mockConfig, + ToolErrorType.EXECUTION_DENIED, + ); + + expect(processStderrWriteSpy).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('should not show warning when EXECUTION_DENIED in JSON mode', () => { + (mockConfig.getDebugMode as Mock).mockReturnValue(false); + (mockConfig.isInteractive as Mock).mockReturnValue(false); + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.JSON); + + handleToolError( + toolName, + toolError, + mockConfig, + ToolErrorType.EXECUTION_DENIED, + ); + + expect(processStderrWriteSpy).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('should not show warning for non-EXECUTION_DENIED errors', () => { + (mockConfig.getDebugMode as Mock).mockReturnValue(false); + (mockConfig.isInteractive as Mock).mockReturnValue(false); + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.TEXT); + + handleToolError( + toolName, + toolError, + mockConfig, + ToolErrorType.FILE_NOT_FOUND, + ); + + expect(processStderrWriteSpy).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + }); }); describe('handleCancellationError', () => { diff --git a/packages/cli/src/utils/errors.ts b/packages/cli/src/utils/errors.ts index 5338fa2fd..f804a630c 100644 --- a/packages/cli/src/utils/errors.ts +++ b/packages/cli/src/utils/errors.ts @@ -11,6 +11,7 @@ import { parseAndFormatApiError, FatalTurnLimitedError, FatalCancellationError, + ToolErrorType, } from '@qwen-code/qwen-code-core'; export function getErrorMessage(error: unknown): string { @@ -102,10 +103,24 @@ export function handleToolError( toolName: string, toolError: Error, config: Config, - _errorCode?: string | number, + errorCode?: string | number, resultDisplay?: string, ): void { - // Always just log to stderr; JSON/streaming formatting happens in the tool_result block elsewhere + // Check if this is a permission denied error in non-interactive mode + const isExecutionDenied = errorCode === ToolErrorType.EXECUTION_DENIED; + const isNonInteractive = !config.isInteractive(); + const isTextMode = config.getOutputFormat() === OutputFormat.TEXT; + + // Show warning for permission denied errors in non-interactive text mode + if (isExecutionDenied && isNonInteractive && isTextMode) { + const warningMessage = + `Warning: Tool "${toolName}" requires user approval but cannot execute in non-interactive mode.\n` + + `To enable automatic tool execution, use the -y flag (YOLO mode):\n` + + `Example: qwen -p 'your prompt' -y\n\n`; + process.stderr.write(warningMessage); + } + + // Always log detailed error in debug mode if (config.getDebugMode()) { console.error( `Error executing tool ${toolName}: ${resultDisplay || toolError.message}`, From e4caa7a8564e715d4ece2c87d5d5a28c06ae6c09 Mon Sep 17 00:00:00 2001 From: skyfire Date: Wed, 31 Dec 2025 20:15:51 +0800 Subject: [PATCH 036/142] for partial message processing and event timeout processing --- packages/sdk-java/pom.xml | 20 +++ .../com/alibaba/qwen/code/cli/Options.java | 6 - .../com/alibaba/qwen/code/cli/QwenCli.java | 54 ------ .../alibaba/qwen/code/cli/QwenCodeCli.java | 70 ++++++++ .../cli/protocol/data/AssistantContent.java | 6 + .../assistant/SDKAssistantMessage.java | 5 + .../assistant/SDKPartialAssistantMessage.java | 55 ++++++ .../message/assistant/block/ContentBlock.java | 3 +- .../message/assistant/block/TextBlock.java | 5 + .../assistant/block/ThinkingBlock.java | 5 + .../message/assistant/block/ToolUseBlock.java | 5 + .../event/ContentBlockDeltaEvent.java | 96 +++++++++++ .../event/ContentBlockStartEvent.java | 13 ++ .../event/ContentBlockStopEvent.java | 16 ++ .../event/MessageStartStreamEvent.java | 47 +++++ .../event/MessageStopStreamEvent.java | 7 + .../message/assistant/event/StreamEvent.java | 18 ++ .../control/CLIControlSetModelResponse.java | 22 +++ .../qwen/code/cli/session/Session.java | 161 ++++++++++-------- .../session/event/SessionEventConsumers.java | 22 +++ .../event/SessionEventSimpleConsumers.java | 115 ++++++++++++- .../qwen/code/cli/transport/Transport.java | 2 + .../code/cli/transport/TransportOptions.java | 79 ++++++--- .../transport/process/ProcessTransport.java | 147 ++++++++-------- .../process/TransportOptionsAdapter.java | 22 ++- .../code/cli/utils/MyConcurrentUtils.java | 65 +++++++ .../qwen/code/cli/utils/ThreadPoolConfig.java | 43 +++++ .../alibaba/qwen/code/cli/utils/Timeout.java | 27 +++ ...{QwenCliTest.java => QwenCodeCliTest.java} | 11 +- .../qwen/code/cli/session/SessionTest.java | 41 ++++- 30 files changed, 934 insertions(+), 254 deletions(-) delete mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/Options.java delete mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCli.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCodeCli.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/AssistantContent.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKPartialAssistantMessage.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockDeltaEvent.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStartEvent.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStopEvent.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStartStreamEvent.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStopStreamEvent.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/StreamEvent.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetModelResponse.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/MyConcurrentUtils.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/ThreadPoolConfig.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/Timeout.java rename packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/{QwenCliTest.java => QwenCodeCliTest.java} (58%) diff --git a/packages/sdk-java/pom.xml b/packages/sdk-java/pom.xml index 45c9ea895..346731815 100644 --- a/packages/sdk-java/pom.xml +++ b/packages/sdk-java/pom.xml @@ -25,6 +25,7 @@ 1.8 UTF-8 3.6.0 + 0.8.12 5.14.1 1.3.16 2.0.60 @@ -81,6 +82,25 @@ + + org.jacoco + jacoco-maven-plugin + ${jacoco-maven-plugin.version} + + + + prepare-agent + + + + report + test + + report + + + + diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/Options.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/Options.java deleted file mode 100644 index 82b0a4652..000000000 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/Options.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.alibaba.qwen.code.cli; - -import com.alibaba.qwen.code.cli.transport.TransportOptions; - -public class Options extends TransportOptions { -} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCli.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCli.java deleted file mode 100644 index 0471ab692..000000000 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCli.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.alibaba.qwen.code.cli; - -import java.util.ArrayList; -import java.util.List; - -import com.alibaba.qwen.code.cli.protocol.message.Message; -import com.alibaba.qwen.code.cli.protocol.message.SDKSystemMessage; -import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKAssistantMessage; -import com.alibaba.qwen.code.cli.session.Session; -import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; -import com.alibaba.qwen.code.cli.transport.Transport; -import com.alibaba.qwen.code.cli.transport.process.ProcessTransport; - -public class QwenCli { - public static List query(String prompt) { - Transport transport; - try { - transport = new ProcessTransport(); - } catch (Exception e) { - throw new RuntimeException("initialized ProcessTransport error!", e); - } - - Session session; - try { - session = new Session(transport); - } catch (Exception e) { - throw new RuntimeException("initialized Session error!", e); - } - - final List response = new ArrayList<>(); - try { - session.sendPrompt(prompt, new SessionEventSimpleConsumers() { - @Override - public void onSystemMessage(Session session, SDKSystemMessage systemMessage) { - response.add(systemMessage); - } - - @Override - public void onAssistantMessage(Session session, SDKAssistantMessage assistantMessage) { - response.add(assistantMessage); - } - }); - } catch (Exception e) { - throw new RuntimeException("sendPrompt error!", e); - } - - try { - session.close(); - } catch (Exception e) { - throw new RuntimeException("close Session error!", e); - } - return response; - } -} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCodeCli.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCodeCli.java new file mode 100644 index 000000000..591552fc3 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCodeCli.java @@ -0,0 +1,70 @@ +package com.alibaba.qwen.code.cli; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior.Operation; +import com.alibaba.qwen.code.cli.session.Session; +import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; +import com.alibaba.qwen.code.cli.transport.Transport; +import com.alibaba.qwen.code.cli.transport.TransportOptions; +import com.alibaba.qwen.code.cli.transport.process.ProcessTransport; +import com.alibaba.qwen.code.cli.utils.MyConcurrentUtils; +import com.alibaba.qwen.code.cli.utils.Timeout; + +public class QwenCodeCli { + public static List simpleQuery(String prompt) { + final List response = new ArrayList<>(); + MyConcurrentUtils.runAndWait(() -> simpleQuery(prompt, response::add), Timeout.TIMEOUT_30_MINUTES); + return response; + } + + public static void simpleQuery(String prompt, Consumer messageConsumer) { + Session session = newSessionWithProcessTransport(new TransportOptions()); + try { + session.sendPrompt(prompt, new SessionEventSimpleConsumers() { + @Override + public void onAssistantMessageIncludePartial(Session session, List assistantContents, AssistantMessageOutputType assistantMessageOutputType) { + messageConsumer.accept(assistantContents.stream() + .map(AssistantContent::getContent) + .map(content -> { + if (content instanceof String) { + return (String) content; + } else { + return JSON.toJSONString(content); + } + }).collect(Collectors.joining())); + } + }.setDefaultPermissionOperation(Operation.allow)); + } catch (Exception e) { + throw new RuntimeException("sendPrompt error!", e); + } + + try { + session.close(); + } catch (Exception e) { + throw new RuntimeException("close Session error!", e); + } + } + + public static Session newSessionWithProcessTransport(TransportOptions transportOptions) { + Transport transport; + try { + transport = new ProcessTransport(transportOptions); + } catch (Exception e) { + throw new RuntimeException("initialized ProcessTransport error!", e); + } + + Session session; + try { + session = new Session(transport); + } catch (Exception e) { + throw new RuntimeException("initialized Session error!", e); + } + return session; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/AssistantContent.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/AssistantContent.java new file mode 100644 index 000000000..40d7f520d --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/AssistantContent.java @@ -0,0 +1,6 @@ +package com.alibaba.qwen.code.cli.protocol.data; + +public interface AssistantContent { + String getType(); + Object getContent(); +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKAssistantMessage.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKAssistantMessage.java index 7e906fc44..b0a3012c4 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKAssistantMessage.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKAssistantMessage.java @@ -15,6 +15,11 @@ public class SDKAssistantMessage extends MessageBase { @JSONField(name = "parent_tool_use_id") private String parentToolUseId; + public SDKAssistantMessage() { + super(); + this.type = "assistant"; + } + public String getUuid() { return uuid; } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKPartialAssistantMessage.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKPartialAssistantMessage.java new file mode 100644 index 000000000..a9ac24d05 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKPartialAssistantMessage.java @@ -0,0 +1,55 @@ +package com.alibaba.qwen.code.cli.protocol.message.assistant; + +import com.alibaba.fastjson2.annotation.JSONField; +import com.alibaba.fastjson2.annotation.JSONType; +import com.alibaba.qwen.code.cli.protocol.message.MessageBase; +import com.alibaba.qwen.code.cli.protocol.message.assistant.event.StreamEvent; + +@JSONType(typeKey = "type", typeName = "stream_event") +public class SDKPartialAssistantMessage extends MessageBase { + private String uuid; + + @JSONField(name = "session_id") + private String sessionId; + private StreamEvent event; + + @JSONField(name = "parent_tool_use_id") + private String parentToolUseId; + + public SDKPartialAssistantMessage() { + super(); + this.type = "stream_event"; + } + + public String getUuid() { + return uuid; + } + + public void setUuid(String uuid) { + this.uuid = uuid; + } + + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public StreamEvent getEvent() { + return event; + } + + public void setEvent(StreamEvent event) { + this.event = event; + } + + public String getParentToolUseId() { + return parentToolUseId; + } + + public void setParentToolUseId(String parentToolUseId) { + this.parentToolUseId = parentToolUseId; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ContentBlock.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ContentBlock.java index 3e72ad7d0..d40200c6e 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ContentBlock.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ContentBlock.java @@ -4,9 +4,10 @@ import java.util.List; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.annotation.JSONType; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent; @JSONType(typeKey = "type", typeName = "ContentBlock", seeAlso = { TextBlock.class, ToolResultBlock.class, ThinkingBlock.class, ToolUseBlock.class }) -public class ContentBlock { +public abstract class ContentBlock implements AssistantContent { protected String type; protected List annotations; diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/TextBlock.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/TextBlock.java index 86e5513d3..7a8cf7d43 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/TextBlock.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/TextBlock.java @@ -13,4 +13,9 @@ public class TextBlock extends ContentBlock { public void setText(String text) { this.text = text; } + + @Override + public Object getContent() { + return text; + } } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ThinkingBlock.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ThinkingBlock.java index fa479563f..4a133840f 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ThinkingBlock.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ThinkingBlock.java @@ -22,4 +22,9 @@ public class ThinkingBlock extends ContentBlock{ public void setSignature(String signature) { this.signature = signature; } + + @Override + public Object getContent() { + return thinking; + } } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolUseBlock.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolUseBlock.java index 58a3bd4fc..ef5de8b02 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolUseBlock.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolUseBlock.java @@ -46,4 +46,9 @@ public class ToolUseBlock extends ContentBlock { public void setAnnotations(List annotations) { this.annotations = annotations; } + + @Override + public Object getContent() { + return input; + } } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockDeltaEvent.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockDeltaEvent.java new file mode 100644 index 000000000..78b7961cc --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockDeltaEvent.java @@ -0,0 +1,96 @@ +package com.alibaba.qwen.code.cli.protocol.message.assistant.event; + +import com.alibaba.fastjson2.annotation.JSONField; +import com.alibaba.fastjson2.annotation.JSONType; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent; + +@JSONType(typeKey = "type", typeName = "content_block_delta") +public class ContentBlockDeltaEvent extends StreamEvent { + private int index; + private ContentBlockDelta delta; + + public int getIndex() { + return index; + } + + public void setIndex(int index) { + this.index = index; + } + + public ContentBlockDelta getDelta() { + return delta; + } + + public void setDelta(ContentBlockDelta delta) { + this.delta = delta; + } + + @JSONType(typeKey = "type", typeName = "ContentBlockDelta", + seeAlso = {ContentBlockDeltaText.class, ContentBlockDeltaThinking.class, ContentBlockDeltaInputJson.class}) + public abstract static class ContentBlockDelta implements AssistantContent { + private String type; + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + } + + @JSONType(typeKey = "type", typeName = "text_delta") + public static class ContentBlockDeltaText extends ContentBlockDelta { + private String text; + + public String getText() { + return text; + } + + public void setText(String text) { + this.text = text; + } + + @Override + public Object getContent() { + return text; + } + } + + @JSONType(typeKey = "type", typeName = "thinking_delta") + public static class ContentBlockDeltaThinking extends ContentBlockDelta { + private String thinking; + + public String getThinking() { + return thinking; + } + + public void setThinking(String thinking) { + this.thinking = thinking; + } + + @Override + public Object getContent() { + return thinking; + } + } + + @JSONType(typeKey = "type", typeName = "input_json_delta") + public static class ContentBlockDeltaInputJson extends ContentBlockDelta { + @JSONField(name = "partial_json") + private String partialJson; + + public String getPartialJson() { + return partialJson; + } + + public void setPartialJson(String partialJson) { + this.partialJson = partialJson; + } + + @Override + public Object getContent() { + return partialJson; + } + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStartEvent.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStartEvent.java new file mode 100644 index 000000000..884558512 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStartEvent.java @@ -0,0 +1,13 @@ +package com.alibaba.qwen.code.cli.protocol.message.assistant.event; + +import com.alibaba.fastjson2.annotation.JSONField; +import com.alibaba.fastjson2.annotation.JSONType; +import com.alibaba.qwen.code.cli.protocol.message.assistant.block.ContentBlock; + +@JSONType(typeKey = "type", typeName = "content_block_start") +public class ContentBlockStartEvent extends StreamEvent{ + private int index; + + @JSONField(name = "content_block") + private ContentBlock contentBlock; +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStopEvent.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStopEvent.java new file mode 100644 index 000000000..0e950f817 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStopEvent.java @@ -0,0 +1,16 @@ +package com.alibaba.qwen.code.cli.protocol.message.assistant.event; + +import com.alibaba.fastjson2.annotation.JSONType; + +@JSONType(typeKey = "type", typeName = "content_block_stop") +public class ContentBlockStopEvent extends StreamEvent{ + Long index; + + public Long getIndex() { + return index; + } + + public void setIndex(Long index) { + this.index = index; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStartStreamEvent.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStartStreamEvent.java new file mode 100644 index 000000000..88be40545 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStartStreamEvent.java @@ -0,0 +1,47 @@ +package com.alibaba.qwen.code.cli.protocol.message.assistant.event; + +import com.alibaba.fastjson2.annotation.JSONType; + +@JSONType(typeName = "message_start") +public class MessageStartStreamEvent extends StreamEvent{ + private Message message; + + public static class Message { + private String id; + private String role; + private String model; + + // Getters and setters + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + } + + public Message getMessage() { + return message; + } + + public void setMessage(Message message) { + this.message = message; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStopStreamEvent.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStopStreamEvent.java new file mode 100644 index 000000000..3ea04bc50 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStopStreamEvent.java @@ -0,0 +1,7 @@ +package com.alibaba.qwen.code.cli.protocol.message.assistant.event; + +import com.alibaba.fastjson2.annotation.JSONType; + +@JSONType(typeName = "message_stop") +public class MessageStopStreamEvent extends StreamEvent{ +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/StreamEvent.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/StreamEvent.java new file mode 100644 index 000000000..d288402fa --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/StreamEvent.java @@ -0,0 +1,18 @@ +package com.alibaba.qwen.code.cli.protocol.message.assistant.event; + +import com.alibaba.fastjson2.annotation.JSONType; + +@JSONType(typeKey = "type", typeName = "StreamEvent", + seeAlso = {MessageStartStreamEvent.class, MessageStopStreamEvent.class, ContentBlockStartEvent.class, ContentBlockStopEvent.class, + ContentBlockDeltaEvent.class}) +public class StreamEvent { + protected String type; + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetModelResponse.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetModelResponse.java new file mode 100644 index 000000000..71d6b0e38 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetModelResponse.java @@ -0,0 +1,22 @@ +package com.alibaba.qwen.code.cli.protocol.message.control; + +public class CLIControlSetModelResponse { + String subtype = "set_model"; + String model; + + public String getSubtype() { + return subtype; + } + + public void setSubtype(String subtype) { + this.subtype = subtype; + } + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/Session.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/Session.java index 79a210742..c3e605476 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/Session.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/Session.java @@ -2,6 +2,8 @@ package com.alibaba.qwen.code.cli.session; import java.io.IOException; import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; @@ -15,6 +17,7 @@ import com.alibaba.qwen.code.cli.protocol.message.SDKResultMessage; import com.alibaba.qwen.code.cli.protocol.message.SDKSystemMessage; import com.alibaba.qwen.code.cli.protocol.message.SDKUserMessage; import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKAssistantMessage; +import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKPartialAssistantMessage; import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlInitializeRequest; import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlInitializeResponse; import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlInterruptRequest; @@ -28,16 +31,19 @@ import com.alibaba.qwen.code.cli.session.event.SessionEventConsumers; import com.alibaba.qwen.code.cli.session.exception.SessionControlException; import com.alibaba.qwen.code.cli.session.exception.SessionSendPromptException; import com.alibaba.qwen.code.cli.transport.Transport; +import com.alibaba.qwen.code.cli.utils.MyConcurrentUtils; +import com.alibaba.qwen.code.cli.utils.Timeout; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class Session { + private static final Logger log = LoggerFactory.getLogger(Session.class); private final Transport transport; private CLIControlInitializeResponse lastCliControlInitializeResponse; private SDKSystemMessage lastSdkSystemMessage; - private static final Logger log = LoggerFactory.getLogger(Session.class); + private final Timeout defaultEventTimeout = Timeout.TIMEOUT_60_SECONDS; public Session(Transport transport) throws SessionControlException { if (transport == null || !transport.isAvailable()) { @@ -61,43 +67,44 @@ public class Session { } } - public void interrupt() throws SessionControlException { - if (!isAvailable()) { - throw new SessionControlException("Session is not available"); - } - + public void close() throws SessionControlException { try { - transport.inputNoWaitResponse( - new CLIControlRequest().setRequest(new CLIControlInterruptRequest()).toString()); + transport.close(); } catch (Exception e) { - throw new SessionControlException("Failed to interrupt the session", e); + throw new SessionControlException("Failed to close the session", e); } } - public void setModel(String modelName) throws SessionControlException { - if (!isAvailable()) { - throw new SessionControlException("Session is not available"); - } + public Optional interrupt() throws SessionControlException { + checkAvailable(); + return processControlRequest(new CLIControlRequest().setRequest(new CLIControlInterruptRequest()).toString()); + } + public Optional setModel(String modelName) throws SessionControlException { + checkAvailable(); CLIControlSetModelRequest cliControlSetModelRequest = new CLIControlSetModelRequest(); cliControlSetModelRequest.setModel(modelName); - try { - transport.inputNoWaitResponse(new CLIControlRequest().setRequest(cliControlSetModelRequest).toString()); - } catch (Exception e) { - throw new SessionControlException("Failed to set model", e); - } + return processControlRequest(new CLIControlRequest().setRequest(cliControlSetModelRequest).toString()); } - public void setPermissionMode(PermissionMode permissionMode) throws SessionControlException { - if (!isAvailable()) { - throw new SessionControlException("Session is not available"); - } - + public Optional setPermissionMode(PermissionMode permissionMode) throws SessionControlException { + checkAvailable(); CLIControlSetPermissionModeRequest cliControlSetPermissionModeRequest = new CLIControlSetPermissionModeRequest(); cliControlSetPermissionModeRequest.setMode(permissionMode.getValue()); + return processControlRequest( + new CLIControlRequest().setRequest(cliControlSetPermissionModeRequest).toString()); + } + + private Optional processControlRequest(String request) throws SessionControlException { try { - transport.inputNoWaitResponse( - new CLIControlRequest().setRequest(cliControlSetPermissionModeRequest).toString()); + if (transport.isReading()) { + transport.inputNoWaitResponse(request); + return Optional.empty(); + } else { + String response = transport.inputWaitForOneLine(request); + CLIControlResponse cliControlResponse = JSON.parseObject(response, new TypeReference>() {}); + return Optional.of("success".equals(cliControlResponse.getResponse().getSubtype())); + } } catch (Exception e) { throw new SessionControlException("Failed to set model", e); } @@ -108,61 +115,43 @@ public class Session { } public void resumeSession(String sessionId) throws SessionControlException { - if (!isAvailable()) { - throw new SessionControlException("Session is not available"); - } - if (StringUtils.isNotBlank(sessionId)) { transport.getTransportOptions().setResumeSessionId(sessionId); } this.start(); } - public String getSessionId() { - return Optional.ofNullable(lastSdkSystemMessage).map(SDKSystemMessage::getSessionId).orElse(null); - } - - public void close() throws SessionControlException { - try { - transport.close(); - } catch (Exception e) { - throw new SessionControlException("Failed to close the session", e); - } - } - - public boolean isAvailable() { - return transport.isAvailable(); - } - - public Capabilities getCapabilities() { - return Optional.ofNullable(lastCliControlInitializeResponse).map(CLIControlInitializeResponse::getCapabilities).orElse(new Capabilities()); - } - - public void sendPrompt(String prompt, SessionEventConsumers sessionEventConsumers) throws SessionSendPromptException { - if (!transport.isAvailable()) { - throw new SessionSendPromptException("Session is not available"); - } - + public void sendPrompt(String prompt, SessionEventConsumers sessionEventConsumers) throws SessionSendPromptException, SessionControlException { + checkAvailable(); try { transport.inputWaitForMultiLine(new SDKUserMessage().setContent(prompt).toString(), (line) -> { - log.debug("read a message from agent {}", line); JSONObject jsonObject = JSON.parseObject(line); String messageType = jsonObject.getString("type"); if ("system".equals(messageType)) { lastSdkSystemMessage = jsonObject.to(SDKSystemMessage.class); - sessionEventConsumers.onSystemMessage(this, lastSdkSystemMessage); + MyConcurrentUtils.runAndWait(() -> sessionEventConsumers.onSystemMessage(this, lastSdkSystemMessage), + Optional.ofNullable(sessionEventConsumers.onSystemMessageTimeout(this)).orElse(defaultEventTimeout)); return false; } else if ("assistant".equals(messageType)) { - sessionEventConsumers.onAssistantMessage(this, jsonObject.to(SDKAssistantMessage.class)); + MyConcurrentUtils.runAndWait(() -> sessionEventConsumers.onAssistantMessage(this, jsonObject.to(SDKAssistantMessage.class)), + Optional.ofNullable(sessionEventConsumers.onAssistantMessageTimeout(this)).orElse(defaultEventTimeout)); + return false; + } else if ("stream_event".equals(messageType)) { + MyConcurrentUtils.runAndWait(() -> sessionEventConsumers.onPartialAssistantMessage(this, jsonObject.to(SDKPartialAssistantMessage.class)), + Optional.ofNullable(sessionEventConsumers.onPartialAssistantMessageTimeout(this)).orElse(defaultEventTimeout)); return false; } else if ("user".equals(messageType)) { - sessionEventConsumers.onUserMessage(this, jsonObject.to(SDKUserMessage.class, Feature.FieldBased)); + MyConcurrentUtils.runAndWait( + () -> sessionEventConsumers.onUserMessage(this, jsonObject.to(SDKUserMessage.class, Feature.FieldBased)), + Optional.ofNullable(sessionEventConsumers.onUserMessageTimeout(this)).orElse(defaultEventTimeout)); return false; } else if ("result".equals(messageType)) { - sessionEventConsumers.onResultMessage(this, jsonObject.to(SDKResultMessage.class)); + MyConcurrentUtils.runAndWait(() -> sessionEventConsumers.onResultMessage(this, jsonObject.to(SDKResultMessage.class)), + Optional.ofNullable(sessionEventConsumers.onResultMessageTimeout(this)).orElse(defaultEventTimeout)); return true; } else if ("control_response".equals(messageType)) { - sessionEventConsumers.onControlResponse(this, jsonObject.to(CLIControlResponse.class)); + MyConcurrentUtils.runAndWait(() -> sessionEventConsumers.onControlResponse(this, jsonObject.to(CLIControlResponse.class)), + Optional.ofNullable(sessionEventConsumers.onControlResponseTimeout(this)).orElse(defaultEventTimeout)); if (!"error".equals(jsonObject.getString("subtype"))) { return false; } else { @@ -170,10 +159,11 @@ public class Session { return "error".equals(jsonObject.getString("subtype")); } } else if ("control_request".equals(messageType)) { - return processControlRequest(jsonObject, sessionEventConsumers); + return processControlRequestInThePrompting(jsonObject, sessionEventConsumers); } else { log.warn("unknown message type: {}", messageType); - sessionEventConsumers.onOtherMessage(this, line); + MyConcurrentUtils.runAndWait(() -> sessionEventConsumers.onOtherMessage(this, line), + Optional.ofNullable(sessionEventConsumers.onOtherMessageTimeout(this)).orElse(defaultEventTimeout)); return false; } }); @@ -182,7 +172,7 @@ public class Session { } } - private boolean processControlRequest(JSONObject jsonObject, SessionEventConsumers sessionEventConsumers) { + private boolean processControlRequestInThePrompting(JSONObject jsonObject, SessionEventConsumers sessionEventConsumers) { String subType = Optional.of(jsonObject) .map(cr -> cr.getJSONObject("request")) .map(r -> r.getString("subtype")) @@ -190,13 +180,21 @@ public class Session { if ("can_use_tool".equals(subType)) { try { return processPermissionResponse(jsonObject, sessionEventConsumers); - } catch (IOException e) { + } catch (IOException | ExecutionException | InterruptedException | TimeoutException e) { log.error("Failed to process permission response", e); return false; } } else { - CLIControlResponse cliControlResponse = sessionEventConsumers.onControlRequest(this, - jsonObject.to(new TypeReference>() {})); + CLIControlResponse cliControlResponse; + try { + cliControlResponse = MyConcurrentUtils.runAndWait( + () -> sessionEventConsumers.onControlRequest(this, jsonObject.to(new TypeReference>() {})), + Optional.ofNullable(sessionEventConsumers.onControlRequestTimeout(this)).orElse(defaultEventTimeout)); + } catch (Exception e) { + log.error("Failed to process control request", e); + return false; + } + if (cliControlResponse != null) { try { transport.inputNoWaitResponse(cliControlResponse.toString()); @@ -209,9 +207,13 @@ public class Session { } } - private boolean processPermissionResponse(JSONObject jsonObject, SessionEventConsumers sessionEventConsumers) throws IOException { - CLIControlRequest permissionRequest = jsonObject.to(new TypeReference>() {}); - Behavior behavior = Optional.ofNullable(sessionEventConsumers.onPermissionRequest(this, permissionRequest)) + private boolean processPermissionResponse(JSONObject jsonObject, SessionEventConsumers sessionEventConsumers) + throws IOException, ExecutionException, InterruptedException, TimeoutException { + CLIControlRequest permissionRequest = jsonObject.to( + new TypeReference>() {}); + + Behavior behavior = Optional.ofNullable(MyConcurrentUtils.runAndWait(() -> sessionEventConsumers.onPermissionRequest(this, permissionRequest), + Optional.ofNullable(sessionEventConsumers.onPermissionRequestTimeout(this)).orElse(defaultEventTimeout))) .map(b -> { if (b instanceof Allow) { Allow allow = (Allow) b; @@ -223,11 +225,30 @@ public class Session { }) .orElse(Behavior.defaultBehavior()); CLIControlResponse permissionResponse = new CLIControlResponse<>(); - permissionResponse.createResponse().setResponse(new CLIControlPermissionResponse().setBehavior(behavior)).setRequestId(permissionRequest.getRequestId()); + permissionResponse.createResponse().setResponse(new CLIControlPermissionResponse().setBehavior(behavior)).setRequestId( + permissionRequest.getRequestId()); String permissionMessage = permissionResponse.toString(); log.debug("send permission message to agent: {}", permissionMessage); transport.inputNoWaitResponse(permissionMessage); return false; } + + public String getSessionId() { + return Optional.ofNullable(lastSdkSystemMessage).map(SDKSystemMessage::getSessionId).orElse(null); + } + + public boolean isAvailable() { + return transport.isAvailable(); + } + + public Capabilities getCapabilities() { + return Optional.ofNullable(lastCliControlInitializeResponse).map(CLIControlInitializeResponse::getCapabilities).orElse(new Capabilities()); + } + + private void checkAvailable() throws SessionControlException { + if (!isAvailable()) { + throw new SessionControlException("Session is not available"); + } + } } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventConsumers.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventConsumers.java index e2100b5cc..686620cfa 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventConsumers.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventConsumers.java @@ -5,10 +5,12 @@ import com.alibaba.qwen.code.cli.protocol.message.SDKResultMessage; import com.alibaba.qwen.code.cli.protocol.message.SDKSystemMessage; import com.alibaba.qwen.code.cli.protocol.message.SDKUserMessage; import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKAssistantMessage; +import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKPartialAssistantMessage; import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlPermissionRequest; import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlRequest; import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlResponse; import com.alibaba.qwen.code.cli.session.Session; +import com.alibaba.qwen.code.cli.utils.Timeout; public interface SessionEventConsumers { void onSystemMessage(Session session, SDKSystemMessage systemMessage); @@ -17,6 +19,8 @@ public interface SessionEventConsumers { void onAssistantMessage(Session session, SDKAssistantMessage assistantMessage); + void onPartialAssistantMessage(Session session, SDKPartialAssistantMessage partialAssistantMessage); + void onUserMessage(Session session, SDKUserMessage userMessage); void onOtherMessage(Session session, String message); @@ -26,4 +30,22 @@ public interface SessionEventConsumers { CLIControlResponse onControlRequest(Session session, CLIControlRequest cliControlRequest); Behavior onPermissionRequest(Session session, CLIControlRequest permissionRequest); + + Timeout onSystemMessageTimeout(Session session); + + Timeout onResultMessageTimeout(Session session); + + Timeout onAssistantMessageTimeout(Session session); + + Timeout onPartialAssistantMessageTimeout(Session session); + + Timeout onUserMessageTimeout(Session session); + + Timeout onOtherMessageTimeout(Session session); + + Timeout onControlResponseTimeout(Session session); + + Timeout onControlRequestTimeout(Session session); + + Timeout onPermissionRequestTimeout(Session session); } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventSimpleConsumers.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventSimpleConsumers.java index 9c685e755..fe21bbe96 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventSimpleConsumers.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventSimpleConsumers.java @@ -1,14 +1,28 @@ package com.alibaba.qwen.code.cli.session.event; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.behavior.Allow; import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior; +import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior.Operation; +import com.alibaba.qwen.code.cli.protocol.data.behavior.Deny; import com.alibaba.qwen.code.cli.protocol.message.SDKResultMessage; import com.alibaba.qwen.code.cli.protocol.message.SDKSystemMessage; import com.alibaba.qwen.code.cli.protocol.message.SDKUserMessage; import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKAssistantMessage; +import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKPartialAssistantMessage; +import com.alibaba.qwen.code.cli.protocol.message.assistant.event.ContentBlockDeltaEvent; +import com.alibaba.qwen.code.cli.protocol.message.assistant.event.StreamEvent; import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlPermissionRequest; import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlRequest; import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlResponse; import com.alibaba.qwen.code.cli.session.Session; +import com.alibaba.qwen.code.cli.utils.Timeout; public class SessionEventSimpleConsumers implements SessionEventConsumers { @Override @@ -21,6 +35,22 @@ public class SessionEventSimpleConsumers implements SessionEventConsumers { @Override public void onAssistantMessage(Session session, SDKAssistantMessage assistantMessage) { + onAssistantMessageIncludePartial(session, Optional.ofNullable(assistantMessage.getMessage().getContent()) + .map(cbs -> cbs.stream().map(cb -> (AssistantContent) cb).collect(Collectors.toList())) + .orElse(new ArrayList<>()), AssistantMessageOutputType.entire); + } + + @Override + public void onPartialAssistantMessage(Session session, SDKPartialAssistantMessage partialAssistantMessage) { + StreamEvent event = partialAssistantMessage.getEvent(); + if (!(event instanceof ContentBlockDeltaEvent)) { + return; + } + onAssistantMessageIncludePartial(session, Collections.singletonList(((ContentBlockDeltaEvent) event).getDelta()), AssistantMessageOutputType.partial); + } + + public void onAssistantMessageIncludePartial(Session session, List assistantContents, + AssistantMessageOutputType assistantMessageOutputType) { } @Override @@ -42,6 +72,89 @@ public class SessionEventSimpleConsumers implements SessionEventConsumers { @Override public Behavior onPermissionRequest(Session session, CLIControlRequest permissionRequest) { - return Behavior.defaultBehavior(); + if (Operation.deny.equals(this.defaultPermissionOperation)) { + return new Deny().setMessage("Permission denied."); + } else { + return new Allow().setUpdatedInput(permissionRequest.getRequest().getInput()); + } + } + + @Override + public Timeout onSystemMessageTimeout(Session session) { + return defaultEventTimeout; + } + + @Override + public Timeout onResultMessageTimeout(Session session) { + return defaultEventTimeout; + } + + @Override + public Timeout onAssistantMessageTimeout(Session session) { + return defaultEventTimeout; + } + + @Override + public Timeout onPartialAssistantMessageTimeout(Session session) { + return defaultEventTimeout; + } + + @Override + public Timeout onUserMessageTimeout(Session session) { + return defaultEventTimeout; + } + + @Override + public Timeout onOtherMessageTimeout(Session session) { + return defaultEventTimeout; + } + + @Override + public Timeout onControlResponseTimeout(Session session) { + return defaultEventTimeout; + } + + @Override + public Timeout onControlRequestTimeout(Session session) { + return defaultEventTimeout; + } + + @Override + public Timeout onPermissionRequestTimeout(Session session) { + return defaultEventTimeout; + } + + public Timeout getDefaultEventTimeout() { + return defaultEventTimeout; + } + + public SessionEventSimpleConsumers setDefaultEventTimeout(Timeout defaultEventTimeout) { + this.defaultEventTimeout = defaultEventTimeout; + return this; + } + + public Operation getDefaultPermissionOperation() { + return defaultPermissionOperation; + } + + public SessionEventSimpleConsumers setDefaultPermissionOperation(Operation defaultPermissionOperation) { + this.defaultPermissionOperation = defaultPermissionOperation; + return this; + } + + public SessionEventSimpleConsumers() { + } + + public SessionEventSimpleConsumers(Operation defaultPermissionOperation, Timeout defaultEventTimeout) { + this.defaultPermissionOperation = defaultPermissionOperation; + this.defaultEventTimeout = defaultEventTimeout; + } + + private Operation defaultPermissionOperation = Operation.deny; + protected Timeout defaultEventTimeout = Timeout.TIMEOUT_60_SECONDS; + + public enum AssistantMessageOutputType { + entire, + partial } } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/Transport.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/Transport.java index b3d69ee28..af4266f45 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/Transport.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/Transport.java @@ -8,6 +8,8 @@ import java.util.function.Function; public interface Transport { TransportOptions getTransportOptions(); + boolean isReading(); + void start() throws IOException; void close() throws IOException; diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java index b5e6ada6f..7e274d1a0 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Map; import com.alibaba.qwen.code.cli.protocol.data.PermissionMode; +import com.alibaba.qwen.code.cli.utils.Timeout; public class TransportOptions implements Cloneable { private String pathToQwenExecutable; @@ -17,120 +18,154 @@ public class TransportOptions implements Cloneable { private List allowedTools; private String authType; private Boolean includePartialMessages; - private Long turnTimeoutMs; - private Long messageTimeoutMs; + private Boolean skillsEnable; + private Timeout turnTimeout; + private Timeout messageTimeout; private String resumeSessionId; + private List otherOptions; public String getPathToQwenExecutable() { return pathToQwenExecutable; } - public void setPathToQwenExecutable(String pathToQwenExecutable) { + public TransportOptions setPathToQwenExecutable(String pathToQwenExecutable) { this.pathToQwenExecutable = pathToQwenExecutable; + return this; } public String getCwd() { return cwd; } - public void setCwd(String cwd) { + public TransportOptions setCwd(String cwd) { this.cwd = cwd; + return this; } public String getModel() { return model; } - public void setModel(String model) { + public TransportOptions setModel(String model) { this.model = model; + return this; } public PermissionMode getPermissionMode() { return permissionMode; } - public void setPermissionMode(PermissionMode permissionMode) { + public TransportOptions setPermissionMode(PermissionMode permissionMode) { this.permissionMode = permissionMode; + return this; } public Map getEnv() { return env; } - public void setEnv(Map env) { + public TransportOptions setEnv(Map env) { this.env = env; + return this; } public Integer getMaxSessionTurns() { return maxSessionTurns; } - public void setMaxSessionTurns(Integer maxSessionTurns) { + public TransportOptions setMaxSessionTurns(Integer maxSessionTurns) { this.maxSessionTurns = maxSessionTurns; + return this; } public List getCoreTools() { return coreTools; } - public void setCoreTools(List coreTools) { + public TransportOptions setCoreTools(List coreTools) { this.coreTools = coreTools; + return this; } public List getExcludeTools() { return excludeTools; } - public void setExcludeTools(List excludeTools) { + public TransportOptions setExcludeTools(List excludeTools) { this.excludeTools = excludeTools; + return this; } public List getAllowedTools() { return allowedTools; } - public void setAllowedTools(List allowedTools) { + public TransportOptions setAllowedTools(List allowedTools) { this.allowedTools = allowedTools; + return this; } public String getAuthType() { return authType; } - public void setAuthType(String authType) { + public TransportOptions setAuthType(String authType) { this.authType = authType; + return this; } public Boolean getIncludePartialMessages() { return includePartialMessages; } - public void setIncludePartialMessages(Boolean includePartialMessages) { + public TransportOptions setIncludePartialMessages(Boolean includePartialMessages) { this.includePartialMessages = includePartialMessages; + return this; } - public Long getTurnTimeoutMs() { - return turnTimeoutMs; + public Boolean getSkillsEnable() { + return skillsEnable; } - public void setTurnTimeoutMs(Long turnTimeoutMs) { - this.turnTimeoutMs = turnTimeoutMs; + public TransportOptions setSkillsEnable(Boolean skillsEnable) { + this.skillsEnable = skillsEnable; + return this; } - public Long getMessageTimeoutMs() { - return messageTimeoutMs; + public Timeout getTurnTimeout() { + return turnTimeout; } - public void setMessageTimeoutMs(Long messageTimeoutMs) { - this.messageTimeoutMs = messageTimeoutMs; + public TransportOptions setTurnTimeout(Timeout turnTimeout) { + this.turnTimeout = turnTimeout; + return this; + } + + public Timeout getMessageTimeout() { + return messageTimeout; + } + + public TransportOptions setMessageTimeout(Timeout messageTimeout) { + this.messageTimeout = messageTimeout; + return this; } public String getResumeSessionId() { return resumeSessionId; } - public void setResumeSessionId(String resumeSessionId) { + public TransportOptions setResumeSessionId(String resumeSessionId) { this.resumeSessionId = resumeSessionId; + return this; + } + + public List getOtherOptions() { + return otherOptions; + } + + public TransportOptions setOtherOptions(List otherOptions) { + this.otherOptions = otherOptions; + return this; } @Override diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransport.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransport.java index 14a0eb0ff..11be50ecc 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransport.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransport.java @@ -2,6 +2,8 @@ package com.alibaba.qwen.code.cli.transport.process; import com.alibaba.qwen.code.cli.transport.Transport; import com.alibaba.qwen.code.cli.transport.TransportOptions; +import com.alibaba.qwen.code.cli.utils.MyConcurrentUtils; +import com.alibaba.qwen.code.cli.utils.Timeout; import org.apache.commons.lang3.exception.ContextedRuntimeException; import org.slf4j.Logger; @@ -14,29 +16,37 @@ import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.lang.ProcessBuilder.Redirect; -import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; import java.util.function.Function; public class ProcessTransport implements Transport { private static final Logger log = LoggerFactory.getLogger(ProcessTransport.class); private final TransportOptions transportOptions; - protected Long turnTimeoutMs; - protected Long messageTimeoutMs; + protected Timeout turnTimeout; + protected Timeout messageTimeout; protected Process process; protected BufferedWriter processInput; protected BufferedReader processOutput; protected BufferedReader processError; + protected final Consumer errorHandler; + + private final AtomicBoolean reading = new AtomicBoolean(false); public ProcessTransport() throws IOException { this(new TransportOptions()); } public ProcessTransport(TransportOptions transportOptions) throws IOException { + this(transportOptions, (line) -> log.error("process error: {}", line)); + } + + public ProcessTransport(TransportOptions transportOptions, Consumer errorHandler) throws IOException { this.transportOptions = transportOptions; + this.errorHandler = errorHandler; start(); } @@ -45,11 +55,16 @@ public class ProcessTransport implements Transport { return transportOptions; } + @Override + public boolean isReading() { + return reading.get(); + } + @Override public void start() throws IOException { TransportOptionsAdapter transportOptionsAdapter = new TransportOptionsAdapter(transportOptions); - this.turnTimeoutMs = transportOptionsAdapter.getHandledTransportOptions().getTurnTimeoutMs(); - this.messageTimeoutMs = transportOptionsAdapter.getHandledTransportOptions().getMessageTimeoutMs(); + this.turnTimeout = transportOptionsAdapter.getHandledTransportOptions().getTurnTimeout(); + this.messageTimeout = transportOptionsAdapter.getHandledTransportOptions().getMessageTimeout(); String[] commandArgs = transportOptionsAdapter.buildCommandArgs(); log.debug("trans to command args: {}", transportOptionsAdapter); @@ -91,113 +106,87 @@ public class ProcessTransport implements Transport { @Override public String inputWaitForOneLine(String message) throws IOException, ExecutionException, InterruptedException, TimeoutException { - return inputWaitForOneLine(message, turnTimeoutMs); + return inputWaitForOneLine(message, turnTimeout); } - private String inputWaitForOneLine(String message, long timeOutInMs) + private String inputWaitForOneLine(String message, Timeout timeOut) throws IOException, TimeoutException, InterruptedException, ExecutionException { inputNoWaitResponse(message); - CompletableFuture future = CompletableFuture.supplyAsync(() -> { - try { - return processOutput.readLine(); - } catch (IOException e) { - throw new ContextedRuntimeException("read line error", e) - .addContextValue("message", message); - } - }); - try { - String line = future.get(timeOutInMs, TimeUnit.MILLISECONDS); + reading.set(true); + String line = MyConcurrentUtils.runAndWait(() -> { + try { + return processOutput.readLine(); + } catch (IOException e) { + throw new ContextedRuntimeException("read line error", e) + .addContextValue("message", message); + } + }, timeOut); log.info("inputWaitForOneLine result: {}", line); return line; - } catch (TimeoutException e) { - future.cancel(true); - log.warn("read message timeout {}, canceled readOneLine task", timeOutInMs, e); - throw e; - } catch (InterruptedException e) { - future.cancel(true); - log.warn("interrupted task, canceled task", e); - throw e; - } catch (ExecutionException e) { - future.cancel(true); - log.warn("the readOneLine task execute error", e); - throw e; + } finally { + reading.set(false); } } @Override public void inputWaitForMultiLine(String message, Function callBackFunction) throws IOException { - inputWaitForMultiLine(message, callBackFunction, turnTimeoutMs); + inputWaitForMultiLine(message, callBackFunction, turnTimeout); } - private void inputWaitForMultiLine(String message, Function callBackFunction, long timeOutInMs) throws IOException { + private void inputWaitForMultiLine(String message, Function callBackFunction, Timeout timeOut) throws IOException { log.debug("input message for multiLine: {}", message); inputNoWaitResponse(message); - - CompletableFuture future = CompletableFuture.runAsync(() -> iterateOutput(callBackFunction)); - try { - future.get(timeOutInMs, TimeUnit.MILLISECONDS); - } catch (TimeoutException e) { - future.cancel(true); - log.warn("read message timeout {}, canceled readMultiMessages task", timeOutInMs, e); - } catch (InterruptedException e) { - future.cancel(true); - log.warn("interrupted task, canceled task", e); - } catch (ExecutionException e) { - future.cancel(true); - log.warn("the readMultiMessages task execute error", e); - } catch (Exception e) { - future.cancel(true); - log.warn("other error"); - } + MyConcurrentUtils.runAndWait(() -> iterateOutput(callBackFunction), timeOut); } @Override public void inputNoWaitResponse(String message) throws IOException { - log.debug("input message to agent: {}", message); + log.debug("input message to process: {}", message); processInput.write(message); processInput.newLine(); processInput.flush(); } private void startErrorReading() { - CompletableFuture.runAsync(() -> { + MyConcurrentUtils.asyncRun(() -> { try { - String line; - while ((line = processError.readLine()) != null) { - System.err.println("错误: " + line); - } - } catch (Exception e) { - System.err.println("错误: " + e.getMessage()); - } - }); - } - - private void iterateOutput(Function callBackFunction) { - CompletableFuture future = CompletableFuture.runAsync(() -> { - try { - for (String line = processOutput.readLine(); line != null; line = processOutput.readLine()) { - log.debug("read a message from agent {}", line); - if (callBackFunction.apply(line)) { + for (;;) { + final String line = processError.readLine(); + if (line == null) { break; } + if (errorHandler != null) { + try { + MyConcurrentUtils.runAndWait(() -> errorHandler.accept(line), messageTimeout); + } catch (Exception e) { + log.warn("error handler error", e); + } + } } } catch (IOException e) { - throw new RuntimeException("read process output error", e); + log.warn("Failed read error {}, caused by {}", e.getMessage(), e.getCause(), e); } - }); + }, (e, t) -> log.warn("read error {}", t.getMessage(), t)); + } + private void iterateOutput(Function callBackFunction) { try { - future.get(messageTimeoutMs, TimeUnit.MILLISECONDS); - } catch (InterruptedException e) { - log.warn("read message task interrupted", e); - future.cancel(true); - } catch (TimeoutException e) { - log.warn("Operation timed out", e); - future.cancel(true); - } catch (Exception e) { - future.cancel(true); - log.warn("Operation error", e); + reading.set(true); + MyConcurrentUtils.runAndWait(() -> { + try { + for (String line = processOutput.readLine(); line != null; line = processOutput.readLine()) { + log.debug("read a message from process {}", line); + if (callBackFunction.apply(line)) { + break; + } + } + } catch (IOException e) { + throw new RuntimeException("read process output error", e); + } + }, messageTimeout); + } finally { + reading.set(false); } } } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java index 1f179f93e..a2226a499 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java @@ -1,6 +1,7 @@ package com.alibaba.qwen.code.cli.transport.process; import com.alibaba.qwen.code.cli.transport.TransportOptions; +import com.alibaba.qwen.code.cli.utils.Timeout; import org.apache.commons.lang3.StringUtils; @@ -11,11 +12,12 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.TimeUnit; class TransportOptionsAdapter { TransportOptions transportOptions; - private static final Long DEFAULT_TURN_TIMEOUT_MS = 1000 * 60 * 30L; - private static final Long DEFAULT_MESSAGE_TIMEOUT_MS = 1000 * 60 * 3L; + private static final Timeout DEFAULT_TURN_TIMEOUT = new Timeout(1000 * 60 * 30L, TimeUnit.MILLISECONDS); + private static final Timeout DEFAULT_MESSAGE_TIMEOUT = new Timeout(1000 * 60 * 3L, TimeUnit.MILLISECONDS); TransportOptionsAdapter(TransportOptions userTransportOptions) { transportOptions = addDefaultTransportOptions(userTransportOptions); @@ -73,10 +75,18 @@ class TransportOptionsAdapter { args.add("--include-partial-messages"); } + if (transportOptions.getSkillsEnable() != null && transportOptions.getSkillsEnable()) { + args.add("--experimental-skills"); + } + if (StringUtils.isNotBlank(transportOptions.getResumeSessionId())) { args.add("--resume"); args.add(transportOptions.getResumeSessionId()); } + + if (transportOptions.getOtherOptions() != null) { + args.addAll(transportOptions.getOtherOptions()); + } return args.toArray(new String[] {}); } @@ -97,12 +107,12 @@ class TransportOptionsAdapter { Optional.ofNullable(transportOptions.getEnv()).ifPresent(env::putAll); transportOptions.setEnv(env); - if (transportOptions.getTurnTimeoutMs() == null) { - transportOptions.setTurnTimeoutMs(DEFAULT_TURN_TIMEOUT_MS); + if (transportOptions.getTurnTimeout() == null) { + transportOptions.setTurnTimeout(DEFAULT_TURN_TIMEOUT); } - if (transportOptions.getMessageTimeoutMs() == null) { - transportOptions.setMessageTimeoutMs(DEFAULT_MESSAGE_TIMEOUT_MS); + if (transportOptions.getMessageTimeout() == null) { + transportOptions.setMessageTimeout(DEFAULT_MESSAGE_TIMEOUT); } return transportOptions; } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/MyConcurrentUtils.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/MyConcurrentUtils.java new file mode 100644 index 000000000..3b5cf22da --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/MyConcurrentUtils.java @@ -0,0 +1,65 @@ +package com.alibaba.qwen.code.cli.utils; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; +import java.util.function.BiConsumer; +import java.util.function.Supplier; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class MyConcurrentUtils { + private static final Logger log = LoggerFactory.getLogger(MyConcurrentUtils.class); + + public static void runAndWait(Runnable runnable, Timeout timeOut) { + CompletableFuture future = CompletableFuture.runAsync(() -> { + try { + runnable.run(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }, ThreadPoolConfig.getExecutor()); + try { + future.get(timeOut.getValue(), timeOut.getUnit()); + } catch (InterruptedException e) { + log.warn("task interrupted", e); + future.cancel(true); + } catch (TimeoutException e) { + log.warn("Operation timed out", e); + future.cancel(true); + } catch (Exception e) { + future.cancel(true); + log.warn("Operation error", e); + } + } + + public static T runAndWait(Supplier supplier, Timeout timeOut) + throws ExecutionException, InterruptedException, TimeoutException { + CompletableFuture future = CompletableFuture.supplyAsync(() -> { + try { + return supplier.get(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }, ThreadPoolConfig.getExecutor()); + + try { + return future.get(timeOut.getValue(), timeOut.getUnit()); + } catch (TimeoutException | InterruptedException | ExecutionException e) { + future.cancel(true); + throw e; + } + } + + public static void asyncRun(Runnable runnable, BiConsumer errorCallback) { + CompletableFuture future = CompletableFuture.runAsync(() -> { + try { + runnable.run(); + } catch (Exception e) { + log.warn("async task error", e); + } + }, ThreadPoolConfig.getExecutor()); + future.whenComplete(errorCallback); + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/ThreadPoolConfig.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/ThreadPoolConfig.java new file mode 100644 index 000000000..59b7ef4cb --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/ThreadPoolConfig.java @@ -0,0 +1,43 @@ +package com.alibaba.qwen.code.cli.utils; + +import java.util.Optional; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; + +public class ThreadPoolConfig { + private static final ThreadPoolExecutor defaultExecutor = new ThreadPoolExecutor( + 10, 30, 60L, TimeUnit.SECONDS, + new LinkedBlockingQueue<>(300), + new ThreadFactory() { + private final AtomicInteger threadNumber = new AtomicInteger(1); + + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(r, "qwen_code_cli-pool-" + threadNumber.getAndIncrement()); + t.setDaemon(false); + return t; + } + }, + new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略 + ); + + private static Supplier executorSupplier; + public static void setExecutorSupplier(Supplier executorSupplier) { + ThreadPoolConfig.executorSupplier = executorSupplier; + } + + public static ExecutorService getExecutor() { + return Optional.ofNullable(executorSupplier).map(s -> { + try { + return s.get(); + } catch (Exception e) { + return defaultExecutor; + } + }).orElse(defaultExecutor); + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/Timeout.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/Timeout.java new file mode 100644 index 000000000..f00b39085 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/Timeout.java @@ -0,0 +1,27 @@ +package com.alibaba.qwen.code.cli.utils; + +import java.util.concurrent.TimeUnit; + +import org.apache.commons.lang3.Validate; + +public class Timeout { + private final Long value; + private final TimeUnit unit; + public Timeout(Long value, TimeUnit unit) { + Validate.notNull(value, "value can not be null"); + Validate.notNull(unit, "unit can not be null"); + this.value = value; + this.unit = unit; + } + + public Long getValue() { + return value; + } + + public TimeUnit getUnit() { + return unit; + } + + public static final Timeout TIMEOUT_60_SECONDS = new Timeout(60L, TimeUnit.SECONDS); + public static final Timeout TIMEOUT_30_MINUTES = new Timeout(60L, TimeUnit.MINUTES); +} diff --git a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/QwenCliTest.java b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/QwenCodeCliTest.java similarity index 58% rename from packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/QwenCliTest.java rename to packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/QwenCodeCliTest.java index 01295ce6c..51be8bf4c 100644 --- a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/QwenCliTest.java +++ b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/QwenCodeCliTest.java @@ -2,21 +2,18 @@ package com.alibaba.qwen.code.cli; import java.util.List; -import com.alibaba.qwen.code.cli.protocol.message.Message; -import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKAssistantMessage; - import org.junit.jupiter.api.Test; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import static org.junit.jupiter.api.Assertions.*; -class QwenCliTest { +class QwenCodeCliTest { - private static final Logger log = LoggerFactory.getLogger(QwenCliTest.class); + private static final Logger log = LoggerFactory.getLogger(QwenCodeCliTest.class); @Test - void query() { - List result = QwenCli.query("hello world"); + void simpleQuery() { + List result = QwenCodeCli.simpleQuery("hello world"); log.info("result: {}", result); assertNotNull(result); } diff --git a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java index 51c37c6c8..8cdfa03c1 100644 --- a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java +++ b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java @@ -1,11 +1,14 @@ package com.alibaba.qwen.code.cli.session; import java.io.IOException; +import java.util.List; import com.alibaba.fastjson2.JSON; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent; import com.alibaba.qwen.code.cli.protocol.data.PermissionMode; import com.alibaba.qwen.code.cli.protocol.data.behavior.Allow; import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior; +import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior.Operation; import com.alibaba.qwen.code.cli.protocol.message.SDKResultMessage; import com.alibaba.qwen.code.cli.protocol.message.SDKSystemMessage; import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKAssistantMessage; @@ -17,6 +20,7 @@ import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; import com.alibaba.qwen.code.cli.session.exception.SessionControlException; import com.alibaba.qwen.code.cli.session.exception.SessionSendPromptException; import com.alibaba.qwen.code.cli.transport.Transport; +import com.alibaba.qwen.code.cli.transport.TransportOptions; import com.alibaba.qwen.code.cli.transport.process.ProcessTransport; import org.apache.commons.lang3.StringUtils; @@ -28,18 +32,34 @@ class SessionTest { private static final Logger log = LoggerFactory.getLogger(SessionTest.class); + @Test + void partialSendPromptSuccessfully() throws IOException, SessionControlException, SessionSendPromptException { + Transport transport = new ProcessTransport(new TransportOptions().setIncludePartialMessages(true)); + Session session = new Session(transport); + session.sendPrompt("in the dir src/test/temp/, create file empty file test.touch", new SessionEventSimpleConsumers() { + @Override + public void onAssistantMessageIncludePartial(Session session, List assistantContents, + AssistantMessageOutputType assistantMessageOutputType) { + log.info("onAssistantMessageIncludePartial: {}", JSON.toJSONString(assistantContents)); + } + }.setDefaultPermissionOperation(Operation.allow)); + } + @Test void setPermissionModeSuccessfully() throws IOException, SessionControlException, SessionSendPromptException { Transport transport = new ProcessTransport(); Session session = new Session(transport); - session.setPermissionMode(PermissionMode.YOLO); + log.info(session.setPermissionMode(PermissionMode.YOLO).map(s -> s ? "setPermissionMode 1 success" : "setPermissionMode 1 error") + .orElse("setPermissionMode 1 unknown")); session.sendPrompt("in the dir src/test/temp/, create file empty file test.touch", new SessionEventSimpleConsumers()); - session.setPermissionMode(PermissionMode.PLAN); + log.info(session.setPermissionMode(PermissionMode.PLAN).map(s -> s ? "setPermissionMode 2 success" : "setPermissionMode 2 error") + .orElse("setPermissionMode 2 unknown")); session.sendPrompt("rename test.touch to test_rename.touch", new SessionEventSimpleConsumers()); - session.setPermissionMode(PermissionMode.AUTO_EDIT); + log.info(session.setPermissionMode(PermissionMode.AUTO_EDIT).map(s -> s ? "setPermissionMode 3 success" : "setPermissionMode 3 error") + .orElse("setPermissionMode 3 unknown")); session.sendPrompt("rename test.touch to test_rename.touch", new SessionEventSimpleConsumers()); session.sendPrompt("rename test.touch to test_rename.touch again user will allow", new SessionEventSimpleConsumers() { @@ -57,19 +77,19 @@ class SessionTest { Transport transport = new ProcessTransport(); Session session = new Session(transport); - session.setModel("qwen3-coder-flash"); + log.info(session.setModel("qwen3-coder-flash").map(s -> s ? "setModel 1 success" : "setModel 1 error").orElse("setModel 1 unknown")); writeSplitLine("setModel 1 end"); session.sendPrompt("hello world", new SessionEventSimpleConsumers()); writeSplitLine("prompt 1 end"); - session.setModel("qwen3-coder-plus"); + log.info(session.setModel("qwen3-coder-plus").map(s -> s ? "setModel 2 success" : "setModel 2 error").orElse("setModel 2 unknown")); writeSplitLine("setModel 1 end"); session.sendPrompt("查看下当前目录有多少个文件", new SessionEventSimpleConsumers()); writeSplitLine("prompt 2 end"); - session.setModel("qwen3-max"); + log.info(session.setModel("qwen3-max").map(s -> s ? "setModel 3 success" : "setModel 3 error").orElse("setModel 3 unknown")); writeSplitLine("setModel 1 end"); session.sendPrompt("查看下当前目录有多少个xml文件", new SessionEventSimpleConsumers()); @@ -129,12 +149,17 @@ class SessionTest { } public void writeSplitLine(String line) { - log.info("{} {}",line, StringUtils.repeat("=", 300)); + log.info("{} {}", line, StringUtils.repeat("=", 300)); } @Test void testJSON() { - String json = "{\"type\":\"assistant\",\"uuid\":\"ed8374fe-a4eb-4fc0-9780-9bd2fd831cda\",\"session_id\":\"166badc0-e6d3-4978-ae47-4ccd51c468ef\",\"message\":{\"content\":[{\"text\":\"Hello! How can I help you with the Qwen Code SDK for Java today?\",\"type\":\"text\"}],\"id\":\"ed8374fe-a4eb-4fc0-9780-9bd2fd831cda\",\"model\":\"qwen3-coder-plus\",\"role\":\"assistant\",\"type\":\"message\",\"usage\":{\"cache_read_input_tokens\":12766,\"input_tokens\":12770,\"output_tokens\":17,\"total_tokens\":12787}}}"; + String json + = "{\"type\":\"assistant\",\"uuid\":\"ed8374fe-a4eb-4fc0-9780-9bd2fd831cda\"," + + "\"session_id\":\"166badc0-e6d3-4978-ae47-4ccd51c468ef\",\"message\":{\"content\":[{\"text\":\"Hello! How can I help you with the" + + " Qwen Code SDK for Java today?\",\"type\":\"text\"}],\"id\":\"ed8374fe-a4eb-4fc0-9780-9bd2fd831cda\"," + + "\"model\":\"qwen3-coder-plus\",\"role\":\"assistant\",\"type\":\"message\",\"usage\":{\"cache_read_input_tokens\":12766," + + "\"input_tokens\":12770,\"output_tokens\":17,\"total_tokens\":12787}}}"; SDKAssistantMessage assistantMessage = JSON.parseObject(json, SDKAssistantMessage.class); log.info("the assistantMessage: {}", assistantMessage); } From 30f9e9c7828752709f4737a9df0702983815ec13 Mon Sep 17 00:00:00 2001 From: skyfire Date: Wed, 31 Dec 2025 22:57:20 +0800 Subject: [PATCH 037/142] for README.md --- packages/sdk-java/QWEN.md | 170 +++++ packages/sdk-java/README.md | 609 ++++++++++++++++++ .../alibaba/qwen/code/cli/QwenCodeCli.java | 25 +- .../qwen/code/cli/utils/ThreadPoolConfig.java | 6 +- .../qwen/code/cli/session/SessionTest.java | 25 +- packages/sdk-java/todo | 6 - 6 files changed, 815 insertions(+), 26 deletions(-) create mode 100644 packages/sdk-java/QWEN.md create mode 100644 packages/sdk-java/README.md delete mode 100644 packages/sdk-java/todo diff --git a/packages/sdk-java/QWEN.md b/packages/sdk-java/QWEN.md new file mode 100644 index 000000000..0ebb55c7d --- /dev/null +++ b/packages/sdk-java/QWEN.md @@ -0,0 +1,170 @@ +# Qwen Code Java SDK + +## Project Overview + +The Qwen Code Java SDK is a minimum experimental SDK for programmatic access to Qwen Code functionality. It provides a Java interface to interact with the Qwen Code CLI, allowing developers to integrate Qwen Code capabilities into their Java applications. + +The project is structured as a Maven-based Java library with the following key characteristics: + +- **Group ID**: com.alibaba +- **Artifact ID**: qwencode-sdk-java +- **Version**: 0.0.1 +- **Packaging**: JAR +- **Java Version**: 1.8+ (source and target) + +## Architecture + +The SDK follows a layered architecture: + +- **CLI Layer**: Provides the main entry point through `QwenCodeCli` class +- **Session Layer**: Manages communication sessions with the Qwen Code CLI +- **Transport Layer**: Handles communication between the SDK and CLI process +- **Protocol Layer**: Defines data structures for communication +- **Utils**: Common utilities for concurrent execution and timeout handling + +## Key Components + +### Main Classes + +- `QwenCodeCli`: Main entry point with static methods for simple queries +- `Session`: Manages communication sessions with the CLI +- `Transport`: Abstracts the communication mechanism (currently using process transport) +- `ProcessTransport`: Implementation that communicates via process execution + +### Dependencies + +- **Logging**: ch.qos.logback:logback-classic +- **Utilities**: org.apache.commons:commons-lang3 +- **JSON Processing**: com.alibaba.fastjson2:fastjson2 +- **Testing**: JUnit 5 (org.junit.jupiter:junit-jupiter) + +## Building and Running + +### Prerequisites + +- Java 8 or higher +- Apache Maven 3.6.0 or higher + +### Build Commands + +```bash +# Compile the project +mvn compile + +# Run tests +mvn test + +# Package the JAR +mvn package + +# Install to local repository +mvn install + +# Run checkstyle verification +mvn checkstyle:check +``` + +### Testing + +The project includes basic unit tests using JUnit 5. The main test class `QwenCodeCliTest` demonstrates how to use the SDK to make simple queries to the Qwen Code CLI. + +### Code Quality + +The project uses Checkstyle for code formatting and style enforcement. The configuration is defined in `checkstyle.xml` and includes rules for: + +- Whitespace and indentation +- Naming conventions +- Import ordering +- Code structure + +## Development Conventions + +### Coding Standards + +- Java 8 language features are supported +- Follow standard Java naming conventions +- Use UTF-8 encoding for source files +- Line endings should be LF (Unix-style) +- No trailing whitespace allowed +- Use 8-space indentation for line wrapping + +### Testing Practices + +- Write unit tests using JUnit 5 +- Test classes should be in the `src/test/java` directory +- Follow the naming convention `*Test.java` for test classes +- Use appropriate assertions to validate functionality + +### Documentation + +- API documentation should follow JavaDoc conventions +- Update README files when adding new features +- Include examples in documentation + +## API Reference + +### QwenCodeCli Class + +The main class provides two primary methods: + +- `simpleQuery(String prompt)`: Synchronous method that returns a list of responses +- `simpleQuery(String prompt, Consumer messageConsumer)`: Asynchronous method that streams responses to a consumer + +### Permission Modes + +The SDK supports different permission modes for controlling tool execution: + +- **`default`**: Write tools are denied unless approved via `canUseTool` callback or in `allowedTools`. Read-only tools execute without confirmation. +- **`plan`**: Blocks all write tools, instructing AI to present a plan first. +- **`auto-edit`**: Auto-approve edit tools (edit, write_file) while other tools require confirmation. +- **`yolo`**: All tools execute automatically without confirmation. + +## Usage Example + +```java +import com.alibaba.qwen.code.cli.QwenCodeCli; +import java.util.List; + +public class Example { + public static void main(String[] args) { + List result = QwenCodeCli.simpleQuery("hello world"); + result.forEach(System.out::println); + } +} +``` + +## Project Structure + +``` +src/ +├── main/ +│ └── java/ +│ └── com/ +│ └── alibaba/ +│ └── qwen/ +│ └── code/ +│ └── cli/ +│ ├── QwenCodeCli.java +│ ├── protocol/ +│ ├── session/ +│ ├── transport/ +│ └── utils/ +└── test/ + └── java/ + └── com/ + └── alibaba/ + └── qwen/ + └── code/ + └── cli/ + └── QwenCodeCliTest.java +``` + +## Configuration Files + +- `pom.xml`: Maven build configuration and dependencies +- `checkstyle.xml`: Code style and formatting rules +- `.editorconfig`: Editor configuration settings + +## License + +Apache-2.0 - see [LICENSE](./LICENSE) for details. diff --git a/packages/sdk-java/README.md b/packages/sdk-java/README.md new file mode 100644 index 000000000..0d7030658 --- /dev/null +++ b/packages/sdk-java/README.md @@ -0,0 +1,609 @@ +# Qwen Code Java SDK + +A minimum experimental Java SDK for programmatic access to Qwen Code functionality. This SDK provides a Java interface to interact with the Qwen Code CLI, allowing developers to integrate Qwen Code capabilities into their Java applications. + +Feel free to submit a feature request/issue/PR. + +## Installation + +Add the following dependency to your Maven `pom.xml`: + +```xml + + com.alibaba + qwencode-sdk-java + 0.0.1 + +``` + +Or if using Gradle, add to your `build.gradle`: + +```gradle +implementation 'com.alibaba:qwencode-sdk-java:0.0.1' +``` + +## Requirements + +- Java >= 1.8 +- Maven >= 3.6.0 (for building from source) + +> From v0.1.1, the CLI is bundled with the SDK. So no standalone CLI installation is needed. + +## Quick Start + +The simplest way to use the SDK is through the `QwenCodeCli.simpleQuery()` method: + +```java +import com.alibaba.qwen.code.cli.QwenCodeCli; +import java.util.List; + +public class Example { + public static void main(String[] args) { + List result = QwenCodeCli.simpleQuery("hello world"); + result.forEach(System.out::println); + } +} +``` + +For more advanced usage with streaming responses: + +```java +import com.alibaba.qwen.code.cli.QwenCodeCli; +import java.util.function.Consumer; + +public class StreamingExample { + public static void main(String[] args) { + QwenCodeCli.simpleQuery("hello world", (String message) -> { + System.out.println("Received: " + message); + }); + } +} +``` + +For session-based usage with custom event handling: + +```java +import com.alibaba.qwen.code.cli.QwenCodeCli; +import com.alibaba.qwen.code.cli.session.Session; +import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; +import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKAssistantMessage; +import com.alibaba.qwen.code.cli.utils.Timeout; +import java.util.concurrent.TimeUnit; + +public class SessionExample { + public static void main(String[] args) { + try (Session session = QwenCodeCli.newSession()) { + SessionEventSimpleConsumers eventConsumers = new SessionEventSimpleConsumers() { + @Override + public void onAssistantMessage(Session session, SDKAssistantMessage assistantMessage) { + String message = assistantMessage.getMessage().getContent().stream() + .findFirst() + .map(content -> content.getText()) + .orElse(""); + System.out.println("Assistant: " + message); + } + }.setDefaultEventTimeout(new Timeout(60L, TimeUnit.SECONDS)); + + session.sendPrompt("hello world", eventConsumers); + } catch (Exception e) { + e.printStackTrace(); + } + } +} +``` + +## Architecture + +The Qwen Code Java SDK follows a layered architecture that abstracts the communication with the Qwen Code CLI: + +### Layered Architecture + +- **API Layer**: Provides the main entry points through `QwenCodeCli` class with simple static methods for basic usage +- **Session Layer**: Manages communication sessions with the Qwen Code CLI through the `Session` class +- **Transport Layer**: Handles the communication mechanism between the SDK and CLI process (currently using process transport via `ProcessTransport`) +- **Protocol Layer**: Defines data structures for communication based on the CLI protocol +- **Utils**: Common utilities for concurrent execution, timeout handling, and error management + +### Core Classes and Their Relationships + +- `QwenCodeCli`: The main entry point that provides static methods (`simpleQuery`) which internally create and manage `Session` instances +- `Session`: Manages the lifecycle of a communication session with the CLI, including initialization, prompt sending, and cleanup +- `Transport`: Abstracts the communication mechanism (currently implemented by `ProcessTransport`) +- `ProcessTransport`: Implementation that communicates with the CLI via process execution, using `TransportOptions` for configuration +- `TransportOptions`: Configuration class that defines how the transport layer should interact with the CLI (path to executable, working directory, model, permission mode, etc.) +- `SessionEventSimpleConsumers`: Event handler interface for processing responses from the CLI, allowing custom handling of assistant messages and other events +- `PermissionMode`: Enum that defines different permission modes for controlling tool execution (default, plan, auto-edit, yolo) + +The architecture allows for both simple usage through static methods in `QwenCodeCli` and more advanced usage through direct `Session` management with custom event handlers and transport options. + +### Session Event Consumers + +The SDK allows you to customize how events from the CLI are handled using event consumers. The `SessionEventConsumers` interface provides callbacks for different types of messages during a session: + +- `onSystemMessage`: Handles system messages from the CLI (receives Session and SDKSystemMessage) +- `onResultMessage`: Handles result messages from the CLI (receives Session and SDKResultMessage) +- `onAssistantMessage`: Handles assistant messages (AI responses) (receives Session and SDKAssistantMessage) +- `onPartialAssistantMessage`: Handles partial assistant messages during streaming (receives Session and SDKPartialAssistantMessage) +- `onUserMessage`: Handles user messages (receives Session and SDKUserMessage) +- `onOtherMessage`: Handles other types of messages (receives Session and String message) +- `onControlResponse`: Handles control responses (receives Session and CLIControlResponse) +- `onControlRequest`: Handles control requests (receives Session and CLIControlRequest, returns CLIControlResponse) +- `onPermissionRequest`: Handles permission requests (receives Session and CLIControlRequest, returns Behavior) +- `onAssistantMessageIncludePartial`: Handles assistant messages including partial content (specific to SessionEventSimpleConsumers, called by both onAssistantMessage and onPartialAssistantMessage) (receives Session, List, and AssistantMessageOutputType) + +Event processing is subject to the timeout settings configured in `TransportOptions` and `SessionEventConsumers`. For detailed timeout configuration options, see the "Timeout" section above. + +Example of custom event handling: + +```java +import com.alibaba.qwen.code.cli.session.Session; +import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; +import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKAssistantMessage; +import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKPartialAssistantMessage; +import com.alibaba.qwen.code.cli.protocol.message.SDKResultMessage; +import com.alibaba.qwen.code.cli.protocol.message.SDKSystemMessage; +import com.alibaba.qwen.code.cli.protocol.message.SDKUserMessage; +import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlPermissionRequest; +import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlRequest; +import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlResponse; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior; +import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers.AssistantMessageOutputType; +import com.alibaba.qwen.code.cli.utils.Timeout; +import java.util.List; +import java.util.concurrent.TimeUnit; + +Session session = QwenCodeCli.newSession(); +SessionEventSimpleConsumers eventConsumers = new SessionEventSimpleConsumers() { + @Override + public void onAssistantMessage(Session session, SDKAssistantMessage assistantMessage) { + String message = assistantMessage.getMessage().getContent().stream() + .findFirst() + .map(content -> content.getText()) + .orElse(""); + System.out.println("Assistant: " + message); + } + + @Override + public void onPartialAssistantMessage(Session session, SDKPartialAssistantMessage partialAssistantMessage) { + System.out.println("Partial assistant message: " + partialAssistantMessage); + } + + public void onAssistantMessageIncludePartial(Session session, List assistantContents, + AssistantMessageOutputType assistantMessageOutputType) { + System.out.println("Assistant content (type: " + assistantMessageOutputType + "): " + assistantContents); + } + + @Override + public void onSystemMessage(Session session, SDKSystemMessage systemMessage) { + System.out.println("System: " + systemMessage.getMessage()); + } + + @Override + public void onResultMessage(Session session, SDKResultMessage resultMessage) { + System.out.println("Result: " + resultMessage.getMessage()); + } + + @Override + public void onUserMessage(Session session, SDKUserMessage userMessage) { + System.out.println("User: " + userMessage.getMessage()); + } + + @Override + public void onOtherMessage(Session session, String message) { + System.out.println("Other: " + message); + } + + @Override + public void onControlResponse(Session session, CLIControlResponse cliControlResponse) { + System.out.println("Control response: " + cliControlResponse); + } + + @Override + public CLIControlResponse onControlRequest(Session session, CLIControlRequest cliControlRequest) { + System.out.println("Control request: " + cliControlRequest); + return new CLIControlResponse<>(); // Return appropriate response + } + + @Override + public Behavior onPermissionRequest(Session session, CLIControlRequest permissionRequest) { + System.out.println("Permission request: " + permissionRequest.getRequest().getInput()); + return new com.alibaba.qwen.code.cli.protocol.data.behavior.Allow() + .setUpdatedInput(permissionRequest.getRequest().getInput()); // Allow by default + } + + @Override + public Timeout onAssistantMessageTimeout(Session session) { + return new Timeout(90L, TimeUnit.SECONDS); // Timeout for processing assistant messages + } + + @Override + public Timeout onSystemMessageTimeout(Session session) { + return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing system messages + } + + @Override + public Timeout onResultMessageTimeout(Session session) { + return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing result messages + } + + @Override + public Timeout onPartialAssistantMessageTimeout(Session session) { + return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing partial assistant messages + } + + @Override + public Timeout onUserMessageTimeout(Session session) { + return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing user messages + } + + @Override + public Timeout onOtherMessageTimeout(Session session) { + return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing other messages + } + + @Override + public Timeout onControlResponseTimeout(Session session) { + return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing control responses + } + + @Override + public Timeout onControlRequestTimeout(Session session) { + return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing control requests + } + + @Override + public Timeout onPermissionRequestTimeout(Session session) { + return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing permission requests + } +}.setDefaultEventTimeout(new Timeout(60L, TimeUnit.SECONDS)); // Default timeout for all events + +session.sendPrompt("Example prompt", eventConsumers); +``` + +### Permission Modes + +The SDK supports different permission modes for controlling tool execution: + +- **`default`**: Write tools are denied unless approved via `canUseTool` callback or in `allowedTools`. Read-only tools execute without confirmation. +- **`plan`**: Blocks all write tools, instructing AI to present a plan first. +- **`auto-edit`**: Auto-approve edit tools (edit, write_file) while other tools require confirmation. +- **`yolo`**: All tools execute automatically without confirmation. + +To set a permission mode: + +```java +Session session = QwenCodeCli.newSession(new TransportOptions().setPermissionMode(PermissionMode.YOLO)); +session.setPermissionMode(PermissionMode.PLAN); +``` + +### Session Control + +The SDK provides fine-grained control over session lifecycle and behavior: + +- **Session creation**: Use `QwenCodeCli.newSession()` to create a new session with custom options +- **Session management**: The `Session` class provides methods to send prompts, handle responses, and manage session state +- **Session cleanup**: Always close sessions using `session.close()` to properly terminate the CLI process +- **Session resumption**: Use `setResumeSessionId()` in `TransportOptions` to resume a previous session +- **Session interruption**: Use `session.interrupt()` to interrupt a currently running prompt +- **Dynamic model switching**: Use `session.setModel()` to change the model during a session +- **Dynamic permission mode switching**: Use `session.setPermissionMode()` to change the permission mode during a session + +Example of session control: + +```java +import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; + +TransportOptions options = new TransportOptions() + .setModel("qwen-max") + .setPermissionMode(PermissionMode.AUTO_EDIT); + +try (Session session = QwenCodeCli.newSession(options)) { + // Use the session with default event consumers + List result = session.sendPrompt("Explain how to use the SDK", new SessionEventSimpleConsumers()); + result.forEach(System.out::println); +} // Session automatically closes when exiting try-with-resources +``` + +#### Interrupt Function + +The `interrupt()` function allows you to interrupt a currently running prompt. This is useful when you need to stop a long-running operation without closing the entire session: + +- **Method signature**: `public Optional interrupt() throws SessionControlException` +- **Purpose**: Interrupts the current prompt processing without closing the session +- **Return value**: An `Optional` that indicates whether the interrupt was successful (true if successful, empty if the interrupt was sent asynchronously) + +Example of interrupting a running prompt: + +```java +import com.alibaba.qwen.code.cli.session.Session; +import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; +import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKAssistantMessage; +import com.alibaba.qwen.code.cli.session.exception.SessionControlException; + +try (Session session = QwenCodeCli.newSession()) { + session.sendPrompt("Analyze this large codebase...", new SessionEventSimpleConsumers() { + @Override + public void onAssistantMessage(Session session, SDKAssistantMessage assistantMessage) { + System.out.println("Received: " + assistantMessage.getMessage().getContent().stream() + .findFirst() + .map(content -> content.getText()) + .orElse("")); + + // Interrupt the session after receiving the first message + try { + Optional interruptResult = session.interrupt(); + System.out.println(interruptResult.map(s -> s ? "Interrupt successful" : "Interrupt error") + .orElse("Interrupt unknown")); + } catch (SessionControlException e) { + System.err.println("Interrupt error: " + e.getMessage()); + } + } + }); +} +``` + +#### Set Model Function + +The `setModel()` function allows you to dynamically change the AI model during an active session. This is useful when you want to switch between different models (e.g., from a faster model for simple queries to a more powerful model for complex analysis) without creating a new session: + +- **Method signature**: `public Optional setModel(String modelName) throws SessionControlException` +- **Purpose**: Changes the AI model being used for the current and subsequent prompts in the session +- **Parameters**: `modelName` - the name of the model to switch to (e.g., "qwen-max", "qwen-plus", etc.) +- **Return value**: An `Optional` that indicates whether the model change was successful (true if successful, empty if the request was sent asynchronously) + +Example of changing the model during a session: + +```java +import com.alibaba.qwen.code.cli.session.Session; +import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; + +try (Session session = QwenCodeCli.newSession()) { + // Switch to a specific model + Optional modelChangeResult = session.setModel("qwen3-coder-flash"); + System.out.println(modelChangeResult.map(s -> s ? "setModel success" : "setModel error") + .orElse("setModel unknown")); + + // Use the model for a prompt + session.sendPrompt("hello world", new SessionEventSimpleConsumers()); + + // Switch to another model + Optional modelChangeResult2 = session.setModel("qwen3-coder-plus"); + System.out.println(modelChangeResult2.map(s -> s ? "setModel success" : "setModel error") + .orElse("setModel unknown")); + + // Use the new model for another prompt + session.sendPrompt("list files in the current directory", new SessionEventSimpleConsumers()); +} +``` + +#### Set Permission Mode Function + +The `setPermissionMode()` function allows you to dynamically change the permission mode during an active session. This is useful when you want to adjust the level of access granted to tools (e.g., switching from a restrictive mode to allow more operations) without creating a new session: + +- **Method signature**: `public Optional setPermissionMode(PermissionMode permissionMode) throws SessionControlException` +- **Purpose**: Changes the permission mode governing tool execution for the current and subsequent prompts in the session +- **Parameters**: `permissionMode` - the permission mode to switch to (e.g., `PermissionMode.DEFAULT`, `PermissionMode.PLAN`, `PermissionMode.AUTO_EDIT`, `PermissionMode.YOLO`) +- **Return value**: An `Optional` that indicates whether the permission mode change was successful (true if successful, empty if the request was sent asynchronously) + +Example of changing the permission mode during a session: + +```java +import com.alibaba.qwen.code.cli.session.Session; +import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; +import com.alibaba.qwen.code.cli.protocol.data.PermissionMode; + +try (Session session = QwenCodeCli.newSession()) { + // Switch to a permissive mode + Optional permissionChangeResult = session.setPermissionMode(PermissionMode.YOLO); + System.out.println(permissionChangeResult.map(s -> s ? "setPermissionMode success" : "setPermissionMode error") + .orElse("setPermissionMode unknown")); + + // Use the session with the new permission mode + session.sendPrompt("in the dir src/test/temp/, create file empty file test.touch", new SessionEventSimpleConsumers()); + + // Switch to another permission mode + Optional permissionChangeResult2 = session.setPermissionMode(PermissionMode.PLAN); + System.out.println(permissionChangeResult2.map(s -> s ? "setPermissionMode success" : "setPermissionMode error") + .orElse("setPermissionMode unknown")); + + // Use the session with the new permission mode + session.sendPrompt("rename test.touch to test_rename.touch", new SessionEventSimpleConsumers()); +} +``` + +### Timeout Configuration + +The timeout configuration allows you to control how long the SDK waits for responses from the CLI before timing out. There are two levels of timeout configuration: + +- **Transport-level timeouts**: Configured via `TransportOptions` + - `turnTimeout`: Time to wait for a complete turn of conversation (default: 60 seconds) + - `messageTimeout`: Time to wait for individual messages within a turn (default: 60 seconds) + +- **Event-level timeouts**: Configured via `SessionEventConsumers` interface with callback methods for specific message types: + - `onSystemMessageTimeout`: Timeout for processing system messages + - `onResultMessageTimeout`: Timeout for processing result messages + - `onAssistantMessageTimeout`: Timeout for processing assistant messages + - `onPartialAssistantMessageTimeout`: Timeout for processing partial assistant messages + - `onUserMessageTimeout`: Timeout for processing user messages + - `onOtherMessageTimeout`: Timeout for processing other types of messages + - `onControlResponseTimeout`: Timeout for processing control responses + - `onControlRequestTimeout`: Timeout for processing control requests + - `onPermissionRequestTimeout`: Timeout for processing permission requests + +To customize timeout settings: + +```java +import com.alibaba.qwen.code.cli.utils.Timeout; +import java.util.concurrent.TimeUnit; +import com.alibaba.qwen.code.cli.session.event.SessionEventConsumers; +import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; + +// Configure transport-level timeouts +TransportOptions options = new TransportOptions() + .setTurnTimeout(new Timeout(120L, TimeUnit.SECONDS)) // Timeout for a complete turn of conversation + .setMessageTimeout(new Timeout(90L, TimeUnit.SECONDS)); // Timeout for individual messages within a turn + +Session session = QwenCodeCli.newSession(options); + +// Configure event-level timeouts using SessionEventConsumers +SessionEventConsumers eventConsumers = new SessionEventSimpleConsumers() { + @Override + public Timeout onSystemMessageTimeout(Session session) { + return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing system messages + } + + @Override + public Timeout onResultMessageTimeout(Session session) { + return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing result messages + } + + @Override + public Timeout onAssistantMessageTimeout(Session session) { + return new Timeout(90L, TimeUnit.SECONDS); // Timeout for processing assistant messages + } + + @Override + public Timeout onControlResponseTimeout(Session session) { + return new Timeout(45L, TimeUnit.SECONDS); // Timeout for processing control responses + } + + @Override + public Timeout onPermissionRequestTimeout(Session session) { + return new Timeout(30L, TimeUnit.SECONDS); // Timeout for processing permission requests + } + + @Override + public Timeout onOtherMessageTimeout(Session session) { + return new Timeout(35L, TimeUnit.SECONDS); // Timeout for processing other messages + } +}.setDefaultEventTimeout(new Timeout(90L, TimeUnit.SECONDS)); // Default timeout for all events +session.sendPrompt("hello world", sessionEventConsumers); +``` + +### Thread Pool Configuration + +The SDK uses a thread pool for managing concurrent operations. The default thread pool configuration is defined in the `ThreadPoolConfig` class: + +- **Core Pool Size**: 10 threads +- **Maximum Pool Size**: 30 threads +- **Keep-Alive Time**: 60 seconds +- **Queue Capacity**: 300 tasks (using LinkedBlockingQueue) +- **Thread Naming**: "qwen_code_cli-pool-{number}" +- **Daemon Threads**: false +- **Rejected Execution Handler**: CallerRunsPolicy (executes the task on the calling thread when the pool is full) + +The thread pool can be customized in two ways: + +1. **Using a custom supplier**: Provide a custom `Supplier` through the `ThreadPoolConfig.setExecutorSupplier()` method. If no custom supplier is provided, or if the supplier throws an exception, the SDK will fall back to the default thread pool configuration. + +2. **Modifying properties after getting the default executor**: You can retrieve the default executor using `ThreadPoolConfig.getDefaultExecutor()` and then modify its properties such as core pool size, maximum pool size, and keep-alive time. + +Example of custom thread pool configuration using a supplier: + +```java +import com.alibaba.qwen.code.cli.utils.ThreadPoolConfig; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.function.Supplier; + +// Set a custom thread pool supplier +ThreadPoolConfig.setExecutorSupplier(new Supplier() { + @Override + public ThreadPoolExecutor get() { + return (ThreadPoolExecutor) Executors.newFixedThreadPool(20); + } +}); + +// The SDK will now use the custom thread pool for all operations +Session session = QwenCodeCli.newSession(); +``` + +Example of modifying properties after getting the default executor: + +```java +import com.alibaba.qwen.code.cli.utils.ThreadPoolConfig; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +// Get the default executor and modify its properties +ThreadPoolExecutor executor = ThreadPoolConfig.getDefaultExecutor(); + +// Modify the core pool size +executor.setCorePoolSize(15); + +// Modify the maximum pool size +executor.setMaximumPoolSize(40); + +// Modify the keep-alive time +executor.setKeepAliveTime(120, TimeUnit.SECONDS); + +// The SDK will now use the modified executor for all operations +Session session = QwenCodeCli.newSession(); +``` + +Note that when modifying the default executor directly, you're changing the properties of the shared static instance that will affect all subsequent operations in the application. If you need different configurations for different parts of your application, using the supplier approach is recommended. + +### Error Handling + +The SDK provides specific exception types for different error scenarios: + +- `SessionControlException`: Thrown when there's an issue with session control (creation, initialization, etc.) +- `SessionSendPromptException`: Thrown when there's an issue sending a prompt or receiving a response +- `SessionClosedException`: Thrown when attempting to use a closed session + +Example of comprehensive error handling: + +```java +import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; + +try (Session session = QwenCodeCli.newSession()) { + try { + List result = session.sendPrompt("Process this request", new SessionEventSimpleConsumers()); + result.forEach(System.out::println); + } catch (SessionSendPromptException e) { + System.err.println("Error sending prompt: " + e.getMessage()); + e.printStackTrace(); + } +} catch (SessionControlException e) { + System.err.println("Error controlling session: " + e.getMessage()); + e.printStackTrace(); +} catch (Exception e) { + System.err.println("Unexpected error: " + e.getMessage()); + e.printStackTrace(); +} +``` + +## FAQ / Troubleshooting + +### Q: Do I need to install the Qwen CLI separately? + +A: No, from v0.1.1, the CLI is bundled with the SDK, so no standalone CLI installation is needed. + +### Q: What Java versions are supported? + +A: The SDK requires Java 1.8 or higher. + +### Q: How do I handle long-running requests? + +A: The SDK includes timeout utilities. You can configure timeouts using the `Timeout` class in `TransportOptions`. + +### Q: Why are some tools not executing? + +A: This is likely due to permission modes. Check your permission mode settings and consider using `allowedTools` to pre-approve certain tools. + +### Q: How do I resume a previous session? + +A: Use the `setResumeSessionId()` method in `TransportOptions` to resume a previous session. + +### Q: Can I customize the environment for the CLI process? + +A: Yes, use the `setEnv()` method in `TransportOptions` to pass environment variables to the CLI process. + +### Q: What happens if the CLI process crashes? + +A: The SDK will throw appropriate exceptions. Make sure to handle `SessionControlException` and implement retry logic if needed. + +## License + +Apache-2.0 - see [LICENSE](./LICENSE) for details. diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCodeCli.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCodeCli.java index 591552fc3..1e306dc15 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCodeCli.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCodeCli.java @@ -16,7 +16,12 @@ import com.alibaba.qwen.code.cli.transport.process.ProcessTransport; import com.alibaba.qwen.code.cli.utils.MyConcurrentUtils; import com.alibaba.qwen.code.cli.utils.Timeout; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + public class QwenCodeCli { + private static final Logger log = LoggerFactory.getLogger(QwenCodeCli.class); + public static List simpleQuery(String prompt) { final List response = new ArrayList<>(); MyConcurrentUtils.runAndWait(() -> simpleQuery(prompt, response::add), Timeout.TIMEOUT_30_MINUTES); @@ -24,7 +29,7 @@ public class QwenCodeCli { } public static void simpleQuery(String prompt, Consumer messageConsumer) { - Session session = newSessionWithProcessTransport(new TransportOptions()); + Session session = newSession(new TransportOptions()); try { session.sendPrompt(prompt, new SessionEventSimpleConsumers() { @Override @@ -42,16 +47,20 @@ public class QwenCodeCli { }.setDefaultPermissionOperation(Operation.allow)); } catch (Exception e) { throw new RuntimeException("sendPrompt error!", e); - } - - try { - session.close(); - } catch (Exception e) { - throw new RuntimeException("close Session error!", e); + } finally { + try { + session.close(); + } catch (Exception e) { + log.error("close session error!", e); + } } } - public static Session newSessionWithProcessTransport(TransportOptions transportOptions) { + public static Session newSession() { + return newSession(new TransportOptions()); + } + + public static Session newSession(TransportOptions transportOptions) { Transport transport; try { transport = new ProcessTransport(transportOptions); diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/ThreadPoolConfig.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/ThreadPoolConfig.java index 59b7ef4cb..9837ef798 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/ThreadPoolConfig.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/ThreadPoolConfig.java @@ -31,7 +31,11 @@ public class ThreadPoolConfig { ThreadPoolConfig.executorSupplier = executorSupplier; } - public static ExecutorService getExecutor() { + public static ThreadPoolExecutor getDefaultExecutor() { + return defaultExecutor; + } + + static ExecutorService getExecutor() { return Optional.ofNullable(executorSupplier).map(s -> { try { return s.get(); diff --git a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java index 8cdfa03c1..9292d83fe 100644 --- a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java +++ b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java @@ -2,8 +2,10 @@ package com.alibaba.qwen.code.cli.session; import java.io.IOException; import java.util.List; +import java.util.concurrent.TimeUnit; import com.alibaba.fastjson2.JSON; +import com.alibaba.qwen.code.cli.QwenCodeCli; import com.alibaba.qwen.code.cli.protocol.data.AssistantContent; import com.alibaba.qwen.code.cli.protocol.data.PermissionMode; import com.alibaba.qwen.code.cli.protocol.data.behavior.Allow; @@ -19,9 +21,8 @@ import com.alibaba.qwen.code.cli.session.event.SessionEventConsumers; import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; import com.alibaba.qwen.code.cli.session.exception.SessionControlException; import com.alibaba.qwen.code.cli.session.exception.SessionSendPromptException; -import com.alibaba.qwen.code.cli.transport.Transport; import com.alibaba.qwen.code.cli.transport.TransportOptions; -import com.alibaba.qwen.code.cli.transport.process.ProcessTransport; +import com.alibaba.qwen.code.cli.utils.Timeout; import org.apache.commons.lang3.StringUtils; import org.junit.jupiter.api.Test; @@ -34,8 +35,7 @@ class SessionTest { @Test void partialSendPromptSuccessfully() throws IOException, SessionControlException, SessionSendPromptException { - Transport transport = new ProcessTransport(new TransportOptions().setIncludePartialMessages(true)); - Session session = new Session(transport); + Session session = QwenCodeCli.newSession(new TransportOptions().setIncludePartialMessages(true)); session.sendPrompt("in the dir src/test/temp/, create file empty file test.touch", new SessionEventSimpleConsumers() { @Override public void onAssistantMessageIncludePartial(Session session, List assistantContents, @@ -47,8 +47,7 @@ class SessionTest { @Test void setPermissionModeSuccessfully() throws IOException, SessionControlException, SessionSendPromptException { - Transport transport = new ProcessTransport(); - Session session = new Session(transport); + Session session = QwenCodeCli.newSession(new TransportOptions()); log.info(session.setPermissionMode(PermissionMode.YOLO).map(s -> s ? "setPermissionMode 1 success" : "setPermissionMode 1 error") .orElse("setPermissionMode 1 unknown")); @@ -74,8 +73,7 @@ class SessionTest { @Test void sendPromptAndSetModelSuccessfully() throws IOException, SessionControlException, SessionSendPromptException { - Transport transport = new ProcessTransport(); - Session session = new Session(transport); + Session session = QwenCodeCli.newSession(new TransportOptions()); log.info(session.setModel("qwen3-coder-flash").map(s -> s ? "setModel 1 success" : "setModel 1 error").orElse("setModel 1 unknown")); writeSplitLine("setModel 1 end"); @@ -100,10 +98,10 @@ class SessionTest { @Test void sendPromptAndInterruptContinueSuccessfully() throws IOException, SessionControlException, SessionSendPromptException { - Transport transport = new ProcessTransport(); - Session session = new Session(transport); + Session session = QwenCodeCli.newSession(); SessionEventConsumers sessionEventConsumers = new SessionEventSimpleConsumers() { + @Override public void onSystemMessage(Session session, SDKSystemMessage systemMessage) { log.info("systemMessage: {}", systemMessage); @@ -133,7 +131,12 @@ class SessionTest { public void onOtherMessage(Session session, String message) { log.info("otherMessage: {}", message); } - }; + + @Override + public Timeout onPermissionRequestTimeout(Session session) { + return Timeout.TIMEOUT_30_MINUTES; + } + }.setDefaultEventTimeout(new Timeout(90L, TimeUnit.SECONDS)); session.sendPrompt("查看下当前目录有多少个文件", sessionEventConsumers); writeSplitLine("prompt 1 end"); diff --git a/packages/sdk-java/todo b/packages/sdk-java/todo deleted file mode 100644 index 656489715..000000000 --- a/packages/sdk-java/todo +++ /dev/null @@ -1,6 +0,0 @@ -1、event timeout -2、mcp servers -3、errorHandle -4、review QwenCli -https://github.com/QwenLM/qwen-code/tree/main/packages/sdk-typescript#custom-permission-handler - From 6ff437671e2d51b9dc6e5b04f4a1ab3516cc9a5e Mon Sep 17 00:00:00 2001 From: skyfire Date: Wed, 31 Dec 2025 23:26:20 +0800 Subject: [PATCH 038/142] for README.md --- packages/sdk-java/README.md | 3 +-- packages/sdk-java/pom.xml | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/sdk-java/README.md b/packages/sdk-java/README.md index 0d7030658..65dbfc7ae 100644 --- a/packages/sdk-java/README.md +++ b/packages/sdk-java/README.md @@ -26,8 +26,7 @@ implementation 'com.alibaba:qwencode-sdk-java:0.0.1' - Java >= 1.8 - Maven >= 3.6.0 (for building from source) - -> From v0.1.1, the CLI is bundled with the SDK. So no standalone CLI installation is needed. +- Qwen Code CLI: The SDK communicates with the Qwen Code CLI executable. By default, the SDK looks for a `qwen` command in the system PATH. ## Quick Start diff --git a/packages/sdk-java/pom.xml b/packages/sdk-java/pom.xml index 346731815..33de7f073 100644 --- a/packages/sdk-java/pom.xml +++ b/packages/sdk-java/pom.xml @@ -5,7 +5,7 @@ com.alibaba qwencode-sdk-java jar - 0.0.1 + 0.0.1-SNAPSHOT qwencode-sdk-java https://maven.apache.org From 6a62167f79a5e6e50fa525d3f626c107682aca09 Mon Sep 17 00:00:00 2001 From: skyfire Date: Wed, 31 Dec 2025 23:36:17 +0800 Subject: [PATCH 039/142] for README.md --- packages/sdk-java/README.md | 492 ++++++++++++++++++++---------------- packages/sdk-java/pom.xml | 4 +- 2 files changed, 283 insertions(+), 213 deletions(-) diff --git a/packages/sdk-java/README.md b/packages/sdk-java/README.md index 65dbfc7ae..149d634c4 100644 --- a/packages/sdk-java/README.md +++ b/packages/sdk-java/README.md @@ -135,6 +135,7 @@ Event processing is subject to the timeout settings configured in `TransportOpti Example of custom event handling: ```java +import com.alibaba.qwen.code.cli.QwenCodeCli; import com.alibaba.qwen.code.cli.session.Session; import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKAssistantMessage; @@ -152,112 +153,116 @@ import com.alibaba.qwen.code.cli.utils.Timeout; import java.util.List; import java.util.concurrent.TimeUnit; -Session session = QwenCodeCli.newSession(); -SessionEventSimpleConsumers eventConsumers = new SessionEventSimpleConsumers() { - @Override - public void onAssistantMessage(Session session, SDKAssistantMessage assistantMessage) { - String message = assistantMessage.getMessage().getContent().stream() - .findFirst() - .map(content -> content.getText()) - .orElse(""); - System.out.println("Assistant: " + message); - } +public class CustomEventHandlingExample { + public static void main(String[] args) { + Session session = QwenCodeCli.newSession(); + SessionEventSimpleConsumers eventConsumers = new SessionEventSimpleConsumers() { + @Override + public void onAssistantMessage(Session session, SDKAssistantMessage assistantMessage) { + String message = assistantMessage.getMessage().getContent().stream() + .findFirst() + .map(content -> content.getText()) + .orElse(""); + System.out.println("Assistant: " + message); + } - @Override - public void onPartialAssistantMessage(Session session, SDKPartialAssistantMessage partialAssistantMessage) { - System.out.println("Partial assistant message: " + partialAssistantMessage); - } + @Override + public void onPartialAssistantMessage(Session session, SDKPartialAssistantMessage partialAssistantMessage) { + System.out.println("Partial assistant message: " + partialAssistantMessage); + } - public void onAssistantMessageIncludePartial(Session session, List assistantContents, - AssistantMessageOutputType assistantMessageOutputType) { - System.out.println("Assistant content (type: " + assistantMessageOutputType + "): " + assistantContents); - } + public void onAssistantMessageIncludePartial(Session session, List assistantContents, + AssistantMessageOutputType assistantMessageOutputType) { + System.out.println("Assistant content (type: " + assistantMessageOutputType + "): " + assistantContents); + } - @Override - public void onSystemMessage(Session session, SDKSystemMessage systemMessage) { - System.out.println("System: " + systemMessage.getMessage()); - } + @Override + public void onSystemMessage(Session session, SDKSystemMessage systemMessage) { + System.out.println("System: " + systemMessage.getMessage()); + } - @Override - public void onResultMessage(Session session, SDKResultMessage resultMessage) { - System.out.println("Result: " + resultMessage.getMessage()); - } + @Override + public void onResultMessage(Session session, SDKResultMessage resultMessage) { + System.out.println("Result: " + resultMessage.getMessage()); + } - @Override - public void onUserMessage(Session session, SDKUserMessage userMessage) { - System.out.println("User: " + userMessage.getMessage()); - } + @Override + public void onUserMessage(Session session, SDKUserMessage userMessage) { + System.out.println("User: " + userMessage.getMessage()); + } - @Override - public void onOtherMessage(Session session, String message) { - System.out.println("Other: " + message); - } + @Override + public void onOtherMessage(Session session, String message) { + System.out.println("Other: " + message); + } - @Override - public void onControlResponse(Session session, CLIControlResponse cliControlResponse) { - System.out.println("Control response: " + cliControlResponse); - } + @Override + public void onControlResponse(Session session, CLIControlResponse cliControlResponse) { + System.out.println("Control response: " + cliControlResponse); + } - @Override - public CLIControlResponse onControlRequest(Session session, CLIControlRequest cliControlRequest) { - System.out.println("Control request: " + cliControlRequest); - return new CLIControlResponse<>(); // Return appropriate response - } + @Override + public CLIControlResponse onControlRequest(Session session, CLIControlRequest cliControlRequest) { + System.out.println("Control request: " + cliControlRequest); + return new CLIControlResponse<>(); // Return appropriate response + } - @Override - public Behavior onPermissionRequest(Session session, CLIControlRequest permissionRequest) { - System.out.println("Permission request: " + permissionRequest.getRequest().getInput()); - return new com.alibaba.qwen.code.cli.protocol.data.behavior.Allow() - .setUpdatedInput(permissionRequest.getRequest().getInput()); // Allow by default - } + @Override + public Behavior onPermissionRequest(Session session, CLIControlRequest permissionRequest) { + System.out.println("Permission request: " + permissionRequest.getRequest().getInput()); + return new com.alibaba.qwen.code.cli.protocol.data.behavior.Allow() + .setUpdatedInput(permissionRequest.getRequest().getInput()); // Allow by default + } - @Override - public Timeout onAssistantMessageTimeout(Session session) { - return new Timeout(90L, TimeUnit.SECONDS); // Timeout for processing assistant messages - } + @Override + public Timeout onAssistantMessageTimeout(Session session) { + return new Timeout(90L, TimeUnit.SECONDS); // Timeout for processing assistant messages + } - @Override - public Timeout onSystemMessageTimeout(Session session) { - return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing system messages - } + @Override + public Timeout onSystemMessageTimeout(Session session) { + return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing system messages + } - @Override - public Timeout onResultMessageTimeout(Session session) { - return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing result messages - } + @Override + public Timeout onResultMessageTimeout(Session session) { + return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing result messages + } - @Override - public Timeout onPartialAssistantMessageTimeout(Session session) { - return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing partial assistant messages - } + @Override + public Timeout onPartialAssistantMessageTimeout(Session session) { + return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing partial assistant messages + } - @Override - public Timeout onUserMessageTimeout(Session session) { - return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing user messages - } + @Override + public Timeout onUserMessageTimeout(Session session) { + return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing user messages + } - @Override - public Timeout onOtherMessageTimeout(Session session) { - return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing other messages - } + @Override + public Timeout onOtherMessageTimeout(Session session) { + return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing other messages + } - @Override - public Timeout onControlResponseTimeout(Session session) { - return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing control responses - } + @Override + public Timeout onControlResponseTimeout(Session session) { + return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing control responses + } - @Override - public Timeout onControlRequestTimeout(Session session) { - return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing control requests - } + @Override + public Timeout onControlRequestTimeout(Session session) { + return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing control requests + } - @Override - public Timeout onPermissionRequestTimeout(Session session) { - return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing permission requests - } -}.setDefaultEventTimeout(new Timeout(60L, TimeUnit.SECONDS)); // Default timeout for all events + @Override + public Timeout onPermissionRequestTimeout(Session session) { + return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing permission requests + } + }.setDefaultEventTimeout(new Timeout(60L, TimeUnit.SECONDS)); // Default timeout for all events -session.sendPrompt("Example prompt", eventConsumers); + session.sendPrompt("Example prompt", eventConsumers); + } +} ``` ### Permission Modes @@ -272,8 +277,17 @@ The SDK supports different permission modes for controlling tool execution: To set a permission mode: ```java -Session session = QwenCodeCli.newSession(new TransportOptions().setPermissionMode(PermissionMode.YOLO)); -session.setPermissionMode(PermissionMode.PLAN); +import com.alibaba.qwen.code.cli.QwenCodeCli; +import com.alibaba.qwen.code.cli.session.Session; +import com.alibaba.qwen.code.cli.transport.TransportOptions; +import com.alibaba.qwen.code.cli.protocol.data.PermissionMode; + +public class PermissionModeExample { + public static void main(String[] args) { + Session session = QwenCodeCli.newSession(new TransportOptions().setPermissionMode(PermissionMode.YOLO)); + session.setPermissionMode(PermissionMode.PLAN); + } +} ``` ### Session Control @@ -291,17 +305,26 @@ The SDK provides fine-grained control over session lifecycle and behavior: Example of session control: ```java +import com.alibaba.qwen.code.cli.QwenCodeCli; +import com.alibaba.qwen.code.cli.session.Session; import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; +import com.alibaba.qwen.code.cli.transport.TransportOptions; +import com.alibaba.qwen.code.cli.protocol.data.PermissionMode; +import java.util.List; -TransportOptions options = new TransportOptions() - .setModel("qwen-max") - .setPermissionMode(PermissionMode.AUTO_EDIT); +public class SessionControlExample { + public static void main(String[] args) { + TransportOptions options = new TransportOptions() + .setModel("qwen-max") + .setPermissionMode(PermissionMode.AUTO_EDIT); -try (Session session = QwenCodeCli.newSession(options)) { - // Use the session with default event consumers - List result = session.sendPrompt("Explain how to use the SDK", new SessionEventSimpleConsumers()); - result.forEach(System.out::println); -} // Session automatically closes when exiting try-with-resources + try (Session session = QwenCodeCli.newSession(options)) { + // Use the session with default event consumers + List result = session.sendPrompt("Explain how to use the SDK", new SessionEventSimpleConsumers()); + result.forEach(System.out::println); + } // Session automatically closes when exiting try-with-resources + } +} ``` #### Interrupt Function @@ -315,30 +338,36 @@ The `interrupt()` function allows you to interrupt a currently running prompt. T Example of interrupting a running prompt: ```java +import com.alibaba.qwen.code.cli.QwenCodeCli; import com.alibaba.qwen.code.cli.session.Session; import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKAssistantMessage; import com.alibaba.qwen.code.cli.session.exception.SessionControlException; +import java.util.Optional; -try (Session session = QwenCodeCli.newSession()) { - session.sendPrompt("Analyze this large codebase...", new SessionEventSimpleConsumers() { - @Override - public void onAssistantMessage(Session session, SDKAssistantMessage assistantMessage) { - System.out.println("Received: " + assistantMessage.getMessage().getContent().stream() - .findFirst() - .map(content -> content.getText()) - .orElse("")); +public class InterruptExample { + public static void main(String[] args) { + try (Session session = QwenCodeCli.newSession()) { + session.sendPrompt("Analyze this large codebase...", new SessionEventSimpleConsumers() { + @Override + public void onAssistantMessage(Session session, SDKAssistantMessage assistantMessage) { + System.out.println("Received: " + assistantMessage.getMessage().getContent().stream() + .findFirst() + .map(content -> content.getText()) + .orElse("")); - // Interrupt the session after receiving the first message - try { - Optional interruptResult = session.interrupt(); - System.out.println(interruptResult.map(s -> s ? "Interrupt successful" : "Interrupt error") - .orElse("Interrupt unknown")); - } catch (SessionControlException e) { - System.err.println("Interrupt error: " + e.getMessage()); - } + // Interrupt the session after receiving the first message + try { + Optional interruptResult = session.interrupt(); + System.out.println(interruptResult.map(s -> s ? "Interrupt successful" : "Interrupt error") + .orElse("Interrupt unknown")); + } catch (SessionControlException e) { + System.err.println("Interrupt error: " + e.getMessage()); + } + } + }); } - }); + } } ``` @@ -354,25 +383,31 @@ The `setModel()` function allows you to dynamically change the AI model during a Example of changing the model during a session: ```java +import com.alibaba.qwen.code.cli.QwenCodeCli; import com.alibaba.qwen.code.cli.session.Session; import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; +import java.util.Optional; -try (Session session = QwenCodeCli.newSession()) { - // Switch to a specific model - Optional modelChangeResult = session.setModel("qwen3-coder-flash"); - System.out.println(modelChangeResult.map(s -> s ? "setModel success" : "setModel error") - .orElse("setModel unknown")); +public class SetModelExample { + public static void main(String[] args) { + try (Session session = QwenCodeCli.newSession()) { + // Switch to a specific model + Optional modelChangeResult = session.setModel("qwen3-coder-flash"); + System.out.println(modelChangeResult.map(s -> s ? "setModel success" : "setModel error") + .orElse("setModel unknown")); - // Use the model for a prompt - session.sendPrompt("hello world", new SessionEventSimpleConsumers()); + // Use the model for a prompt + session.sendPrompt("hello world", new SessionEventSimpleConsumers()); - // Switch to another model - Optional modelChangeResult2 = session.setModel("qwen3-coder-plus"); - System.out.println(modelChangeResult2.map(s -> s ? "setModel success" : "setModel error") - .orElse("setModel unknown")); + // Switch to another model + Optional modelChangeResult2 = session.setModel("qwen3-coder-plus"); + System.out.println(modelChangeResult2.map(s -> s ? "setModel success" : "setModel error") + .orElse("setModel unknown")); - // Use the new model for another prompt - session.sendPrompt("list files in the current directory", new SessionEventSimpleConsumers()); + // Use the new model for another prompt + session.sendPrompt("list files in the current directory", new SessionEventSimpleConsumers()); + } + } } ``` @@ -388,26 +423,32 @@ The `setPermissionMode()` function allows you to dynamically change the permissi Example of changing the permission mode during a session: ```java +import com.alibaba.qwen.code.cli.QwenCodeCli; import com.alibaba.qwen.code.cli.session.Session; import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; import com.alibaba.qwen.code.cli.protocol.data.PermissionMode; +import java.util.Optional; -try (Session session = QwenCodeCli.newSession()) { - // Switch to a permissive mode - Optional permissionChangeResult = session.setPermissionMode(PermissionMode.YOLO); - System.out.println(permissionChangeResult.map(s -> s ? "setPermissionMode success" : "setPermissionMode error") - .orElse("setPermissionMode unknown")); +public class SetPermissionModeExample { + public static void main(String[] args) { + try (Session session = QwenCodeCli.newSession()) { + // Switch to a permissive mode + Optional permissionChangeResult = session.setPermissionMode(PermissionMode.YOLO); + System.out.println(permissionChangeResult.map(s -> s ? "setPermissionMode success" : "setPermissionMode error") + .orElse("setPermissionMode unknown")); - // Use the session with the new permission mode - session.sendPrompt("in the dir src/test/temp/, create file empty file test.touch", new SessionEventSimpleConsumers()); + // Use the session with the new permission mode + session.sendPrompt("in the dir src/test/temp/, create file empty file test.touch", new SessionEventSimpleConsumers()); - // Switch to another permission mode - Optional permissionChangeResult2 = session.setPermissionMode(PermissionMode.PLAN); - System.out.println(permissionChangeResult2.map(s -> s ? "setPermissionMode success" : "setPermissionMode error") - .orElse("setPermissionMode unknown")); + // Switch to another permission mode + Optional permissionChangeResult2 = session.setPermissionMode(PermissionMode.PLAN); + System.out.println(permissionChangeResult2.map(s -> s ? "setPermissionMode success" : "setPermissionMode error") + .orElse("setPermissionMode unknown")); - // Use the session with the new permission mode - session.sendPrompt("rename test.touch to test_rename.touch", new SessionEventSimpleConsumers()); + // Use the session with the new permission mode + session.sendPrompt("rename test.touch to test_rename.touch", new SessionEventSimpleConsumers()); + } + } } ``` @@ -433,51 +474,59 @@ The timeout configuration allows you to control how long the SDK waits for respo To customize timeout settings: ```java -import com.alibaba.qwen.code.cli.utils.Timeout; -import java.util.concurrent.TimeUnit; +import com.alibaba.qwen.code.cli.QwenCodeCli; +import com.alibaba.qwen.code.cli.session.Session; import com.alibaba.qwen.code.cli.session.event.SessionEventConsumers; import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; +import com.alibaba.qwen.code.cli.transport.TransportOptions; +import com.alibaba.qwen.code.cli.utils.Timeout; +import java.util.List; +import java.util.concurrent.TimeUnit; -// Configure transport-level timeouts -TransportOptions options = new TransportOptions() - .setTurnTimeout(new Timeout(120L, TimeUnit.SECONDS)) // Timeout for a complete turn of conversation - .setMessageTimeout(new Timeout(90L, TimeUnit.SECONDS)); // Timeout for individual messages within a turn +public class TimeoutConfigurationExample { + public static void main(String[] args) { + // Configure transport-level timeouts + TransportOptions options = new TransportOptions() + .setTurnTimeout(new Timeout(120L, TimeUnit.SECONDS)) // Timeout for a complete turn of conversation + .setMessageTimeout(new Timeout(90L, TimeUnit.SECONDS)); // Timeout for individual messages within a turn -Session session = QwenCodeCli.newSession(options); + Session session = QwenCodeCli.newSession(options); -// Configure event-level timeouts using SessionEventConsumers -SessionEventConsumers eventConsumers = new SessionEventSimpleConsumers() { - @Override - public Timeout onSystemMessageTimeout(Session session) { - return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing system messages + // Configure event-level timeouts using SessionEventConsumers + SessionEventConsumers eventConsumers = new SessionEventSimpleConsumers() { + @Override + public Timeout onSystemMessageTimeout(Session session) { + return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing system messages + } + + @Override + public Timeout onResultMessageTimeout(Session session) { + return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing result messages + } + + @Override + public Timeout onAssistantMessageTimeout(Session session) { + return new Timeout(90L, TimeUnit.SECONDS); // Timeout for processing assistant messages + } + + @Override + public Timeout onControlResponseTimeout(Session session) { + return new Timeout(45L, TimeUnit.SECONDS); // Timeout for processing control responses + } + + @Override + public Timeout onPermissionRequestTimeout(Session session) { + return new Timeout(30L, TimeUnit.SECONDS); // Timeout for processing permission requests + } + + @Override + public Timeout onOtherMessageTimeout(Session session) { + return new Timeout(35L, TimeUnit.SECONDS); // Timeout for processing other messages + } + }.setDefaultEventTimeout(new Timeout(90L, TimeUnit.SECONDS)); // Default timeout for all events + session.sendPrompt("hello world", eventConsumers); } - - @Override - public Timeout onResultMessageTimeout(Session session) { - return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing result messages - } - - @Override - public Timeout onAssistantMessageTimeout(Session session) { - return new Timeout(90L, TimeUnit.SECONDS); // Timeout for processing assistant messages - } - - @Override - public Timeout onControlResponseTimeout(Session session) { - return new Timeout(45L, TimeUnit.SECONDS); // Timeout for processing control responses - } - - @Override - public Timeout onPermissionRequestTimeout(Session session) { - return new Timeout(30L, TimeUnit.SECONDS); // Timeout for processing permission requests - } - - @Override - public Timeout onOtherMessageTimeout(Session session) { - return new Timeout(35L, TimeUnit.SECONDS); // Timeout for processing other messages - } -}.setDefaultEventTimeout(new Timeout(90L, TimeUnit.SECONDS)); // Default timeout for all events -session.sendPrompt("hello world", sessionEventConsumers); +} ``` ### Thread Pool Configuration @@ -501,44 +550,56 @@ The thread pool can be customized in two ways: Example of custom thread pool configuration using a supplier: ```java +import com.alibaba.qwen.code.cli.QwenCodeCli; +import com.alibaba.qwen.code.cli.session.Session; import com.alibaba.qwen.code.cli.utils.ThreadPoolConfig; import java.util.concurrent.Executors; import java.util.concurrent.ThreadPoolExecutor; import java.util.function.Supplier; -// Set a custom thread pool supplier -ThreadPoolConfig.setExecutorSupplier(new Supplier() { - @Override - public ThreadPoolExecutor get() { - return (ThreadPoolExecutor) Executors.newFixedThreadPool(20); - } -}); +public class ThreadPoolConfigurationExample { + public static void main(String[] args) { + // Set a custom thread pool supplier + ThreadPoolConfig.setExecutorSupplier(new Supplier() { + @Override + public ThreadPoolExecutor get() { + return (ThreadPoolExecutor) Executors.newFixedThreadPool(20); + } + }); -// The SDK will now use the custom thread pool for all operations -Session session = QwenCodeCli.newSession(); + // The SDK will now use the custom thread pool for all operations + Session session = QwenCodeCli.newSession(); + } +} ``` Example of modifying properties after getting the default executor: ```java +import com.alibaba.qwen.code.cli.QwenCodeCli; +import com.alibaba.qwen.code.cli.session.Session; import com.alibaba.qwen.code.cli.utils.ThreadPoolConfig; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; -// Get the default executor and modify its properties -ThreadPoolExecutor executor = ThreadPoolConfig.getDefaultExecutor(); +public class ModifyThreadPoolExample { + public static void main(String[] args) { + // Get the default executor and modify its properties + ThreadPoolExecutor executor = ThreadPoolConfig.getDefaultExecutor(); -// Modify the core pool size -executor.setCorePoolSize(15); + // Modify the core pool size + executor.setCorePoolSize(15); -// Modify the maximum pool size -executor.setMaximumPoolSize(40); + // Modify the maximum pool size + executor.setMaximumPoolSize(40); -// Modify the keep-alive time -executor.setKeepAliveTime(120, TimeUnit.SECONDS); + // Modify the keep-alive time + executor.setKeepAliveTime(120, TimeUnit.SECONDS); -// The SDK will now use the modified executor for all operations -Session session = QwenCodeCli.newSession(); + // The SDK will now use the modified executor for all operations + Session session = QwenCodeCli.newSession(); + } +} ``` Note that when modifying the default executor directly, you're changing the properties of the shared static instance that will affect all subsequent operations in the application. If you need different configurations for different parts of your application, using the supplier approach is recommended. @@ -554,22 +615,31 @@ The SDK provides specific exception types for different error scenarios: Example of comprehensive error handling: ```java +import com.alibaba.qwen.code.cli.QwenCodeCli; +import com.alibaba.qwen.code.cli.session.Session; import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; +import com.alibaba.qwen.code.cli.session.exception.SessionControlException; +import com.alibaba.qwen.code.cli.session.exception.SessionSendPromptException; +import java.util.List; -try (Session session = QwenCodeCli.newSession()) { - try { - List result = session.sendPrompt("Process this request", new SessionEventSimpleConsumers()); - result.forEach(System.out::println); - } catch (SessionSendPromptException e) { - System.err.println("Error sending prompt: " + e.getMessage()); - e.printStackTrace(); +public class ErrorHandlingExample { + public static void main(String[] args) { + try (Session session = QwenCodeCli.newSession()) { + try { + List result = session.sendPrompt("Process this request", new SessionEventSimpleConsumers()); + result.forEach(System.out::println); + } catch (SessionSendPromptException e) { + System.err.println("Error sending prompt: " + e.getMessage()); + e.printStackTrace(); + } + } catch (SessionControlException e) { + System.err.println("Error controlling session: " + e.getMessage()); + e.printStackTrace(); + } catch (Exception e) { + System.err.println("Unexpected error: " + e.getMessage()); + e.printStackTrace(); + } } -} catch (SessionControlException e) { - System.err.println("Error controlling session: " + e.getMessage()); - e.printStackTrace(); -} catch (Exception e) { - System.err.println("Unexpected error: " + e.getMessage()); - e.printStackTrace(); } ``` diff --git a/packages/sdk-java/pom.xml b/packages/sdk-java/pom.xml index 33de7f073..41b4b9c3a 100644 --- a/packages/sdk-java/pom.xml +++ b/packages/sdk-java/pom.xml @@ -3,10 +3,10 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd"> 4.0.0 com.alibaba - qwencode-sdk-java + qwencode-sdk jar 0.0.1-SNAPSHOT - qwencode-sdk-java + qwencode-sdk https://maven.apache.org From 73848d3867d96c640210f714c128dfead232b7bd Mon Sep 17 00:00:00 2001 From: skyfire Date: Thu, 1 Jan 2026 01:30:58 +0800 Subject: [PATCH 040/142] fix arg --- packages/sdk-java/README.md | 108 ++++++++++++++++++ packages/sdk-java/pom.xml | 80 ++++++++++++- .../process/TransportOptionsAdapter.java | 2 +- 3 files changed, 188 insertions(+), 2 deletions(-) diff --git a/packages/sdk-java/README.md b/packages/sdk-java/README.md index 149d634c4..0898b6d31 100644 --- a/packages/sdk-java/README.md +++ b/packages/sdk-java/README.md @@ -115,6 +115,8 @@ The Qwen Code Java SDK follows a layered architecture that abstracts the communi The architecture allows for both simple usage through static methods in `QwenCodeCli` and more advanced usage through direct `Session` management with custom event handlers and transport options. +## Usage + ### Session Event Consumers The SDK allows you to customize how events from the CLI are handled using event consumers. The `SessionEventConsumers` interface provides callbacks for different types of messages during a session: @@ -604,6 +606,112 @@ public class ModifyThreadPoolExample { Note that when modifying the default executor directly, you're changing the properties of the shared static instance that will affect all subsequent operations in the application. If you need different configurations for different parts of your application, using the supplier approach is recommended. +### Transport Options + +The `TransportOptions` class allows you to configure how the SDK communicates with the Qwen Code CLI. Below are all the available options with their descriptions: + +- **`pathToQwenExecutable`**: Specifies the path to the Qwen Code CLI executable. By default, the SDK looks for a `qwen` command in the system PATH. + - Type: `String` + - Example: `new TransportOptions().setPathToQwenExecutable("/usr/local/bin/qwen")` + +- **`cwd`**: Sets the working directory for the CLI process. This affects where the CLI operates and where relative paths are resolved from. + - Type: `String` + - Example: `new TransportOptions().setCwd("/path/to/project")` + +- **`model`**: Specifies the AI model to use for the session (e.g., "qwen-max", "qwen-plus", "qwen3-coder-flash", etc.). + - Type: `String` + - Example: `new TransportOptions().setModel("qwen3-coder-flash")` + +- **`permissionMode`**: Sets the permission mode that controls tool execution. Available modes are: + - `PermissionMode.DEFAULT`: Write tools are denied unless approved via `canUseTool` callback or in `allowedTools`. Read-only tools execute without confirmation. + - `PermissionMode.PLAN`: Blocks all write tools, instructing AI to present a plan first. + - `PermissionMode.AUTO_EDIT`: Auto-approve edit tools (edit, write_file) while other tools require confirmation. + - `PermissionMode.YOLO`: All tools execute automatically without confirmation. + - Type: `PermissionMode` + - Example: `new TransportOptions().setPermissionMode(PermissionMode.YOLO)` + +- **`env`**: A map of environment variables to pass to the CLI process. + - Type: `Map` + - Example: `new TransportOptions().setEnv(Map.of("ENV_VAR", "value"))` + +- **`maxSessionTurns`**: Limits the number of conversation turns in a session. + - Type: `Integer` + - Example: `new TransportOptions().setMaxSessionTurns(10)` + +- **`coreTools`**: Specifies a list of core tools that should be available to the AI. + - Type: `List` + - Example: `new TransportOptions().setCoreTools(List.of("read_file", "write_file"))` + +- **`excludeTools`**: Specifies a list of tools to exclude from being available to the AI. + - Type: `List` + - Example: `new TransportOptions().setExcludeTools(List.of("shell"))` + +- **`allowedTools`**: Specifies a list of tools that are pre-approved for use without additional confirmation. + - Type: `List` + - Example: `new TransportOptions().setAllowedTools(List.of("read_file", "list_directory"))` + +- **`authType`**: Specifies the authentication type to use for the session. + - Type: `String` + - Example: `new TransportOptions().setAuthType("bearer")` + +- **`includePartialMessages`**: When true, enables receiving partial messages during streaming responses. + - Type: `Boolean` + - Example: `new TransportOptions().setIncludePartialMessages(true)` + +- **`skillsEnable`**: Enables or disables skills functionality for the session. + - Type: `Boolean` + - Example: `new TransportOptions().setSkillsEnable(true)` + +- **`turnTimeout`**: Sets the timeout for a complete turn of conversation (default: 60 seconds). + - Type: `Timeout` + - Example: `new TransportOptions().setTurnTimeout(new Timeout(120L, TimeUnit.SECONDS))` + +- **`messageTimeout`**: Sets the timeout for individual messages within a turn (default: 60 seconds). + - Type: `Timeout` + - Example: `new TransportOptions().setMessageTimeout(new Timeout(90L, TimeUnit.SECONDS))` + +- **`resumeSessionId`**: Specifies the ID of a previous session to resume. + - Type: `String` + - Example: `new TransportOptions().setResumeSessionId("session-12345")` + +- **`otherOptions`**: Allows passing additional command-line options directly to the CLI. + - Type: `List` + - Example: `new TransportOptions().setOtherOptions(List.of("--verbose", "--no-cache"))` + +Example of using TransportOptions: + +```java +import com.alibaba.qwen.code.cli.QwenCodeCli; +import com.alibaba.qwen.code.cli.session.Session; +import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; +import com.alibaba.qwen.code.cli.transport.TransportOptions; +import com.alibaba.qwen.code.cli.protocol.data.PermissionMode; +import com.alibaba.qwen.code.cli.utils.Timeout; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +public class TransportOptionsExample { + public static void main(String[] args) { + TransportOptions options = new TransportOptions() + .setModel("qwen3-coder-flash") + .setPermissionMode(PermissionMode.AUTO_EDIT) + .setCwd("/path/to/working/directory") + .setEnv(Map.of("CUSTOM_VAR", "value")) + .setIncludePartialMessages(true) + .setTurnTimeout(new Timeout(120L, TimeUnit.SECONDS)) + .setMessageTimeout(new Timeout(90L, TimeUnit.SECONDS)) + .setAllowedTools(List.of("read_file", "write_file", "list_directory")); + + try (Session session = QwenCodeCli.newSession(options)) { + // Use the session with custom options + List result = session.sendPrompt("Analyze the current project", new SessionEventSimpleConsumers()); + result.forEach(System.out::println); + } + } +} +``` + ### Error Handling The SDK provides specific exception types for different error scenarios: diff --git a/packages/sdk-java/pom.xml b/packages/sdk-java/pom.xml index 41b4b9c3a..2bc86a988 100644 --- a/packages/sdk-java/pom.xml +++ b/packages/sdk-java/pom.xml @@ -5,7 +5,7 @@ com.alibaba qwencode-sdk jar - 0.0.1-SNAPSHOT + 0.0.1 qwencode-sdk https://maven.apache.org @@ -67,6 +67,34 @@ + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 1.8 + 1.8 + + + + compile-examples + compile + + compile + + + 1.8 + 1.8 + + com/alibaba/qwen/code/example/**/*.java + + + ${project.basedir}/src/example/java + + + + + org.apache.maven.plugins maven-checkstyle-plugin @@ -101,6 +129,56 @@ + + org.sonatype.central + central-publishing-maven-plugin + 0.9.0 + true + + central + + + + + org.apache.maven.plugins + maven-source-plugin + 2.2.1 + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 2.9.1 + + + attach-javadocs + + jar + + + + + + org.apache.maven.plugins + maven-gpg-plugin + 1.5 + + + sign-artifacts + verify + + sign + + + + diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java index a2226a499..ba9289181 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java @@ -42,7 +42,7 @@ class TransportOptionsAdapter { } if (transportOptions.getPermissionMode() != null) { - args.add("--permission-mode"); + args.add("--approval-mode"); args.add(transportOptions.getPermissionMode().getValue()); } From 7fdebe8fe60e3d464c7775ea55698e61d1986393 Mon Sep 17 00:00:00 2001 From: Tu Shaokun <2801884530@qq.com> Date: Thu, 1 Jan 2026 09:56:27 +0800 Subject: [PATCH 041/142] fix(cli): improve error message display for object errors Previously, when a tool execution failed with an error object (not an Error instance), getErrorMessage() would return '[object Object]', hiding useful error information from users. This change improves getErrorMessage() to: 1. Extract the 'message' property from error-like objects 2. JSON.stringify plain objects to show their full content 3. Fall back to String() only when JSON.stringify fails Fixes #1338 --- packages/cli/src/utils/errors.test.ts | 22 +++++++++++++++++++--- packages/cli/src/utils/errors.ts | 21 +++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/utils/errors.test.ts b/packages/cli/src/utils/errors.test.ts index 818c3ac39..2515fe402 100644 --- a/packages/cli/src/utils/errors.test.ts +++ b/packages/cli/src/utils/errors.test.ts @@ -105,9 +105,25 @@ describe('errors', () => { expect(getErrorMessage(undefined)).toBe('undefined'); }); - it('should handle objects', () => { - const obj = { message: 'test' }; - expect(getErrorMessage(obj)).toBe('[object Object]'); + it('should extract message from error-like objects', () => { + const obj = { message: 'test error message' }; + expect(getErrorMessage(obj)).toBe('test error message'); + }); + + it('should stringify plain objects without message property', () => { + const obj = { code: 500, details: 'internal error' }; + expect(getErrorMessage(obj)).toBe( + '{"code":500,"details":"internal error"}', + ); + }); + + it('should handle empty objects', () => { + expect(getErrorMessage({})).toBe('{}'); + }); + + it('should handle objects with non-string message property', () => { + const obj = { message: 123 }; + expect(getErrorMessage(obj)).toBe('{"message":123}'); }); }); diff --git a/packages/cli/src/utils/errors.ts b/packages/cli/src/utils/errors.ts index 5338fa2fd..27966df4f 100644 --- a/packages/cli/src/utils/errors.ts +++ b/packages/cli/src/utils/errors.ts @@ -17,6 +17,27 @@ export function getErrorMessage(error: unknown): string { if (error instanceof Error) { return error.message; } + + // Handle objects with message property (error-like objects) + if ( + error !== null && + typeof error === 'object' && + 'message' in error && + typeof (error as { message: unknown }).message === 'string' + ) { + return (error as { message: string }).message; + } + + // Handle plain objects by stringifying them + if (error !== null && typeof error === 'object') { + try { + return JSON.stringify(error); + } catch { + // If JSON.stringify fails (circular reference, etc.), fall back to String + return String(error); + } + } + return String(error); } From 4f664d00ac68033d06dd67c87be83829f625d227 Mon Sep 17 00:00:00 2001 From: Tu Shaokun <2801884530@qq.com> Date: Thu, 1 Jan 2026 10:10:24 +0800 Subject: [PATCH 042/142] fix: handle edge case where JSON.stringify returns undefined Add fallback to String() when JSON.stringify returns undefined, which can happen with objects that have toJSON() returning undefined. --- packages/cli/src/utils/errors.test.ts | 9 +++++++++ packages/cli/src/utils/errors.ts | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/utils/errors.test.ts b/packages/cli/src/utils/errors.test.ts index 2515fe402..63a60bf65 100644 --- a/packages/cli/src/utils/errors.test.ts +++ b/packages/cli/src/utils/errors.test.ts @@ -125,6 +125,15 @@ describe('errors', () => { const obj = { message: 123 }; expect(getErrorMessage(obj)).toBe('{"message":123}'); }); + + it('should fallback to String() when toJSON returns undefined', () => { + const obj = { + toJSON() { + return undefined; + }, + }; + expect(getErrorMessage(obj)).toBe('[object Object]'); + }); }); describe('handleError', () => { diff --git a/packages/cli/src/utils/errors.ts b/packages/cli/src/utils/errors.ts index 27966df4f..676418708 100644 --- a/packages/cli/src/utils/errors.ts +++ b/packages/cli/src/utils/errors.ts @@ -31,7 +31,9 @@ export function getErrorMessage(error: unknown): string { // Handle plain objects by stringifying them if (error !== null && typeof error === 'object') { try { - return JSON.stringify(error); + const stringified = JSON.stringify(error); + // JSON.stringify can return undefined for objects with toJSON() returning undefined + return stringified ?? String(error); } catch { // If JSON.stringify fails (circular reference, etc.), fall back to String return String(error); From e5cced88135f4c569dd6cda9d54be4987e274d08 Mon Sep 17 00:00:00 2001 From: Weaxs <459312872@qq.com> Date: Fri, 2 Jan 2026 18:59:23 +0800 Subject: [PATCH 043/142] buildRequest add thinking config && convert Handle reasoning content --- .../core/openaiContentGenerator/converter.ts | 25 +++++++++++++++++++ .../core/openaiContentGenerator/pipeline.ts | 24 ++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/packages/core/src/core/openaiContentGenerator/converter.ts b/packages/core/src/core/openaiContentGenerator/converter.ts index 07a8f1831..184ba5493 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.ts @@ -696,6 +696,17 @@ export class OpenAIContentConverter { parts.push({ text: choice.message.content }); } + // Handle reasoning content + const message = choice.message as typeof choice.message & { + reasoning_content?: string; + }; + if (message.reasoning_content) { + parts.push({ + text: message.reasoning_content, + thought: true, + } as unknown as Part); + } + // Handle tool calls if (choice.message.tool_calls) { for (const toolCall of choice.message.tool_calls) { @@ -752,6 +763,8 @@ export class OpenAIContentConverter { usage.prompt_tokens_details?.cached_tokens ?? extendedUsage.cached_tokens ?? 0; + const reasoningTokens = + usage.completion_tokens_details?.reasoning_tokens || 0; // If we only have total tokens but no breakdown, estimate the split // Typically input is ~70% and output is ~30% for most conversations @@ -769,6 +782,7 @@ export class OpenAIContentConverter { candidatesTokenCount: finalCompletionTokens, totalTokenCount: totalTokens, cachedContentTokenCount: cachedTokens, + thoughtsTokenCount: reasoningTokens, }; } @@ -800,6 +814,17 @@ export class OpenAIContentConverter { } } + // Handle reasoning content + const delta = choice.delta as typeof choice.delta & { + reasoning_content?: string; + }; + if (delta.reasoning_content) { + parts.push({ + text: delta.reasoning_content, + thought: true, + }); + } + // Handle tool calls using the streaming parser if (choice.delta?.tool_calls) { for (const toolCall of choice.delta.tool_calls) { diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts index 88ac38f6a..0eab0c2d2 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts @@ -242,6 +242,30 @@ export class ContentGenerationPipeline { baseRequest.stream_options = { include_usage: true }; } + // Add thinking options if present + if ( + request.config?.thinkingConfig && + request.config.thinkingConfig.includeThoughts + ) { + ( + baseRequest as OpenAI.Chat.ChatCompletionCreateParams & { + extra_body?: Record; + } + ).extra_body = { enable_thinking: true }; + ( + baseRequest as OpenAI.Chat.ChatCompletionCreateParams & { + enable_thinking?: boolean; + } + ).enable_thinking = true; + if (request.config.thinkingConfig.thinkingBudget) { + ( + baseRequest as OpenAI.Chat.ChatCompletionCreateParams & { + thinking_budget?: number; + } + ).thinking_budget = request.config.thinkingConfig.thinkingBudget; + } + } + // Add tools if present if (request.config?.tools) { baseRequest.tools = await this.converter.convertGeminiToolsToOpenAI( From 473cb7b951dd9dc25bca258d204641a4b09b1eb7 Mon Sep 17 00:00:00 2001 From: liqoingyu Date: Sun, 4 Jan 2026 14:32:32 +0800 Subject: [PATCH 044/142] fix(cli): skip update check when disableUpdateNag is true --- packages/cli/src/gemini.test.tsx | 33 ++++++++++++++++++++++++++++++++ packages/cli/src/gemini.tsx | 22 +++++++++++---------- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 9fa0b8261..2d26877e6 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -639,4 +639,37 @@ describe('startInteractiveUI', () => { await new Promise((resolve) => setTimeout(resolve, 0)); expect(checkForUpdates).toHaveBeenCalledTimes(1); }); + + it('should not check for updates when update nag is disabled', async () => { + const { checkForUpdates } = await import('./ui/utils/updateCheck.js'); + + const mockInitializationResult = { + authError: null, + themeError: null, + shouldOpenAuthDialog: false, + geminiMdFileCount: 0, + }; + + const settingsWithUpdateNagDisabled = { + merged: { + general: { + disableUpdateNag: true, + }, + ui: { + hideWindowTitle: false, + }, + }, + } as LoadedSettings; + + await startInteractiveUI( + mockConfig, + settingsWithUpdateNagDisabled, + mockStartupWarnings, + mockWorkspaceRoot, + mockInitializationResult, + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(checkForUpdates).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index b05f12453..da945546d 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -183,16 +183,18 @@ export async function startInteractiveUI( }, ); - checkForUpdates() - .then((info) => { - handleAutoUpdate(info, settings, config.getProjectRoot()); - }) - .catch((err) => { - // Silently ignore update check errors. - if (config.getDebugMode()) { - console.error('Update check failed:', err); - } - }); + if (!settings.merged.general?.disableUpdateNag) { + checkForUpdates() + .then((info) => { + handleAutoUpdate(info, settings, config.getProjectRoot()); + }) + .catch((err) => { + // Silently ignore update check errors. + if (config.getDebugMode()) { + console.error('Update check failed:', err); + } + }); + } registerCleanup(() => instance.unmount()); } From db9d5cb45d32192121b1cec8af3d31e81dc3c36b Mon Sep 17 00:00:00 2001 From: skyfire Date: Sun, 4 Jan 2026 18:07:56 +0800 Subject: [PATCH 045/142] add javadoc --- packages/sdk-java/.gitignore | 1 + packages/sdk-java/README.md | 10 +- packages/sdk-java/pom.xml | 8 +- .../alibaba/qwen/code/cli/QwenCodeCli.java | 98 +++++-- .../cli/protocol/data/AssistantContent.java | 91 +++++- .../cli/protocol/data/AssistantUsage.java | 68 +++++ .../protocol/data/CLIPermissionDenial.java | 42 +++ .../code/cli/protocol/data/Capabilities.java | 68 +++++ .../code/cli/protocol/data/ExtendedUsage.java | 77 +++++ .../cli/protocol/data/InitializeConfig.java | 55 ++++ .../code/cli/protocol/data/ModelUsage.java | 81 ++++++ .../cli/protocol/data/PermissionMode.java | 26 ++ .../qwen/code/cli/protocol/data/Usage.java | 73 +++++ .../cli/protocol/data/behavior/Allow.java | 20 ++ .../cli/protocol/data/behavior/Behavior.java | 30 ++ .../code/cli/protocol/data/behavior/Deny.java | 20 ++ .../code/cli/protocol/message/Message.java | 15 + .../cli/protocol/message/MessageBase.java | 32 ++ .../protocol/message/SDKResultMessage.java | 178 ++++++++++++ .../protocol/message/SDKSystemMessage.java | 274 +++++++++++++++++- .../cli/protocol/message/SDKUserMessage.java | 105 ++++++- .../assistant/APIAssistantMessage.java | 101 ++++++- .../assistant/SDKAssistantMessage.java | 63 ++++ .../assistant/SDKPartialAssistantMessage.java | 58 ++++ .../message/assistant/block/Annotation.java | 30 +- .../message/assistant/block/ContentBlock.java | 47 ++- .../message/assistant/block/TextBlock.java | 21 +- .../assistant/block/ThinkingBlock.java | 34 ++- .../assistant/block/ToolResultBlock.java | 56 +++- .../message/assistant/block/ToolUseBlock.java | 67 ++++- .../event/ContentBlockDeltaEvent.java | 149 +++++++++- .../event/ContentBlockStartEvent.java | 9 + .../event/ContentBlockStopEvent.java | 16 + .../event/MessageStartStreamEvent.java | 59 +++- .../event/MessageStopStreamEvent.java | 3 + .../message/assistant/event/StreamEvent.java | 16 + .../control/CLIControlInitializeRequest.java | 30 ++ .../control/CLIControlInitializeResponse.java | 29 ++ .../control/CLIControlInterruptRequest.java | 16 + .../control/CLIControlPermissionRequest.java | 136 +++++++++ .../control/CLIControlPermissionResponse.java | 30 ++ .../message/control/CLIControlRequest.java | 43 +++ .../message/control/CLIControlResponse.java | 73 +++++ .../control/CLIControlSetModelRequest.java | 29 ++ .../control/CLIControlSetModelResponse.java | 29 ++ .../CLIControlSetPermissionModeRequest.java | 29 ++ .../qwen/code/cli/session/Session.java | 89 +++++- .../event/AssistantContentConsumers.java | 62 ++++ .../AssistantContentSimpleConsumers.java | 38 +++ .../session/event/SessionEventConsumers.java | 113 ++++++++ .../event/SessionEventSimpleConsumers.java | 111 ++++++- .../exception/SessionControlException.java | 30 ++ .../exception/SessionSendPromptException.java | 30 ++ .../qwen/code/cli/transport/Transport.java | 51 ++++ .../code/cli/transport/TransportOptions.java | 227 +++++++++++++++ .../transport/process/ProcessTransport.java | 21 ++ .../process/TransportOptionsAdapter.java | 38 +++ .../code/cli/utils/MyConcurrentUtils.java | 26 ++ .../qwen/code/cli/utils/ThreadPoolConfig.java | 14 + .../alibaba/qwen/code/cli/utils/Timeout.java | 32 ++ .../qwen/code/cli/QwenCodeCliTest.java | 11 +- .../qwen/code/cli/session/SessionTest.java | 48 ++- 62 files changed, 3395 insertions(+), 91 deletions(-) create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/AssistantUsage.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/AssistantContentConsumers.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/AssistantContentSimpleConsumers.java diff --git a/packages/sdk-java/.gitignore b/packages/sdk-java/.gitignore index bb45e2790..23cdb8c94 100644 --- a/packages/sdk-java/.gitignore +++ b/packages/sdk-java/.gitignore @@ -11,3 +11,4 @@ log/ target/ +/docs/ diff --git a/packages/sdk-java/README.md b/packages/sdk-java/README.md index 0898b6d31..5794a9a16 100644 --- a/packages/sdk-java/README.md +++ b/packages/sdk-java/README.md @@ -150,8 +150,8 @@ import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlRequest; import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlResponse; import com.alibaba.qwen.code.cli.protocol.data.AssistantContent; import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior; -import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers.AssistantMessageOutputType; import com.alibaba.qwen.code.cli.utils.Timeout; + import java.util.List; import java.util.concurrent.TimeUnit; @@ -162,9 +162,9 @@ public class CustomEventHandlingExample { @Override public void onAssistantMessage(Session session, SDKAssistantMessage assistantMessage) { String message = assistantMessage.getMessage().getContent().stream() - .findFirst() - .map(content -> content.getText()) - .orElse(""); + .findFirst() + .map(content -> content.getText()) + .orElse(""); System.out.println("Assistant: " + message); } @@ -213,7 +213,7 @@ public class CustomEventHandlingExample { public Behavior onPermissionRequest(Session session, CLIControlRequest permissionRequest) { System.out.println("Permission request: " + permissionRequest.getRequest().getInput()); return new com.alibaba.qwen.code.cli.protocol.data.behavior.Allow() - .setUpdatedInput(permissionRequest.getRequest().getInput()); // Allow by default + .setUpdatedInput(permissionRequest.getRequest().getInput()); // Allow by default } @Override diff --git a/packages/sdk-java/pom.xml b/packages/sdk-java/pom.xml index 2bc86a988..8786a78cc 100644 --- a/packages/sdk-java/pom.xml +++ b/packages/sdk-java/pom.xml @@ -5,7 +5,7 @@ com.alibaba qwencode-sdk jar - 0.0.1 + 0.0.1-SNAPSHOT qwencode-sdk https://maven.apache.org @@ -202,8 +202,10 @@ - central - https://central.sonatype.com/repository/maven-snapshots/ + + + snapshots + http://mvnrepo.alibaba-inc.com/mvn/snapshots diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCodeCli.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCodeCli.java index 1e306dc15..adbb235e6 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCodeCli.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCodeCli.java @@ -2,13 +2,17 @@ package com.alibaba.qwen.code.cli; import java.util.ArrayList; import java.util.List; -import java.util.function.Consumer; -import java.util.stream.Collectors; import com.alibaba.fastjson2.JSON; +import com.alibaba.qwen.code.cli.protocol.data.AssistantUsage; import com.alibaba.qwen.code.cli.protocol.data.AssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.TextAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ThingkingAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolResultAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolUseAssistantContent; import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior.Operation; import com.alibaba.qwen.code.cli.session.Session; +import com.alibaba.qwen.code.cli.session.event.AssistantContentConsumers; import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; import com.alibaba.qwen.code.cli.transport.Transport; import com.alibaba.qwen.code.cli.transport.TransportOptions; @@ -19,32 +23,77 @@ import com.alibaba.qwen.code.cli.utils.Timeout; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +/** + * Main entry point for interacting with the Qwen Code CLI. Provides static methods for simple queries and session management. + */ public class QwenCodeCli { private static final Logger log = LoggerFactory.getLogger(QwenCodeCli.class); + /** + * Sends a simple query to the Qwen Code CLI and returns a list of responses. + * + * @param prompt The input prompt to send to the CLI + * @return A list of strings representing the CLI's responses + */ public static List simpleQuery(String prompt) { + return simpleQuery(prompt, new TransportOptions()); + } + + /** + * Sends a simple query with custom transport options. + * + * @param prompt The input prompt to send to the CLI + * @param transportOptions Configuration options for the transport layer + * @return A list of strings representing the CLI's responses + */ + public static List simpleQuery(String prompt, TransportOptions transportOptions) { final List response = new ArrayList<>(); - MyConcurrentUtils.runAndWait(() -> simpleQuery(prompt, response::add), Timeout.TIMEOUT_30_MINUTES); + MyConcurrentUtils.runAndWait(() -> simpleQuery(prompt, transportOptions, new AssistantContentConsumers() { + @Override + public void onText(Session session, TextAssistantContent textAssistantContent) { + response.add(textAssistantContent.getText()); + } + + @Override + public void onThinking(Session session, ThingkingAssistantContent thingkingAssistantContent) { + response.add(thingkingAssistantContent.getThinking()); + } + + @Override + public void onToolUse(Session session, ToolUseAssistantContent toolUseAssistantContent) { + response.add(JSON.toJSONString(toolUseAssistantContent.getContentOfAssistant())); + } + + @Override + public void onToolResult(Session session, ToolResultAssistantContent toolResultAssistantContent) { + response.add(JSON.toJSONString(toolResultAssistantContent)); + } + + public void onOtherContent(Session session, AssistantContent other) { + response.add(JSON.toJSONString(other.getContentOfAssistant())); + } + + @Override + public void onUsage(Session session, AssistantUsage assistantUsage) { + log.info("received usage {} of message {}", assistantUsage.getUsage(), assistantUsage.getMessageId()); + } + }), Timeout.TIMEOUT_30_MINUTES); return response; } - public static void simpleQuery(String prompt, Consumer messageConsumer) { - Session session = newSession(new TransportOptions()); + /** + * Sends a query with custom content consumers. + * + * @param prompt The input prompt to send to the CLI + * @param transportOptions Configuration options for the transport layer + * @param assistantContentConsumers Consumers for handling different types of assistant content + */ + public static void simpleQuery(String prompt, TransportOptions transportOptions, AssistantContentConsumers assistantContentConsumers) { + Session session = newSession(transportOptions); try { - session.sendPrompt(prompt, new SessionEventSimpleConsumers() { - @Override - public void onAssistantMessageIncludePartial(Session session, List assistantContents, AssistantMessageOutputType assistantMessageOutputType) { - messageConsumer.accept(assistantContents.stream() - .map(AssistantContent::getContent) - .map(content -> { - if (content instanceof String) { - return (String) content; - } else { - return JSON.toJSONString(content); - } - }).collect(Collectors.joining())); - } - }.setDefaultPermissionOperation(Operation.allow)); + session.sendPrompt(prompt, new SessionEventSimpleConsumers() + .setDefaultPermissionOperation(Operation.allow) + .setBlockConsumer(assistantContentConsumers)); } catch (Exception e) { throw new RuntimeException("sendPrompt error!", e); } finally { @@ -56,10 +105,21 @@ public class QwenCodeCli { } } + /** + * Creates a new session with default transport options. + * + * @return A new Session instance + */ public static Session newSession() { return newSession(new TransportOptions()); } + /** + * Creates a new session with custom transport options. + * + * @param transportOptions Configuration options for the transport layer + * @return A new Session instance + */ public static Session newSession(TransportOptions transportOptions) { Transport transport; try { diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/AssistantContent.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/AssistantContent.java index 40d7f520d..d0414a83f 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/AssistantContent.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/AssistantContent.java @@ -1,6 +1,93 @@ package com.alibaba.qwen.code.cli.protocol.data; -public interface AssistantContent { +import java.util.Map; + +/** + * Represents content from the assistant in a Qwen Code session. + * + * @param The type of content + */ +public interface AssistantContent { + /** + * Gets the type of the assistant content. + * + * @return The type of the assistant content + */ String getType(); - Object getContent(); + + /** + * Gets the actual content from the assistant. + * + * @return The content from the assistant + */ + C getContentOfAssistant(); + + /** + * Gets the message ID associated with this content. + * + * @return The message ID + */ + String getMessageId(); + + /** + * Represents text content from the assistant. + */ + interface TextAssistantContent extends AssistantContent { + /** + * Gets the text content. + * + * @return The text content + */ + String getText(); + } + + /** + * Represents thinking content from the assistant. + */ + interface ThingkingAssistantContent extends AssistantContent { + /** + * Gets the thinking content. + * + * @return The thinking content + */ + String getThinking(); + } + + /** + * Represents tool use content from the assistant. + */ + interface ToolUseAssistantContent extends AssistantContent> { + /** + * Gets the tool input. + * + * @return The tool input + */ + Map getInput(); + } + + /** + * Represents tool result content from the assistant. + */ + interface ToolResultAssistantContent extends AssistantContent { + /** + * Gets whether the tool result indicates an error. + * + * @return Whether the tool result indicates an error + */ + Boolean getIsError(); + + /** + * Gets the tool result content. + * + * @return The tool result content + */ + String getContent(); + + /** + * Gets the tool use ID. + * + * @return The tool use ID + */ + String getToolUseId(); + } } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/AssistantUsage.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/AssistantUsage.java new file mode 100644 index 000000000..261898796 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/AssistantUsage.java @@ -0,0 +1,68 @@ +package com.alibaba.qwen.code.cli.protocol.data; + +import com.alibaba.fastjson2.JSON; + +/** + * Represents usage information for an assistant message. + */ +public class AssistantUsage { + /** + * The ID of the message. + */ + String messageId; + /** + * The usage information. + */ + Usage usage; + + /** + * Gets the message ID. + * + * @return The message ID + */ + public String getMessageId() { + return messageId; + } + + /** + * Sets the message ID. + * + * @param messageId The message ID + */ + public void setMessageId(String messageId) { + this.messageId = messageId; + } + + /** + * Gets the usage information. + * + * @return The usage information + */ + public Usage getUsage() { + return usage; + } + + /** + * Sets the usage information. + * + * @param usage The usage information + */ + public void setUsage(Usage usage) { + this.usage = usage; + } + + /** + * Constructs a new AssistantUsage instance. + * + * @param messageId The message ID + * @param usage The usage information + */ + public AssistantUsage(String messageId, Usage usage) { + this.messageId = messageId; + this.usage = usage; + } + + public String toString() { + return JSON.toJSONString(this); + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/CLIPermissionDenial.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/CLIPermissionDenial.java index 4abd68bc3..312344296 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/CLIPermissionDenial.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/CLIPermissionDenial.java @@ -2,36 +2,78 @@ package com.alibaba.qwen.code.cli.protocol.data; import com.alibaba.fastjson2.annotation.JSONField; +/** + * Represents a permission denial from the CLI. + */ public class CLIPermissionDenial { + /** + * The name of the denied tool. + */ @JSONField(name = "tool_name") private String toolName; + /** + * The ID of the denied tool use. + */ @JSONField(name = "tool_use_id") private String toolUseId; + /** + * The input for the denied tool. + */ @JSONField(name = "tool_input") private Object toolInput; + /** + * Gets the name of the denied tool. + * + * @return The name of the denied tool + */ public String getToolName() { return toolName; } + /** + * Sets the name of the denied tool. + * + * @param toolName The name of the denied tool + */ public void setToolName(String toolName) { this.toolName = toolName; } + /** + * Gets the ID of the denied tool use. + * + * @return The ID of the denied tool use + */ public String getToolUseId() { return toolUseId; } + /** + * Sets the ID of the denied tool use. + * + * @param toolUseId The ID of the denied tool use + */ public void setToolUseId(String toolUseId) { this.toolUseId = toolUseId; } + /** + * Gets the input for the denied tool. + * + * @return The input for the denied tool + */ public Object getToolInput() { return toolInput; } + /** + * Sets the input for the denied tool. + * + * @param toolInput The input for the denied tool + */ public void setToolInput(Object toolInput) { this.toolInput = toolInput; } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Capabilities.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Capabilities.java index 13200b654..2f22c0ce4 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Capabilities.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Capabilities.java @@ -2,58 +2,126 @@ package com.alibaba.qwen.code.cli.protocol.data; import com.alibaba.fastjson2.annotation.JSONField; +/** + * Represents the capabilities of the Qwen Code CLI. + */ public class Capabilities { + /** + * Whether the CLI can handle can_use_tool requests. + */ @JSONField(name = "can_handle_can_use_tool") boolean canHandleCanUseTool; + /** + * Whether the CLI can handle hook callbacks. + */ @JSONField(name = "can_handle_hook_callback") boolean canHandleHookCallback; + /** + * Whether the CLI can set permission mode. + */ @JSONField(name = "can_set_permission_mode") boolean canSetPermissionMode; + /** + * Whether the CLI can set the model. + */ @JSONField(name = "can_set_model") boolean canSetModel; + /** + * Whether the CLI can handle MCP messages. + */ @JSONField(name = "can_handle_mcp_message") boolean canHandleMcpMessage; + /** + * Checks if the CLI can handle can_use_tool requests. + * + * @return true if the CLI can handle can_use_tool requests, false otherwise + */ public boolean isCanHandleCanUseTool() { return canHandleCanUseTool; } + /** + * Sets whether the CLI can handle can_use_tool requests. + * + * @param canHandleCanUseTool Whether the CLI can handle can_use_tool requests + */ public void setCanHandleCanUseTool(boolean canHandleCanUseTool) { this.canHandleCanUseTool = canHandleCanUseTool; } + /** + * Checks if the CLI can handle hook callbacks. + * + * @return true if the CLI can handle hook callbacks, false otherwise + */ public boolean isCanHandleHookCallback() { return canHandleHookCallback; } + /** + * Sets whether the CLI can handle hook callbacks. + * + * @param canHandleHookCallback Whether the CLI can handle hook callbacks + */ public void setCanHandleHookCallback(boolean canHandleHookCallback) { this.canHandleHookCallback = canHandleHookCallback; } + /** + * Checks if the CLI can set permission mode. + * + * @return true if the CLI can set permission mode, false otherwise + */ public boolean isCanSetPermissionMode() { return canSetPermissionMode; } + /** + * Sets whether the CLI can set permission mode. + * + * @param canSetPermissionMode Whether the CLI can set permission mode + */ public void setCanSetPermissionMode(boolean canSetPermissionMode) { this.canSetPermissionMode = canSetPermissionMode; } + /** + * Checks if the CLI can set the model. + * + * @return true if the CLI can set the model, false otherwise + */ public boolean isCanSetModel() { return canSetModel; } + /** + * Sets whether the CLI can set the model. + * + * @param canSetModel Whether the CLI can set the model + */ public void setCanSetModel(boolean canSetModel) { this.canSetModel = canSetModel; } + /** + * Checks if the CLI can handle MCP messages. + * + * @return true if the CLI can handle MCP messages, false otherwise + */ public boolean isCanHandleMcpMessage() { return canHandleMcpMessage; } + /** + * Sets whether the CLI can handle MCP messages. + * + * @param canHandleMcpMessage Whether the CLI can handle MCP messages + */ public void setCanHandleMcpMessage(boolean canHandleMcpMessage) { this.canHandleMcpMessage = canHandleMcpMessage; } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ExtendedUsage.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ExtendedUsage.java index 4965f4b8c..a894f5b7a 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ExtendedUsage.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ExtendedUsage.java @@ -2,64 +2,141 @@ package com.alibaba.qwen.code.cli.protocol.data; import com.alibaba.fastjson2.annotation.JSONField; +/** + * Extends the Usage class with additional usage information. + */ public class ExtendedUsage extends Usage { + /** + * Server tool use information. + */ @JSONField(name = "server_tool_use") private ServerToolUse serverToolUse; + /** + * Service tier information. + */ @JSONField(name = "service_tier") private String serviceTier; + /** + * Cache creation information. + */ @JSONField(name = "cache_creation") private CacheCreation cacheCreation; + /** + * Gets the server tool use information. + * + * @return The server tool use information + */ public ServerToolUse getServerToolUse() { return serverToolUse; } + /** + * Sets the server tool use information. + * + * @param serverToolUse The server tool use information + */ public void setServerToolUse(ServerToolUse serverToolUse) { this.serverToolUse = serverToolUse; } + /** + * Gets the service tier information. + * + * @return The service tier information + */ public String getServiceTier() { return serviceTier; } + /** + * Sets the service tier information. + * + * @param serviceTier The service tier information + */ public void setServiceTier(String serviceTier) { this.serviceTier = serviceTier; } + /** + * Gets the cache creation information. + * + * @return The cache creation information + */ public CacheCreation getCacheCreation() { return cacheCreation; } + /** + * Sets the cache creation information. + * + * @param cacheCreation The cache creation information + */ public void setCacheCreation(CacheCreation cacheCreation) { this.cacheCreation = cacheCreation; } + /** + * Represents server tool use information. + */ public static class ServerToolUse { + /** + * Number of web search requests. + */ @JSONField(name = "web_search_requests") private int webSearchRequests; } + /** + * Represents cache creation information. + */ public static class CacheCreation { + /** + * Number of ephemeral 1-hour input tokens. + */ @JSONField(name = "ephemeral_1h_input_tokens") private int ephemeral1hInputTokens; + /** + * Number of ephemeral 5-minute input tokens. + */ @JSONField(name = "ephemeral_5m_input_tokens") private int ephemeral5mInputTokens; + /** + * Gets the number of ephemeral 1-hour input tokens. + * + * @return The number of ephemeral 1-hour input tokens + */ public int getEphemeral1hInputTokens() { return ephemeral1hInputTokens; } + /** + * Sets the number of ephemeral 1-hour input tokens. + * + * @param ephemeral1hInputTokens The number of ephemeral 1-hour input tokens + */ public void setEphemeral1hInputTokens(int ephemeral1hInputTokens) { this.ephemeral1hInputTokens = ephemeral1hInputTokens; } + /** + * Gets the number of ephemeral 5-minute input tokens. + * + * @return The number of ephemeral 5-minute input tokens + */ public int getEphemeral5mInputTokens() { return ephemeral5mInputTokens; } + /** + * Sets the number of ephemeral 5-minute input tokens. + * + * @param ephemeral5mInputTokens The number of ephemeral 5-minute input tokens + */ public void setEphemeral5mInputTokens(int ephemeral5mInputTokens) { this.ephemeral5mInputTokens = ephemeral5mInputTokens; } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/InitializeConfig.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/InitializeConfig.java index ccafed4f0..c0858ee4d 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/InitializeConfig.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/InitializeConfig.java @@ -1,39 +1,94 @@ package com.alibaba.qwen.code.cli.protocol.data; +/** + * Configuration for initializing the CLI. + */ public class InitializeConfig { + /** + * Hooks configuration. + */ String hooks; + /** + * SDK MCP servers configuration. + */ String sdkMcpServers; + /** + * MCP servers configuration. + */ String mcpServers; + /** + * Agents configuration. + */ String agents; + /** + * Gets the hooks configuration. + * + * @return The hooks configuration + */ public String getHooks() { return hooks; } + /** + * Sets the hooks configuration. + * + * @param hooks The hooks configuration + */ public void setHooks(String hooks) { this.hooks = hooks; } + /** + * Gets the SDK MCP servers configuration. + * + * @return The SDK MCP servers configuration + */ public String getSdkMcpServers() { return sdkMcpServers; } + /** + * Sets the SDK MCP servers configuration. + * + * @param sdkMcpServers The SDK MCP servers configuration + */ public void setSdkMcpServers(String sdkMcpServers) { this.sdkMcpServers = sdkMcpServers; } + /** + * Gets the MCP servers configuration. + * + * @return The MCP servers configuration + */ public String getMcpServers() { return mcpServers; } + /** + * Sets the MCP servers configuration. + * + * @param mcpServers The MCP servers configuration + */ public void setMcpServers(String mcpServers) { this.mcpServers = mcpServers; } + /** + * Gets the agents configuration. + * + * @return The agents configuration + */ public String getAgents() { return agents; } + /** + * Sets the agents configuration. + * + * @param agents The agents configuration + */ public void setAgents(String agents) { this.agents = agents; } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ModelUsage.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ModelUsage.java index 22787f232..d3286c8a3 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ModelUsage.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ModelUsage.java @@ -1,57 +1,138 @@ package com.alibaba.qwen.code.cli.protocol.data; +/** + * Represents usage information for a specific model. + */ public class ModelUsage { + /** + * Number of input tokens. + */ private int inputTokens; + /** + * Number of output tokens. + */ private int outputTokens; + /** + * Number of cache read input tokens. + */ private int cacheReadInputTokens; + /** + * Number of cache creation input tokens. + */ private int cacheCreationInputTokens; + /** + * Number of web search requests. + */ private int webSearchRequests; + /** + * Context window size. + */ private int contextWindow; + /** + * Gets the number of input tokens. + * + * @return The number of input tokens + */ public int getInputTokens() { return inputTokens; } + /** + * Sets the number of input tokens. + * + * @param inputTokens The number of input tokens + */ public void setInputTokens(int inputTokens) { this.inputTokens = inputTokens; } + /** + * Gets the number of output tokens. + * + * @return The number of output tokens + */ public int getOutputTokens() { return outputTokens; } + /** + * Sets the number of output tokens. + * + * @param outputTokens The number of output tokens + */ public void setOutputTokens(int outputTokens) { this.outputTokens = outputTokens; } + /** + * Gets the number of cache read input tokens. + * + * @return The number of cache read input tokens + */ public int getCacheReadInputTokens() { return cacheReadInputTokens; } + /** + * Sets the number of cache read input tokens. + * + * @param cacheReadInputTokens The number of cache read input tokens + */ public void setCacheReadInputTokens(int cacheReadInputTokens) { this.cacheReadInputTokens = cacheReadInputTokens; } + /** + * Gets the number of cache creation input tokens. + * + * @return The number of cache creation input tokens + */ public int getCacheCreationInputTokens() { return cacheCreationInputTokens; } + /** + * Sets the number of cache creation input tokens. + * + * @param cacheCreationInputTokens The number of cache creation input tokens + */ public void setCacheCreationInputTokens(int cacheCreationInputTokens) { this.cacheCreationInputTokens = cacheCreationInputTokens; } + /** + * Gets the number of web search requests. + * + * @return The number of web search requests + */ public int getWebSearchRequests() { return webSearchRequests; } + /** + * Sets the number of web search requests. + * + * @param webSearchRequests The number of web search requests + */ public void setWebSearchRequests(int webSearchRequests) { this.webSearchRequests = webSearchRequests; } + /** + * Gets the context window size. + * + * @return The context window size + */ public int getContextWindow() { return contextWindow; } + /** + * Sets the context window size. + * + * @param contextWindow The context window size + */ public void setContextWindow(int contextWindow) { this.contextWindow = contextWindow; } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/PermissionMode.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/PermissionMode.java index d960a396e..aafc69bef 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/PermissionMode.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/PermissionMode.java @@ -1,9 +1,24 @@ package com.alibaba.qwen.code.cli.protocol.data; +/** + * Represents different permission modes for the CLI. + */ public enum PermissionMode { + /** + * Default permission mode. + */ DEFAULT("default"), + /** + * Plan permission mode. + */ PLAN("plan"), + /** + * Auto-edit permission mode. + */ AUTO_EDIT("auto-edit"), + /** + * YOLO permission mode. + */ YOLO("yolo"); private final String value; @@ -12,10 +27,21 @@ public enum PermissionMode { this.value = value; } + /** + * Gets the string value of the permission mode. + * + * @return The string value of the permission mode + */ public String getValue() { return value; } + /** + * Gets the permission mode from its string value. + * + * @param value The string value + * @return The corresponding permission mode + */ public static PermissionMode fromValue(String value) { for (PermissionMode mode : PermissionMode.values()) { if (mode.value.equals(value)) { diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Usage.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Usage.java index 1222b16f2..a0e4b3009 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Usage.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Usage.java @@ -1,56 +1,129 @@ package com.alibaba.qwen.code.cli.protocol.data; +import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.annotation.JSONField; +/** + * Represents usage information for a message. + */ public class Usage { + /** + * Number of input tokens. + */ @JSONField(name = "input_tokens") private Integer inputTokens; + /** + * Number of output tokens. + */ @JSONField(name = "output_tokens") private Integer outputTokens; + /** + * Number of cache creation input tokens. + */ @JSONField(name = "cache_creation_input_tokens") private Integer cacheCreationInputTokens; + /** + * Number of cache read input tokens. + */ @JSONField(name = "cache_read_input_tokens") private Integer cacheReadInputTokens; + /** + * Total number of tokens. + */ @JSONField(name = "total_tokens") private Integer totalTokens; + /** + * Gets the number of input tokens. + * + * @return The number of input tokens + */ public Integer getInputTokens() { return inputTokens; } + /** + * Sets the number of input tokens. + * + * @param inputTokens The number of input tokens + */ public void setInputTokens(Integer inputTokens) { this.inputTokens = inputTokens; } + /** + * Gets the number of output tokens. + * + * @return The number of output tokens + */ public Integer getOutputTokens() { return outputTokens; } + /** + * Sets the number of output tokens. + * + * @param outputTokens The number of output tokens + */ public void setOutputTokens(Integer outputTokens) { this.outputTokens = outputTokens; } + /** + * Gets the number of cache creation input tokens. + * + * @return The number of cache creation input tokens + */ public Integer getCacheCreationInputTokens() { return cacheCreationInputTokens; } + /** + * Sets the number of cache creation input tokens. + * + * @param cacheCreationInputTokens The number of cache creation input tokens + */ public void setCacheCreationInputTokens(Integer cacheCreationInputTokens) { this.cacheCreationInputTokens = cacheCreationInputTokens; } + /** + * Gets the number of cache read input tokens. + * + * @return The number of cache read input tokens + */ public Integer getCacheReadInputTokens() { return cacheReadInputTokens; } + /** + * Sets the number of cache read input tokens. + * + * @param cacheReadInputTokens The number of cache read input tokens + */ public void setCacheReadInputTokens(Integer cacheReadInputTokens) { this.cacheReadInputTokens = cacheReadInputTokens; } + /** + * Gets the total number of tokens. + * + * @return The total number of tokens + */ public Integer getTotalTokens() { return totalTokens; } + /** + * Sets the total number of tokens. + * + * @param totalTokens The total number of tokens + */ public void setTotalTokens(Integer totalTokens) { this.totalTokens = totalTokens; } + + public String toString() { + return JSON.toJSONString(this); + } } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Allow.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Allow.java index 14adf7a2f..cc2f5d533 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Allow.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Allow.java @@ -4,18 +4,38 @@ import java.util.Map; import com.alibaba.fastjson2.annotation.JSONType; +/** + * Represents an allow behavior that permits an operation. + */ @JSONType(typeKey = "operation", typeName = "allow") public class Allow extends Behavior { + /** + * Creates a new Allow instance and sets the behavior to allow. + */ public Allow() { super(); this.behavior = Operation.allow; } + /** + * Updated input for the operation. + */ Map updatedInput; + /** + * Gets the updated input. + * + * @return The updated input + */ public Map getUpdatedInput() { return updatedInput; } + /** + * Sets the updated input. + * + * @param updatedInput The updated input + * @return This instance for method chaining + */ public Allow setUpdatedInput(Map updatedInput) { this.updatedInput = updatedInput; return this; diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Behavior.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Behavior.java index 1f54f2341..2ea1b6ff1 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Behavior.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Behavior.java @@ -2,23 +2,53 @@ package com.alibaba.qwen.code.cli.protocol.data.behavior; import com.alibaba.fastjson2.annotation.JSONType; +/** + * Base class for behavior objects that define how the CLI should handle requests. + */ @JSONType(typeKey = "operation", typeName = "Behavior", seeAlso = {Allow.class, Deny.class}) public class Behavior { + /** + * The behavior operation (allow or deny). + */ Operation behavior; + /** + * Gets the behavior operation. + * + * @return The behavior operation + */ public Operation getBehavior() { return behavior; } + /** + * Sets the behavior operation. + * + * @param behavior The behavior operation + */ public void setBehavior(Operation behavior) { this.behavior = behavior; } + /** + * Represents the type of operation. + */ public enum Operation { + /** + * Allow the operation. + */ allow, + /** + * Deny the operation. + */ deny } + /** + * Gets the default behavior (deny with message). + * + * @return The default behavior + */ public static Behavior defaultBehavior() { return new Deny().setMessage("Default Behavior Permission denied"); } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Deny.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Deny.java index 17d37ca05..d24560620 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Deny.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Deny.java @@ -2,19 +2,39 @@ package com.alibaba.qwen.code.cli.protocol.data.behavior; import com.alibaba.fastjson2.annotation.JSONType; +/** + * Represents a deny behavior that rejects an operation. + */ @JSONType(typeKey = "operation", typeName = "deny") public class Deny extends Behavior { + /** + * Creates a new Deny instance and sets the behavior to deny. + */ public Deny() { super(); this.behavior = Operation.deny; } + /** + * The message explaining why the operation was denied. + */ String message; + /** + * Gets the denial message. + * + * @return The denial message + */ public String getMessage() { return message; } + /** + * Sets the denial message. + * + * @param message The denial message + * @return This instance for method chaining + */ public Deny setMessage(String message) { this.message = message; return this; diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/Message.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/Message.java index de43924df..f816d7f2e 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/Message.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/Message.java @@ -1,5 +1,20 @@ package com.alibaba.qwen.code.cli.protocol.message; +/** + * Represents a message in the Qwen Code protocol. + */ public interface Message { + /** + * Gets the type of the message. + * + * @return The type of the message + */ String getType(); + + /** + * Gets the ID of the message. + * + * @return The ID of the message + */ + String getMessageId(); } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/MessageBase.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/MessageBase.java index c66df12c4..aa9cbfd1a 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/MessageBase.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/MessageBase.java @@ -1,12 +1,25 @@ package com.alibaba.qwen.code.cli.protocol.message; import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.annotation.JSONField; import com.alibaba.fastjson2.annotation.JSONType; +/** + * Base class for messages in the Qwen Code protocol. + */ @JSONType(alphabetic = false, typeKey = "type", typeName = "MessageBase") public class MessageBase implements Message{ + /** + * The type of the message. + */ protected String type; + /** + * The ID of the message. + */ + @JSONField(name = "message_id") + protected String messageId; + public String toString() { return JSON.toJSONString(this); } @@ -16,7 +29,26 @@ public class MessageBase implements Message{ return type; } + /** + * Sets the type of the message. + * + * @param type The type of the message + */ public void setType(String type) { this.type = type; } + + @Override + public String getMessageId() { + return messageId; + } + + /** + * Sets the ID of the message. + * + * @param messageId The ID of the message + */ + public void setMessageId(String messageId) { + this.messageId = messageId; + } } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKResultMessage.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKResultMessage.java index dfa2275ff..f96ecade7 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKResultMessage.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKResultMessage.java @@ -9,141 +9,319 @@ import com.alibaba.qwen.code.cli.protocol.data.CLIPermissionDenial; import com.alibaba.qwen.code.cli.protocol.data.ExtendedUsage; import com.alibaba.qwen.code.cli.protocol.data.Usage; +/** + * Represents a result message from the SDK. + */ @JSONType(typeKey = "type", typeName = "result") public class SDKResultMessage extends MessageBase { + /** + * The subtype of the result. + */ private String subtype; // 'error_max_turns' | 'error_during_execution' + /** + * The UUID of the message. + */ private String uuid; + /** + * The session ID. + */ @JSONField(name = "session_id") private String sessionId; + /** + * Whether the result represents an error. + */ @JSONField(name = "is_error") private boolean isError = true; + /** + * Duration in milliseconds. + */ @JSONField(name = "duration_ms") private Long durationMs; + /** + * API duration in milliseconds. + */ @JSONField(name = "duration_api_ms") private Long durationApiMs; + /** + * Number of turns. + */ @JSONField(name = "num_turns") private Integer numTurns; + /** + * Usage information. + */ private ExtendedUsage usage; + /** + * Model usage information. + */ private Map modelUsage; + /** + * List of permission denials. + */ @JSONField(name = "permission_denials") private List permissionDenials; + /** + * Error information. + */ private Error error; + /** + * Creates a new SDKResultMessage instance and sets the type to "result". + */ public SDKResultMessage() { super(); this.type = "result"; } + /** + * Gets the subtype of the result. + * + * @return The subtype of the result + */ public String getSubtype() { return subtype; } + /** + * Sets the subtype of the result. + * + * @param subtype The subtype of the result + */ public void setSubtype(String subtype) { this.subtype = subtype; } + /** + * Gets the UUID of the message. + * + * @return The UUID of the message + */ public String getUuid() { return uuid; } + /** + * Sets the UUID of the message. + * + * @param uuid The UUID of the message + */ public void setUuid(String uuid) { this.uuid = uuid; } + /** + * Gets the session ID. + * + * @return The session ID + */ public String getSessionId() { return sessionId; } + /** + * Sets the session ID. + * + * @param sessionId The session ID + */ public void setSessionId(String sessionId) { this.sessionId = sessionId; } + /** + * Checks if the result represents an error. + * + * @return Whether the result represents an error + */ public boolean isError() { return isError; } + /** + * Sets whether the result represents an error. + * + * @param error Whether the result represents an error + */ public void setError(boolean error) { isError = error; } + /** + * Gets the duration in milliseconds. + * + * @return The duration in milliseconds + */ public Long getDurationMs() { return durationMs; } + /** + * Sets the duration in milliseconds. + * + * @param durationMs The duration in milliseconds + */ public void setDurationMs(Long durationMs) { this.durationMs = durationMs; } + /** + * Gets the API duration in milliseconds. + * + * @return The API duration in milliseconds + */ public Long getDurationApiMs() { return durationApiMs; } + /** + * Sets the API duration in milliseconds. + * + * @param durationApiMs The API duration in milliseconds + */ public void setDurationApiMs(Long durationApiMs) { this.durationApiMs = durationApiMs; } + /** + * Gets the number of turns. + * + * @return The number of turns + */ public Integer getNumTurns() { return numTurns; } + /** + * Sets the number of turns. + * + * @param numTurns The number of turns + */ public void setNumTurns(Integer numTurns) { this.numTurns = numTurns; } + /** + * Gets the usage information. + * + * @return The usage information + */ public ExtendedUsage getUsage() { return usage; } + /** + * Sets the usage information. + * + * @param usage The usage information + */ public void setUsage(ExtendedUsage usage) { this.usage = usage; } + /** + * Gets the model usage information. + * + * @return The model usage information + */ public Map getModelUsage() { return modelUsage; } + /** + * Sets the model usage information. + * + * @param modelUsage The model usage information + */ public void setModelUsage(Map modelUsage) { this.modelUsage = modelUsage; } + /** + * Gets the list of permission denials. + * + * @return The list of permission denials + */ public List getPermissionDenials() { return permissionDenials; } + /** + * Sets the list of permission denials. + * + * @param permissionDenials The list of permission denials + */ public void setPermissionDenials(List permissionDenials) { this.permissionDenials = permissionDenials; } + /** + * Gets the error information. + * + * @return The error information + */ public Error getError() { return error; } + /** + * Sets the error information. + * + * @param error The error information + */ public void setError(Error error) { this.error = error; } + /** + * Represents error information. + */ public static class Error { + /** + * Error type. + */ private String type; + /** + * Error message. + */ private String message; + /** + * Gets the error type. + * + * @return The error type + */ public String getType() { return type; } + /** + * Sets the error type. + * + * @param type The error type + */ public void setType(String type) { this.type = type; } + /** + * Gets the error message. + * + * @return The error message + */ public String getMessage() { return message; } + /** + * Sets the error message. + * + * @param message The error message + */ public void setMessage(String message) { this.message = message; } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKSystemMessage.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKSystemMessage.java index 22870cb85..4a61513d5 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKSystemMessage.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKSystemMessage.java @@ -6,206 +6,476 @@ import java.util.Map; import com.alibaba.fastjson2.annotation.JSONField; import com.alibaba.fastjson2.annotation.JSONType; +/** + * Represents a system message from the SDK. + */ @JSONType(typeKey = "type", typeName = "system") public class SDKSystemMessage extends MessageBase { + /** + * The subtype of the system message. + */ private String subtype; + /** + * The UUID of the message. + */ private String uuid; + /** + * The session ID. + */ @JSONField(name = "session_id") private String sessionId; + /** + * Additional data. + */ private Object data; + /** + * Current working directory. + */ private String cwd; + /** + * List of available tools. + */ private List tools; + /** + * List of MCP servers. + */ @JSONField(name = "mcp_servers") private List mcpServers; + /** + * Model information. + */ private String model; + /** + * Permission mode. + */ @JSONField(name = "permission_mode") private String permissionMode; + /** + * Available slash commands. + */ @JSONField(name = "slash_commands") private List slashCommands; + /** + * Qwen Code version. + */ @JSONField(name = "qwen_code_version") private String qwenCodeVersion; + /** + * Output style. + */ @JSONField(name = "output_style") private String outputStyle; + /** + * Available agents. + */ private List agents; + /** + * Available skills. + */ private List skills; + /** + * Capabilities information. + */ private Map capabilities; + /** + * Compact metadata. + */ @JSONField(name = "compact_metadata") private CompactMetadata compactMetadata; + /** + * Creates a new SDKSystemMessage instance and sets the type to "system". + */ public SDKSystemMessage() { super(); this.type = "system"; } + /** + * Gets the subtype of the system message. + * + * @return The subtype of the system message + */ public String getSubtype() { return subtype; } + /** + * Sets the subtype of the system message. + * + * @param subtype The subtype of the system message + */ public void setSubtype(String subtype) { this.subtype = subtype; } + /** + * Gets the UUID of the message. + * + * @return The UUID of the message + */ public String getUuid() { return uuid; } + /** + * Sets the UUID of the message. + * + * @param uuid The UUID of the message + */ public void setUuid(String uuid) { this.uuid = uuid; } + /** + * Gets the session ID. + * + * @return The session ID + */ public String getSessionId() { return sessionId; } + /** + * Sets the session ID. + * + * @param sessionId The session ID + */ public void setSessionId(String sessionId) { this.sessionId = sessionId; } + /** + * Gets the additional data. + * + * @return The additional data + */ public Object getData() { return data; } + /** + * Sets the additional data. + * + * @param data The additional data + */ public void setData(Object data) { this.data = data; } + /** + * Gets the current working directory. + * + * @return The current working directory + */ public String getCwd() { return cwd; } + /** + * Sets the current working directory. + * + * @param cwd The current working directory + */ public void setCwd(String cwd) { this.cwd = cwd; } + /** + * Gets the list of available tools. + * + * @return The list of available tools + */ public List getTools() { return tools; } + /** + * Sets the list of available tools. + * + * @param tools The list of available tools + */ public void setTools(List tools) { this.tools = tools; } + /** + * Gets the list of MCP servers. + * + * @return The list of MCP servers + */ public List getMcpServers() { return mcpServers; } + /** + * Sets the list of MCP servers. + * + * @param mcpServers The list of MCP servers + */ public void setMcpServers(List mcpServers) { this.mcpServers = mcpServers; } + /** + * Gets the model information. + * + * @return The model information + */ public String getModel() { return model; } + /** + * Sets the model information. + * + * @param model The model information + */ public void setModel(String model) { this.model = model; } + /** + * Gets the permission mode. + * + * @return The permission mode + */ public String getPermissionMode() { return permissionMode; } + /** + * Sets the permission mode. + * + * @param permissionMode The permission mode + */ public void setPermissionMode(String permissionMode) { this.permissionMode = permissionMode; } + /** + * Gets the available slash commands. + * + * @return The available slash commands + */ public List getSlashCommands() { return slashCommands; } + /** + * Sets the available slash commands. + * + * @param slashCommands The available slash commands + */ public void setSlashCommands(List slashCommands) { this.slashCommands = slashCommands; } + /** + * Gets the Qwen Code version. + * + * @return The Qwen Code version + */ public String getQwenCodeVersion() { return qwenCodeVersion; } + /** + * Sets the Qwen Code version. + * + * @param qwenCodeVersion The Qwen Code version + */ public void setQwenCodeVersion(String qwenCodeVersion) { this.qwenCodeVersion = qwenCodeVersion; } + /** + * Gets the output style. + * + * @return The output style + */ public String getOutputStyle() { return outputStyle; } + /** + * Sets the output style. + * + * @param outputStyle The output style + */ public void setOutputStyle(String outputStyle) { this.outputStyle = outputStyle; } + /** + * Gets the available agents. + * + * @return The available agents + */ public List getAgents() { return agents; } + /** + * Sets the available agents. + * + * @param agents The available agents + */ public void setAgents(List agents) { this.agents = agents; } + /** + * Gets the available skills. + * + * @return The available skills + */ public List getSkills() { return skills; } + /** + * Sets the available skills. + * + * @param skills The available skills + */ public void setSkills(List skills) { this.skills = skills; } + /** + * Gets the capabilities information. + * + * @return The capabilities information + */ public Map getCapabilities() { return capabilities; } + /** + * Sets the capabilities information. + * + * @param capabilities The capabilities information + */ public void setCapabilities(Map capabilities) { this.capabilities = capabilities; } + /** + * Gets the compact metadata. + * + * @return The compact metadata + */ public CompactMetadata getCompactMetadata() { return compactMetadata; } + /** + * Sets the compact metadata. + * + * @param compactMetadata The compact metadata + */ public void setCompactMetadata(CompactMetadata compactMetadata) { this.compactMetadata = compactMetadata; } + /** + * Represents MCP server information. + */ public static class McpServer { + /** + * Server name. + */ private String name; + /** + * Server status. + */ private String status; - // Getters and setters + /** + * Gets the server name. + * + * @return The server name + */ public String getName() { return name; } + /** + * Sets the server name. + * + * @param name The server name + */ public void setName(String name) { this.name = name; } + /** + * Gets the server status. + * + * @return The server status + */ public String getStatus() { return status; } + /** + * Sets the server status. + * + * @param status The server status + */ public void setStatus(String status) { this.status = status; } } + /** + * Represents compact metadata. + */ public static class CompactMetadata { + /** + * Trigger information. + */ private String trigger; + /** + * Pre-tokens information. + */ @JSONField(name = "pre_tokens") private Integer preTokens; - // Getters and setters + /** + * Gets the trigger information. + * + * @return The trigger information + */ public String getTrigger() { return trigger; } + /** + * Sets the trigger information. + * + * @param trigger The trigger information + */ public void setTrigger(String trigger) { this.trigger = trigger; } + /** + * Gets the pre-tokens information. + * + * @return The pre-tokens information + */ public Integer getPreTokens() { return preTokens; } + /** + * Sets the pre-tokens information. + * + * @param preTokens The pre-tokens information + */ public void setPreTokens(Integer preTokens) { this.preTokens = preTokens; } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKUserMessage.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKUserMessage.java index e896b08c4..bdd69f01d 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKUserMessage.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKUserMessage.java @@ -5,84 +5,187 @@ import java.util.Map; import com.alibaba.fastjson2.annotation.JSONField; import com.alibaba.fastjson2.annotation.JSONType; +/** + * Represents a user message in the SDK protocol. + */ @JSONType(typeKey = "type", typeName = "user") public class SDKUserMessage extends MessageBase { + /** + * The UUID of the message. + */ private String uuid; + /** + * The session ID. + */ @JSONField(name = "session_id") private String sessionId; + /** + * The API user message. + */ private final APIUserMessage message = new APIUserMessage(); + /** + * The parent tool use ID. + */ @JSONField(name = "parent_tool_use_id") private String parentToolUseId; + /** + * Additional options. + */ private Map options; + /** + * Creates a new SDKUserMessage instance and sets the type to "user". + */ public SDKUserMessage() { super(); this.setType("user"); } + /** + * Gets the UUID of the message. + * + * @return The UUID of the message + */ public String getUuid() { return uuid; } + /** + * Sets the UUID of the message. + * + * @param uuid The UUID of the message + */ public void setUuid(String uuid) { this.uuid = uuid; } + /** + * Gets the session ID. + * + * @return The session ID + */ public String getSessionId() { return sessionId; } + /** + * Sets the session ID. + * + * @param sessionId The session ID + * @return This instance for method chaining + */ public SDKUserMessage setSessionId(String sessionId) { this.sessionId = sessionId; return this; } + /** + * Sets the content of the message. + * + * @param content The content of the message + * @return This instance for method chaining + */ public SDKUserMessage setContent(String content) { message.setContent(content); return this; } + /** + * Gets the content of the message. + * + * @return The content of the message + */ public String getContent() { return message.getContent(); } + /** + * Gets the parent tool use ID. + * + * @return The parent tool use ID + */ public String getParentToolUseId() { return parentToolUseId; } + /** + * Sets the parent tool use ID. + * + * @param parentToolUseId The parent tool use ID + * @return This instance for method chaining + */ public SDKUserMessage setParentToolUseId(String parentToolUseId) { this.parentToolUseId = parentToolUseId; return this; } + /** + * Gets the additional options. + * + * @return The additional options + */ public Map getOptions() { return options; } + /** + * Sets the additional options. + * + * @param options The additional options + * @return This instance for method chaining + */ public SDKUserMessage setOptions(Map options) { this.options = options; return this; } + /** + * Represents the API user message. + */ public static class APIUserMessage { + /** + * User role. + */ private String role = "user"; + /** + * Message content. + */ private String content; - // Getters and Setters + /** + * Gets the user role. + * + * @return The user role + */ public String getRole() { return role; } + /** + * Sets the user role. + * + * @param role The user role + */ public void setRole(String role) { this.role = role; } + /** + * Gets the message content. + * + * @return The message content + */ public String getContent() { return content; } + /** + * Sets the message content. + * + * @param content The message content + */ public void setContent(String content) { this.content = content; } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/APIAssistantMessage.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/APIAssistantMessage.java index 5a0b3776c..b64952228 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/APIAssistantMessage.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/APIAssistantMessage.java @@ -6,71 +6,164 @@ import com.alibaba.fastjson2.annotation.JSONField; import com.alibaba.qwen.code.cli.protocol.data.Usage; import com.alibaba.qwen.code.cli.protocol.message.assistant.block.ContentBlock; +/** + * Represents an API assistant message. + */ public class APIAssistantMessage { + /** + * Message ID. + */ private String id; + /** + * Message type. + */ private String type = "message"; + /** + * Message role. + */ private String role = "assistant"; + /** + * Message model. + */ private String model; - private List content; + /** + * Message content. + */ + private List> content; + /** + * Stop reason. + */ @JSONField(name = "stop_reason") private String stopReason; + /** + * Usage information. + */ private Usage usage; - // Getters and setters + /** + * Gets the message ID. + * + * @return The message ID + */ public String getId() { return id; } + /** + * Sets the message ID. + * + * @param id The message ID + */ public void setId(String id) { this.id = id; } + /** + * Gets the message type. + * + * @return The message type + */ public String getType() { return type; } + /** + * Sets the message type. + * + * @param type The message type + */ public void setType(String type) { this.type = type; } + /** + * Gets the message role. + * + * @return The message role + */ public String getRole() { return role; } + /** + * Sets the message role. + * + * @param role The message role + */ public void setRole(String role) { this.role = role; } + /** + * Gets the message model. + * + * @return The message model + */ public String getModel() { return model; } + /** + * Sets the message model. + * + * @param model The message model + */ public void setModel(String model) { this.model = model; } + /** + * Gets the stop reason. + * + * @return The stop reason + */ public String getStopReason() { return stopReason; } + /** + * Sets the stop reason. + * + * @param stopReason The stop reason + */ public void setStopReason(String stopReason) { this.stopReason = stopReason; } + /** + * Gets the usage information. + * + * @return The usage information + */ public Usage getUsage() { return usage; } + /** + * Sets the usage information. + * + * @param usage The usage information + */ public void setUsage(Usage usage) { this.usage = usage; } - public List getContent() { + /** + * Gets the message content. + * + * @return The message content + */ + public List> getContent() { return content; } - public void setContent(List content) { + /** + * Sets the message content. + * + * @param content The message content + */ + public void setContent(List> content) { this.content = content; } } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKAssistantMessage.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKAssistantMessage.java index b0a3012c4..efb6071cf 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKAssistantMessage.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKAssistantMessage.java @@ -4,50 +4,113 @@ import com.alibaba.fastjson2.annotation.JSONField; import com.alibaba.fastjson2.annotation.JSONType; import com.alibaba.qwen.code.cli.protocol.message.MessageBase; +/** + * Represents an SDK assistant message. + */ @JSONType(typeKey = "type", typeName = "assistant") public class SDKAssistantMessage extends MessageBase { + /** + * The UUID of the message. + */ private String uuid; + /** + * The session ID. + */ @JSONField(name = "session_id") private String sessionId; + /** + * The API assistant message. + */ private APIAssistantMessage message; + /** + * The parent tool use ID. + */ @JSONField(name = "parent_tool_use_id") private String parentToolUseId; + /** + * Creates a new SDKAssistantMessage instance and sets the type to "assistant". + */ public SDKAssistantMessage() { super(); this.type = "assistant"; } + @Override + public String getMessageId() { + return this.getUuid(); + } + + /** + * Gets the UUID of the message. + * + * @return The UUID of the message + */ public String getUuid() { return uuid; } + /** + * Sets the UUID of the message. + * + * @param uuid The UUID of the message + */ public void setUuid(String uuid) { this.uuid = uuid; } + /** + * Gets the session ID. + * + * @return The session ID + */ public String getSessionId() { return sessionId; } + /** + * Sets the session ID. + * + * @param sessionId The session ID + */ public void setSessionId(String sessionId) { this.sessionId = sessionId; } + /** + * Gets the API assistant message. + * + * @return The API assistant message + */ public APIAssistantMessage getMessage() { return message; } + /** + * Sets the API assistant message. + * + * @param message The API assistant message + */ public void setMessage(APIAssistantMessage message) { this.message = message; } + /** + * Gets the parent tool use ID. + * + * @return The parent tool use ID + */ public String getParentToolUseId() { return parentToolUseId; } + /** + * Sets the parent tool use ID. + * + * @param parentToolUseId The parent tool use ID + */ public void setParentToolUseId(String parentToolUseId) { this.parentToolUseId = parentToolUseId; } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKPartialAssistantMessage.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKPartialAssistantMessage.java index a9ac24d05..2c7cc0934 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKPartialAssistantMessage.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKPartialAssistantMessage.java @@ -5,50 +5,108 @@ import com.alibaba.fastjson2.annotation.JSONType; import com.alibaba.qwen.code.cli.protocol.message.MessageBase; import com.alibaba.qwen.code.cli.protocol.message.assistant.event.StreamEvent; +/** + * Represents a partial assistant message during streaming. + */ @JSONType(typeKey = "type", typeName = "stream_event") public class SDKPartialAssistantMessage extends MessageBase { + /** + * The UUID of the message. + */ private String uuid; + /** + * The session ID. + */ @JSONField(name = "session_id") private String sessionId; + /** + * The stream event. + */ private StreamEvent event; + /** + * The parent tool use ID. + */ @JSONField(name = "parent_tool_use_id") private String parentToolUseId; + /** + * Creates a new SDKPartialAssistantMessage instance and sets the type to "stream_event". + */ public SDKPartialAssistantMessage() { super(); this.type = "stream_event"; } + /** + * Gets the UUID of the message. + * + * @return The UUID of the message + */ public String getUuid() { return uuid; } + /** + * Sets the UUID of the message. + * + * @param uuid The UUID of the message + */ public void setUuid(String uuid) { this.uuid = uuid; } + /** + * Gets the session ID. + * + * @return The session ID + */ public String getSessionId() { return sessionId; } + /** + * Sets the session ID. + * + * @param sessionId The session ID + */ public void setSessionId(String sessionId) { this.sessionId = sessionId; } + /** + * Gets the stream event. + * + * @return The stream event + */ public StreamEvent getEvent() { return event; } + /** + * Sets the stream event. + * + * @param event The stream event + */ public void setEvent(StreamEvent event) { this.event = event; } + /** + * Gets the parent tool use ID. + * + * @return The parent tool use ID + */ public String getParentToolUseId() { return parentToolUseId; } + /** + * Sets the parent tool use ID. + * + * @param parentToolUseId The parent tool use ID + */ public void setParentToolUseId(String parentToolUseId) { this.parentToolUseId = parentToolUseId; } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/Annotation.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/Annotation.java index 5e8b9a2b5..e78cf3576 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/Annotation.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/Annotation.java @@ -2,26 +2,54 @@ package com.alibaba.qwen.code.cli.protocol.message.assistant.block; import com.alibaba.fastjson2.annotation.JSONField; +/** + * Represents an annotation for a content block. + */ public class Annotation { + /** + * The annotation type. + */ @JSONField(name = "type") private String type; + /** + * The annotation value. + */ @JSONField(name = "value") private String value; - // Getters and setters + /** + * Gets the annotation type. + * + * @return The annotation type + */ public String getType() { return type; } + /** + * Sets the annotation type. + * + * @param type The annotation type + */ public void setType(String type) { this.type = type; } + /** + * Gets the annotation value. + * + * @return The annotation value + */ public String getValue() { return value; } + /** + * Sets the annotation value. + * + * @param value The annotation value + */ public void setValue(String value) { this.value = value; } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ContentBlock.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ContentBlock.java index d40200c6e..c8c7284b0 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ContentBlock.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ContentBlock.java @@ -6,27 +6,72 @@ import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.annotation.JSONType; import com.alibaba.qwen.code.cli.protocol.data.AssistantContent; +/** + * Abstract base class for content blocks in assistant messages. + * + * @param The type of content + */ @JSONType(typeKey = "type", typeName = "ContentBlock", seeAlso = { TextBlock.class, ToolResultBlock.class, ThinkingBlock.class, ToolUseBlock.class }) -public abstract class ContentBlock implements AssistantContent { +public abstract class ContentBlock implements AssistantContent { + /** + * The type of the content block. + */ protected String type; + /** + * List of annotations. + */ protected List annotations; + /** + * The message ID. + */ + protected String messageId; + @Override public String getType() { return type; } + /** + * Sets the type of the content block. + * + * @param type The type of the content block + */ public void setType(String type) { this.type = type; } + /** + * Gets the list of annotations. + * + * @return The list of annotations + */ public List getAnnotations() { return annotations; } + /** + * Sets the list of annotations. + * + * @param annotations The list of annotations + */ public void setAnnotations(List annotations) { this.annotations = annotations; } + @Override + public String getMessageId() { + return messageId; + } + + /** + * Sets the message ID. + * + * @param messageId The message ID + */ + public void setMessageId(String messageId) { + this.messageId = messageId; + } + public String toString() { return JSON.toJSONString(this); } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/TextBlock.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/TextBlock.java index 7a8cf7d43..9980a74b8 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/TextBlock.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/TextBlock.java @@ -1,21 +1,38 @@ package com.alibaba.qwen.code.cli.protocol.message.assistant.block; import com.alibaba.fastjson2.annotation.JSONType; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.TextAssistantContent; +/** + * Represents a text content block. + */ @JSONType(typeKey = "type", typeName = "text") -public class TextBlock extends ContentBlock { +public class TextBlock extends ContentBlock implements TextAssistantContent { + /** + * The text content. + */ private String text; + /** + * Gets the text content. + * + * @return The text content + */ public String getText() { return text; } + /** + * Sets the text content. + * + * @param text The text content + */ public void setText(String text) { this.text = text; } @Override - public Object getContent() { + public String getContentOfAssistant() { return text; } } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ThinkingBlock.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ThinkingBlock.java index 4a133840f..9b33730bc 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ThinkingBlock.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ThinkingBlock.java @@ -1,30 +1,60 @@ package com.alibaba.qwen.code.cli.protocol.message.assistant.block; import com.alibaba.fastjson2.annotation.JSONType; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ThingkingAssistantContent; +/** + * Represents a thinking content block. + */ @JSONType(typeKey = "type", typeName = "thinking") -public class ThinkingBlock extends ContentBlock{ +public class ThinkingBlock extends ContentBlock implements ThingkingAssistantContent { + /** + * The thinking content. + */ private String thinking; + /** + * The signature. + */ private String signature; + /** + * Gets the thinking content. + * + * @return The thinking content + */ public String getThinking() { return thinking; } + /** + * Sets the thinking content. + * + * @param thinking The thinking content + */ public void setThinking(String thinking) { this.thinking = thinking; } + /** + * Gets the signature. + * + * @return The signature + */ public String getSignature() { return signature; } + /** + * Sets the signature. + * + * @param signature The signature + */ public void setSignature(String signature) { this.signature = signature; } @Override - public Object getContent() { + public String getContentOfAssistant() { return thinking; } } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolResultBlock.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolResultBlock.java index 3d7acfea2..43da74b0c 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolResultBlock.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolResultBlock.java @@ -2,39 +2,87 @@ package com.alibaba.qwen.code.cli.protocol.message.assistant.block; import com.alibaba.fastjson2.annotation.JSONField; import com.alibaba.fastjson2.annotation.JSONType; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolResultAssistantContent; +/** + * Represents a tool result content block. + */ @JSONType(typeKey = "type", typeName = "tool_result") -public class ToolResultBlock extends ContentBlock { +public class ToolResultBlock extends ContentBlock implements ToolResultAssistantContent { + /** + * The tool use ID. + */ @JSONField(name = "tool_use_id") private String toolUseId; + /** + * The result content. + */ @JSONField(name = "content") - private Object content; // Can be String or List + private String content; + /** + * Whether the result is an error. + */ @JSONField(name = "is_error") private Boolean isError; + /** + * Gets the tool use ID. + * + * @return The tool use ID + */ public String getToolUseId() { return toolUseId; } + /** + * Sets the tool use ID. + * + * @param toolUseId The tool use ID + */ public void setToolUseId(String toolUseId) { this.toolUseId = toolUseId; } - public Object getContent() { + /** + * Gets the result content. + * + * @return The result content + */ + public String getContent() { return content; } - public void setContent(Object content) { + /** + * Sets the result content. + * + * @param content The result content + */ + public void setContent(String content) { this.content = content; } + /** + * Gets whether the result is an error. + * + * @return Whether the result is an error + */ public Boolean getIsError() { return isError; } + /** + * Sets whether the result is an error. + * + * @param isError Whether the result is an error + */ public void setIsError(Boolean isError) { this.isError = isError; } + + @Override + public String getContentOfAssistant() { + return content; + } } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolUseBlock.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolUseBlock.java index ef5de8b02..da3624a67 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolUseBlock.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolUseBlock.java @@ -1,54 +1,113 @@ package com.alibaba.qwen.code.cli.protocol.message.assistant.block; +import java.util.Collections; import java.util.List; import java.util.Map; import com.alibaba.fastjson2.annotation.JSONType; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolUseAssistantContent; +/** + * Represents a tool use content block. + */ @JSONType(typeKey = "type", typeName = "tool_use") -public class ToolUseBlock extends ContentBlock { +public class ToolUseBlock extends ContentBlock> implements ToolUseAssistantContent { + /** + * The tool use ID. + */ private String id; + /** + * The tool name. + */ private String name; + /** + * The tool input. + */ private Map input; + /** + * List of annotations. + */ private List annotations; - // 构造函数 + /** + * Creates a new ToolUseBlock instance. + */ public ToolUseBlock() {} + /** + * Gets the tool use ID. + * + * @return The tool use ID + */ public String getId() { return id; } + /** + * Sets the tool use ID. + * + * @param id The tool use ID + */ public void setId(String id) { this.id = id; } + /** + * Gets the tool name. + * + * @return The tool name + */ public String getName() { return name; } + /** + * Sets the tool name. + * + * @param name The tool name + */ public void setName(String name) { this.name = name; } + /** + * Gets the tool input. + * + * @return The tool input + */ public Map getInput() { return input; } + /** + * Sets the tool input. + * + * @param input The tool input + */ public void setInput(Map input) { this.input = input; } + /** + * Gets the list of annotations. + * + * @return The list of annotations + */ public List getAnnotations() { return annotations; } + /** + * Sets the list of annotations. + * + * @param annotations The list of annotations + */ public void setAnnotations(List annotations) { this.annotations = annotations; } @Override - public Object getContent() { - return input; + public Map getContentOfAssistant() { + return Collections.emptyMap(); } } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockDeltaEvent.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockDeltaEvent.java index 78b7961cc..6486404f8 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockDeltaEvent.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockDeltaEvent.java @@ -1,96 +1,221 @@ package com.alibaba.qwen.code.cli.protocol.message.assistant.event; +import java.util.Map; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.TypeReference; import com.alibaba.fastjson2.annotation.JSONField; import com.alibaba.fastjson2.annotation.JSONType; import com.alibaba.qwen.code.cli.protocol.data.AssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.TextAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ThingkingAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolUseAssistantContent; +/** + * Represents a content block delta event during streaming. + */ @JSONType(typeKey = "type", typeName = "content_block_delta") public class ContentBlockDeltaEvent extends StreamEvent { + /** + * The index of the content block. + */ private int index; - private ContentBlockDelta delta; + /** + * The content block delta. + */ + private ContentBlockDelta delta; + /** + * Gets the index of the content block. + * + * @return The index of the content block + */ public int getIndex() { return index; } + /** + * Sets the index of the content block. + * + * @param index The index of the content block + */ public void setIndex(int index) { this.index = index; } - public ContentBlockDelta getDelta() { + /** + * Gets the content block delta. + * + * @return The content block delta + */ + public ContentBlockDelta getDelta() { return delta; } - public void setDelta(ContentBlockDelta delta) { + /** + * Sets the content block delta. + * + * @param delta The content block delta + */ + public void setDelta(ContentBlockDelta delta) { this.delta = delta; } + /** + * Abstract base class for content block deltas. + * + * @param The type of content + */ @JSONType(typeKey = "type", typeName = "ContentBlockDelta", seeAlso = {ContentBlockDeltaText.class, ContentBlockDeltaThinking.class, ContentBlockDeltaInputJson.class}) - public abstract static class ContentBlockDelta implements AssistantContent { - private String type; + public abstract static class ContentBlockDelta implements AssistantContent { + /** + * The type of the content block delta. + */ + protected String type; + /** + * The message ID. + */ + protected String messageId; + @Override public String getType() { return type; } + /** + * Sets the type of the content block delta. + * + * @param type The type of the content block delta + */ public void setType(String type) { this.type = type; } + + @Override + public String getMessageId() { + return messageId; + } + + /** + * Sets the message ID. + * + * @param messageId The message ID + */ + public void setMessageId(String messageId) { + this.messageId = messageId; + } + + public String toString() { + return JSON.toJSONString(this); + } } + /** + * Represents a text delta. + */ @JSONType(typeKey = "type", typeName = "text_delta") - public static class ContentBlockDeltaText extends ContentBlockDelta { + public static class ContentBlockDeltaText extends ContentBlockDelta implements TextAssistantContent { + /** + * The text content. + */ private String text; + /** + * Gets the text content. + * + * @return The text content + */ public String getText() { return text; } + /** + * Sets the text content. + * + * @param text The text content + */ public void setText(String text) { this.text = text; } @Override - public Object getContent() { + public String getContentOfAssistant() { return text; } } + /** + * Represents a thinking delta. + */ @JSONType(typeKey = "type", typeName = "thinking_delta") - public static class ContentBlockDeltaThinking extends ContentBlockDelta { + public static class ContentBlockDeltaThinking extends ContentBlockDelta implements ThingkingAssistantContent { + /** + * The thinking content. + */ private String thinking; + /** + * Gets the thinking content. + * + * @return The thinking content + */ public String getThinking() { return thinking; } + /** + * Sets the thinking content. + * + * @param thinking The thinking content + */ public void setThinking(String thinking) { this.thinking = thinking; } @Override - public Object getContent() { + public String getContentOfAssistant() { return thinking; } } + /** + * Represents an input JSON delta. + */ @JSONType(typeKey = "type", typeName = "input_json_delta") - public static class ContentBlockDeltaInputJson extends ContentBlockDelta { + public static class ContentBlockDeltaInputJson extends ContentBlockDelta> implements ToolUseAssistantContent { + /** + * The partial JSON content. + */ @JSONField(name = "partial_json") private String partialJson; + /** + * Gets the partial JSON content. + * + * @return The partial JSON content + */ public String getPartialJson() { return partialJson; } + /** + * Sets the partial JSON content. + * + * @param partialJson The partial JSON content + */ public void setPartialJson(String partialJson) { this.partialJson = partialJson; } @Override - public Object getContent() { - return partialJson; + public Map getContentOfAssistant() { + return getInput(); + } + + @Override + public Map getInput() { + return JSON.parseObject(partialJson, new TypeReference>() {}); } } } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStartEvent.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStartEvent.java index 884558512..eaf132934 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStartEvent.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStartEvent.java @@ -4,10 +4,19 @@ import com.alibaba.fastjson2.annotation.JSONField; import com.alibaba.fastjson2.annotation.JSONType; import com.alibaba.qwen.code.cli.protocol.message.assistant.block.ContentBlock; +/** + * Represents a content block start event during message streaming. + */ @JSONType(typeKey = "type", typeName = "content_block_start") public class ContentBlockStartEvent extends StreamEvent{ + /** + * The index of the content block. + */ private int index; + /** + * The content block that is starting. + */ @JSONField(name = "content_block") private ContentBlock contentBlock; } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStopEvent.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStopEvent.java index 0e950f817..9b4529b83 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStopEvent.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStopEvent.java @@ -2,14 +2,30 @@ package com.alibaba.qwen.code.cli.protocol.message.assistant.event; import com.alibaba.fastjson2.annotation.JSONType; +/** + * Represents a content block stop event during message streaming. + */ @JSONType(typeKey = "type", typeName = "content_block_stop") public class ContentBlockStopEvent extends StreamEvent{ + /** + * The index of the content block. + */ Long index; + /** + * Gets the index of the content block. + * + * @return The index of the content block + */ public Long getIndex() { return index; } + /** + * Sets the index of the content block. + * + * @param index The index of the content block + */ public void setIndex(Long index) { this.index = index; } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStartStreamEvent.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStartStreamEvent.java index 88be40545..cdd89ba4a 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStartStreamEvent.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStartStreamEvent.java @@ -2,45 +2,102 @@ package com.alibaba.qwen.code.cli.protocol.message.assistant.event; import com.alibaba.fastjson2.annotation.JSONType; +/** + * Represents a message start event during message streaming. + */ @JSONType(typeName = "message_start") public class MessageStartStreamEvent extends StreamEvent{ + /** + * The message that is starting. + */ private Message message; + /** + * Represents the message information. + */ public static class Message { + /** + * Message ID. + */ private String id; + /** + * Message role. + */ private String role; + /** + * Message model. + */ private String model; - // Getters and setters + /** + * Gets the message ID. + * + * @return The message ID + */ public String getId() { return id; } + /** + * Sets the message ID. + * + * @param id The message ID + */ public void setId(String id) { this.id = id; } + /** + * Gets the message role. + * + * @return The message role + */ public String getRole() { return role; } + /** + * Sets the message role. + * + * @param role The message role + */ public void setRole(String role) { this.role = role; } + /** + * Gets the message model. + * + * @return The message model + */ public String getModel() { return model; } + /** + * Sets the message model. + * + * @param model The message model + */ public void setModel(String model) { this.model = model; } } + /** + * Gets the message that is starting. + * + * @return The message that is starting + */ public Message getMessage() { return message; } + /** + * Sets the message that is starting. + * + * @param message The message that is starting + */ public void setMessage(Message message) { this.message = message; } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStopStreamEvent.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStopStreamEvent.java index 3ea04bc50..602ae4dfd 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStopStreamEvent.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStopStreamEvent.java @@ -2,6 +2,9 @@ package com.alibaba.qwen.code.cli.protocol.message.assistant.event; import com.alibaba.fastjson2.annotation.JSONType; +/** + * Represents a message stop event during message streaming. + */ @JSONType(typeName = "message_stop") public class MessageStopStreamEvent extends StreamEvent{ } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/StreamEvent.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/StreamEvent.java index d288402fa..1a4627dd5 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/StreamEvent.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/StreamEvent.java @@ -2,16 +2,32 @@ package com.alibaba.qwen.code.cli.protocol.message.assistant.event; import com.alibaba.fastjson2.annotation.JSONType; +/** + * Base class for stream events during message streaming. + */ @JSONType(typeKey = "type", typeName = "StreamEvent", seeAlso = {MessageStartStreamEvent.class, MessageStopStreamEvent.class, ContentBlockStartEvent.class, ContentBlockStopEvent.class, ContentBlockDeltaEvent.class}) public class StreamEvent { + /** + * The type of the stream event. + */ protected String type; + /** + * Gets the type of the stream event. + * + * @return The type of the stream event + */ public String getType() { return type; } + /** + * Sets the type of the stream event. + * + * @param type The type of the stream event + */ public void setType(String type) { this.type = type; } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInitializeRequest.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInitializeRequest.java index 3d217289c..6ddc66e41 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInitializeRequest.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInitializeRequest.java @@ -3,24 +3,54 @@ package com.alibaba.qwen.code.cli.protocol.message.control; import com.alibaba.fastjson2.annotation.JSONField; import com.alibaba.qwen.code.cli.protocol.data.InitializeConfig; +/** + * Represents a control initialize request to the CLI. + */ public class CLIControlInitializeRequest { + /** + * The subtype of the request. + */ String subtype = "initialize"; + /** + * The initialization configuration. + */ @JSONField(unwrapped = true) InitializeConfig initializeConfig = new InitializeConfig(); + /** + * Gets the subtype of the request. + * + * @return The subtype of the request + */ public String getSubtype() { return subtype; } + /** + * Sets the subtype of the request. + * + * @param subtype The subtype of the request + */ public void setSubtype(String subtype) { this.subtype = subtype; } + /** + * Gets the initialization configuration. + * + * @return The initialization configuration + */ public InitializeConfig getInitializeConfig() { return initializeConfig; } + /** + * Sets the initialization configuration. + * + * @param initializeConfig The initialization configuration + * @return This instance for method chaining + */ public CLIControlInitializeRequest setInitializeConfig(InitializeConfig initializeConfig) { this.initializeConfig = initializeConfig; return this; diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInitializeResponse.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInitializeResponse.java index 284781a76..69da95deb 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInitializeResponse.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInitializeResponse.java @@ -2,22 +2,51 @@ package com.alibaba.qwen.code.cli.protocol.message.control; import com.alibaba.qwen.code.cli.protocol.data.Capabilities; +/** + * Represents a control initialize response from the CLI. + */ public class CLIControlInitializeResponse { + /** + * The subtype of the response. + */ String subtype = "initialize"; + /** + * The capabilities' information. + */ Capabilities capabilities; + /** + * Gets the subtype of the response. + * + * @return The subtype of the response + */ public String getSubtype() { return subtype; } + /** + * Sets the subtype of the response. + * + * @param subtype The subtype of the response + */ public void setSubtype(String subtype) { this.subtype = subtype; } + /** + * Gets the capabilities information. + * + * @return The capabilities information + */ public Capabilities getCapabilities() { return capabilities; } + /** + * Sets the capabilities information. + * + * @param capabilities The capabilities information + */ public void setCapabilities(Capabilities capabilities) { this.capabilities = capabilities; } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInterruptRequest.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInterruptRequest.java index f4a052697..2b1ec9fc5 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInterruptRequest.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInterruptRequest.java @@ -1,12 +1,28 @@ package com.alibaba.qwen.code.cli.protocol.message.control; +/** + * Represents a control interrupt request to the CLI. + */ public class CLIControlInterruptRequest { + /** + * The subtype of the request ("interrupt"). + */ String subtype = "interrupt"; + /** + * Gets the subtype of the request. + * + * @return The subtype of the request + */ public String getSubtype() { return subtype; } + /** + * Sets the subtype of the request. + * + * @param subtype The subtype of the request + */ public void setSubtype(String subtype) { this.subtype = subtype; } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlPermissionRequest.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlPermissionRequest.java index ac3e43e79..e03d86bad 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlPermissionRequest.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlPermissionRequest.java @@ -5,106 +5,242 @@ import java.util.Map; import com.alibaba.fastjson2.annotation.JSONField; +/** + * Represents a control permission request to the CLI. + */ public class CLIControlPermissionRequest { + /** + * The subtype of the request. + */ private String subtype; + /** + * The name of the tool requesting permission. + */ @JSONField(name = "tool_name") private String toolName; + /** + * The ID of the tool use. + */ @JSONField(name = "tool_use_id") private String toolUseId; + /** + * The input for the tool. + */ private Map input; + /** + * List of permission suggestions. + */ @JSONField(name = "permission_suggestions") private List permissionSuggestions; + /** + * The blocked path. + */ @JSONField(name = "blocked_path") private String blockedPath; + /** + * Gets the subtype of the request. + * + * @return The subtype of the request + */ public String getSubtype() { return subtype; } + /** + * Sets the subtype of the request. + * + * @param subtype The subtype of the request + */ public void setSubtype(String subtype) { this.subtype = subtype; } + /** + * Gets the name of the tool requesting permission. + * + * @return The name of the tool requesting permission + */ public String getToolName() { return toolName; } + /** + * Sets the name of the tool requesting permission. + * + * @param toolName The name of the tool requesting permission + */ public void setToolName(String toolName) { this.toolName = toolName; } + /** + * Gets the ID of the tool use. + * + * @return The ID of the tool use + */ public String getToolUseId() { return toolUseId; } + /** + * Sets the ID of the tool use. + * + * @param toolUseId The ID of the tool use + */ public void setToolUseId(String toolUseId) { this.toolUseId = toolUseId; } + /** + * Gets the input for the tool. + * + * @return The input for the tool + */ public Map getInput() { return input; } + /** + * Sets the input for the tool. + * + * @param input The input for the tool + */ public void setInput(Map input) { this.input = input; } + /** + * Gets the list of permission suggestions. + * + * @return The list of permission suggestions + */ public List getPermissionSuggestions() { return permissionSuggestions; } + /** + * Sets the list of permission suggestions. + * + * @param permissionSuggestions The list of permission suggestions + */ public void setPermissionSuggestions( List permissionSuggestions) { this.permissionSuggestions = permissionSuggestions; } + /** + * Gets the blocked path. + * + * @return The blocked path + */ public String getBlockedPath() { return blockedPath; } + /** + * Sets the blocked path. + * + * @param blockedPath The blocked path + */ public void setBlockedPath(String blockedPath) { this.blockedPath = blockedPath; } + /** + * Represents a permission suggestion. + */ public static class PermissionSuggestion { + /** + * The type of suggestion (allow, deny, modify). + */ private String type; // 'allow' | 'deny' | 'modify' + /** + * The label for the suggestion. + */ private String label; + /** + * The description of the suggestion. + */ private String description; + /** + * The modified input. + */ private Object modifiedInput; + /** + * Gets the type of suggestion. + * + * @return The type of suggestion + */ public String getType() { return type; } + /** + * Sets the type of suggestion. + * + * @param type The type of suggestion + */ public void setType(String type) { this.type = type; } + /** + * Gets the label for the suggestion. + * + * @return The label for the suggestion + */ public String getLabel() { return label; } + /** + * Sets the label for the suggestion. + * + * @param label The label for the suggestion + */ public void setLabel(String label) { this.label = label; } + /** + * Gets the description of the suggestion. + * + * @return The description of the suggestion + */ public String getDescription() { return description; } + /** + * Sets the description of the suggestion. + * + * @param description The description of the suggestion + */ public void setDescription(String description) { this.description = description; } + /** + * Gets the modified input. + * + * @return The modified input + */ public Object getModifiedInput() { return modifiedInput; } + /** + * Sets the modified input. + * + * @param modifiedInput The modified input + */ public void setModifiedInput(Object modifiedInput) { this.modifiedInput = modifiedInput; } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlPermissionResponse.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlPermissionResponse.java index 66c199632..2a1d22588 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlPermissionResponse.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlPermissionResponse.java @@ -3,24 +3,54 @@ package com.alibaba.qwen.code.cli.protocol.message.control; import com.alibaba.fastjson2.annotation.JSONField; import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior; +/** + * Represents a control permission response from the CLI. + */ public class CLIControlPermissionResponse { + /** + * The subtype of the response ("can_use_tool"). + */ private String subtype = "can_use_tool"; + /** + * The behavior for the permission request. + */ @JSONField(unwrapped = true) Behavior behavior; + /** + * Gets the subtype of the response. + * + * @return The subtype of the response + */ public String getSubtype() { return subtype; } + /** + * Sets the subtype of the response. + * + * @param subtype The subtype of the response + */ public void setSubtype(String subtype) { this.subtype = subtype; } + /** + * Gets the behavior for the permission request. + * + * @return The behavior for the permission request + */ public Behavior getBehavior() { return behavior; } + /** + * Sets the behavior for the permission request. + * + * @param behavior The behavior for the permission request + * @return This instance for method chaining + */ public CLIControlPermissionResponse setBehavior(Behavior behavior) { this.behavior = behavior; return this; diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlRequest.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlRequest.java index e6ea7b956..e12319cad 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlRequest.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlRequest.java @@ -6,37 +6,80 @@ import com.alibaba.fastjson2.annotation.JSONField; import com.alibaba.fastjson2.annotation.JSONType; import com.alibaba.qwen.code.cli.protocol.message.MessageBase; +/** + * Represents a control request to the CLI. + * + * @param The type of the request object + */ @JSONType(typeKey = "type", typeName = "control_request") public class CLIControlRequest extends MessageBase { + /** + * The ID of the request. + */ @JSONField(name = "request_id") private String requestId = UUID.randomUUID().toString(); + /** + * The actual request object. + */ private R request; + /** + * Creates a new CLIControlRequest instance and sets the type to "control_request". + */ public CLIControlRequest() { super(); type = "control_request"; } + /** + * Creates a new control request with the specified request object. + * + * @param request The request object + * @param The type of the request object + * @return A new control request instance + */ public static CLIControlRequest create(T request) { CLIControlRequest controlRequest = new CLIControlRequest<>(); controlRequest.setRequest(request); return controlRequest; } + /** + * Gets the ID of the request. + * + * @return The ID of the request + */ public String getRequestId() { return requestId; } + /** + * Sets the ID of the request. + * + * @param requestId The ID of the request + * @return This instance for method chaining + */ public CLIControlRequest setRequestId(String requestId) { this.requestId = requestId; return this; } + /** + * Gets the actual request object. + * + * @return The actual request object + */ public R getRequest() { return request; } + /** + * Sets the actual request object. + * + * @param request The actual request object + * @return This instance for method chaining + */ public CLIControlRequest setRequest(R request) { this.request = request; return this; diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlResponse.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlResponse.java index bce0c03cc..f71c2156c 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlResponse.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlResponse.java @@ -4,57 +4,130 @@ import com.alibaba.fastjson2.annotation.JSONField; import com.alibaba.fastjson2.annotation.JSONType; import com.alibaba.qwen.code.cli.protocol.message.MessageBase; +/** + * Represents a control response from the CLI. + * + * @param The type of the response object + */ @JSONType(typeKey = "type", typeName = "control_response") public class CLIControlResponse extends MessageBase { + /** + * The response object. + */ private Response response; + /** + * Creates a new CLIControlResponse instance and sets the type to "control_response". + */ public CLIControlResponse() { super(); this.type = "control_response"; } + /** + * Gets the response object. + * + * @return The response object + */ public Response getResponse() { return response; } + /** + * Sets the response object. + * + * @param response The response object + */ public void setResponse(Response response) { this.response = response; } + /** + * Creates a new response object. + * + * @return A new response object + */ public Response createResponse() { Response response = new Response<>(); this.setResponse(response); return response; } + /** + * Represents the response information. + * + * @param The type of the response object + */ public static class Response { + /** + * The ID of the request. + */ @JSONField(name = "request_id") private String requestId; + /** + * The subtype of the response. + */ private String subtype = "success"; + /** + * The actual response. + */ R response; + /** + * Gets the ID of the request. + * + * @return The ID of the request + */ public String getRequestId() { return requestId; } + /** + * Sets the ID of the request. + * + * @param requestId The ID of the request + * @return This instance for method chaining + */ public Response setRequestId(String requestId) { this.requestId = requestId; return this; } + /** + * Gets the subtype of the response. + * + * @return The subtype of the response + */ public String getSubtype() { return subtype; } + /** + * Sets the subtype of the response. + * + * @param subtype The subtype of the response + * @return This instance for method chaining + */ public Response setSubtype(String subtype) { this.subtype = subtype; return this; } + /** + * Gets the actual response. + * + * @return The actual response + */ public R getResponse() { return response; } + /** + * Sets the actual response. + * + * @param response The actual response + * @return This instance for method chaining + */ public Response setResponse(R response) { this.response = response; return this; diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetModelRequest.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetModelRequest.java index d93a6fb6d..8b1e5d8dc 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetModelRequest.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetModelRequest.java @@ -1,21 +1,50 @@ package com.alibaba.qwen.code.cli.protocol.message.control; +/** + * Represents a control request to set the model in the CLI. + */ public class CLIControlSetModelRequest { + /** + * The subtype of the request ("set_model"). + */ String subtype = "set_model"; + /** + * The model to set. + */ String model; + /** + * Gets the subtype of the request. + * + * @return The subtype of the request + */ public String getSubtype() { return subtype; } + /** + * Sets the subtype of the request. + * + * @param subtype The subtype of the request + */ public void setSubtype(String subtype) { this.subtype = subtype; } + /** + * Gets the model to set. + * + * @return The model to set + */ public String getModel() { return model; } + /** + * Sets the model to set. + * + * @param model The model to set + */ public void setModel(String model) { this.model = model; } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetModelResponse.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetModelResponse.java index 71d6b0e38..d5a0c2c87 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetModelResponse.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetModelResponse.java @@ -1,21 +1,50 @@ package com.alibaba.qwen.code.cli.protocol.message.control; +/** + * Represents a control response for setting the model in the CLI. + */ public class CLIControlSetModelResponse { + /** + * The subtype of the response ("set_model"). + */ String subtype = "set_model"; + /** + * The model that was set. + */ String model; + /** + * Gets the subtype of the response. + * + * @return The subtype of the response + */ public String getSubtype() { return subtype; } + /** + * Sets the subtype of the response. + * + * @param subtype The subtype of the response + */ public void setSubtype(String subtype) { this.subtype = subtype; } + /** + * Gets the model that was set. + * + * @return The model that was set + */ public String getModel() { return model; } + /** + * Sets the model that was set. + * + * @param model The model that was set + */ public void setModel(String model) { this.model = model; } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetPermissionModeRequest.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetPermissionModeRequest.java index ea1ad9698..587fafdc5 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetPermissionModeRequest.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetPermissionModeRequest.java @@ -1,22 +1,51 @@ package com.alibaba.qwen.code.cli.protocol.message.control; +/** + * Represents a control request to set the permission mode in the CLI. + */ public class CLIControlSetPermissionModeRequest { + /** + * The subtype of the request ("set_permission_mode"). + */ String subtype = "set_permission_mode"; + /** + * The permission mode to set. + */ String mode; + /** + * Gets the subtype of the request. + * + * @return The subtype of the request + */ public String getSubtype() { return subtype; } + /** + * Sets the subtype of the request. + * + * @param subtype The subtype of the request + */ public void setSubtype(String subtype) { this.subtype = subtype; } + /** + * Gets the permission mode to set. + * + * @return The permission mode to set + */ public String getMode() { return mode; } + /** + * Sets the permission mode to set. + * + * @param mode The permission mode to set + */ public void setMode(String mode) { this.mode = mode; } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/Session.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/Session.java index c3e605476..2706bcb1a 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/Session.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/Session.java @@ -31,6 +31,7 @@ import com.alibaba.qwen.code.cli.session.event.SessionEventConsumers; import com.alibaba.qwen.code.cli.session.exception.SessionControlException; import com.alibaba.qwen.code.cli.session.exception.SessionSendPromptException; import com.alibaba.qwen.code.cli.transport.Transport; +import com.alibaba.qwen.code.cli.transport.TransportOptions; import com.alibaba.qwen.code.cli.utils.MyConcurrentUtils; import com.alibaba.qwen.code.cli.utils.Timeout; @@ -38,6 +39,9 @@ import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +/** + * Manages a session with the Qwen Code CLI, handling communication, sending prompts, and processing responses. + */ public class Session { private static final Logger log = LoggerFactory.getLogger(Session.class); private final Transport transport; @@ -45,6 +49,24 @@ public class Session { private SDKSystemMessage lastSdkSystemMessage; private final Timeout defaultEventTimeout = Timeout.TIMEOUT_60_SECONDS; + /** + * Checks if the session is configured for streaming. + * + * @return true if streaming is enabled, false otherwise + */ + public boolean isStreaming() { + return Optional.ofNullable(transport) + .map(Transport::getTransportOptions) + .map(TransportOptions::getIncludePartialMessages) + .orElse(false); + } + + /** + * Constructs a new session with the specified transport. + * + * @param transport The transport layer to use for communication + * @throws SessionControlException if the transport is not available + */ public Session(Transport transport) throws SessionControlException { if (transport == null || !transport.isAvailable()) { throw new SessionControlException("Transport is not available"); @@ -53,6 +75,11 @@ public class Session { start(); } + /** + * Starts the session by initializing communication with the CLI. + * + * @throws SessionControlException if initialization fails + */ public void start() throws SessionControlException { try { if (!transport.isAvailable()) { @@ -67,6 +94,11 @@ public class Session { } } + /** + * Closes the session and releases resources. + * + * @throws SessionControlException if closing fails + */ public void close() throws SessionControlException { try { transport.close(); @@ -75,11 +107,24 @@ public class Session { } } + /** + * Interrupts the current operation in the CLI. + * + * @return An optional boolean indicating success of the interrupt operation + * @throws SessionControlException if the operation fails + */ public Optional interrupt() throws SessionControlException { checkAvailable(); return processControlRequest(new CLIControlRequest().setRequest(new CLIControlInterruptRequest()).toString()); } + /** + * Sets the model to be used in the session. + * + * @param modelName The name of the model to use + * @return An optional boolean indicating success of the operation + * @throws SessionControlException if the operation fails + */ public Optional setModel(String modelName) throws SessionControlException { checkAvailable(); CLIControlSetModelRequest cliControlSetModelRequest = new CLIControlSetModelRequest(); @@ -87,6 +132,13 @@ public class Session { return processControlRequest(new CLIControlRequest().setRequest(cliControlSetModelRequest).toString()); } + /** + * Sets the permission mode for the session. + * + * @param permissionMode The permission mode to use + * @return An optional boolean indicating success of the operation + * @throws SessionControlException if the operation fails + */ public Optional setPermissionMode(PermissionMode permissionMode) throws SessionControlException { checkAvailable(); CLIControlSetPermissionModeRequest cliControlSetPermissionModeRequest = new CLIControlSetPermissionModeRequest(); @@ -110,10 +162,21 @@ public class Session { } } + /** + * Continues the current session. + * + * @throws SessionControlException if the operation fails + */ public void continueSession() throws SessionControlException { resumeSession(getSessionId()); } + /** + * Resumes a session with the specified ID. + * + * @param sessionId The ID of the session to resume + * @throws SessionControlException if the operation fails + */ public void resumeSession(String sessionId) throws SessionControlException { if (StringUtils.isNotBlank(sessionId)) { transport.getTransportOptions().setResumeSessionId(sessionId); @@ -121,6 +184,14 @@ public class Session { this.start(); } + /** + * Sends a prompt to the CLI and processes the response. + * + * @param prompt The prompt to send to the CLI + * @param sessionEventConsumers Consumers for handling different types of events + * @throws SessionSendPromptException if sending the prompt fails + * @throws SessionControlException if a control operation fails + */ public void sendPrompt(String prompt, SessionEventConsumers sessionEventConsumers) throws SessionSendPromptException, SessionControlException { checkAvailable(); try { @@ -137,7 +208,8 @@ public class Session { Optional.ofNullable(sessionEventConsumers.onAssistantMessageTimeout(this)).orElse(defaultEventTimeout)); return false; } else if ("stream_event".equals(messageType)) { - MyConcurrentUtils.runAndWait(() -> sessionEventConsumers.onPartialAssistantMessage(this, jsonObject.to(SDKPartialAssistantMessage.class)), + MyConcurrentUtils.runAndWait( + () -> sessionEventConsumers.onPartialAssistantMessage(this, jsonObject.to(SDKPartialAssistantMessage.class)), Optional.ofNullable(sessionEventConsumers.onPartialAssistantMessageTimeout(this)).orElse(defaultEventTimeout)); return false; } else if ("user".equals(messageType)) { @@ -234,14 +306,29 @@ public class Session { return false; } + /** + * Gets the current session ID. + * + * @return The session ID, or null if not available + */ public String getSessionId() { return Optional.ofNullable(lastSdkSystemMessage).map(SDKSystemMessage::getSessionId).orElse(null); } + /** + * Checks if the session is available for operations. + * + * @return true if the session is available, false otherwise + */ public boolean isAvailable() { return transport.isAvailable(); } + /** + * Gets the capabilities of the CLI. + * + * @return A Capabilities object representing the CLI's capabilities + */ public Capabilities getCapabilities() { return Optional.ofNullable(lastCliControlInitializeResponse).map(CLIControlInitializeResponse::getCapabilities).orElse(new Capabilities()); } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/AssistantContentConsumers.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/AssistantContentConsumers.java new file mode 100644 index 000000000..3338c4ac7 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/AssistantContentConsumers.java @@ -0,0 +1,62 @@ +package com.alibaba.qwen.code.cli.session.event; + +import com.alibaba.qwen.code.cli.protocol.data.AssistantUsage; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.TextAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ThingkingAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolResultAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolUseAssistantContent; +import com.alibaba.qwen.code.cli.session.Session; + +/** + * Interface for handling different types of assistant content during a session. + */ +public interface AssistantContentConsumers { + /** + * Handles text content from the assistant. + * + * @param session The session + * @param textAssistantContent The text content from the assistant + */ + void onText(Session session, TextAssistantContent textAssistantContent); + + /** + * Handles thinking content from the assistant. + * + * @param session The session + * @param thingkingAssistantContent The thinking content from the assistant + */ + void onThinking(Session session, ThingkingAssistantContent thingkingAssistantContent); + + /** + * Handles tool use content from the assistant. + * + * @param session The session + * @param toolUseAssistantContent The tool use content from the assistant + */ + void onToolUse(Session session, ToolUseAssistantContent toolUseAssistantContent); + + /** + * Handles tool result content from the assistant. + * + * @param session The session + * @param toolResultAssistantContent The tool result content from the assistant + */ + void onToolResult(Session session, ToolResultAssistantContent toolResultAssistantContent); + + /** + * Handles other types of assistant content. + * + * @param session The session + * @param other The other content from the assistant + */ + void onOtherContent(Session session, AssistantContent other); + + /** + * Handles usage information from the assistant. + * + * @param session The session + * @param AssistantUsage The usage information from the assistant + */ + void onUsage(Session session, AssistantUsage AssistantUsage); +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/AssistantContentSimpleConsumers.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/AssistantContentSimpleConsumers.java new file mode 100644 index 000000000..f5c390a59 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/AssistantContentSimpleConsumers.java @@ -0,0 +1,38 @@ +package com.alibaba.qwen.code.cli.session.event; + +import com.alibaba.qwen.code.cli.protocol.data.AssistantUsage; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.TextAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ThingkingAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolResultAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolUseAssistantContent; +import com.alibaba.qwen.code.cli.session.Session; + +/** + * Simple implementation of AssistantContentConsumers that provides empty implementations for all methods. + */ +public class AssistantContentSimpleConsumers implements AssistantContentConsumers { + @Override + public void onText(Session session, TextAssistantContent textAssistantContent) { + } + + @Override + public void onThinking(Session session, ThingkingAssistantContent thingkingAssistantContent) { + } + + @Override + public void onToolUse(Session session, ToolUseAssistantContent toolUseAssistantContent) { + } + + @Override + public void onToolResult(Session session, ToolResultAssistantContent toolResultAssistantContent) { + } + + @Override + public void onOtherContent(Session session, AssistantContent other) { + } + + @Override + public void onUsage(Session session, AssistantUsage AssistantUsage) { + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventConsumers.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventConsumers.java index 686620cfa..da861ee8b 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventConsumers.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventConsumers.java @@ -12,40 +12,153 @@ import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlResponse; import com.alibaba.qwen.code.cli.session.Session; import com.alibaba.qwen.code.cli.utils.Timeout; +/** + * Interface for handling different types of events during a session. + */ public interface SessionEventConsumers { + /** + * Handles system messages. + * + * @param session The session + * @param systemMessage The system message + */ void onSystemMessage(Session session, SDKSystemMessage systemMessage); + /** + * Handles result messages. + * + * @param session The session + * @param resultMessage The result message + */ void onResultMessage(Session session, SDKResultMessage resultMessage); + /** + * Handles assistant messages. + * + * @param session The session + * @param assistantMessage The assistant message + */ void onAssistantMessage(Session session, SDKAssistantMessage assistantMessage); + /** + * Handles partial assistant messages. + * + * @param session The session + * @param partialAssistantMessage The partial assistant message + */ void onPartialAssistantMessage(Session session, SDKPartialAssistantMessage partialAssistantMessage); + /** + * Handles user messages. + * + * @param session The session + * @param userMessage The user message + */ void onUserMessage(Session session, SDKUserMessage userMessage); + /** + * Handles other types of messages. + * + * @param session The session + * @param message The message + */ void onOtherMessage(Session session, String message); + /** + * Handles control responses. + * + * @param session The session + * @param cliControlResponse The control response + */ void onControlResponse(Session session, CLIControlResponse cliControlResponse); + /** + * Handles control requests. + * + * @param session The session + * @param cliControlRequest The control request + * @return The control response + */ CLIControlResponse onControlRequest(Session session, CLIControlRequest cliControlRequest); + /** + * Handles permission requests. + * + * @param session The session + * @param permissionRequest The permission request + * @return The behavior for the permission request + */ Behavior onPermissionRequest(Session session, CLIControlRequest permissionRequest); + /** + * Gets timeout for system message handling. + * + * @param session The session + * @return The timeout for system message handling + */ Timeout onSystemMessageTimeout(Session session); + /** + * Gets timeout for result message handling. + * + * @param session The session + * @return The timeout for result message handling + */ Timeout onResultMessageTimeout(Session session); + /** + * Gets timeout for assistant message handling. + * + * @param session The session + * @return The timeout for assistant message handling + */ Timeout onAssistantMessageTimeout(Session session); + /** + * Gets timeout for partial assistant message handling. + * + * @param session The session + * @return The timeout for partial assistant message handling + */ Timeout onPartialAssistantMessageTimeout(Session session); + /** + * Gets timeout for user message handling. + * + * @param session The session + * @return The timeout for user message handling + */ Timeout onUserMessageTimeout(Session session); + /** + * Gets timeout for other message handling. + * + * @param session The session + * @return The timeout for other message handling + */ Timeout onOtherMessageTimeout(Session session); + /** + * Gets timeout for control response handling. + * + * @param session The session + * @return The timeout for control response handling + */ Timeout onControlResponseTimeout(Session session); + /** + * Gets timeout for control request handling. + * + * @param session The session + * @return The timeout for control request handling + */ Timeout onControlRequestTimeout(Session session); + /** + * Gets timeout for permission request handling. + * + * @param session The session + * @return The timeout for permission request handling + */ Timeout onPermissionRequestTimeout(Session session); } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventSimpleConsumers.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventSimpleConsumers.java index fe21bbe96..517cc022c 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventSimpleConsumers.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventSimpleConsumers.java @@ -1,12 +1,13 @@ package com.alibaba.qwen.code.cli.session.event; -import java.util.ArrayList; -import java.util.Collections; import java.util.List; -import java.util.Optional; -import java.util.stream.Collectors; import com.alibaba.qwen.code.cli.protocol.data.AssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.TextAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ThingkingAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolResultAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolUseAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantUsage; import com.alibaba.qwen.code.cli.protocol.data.behavior.Allow; import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior; import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior.Operation; @@ -16,6 +17,7 @@ import com.alibaba.qwen.code.cli.protocol.message.SDKSystemMessage; import com.alibaba.qwen.code.cli.protocol.message.SDKUserMessage; import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKAssistantMessage; import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKPartialAssistantMessage; +import com.alibaba.qwen.code.cli.protocol.message.assistant.block.ContentBlock; import com.alibaba.qwen.code.cli.protocol.message.assistant.event.ContentBlockDeltaEvent; import com.alibaba.qwen.code.cli.protocol.message.assistant.event.StreamEvent; import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlPermissionRequest; @@ -24,6 +26,12 @@ import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlResponse; import com.alibaba.qwen.code.cli.session.Session; import com.alibaba.qwen.code.cli.utils.Timeout; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Simple implementation of SessionEventConsumers that provides basic implementations for all methods. + */ public class SessionEventSimpleConsumers implements SessionEventConsumers { @Override public void onSystemMessage(Session session, SDKSystemMessage systemMessage) { @@ -35,22 +43,42 @@ public class SessionEventSimpleConsumers implements SessionEventConsumers { @Override public void onAssistantMessage(Session session, SDKAssistantMessage assistantMessage) { - onAssistantMessageIncludePartial(session, Optional.ofNullable(assistantMessage.getMessage().getContent()) - .map(cbs -> cbs.stream().map(cb -> (AssistantContent) cb).collect(Collectors.toList())) - .orElse(new ArrayList<>()), AssistantMessageOutputType.entire); + List> contentBlocks = assistantMessage.getMessage().getContent(); + if (assistantContentConsumers == null || contentBlocks == null || contentBlocks.isEmpty()) { + return; + } + assistantContentConsumers.onUsage(session, new AssistantUsage(assistantMessage.getMessage().getId(), assistantMessage.getMessage().getUsage())); + + if (!session.isStreaming()) { + contentBlocks.forEach(contentBlock -> consumeAssistantContent(session, contentBlock)); + } } @Override public void onPartialAssistantMessage(Session session, SDKPartialAssistantMessage partialAssistantMessage) { StreamEvent event = partialAssistantMessage.getEvent(); if (!(event instanceof ContentBlockDeltaEvent)) { + log.debug("received partialAssistantMessage and is not instance of ContentBlockDeltaEvent, will ignore process. the message is {}", + partialAssistantMessage); return; } - onAssistantMessageIncludePartial(session, Collections.singletonList(((ContentBlockDeltaEvent) event).getDelta()), AssistantMessageOutputType.partial); + ContentBlockDeltaEvent contentBlockDeltaEvent = (ContentBlockDeltaEvent) event; + contentBlockDeltaEvent.getDelta().setMessageId(partialAssistantMessage.getMessageId()); + consumeAssistantContent(session, contentBlockDeltaEvent.getDelta()); } - public void onAssistantMessageIncludePartial(Session session, List assistantContents, - AssistantMessageOutputType assistantMessageOutputType) { + protected void consumeAssistantContent(Session session, AssistantContent assistantContent) { + if (assistantContent instanceof TextAssistantContent) { + assistantContentConsumers.onText(session, (TextAssistantContent) assistantContent); + } else if (assistantContent instanceof ThingkingAssistantContent) { + assistantContentConsumers.onThinking(session, (ThingkingAssistantContent) assistantContent); + } else if (assistantContent instanceof ToolUseAssistantContent) { + assistantContentConsumers.onToolUse(session, (ToolUseAssistantContent) assistantContent); + } else if (assistantContent instanceof ToolResultAssistantContent) { + assistantContentConsumers.onToolResult(session, (ToolResultAssistantContent) assistantContent); + } else { + assistantContentConsumers.onOtherContent(session, assistantContent); + } } @Override @@ -124,37 +152,88 @@ public class SessionEventSimpleConsumers implements SessionEventConsumers { return defaultEventTimeout; } - public Timeout getDefaultEventTimeout() { + /** + * Gets the default event timeout. + * + * @return The default event timeout + */ + protected Timeout getDefaultEventTimeout() { return defaultEventTimeout; } + /** + * Sets the default event timeout. + * + * @param defaultEventTimeout The default event timeout + * @return This instance for method chaining + */ public SessionEventSimpleConsumers setDefaultEventTimeout(Timeout defaultEventTimeout) { this.defaultEventTimeout = defaultEventTimeout; return this; } - public Operation getDefaultPermissionOperation() { + /** + * Gets the default permission operation. + * + * @return The default permission operation + */ + protected Operation getDefaultPermissionOperation() { return defaultPermissionOperation; } + /** + * Sets the default permission operation. + * + * @param defaultPermissionOperation The default permission operation + * @return This instance for method chaining + */ public SessionEventSimpleConsumers setDefaultPermissionOperation(Operation defaultPermissionOperation) { this.defaultPermissionOperation = defaultPermissionOperation; return this; } + /** + * Creates a new SessionEventSimpleConsumers instance with default values. + */ public SessionEventSimpleConsumers() { } - public SessionEventSimpleConsumers(Operation defaultPermissionOperation, Timeout defaultEventTimeout) { + /** + * Creates a new SessionEventSimpleConsumers instance with the specified parameters. + * + * @param defaultPermissionOperation The default permission operation + * @param defaultEventTimeout The default event timeout + * @param assistantContentConsumers The assistant content consumers + */ + public SessionEventSimpleConsumers(Operation defaultPermissionOperation, Timeout defaultEventTimeout, + AssistantContentConsumers assistantContentConsumers) { this.defaultPermissionOperation = defaultPermissionOperation; this.defaultEventTimeout = defaultEventTimeout; + this.assistantContentConsumers = assistantContentConsumers; } + /** + * The default permission operation. + */ private Operation defaultPermissionOperation = Operation.deny; + /** + * The default event timeout. + */ protected Timeout defaultEventTimeout = Timeout.TIMEOUT_60_SECONDS; + /** + * The assistant content consumers. + */ + protected AssistantContentConsumers assistantContentConsumers; + private static final Logger log = LoggerFactory.getLogger(SessionEventSimpleConsumers.class); - public enum AssistantMessageOutputType { - entire, - partial + /** + * Sets the assistant content consumers. + * + * @param assistantContentConsumers The assistant content consumers + * @return This instance for method chaining + */ + public SessionEventSimpleConsumers setBlockConsumer(AssistantContentConsumers assistantContentConsumers) { + this.assistantContentConsumers = assistantContentConsumers; + return this; } } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionControlException.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionControlException.java index 770d5982c..3fa30bd55 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionControlException.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionControlException.java @@ -1,21 +1,51 @@ package com.alibaba.qwen.code.cli.session.exception; +/** + * Exception thrown when a session control operation fails. + */ public class SessionControlException extends Exception { + /** + * Creates a new exception. + */ public SessionControlException() { } + /** + * Creates a new exception with a message. + * + * @param message The exception message + */ public SessionControlException(String message) { super(message); } + /** + * Creates a new exception with a message and cause. + * + * @param message The exception message + * @param cause The exception cause + */ public SessionControlException(String message, Throwable cause) { super(message, cause); } + /** + * Creates a new exception with a cause. + * + * @param cause The exception cause + */ public SessionControlException(Throwable cause) { super(cause); } + /** + * Creates a new exception with all parameters. + * + * @param message The exception message + * @param cause The exception cause + * @param enableSuppression Whether suppression is enabled + * @param writableStackTrace Whether the stack trace is writable + */ public SessionControlException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { super(message, cause, enableSuppression, writableStackTrace); } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionSendPromptException.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionSendPromptException.java index 74de3bba7..460c5f560 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionSendPromptException.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionSendPromptException.java @@ -1,21 +1,51 @@ package com.alibaba.qwen.code.cli.session.exception; +/** + * Exception thrown when sending a prompt in a session fails. + */ public class SessionSendPromptException extends Exception { + /** + * Creates a new exception. + */ public SessionSendPromptException() { } + /** + * Creates a new exception with a message. + * + * @param message The exception message + */ public SessionSendPromptException(String message) { super(message); } + /** + * Creates a new exception with a message and cause. + * + * @param message The exception message + * @param cause The exception cause + */ public SessionSendPromptException(String message, Throwable cause) { super(message, cause); } + /** + * Creates a new exception with a cause. + * + * @param cause The exception cause + */ public SessionSendPromptException(Throwable cause) { super(cause); } + /** + * Creates a new exception with all parameters. + * + * @param message The exception message + * @param cause The exception cause + * @param enableSuppression Whether suppression is enabled + * @param writableStackTrace Whether the stack trace is writable + */ public SessionSendPromptException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { super(message, cause, enableSuppression, writableStackTrace); } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/Transport.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/Transport.java index af4266f45..1bb46020e 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/Transport.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/Transport.java @@ -5,20 +5,71 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeoutException; import java.util.function.Function; +/** + * Defines the contract for communication with the Qwen Code CLI. + */ public interface Transport { + /** + * Gets the transport options used by this transport. + * + * @return The transport options + */ TransportOptions getTransportOptions(); + /** + * Checks if the transport is currently reading. + * + * @return true if reading, false otherwise + */ boolean isReading(); + /** + * Starts the transport. + * + * @throws IOException if starting fails + */ void start() throws IOException; + /** + * Closes the transport and releases resources. + * + * @throws IOException if closing fails + */ void close() throws IOException; + /** + * Checks if the transport is available for communication. + * + * @return true if available, false otherwise + */ boolean isAvailable(); + /** + * Sends a message and waits for a single-line response. + * + * @param message The message to send + * @return The response message + * @throws IOException if an I/O error occurs + * @throws ExecutionException if an execution error occurs + * @throws InterruptedException if the operation is interrupted + * @throws TimeoutException if the operation times out + */ String inputWaitForOneLine(String message) throws IOException, ExecutionException, InterruptedException, TimeoutException; + /** + * Sends a message and waits for a multi-line response. + * + * @param message The message to send + * @param callBackFunction A function to process each line of the response + * @throws IOException if an I/O error occurs + */ void inputWaitForMultiLine(String message, Function callBackFunction) throws IOException; + /** + * Sends a message without waiting for a response. + * + * @param message The message to send + * @throws IOException if an I/O error occurs + */ void inputNoWaitResponse(String message) throws IOException; } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java index 7e274d1a0..a73f1c13d 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java @@ -6,163 +6,390 @@ import java.util.Map; import com.alibaba.qwen.code.cli.protocol.data.PermissionMode; import com.alibaba.qwen.code.cli.utils.Timeout; +/** + * Configuration options for the transport layer. + */ public class TransportOptions implements Cloneable { + /** + * Path to the Qwen executable. + */ private String pathToQwenExecutable; + /** + * Current working directory for the CLI process. + */ private String cwd; + /** + * Model to use for the session. + */ private String model; + /** + * Permission mode for the session. + */ private PermissionMode permissionMode; + /** + * Environment variables to pass to the CLI process. + */ private Map env; + /** + * Maximum number of turns in a session. + */ private Integer maxSessionTurns; + /** + * List of core tools to enable. + */ private List coreTools; + /** + * List of tools to exclude. + */ private List excludeTools; + /** + * List of tools that are allowed. + */ private List allowedTools; + /** + * Authentication type to use. + */ private String authType; + /** + * Whether to include partial messages in responses. + */ private Boolean includePartialMessages; + /** + * Whether to enable skills. + */ private Boolean skillsEnable; + /** + * Timeout for individual turns. + */ private Timeout turnTimeout; + /** + * Timeout for messages. + */ private Timeout messageTimeout; + /** + * Session ID to resume. + */ private String resumeSessionId; + /** + * Additional options to pass to the CLI. + */ private List otherOptions; + /** + * Gets the path to the Qwen executable. + * + * @return The path to the Qwen executable + */ public String getPathToQwenExecutable() { return pathToQwenExecutable; } + /** + * Sets the path to the Qwen executable. + * + * @param pathToQwenExecutable The path to the Qwen executable + * @return This instance for method chaining + */ public TransportOptions setPathToQwenExecutable(String pathToQwenExecutable) { this.pathToQwenExecutable = pathToQwenExecutable; return this; } + /** + * Gets the current working directory. + * + * @return The current working directory + */ public String getCwd() { return cwd; } + /** + * Sets the current working directory. + * + * @param cwd The current working directory + * @return This instance for method chaining + */ public TransportOptions setCwd(String cwd) { this.cwd = cwd; return this; } + /** + * Gets the model to use. + * + * @return The model name + */ public String getModel() { return model; } + /** + * Sets the model to use. + * + * @param model The model name + * @return This instance for method chaining + */ public TransportOptions setModel(String model) { this.model = model; return this; } + /** + * Gets the permission mode. + * + * @return The permission mode + */ public PermissionMode getPermissionMode() { return permissionMode; } + /** + * Sets the permission mode. + * + * @param permissionMode The permission mode + * @return This instance for method chaining + */ public TransportOptions setPermissionMode(PermissionMode permissionMode) { this.permissionMode = permissionMode; return this; } + /** + * Gets the environment variables. + * + * @return A map of environment variables + */ public Map getEnv() { return env; } + /** + * Sets the environment variables. + * + * @param env A map of environment variables + * @return This instance for method chaining + */ public TransportOptions setEnv(Map env) { this.env = env; return this; } + /** + * Gets the maximum number of session turns. + * + * @return The maximum number of session turns + */ public Integer getMaxSessionTurns() { return maxSessionTurns; } + /** + * Sets the maximum number of session turns. + * + * @param maxSessionTurns The maximum number of session turns + * @return This instance for method chaining + */ public TransportOptions setMaxSessionTurns(Integer maxSessionTurns) { this.maxSessionTurns = maxSessionTurns; return this; } + /** + * Gets the list of core tools. + * + * @return The list of core tools + */ public List getCoreTools() { return coreTools; } + /** + * Sets the list of core tools. + * + * @param coreTools The list of core tools + * @return This instance for method chaining + */ public TransportOptions setCoreTools(List coreTools) { this.coreTools = coreTools; return this; } + /** + * Gets the list of excluded tools. + * + * @return The list of excluded tools + */ public List getExcludeTools() { return excludeTools; } + /** + * Sets the list of excluded tools. + * + * @param excludeTools The list of excluded tools + * @return This instance for method chaining + */ public TransportOptions setExcludeTools(List excludeTools) { this.excludeTools = excludeTools; return this; } + /** + * Gets the list of allowed tools. + * + * @return The list of allowed tools + */ public List getAllowedTools() { return allowedTools; } + /** + * Sets the list of allowed tools. + * + * @param allowedTools The list of allowed tools + * @return This instance for method chaining + */ public TransportOptions setAllowedTools(List allowedTools) { this.allowedTools = allowedTools; return this; } + /** + * Gets the authentication type. + * + * @return The authentication type + */ public String getAuthType() { return authType; } + /** + * Sets the authentication type. + * + * @param authType The authentication type + * @return This instance for method chaining + */ public TransportOptions setAuthType(String authType) { this.authType = authType; return this; } + /** + * Gets whether to include partial messages. + * + * @return Whether to include partial messages + */ public Boolean getIncludePartialMessages() { return includePartialMessages; } + /** + * Sets whether to include partial messages. + * + * @param includePartialMessages Whether to include partial messages + * @return This instance for method chaining + */ public TransportOptions setIncludePartialMessages(Boolean includePartialMessages) { this.includePartialMessages = includePartialMessages; return this; } + /** + * Gets whether skills are enabled. + * + * @return Whether skills are enabled + */ public Boolean getSkillsEnable() { return skillsEnable; } + /** + * Sets whether skills are enabled. + * + * @param skillsEnable Whether skills are enabled + * @return This instance for method chaining + */ public TransportOptions setSkillsEnable(Boolean skillsEnable) { this.skillsEnable = skillsEnable; return this; } + /** + * Gets the turn timeout. + * + * @return The turn timeout + */ public Timeout getTurnTimeout() { return turnTimeout; } + /** + * Sets the turn timeout. + * + * @param turnTimeout The turn timeout + * @return This instance for method chaining + */ public TransportOptions setTurnTimeout(Timeout turnTimeout) { this.turnTimeout = turnTimeout; return this; } + /** + * Gets the message timeout. + * + * @return The message timeout + */ public Timeout getMessageTimeout() { return messageTimeout; } + /** + * Sets the message timeout. + * + * @param messageTimeout The message timeout + * @return This instance for method chaining + */ public TransportOptions setMessageTimeout(Timeout messageTimeout) { this.messageTimeout = messageTimeout; return this; } + /** + * Gets the session ID to resume. + * + * @return The session ID to resume + */ public String getResumeSessionId() { return resumeSessionId; } + /** + * Sets the session ID to resume. + * + * @param resumeSessionId The session ID to resume + * @return This instance for method chaining + */ public TransportOptions setResumeSessionId(String resumeSessionId) { this.resumeSessionId = resumeSessionId; return this; } + /** + * Gets additional options. + * + * @return Additional options + */ public List getOtherOptions() { return otherOptions; } + /** + * Sets additional options. + * + * @param otherOptions Additional options + * @return This instance for method chaining + */ public TransportOptions setOtherOptions(List otherOptions) { this.otherOptions = otherOptions; return this; diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransport.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransport.java index 11be50ecc..fb590e9c0 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransport.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransport.java @@ -22,6 +22,9 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; import java.util.function.Function; +/** + * Implementation of the Transport interface that communicates with the Qwen CLI via a process. + */ public class ProcessTransport implements Transport { private static final Logger log = LoggerFactory.getLogger(ProcessTransport.class); private final TransportOptions transportOptions; @@ -36,14 +39,32 @@ public class ProcessTransport implements Transport { private final AtomicBoolean reading = new AtomicBoolean(false); + /** + * Constructs a new ProcessTransport with default options. + * + * @throws IOException if starting the process fails + */ public ProcessTransport() throws IOException { this(new TransportOptions()); } + /** + * Constructs a new ProcessTransport with the specified options. + * + * @param transportOptions The transport options to use + * @throws IOException if starting the process fails + */ public ProcessTransport(TransportOptions transportOptions) throws IOException { this(transportOptions, (line) -> log.error("process error: {}", line)); } + /** + * Constructs a new ProcessTransport with the specified options and error handler. + * + * @param transportOptions The transport options to use + * @param errorHandler The error handler to use + * @throws IOException if starting the process fails + */ public ProcessTransport(TransportOptions transportOptions, Consumer errorHandler) throws IOException { this.transportOptions = transportOptions; this.errorHandler = errorHandler; diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java index ba9289181..fe8f21691 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/TransportOptionsAdapter.java @@ -14,23 +14,55 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.TimeUnit; +/** + * Adapter that converts TransportOptions to command-line arguments for the CLI process. + */ class TransportOptionsAdapter { + /** + * The adapted transport options. + */ TransportOptions transportOptions; + /** + * Default timeout for turns. + */ private static final Timeout DEFAULT_TURN_TIMEOUT = new Timeout(1000 * 60 * 30L, TimeUnit.MILLISECONDS); + /** + * Default timeout for messages. + */ private static final Timeout DEFAULT_MESSAGE_TIMEOUT = new Timeout(1000 * 60 * 3L, TimeUnit.MILLISECONDS); + /** + * Constructs a new adapter with the specified options. + * + * @param userTransportOptions The user's transport options + */ TransportOptionsAdapter(TransportOptions userTransportOptions) { transportOptions = addDefaultTransportOptions(userTransportOptions); } + /** + * Gets the processed transport options. + * + * @return The processed transport options + */ TransportOptions getHandledTransportOptions() { return transportOptions; } + /** + * Gets the current working directory. + * + * @return The current working directory + */ String getCwd() { return transportOptions.getCwd(); } + /** + * Builds command-line arguments from the transport options. + * + * @return An array of command-line arguments + */ String[] buildCommandArgs() { List args = new ArrayList<>( Arrays.asList(transportOptions.getPathToQwenExecutable(), "--input-format", "stream-json", "--output-format", @@ -90,6 +122,12 @@ class TransportOptionsAdapter { return args.toArray(new String[] {}); } + /** + * Adds default values to the user's transport options. + * + * @param userTransportOptions The user's transport options + * @return The options with defaults added + */ private TransportOptions addDefaultTransportOptions(TransportOptions userTransportOptions) { TransportOptions transportOptions = Optional.ofNullable(userTransportOptions) .map(TransportOptions::clone) diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/MyConcurrentUtils.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/MyConcurrentUtils.java index 3b5cf22da..d50892701 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/MyConcurrentUtils.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/MyConcurrentUtils.java @@ -9,9 +9,18 @@ import java.util.function.Supplier; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +/** + * Utility class for concurrent operations. + */ public class MyConcurrentUtils { private static final Logger log = LoggerFactory.getLogger(MyConcurrentUtils.class); + /** + * Runs a task and waits for it to complete with a timeout. + * + * @param runnable The task to run + * @param timeOut The timeout for the operation + */ public static void runAndWait(Runnable runnable, Timeout timeOut) { CompletableFuture future = CompletableFuture.runAsync(() -> { try { @@ -34,6 +43,17 @@ public class MyConcurrentUtils { } } + /** + * Runs a task that returns a value and waits for it to complete with a timeout. + * + * @param supplier The task to run + * @param timeOut The timeout for the operation + * @param The type of the result + * @return The result of the task + * @throws ExecutionException if an execution error occurs + * @throws InterruptedException if the operation is interrupted + * @throws TimeoutException if the operation times out + */ public static T runAndWait(Supplier supplier, Timeout timeOut) throws ExecutionException, InterruptedException, TimeoutException { CompletableFuture future = CompletableFuture.supplyAsync(() -> { @@ -52,6 +72,12 @@ public class MyConcurrentUtils { } } + /** + * Runs a task asynchronously with an error callback. + * + * @param runnable The task to run + * @param errorCallback The error callback + */ public static void asyncRun(Runnable runnable, BiConsumer errorCallback) { CompletableFuture future = CompletableFuture.runAsync(() -> { try { diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/ThreadPoolConfig.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/ThreadPoolConfig.java index 9837ef798..671bdde74 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/ThreadPoolConfig.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/ThreadPoolConfig.java @@ -9,6 +9,9 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Supplier; +/** + * Configuration for the thread pool used by the SDK. + */ public class ThreadPoolConfig { private static final ThreadPoolExecutor defaultExecutor = new ThreadPoolExecutor( 10, 30, 60L, TimeUnit.SECONDS, @@ -27,10 +30,21 @@ public class ThreadPoolConfig { ); private static Supplier executorSupplier; + + /** + * Sets the supplier for the executor. + * + * @param executorSupplier The supplier for the executor + */ public static void setExecutorSupplier(Supplier executorSupplier) { ThreadPoolConfig.executorSupplier = executorSupplier; } + /** + * Gets the default executor. + * + * @return The default executor + */ public static ThreadPoolExecutor getDefaultExecutor() { return defaultExecutor; } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/Timeout.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/Timeout.java index f00b39085..76fb29ab8 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/Timeout.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/Timeout.java @@ -4,9 +4,25 @@ import java.util.concurrent.TimeUnit; import org.apache.commons.lang3.Validate; +/** + * Represents a timeout value with a time unit. + */ public class Timeout { + /** + * The timeout value. + */ private final Long value; + /** + * The time unit. + */ private final TimeUnit unit; + + /** + * Creates a new Timeout instance. + * + * @param value The timeout value + * @param unit The time unit + */ public Timeout(Long value, TimeUnit unit) { Validate.notNull(value, "value can not be null"); Validate.notNull(unit, "unit can not be null"); @@ -14,14 +30,30 @@ public class Timeout { this.unit = unit; } + /** + * Gets the timeout value. + * + * @return The timeout value + */ public Long getValue() { return value; } + /** + * Gets the time unit. + * + * @return The time unit + */ public TimeUnit getUnit() { return unit; } + /** + * A timeout of 60 seconds. + */ public static final Timeout TIMEOUT_60_SECONDS = new Timeout(60L, TimeUnit.SECONDS); + /** + * A timeout of 30 minutes. + */ public static final Timeout TIMEOUT_30_MINUTES = new Timeout(60L, TimeUnit.MINUTES); } diff --git a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/QwenCodeCliTest.java b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/QwenCodeCliTest.java index 51be8bf4c..ca10173f7 100644 --- a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/QwenCodeCliTest.java +++ b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/QwenCodeCliTest.java @@ -2,6 +2,8 @@ package com.alibaba.qwen.code.cli; import java.util.List; +import com.alibaba.qwen.code.cli.transport.TransportOptions; + import org.junit.jupiter.api.Test; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -14,7 +16,14 @@ class QwenCodeCliTest { @Test void simpleQuery() { List result = QwenCodeCli.simpleQuery("hello world"); - log.info("result: {}", result); + log.info("simpleQuery result: {}", result); + assertNotNull(result); + } + + @Test + void simpleQueryWithModel() { + List result = QwenCodeCli.simpleQuery("hello world", new TransportOptions().setModel("qwen-plus")); + log.info("simpleQueryWithModel result: {}", result); assertNotNull(result); } } diff --git a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java index 9292d83fe..a6463d41d 100644 --- a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java +++ b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java @@ -1,13 +1,16 @@ package com.alibaba.qwen.code.cli.session; -import java.io.IOException; -import java.util.List; import java.util.concurrent.TimeUnit; import com.alibaba.fastjson2.JSON; import com.alibaba.qwen.code.cli.QwenCodeCli; +import com.alibaba.qwen.code.cli.protocol.data.AssistantUsage; import com.alibaba.qwen.code.cli.protocol.data.AssistantContent; import com.alibaba.qwen.code.cli.protocol.data.PermissionMode; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.TextAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ThingkingAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolResultAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolUseAssistantContent; import com.alibaba.qwen.code.cli.protocol.data.behavior.Allow; import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior; import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior.Operation; @@ -17,6 +20,7 @@ import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKAssistantMessage; import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlPermissionRequest; import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlRequest; import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlResponse; +import com.alibaba.qwen.code.cli.session.event.AssistantContentConsumers; import com.alibaba.qwen.code.cli.session.event.SessionEventConsumers; import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; import com.alibaba.qwen.code.cli.session.exception.SessionControlException; @@ -34,19 +38,43 @@ class SessionTest { private static final Logger log = LoggerFactory.getLogger(SessionTest.class); @Test - void partialSendPromptSuccessfully() throws IOException, SessionControlException, SessionSendPromptException { + void partialSendPromptSuccessfully() throws SessionControlException, SessionSendPromptException { Session session = QwenCodeCli.newSession(new TransportOptions().setIncludePartialMessages(true)); session.sendPrompt("in the dir src/test/temp/, create file empty file test.touch", new SessionEventSimpleConsumers() { + }.setDefaultPermissionOperation(Operation.allow).setBlockConsumer(new AssistantContentConsumers() { @Override - public void onAssistantMessageIncludePartial(Session session, List assistantContents, - AssistantMessageOutputType assistantMessageOutputType) { - log.info("onAssistantMessageIncludePartial: {}", JSON.toJSONString(assistantContents)); + public void onText(Session session, TextAssistantContent textAssistantContent) { + log.info("receive textAssistantContent {}", textAssistantContent); } - }.setDefaultPermissionOperation(Operation.allow)); + + @Override + public void onThinking(Session session, ThingkingAssistantContent thingkingAssistantContent) { + log.info("receive thingkingAssistantContent {}", thingkingAssistantContent); + } + + @Override + public void onToolUse(Session session, ToolUseAssistantContent toolUseAssistantContent) { + log.info("receive toolUseAssistantContent {}", toolUseAssistantContent); + } + + @Override + public void onToolResult(Session session, ToolResultAssistantContent toolResultAssistantContent) { + log.info("receive toolResultAssistantContent {}", toolResultAssistantContent); + } + + public void onOtherContent(Session session, AssistantContent other) { + log.info("receive otherContent {}", other); + } + + @Override + public void onUsage(Session session, AssistantUsage assistantUsage) { + log.info("receive assistantUsage {}", assistantUsage); + } + })); } @Test - void setPermissionModeSuccessfully() throws IOException, SessionControlException, SessionSendPromptException { + void setPermissionModeSuccessfully() throws SessionControlException, SessionSendPromptException { Session session = QwenCodeCli.newSession(new TransportOptions()); log.info(session.setPermissionMode(PermissionMode.YOLO).map(s -> s ? "setPermissionMode 1 success" : "setPermissionMode 1 error") @@ -72,7 +100,7 @@ class SessionTest { } @Test - void sendPromptAndSetModelSuccessfully() throws IOException, SessionControlException, SessionSendPromptException { + void sendPromptAndSetModelSuccessfully() throws SessionControlException, SessionSendPromptException { Session session = QwenCodeCli.newSession(new TransportOptions()); log.info(session.setModel("qwen3-coder-flash").map(s -> s ? "setModel 1 success" : "setModel 1 error").orElse("setModel 1 unknown")); @@ -97,7 +125,7 @@ class SessionTest { } @Test - void sendPromptAndInterruptContinueSuccessfully() throws IOException, SessionControlException, SessionSendPromptException { + void sendPromptAndInterruptContinueSuccessfully() throws SessionControlException, SessionSendPromptException { Session session = QwenCodeCli.newSession(); SessionEventConsumers sessionEventConsumers = new SessionEventSimpleConsumers() { From 32e8b01cf0a18eb7e1a84ad908a40a3461f527ee Mon Sep 17 00:00:00 2001 From: skyfire Date: Sun, 4 Jan 2026 19:39:00 +0800 Subject: [PATCH 046/142] for javadoc --- .../alibaba/qwen/code/cli/QwenCodeCli.java | 3 +++ .../cli/protocol/data/AssistantContent.java | 2 ++ .../cli/protocol/data/AssistantUsage.java | 8 ++++++ .../protocol/data/CLIPermissionDenial.java | 3 +++ .../code/cli/protocol/data/Capabilities.java | 3 +++ .../code/cli/protocol/data/ExtendedUsage.java | 3 +++ .../cli/protocol/data/InitializeConfig.java | 3 +++ .../code/cli/protocol/data/ModelUsage.java | 3 +++ .../cli/protocol/data/PermissionMode.java | 3 +++ .../qwen/code/cli/protocol/data/Usage.java | 8 ++++++ .../cli/protocol/data/behavior/Allow.java | 3 +++ .../cli/protocol/data/behavior/Behavior.java | 3 +++ .../code/cli/protocol/data/behavior/Deny.java | 3 +++ .../code/cli/protocol/message/Message.java | 3 +++ .../cli/protocol/message/MessageBase.java | 10 +++++++ .../protocol/message/SDKResultMessage.java | 3 +++ .../protocol/message/SDKSystemMessage.java | 3 +++ .../cli/protocol/message/SDKUserMessage.java | 3 +++ .../assistant/APIAssistantMessage.java | 3 +++ .../assistant/SDKAssistantMessage.java | 4 +++ .../assistant/SDKPartialAssistantMessage.java | 3 +++ .../message/assistant/block/Annotation.java | 3 +++ .../message/assistant/block/ContentBlock.java | 9 +++++++ .../message/assistant/block/TextBlock.java | 4 +++ .../assistant/block/ThinkingBlock.java | 4 +++ .../assistant/block/ToolResultBlock.java | 4 +++ .../message/assistant/block/ToolUseBlock.java | 13 +++++++-- .../event/ContentBlockDeltaEvent.java | 3 +++ .../event/ContentBlockStartEvent.java | 3 +++ .../event/ContentBlockStopEvent.java | 3 +++ .../event/MessageStartStreamEvent.java | 3 +++ .../event/MessageStopStreamEvent.java | 3 +++ .../message/assistant/event/StreamEvent.java | 3 +++ .../control/CLIControlInitializeRequest.java | 3 +++ .../control/CLIControlInitializeResponse.java | 3 +++ .../control/CLIControlInterruptRequest.java | 3 +++ .../control/CLIControlPermissionRequest.java | 3 +++ .../control/CLIControlPermissionResponse.java | 3 +++ .../message/control/CLIControlRequest.java | 2 ++ .../message/control/CLIControlResponse.java | 2 ++ .../control/CLIControlSetModelRequest.java | 3 +++ .../control/CLIControlSetModelResponse.java | 3 +++ .../CLIControlSetPermissionModeRequest.java | 3 +++ .../qwen/code/cli/session/Session.java | 23 +++++++++------- .../event/AssistantContentConsumers.java | 3 +++ .../AssistantContentSimpleConsumers.java | 9 +++++++ .../session/event/SessionEventConsumers.java | 3 +++ .../event/SessionEventSimpleConsumers.java | 27 +++++++++++++++++++ .../exception/SessionControlException.java | 3 +++ .../exception/SessionSendPromptException.java | 3 +++ .../qwen/code/cli/transport/Transport.java | 19 +++++++------ .../code/cli/transport/TransportOptions.java | 4 +++ .../transport/process/ProcessTransport.java | 17 +++++++++--- .../code/cli/utils/MyConcurrentUtils.java | 9 ++++--- .../qwen/code/cli/utils/ThreadPoolConfig.java | 3 +++ .../alibaba/qwen/code/cli/utils/Timeout.java | 3 +++ 56 files changed, 263 insertions(+), 26 deletions(-) diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCodeCli.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCodeCli.java index adbb235e6..6571f5de3 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCodeCli.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCodeCli.java @@ -25,6 +25,9 @@ import org.slf4j.LoggerFactory; /** * Main entry point for interacting with the Qwen Code CLI. Provides static methods for simple queries and session management. + * + * @author skyfire + * @version $Id: 0.0.1 */ public class QwenCodeCli { private static final Logger log = LoggerFactory.getLogger(QwenCodeCli.class); diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/AssistantContent.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/AssistantContent.java index d0414a83f..ba0356545 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/AssistantContent.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/AssistantContent.java @@ -6,6 +6,8 @@ import java.util.Map; * Represents content from the assistant in a Qwen Code session. * * @param The type of content + * @author skyfire + * @version $Id: 0.0.1 */ public interface AssistantContent { /** diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/AssistantUsage.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/AssistantUsage.java index 261898796..8ecb2a5bf 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/AssistantUsage.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/AssistantUsage.java @@ -4,6 +4,9 @@ import com.alibaba.fastjson2.JSON; /** * Represents usage information for an assistant message. + * + * @author skyfire + * @version $Id: 0.0.1 */ public class AssistantUsage { /** @@ -62,6 +65,11 @@ public class AssistantUsage { this.usage = usage; } + /** + *

toString.

+ * + * @return a {@link java.lang.String} object. + */ public String toString() { return JSON.toJSONString(this); } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/CLIPermissionDenial.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/CLIPermissionDenial.java index 312344296..bc155b776 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/CLIPermissionDenial.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/CLIPermissionDenial.java @@ -4,6 +4,9 @@ import com.alibaba.fastjson2.annotation.JSONField; /** * Represents a permission denial from the CLI. + * + * @author skyfire + * @version $Id: 0.0.1 */ public class CLIPermissionDenial { /** diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Capabilities.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Capabilities.java index 2f22c0ce4..e6cbadfe9 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Capabilities.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Capabilities.java @@ -4,6 +4,9 @@ import com.alibaba.fastjson2.annotation.JSONField; /** * Represents the capabilities of the Qwen Code CLI. + * + * @author skyfire + * @version $Id: 0.0.1 */ public class Capabilities { /** diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ExtendedUsage.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ExtendedUsage.java index a894f5b7a..7e67a629f 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ExtendedUsage.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ExtendedUsage.java @@ -4,6 +4,9 @@ import com.alibaba.fastjson2.annotation.JSONField; /** * Extends the Usage class with additional usage information. + * + * @author skyfire + * @version $Id: 0.0.1 */ public class ExtendedUsage extends Usage { /** diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/InitializeConfig.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/InitializeConfig.java index c0858ee4d..36296d053 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/InitializeConfig.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/InitializeConfig.java @@ -2,6 +2,9 @@ package com.alibaba.qwen.code.cli.protocol.data; /** * Configuration for initializing the CLI. + * + * @author skyfire + * @version $Id: 0.0.1 */ public class InitializeConfig { /** diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ModelUsage.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ModelUsage.java index d3286c8a3..33b426c4a 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ModelUsage.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/ModelUsage.java @@ -2,6 +2,9 @@ package com.alibaba.qwen.code.cli.protocol.data; /** * Represents usage information for a specific model. + * + * @author skyfire + * @version $Id: 0.0.1 */ public class ModelUsage { /** diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/PermissionMode.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/PermissionMode.java index aafc69bef..420dd760d 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/PermissionMode.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/PermissionMode.java @@ -2,6 +2,9 @@ package com.alibaba.qwen.code.cli.protocol.data; /** * Represents different permission modes for the CLI. + * + * @author skyfire + * @version $Id: 0.0.1 */ public enum PermissionMode { /** diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Usage.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Usage.java index a0e4b3009..7fb430511 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Usage.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/Usage.java @@ -5,6 +5,9 @@ import com.alibaba.fastjson2.annotation.JSONField; /** * Represents usage information for a message. + * + * @author skyfire + * @version $Id: 0.0.1 */ public class Usage { /** @@ -123,6 +126,11 @@ public class Usage { this.totalTokens = totalTokens; } + /** + *

toString.

+ * + * @return a {@link java.lang.String} object. + */ public String toString() { return JSON.toJSONString(this); } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Allow.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Allow.java index cc2f5d533..5ed08f099 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Allow.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Allow.java @@ -6,6 +6,9 @@ import com.alibaba.fastjson2.annotation.JSONType; /** * Represents an allow behavior that permits an operation. + * + * @author skyfire + * @version $Id: 0.0.1 */ @JSONType(typeKey = "operation", typeName = "allow") public class Allow extends Behavior { diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Behavior.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Behavior.java index 2ea1b6ff1..20893e044 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Behavior.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Behavior.java @@ -4,6 +4,9 @@ import com.alibaba.fastjson2.annotation.JSONType; /** * Base class for behavior objects that define how the CLI should handle requests. + * + * @author skyfire + * @version $Id: 0.0.1 */ @JSONType(typeKey = "operation", typeName = "Behavior", seeAlso = {Allow.class, Deny.class}) public class Behavior { diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Deny.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Deny.java index d24560620..042673e45 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Deny.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Deny.java @@ -4,6 +4,9 @@ import com.alibaba.fastjson2.annotation.JSONType; /** * Represents a deny behavior that rejects an operation. + * + * @author skyfire + * @version $Id: 0.0.1 */ @JSONType(typeKey = "operation", typeName = "deny") public class Deny extends Behavior { diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/Message.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/Message.java index f816d7f2e..855fb5de7 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/Message.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/Message.java @@ -2,6 +2,9 @@ package com.alibaba.qwen.code.cli.protocol.message; /** * Represents a message in the Qwen Code protocol. + * + * @author skyfire + * @version $Id: 0.0.1 */ public interface Message { /** diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/MessageBase.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/MessageBase.java index aa9cbfd1a..37390164a 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/MessageBase.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/MessageBase.java @@ -6,6 +6,9 @@ import com.alibaba.fastjson2.annotation.JSONType; /** * Base class for messages in the Qwen Code protocol. + * + * @author skyfire + * @version $Id: 0.0.1 */ @JSONType(alphabetic = false, typeKey = "type", typeName = "MessageBase") public class MessageBase implements Message{ @@ -20,10 +23,16 @@ public class MessageBase implements Message{ @JSONField(name = "message_id") protected String messageId; + /** + *

toString.

+ * + * @return a {@link java.lang.String} object. + */ public String toString() { return JSON.toJSONString(this); } + /** {@inheritDoc} */ @Override public String getType() { return type; @@ -38,6 +47,7 @@ public class MessageBase implements Message{ this.type = type; } + /** {@inheritDoc} */ @Override public String getMessageId() { return messageId; diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKResultMessage.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKResultMessage.java index f96ecade7..58889630b 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKResultMessage.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKResultMessage.java @@ -11,6 +11,9 @@ import com.alibaba.qwen.code.cli.protocol.data.Usage; /** * Represents a result message from the SDK. + * + * @author skyfire + * @version $Id: 0.0.1 */ @JSONType(typeKey = "type", typeName = "result") public class SDKResultMessage extends MessageBase { diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKSystemMessage.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKSystemMessage.java index 4a61513d5..abdb0fe5b 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKSystemMessage.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKSystemMessage.java @@ -8,6 +8,9 @@ import com.alibaba.fastjson2.annotation.JSONType; /** * Represents a system message from the SDK. + * + * @author skyfire + * @version $Id: 0.0.1 */ @JSONType(typeKey = "type", typeName = "system") public class SDKSystemMessage extends MessageBase { diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKUserMessage.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKUserMessage.java index bdd69f01d..e2d9f1e2a 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKUserMessage.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/SDKUserMessage.java @@ -7,6 +7,9 @@ import com.alibaba.fastjson2.annotation.JSONType; /** * Represents a user message in the SDK protocol. + * + * @author skyfire + * @version $Id: 0.0.1 */ @JSONType(typeKey = "type", typeName = "user") public class SDKUserMessage extends MessageBase { diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/APIAssistantMessage.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/APIAssistantMessage.java index b64952228..b54bca443 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/APIAssistantMessage.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/APIAssistantMessage.java @@ -8,6 +8,9 @@ import com.alibaba.qwen.code.cli.protocol.message.assistant.block.ContentBlock; /** * Represents an API assistant message. + * + * @author skyfire + * @version $Id: 0.0.1 */ public class APIAssistantMessage { /** diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKAssistantMessage.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKAssistantMessage.java index efb6071cf..b94fde07b 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKAssistantMessage.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKAssistantMessage.java @@ -6,6 +6,9 @@ import com.alibaba.qwen.code.cli.protocol.message.MessageBase; /** * Represents an SDK assistant message. + * + * @author skyfire + * @version $Id: 0.0.1 */ @JSONType(typeKey = "type", typeName = "assistant") public class SDKAssistantMessage extends MessageBase { @@ -38,6 +41,7 @@ public class SDKAssistantMessage extends MessageBase { this.type = "assistant"; } + /** {@inheritDoc} */ @Override public String getMessageId() { return this.getUuid(); diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKPartialAssistantMessage.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKPartialAssistantMessage.java index 2c7cc0934..0ace1e3f0 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKPartialAssistantMessage.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/SDKPartialAssistantMessage.java @@ -7,6 +7,9 @@ import com.alibaba.qwen.code.cli.protocol.message.assistant.event.StreamEvent; /** * Represents a partial assistant message during streaming. + * + * @author skyfire + * @version $Id: 0.0.1 */ @JSONType(typeKey = "type", typeName = "stream_event") public class SDKPartialAssistantMessage extends MessageBase { diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/Annotation.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/Annotation.java index e78cf3576..880bb12f8 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/Annotation.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/Annotation.java @@ -4,6 +4,9 @@ import com.alibaba.fastjson2.annotation.JSONField; /** * Represents an annotation for a content block. + * + * @author skyfire + * @version $Id: 0.0.1 */ public class Annotation { /** diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ContentBlock.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ContentBlock.java index c8c7284b0..fabee58b5 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ContentBlock.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ContentBlock.java @@ -10,6 +10,8 @@ import com.alibaba.qwen.code.cli.protocol.data.AssistantContent; * Abstract base class for content blocks in assistant messages. * * @param The type of content + * @author skyfire + * @version $Id: 0.0.1 */ @JSONType(typeKey = "type", typeName = "ContentBlock", seeAlso = { TextBlock.class, ToolResultBlock.class, ThinkingBlock.class, ToolUseBlock.class }) public abstract class ContentBlock implements AssistantContent { @@ -26,6 +28,7 @@ public abstract class ContentBlock implements AssistantContent { */ protected String messageId; + /** {@inheritDoc} */ @Override public String getType() { return type; @@ -58,6 +61,7 @@ public abstract class ContentBlock implements AssistantContent { this.annotations = annotations; } + /** {@inheritDoc} */ @Override public String getMessageId() { return messageId; @@ -72,6 +76,11 @@ public abstract class ContentBlock implements AssistantContent { this.messageId = messageId; } + /** + *

toString.

+ * + * @return a {@link java.lang.String} object. + */ public String toString() { return JSON.toJSONString(this); } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/TextBlock.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/TextBlock.java index 9980a74b8..5b6953fda 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/TextBlock.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/TextBlock.java @@ -5,6 +5,9 @@ import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.TextAssistantCon /** * Represents a text content block. + * + * @author skyfire + * @version $Id: 0.0.1 */ @JSONType(typeKey = "type", typeName = "text") public class TextBlock extends ContentBlock implements TextAssistantContent { @@ -31,6 +34,7 @@ public class TextBlock extends ContentBlock implements TextAssistantCont this.text = text; } + /** {@inheritDoc} */ @Override public String getContentOfAssistant() { return text; diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ThinkingBlock.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ThinkingBlock.java index 9b33730bc..52967e67b 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ThinkingBlock.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ThinkingBlock.java @@ -5,6 +5,9 @@ import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ThingkingAssista /** * Represents a thinking content block. + * + * @author skyfire + * @version $Id: 0.0.1 */ @JSONType(typeKey = "type", typeName = "thinking") public class ThinkingBlock extends ContentBlock implements ThingkingAssistantContent { @@ -53,6 +56,7 @@ public class ThinkingBlock extends ContentBlock implements ThingkingAssi this.signature = signature; } + /** {@inheritDoc} */ @Override public String getContentOfAssistant() { return thinking; diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolResultBlock.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolResultBlock.java index 43da74b0c..35185a40d 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolResultBlock.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolResultBlock.java @@ -6,6 +6,9 @@ import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolResultAssist /** * Represents a tool result content block. + * + * @author skyfire + * @version $Id: 0.0.1 */ @JSONType(typeKey = "type", typeName = "tool_result") public class ToolResultBlock extends ContentBlock implements ToolResultAssistantContent { @@ -81,6 +84,7 @@ public class ToolResultBlock extends ContentBlock implements ToolResultA this.isError = isError; } + /** {@inheritDoc} */ @Override public String getContentOfAssistant() { return content; diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolUseBlock.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolUseBlock.java index da3624a67..91cfacb36 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolUseBlock.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/block/ToolUseBlock.java @@ -9,6 +9,9 @@ import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolUseAssistant /** * Represents a tool use content block. + * + * @author skyfire + * @version $Id: 0.0.1 */ @JSONType(typeKey = "type", typeName = "tool_use") public class ToolUseBlock extends ContentBlock> implements ToolUseAssistantContent { @@ -98,14 +101,20 @@ public class ToolUseBlock extends ContentBlock> implements T } /** - * Sets the list of annotations. + * {@inheritDoc} * - * @param annotations The list of annotations + * Sets the list of annotations. */ + @Override public void setAnnotations(List annotations) { this.annotations = annotations; } + /** + * {@inheritDoc} + * + * Gets the content of the assistant. + */ @Override public Map getContentOfAssistant() { return Collections.emptyMap(); diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockDeltaEvent.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockDeltaEvent.java index 6486404f8..b7328b08e 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockDeltaEvent.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockDeltaEvent.java @@ -13,6 +13,9 @@ import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolUseAssistant /** * Represents a content block delta event during streaming. + * + * @author skyfire + * @version $Id: 0.0.1 */ @JSONType(typeKey = "type", typeName = "content_block_delta") public class ContentBlockDeltaEvent extends StreamEvent { diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStartEvent.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStartEvent.java index eaf132934..758e59660 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStartEvent.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStartEvent.java @@ -6,6 +6,9 @@ import com.alibaba.qwen.code.cli.protocol.message.assistant.block.ContentBlock; /** * Represents a content block start event during message streaming. + * + * @author skyfire + * @version $Id: 0.0.1 */ @JSONType(typeKey = "type", typeName = "content_block_start") public class ContentBlockStartEvent extends StreamEvent{ diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStopEvent.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStopEvent.java index 9b4529b83..ed1241957 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStopEvent.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/ContentBlockStopEvent.java @@ -4,6 +4,9 @@ import com.alibaba.fastjson2.annotation.JSONType; /** * Represents a content block stop event during message streaming. + * + * @author skyfire + * @version $Id: 0.0.1 */ @JSONType(typeKey = "type", typeName = "content_block_stop") public class ContentBlockStopEvent extends StreamEvent{ diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStartStreamEvent.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStartStreamEvent.java index cdd89ba4a..2377e6662 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStartStreamEvent.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStartStreamEvent.java @@ -4,6 +4,9 @@ import com.alibaba.fastjson2.annotation.JSONType; /** * Represents a message start event during message streaming. + * + * @author skyfire + * @version $Id: 0.0.1 */ @JSONType(typeName = "message_start") public class MessageStartStreamEvent extends StreamEvent{ diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStopStreamEvent.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStopStreamEvent.java index 602ae4dfd..cbf32c27a 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStopStreamEvent.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/MessageStopStreamEvent.java @@ -4,6 +4,9 @@ import com.alibaba.fastjson2.annotation.JSONType; /** * Represents a message stop event during message streaming. + * + * @author skyfire + * @version $Id: 0.0.1 */ @JSONType(typeName = "message_stop") public class MessageStopStreamEvent extends StreamEvent{ diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/StreamEvent.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/StreamEvent.java index 1a4627dd5..b45c852ca 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/StreamEvent.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/assistant/event/StreamEvent.java @@ -4,6 +4,9 @@ import com.alibaba.fastjson2.annotation.JSONType; /** * Base class for stream events during message streaming. + * + * @author skyfire + * @version $Id: 0.0.1 */ @JSONType(typeKey = "type", typeName = "StreamEvent", seeAlso = {MessageStartStreamEvent.class, MessageStopStreamEvent.class, ContentBlockStartEvent.class, ContentBlockStopEvent.class, diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInitializeRequest.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInitializeRequest.java index 6ddc66e41..64b5ffc78 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInitializeRequest.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInitializeRequest.java @@ -5,6 +5,9 @@ import com.alibaba.qwen.code.cli.protocol.data.InitializeConfig; /** * Represents a control initialize request to the CLI. + * + * @author skyfire + * @version $Id: 0.0.1 */ public class CLIControlInitializeRequest { /** diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInitializeResponse.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInitializeResponse.java index 69da95deb..2216de169 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInitializeResponse.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInitializeResponse.java @@ -4,6 +4,9 @@ import com.alibaba.qwen.code.cli.protocol.data.Capabilities; /** * Represents a control initialize response from the CLI. + * + * @author skyfire + * @version $Id: 0.0.1 */ public class CLIControlInitializeResponse { /** diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInterruptRequest.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInterruptRequest.java index 2b1ec9fc5..fb72114bc 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInterruptRequest.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInterruptRequest.java @@ -2,6 +2,9 @@ package com.alibaba.qwen.code.cli.protocol.message.control; /** * Represents a control interrupt request to the CLI. + * + * @author skyfire + * @version $Id: 0.0.1 */ public class CLIControlInterruptRequest { /** diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlPermissionRequest.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlPermissionRequest.java index e03d86bad..2d488c96c 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlPermissionRequest.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlPermissionRequest.java @@ -7,6 +7,9 @@ import com.alibaba.fastjson2.annotation.JSONField; /** * Represents a control permission request to the CLI. + * + * @author skyfire + * @version $Id: 0.0.1 */ public class CLIControlPermissionRequest { /** diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlPermissionResponse.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlPermissionResponse.java index 2a1d22588..c5b23fe24 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlPermissionResponse.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlPermissionResponse.java @@ -5,6 +5,9 @@ import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior; /** * Represents a control permission response from the CLI. + * + * @author skyfire + * @version $Id: 0.0.1 */ public class CLIControlPermissionResponse { /** diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlRequest.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlRequest.java index e12319cad..e888984be 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlRequest.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlRequest.java @@ -10,6 +10,8 @@ import com.alibaba.qwen.code.cli.protocol.message.MessageBase; * Represents a control request to the CLI. * * @param The type of the request object + * @author skyfire + * @version $Id: 0.0.1 */ @JSONType(typeKey = "type", typeName = "control_request") public class CLIControlRequest extends MessageBase { diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlResponse.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlResponse.java index f71c2156c..7e416786e 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlResponse.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlResponse.java @@ -8,6 +8,8 @@ import com.alibaba.qwen.code.cli.protocol.message.MessageBase; * Represents a control response from the CLI. * * @param The type of the response object + * @author skyfire + * @version $Id: 0.0.1 */ @JSONType(typeKey = "type", typeName = "control_response") public class CLIControlResponse extends MessageBase { diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetModelRequest.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetModelRequest.java index 8b1e5d8dc..2f2d4a751 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetModelRequest.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetModelRequest.java @@ -2,6 +2,9 @@ package com.alibaba.qwen.code.cli.protocol.message.control; /** * Represents a control request to set the model in the CLI. + * + * @author skyfire + * @version $Id: 0.0.1 */ public class CLIControlSetModelRequest { /** diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetModelResponse.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetModelResponse.java index d5a0c2c87..6c3dc2c2c 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetModelResponse.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetModelResponse.java @@ -2,6 +2,9 @@ package com.alibaba.qwen.code.cli.protocol.message.control; /** * Represents a control response for setting the model in the CLI. + * + * @author skyfire + * @version $Id: 0.0.1 */ public class CLIControlSetModelResponse { /** diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetPermissionModeRequest.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetPermissionModeRequest.java index 587fafdc5..7fb26851f 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetPermissionModeRequest.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetPermissionModeRequest.java @@ -2,6 +2,9 @@ package com.alibaba.qwen.code.cli.protocol.message.control; /** * Represents a control request to set the permission mode in the CLI. + * + * @author skyfire + * @version $Id: 0.0.1 */ public class CLIControlSetPermissionModeRequest { /** diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/Session.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/Session.java index 2706bcb1a..95a6cb6ba 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/Session.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/Session.java @@ -41,6 +41,9 @@ import org.slf4j.LoggerFactory; /** * Manages a session with the Qwen Code CLI, handling communication, sending prompts, and processing responses. + * + * @author skyfire + * @version $Id: 0.0.1 */ public class Session { private static final Logger log = LoggerFactory.getLogger(Session.class); @@ -65,7 +68,7 @@ public class Session { * Constructs a new session with the specified transport. * * @param transport The transport layer to use for communication - * @throws SessionControlException if the transport is not available + * @throws com.alibaba.qwen.code.cli.session.exception.SessionControlException if the transport is not available */ public Session(Transport transport) throws SessionControlException { if (transport == null || !transport.isAvailable()) { @@ -78,7 +81,7 @@ public class Session { /** * Starts the session by initializing communication with the CLI. * - * @throws SessionControlException if initialization fails + * @throws com.alibaba.qwen.code.cli.session.exception.SessionControlException if initialization fails */ public void start() throws SessionControlException { try { @@ -97,7 +100,7 @@ public class Session { /** * Closes the session and releases resources. * - * @throws SessionControlException if closing fails + * @throws com.alibaba.qwen.code.cli.session.exception.SessionControlException if closing fails */ public void close() throws SessionControlException { try { @@ -111,7 +114,7 @@ public class Session { * Interrupts the current operation in the CLI. * * @return An optional boolean indicating success of the interrupt operation - * @throws SessionControlException if the operation fails + * @throws com.alibaba.qwen.code.cli.session.exception.SessionControlException if the operation fails */ public Optional interrupt() throws SessionControlException { checkAvailable(); @@ -123,7 +126,7 @@ public class Session { * * @param modelName The name of the model to use * @return An optional boolean indicating success of the operation - * @throws SessionControlException if the operation fails + * @throws com.alibaba.qwen.code.cli.session.exception.SessionControlException if the operation fails */ public Optional setModel(String modelName) throws SessionControlException { checkAvailable(); @@ -137,7 +140,7 @@ public class Session { * * @param permissionMode The permission mode to use * @return An optional boolean indicating success of the operation - * @throws SessionControlException if the operation fails + * @throws com.alibaba.qwen.code.cli.session.exception.SessionControlException if the operation fails */ public Optional setPermissionMode(PermissionMode permissionMode) throws SessionControlException { checkAvailable(); @@ -165,7 +168,7 @@ public class Session { /** * Continues the current session. * - * @throws SessionControlException if the operation fails + * @throws com.alibaba.qwen.code.cli.session.exception.SessionControlException if the operation fails */ public void continueSession() throws SessionControlException { resumeSession(getSessionId()); @@ -175,7 +178,7 @@ public class Session { * Resumes a session with the specified ID. * * @param sessionId The ID of the session to resume - * @throws SessionControlException if the operation fails + * @throws com.alibaba.qwen.code.cli.session.exception.SessionControlException if the operation fails */ public void resumeSession(String sessionId) throws SessionControlException { if (StringUtils.isNotBlank(sessionId)) { @@ -189,8 +192,8 @@ public class Session { * * @param prompt The prompt to send to the CLI * @param sessionEventConsumers Consumers for handling different types of events - * @throws SessionSendPromptException if sending the prompt fails - * @throws SessionControlException if a control operation fails + * @throws com.alibaba.qwen.code.cli.session.exception.SessionSendPromptException if sending the prompt fails + * @throws com.alibaba.qwen.code.cli.session.exception.SessionControlException if a control operation fails */ public void sendPrompt(String prompt, SessionEventConsumers sessionEventConsumers) throws SessionSendPromptException, SessionControlException { checkAvailable(); diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/AssistantContentConsumers.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/AssistantContentConsumers.java index 3338c4ac7..8fce1c4fa 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/AssistantContentConsumers.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/AssistantContentConsumers.java @@ -10,6 +10,9 @@ import com.alibaba.qwen.code.cli.session.Session; /** * Interface for handling different types of assistant content during a session. + * + * @author skyfire + * @version $Id: 0.0.1 */ public interface AssistantContentConsumers { /** diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/AssistantContentSimpleConsumers.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/AssistantContentSimpleConsumers.java index f5c390a59..9a2b63df9 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/AssistantContentSimpleConsumers.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/AssistantContentSimpleConsumers.java @@ -10,28 +10,37 @@ import com.alibaba.qwen.code.cli.session.Session; /** * Simple implementation of AssistantContentConsumers that provides empty implementations for all methods. + * + * @author skyfire + * @version $Id: 0.0.1 */ public class AssistantContentSimpleConsumers implements AssistantContentConsumers { + /** {@inheritDoc} */ @Override public void onText(Session session, TextAssistantContent textAssistantContent) { } + /** {@inheritDoc} */ @Override public void onThinking(Session session, ThingkingAssistantContent thingkingAssistantContent) { } + /** {@inheritDoc} */ @Override public void onToolUse(Session session, ToolUseAssistantContent toolUseAssistantContent) { } + /** {@inheritDoc} */ @Override public void onToolResult(Session session, ToolResultAssistantContent toolResultAssistantContent) { } + /** {@inheritDoc} */ @Override public void onOtherContent(Session session, AssistantContent other) { } + /** {@inheritDoc} */ @Override public void onUsage(Session session, AssistantUsage AssistantUsage) { } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventConsumers.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventConsumers.java index da861ee8b..7159beaef 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventConsumers.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventConsumers.java @@ -14,6 +14,9 @@ import com.alibaba.qwen.code.cli.utils.Timeout; /** * Interface for handling different types of events during a session. + * + * @author skyfire + * @version $Id: 0.0.1 */ public interface SessionEventConsumers { /** diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventSimpleConsumers.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventSimpleConsumers.java index 517cc022c..38ed31aa1 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventSimpleConsumers.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventSimpleConsumers.java @@ -31,16 +31,22 @@ import org.slf4j.LoggerFactory; /** * Simple implementation of SessionEventConsumers that provides basic implementations for all methods. + * + * @author skyfire + * @version $Id: 0.0.1 */ public class SessionEventSimpleConsumers implements SessionEventConsumers { + /** {@inheritDoc} */ @Override public void onSystemMessage(Session session, SDKSystemMessage systemMessage) { } + /** {@inheritDoc} */ @Override public void onResultMessage(Session session, SDKResultMessage resultMessage) { } + /** {@inheritDoc} */ @Override public void onAssistantMessage(Session session, SDKAssistantMessage assistantMessage) { List> contentBlocks = assistantMessage.getMessage().getContent(); @@ -54,6 +60,7 @@ public class SessionEventSimpleConsumers implements SessionEventConsumers { } } + /** {@inheritDoc} */ @Override public void onPartialAssistantMessage(Session session, SDKPartialAssistantMessage partialAssistantMessage) { StreamEvent event = partialAssistantMessage.getEvent(); @@ -67,6 +74,12 @@ public class SessionEventSimpleConsumers implements SessionEventConsumers { consumeAssistantContent(session, contentBlockDeltaEvent.getDelta()); } + /** + *

consumeAssistantContent.

+ * + * @param session a {@link com.alibaba.qwen.code.cli.session.Session} object. + * @param assistantContent a {@link com.alibaba.qwen.code.cli.protocol.data.AssistantContent} object. + */ protected void consumeAssistantContent(Session session, AssistantContent assistantContent) { if (assistantContent instanceof TextAssistantContent) { assistantContentConsumers.onText(session, (TextAssistantContent) assistantContent); @@ -81,23 +94,28 @@ public class SessionEventSimpleConsumers implements SessionEventConsumers { } } + /** {@inheritDoc} */ @Override public void onUserMessage(Session session, SDKUserMessage userMessage) { } + /** {@inheritDoc} */ @Override public void onOtherMessage(Session session, String message) { } + /** {@inheritDoc} */ @Override public void onControlResponse(Session session, CLIControlResponse cliControlResponse) { } + /** {@inheritDoc} */ @Override public CLIControlResponse onControlRequest(Session session, CLIControlRequest cliControlRequest) { return new CLIControlResponse<>(); } + /** {@inheritDoc} */ @Override public Behavior onPermissionRequest(Session session, CLIControlRequest permissionRequest) { if (Operation.deny.equals(this.defaultPermissionOperation)) { @@ -107,46 +125,55 @@ public class SessionEventSimpleConsumers implements SessionEventConsumers { } } + /** {@inheritDoc} */ @Override public Timeout onSystemMessageTimeout(Session session) { return defaultEventTimeout; } + /** {@inheritDoc} */ @Override public Timeout onResultMessageTimeout(Session session) { return defaultEventTimeout; } + /** {@inheritDoc} */ @Override public Timeout onAssistantMessageTimeout(Session session) { return defaultEventTimeout; } + /** {@inheritDoc} */ @Override public Timeout onPartialAssistantMessageTimeout(Session session) { return defaultEventTimeout; } + /** {@inheritDoc} */ @Override public Timeout onUserMessageTimeout(Session session) { return defaultEventTimeout; } + /** {@inheritDoc} */ @Override public Timeout onOtherMessageTimeout(Session session) { return defaultEventTimeout; } + /** {@inheritDoc} */ @Override public Timeout onControlResponseTimeout(Session session) { return defaultEventTimeout; } + /** {@inheritDoc} */ @Override public Timeout onControlRequestTimeout(Session session) { return defaultEventTimeout; } + /** {@inheritDoc} */ @Override public Timeout onPermissionRequestTimeout(Session session) { return defaultEventTimeout; diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionControlException.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionControlException.java index 3fa30bd55..ad629e895 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionControlException.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionControlException.java @@ -2,6 +2,9 @@ package com.alibaba.qwen.code.cli.session.exception; /** * Exception thrown when a session control operation fails. + * + * @author skyfire + * @version $Id: 0.0.1 */ public class SessionControlException extends Exception { /** diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionSendPromptException.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionSendPromptException.java index 460c5f560..6f2c87f0c 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionSendPromptException.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/exception/SessionSendPromptException.java @@ -2,6 +2,9 @@ package com.alibaba.qwen.code.cli.session.exception; /** * Exception thrown when sending a prompt in a session fails. + * + * @author skyfire + * @version $Id: 0.0.1 */ public class SessionSendPromptException extends Exception { /** diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/Transport.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/Transport.java index 1bb46020e..0171821e1 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/Transport.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/Transport.java @@ -7,6 +7,9 @@ import java.util.function.Function; /** * Defines the contract for communication with the Qwen Code CLI. + * + * @author skyfire + * @version $Id: 0.0.1 */ public interface Transport { /** @@ -26,14 +29,14 @@ public interface Transport { /** * Starts the transport. * - * @throws IOException if starting fails + * @throws java.io.IOException if starting fails */ void start() throws IOException; /** * Closes the transport and releases resources. * - * @throws IOException if closing fails + * @throws java.io.IOException if closing fails */ void close() throws IOException; @@ -49,10 +52,10 @@ public interface Transport { * * @param message The message to send * @return The response message - * @throws IOException if an I/O error occurs - * @throws ExecutionException if an execution error occurs - * @throws InterruptedException if the operation is interrupted - * @throws TimeoutException if the operation times out + * @throws java.io.IOException if an I/O error occurs + * @throws java.util.concurrent.ExecutionException if an execution error occurs + * @throws java.lang.InterruptedException if the operation is interrupted + * @throws java.util.concurrent.TimeoutException if the operation times out */ String inputWaitForOneLine(String message) throws IOException, ExecutionException, InterruptedException, TimeoutException; @@ -61,7 +64,7 @@ public interface Transport { * * @param message The message to send * @param callBackFunction A function to process each line of the response - * @throws IOException if an I/O error occurs + * @throws java.io.IOException if an I/O error occurs */ void inputWaitForMultiLine(String message, Function callBackFunction) throws IOException; @@ -69,7 +72,7 @@ public interface Transport { * Sends a message without waiting for a response. * * @param message The message to send - * @throws IOException if an I/O error occurs + * @throws java.io.IOException if an I/O error occurs */ void inputNoWaitResponse(String message) throws IOException; } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java index a73f1c13d..5f72e1c0b 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/TransportOptions.java @@ -8,6 +8,9 @@ import com.alibaba.qwen.code.cli.utils.Timeout; /** * Configuration options for the transport layer. + * + * @author skyfire + * @version $Id: 0.0.1 */ public class TransportOptions implements Cloneable { /** @@ -395,6 +398,7 @@ public class TransportOptions implements Cloneable { return this; } + /** {@inheritDoc} */ @Override public TransportOptions clone() { try { diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransport.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransport.java index fb590e9c0..ee53c21f7 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransport.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransport.java @@ -24,6 +24,9 @@ import java.util.function.Function; /** * Implementation of the Transport interface that communicates with the Qwen CLI via a process. + * + * @author skyfire + * @version $Id: 0.0.1 */ public class ProcessTransport implements Transport { private static final Logger log = LoggerFactory.getLogger(ProcessTransport.class); @@ -42,7 +45,7 @@ public class ProcessTransport implements Transport { /** * Constructs a new ProcessTransport with default options. * - * @throws IOException if starting the process fails + * @throws java.io.IOException if starting the process fails */ public ProcessTransport() throws IOException { this(new TransportOptions()); @@ -52,7 +55,7 @@ public class ProcessTransport implements Transport { * Constructs a new ProcessTransport with the specified options. * * @param transportOptions The transport options to use - * @throws IOException if starting the process fails + * @throws java.io.IOException if starting the process fails */ public ProcessTransport(TransportOptions transportOptions) throws IOException { this(transportOptions, (line) -> log.error("process error: {}", line)); @@ -63,7 +66,7 @@ public class ProcessTransport implements Transport { * * @param transportOptions The transport options to use * @param errorHandler The error handler to use - * @throws IOException if starting the process fails + * @throws java.io.IOException if starting the process fails */ public ProcessTransport(TransportOptions transportOptions, Consumer errorHandler) throws IOException { this.transportOptions = transportOptions; @@ -71,16 +74,19 @@ public class ProcessTransport implements Transport { start(); } + /** {@inheritDoc} */ @Override public TransportOptions getTransportOptions() { return transportOptions; } + /** {@inheritDoc} */ @Override public boolean isReading() { return reading.get(); } + /** {@inheritDoc} */ @Override public void start() throws IOException { TransportOptionsAdapter transportOptionsAdapter = new TransportOptionsAdapter(transportOptions); @@ -104,6 +110,7 @@ public class ProcessTransport implements Transport { startErrorReading(); } + /** {@inheritDoc} */ @Override public void close() throws IOException { if (processInput != null) { @@ -120,11 +127,13 @@ public class ProcessTransport implements Transport { } } + /** {@inheritDoc} */ @Override public boolean isAvailable() { return process != null && process.isAlive(); } + /** {@inheritDoc} */ @Override public String inputWaitForOneLine(String message) throws IOException, ExecutionException, InterruptedException, TimeoutException { return inputWaitForOneLine(message, turnTimeout); @@ -150,6 +159,7 @@ public class ProcessTransport implements Transport { } } + /** {@inheritDoc} */ @Override public void inputWaitForMultiLine(String message, Function callBackFunction) throws IOException { inputWaitForMultiLine(message, callBackFunction, turnTimeout); @@ -161,6 +171,7 @@ public class ProcessTransport implements Transport { MyConcurrentUtils.runAndWait(() -> iterateOutput(callBackFunction), timeOut); } + /** {@inheritDoc} */ @Override public void inputNoWaitResponse(String message) throws IOException { log.debug("input message to process: {}", message); diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/MyConcurrentUtils.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/MyConcurrentUtils.java index d50892701..34c7585d5 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/MyConcurrentUtils.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/MyConcurrentUtils.java @@ -11,6 +11,9 @@ import org.slf4j.LoggerFactory; /** * Utility class for concurrent operations. + * + * @author skyfire + * @version $Id: 0.0.1 */ public class MyConcurrentUtils { private static final Logger log = LoggerFactory.getLogger(MyConcurrentUtils.class); @@ -50,9 +53,9 @@ public class MyConcurrentUtils { * @param timeOut The timeout for the operation * @param The type of the result * @return The result of the task - * @throws ExecutionException if an execution error occurs - * @throws InterruptedException if the operation is interrupted - * @throws TimeoutException if the operation times out + * @throws java.util.concurrent.ExecutionException if an execution error occurs + * @throws java.lang.InterruptedException if the operation is interrupted + * @throws java.util.concurrent.TimeoutException if the operation times out */ public static T runAndWait(Supplier supplier, Timeout timeOut) throws ExecutionException, InterruptedException, TimeoutException { diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/ThreadPoolConfig.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/ThreadPoolConfig.java index 671bdde74..8213cdef8 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/ThreadPoolConfig.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/ThreadPoolConfig.java @@ -11,6 +11,9 @@ import java.util.function.Supplier; /** * Configuration for the thread pool used by the SDK. + * + * @author skyfire + * @version $Id: 0.0.1 */ public class ThreadPoolConfig { private static final ThreadPoolExecutor defaultExecutor = new ThreadPoolExecutor( diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/Timeout.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/Timeout.java index 76fb29ab8..e221cddbb 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/Timeout.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/Timeout.java @@ -6,6 +6,9 @@ import org.apache.commons.lang3.Validate; /** * Represents a timeout value with a time unit. + * + * @author skyfire + * @version $Id: 0.0.1 */ public class Timeout { /** From 51b08f700c786c4ae86e78954dc827c964f2c1f9 Mon Sep 17 00:00:00 2001 From: skyfire Date: Mon, 5 Jan 2026 17:44:07 +0800 Subject: [PATCH 047/142] for examples --- packages/sdk-java/QWEN.md | 268 +++++- packages/sdk-java/README.md | 856 ++++-------------- packages/sdk-java/pom.xml | 41 +- .../alibaba/qwen/code/cli/QwenCodeCli.java | 12 +- .../cli/protocol/data/behavior/Behavior.java | 4 + .../control/CLIControlInterruptRequest.java | 32 - .../message/control/CLIControlRequest.java | 5 +- .../control/CLIControlSetModelRequest.java | 54 -- .../CLIControlSetPermissionModeRequest.java | 55 -- .../CLIControlInitializeRequest.java | 32 +- .../CLIControlInitializeResponse.java | 33 +- .../payload/CLIControlInterruptRequest.java | 17 + .../CLIControlPermissionRequest.java | 32 +- .../CLIControlPermissionResponse.java | 32 +- .../payload/CLIControlSetModelRequest.java | 40 + .../CLIControlSetModelResponse.java | 2 +- .../CLIControlSetPermissionModeRequest.java | 40 + .../payload/ControlRequestPayload.java | 26 + .../payload/ControlResponsePayload.java | 26 + .../qwen/code/cli/session/Session.java | 134 +-- .../event/AssistantContentConsumers.java | 65 -- .../AssistantContentSimpleConsumers.java | 47 - .../event/SessionEventSimpleConsumers.java | 266 ------ .../consumers/AssistantContentConsumers.java | 159 ++++ .../AssistantContentSimpleConsumers.java | 176 ++++ .../SessionEventConsumers.java | 49 +- .../SessionEventSimpleConsumers.java | 339 +++++++ .../qwen/code/cli/utils/ThreadPoolConfig.java | 2 +- .../alibaba/qwen/code/cli/utils/Timeout.java | 6 + .../code/cli/example/QuickStartExample.java | 109 +++ .../qwen/code/cli/example/SessionExample.java | 256 ++++++ .../ThreadPoolConfigurationExample.java | 50 + .../qwen/code/cli/session/SessionTest.java | 34 +- .../process/ProcessTransportTest.java | 4 +- 34 files changed, 1808 insertions(+), 1495 deletions(-) delete mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInterruptRequest.java delete mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetModelRequest.java delete mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetPermissionModeRequest.java rename packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/{ => payload}/CLIControlInitializeRequest.java (63%) rename packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/{ => payload}/CLIControlInitializeResponse.java (55%) create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlInterruptRequest.java rename packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/{ => payload}/CLIControlPermissionRequest.java (90%) rename packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/{ => payload}/CLIControlPermissionResponse.java (60%) create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlSetModelRequest.java rename packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/{ => payload}/CLIControlSetModelResponse.java (93%) create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlSetPermissionModeRequest.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/ControlRequestPayload.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/ControlResponsePayload.java delete mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/AssistantContentConsumers.java delete mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/AssistantContentSimpleConsumers.java delete mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventSimpleConsumers.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/AssistantContentConsumers.java create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/AssistantContentSimpleConsumers.java rename packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/{ => consumers}/SessionEventConsumers.java (71%) create mode 100644 packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/SessionEventSimpleConsumers.java create mode 100644 packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/example/QuickStartExample.java create mode 100644 packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/example/SessionExample.java create mode 100644 packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/example/ThreadPoolConfigurationExample.java diff --git a/packages/sdk-java/QWEN.md b/packages/sdk-java/QWEN.md index 0ebb55c7d..fab09cf5c 100644 --- a/packages/sdk-java/QWEN.md +++ b/packages/sdk-java/QWEN.md @@ -4,23 +4,30 @@ The Qwen Code Java SDK is a minimum experimental SDK for programmatic access to Qwen Code functionality. It provides a Java interface to interact with the Qwen Code CLI, allowing developers to integrate Qwen Code capabilities into their Java applications. -The project is structured as a Maven-based Java library with the following key characteristics: +**Context Information:** + +- Current Date: Monday 5 January 2026 +- Operating System: darwin +- Working Directory: /Users/weigeng/repos/qwen-code/packages/sdk-java + +## Project Details - **Group ID**: com.alibaba -- **Artifact ID**: qwencode-sdk-java -- **Version**: 0.0.1 +- **Artifact ID**: qwencode-sdk (as per pom.xml) +- **Version**: 0.0.1-SNAPSHOT - **Packaging**: JAR - **Java Version**: 1.8+ (source and target) +- **License**: Apache-2.0 ## Architecture The SDK follows a layered architecture: -- **CLI Layer**: Provides the main entry point through `QwenCodeCli` class -- **Session Layer**: Manages communication sessions with the Qwen Code CLI -- **Transport Layer**: Handles communication between the SDK and CLI process -- **Protocol Layer**: Defines data structures for communication -- **Utils**: Common utilities for concurrent execution and timeout handling +- **API Layer**: Provides the main entry points through `QwenCodeCli` class with simple static methods for basic usage +- **Session Layer**: Manages communication sessions with the Qwen Code CLI through the `Session` class +- **Transport Layer**: Handles the communication mechanism between the SDK and CLI process (currently using process transport via `ProcessTransport`) +- **Protocol Layer**: Defines data structures for communication based on the CLI protocol +- **Utils**: Common utilities for concurrent execution, timeout handling, and error management ## Key Components @@ -30,6 +37,9 @@ The SDK follows a layered architecture: - `Session`: Manages communication sessions with the CLI - `Transport`: Abstracts the communication mechanism (currently using process transport) - `ProcessTransport`: Implementation that communicates via process execution +- `TransportOptions`: Configuration class for transport layer settings +- `SessionEventSimpleConsumers`: High-level event handler for processing responses +- `AssistantContentSimpleConsumers`: Handles different types of content within assistant messages ### Dependencies @@ -62,6 +72,9 @@ mvn install # Run checkstyle verification mvn checkstyle:check + +# Generate Javadoc +mvn javadoc:javadoc ``` ### Testing @@ -76,6 +89,9 @@ The project uses Checkstyle for code formatting and style enforcement. The confi - Naming conventions - Import ordering - Code structure +- Line endings (LF only) +- No trailing whitespace +- 8-space indentation for line wrapping ## Development Conventions @@ -105,10 +121,13 @@ The project uses Checkstyle for code formatting and style enforcement. The confi ### QwenCodeCli Class -The main class provides two primary methods: +The main class provides several primary methods: - `simpleQuery(String prompt)`: Synchronous method that returns a list of responses -- `simpleQuery(String prompt, Consumer messageConsumer)`: Asynchronous method that streams responses to a consumer +- `simpleQuery(String prompt, TransportOptions transportOptions)`: Synchronous method with custom transport options +- `simpleQuery(String prompt, TransportOptions transportOptions, AssistantContentConsumers assistantContentConsumers)`: Advanced method with custom content consumers +- `newSession()`: Creates a new session with default options +- `newSession(TransportOptions transportOptions)`: Creates a new session with custom options ### Permission Modes @@ -119,24 +138,175 @@ The SDK supports different permission modes for controlling tool execution: - **`auto-edit`**: Auto-approve edit tools (edit, write_file) while other tools require confirmation. - **`yolo`**: All tools execute automatically without confirmation. -## Usage Example +### Transport Options -```java -import com.alibaba.qwen.code.cli.QwenCodeCli; -import java.util.List; +The `TransportOptions` class allows configuration of how the SDK communicates with the Qwen Code CLI: -public class Example { - public static void main(String[] args) { - List result = QwenCodeCli.simpleQuery("hello world"); - result.forEach(System.out::println); - } -} -``` +- `pathToQwenExecutable`: Path to the Qwen Code CLI executable +- `cwd`: Working directory for the CLI process +- `model`: AI model to use for the session +- `permissionMode`: Permission mode that controls tool execution +- `env`: Environment variables to pass to the CLI process +- `maxSessionTurns`: Limits the number of conversation turns in a session +- `coreTools`: List of core tools that should be available to the AI +- `excludeTools`: List of tools to exclude from being available to the AI +- `allowedTools`: List of tools that are pre-approved for use without additional confirmation +- `authType`: Authentication type to use for the session +- `includePartialMessages`: Enables receiving partial messages during streaming responses +- `skillsEnable`: Enables or disables skills functionality for the session +- `turnTimeout`: Timeout for a complete turn of conversation +- `messageTimeout`: Timeout for individual messages within a turn +- `resumeSessionId`: ID of a previous session to resume +- `otherOptions`: Additional command-line options to pass to the CLI + +### Session Control Features + +- **Session creation**: Use `QwenCodeCli.newSession()` to create a new session with custom options +- **Session management**: The `Session` class provides methods to send prompts, handle responses, and manage session state +- **Session cleanup**: Always close sessions using `session.close()` to properly terminate the CLI process +- **Session resumption**: Use `setResumeSessionId()` in `TransportOptions` to resume a previous session +- **Session interruption**: Use `session.interrupt()` to interrupt a currently running prompt +- **Dynamic model switching**: Use `session.setModel()` to change the model during a session +- **Dynamic permission mode switching**: Use `session.setPermissionMode()` to change the permission mode during a session + +### Thread Pool Configuration + +The SDK uses a thread pool for managing concurrent operations with the following default configuration: + +- **Core Pool Size**: 30 threads +- **Maximum Pool Size**: 100 threads +- **Keep-Alive Time**: 60 seconds +- **Queue Capacity**: 300 tasks (using LinkedBlockingQueue) +- **Thread Naming**: "qwen_code_cli-pool-{number}" +- **Daemon Threads**: false +- **Rejected Execution Handler**: CallerRunsPolicy + +### Session Event Consumers and Assistant Content Consumers + +The SDK provides two key interfaces for handling events and content from the CLI: + +#### SessionEventConsumers Interface + +The `SessionEventConsumers` interface provides callbacks for different types of messages during a session: + +- `onSystemMessage`: Handles system messages from the CLI (receives Session and SDKSystemMessage) +- `onResultMessage`: Handles result messages from the CLI (receives Session and SDKResultMessage) +- `onAssistantMessage`: Handles assistant messages (AI responses) (receives Session and SDKAssistantMessage) +- `onPartialAssistantMessage`: Handles partial assistant messages during streaming (receives Session and SDKPartialAssistantMessage) +- `onUserMessage`: Handles user messages (receives Session and SDKUserMessage) +- `onOtherMessage`: Handles other types of messages (receives Session and String message) +- `onControlResponse`: Handles control responses (receives Session and CLIControlResponse) +- `onControlRequest`: Handles control requests (receives Session and CLIControlRequest, returns CLIControlResponse) +- `onPermissionRequest`: Handles permission requests (receives Session and CLIControlRequest, returns Behavior) + +#### AssistantContentConsumers Interface + +The `AssistantContentConsumers` interface handles different types of content within assistant messages: + +- `onText`: Handles text content (receives Session and TextAssistantContent) +- `onThinking`: Handles thinking content (receives Session and ThingkingAssistantContent) +- `onToolUse`: Handles tool use content (receives Session and ToolUseAssistantContent) +- `onToolResult`: Handles tool result content (receives Session and ToolResultAssistantContent) +- `onOtherContent`: Handles other content types (receives Session and AssistantContent) +- `onUsage`: Handles usage information (receives Session and AssistantUsage) +- `onPermissionRequest`: Handles permission requests (receives Session and CLIControlPermissionRequest, returns Behavior) +- `onOtherControlRequest`: Handles other control requests (receives Session and ControlRequestPayload, returns ControlResponsePayload) + +#### Relationship Between the Interfaces + +**Important Note on Event Hierarchy:** + +- `SessionEventConsumers` is the **high-level** event processor that handles different message types (system, assistant, user, etc.) +- `AssistantContentConsumers` is the **low-level** content processor that handles different types of content within assistant messages (text, tools, thinking, etc.) + +**Processor Relationship:** + +- `SessionEventConsumers` → `AssistantContentConsumers` (SessionEventConsumers uses AssistantContentConsumers to process content within assistant messages) + +**Event Derivation Relationships:** + +- `onAssistantMessage` → `onText`, `onThinking`, `onToolUse`, `onToolResult`, `onOtherContent`, `onUsage` +- `onPartialAssistantMessage` → `onText`, `onThinking`, `onToolUse`, `onToolResult`, `onOtherContent` +- `onControlRequest` → `onPermissionRequest`, `onOtherControlRequest` + +**Event Timeout Relationships:** + +Each event handler method has a corresponding timeout method that allows customizing the timeout behavior for that specific event: + +- `onSystemMessage` ↔ `onSystemMessageTimeout` +- `onResultMessage` ↔ `onResultMessageTimeout` +- `onAssistantMessage` ↔ `onAssistantMessageTimeout` +- `onPartialAssistantMessage` ↔ `onPartialAssistantMessageTimeout` +- `onUserMessage` ↔ `onUserMessageTimeout` +- `onOtherMessage` ↔ `onOtherMessageTimeout` +- `onControlResponse` ↔ `onControlResponseTimeout` +- `onControlRequest` ↔ `onControlRequestTimeout` + +For AssistantContentConsumers timeout methods: + +- `onText` ↔ `onTextTimeout` +- `onThinking` ↔ `onThinkingTimeout` +- `onToolUse` ↔ `onToolUseTimeout` +- `onToolResult` ↔ `onToolResultTimeout` +- `onOtherContent` ↔ `onOtherContentTimeout` +- `onPermissionRequest` ↔ `onPermissionRequestTimeout` +- `onOtherControlRequest` ↔ `onOtherControlRequestTimeout` + +**Default Timeout Values:** + +- `SessionEventSimpleConsumers` default timeout: 180 seconds (Timeout.TIMEOUT_180_SECONDS) +- `AssistantContentSimpleConsumers` default timeout: 60 seconds (Timeout.TIMEOUT_60_SECONDS) + +**Timeout Hierarchy Requirements:** + +For proper operation, the following timeout relationships should be maintained: + +- `onAssistantMessageTimeout` return value should be greater than `onTextTimeout`, `onThinkingTimeout`, `onToolUseTimeout`, `onToolResultTimeout`, and `onOtherContentTimeout` return values +- `onControlRequestTimeout` return value should be greater than `onPermissionRequestTimeout` and `onOtherControlRequestTimeout` return values + +#### Relationship Between the Interfaces + +- `AssistantContentSimpleConsumers` is the default implementation of `AssistantContentConsumers` +- `SessionEventSimpleConsumers` is the concrete implementation that combines both interfaces and depends on an `AssistantContentConsumers` instance to handle content within assistant messages +- The timeout methods in `SessionEventConsumers` now include the message object as a parameter (e.g., `onSystemMessageTimeout(Session session, SDKSystemMessage systemMessage)`) + +Event processing is subject to the timeout settings configured in `TransportOptions` and `SessionEventConsumers`. For detailed timeout configuration options, see the "Timeout" section above. + +## Usage Examples + +The SDK includes several example files in `src/test/java/com/alibaba/qwen/code/cli/example/` that demonstrate different aspects of the API: + +### Basic Usage + +- `QuickStartExample.java`: Demonstrates simple query usage, transport options configuration, and streaming content handling + +### Session Control + +- `SessionExample.java`: Shows session control features including permission mode changes, model switching, interruption, and event handling + +### Configuration + +- `ThreadPoolConfigurationExample.java`: Shows how to configure the thread pool used by the SDK + +## Error Handling + +The SDK provides specific exception types for different error scenarios: + +- `SessionControlException`: Thrown when there's an issue with session control (creation, initialization, etc.) +- `SessionSendPromptException`: Thrown when there's an issue sending a prompt or receiving a response +- `SessionClosedException`: Thrown when attempting to use a closed session ## Project Structure ``` src/ +├── example/ +│ └── java/ +│ └── com/ +│ └── alibaba/ +│ └── qwen/ +│ └── code/ +│ └── example/ ├── main/ │ └── java/ │ └── com/ @@ -150,13 +320,20 @@ src/ │ ├── transport/ │ └── utils/ └── test/ - └── java/ - └── com/ - └── alibaba/ - └── qwen/ - └── code/ - └── cli/ - └── QwenCodeCliTest.java + ├── java/ + │ └── com/ + │ └── alibaba/ + │ └── qwen/ + │ └── code/ + │ └── cli/ + │ ├── QwenCodeCliTest.java + │ ├── session/ + │ │ └── SessionTest.java + │ └── transport/ + │ ├── PermissionModeTest.java + │ └── process/ + │ └── ProcessTransportTest.java + └── temp/ ``` ## Configuration Files @@ -165,6 +342,37 @@ src/ - `checkstyle.xml`: Code style and formatting rules - `.editorconfig`: Editor configuration settings -## License +## FAQ / Troubleshooting -Apache-2.0 - see [LICENSE](./LICENSE) for details. +### Q: Do I need to install the Qwen CLI separately? + +A: No, from v0.1.1, the CLI is bundled with the SDK, so no standalone CLI installation is needed. + +### Q: What Java versions are supported? + +A: The SDK requires Java 1.8 or higher. + +### Q: How do I handle long-running requests? + +A: The SDK includes timeout utilities. You can configure timeouts using the `Timeout` class in `TransportOptions`. + +### Q: Why are some tools not executing? + +A: This is likely due to permission modes. Check your permission mode settings and consider using `allowedTools` to pre-approve certain tools. + +### Q: How do I resume a previous session? + +A: Use the `setResumeSessionId()` method in `TransportOptions` to resume a previous session. + +### Q: Can I customize the environment for the CLI process? + +A: Yes, use the `setEnv()` method in `TransportOptions` to pass environment variables to the CLI process. + +### Q: What happens if the CLI process crashes? + +A: The SDK will throw appropriate exceptions. Make sure to handle `SessionControlException` and implement retry logic if needed. + +## Maintainers + +- **Developer**: skyfire (gengwei.gw(at)alibaba-inc.com) +- **Organization**: Alibaba Group diff --git a/packages/sdk-java/README.md b/packages/sdk-java/README.md index 5794a9a16..e9987d946 100644 --- a/packages/sdk-java/README.md +++ b/packages/sdk-java/README.md @@ -1,8 +1,19 @@ # Qwen Code Java SDK -A minimum experimental Java SDK for programmatic access to Qwen Code functionality. This SDK provides a Java interface to interact with the Qwen Code CLI, allowing developers to integrate Qwen Code capabilities into their Java applications. +The Qwen Code Java SDK is a minimum experimental SDK for programmatic access to Qwen Code functionality. It provides a Java interface to interact with the Qwen Code CLI, allowing developers to integrate Qwen Code capabilities into their Java applications. -Feel free to submit a feature request/issue/PR. +## Requirements + +- Java >= 1.8 +- Maven >= 3.6.0 (for building from source) + +### Dependencies + +- **Logging**: ch.qos.logback:logback-classic +- **Utilities**: org.apache.commons:commons-lang3 +- **JSON Processing**: com.alibaba.fastjson2:fastjson2 +- **Testing**: JUnit 5 (org.junit.jupiter:junit-jupiter) +- ## Installation @@ -11,91 +22,111 @@ Add the following dependency to your Maven `pom.xml`: ```xml com.alibaba - qwencode-sdk-java - 0.0.1 + qwencode-sdk + {$version} ``` Or if using Gradle, add to your `build.gradle`: ```gradle -implementation 'com.alibaba:qwencode-sdk-java:0.0.1' +implementation 'com.alibaba:qwencode-sdk:{$version}' ``` -## Requirements +## Building and Running -- Java >= 1.8 -- Maven >= 3.6.0 (for building from source) -- Qwen Code CLI: The SDK communicates with the Qwen Code CLI executable. By default, the SDK looks for a `qwen` command in the system PATH. +### Build Commands + +```bash +# Compile the project +mvn compile + +# Run tests +mvn test + +# Package the JAR +mvn package + +# Install to local repository +mvn install +``` ## Quick Start The simplest way to use the SDK is through the `QwenCodeCli.simpleQuery()` method: ```java -import com.alibaba.qwen.code.cli.QwenCodeCli; -import java.util.List; - -public class Example { - public static void main(String[] args) { - List result = QwenCodeCli.simpleQuery("hello world"); - result.forEach(System.out::println); - } +public static void runSimpleExample() { + List result = QwenCodeCli.simpleQuery("hello world"); + result.forEach(logger::info); } ``` -For more advanced usage with streaming responses: +For more advanced usage with custom transport options: ```java -import com.alibaba.qwen.code.cli.QwenCodeCli; -import java.util.function.Consumer; +public static void runTransportOptionsExample() { + TransportOptions options = new TransportOptions() + .setModel("qwen3-coder-flash") + .setPermissionMode(PermissionMode.AUTO_EDIT) + .setCwd("./") + .setEnv(new HashMap() {{put("CUSTOM_VAR", "value");}}) + .setIncludePartialMessages(true) + .setTurnTimeout(new Timeout(120L, TimeUnit.SECONDS)) + .setMessageTimeout(new Timeout(90L, TimeUnit.SECONDS)) + .setAllowedTools(Arrays.asList("read_file", "write_file", "list_directory")); -public class StreamingExample { - public static void main(String[] args) { - QwenCodeCli.simpleQuery("hello world", (String message) -> { - System.out.println("Received: " + message); - }); - } + List result = QwenCodeCli.simpleQuery("who are you, what are your capabilities?", options); + result.forEach(logger::info); } ``` -For session-based usage with custom event handling: +For streaming content handling with custom content consumers: ```java -import com.alibaba.qwen.code.cli.QwenCodeCli; -import com.alibaba.qwen.code.cli.session.Session; -import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; -import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKAssistantMessage; -import com.alibaba.qwen.code.cli.utils.Timeout; -import java.util.concurrent.TimeUnit; +public static void runStreamingExample() { + QwenCodeCli.simpleQuery("who are you, what are your capabilities?", + new TransportOptions().setMessageTimeout(new Timeout(10L, TimeUnit.SECONDS)), new AssistantContentSimpleConsumers() { -public class SessionExample { - public static void main(String[] args) { - try (Session session = QwenCodeCli.newSession()) { - SessionEventSimpleConsumers eventConsumers = new SessionEventSimpleConsumers() { @Override - public void onAssistantMessage(Session session, SDKAssistantMessage assistantMessage) { - String message = assistantMessage.getMessage().getContent().stream() - .findFirst() - .map(content -> content.getText()) - .orElse(""); - System.out.println("Assistant: " + message); + public void onText(Session session, TextAssistantContent textAssistantContent) { + logger.info("Text content received: {}", textAssistantContent.getText()); } - }.setDefaultEventTimeout(new Timeout(60L, TimeUnit.SECONDS)); - session.sendPrompt("hello world", eventConsumers); - } catch (Exception e) { - e.printStackTrace(); - } - } + @Override + public void onThinking(Session session, ThingkingAssistantContent thingkingAssistantContent) { + logger.info("Thinking content received: {}", thingkingAssistantContent.getThinking()); + } + + @Override + public void onToolUse(Session session, ToolUseAssistantContent toolUseContent) { + logger.info("Tool use content received: {} with arguments: {}", + toolUseContent, toolUseContent.getInput()); + } + + @Override + public void onToolResult(Session session, ToolResultAssistantContent toolResultContent) { + logger.info("Tool result content received: {}", toolResultContent.getContent()); + } + + @Override + public void onOtherContent(Session session, AssistantContent other) { + logger.info("Other content received: {}", other); + } + + @Override + public void onUsage(Session session, AssistantUsage assistantUsage) { + logger.info("Usage information received: Input tokens: {}, Output tokens: {}", + assistantUsage.getUsage().getInputTokens(), assistantUsage.getUsage().getOutputTokens()); + } + }.setDefaultPermissionOperation(Operation.allow)); + logger.info("Streaming example completed."); } ``` ## Architecture -The Qwen Code Java SDK follows a layered architecture that abstracts the communication with the Qwen Code CLI: - -### Layered Architecture +The SDK follows a layered architecture: - **API Layer**: Provides the main entry points through `QwenCodeCli` class with simple static methods for basic usage - **Session Layer**: Manages communication sessions with the Qwen Code CLI through the `Session` class @@ -103,169 +134,7 @@ The Qwen Code Java SDK follows a layered architecture that abstracts the communi - **Protocol Layer**: Defines data structures for communication based on the CLI protocol - **Utils**: Common utilities for concurrent execution, timeout handling, and error management -### Core Classes and Their Relationships - -- `QwenCodeCli`: The main entry point that provides static methods (`simpleQuery`) which internally create and manage `Session` instances -- `Session`: Manages the lifecycle of a communication session with the CLI, including initialization, prompt sending, and cleanup -- `Transport`: Abstracts the communication mechanism (currently implemented by `ProcessTransport`) -- `ProcessTransport`: Implementation that communicates with the CLI via process execution, using `TransportOptions` for configuration -- `TransportOptions`: Configuration class that defines how the transport layer should interact with the CLI (path to executable, working directory, model, permission mode, etc.) -- `SessionEventSimpleConsumers`: Event handler interface for processing responses from the CLI, allowing custom handling of assistant messages and other events -- `PermissionMode`: Enum that defines different permission modes for controlling tool execution (default, plan, auto-edit, yolo) - -The architecture allows for both simple usage through static methods in `QwenCodeCli` and more advanced usage through direct `Session` management with custom event handlers and transport options. - -## Usage - -### Session Event Consumers - -The SDK allows you to customize how events from the CLI are handled using event consumers. The `SessionEventConsumers` interface provides callbacks for different types of messages during a session: - -- `onSystemMessage`: Handles system messages from the CLI (receives Session and SDKSystemMessage) -- `onResultMessage`: Handles result messages from the CLI (receives Session and SDKResultMessage) -- `onAssistantMessage`: Handles assistant messages (AI responses) (receives Session and SDKAssistantMessage) -- `onPartialAssistantMessage`: Handles partial assistant messages during streaming (receives Session and SDKPartialAssistantMessage) -- `onUserMessage`: Handles user messages (receives Session and SDKUserMessage) -- `onOtherMessage`: Handles other types of messages (receives Session and String message) -- `onControlResponse`: Handles control responses (receives Session and CLIControlResponse) -- `onControlRequest`: Handles control requests (receives Session and CLIControlRequest, returns CLIControlResponse) -- `onPermissionRequest`: Handles permission requests (receives Session and CLIControlRequest, returns Behavior) -- `onAssistantMessageIncludePartial`: Handles assistant messages including partial content (specific to SessionEventSimpleConsumers, called by both onAssistantMessage and onPartialAssistantMessage) (receives Session, List, and AssistantMessageOutputType) - -Event processing is subject to the timeout settings configured in `TransportOptions` and `SessionEventConsumers`. For detailed timeout configuration options, see the "Timeout" section above. - -Example of custom event handling: - -```java -import com.alibaba.qwen.code.cli.QwenCodeCli; -import com.alibaba.qwen.code.cli.session.Session; -import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; -import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKAssistantMessage; -import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKPartialAssistantMessage; -import com.alibaba.qwen.code.cli.protocol.message.SDKResultMessage; -import com.alibaba.qwen.code.cli.protocol.message.SDKSystemMessage; -import com.alibaba.qwen.code.cli.protocol.message.SDKUserMessage; -import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlPermissionRequest; -import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlRequest; -import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlResponse; -import com.alibaba.qwen.code.cli.protocol.data.AssistantContent; -import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior; -import com.alibaba.qwen.code.cli.utils.Timeout; - -import java.util.List; -import java.util.concurrent.TimeUnit; - -public class CustomEventHandlingExample { - public static void main(String[] args) { - Session session = QwenCodeCli.newSession(); - SessionEventSimpleConsumers eventConsumers = new SessionEventSimpleConsumers() { - @Override - public void onAssistantMessage(Session session, SDKAssistantMessage assistantMessage) { - String message = assistantMessage.getMessage().getContent().stream() - .findFirst() - .map(content -> content.getText()) - .orElse(""); - System.out.println("Assistant: " + message); - } - - @Override - public void onPartialAssistantMessage(Session session, SDKPartialAssistantMessage partialAssistantMessage) { - System.out.println("Partial assistant message: " + partialAssistantMessage); - } - - public void onAssistantMessageIncludePartial(Session session, List assistantContents, - AssistantMessageOutputType assistantMessageOutputType) { - System.out.println("Assistant content (type: " + assistantMessageOutputType + "): " + assistantContents); - } - - @Override - public void onSystemMessage(Session session, SDKSystemMessage systemMessage) { - System.out.println("System: " + systemMessage.getMessage()); - } - - @Override - public void onResultMessage(Session session, SDKResultMessage resultMessage) { - System.out.println("Result: " + resultMessage.getMessage()); - } - - @Override - public void onUserMessage(Session session, SDKUserMessage userMessage) { - System.out.println("User: " + userMessage.getMessage()); - } - - @Override - public void onOtherMessage(Session session, String message) { - System.out.println("Other: " + message); - } - - @Override - public void onControlResponse(Session session, CLIControlResponse cliControlResponse) { - System.out.println("Control response: " + cliControlResponse); - } - - @Override - public CLIControlResponse onControlRequest(Session session, CLIControlRequest cliControlRequest) { - System.out.println("Control request: " + cliControlRequest); - return new CLIControlResponse<>(); // Return appropriate response - } - - @Override - public Behavior onPermissionRequest(Session session, CLIControlRequest permissionRequest) { - System.out.println("Permission request: " + permissionRequest.getRequest().getInput()); - return new com.alibaba.qwen.code.cli.protocol.data.behavior.Allow() - .setUpdatedInput(permissionRequest.getRequest().getInput()); // Allow by default - } - - @Override - public Timeout onAssistantMessageTimeout(Session session) { - return new Timeout(90L, TimeUnit.SECONDS); // Timeout for processing assistant messages - } - - @Override - public Timeout onSystemMessageTimeout(Session session) { - return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing system messages - } - - @Override - public Timeout onResultMessageTimeout(Session session) { - return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing result messages - } - - @Override - public Timeout onPartialAssistantMessageTimeout(Session session) { - return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing partial assistant messages - } - - @Override - public Timeout onUserMessageTimeout(Session session) { - return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing user messages - } - - @Override - public Timeout onOtherMessageTimeout(Session session) { - return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing other messages - } - - @Override - public Timeout onControlResponseTimeout(Session session) { - return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing control responses - } - - @Override - public Timeout onControlRequestTimeout(Session session) { - return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing control requests - } - - @Override - public Timeout onPermissionRequestTimeout(Session session) { - return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing permission requests - } - }.setDefaultEventTimeout(new Timeout(60L, TimeUnit.SECONDS)); // Default timeout for all events - - session.sendPrompt("Example prompt", eventConsumers); - } -} -``` +## Key Features ### Permission Modes @@ -276,25 +145,111 @@ The SDK supports different permission modes for controlling tool execution: - **`auto-edit`**: Auto-approve edit tools (edit, write_file) while other tools require confirmation. - **`yolo`**: All tools execute automatically without confirmation. -To set a permission mode: +### Session Event Consumers and Assistant Content Consumers -```java -import com.alibaba.qwen.code.cli.QwenCodeCli; -import com.alibaba.qwen.code.cli.session.Session; -import com.alibaba.qwen.code.cli.transport.TransportOptions; -import com.alibaba.qwen.code.cli.protocol.data.PermissionMode; +The SDK provides two key interfaces for handling events and content from the CLI: -public class PermissionModeExample { - public static void main(String[] args) { - Session session = QwenCodeCli.newSession(new TransportOptions().setPermissionMode(PermissionMode.YOLO)); - session.setPermissionMode(PermissionMode.PLAN); - } -} -``` +#### SessionEventConsumers Interface -### Session Control +The `SessionEventConsumers` interface provides callbacks for different types of messages during a session: -The SDK provides fine-grained control over session lifecycle and behavior: +- `onSystemMessage`: Handles system messages from the CLI (receives Session and SDKSystemMessage) +- `onResultMessage`: Handles result messages from the CLI (receives Session and SDKResultMessage) +- `onAssistantMessage`: Handles assistant messages (AI responses) (receives Session and SDKAssistantMessage) +- `onPartialAssistantMessage`: Handles partial assistant messages during streaming (receives Session and SDKPartialAssistantMessage) +- `onUserMessage`: Handles user messages (receives Session and SDKUserMessage) +- `onOtherMessage`: Handles other types of messages (receives Session and String message) +- `onControlResponse`: Handles control responses (receives Session and CLIControlResponse) +- `onControlRequest`: Handles control requests (receives Session and CLIControlRequest, returns CLIControlResponse) +- `onPermissionRequest`: Handles permission requests (receives Session and CLIControlRequest, returns Behavior) + +#### AssistantContentConsumers Interface + +The `AssistantContentConsumers` interface handles different types of content within assistant messages: + +- `onText`: Handles text content (receives Session and TextAssistantContent) +- `onThinking`: Handles thinking content (receives Session and ThingkingAssistantContent) +- `onToolUse`: Handles tool use content (receives Session and ToolUseAssistantContent) +- `onToolResult`: Handles tool result content (receives Session and ToolResultAssistantContent) +- `onOtherContent`: Handles other content types (receives Session and AssistantContent) +- `onUsage`: Handles usage information (receives Session and AssistantUsage) +- `onPermissionRequest`: Handles permission requests (receives Session and CLIControlPermissionRequest, returns Behavior) +- `onOtherControlRequest`: Handles other control requests (receives Session and ControlRequestPayload, returns ControlResponsePayload) + +#### Relationship Between the Interfaces + +**Important Note on Event Hierarchy:** + +- `SessionEventConsumers` is the **high-level** event processor that handles different message types (system, assistant, user, etc.) +- `AssistantContentConsumers` is the **low-level** content processor that handles different types of content within assistant messages (text, tools, thinking, etc.) + +**Processor Relationship:** + +- `SessionEventConsumers` → `AssistantContentConsumers` (SessionEventConsumers uses AssistantContentConsumers to process content within assistant messages) + +**Event Derivation Relationships:** + +- `onAssistantMessage` → `onText`, `onThinking`, `onToolUse`, `onToolResult`, `onOtherContent`, `onUsage` +- `onPartialAssistantMessage` → `onText`, `onThinking`, `onToolUse`, `onToolResult`, `onOtherContent` +- `onControlRequest` → `onPermissionRequest`, `onOtherControlRequest` + +**Event Timeout Relationships:** + +Each event handler method has a corresponding timeout method that allows customizing the timeout behavior for that specific event: + +- `onSystemMessage` ↔ `onSystemMessageTimeout` +- `onResultMessage` ↔ `onResultMessageTimeout` +- `onAssistantMessage` ↔ `onAssistantMessageTimeout` +- `onPartialAssistantMessage` ↔ `onPartialAssistantMessageTimeout` +- `onUserMessage` ↔ `onUserMessageTimeout` +- `onOtherMessage` ↔ `onOtherMessageTimeout` +- `onControlResponse` ↔ `onControlResponseTimeout` +- `onControlRequest` ↔ `onControlRequestTimeout` + +For AssistantContentConsumers timeout methods: + +- `onText` ↔ `onTextTimeout` +- `onThinking` ↔ `onThinkingTimeout` +- `onToolUse` ↔ `onToolUseTimeout` +- `onToolResult` ↔ `onToolResultTimeout` +- `onOtherContent` ↔ `onOtherContentTimeout` +- `onPermissionRequest` ↔ `onPermissionRequestTimeout` +- `onOtherControlRequest` ↔ `onOtherControlRequestTimeout` + +**Default Timeout Values:** + +- `SessionEventSimpleConsumers` default timeout: 180 seconds (Timeout.TIMEOUT_180_SECONDS) +- `AssistantContentSimpleConsumers` default timeout: 60 seconds (Timeout.TIMEOUT_60_SECONDS) + +**Timeout Hierarchy Requirements:** + +For proper operation, the following timeout relationships should be maintained: + +- `onAssistantMessageTimeout` return value should be greater than `onTextTimeout`, `onThinkingTimeout`, `onToolUseTimeout`, `onToolResultTimeout`, and `onOtherContentTimeout` return values +- `onControlRequestTimeout` return value should be greater than `onPermissionRequestTimeout` and `onOtherControlRequestTimeout` return values + +### Transport Options + +The `TransportOptions` class allows configuration of how the SDK communicates with the Qwen Code CLI: + +- `pathToQwenExecutable`: Path to the Qwen Code CLI executable +- `cwd`: Working directory for the CLI process +- `model`: AI model to use for the session +- `permissionMode`: Permission mode that controls tool execution +- `env`: Environment variables to pass to the CLI process +- `maxSessionTurns`: Limits the number of conversation turns in a session +- `coreTools`: List of core tools that should be available to the AI +- `excludeTools`: List of tools to exclude from being available to the AI +- `allowedTools`: List of tools that are pre-approved for use without additional confirmation +- `authType`: Authentication type to use for the session +- `includePartialMessages`: Enables receiving partial messages during streaming responses +- `skillsEnable`: Enables or disables skills functionality for the session +- `turnTimeout`: Timeout for a complete turn of conversation +- `messageTimeout`: Timeout for individual messages within a turn +- `resumeSessionId`: ID of a previous session to resume +- `otherOptions`: Additional command-line options to pass to the CLI + +### Session Control Features - **Session creation**: Use `QwenCodeCli.newSession()` to create a new session with custom options - **Session management**: The `Session` class provides methods to send prompts, handle responses, and manage session state @@ -304,415 +259,19 @@ The SDK provides fine-grained control over session lifecycle and behavior: - **Dynamic model switching**: Use `session.setModel()` to change the model during a session - **Dynamic permission mode switching**: Use `session.setPermissionMode()` to change the permission mode during a session -Example of session control: - -```java -import com.alibaba.qwen.code.cli.QwenCodeCli; -import com.alibaba.qwen.code.cli.session.Session; -import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; -import com.alibaba.qwen.code.cli.transport.TransportOptions; -import com.alibaba.qwen.code.cli.protocol.data.PermissionMode; -import java.util.List; - -public class SessionControlExample { - public static void main(String[] args) { - TransportOptions options = new TransportOptions() - .setModel("qwen-max") - .setPermissionMode(PermissionMode.AUTO_EDIT); - - try (Session session = QwenCodeCli.newSession(options)) { - // Use the session with default event consumers - List result = session.sendPrompt("Explain how to use the SDK", new SessionEventSimpleConsumers()); - result.forEach(System.out::println); - } // Session automatically closes when exiting try-with-resources - } -} -``` - -#### Interrupt Function - -The `interrupt()` function allows you to interrupt a currently running prompt. This is useful when you need to stop a long-running operation without closing the entire session: - -- **Method signature**: `public Optional interrupt() throws SessionControlException` -- **Purpose**: Interrupts the current prompt processing without closing the session -- **Return value**: An `Optional` that indicates whether the interrupt was successful (true if successful, empty if the interrupt was sent asynchronously) - -Example of interrupting a running prompt: - -```java -import com.alibaba.qwen.code.cli.QwenCodeCli; -import com.alibaba.qwen.code.cli.session.Session; -import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; -import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKAssistantMessage; -import com.alibaba.qwen.code.cli.session.exception.SessionControlException; -import java.util.Optional; - -public class InterruptExample { - public static void main(String[] args) { - try (Session session = QwenCodeCli.newSession()) { - session.sendPrompt("Analyze this large codebase...", new SessionEventSimpleConsumers() { - @Override - public void onAssistantMessage(Session session, SDKAssistantMessage assistantMessage) { - System.out.println("Received: " + assistantMessage.getMessage().getContent().stream() - .findFirst() - .map(content -> content.getText()) - .orElse("")); - - // Interrupt the session after receiving the first message - try { - Optional interruptResult = session.interrupt(); - System.out.println(interruptResult.map(s -> s ? "Interrupt successful" : "Interrupt error") - .orElse("Interrupt unknown")); - } catch (SessionControlException e) { - System.err.println("Interrupt error: " + e.getMessage()); - } - } - }); - } - } -} -``` - -#### Set Model Function - -The `setModel()` function allows you to dynamically change the AI model during an active session. This is useful when you want to switch between different models (e.g., from a faster model for simple queries to a more powerful model for complex analysis) without creating a new session: - -- **Method signature**: `public Optional setModel(String modelName) throws SessionControlException` -- **Purpose**: Changes the AI model being used for the current and subsequent prompts in the session -- **Parameters**: `modelName` - the name of the model to switch to (e.g., "qwen-max", "qwen-plus", etc.) -- **Return value**: An `Optional` that indicates whether the model change was successful (true if successful, empty if the request was sent asynchronously) - -Example of changing the model during a session: - -```java -import com.alibaba.qwen.code.cli.QwenCodeCli; -import com.alibaba.qwen.code.cli.session.Session; -import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; -import java.util.Optional; - -public class SetModelExample { - public static void main(String[] args) { - try (Session session = QwenCodeCli.newSession()) { - // Switch to a specific model - Optional modelChangeResult = session.setModel("qwen3-coder-flash"); - System.out.println(modelChangeResult.map(s -> s ? "setModel success" : "setModel error") - .orElse("setModel unknown")); - - // Use the model for a prompt - session.sendPrompt("hello world", new SessionEventSimpleConsumers()); - - // Switch to another model - Optional modelChangeResult2 = session.setModel("qwen3-coder-plus"); - System.out.println(modelChangeResult2.map(s -> s ? "setModel success" : "setModel error") - .orElse("setModel unknown")); - - // Use the new model for another prompt - session.sendPrompt("list files in the current directory", new SessionEventSimpleConsumers()); - } - } -} -``` - -#### Set Permission Mode Function - -The `setPermissionMode()` function allows you to dynamically change the permission mode during an active session. This is useful when you want to adjust the level of access granted to tools (e.g., switching from a restrictive mode to allow more operations) without creating a new session: - -- **Method signature**: `public Optional setPermissionMode(PermissionMode permissionMode) throws SessionControlException` -- **Purpose**: Changes the permission mode governing tool execution for the current and subsequent prompts in the session -- **Parameters**: `permissionMode` - the permission mode to switch to (e.g., `PermissionMode.DEFAULT`, `PermissionMode.PLAN`, `PermissionMode.AUTO_EDIT`, `PermissionMode.YOLO`) -- **Return value**: An `Optional` that indicates whether the permission mode change was successful (true if successful, empty if the request was sent asynchronously) - -Example of changing the permission mode during a session: - -```java -import com.alibaba.qwen.code.cli.QwenCodeCli; -import com.alibaba.qwen.code.cli.session.Session; -import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; -import com.alibaba.qwen.code.cli.protocol.data.PermissionMode; -import java.util.Optional; - -public class SetPermissionModeExample { - public static void main(String[] args) { - try (Session session = QwenCodeCli.newSession()) { - // Switch to a permissive mode - Optional permissionChangeResult = session.setPermissionMode(PermissionMode.YOLO); - System.out.println(permissionChangeResult.map(s -> s ? "setPermissionMode success" : "setPermissionMode error") - .orElse("setPermissionMode unknown")); - - // Use the session with the new permission mode - session.sendPrompt("in the dir src/test/temp/, create file empty file test.touch", new SessionEventSimpleConsumers()); - - // Switch to another permission mode - Optional permissionChangeResult2 = session.setPermissionMode(PermissionMode.PLAN); - System.out.println(permissionChangeResult2.map(s -> s ? "setPermissionMode success" : "setPermissionMode error") - .orElse("setPermissionMode unknown")); - - // Use the session with the new permission mode - session.sendPrompt("rename test.touch to test_rename.touch", new SessionEventSimpleConsumers()); - } - } -} -``` - -### Timeout Configuration - -The timeout configuration allows you to control how long the SDK waits for responses from the CLI before timing out. There are two levels of timeout configuration: - -- **Transport-level timeouts**: Configured via `TransportOptions` - - `turnTimeout`: Time to wait for a complete turn of conversation (default: 60 seconds) - - `messageTimeout`: Time to wait for individual messages within a turn (default: 60 seconds) - -- **Event-level timeouts**: Configured via `SessionEventConsumers` interface with callback methods for specific message types: - - `onSystemMessageTimeout`: Timeout for processing system messages - - `onResultMessageTimeout`: Timeout for processing result messages - - `onAssistantMessageTimeout`: Timeout for processing assistant messages - - `onPartialAssistantMessageTimeout`: Timeout for processing partial assistant messages - - `onUserMessageTimeout`: Timeout for processing user messages - - `onOtherMessageTimeout`: Timeout for processing other types of messages - - `onControlResponseTimeout`: Timeout for processing control responses - - `onControlRequestTimeout`: Timeout for processing control requests - - `onPermissionRequestTimeout`: Timeout for processing permission requests - -To customize timeout settings: - -```java -import com.alibaba.qwen.code.cli.QwenCodeCli; -import com.alibaba.qwen.code.cli.session.Session; -import com.alibaba.qwen.code.cli.session.event.SessionEventConsumers; -import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; -import com.alibaba.qwen.code.cli.transport.TransportOptions; -import com.alibaba.qwen.code.cli.utils.Timeout; -import java.util.List; -import java.util.concurrent.TimeUnit; - -public class TimeoutConfigurationExample { - public static void main(String[] args) { - // Configure transport-level timeouts - TransportOptions options = new TransportOptions() - .setTurnTimeout(new Timeout(120L, TimeUnit.SECONDS)) // Timeout for a complete turn of conversation - .setMessageTimeout(new Timeout(90L, TimeUnit.SECONDS)); // Timeout for individual messages within a turn - - Session session = QwenCodeCli.newSession(options); - - // Configure event-level timeouts using SessionEventConsumers - SessionEventConsumers eventConsumers = new SessionEventSimpleConsumers() { - @Override - public Timeout onSystemMessageTimeout(Session session) { - return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing system messages - } - - @Override - public Timeout onResultMessageTimeout(Session session) { - return new Timeout(60L, TimeUnit.SECONDS); // Timeout for processing result messages - } - - @Override - public Timeout onAssistantMessageTimeout(Session session) { - return new Timeout(90L, TimeUnit.SECONDS); // Timeout for processing assistant messages - } - - @Override - public Timeout onControlResponseTimeout(Session session) { - return new Timeout(45L, TimeUnit.SECONDS); // Timeout for processing control responses - } - - @Override - public Timeout onPermissionRequestTimeout(Session session) { - return new Timeout(30L, TimeUnit.SECONDS); // Timeout for processing permission requests - } - - @Override - public Timeout onOtherMessageTimeout(Session session) { - return new Timeout(35L, TimeUnit.SECONDS); // Timeout for processing other messages - } - }.setDefaultEventTimeout(new Timeout(90L, TimeUnit.SECONDS)); // Default timeout for all events - session.sendPrompt("hello world", eventConsumers); - } -} -``` - ### Thread Pool Configuration -The SDK uses a thread pool for managing concurrent operations. The default thread pool configuration is defined in the `ThreadPoolConfig` class: +The SDK uses a thread pool for managing concurrent operations with the following default configuration: -- **Core Pool Size**: 10 threads -- **Maximum Pool Size**: 30 threads +- **Core Pool Size**: 30 threads +- **Maximum Pool Size**: 100 threads - **Keep-Alive Time**: 60 seconds - **Queue Capacity**: 300 tasks (using LinkedBlockingQueue) - **Thread Naming**: "qwen_code_cli-pool-{number}" - **Daemon Threads**: false -- **Rejected Execution Handler**: CallerRunsPolicy (executes the task on the calling thread when the pool is full) +- **Rejected Execution Handler**: CallerRunsPolicy -The thread pool can be customized in two ways: - -1. **Using a custom supplier**: Provide a custom `Supplier` through the `ThreadPoolConfig.setExecutorSupplier()` method. If no custom supplier is provided, or if the supplier throws an exception, the SDK will fall back to the default thread pool configuration. - -2. **Modifying properties after getting the default executor**: You can retrieve the default executor using `ThreadPoolConfig.getDefaultExecutor()` and then modify its properties such as core pool size, maximum pool size, and keep-alive time. - -Example of custom thread pool configuration using a supplier: - -```java -import com.alibaba.qwen.code.cli.QwenCodeCli; -import com.alibaba.qwen.code.cli.session.Session; -import com.alibaba.qwen.code.cli.utils.ThreadPoolConfig; -import java.util.concurrent.Executors; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.function.Supplier; - -public class ThreadPoolConfigurationExample { - public static void main(String[] args) { - // Set a custom thread pool supplier - ThreadPoolConfig.setExecutorSupplier(new Supplier() { - @Override - public ThreadPoolExecutor get() { - return (ThreadPoolExecutor) Executors.newFixedThreadPool(20); - } - }); - - // The SDK will now use the custom thread pool for all operations - Session session = QwenCodeCli.newSession(); - } -} -``` - -Example of modifying properties after getting the default executor: - -```java -import com.alibaba.qwen.code.cli.QwenCodeCli; -import com.alibaba.qwen.code.cli.session.Session; -import com.alibaba.qwen.code.cli.utils.ThreadPoolConfig; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeUnit; - -public class ModifyThreadPoolExample { - public static void main(String[] args) { - // Get the default executor and modify its properties - ThreadPoolExecutor executor = ThreadPoolConfig.getDefaultExecutor(); - - // Modify the core pool size - executor.setCorePoolSize(15); - - // Modify the maximum pool size - executor.setMaximumPoolSize(40); - - // Modify the keep-alive time - executor.setKeepAliveTime(120, TimeUnit.SECONDS); - - // The SDK will now use the modified executor for all operations - Session session = QwenCodeCli.newSession(); - } -} -``` - -Note that when modifying the default executor directly, you're changing the properties of the shared static instance that will affect all subsequent operations in the application. If you need different configurations for different parts of your application, using the supplier approach is recommended. - -### Transport Options - -The `TransportOptions` class allows you to configure how the SDK communicates with the Qwen Code CLI. Below are all the available options with their descriptions: - -- **`pathToQwenExecutable`**: Specifies the path to the Qwen Code CLI executable. By default, the SDK looks for a `qwen` command in the system PATH. - - Type: `String` - - Example: `new TransportOptions().setPathToQwenExecutable("/usr/local/bin/qwen")` - -- **`cwd`**: Sets the working directory for the CLI process. This affects where the CLI operates and where relative paths are resolved from. - - Type: `String` - - Example: `new TransportOptions().setCwd("/path/to/project")` - -- **`model`**: Specifies the AI model to use for the session (e.g., "qwen-max", "qwen-plus", "qwen3-coder-flash", etc.). - - Type: `String` - - Example: `new TransportOptions().setModel("qwen3-coder-flash")` - -- **`permissionMode`**: Sets the permission mode that controls tool execution. Available modes are: - - `PermissionMode.DEFAULT`: Write tools are denied unless approved via `canUseTool` callback or in `allowedTools`. Read-only tools execute without confirmation. - - `PermissionMode.PLAN`: Blocks all write tools, instructing AI to present a plan first. - - `PermissionMode.AUTO_EDIT`: Auto-approve edit tools (edit, write_file) while other tools require confirmation. - - `PermissionMode.YOLO`: All tools execute automatically without confirmation. - - Type: `PermissionMode` - - Example: `new TransportOptions().setPermissionMode(PermissionMode.YOLO)` - -- **`env`**: A map of environment variables to pass to the CLI process. - - Type: `Map` - - Example: `new TransportOptions().setEnv(Map.of("ENV_VAR", "value"))` - -- **`maxSessionTurns`**: Limits the number of conversation turns in a session. - - Type: `Integer` - - Example: `new TransportOptions().setMaxSessionTurns(10)` - -- **`coreTools`**: Specifies a list of core tools that should be available to the AI. - - Type: `List` - - Example: `new TransportOptions().setCoreTools(List.of("read_file", "write_file"))` - -- **`excludeTools`**: Specifies a list of tools to exclude from being available to the AI. - - Type: `List` - - Example: `new TransportOptions().setExcludeTools(List.of("shell"))` - -- **`allowedTools`**: Specifies a list of tools that are pre-approved for use without additional confirmation. - - Type: `List` - - Example: `new TransportOptions().setAllowedTools(List.of("read_file", "list_directory"))` - -- **`authType`**: Specifies the authentication type to use for the session. - - Type: `String` - - Example: `new TransportOptions().setAuthType("bearer")` - -- **`includePartialMessages`**: When true, enables receiving partial messages during streaming responses. - - Type: `Boolean` - - Example: `new TransportOptions().setIncludePartialMessages(true)` - -- **`skillsEnable`**: Enables or disables skills functionality for the session. - - Type: `Boolean` - - Example: `new TransportOptions().setSkillsEnable(true)` - -- **`turnTimeout`**: Sets the timeout for a complete turn of conversation (default: 60 seconds). - - Type: `Timeout` - - Example: `new TransportOptions().setTurnTimeout(new Timeout(120L, TimeUnit.SECONDS))` - -- **`messageTimeout`**: Sets the timeout for individual messages within a turn (default: 60 seconds). - - Type: `Timeout` - - Example: `new TransportOptions().setMessageTimeout(new Timeout(90L, TimeUnit.SECONDS))` - -- **`resumeSessionId`**: Specifies the ID of a previous session to resume. - - Type: `String` - - Example: `new TransportOptions().setResumeSessionId("session-12345")` - -- **`otherOptions`**: Allows passing additional command-line options directly to the CLI. - - Type: `List` - - Example: `new TransportOptions().setOtherOptions(List.of("--verbose", "--no-cache"))` - -Example of using TransportOptions: - -```java -import com.alibaba.qwen.code.cli.QwenCodeCli; -import com.alibaba.qwen.code.cli.session.Session; -import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; -import com.alibaba.qwen.code.cli.transport.TransportOptions; -import com.alibaba.qwen.code.cli.protocol.data.PermissionMode; -import com.alibaba.qwen.code.cli.utils.Timeout; -import java.util.List; -import java.util.Map; -import java.util.concurrent.TimeUnit; - -public class TransportOptionsExample { - public static void main(String[] args) { - TransportOptions options = new TransportOptions() - .setModel("qwen3-coder-flash") - .setPermissionMode(PermissionMode.AUTO_EDIT) - .setCwd("/path/to/working/directory") - .setEnv(Map.of("CUSTOM_VAR", "value")) - .setIncludePartialMessages(true) - .setTurnTimeout(new Timeout(120L, TimeUnit.SECONDS)) - .setMessageTimeout(new Timeout(90L, TimeUnit.SECONDS)) - .setAllowedTools(List.of("read_file", "write_file", "list_directory")); - - try (Session session = QwenCodeCli.newSession(options)) { - // Use the session with custom options - List result = session.sendPrompt("Analyze the current project", new SessionEventSimpleConsumers()); - result.forEach(System.out::println); - } - } -} -``` - -### Error Handling +## Error Handling The SDK provides specific exception types for different error scenarios: @@ -720,37 +279,6 @@ The SDK provides specific exception types for different error scenarios: - `SessionSendPromptException`: Thrown when there's an issue sending a prompt or receiving a response - `SessionClosedException`: Thrown when attempting to use a closed session -Example of comprehensive error handling: - -```java -import com.alibaba.qwen.code.cli.QwenCodeCli; -import com.alibaba.qwen.code.cli.session.Session; -import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; -import com.alibaba.qwen.code.cli.session.exception.SessionControlException; -import com.alibaba.qwen.code.cli.session.exception.SessionSendPromptException; -import java.util.List; - -public class ErrorHandlingExample { - public static void main(String[] args) { - try (Session session = QwenCodeCli.newSession()) { - try { - List result = session.sendPrompt("Process this request", new SessionEventSimpleConsumers()); - result.forEach(System.out::println); - } catch (SessionSendPromptException e) { - System.err.println("Error sending prompt: " + e.getMessage()); - e.printStackTrace(); - } - } catch (SessionControlException e) { - System.err.println("Error controlling session: " + e.getMessage()); - e.printStackTrace(); - } catch (Exception e) { - System.err.println("Unexpected error: " + e.getMessage()); - e.printStackTrace(); - } - } -} -``` - ## FAQ / Troubleshooting ### Q: Do I need to install the Qwen CLI separately? @@ -777,10 +305,6 @@ A: Use the `setResumeSessionId()` method in `TransportOptions` to resume a previ A: Yes, use the `setEnv()` method in `TransportOptions` to pass environment variables to the CLI process. -### Q: What happens if the CLI process crashes? - -A: The SDK will throw appropriate exceptions. Make sure to handle `SessionControlException` and implement retry logic if needed. - ## License Apache-2.0 - see [LICENSE](./LICENSE) for details. diff --git a/packages/sdk-java/pom.xml b/packages/sdk-java/pom.xml index 8786a78cc..c11385395 100644 --- a/packages/sdk-java/pom.xml +++ b/packages/sdk-java/pom.xml @@ -29,6 +29,11 @@ 5.14.1 1.3.16 2.0.60 + 3.13.0 + 9 + 2 + 2.9.1 + 1.5 @@ -67,34 +72,6 @@ - - org.apache.maven.plugins - maven-compiler-plugin - 3.13.0 - - 1.8 - 1.8 - - - - compile-examples - compile - - compile - - - 1.8 - 1.8 - - com/alibaba/qwen/code/example/**/*.java - - - ${project.basedir}/src/example/java - - - - - org.apache.maven.plugins maven-checkstyle-plugin @@ -132,7 +109,7 @@ org.sonatype.central central-publishing-maven-plugin - 0.9.0 + 0.${central-publishing-maven-plugin.version}.0 true central @@ -142,7 +119,7 @@ org.apache.maven.plugins maven-source-plugin - 2.2.1 + ${maven-source-plugin.version}.2.1 attach-sources @@ -155,7 +132,7 @@ org.apache.maven.plugins maven-javadoc-plugin - 2.9.1 + ${maven-javadoc-plugin.version} attach-javadocs @@ -168,7 +145,7 @@ org.apache.maven.plugins maven-gpg-plugin - 1.5 + ${maven-gpg-plugin.version} sign-artifacts diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCodeCli.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCodeCli.java index 6571f5de3..9a654034d 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCodeCli.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/QwenCodeCli.java @@ -12,8 +12,9 @@ import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolResultAssist import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolUseAssistantContent; import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior.Operation; import com.alibaba.qwen.code.cli.session.Session; -import com.alibaba.qwen.code.cli.session.event.AssistantContentConsumers; -import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; +import com.alibaba.qwen.code.cli.session.event.consumers.AssistantContentConsumers; +import com.alibaba.qwen.code.cli.session.event.consumers.AssistantContentSimpleConsumers; +import com.alibaba.qwen.code.cli.session.event.consumers.SessionEventSimpleConsumers; import com.alibaba.qwen.code.cli.transport.Transport; import com.alibaba.qwen.code.cli.transport.TransportOptions; import com.alibaba.qwen.code.cli.transport.process.ProcessTransport; @@ -51,7 +52,7 @@ public class QwenCodeCli { */ public static List simpleQuery(String prompt, TransportOptions transportOptions) { final List response = new ArrayList<>(); - MyConcurrentUtils.runAndWait(() -> simpleQuery(prompt, transportOptions, new AssistantContentConsumers() { + MyConcurrentUtils.runAndWait(() -> simpleQuery(prompt, transportOptions, new AssistantContentSimpleConsumers() { @Override public void onText(Session session, TextAssistantContent textAssistantContent) { response.add(textAssistantContent.getText()); @@ -80,7 +81,7 @@ public class QwenCodeCli { public void onUsage(Session session, AssistantUsage assistantUsage) { log.info("received usage {} of message {}", assistantUsage.getUsage(), assistantUsage.getMessageId()); } - }), Timeout.TIMEOUT_30_MINUTES); + }.setDefaultPermissionOperation(Operation.allow)), Timeout.TIMEOUT_30_MINUTES); return response; } @@ -95,8 +96,7 @@ public class QwenCodeCli { Session session = newSession(transportOptions); try { session.sendPrompt(prompt, new SessionEventSimpleConsumers() - .setDefaultPermissionOperation(Operation.allow) - .setBlockConsumer(assistantContentConsumers)); + .setAssistantContentConsumer(assistantContentConsumers)); } catch (Exception e) { throw new RuntimeException("sendPrompt error!", e); } finally { diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Behavior.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Behavior.java index 20893e044..5adca830e 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Behavior.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/data/behavior/Behavior.java @@ -53,6 +53,10 @@ public class Behavior { * @return The default behavior */ public static Behavior defaultBehavior() { + return denyBehavior(); + } + + public static Behavior denyBehavior() { return new Deny().setMessage("Default Behavior Permission denied"); } } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInterruptRequest.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInterruptRequest.java deleted file mode 100644 index fb72114bc..000000000 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInterruptRequest.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.alibaba.qwen.code.cli.protocol.message.control; - -/** - * Represents a control interrupt request to the CLI. - * - * @author skyfire - * @version $Id: 0.0.1 - */ -public class CLIControlInterruptRequest { - /** - * The subtype of the request ("interrupt"). - */ - String subtype = "interrupt"; - - /** - * Gets the subtype of the request. - * - * @return The subtype of the request - */ - public String getSubtype() { - return subtype; - } - - /** - * Sets the subtype of the request. - * - * @param subtype The subtype of the request - */ - public void setSubtype(String subtype) { - this.subtype = subtype; - } -} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlRequest.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlRequest.java index e888984be..58079bc6b 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlRequest.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlRequest.java @@ -5,6 +5,7 @@ import java.util.UUID; import com.alibaba.fastjson2.annotation.JSONField; import com.alibaba.fastjson2.annotation.JSONType; import com.alibaba.qwen.code.cli.protocol.message.MessageBase; +import com.alibaba.qwen.code.cli.protocol.message.control.payload.ControlRequestPayload; /** * Represents a control request to the CLI. @@ -14,7 +15,7 @@ import com.alibaba.qwen.code.cli.protocol.message.MessageBase; * @version $Id: 0.0.1 */ @JSONType(typeKey = "type", typeName = "control_request") -public class CLIControlRequest extends MessageBase { +public class CLIControlRequest extends MessageBase { /** * The ID of the request. */ @@ -41,7 +42,7 @@ public class CLIControlRequest extends MessageBase { * @param The type of the request object * @return A new control request instance */ - public static CLIControlRequest create(T request) { + public static CLIControlRequest create(T request) { CLIControlRequest controlRequest = new CLIControlRequest<>(); controlRequest.setRequest(request); return controlRequest; diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetModelRequest.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetModelRequest.java deleted file mode 100644 index 2f2d4a751..000000000 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetModelRequest.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.alibaba.qwen.code.cli.protocol.message.control; - -/** - * Represents a control request to set the model in the CLI. - * - * @author skyfire - * @version $Id: 0.0.1 - */ -public class CLIControlSetModelRequest { - /** - * The subtype of the request ("set_model"). - */ - String subtype = "set_model"; - /** - * The model to set. - */ - String model; - - /** - * Gets the subtype of the request. - * - * @return The subtype of the request - */ - public String getSubtype() { - return subtype; - } - - /** - * Sets the subtype of the request. - * - * @param subtype The subtype of the request - */ - public void setSubtype(String subtype) { - this.subtype = subtype; - } - - /** - * Gets the model to set. - * - * @return The model to set - */ - public String getModel() { - return model; - } - - /** - * Sets the model to set. - * - * @param model The model to set - */ - public void setModel(String model) { - this.model = model; - } -} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetPermissionModeRequest.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetPermissionModeRequest.java deleted file mode 100644 index 7fb26851f..000000000 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetPermissionModeRequest.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.alibaba.qwen.code.cli.protocol.message.control; - -/** - * Represents a control request to set the permission mode in the CLI. - * - * @author skyfire - * @version $Id: 0.0.1 - */ -public class CLIControlSetPermissionModeRequest { - /** - * The subtype of the request ("set_permission_mode"). - */ - String subtype = "set_permission_mode"; - - /** - * The permission mode to set. - */ - String mode; - - /** - * Gets the subtype of the request. - * - * @return The subtype of the request - */ - public String getSubtype() { - return subtype; - } - - /** - * Sets the subtype of the request. - * - * @param subtype The subtype of the request - */ - public void setSubtype(String subtype) { - this.subtype = subtype; - } - - /** - * Gets the permission mode to set. - * - * @return The permission mode to set - */ - public String getMode() { - return mode; - } - - /** - * Sets the permission mode to set. - * - * @param mode The permission mode to set - */ - public void setMode(String mode) { - this.mode = mode; - } -} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInitializeRequest.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlInitializeRequest.java similarity index 63% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInitializeRequest.java rename to packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlInitializeRequest.java index 64b5ffc78..a990e0316 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInitializeRequest.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlInitializeRequest.java @@ -1,6 +1,7 @@ -package com.alibaba.qwen.code.cli.protocol.message.control; +package com.alibaba.qwen.code.cli.protocol.message.control.payload; import com.alibaba.fastjson2.annotation.JSONField; +import com.alibaba.fastjson2.annotation.JSONType; import com.alibaba.qwen.code.cli.protocol.data.InitializeConfig; /** @@ -9,11 +10,12 @@ import com.alibaba.qwen.code.cli.protocol.data.InitializeConfig; * @author skyfire * @version $Id: 0.0.1 */ -public class CLIControlInitializeRequest { - /** - * The subtype of the request. - */ - String subtype = "initialize"; +@JSONType(typeKey = "subtype", typeName = "initialize") +public class CLIControlInitializeRequest extends ControlRequestPayload { + public CLIControlInitializeRequest() { + super(); + this.subtype = "initialize"; + } /** * The initialization configuration. @@ -21,24 +23,6 @@ public class CLIControlInitializeRequest { @JSONField(unwrapped = true) InitializeConfig initializeConfig = new InitializeConfig(); - /** - * Gets the subtype of the request. - * - * @return The subtype of the request - */ - public String getSubtype() { - return subtype; - } - - /** - * Sets the subtype of the request. - * - * @param subtype The subtype of the request - */ - public void setSubtype(String subtype) { - this.subtype = subtype; - } - /** * Gets the initialization configuration. * diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInitializeResponse.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlInitializeResponse.java similarity index 55% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInitializeResponse.java rename to packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlInitializeResponse.java index 2216de169..aabeec016 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlInitializeResponse.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlInitializeResponse.java @@ -1,5 +1,6 @@ -package com.alibaba.qwen.code.cli.protocol.message.control; +package com.alibaba.qwen.code.cli.protocol.message.control.payload; +import com.alibaba.fastjson2.annotation.JSONType; import com.alibaba.qwen.code.cli.protocol.data.Capabilities; /** @@ -8,34 +9,18 @@ import com.alibaba.qwen.code.cli.protocol.data.Capabilities; * @author skyfire * @version $Id: 0.0.1 */ -public class CLIControlInitializeResponse { - /** - * The subtype of the response. - */ - String subtype = "initialize"; +@JSONType(typeKey = "subtype", typeName = "initialize") +public class CLIControlInitializeResponse extends ControlResponsePayload { + public CLIControlInitializeResponse() { + super(); + this.subtype = "initialize"; + } + /** * The capabilities' information. */ Capabilities capabilities; - /** - * Gets the subtype of the response. - * - * @return The subtype of the response - */ - public String getSubtype() { - return subtype; - } - - /** - * Sets the subtype of the response. - * - * @param subtype The subtype of the response - */ - public void setSubtype(String subtype) { - this.subtype = subtype; - } - /** * Gets the capabilities information. * diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlInterruptRequest.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlInterruptRequest.java new file mode 100644 index 000000000..cf3f83567 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlInterruptRequest.java @@ -0,0 +1,17 @@ +package com.alibaba.qwen.code.cli.protocol.message.control.payload; + +import com.alibaba.fastjson2.annotation.JSONType; + +/** + * Represents a control interrupt request to the CLI. + * + * @author skyfire + * @version $Id: 0.0.1 + */ +@JSONType(typeKey = "subtype", typeName = "interrupt") +public class CLIControlInterruptRequest extends ControlRequestPayload { + public CLIControlInterruptRequest() { + super(); + setSubtype("interrupt"); + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlPermissionRequest.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlPermissionRequest.java similarity index 90% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlPermissionRequest.java rename to packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlPermissionRequest.java index 2d488c96c..e15133dfb 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlPermissionRequest.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlPermissionRequest.java @@ -1,9 +1,10 @@ -package com.alibaba.qwen.code.cli.protocol.message.control; +package com.alibaba.qwen.code.cli.protocol.message.control.payload; import java.util.List; import java.util.Map; import com.alibaba.fastjson2.annotation.JSONField; +import com.alibaba.fastjson2.annotation.JSONType; /** * Represents a control permission request to the CLI. @@ -11,11 +12,12 @@ import com.alibaba.fastjson2.annotation.JSONField; * @author skyfire * @version $Id: 0.0.1 */ -public class CLIControlPermissionRequest { - /** - * The subtype of the request. - */ - private String subtype; +@JSONType(typeKey = "subtype", typeName = "can_use_tool") +public class CLIControlPermissionRequest extends ControlRequestPayload { + public CLIControlPermissionRequest() { + super(); + this.subtype = "can_use_tool"; + } /** * The name of the tool requesting permission. @@ -46,24 +48,6 @@ public class CLIControlPermissionRequest { @JSONField(name = "blocked_path") private String blockedPath; - /** - * Gets the subtype of the request. - * - * @return The subtype of the request - */ - public String getSubtype() { - return subtype; - } - - /** - * Sets the subtype of the request. - * - * @param subtype The subtype of the request - */ - public void setSubtype(String subtype) { - this.subtype = subtype; - } - /** * Gets the name of the tool requesting permission. * diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlPermissionResponse.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlPermissionResponse.java similarity index 60% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlPermissionResponse.java rename to packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlPermissionResponse.java index c5b23fe24..771f0d581 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlPermissionResponse.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlPermissionResponse.java @@ -1,6 +1,7 @@ -package com.alibaba.qwen.code.cli.protocol.message.control; +package com.alibaba.qwen.code.cli.protocol.message.control.payload; import com.alibaba.fastjson2.annotation.JSONField; +import com.alibaba.fastjson2.annotation.JSONType; import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior; /** @@ -9,11 +10,12 @@ import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior; * @author skyfire * @version $Id: 0.0.1 */ -public class CLIControlPermissionResponse { - /** - * The subtype of the response ("can_use_tool"). - */ - private String subtype = "can_use_tool"; +@JSONType(typeKey = "subtype", typeName = "can_use_tool") +public class CLIControlPermissionResponse extends ControlResponsePayload { + public CLIControlPermissionResponse() { + super(); + this.subtype = "can_use_tool"; + } /** * The behavior for the permission request. @@ -21,24 +23,6 @@ public class CLIControlPermissionResponse { @JSONField(unwrapped = true) Behavior behavior; - /** - * Gets the subtype of the response. - * - * @return The subtype of the response - */ - public String getSubtype() { - return subtype; - } - - /** - * Sets the subtype of the response. - * - * @param subtype The subtype of the response - */ - public void setSubtype(String subtype) { - this.subtype = subtype; - } - /** * Gets the behavior for the permission request. * diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlSetModelRequest.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlSetModelRequest.java new file mode 100644 index 000000000..e12b704a3 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlSetModelRequest.java @@ -0,0 +1,40 @@ +package com.alibaba.qwen.code.cli.protocol.message.control.payload; + +import com.alibaba.fastjson2.annotation.JSONType; + +/** + * Represents a control request to set the model in the CLI. + * + * @author skyfire + * @version $Id: 0.0.1 + */ +@JSONType(typeKey = "subtype", typeName = "set_model") +public class CLIControlSetModelRequest extends ControlRequestPayload { + public CLIControlSetModelRequest() { + super(); + this.subtype = "set_model"; + } + + /** + * The model to set. + */ + String model; + + /** + * Gets the model to set. + * + * @return The model to set + */ + public String getModel() { + return model; + } + + /** + * Sets the model to set. + * + * @param model The model to set + */ + public void setModel(String model) { + this.model = model; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetModelResponse.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlSetModelResponse.java similarity index 93% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetModelResponse.java rename to packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlSetModelResponse.java index 6c3dc2c2c..b59552e0c 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/CLIControlSetModelResponse.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlSetModelResponse.java @@ -1,4 +1,4 @@ -package com.alibaba.qwen.code.cli.protocol.message.control; +package com.alibaba.qwen.code.cli.protocol.message.control.payload; /** * Represents a control response for setting the model in the CLI. diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlSetPermissionModeRequest.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlSetPermissionModeRequest.java new file mode 100644 index 000000000..3e5ef9dd1 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/CLIControlSetPermissionModeRequest.java @@ -0,0 +1,40 @@ +package com.alibaba.qwen.code.cli.protocol.message.control.payload; + +import com.alibaba.fastjson2.annotation.JSONType; + +/** + * Represents a control request to set the permission mode in the CLI. + * + * @author skyfire + * @version $Id: 0.0.1 + */ +@JSONType(typeKey = "subtype", typeName = "set_permission_mode") +public class CLIControlSetPermissionModeRequest extends ControlRequestPayload { + public CLIControlSetPermissionModeRequest() { + super(); + setSubtype("set_permission_mode"); + } + + /** + * The permission mode to set. + */ + String mode; + + /** + * Gets the permission mode to set. + * + * @return The permission mode to set + */ + public String getMode() { + return mode; + } + + /** + * Sets the permission mode to set. + * + * @param mode The permission mode to set + */ + public void setMode(String mode) { + this.mode = mode; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/ControlRequestPayload.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/ControlRequestPayload.java new file mode 100644 index 000000000..1390850e7 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/ControlRequestPayload.java @@ -0,0 +1,26 @@ +package com.alibaba.qwen.code.cli.protocol.message.control.payload; + +import com.alibaba.fastjson2.annotation.JSONType; + +/** + * Represents a payload request in the CLI control message. + * + * @author skyfire + * @version $Id: 0.0.1 + */ +@JSONType(typeKey = "subtype", typeName = "ControlRequestPayload", + seeAlso = {CLIControlInitializeRequest.class, CLIControlInterruptRequest.class, CLIControlPermissionRequest.class, CLIControlSetModelRequest.class, CLIControlSetPermissionModeRequest.class}) +public class ControlRequestPayload { + /** + * The subtype of the request. + */ + protected String subtype; + + public String getSubtype() { + return subtype; + } + + public void setSubtype(String subtype) { + this.subtype = subtype; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/ControlResponsePayload.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/ControlResponsePayload.java new file mode 100644 index 000000000..fe8cdd8ae --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/protocol/message/control/payload/ControlResponsePayload.java @@ -0,0 +1,26 @@ +package com.alibaba.qwen.code.cli.protocol.message.control.payload; + +import com.alibaba.fastjson2.annotation.JSONType; + +/** + * Represents a payload request in the CLI control message. + * + * @author skyfire + * @version $Id: 0.0.1 + */ +@JSONType(typeKey = "subtype", typeName = "ControlResponsePayload", + seeAlso = {CLIControlInitializeResponse.class, CLIControlPermissionResponse.class}) +public class ControlResponsePayload { + /** + * The subtype of the request. + */ + protected String subtype; + + public String getSubtype() { + return subtype; + } + + public void setSubtype(String subtype) { + this.subtype = subtype; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/Session.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/Session.java index 95a6cb6ba..e72ab6a04 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/Session.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/Session.java @@ -1,9 +1,6 @@ package com.alibaba.qwen.code.cli.session; -import java.io.IOException; import java.util.Optional; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeoutException; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; @@ -11,23 +8,21 @@ import com.alibaba.fastjson2.JSONReader.Feature; import com.alibaba.fastjson2.TypeReference; import com.alibaba.qwen.code.cli.protocol.data.Capabilities; import com.alibaba.qwen.code.cli.protocol.data.PermissionMode; -import com.alibaba.qwen.code.cli.protocol.data.behavior.Allow; -import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior; import com.alibaba.qwen.code.cli.protocol.message.SDKResultMessage; import com.alibaba.qwen.code.cli.protocol.message.SDKSystemMessage; import com.alibaba.qwen.code.cli.protocol.message.SDKUserMessage; import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKAssistantMessage; import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKPartialAssistantMessage; -import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlInitializeRequest; -import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlInitializeResponse; -import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlInterruptRequest; -import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlPermissionRequest; -import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlPermissionResponse; +import com.alibaba.qwen.code.cli.protocol.message.control.payload.CLIControlInitializeRequest; +import com.alibaba.qwen.code.cli.protocol.message.control.payload.CLIControlInitializeResponse; +import com.alibaba.qwen.code.cli.protocol.message.control.payload.CLIControlInterruptRequest; import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlRequest; import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlResponse; -import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlSetModelRequest; -import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlSetPermissionModeRequest; -import com.alibaba.qwen.code.cli.session.event.SessionEventConsumers; +import com.alibaba.qwen.code.cli.protocol.message.control.payload.CLIControlSetModelRequest; +import com.alibaba.qwen.code.cli.protocol.message.control.payload.CLIControlSetPermissionModeRequest; +import com.alibaba.qwen.code.cli.protocol.message.control.payload.ControlRequestPayload; +import com.alibaba.qwen.code.cli.protocol.message.control.payload.ControlResponsePayload; +import com.alibaba.qwen.code.cli.session.event.consumers.SessionEventConsumers; import com.alibaba.qwen.code.cli.session.exception.SessionControlException; import com.alibaba.qwen.code.cli.session.exception.SessionSendPromptException; import com.alibaba.qwen.code.cli.transport.Transport; @@ -204,29 +199,37 @@ public class Session { if ("system".equals(messageType)) { lastSdkSystemMessage = jsonObject.to(SDKSystemMessage.class); MyConcurrentUtils.runAndWait(() -> sessionEventConsumers.onSystemMessage(this, lastSdkSystemMessage), - Optional.ofNullable(sessionEventConsumers.onSystemMessageTimeout(this)).orElse(defaultEventTimeout)); + Optional.ofNullable(sessionEventConsumers.onSystemMessageTimeout(this, lastSdkSystemMessage)) + .orElse(defaultEventTimeout)); return false; } else if ("assistant".equals(messageType)) { - MyConcurrentUtils.runAndWait(() -> sessionEventConsumers.onAssistantMessage(this, jsonObject.to(SDKAssistantMessage.class)), - Optional.ofNullable(sessionEventConsumers.onAssistantMessageTimeout(this)).orElse(defaultEventTimeout)); + SDKAssistantMessage assistantMessage = jsonObject.to(SDKAssistantMessage.class); + MyConcurrentUtils.runAndWait(() -> sessionEventConsumers.onAssistantMessage(this, assistantMessage), + Optional.ofNullable(sessionEventConsumers.onAssistantMessageTimeout(this, assistantMessage)).orElse(defaultEventTimeout)); return false; } else if ("stream_event".equals(messageType)) { + SDKPartialAssistantMessage sdkPartialAssistantMessage = jsonObject.to(SDKPartialAssistantMessage.class); MyConcurrentUtils.runAndWait( - () -> sessionEventConsumers.onPartialAssistantMessage(this, jsonObject.to(SDKPartialAssistantMessage.class)), - Optional.ofNullable(sessionEventConsumers.onPartialAssistantMessageTimeout(this)).orElse(defaultEventTimeout)); + () -> sessionEventConsumers.onPartialAssistantMessage(this, sdkPartialAssistantMessage), + Optional.ofNullable(sessionEventConsumers.onPartialAssistantMessageTimeout(this, sdkPartialAssistantMessage)) + .orElse(defaultEventTimeout)); return false; } else if ("user".equals(messageType)) { + SDKUserMessage sdkUserMessage = jsonObject.to(SDKUserMessage.class, Feature.FieldBased); MyConcurrentUtils.runAndWait( - () -> sessionEventConsumers.onUserMessage(this, jsonObject.to(SDKUserMessage.class, Feature.FieldBased)), - Optional.ofNullable(sessionEventConsumers.onUserMessageTimeout(this)).orElse(defaultEventTimeout)); + () -> sessionEventConsumers.onUserMessage(this, sdkUserMessage), + Optional.ofNullable(sessionEventConsumers.onUserMessageTimeout(this, sdkUserMessage)).orElse(defaultEventTimeout)); return false; } else if ("result".equals(messageType)) { - MyConcurrentUtils.runAndWait(() -> sessionEventConsumers.onResultMessage(this, jsonObject.to(SDKResultMessage.class)), - Optional.ofNullable(sessionEventConsumers.onResultMessageTimeout(this)).orElse(defaultEventTimeout)); + SDKResultMessage sdkResultMessage = jsonObject.to(SDKResultMessage.class); + MyConcurrentUtils.runAndWait(() -> sessionEventConsumers.onResultMessage(this, sdkResultMessage), + Optional.ofNullable(sessionEventConsumers.onResultMessageTimeout(this, sdkResultMessage)).orElse(defaultEventTimeout)); return true; } else if ("control_response".equals(messageType)) { - MyConcurrentUtils.runAndWait(() -> sessionEventConsumers.onControlResponse(this, jsonObject.to(CLIControlResponse.class)), - Optional.ofNullable(sessionEventConsumers.onControlResponseTimeout(this)).orElse(defaultEventTimeout)); + CLIControlResponse controlResponse = jsonObject.to( + new TypeReference>() {}); + MyConcurrentUtils.runAndWait(() -> sessionEventConsumers.onControlResponse(this, controlResponse), + Optional.ofNullable(sessionEventConsumers.onControlResponseTimeout(this, controlResponse)).orElse(defaultEventTimeout)); if (!"error".equals(jsonObject.getString("subtype"))) { return false; } else { @@ -234,11 +237,28 @@ public class Session { return "error".equals(jsonObject.getString("subtype")); } } else if ("control_request".equals(messageType)) { - return processControlRequestInThePrompting(jsonObject, sessionEventConsumers); + CLIControlResponse controlResponse; + try { + CLIControlRequest controlRequest = jsonObject.to( + new TypeReference>() {}); + controlResponse = MyConcurrentUtils.runAndWait( + () -> sessionEventConsumers.onControlRequest(this, controlRequest), + Optional.ofNullable(sessionEventConsumers.onControlRequestTimeout(this, controlRequest)).orElse(defaultEventTimeout)); + } catch (Exception e) { + log.error("Failed to process control request", e); + controlResponse = new CLIControlResponse<>(); + } + try { + transport.inputNoWaitResponse(Optional.ofNullable(controlResponse).map(CLIControlResponse::toString) + .orElse(new CLIControlResponse().toString())); + } catch (Exception e) { + throw new RuntimeException("Failed to send control response", e); + } + return false; } else { log.warn("unknown message type: {}", messageType); MyConcurrentUtils.runAndWait(() -> sessionEventConsumers.onOtherMessage(this, line), - Optional.ofNullable(sessionEventConsumers.onOtherMessageTimeout(this)).orElse(defaultEventTimeout)); + Optional.ofNullable(sessionEventConsumers.onOtherMessageTimeout(this, line)).orElse(defaultEventTimeout)); return false; } }); @@ -247,68 +267,6 @@ public class Session { } } - private boolean processControlRequestInThePrompting(JSONObject jsonObject, SessionEventConsumers sessionEventConsumers) { - String subType = Optional.of(jsonObject) - .map(cr -> cr.getJSONObject("request")) - .map(r -> r.getString("subtype")) - .orElse(""); - if ("can_use_tool".equals(subType)) { - try { - return processPermissionResponse(jsonObject, sessionEventConsumers); - } catch (IOException | ExecutionException | InterruptedException | TimeoutException e) { - log.error("Failed to process permission response", e); - return false; - } - } else { - CLIControlResponse cliControlResponse; - try { - cliControlResponse = MyConcurrentUtils.runAndWait( - () -> sessionEventConsumers.onControlRequest(this, jsonObject.to(new TypeReference>() {})), - Optional.ofNullable(sessionEventConsumers.onControlRequestTimeout(this)).orElse(defaultEventTimeout)); - } catch (Exception e) { - log.error("Failed to process control request", e); - return false; - } - - if (cliControlResponse != null) { - try { - transport.inputNoWaitResponse(cliControlResponse.toString()); - } catch (Exception e) { - log.error("Failed to process control response", e); - return false; - } - } - return false; - } - } - - private boolean processPermissionResponse(JSONObject jsonObject, SessionEventConsumers sessionEventConsumers) - throws IOException, ExecutionException, InterruptedException, TimeoutException { - CLIControlRequest permissionRequest = jsonObject.to( - new TypeReference>() {}); - - Behavior behavior = Optional.ofNullable(MyConcurrentUtils.runAndWait(() -> sessionEventConsumers.onPermissionRequest(this, permissionRequest), - Optional.ofNullable(sessionEventConsumers.onPermissionRequestTimeout(this)).orElse(defaultEventTimeout))) - .map(b -> { - if (b instanceof Allow) { - Allow allow = (Allow) b; - if (allow.getUpdatedInput() == null) { - allow.setUpdatedInput(permissionRequest.getRequest().getInput()); - } - } - return b; - }) - .orElse(Behavior.defaultBehavior()); - CLIControlResponse permissionResponse = new CLIControlResponse<>(); - permissionResponse.createResponse().setResponse(new CLIControlPermissionResponse().setBehavior(behavior)).setRequestId( - permissionRequest.getRequestId()); - String permissionMessage = permissionResponse.toString(); - log.debug("send permission message to agent: {}", permissionMessage); - transport.inputNoWaitResponse(permissionMessage); - - return false; - } - /** * Gets the current session ID. * diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/AssistantContentConsumers.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/AssistantContentConsumers.java deleted file mode 100644 index 8fce1c4fa..000000000 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/AssistantContentConsumers.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.alibaba.qwen.code.cli.session.event; - -import com.alibaba.qwen.code.cli.protocol.data.AssistantUsage; -import com.alibaba.qwen.code.cli.protocol.data.AssistantContent; -import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.TextAssistantContent; -import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ThingkingAssistantContent; -import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolResultAssistantContent; -import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolUseAssistantContent; -import com.alibaba.qwen.code.cli.session.Session; - -/** - * Interface for handling different types of assistant content during a session. - * - * @author skyfire - * @version $Id: 0.0.1 - */ -public interface AssistantContentConsumers { - /** - * Handles text content from the assistant. - * - * @param session The session - * @param textAssistantContent The text content from the assistant - */ - void onText(Session session, TextAssistantContent textAssistantContent); - - /** - * Handles thinking content from the assistant. - * - * @param session The session - * @param thingkingAssistantContent The thinking content from the assistant - */ - void onThinking(Session session, ThingkingAssistantContent thingkingAssistantContent); - - /** - * Handles tool use content from the assistant. - * - * @param session The session - * @param toolUseAssistantContent The tool use content from the assistant - */ - void onToolUse(Session session, ToolUseAssistantContent toolUseAssistantContent); - - /** - * Handles tool result content from the assistant. - * - * @param session The session - * @param toolResultAssistantContent The tool result content from the assistant - */ - void onToolResult(Session session, ToolResultAssistantContent toolResultAssistantContent); - - /** - * Handles other types of assistant content. - * - * @param session The session - * @param other The other content from the assistant - */ - void onOtherContent(Session session, AssistantContent other); - - /** - * Handles usage information from the assistant. - * - * @param session The session - * @param AssistantUsage The usage information from the assistant - */ - void onUsage(Session session, AssistantUsage AssistantUsage); -} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/AssistantContentSimpleConsumers.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/AssistantContentSimpleConsumers.java deleted file mode 100644 index 9a2b63df9..000000000 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/AssistantContentSimpleConsumers.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.alibaba.qwen.code.cli.session.event; - -import com.alibaba.qwen.code.cli.protocol.data.AssistantUsage; -import com.alibaba.qwen.code.cli.protocol.data.AssistantContent; -import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.TextAssistantContent; -import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ThingkingAssistantContent; -import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolResultAssistantContent; -import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolUseAssistantContent; -import com.alibaba.qwen.code.cli.session.Session; - -/** - * Simple implementation of AssistantContentConsumers that provides empty implementations for all methods. - * - * @author skyfire - * @version $Id: 0.0.1 - */ -public class AssistantContentSimpleConsumers implements AssistantContentConsumers { - /** {@inheritDoc} */ - @Override - public void onText(Session session, TextAssistantContent textAssistantContent) { - } - - /** {@inheritDoc} */ - @Override - public void onThinking(Session session, ThingkingAssistantContent thingkingAssistantContent) { - } - - /** {@inheritDoc} */ - @Override - public void onToolUse(Session session, ToolUseAssistantContent toolUseAssistantContent) { - } - - /** {@inheritDoc} */ - @Override - public void onToolResult(Session session, ToolResultAssistantContent toolResultAssistantContent) { - } - - /** {@inheritDoc} */ - @Override - public void onOtherContent(Session session, AssistantContent other) { - } - - /** {@inheritDoc} */ - @Override - public void onUsage(Session session, AssistantUsage AssistantUsage) { - } -} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventSimpleConsumers.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventSimpleConsumers.java deleted file mode 100644 index 38ed31aa1..000000000 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventSimpleConsumers.java +++ /dev/null @@ -1,266 +0,0 @@ -package com.alibaba.qwen.code.cli.session.event; - -import java.util.List; - -import com.alibaba.qwen.code.cli.protocol.data.AssistantContent; -import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.TextAssistantContent; -import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ThingkingAssistantContent; -import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolResultAssistantContent; -import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolUseAssistantContent; -import com.alibaba.qwen.code.cli.protocol.data.AssistantUsage; -import com.alibaba.qwen.code.cli.protocol.data.behavior.Allow; -import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior; -import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior.Operation; -import com.alibaba.qwen.code.cli.protocol.data.behavior.Deny; -import com.alibaba.qwen.code.cli.protocol.message.SDKResultMessage; -import com.alibaba.qwen.code.cli.protocol.message.SDKSystemMessage; -import com.alibaba.qwen.code.cli.protocol.message.SDKUserMessage; -import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKAssistantMessage; -import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKPartialAssistantMessage; -import com.alibaba.qwen.code.cli.protocol.message.assistant.block.ContentBlock; -import com.alibaba.qwen.code.cli.protocol.message.assistant.event.ContentBlockDeltaEvent; -import com.alibaba.qwen.code.cli.protocol.message.assistant.event.StreamEvent; -import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlPermissionRequest; -import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlRequest; -import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlResponse; -import com.alibaba.qwen.code.cli.session.Session; -import com.alibaba.qwen.code.cli.utils.Timeout; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Simple implementation of SessionEventConsumers that provides basic implementations for all methods. - * - * @author skyfire - * @version $Id: 0.0.1 - */ -public class SessionEventSimpleConsumers implements SessionEventConsumers { - /** {@inheritDoc} */ - @Override - public void onSystemMessage(Session session, SDKSystemMessage systemMessage) { - } - - /** {@inheritDoc} */ - @Override - public void onResultMessage(Session session, SDKResultMessage resultMessage) { - } - - /** {@inheritDoc} */ - @Override - public void onAssistantMessage(Session session, SDKAssistantMessage assistantMessage) { - List> contentBlocks = assistantMessage.getMessage().getContent(); - if (assistantContentConsumers == null || contentBlocks == null || contentBlocks.isEmpty()) { - return; - } - assistantContentConsumers.onUsage(session, new AssistantUsage(assistantMessage.getMessage().getId(), assistantMessage.getMessage().getUsage())); - - if (!session.isStreaming()) { - contentBlocks.forEach(contentBlock -> consumeAssistantContent(session, contentBlock)); - } - } - - /** {@inheritDoc} */ - @Override - public void onPartialAssistantMessage(Session session, SDKPartialAssistantMessage partialAssistantMessage) { - StreamEvent event = partialAssistantMessage.getEvent(); - if (!(event instanceof ContentBlockDeltaEvent)) { - log.debug("received partialAssistantMessage and is not instance of ContentBlockDeltaEvent, will ignore process. the message is {}", - partialAssistantMessage); - return; - } - ContentBlockDeltaEvent contentBlockDeltaEvent = (ContentBlockDeltaEvent) event; - contentBlockDeltaEvent.getDelta().setMessageId(partialAssistantMessage.getMessageId()); - consumeAssistantContent(session, contentBlockDeltaEvent.getDelta()); - } - - /** - *

consumeAssistantContent.

- * - * @param session a {@link com.alibaba.qwen.code.cli.session.Session} object. - * @param assistantContent a {@link com.alibaba.qwen.code.cli.protocol.data.AssistantContent} object. - */ - protected void consumeAssistantContent(Session session, AssistantContent assistantContent) { - if (assistantContent instanceof TextAssistantContent) { - assistantContentConsumers.onText(session, (TextAssistantContent) assistantContent); - } else if (assistantContent instanceof ThingkingAssistantContent) { - assistantContentConsumers.onThinking(session, (ThingkingAssistantContent) assistantContent); - } else if (assistantContent instanceof ToolUseAssistantContent) { - assistantContentConsumers.onToolUse(session, (ToolUseAssistantContent) assistantContent); - } else if (assistantContent instanceof ToolResultAssistantContent) { - assistantContentConsumers.onToolResult(session, (ToolResultAssistantContent) assistantContent); - } else { - assistantContentConsumers.onOtherContent(session, assistantContent); - } - } - - /** {@inheritDoc} */ - @Override - public void onUserMessage(Session session, SDKUserMessage userMessage) { - } - - /** {@inheritDoc} */ - @Override - public void onOtherMessage(Session session, String message) { - } - - /** {@inheritDoc} */ - @Override - public void onControlResponse(Session session, CLIControlResponse cliControlResponse) { - } - - /** {@inheritDoc} */ - @Override - public CLIControlResponse onControlRequest(Session session, CLIControlRequest cliControlRequest) { - return new CLIControlResponse<>(); - } - - /** {@inheritDoc} */ - @Override - public Behavior onPermissionRequest(Session session, CLIControlRequest permissionRequest) { - if (Operation.deny.equals(this.defaultPermissionOperation)) { - return new Deny().setMessage("Permission denied."); - } else { - return new Allow().setUpdatedInput(permissionRequest.getRequest().getInput()); - } - } - - /** {@inheritDoc} */ - @Override - public Timeout onSystemMessageTimeout(Session session) { - return defaultEventTimeout; - } - - /** {@inheritDoc} */ - @Override - public Timeout onResultMessageTimeout(Session session) { - return defaultEventTimeout; - } - - /** {@inheritDoc} */ - @Override - public Timeout onAssistantMessageTimeout(Session session) { - return defaultEventTimeout; - } - - /** {@inheritDoc} */ - @Override - public Timeout onPartialAssistantMessageTimeout(Session session) { - return defaultEventTimeout; - } - - /** {@inheritDoc} */ - @Override - public Timeout onUserMessageTimeout(Session session) { - return defaultEventTimeout; - } - - /** {@inheritDoc} */ - @Override - public Timeout onOtherMessageTimeout(Session session) { - return defaultEventTimeout; - } - - /** {@inheritDoc} */ - @Override - public Timeout onControlResponseTimeout(Session session) { - return defaultEventTimeout; - } - - /** {@inheritDoc} */ - @Override - public Timeout onControlRequestTimeout(Session session) { - return defaultEventTimeout; - } - - /** {@inheritDoc} */ - @Override - public Timeout onPermissionRequestTimeout(Session session) { - return defaultEventTimeout; - } - - /** - * Gets the default event timeout. - * - * @return The default event timeout - */ - protected Timeout getDefaultEventTimeout() { - return defaultEventTimeout; - } - - /** - * Sets the default event timeout. - * - * @param defaultEventTimeout The default event timeout - * @return This instance for method chaining - */ - public SessionEventSimpleConsumers setDefaultEventTimeout(Timeout defaultEventTimeout) { - this.defaultEventTimeout = defaultEventTimeout; - return this; - } - - /** - * Gets the default permission operation. - * - * @return The default permission operation - */ - protected Operation getDefaultPermissionOperation() { - return defaultPermissionOperation; - } - - /** - * Sets the default permission operation. - * - * @param defaultPermissionOperation The default permission operation - * @return This instance for method chaining - */ - public SessionEventSimpleConsumers setDefaultPermissionOperation(Operation defaultPermissionOperation) { - this.defaultPermissionOperation = defaultPermissionOperation; - return this; - } - - /** - * Creates a new SessionEventSimpleConsumers instance with default values. - */ - public SessionEventSimpleConsumers() { - } - - /** - * Creates a new SessionEventSimpleConsumers instance with the specified parameters. - * - * @param defaultPermissionOperation The default permission operation - * @param defaultEventTimeout The default event timeout - * @param assistantContentConsumers The assistant content consumers - */ - public SessionEventSimpleConsumers(Operation defaultPermissionOperation, Timeout defaultEventTimeout, - AssistantContentConsumers assistantContentConsumers) { - this.defaultPermissionOperation = defaultPermissionOperation; - this.defaultEventTimeout = defaultEventTimeout; - this.assistantContentConsumers = assistantContentConsumers; - } - - /** - * The default permission operation. - */ - private Operation defaultPermissionOperation = Operation.deny; - /** - * The default event timeout. - */ - protected Timeout defaultEventTimeout = Timeout.TIMEOUT_60_SECONDS; - /** - * The assistant content consumers. - */ - protected AssistantContentConsumers assistantContentConsumers; - private static final Logger log = LoggerFactory.getLogger(SessionEventSimpleConsumers.class); - - /** - * Sets the assistant content consumers. - * - * @param assistantContentConsumers The assistant content consumers - * @return This instance for method chaining - */ - public SessionEventSimpleConsumers setBlockConsumer(AssistantContentConsumers assistantContentConsumers) { - this.assistantContentConsumers = assistantContentConsumers; - return this; - } -} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/AssistantContentConsumers.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/AssistantContentConsumers.java new file mode 100644 index 000000000..233bf7353 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/AssistantContentConsumers.java @@ -0,0 +1,159 @@ +package com.alibaba.qwen.code.cli.session.event.consumers; + +import com.alibaba.qwen.code.cli.protocol.data.AssistantUsage; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.TextAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ThingkingAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolResultAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolUseAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior; +import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior.Operation; +import com.alibaba.qwen.code.cli.protocol.message.control.payload.CLIControlPermissionRequest; +import com.alibaba.qwen.code.cli.protocol.message.control.payload.ControlRequestPayload; +import com.alibaba.qwen.code.cli.protocol.message.control.payload.ControlResponsePayload; +import com.alibaba.qwen.code.cli.session.Session; +import com.alibaba.qwen.code.cli.utils.Timeout; + +/** + * Interface for handling different types of assistant content during a session. + * + * @author skyfire + * @version $Id: 0.0.1 + */ +public interface AssistantContentConsumers { + /** + * Handles text content from the assistant. + * + * @param session The session + * @param textAssistantContent The text content from the assistant + */ + void onText(Session session, TextAssistantContent textAssistantContent); + + /** + * Handles thinking content from the assistant. + * + * @param session The session + * @param thingkingAssistantContent The thinking content from the assistant + */ + void onThinking(Session session, ThingkingAssistantContent thingkingAssistantContent); + + /** + * Handles tool use content from the assistant. + * + * @param session The session + * @param toolUseAssistantContent The tool use content from the assistant + */ + void onToolUse(Session session, ToolUseAssistantContent toolUseAssistantContent); + + /** + * Handles tool result content from the assistant. + * + * @param session The session + * @param toolResultAssistantContent The tool result content from the assistant + */ + void onToolResult(Session session, ToolResultAssistantContent toolResultAssistantContent); + + /** + * Handles other types of assistant content. + * + * @param session The session + * @param other The other content from the assistant + */ + void onOtherContent(Session session, AssistantContent other); + + /** + * Handles permission requests. + * + * @param session The session + * @param permissionRequest The permission request + * @return The behavior for the permission request + */ + Behavior onPermissionRequest(Session session, CLIControlPermissionRequest permissionRequest); + + /** + * Handles permission requests. + * + * @param session The session + * @param requestPayload The control request payload + * @return The response payload for the control request + */ + ControlResponsePayload onOtherControlRequest(Session session, ControlRequestPayload requestPayload); + + /** + * Handles usage information from the assistant. + * + * @param session The session + * @param AssistantUsage The usage information from the assistant + */ + void onUsage(Session session, AssistantUsage AssistantUsage); + + /** + * Sets the default permission operation. + * + * @param defaultPermissionOperation The default permission operation + * @return This instance for method chaining + */ + AssistantContentSimpleConsumers setDefaultPermissionOperation(Operation defaultPermissionOperation); + + /** + * Gets timeout for permission request handling. + * + * @param session The session + * @return The timeout for permission request handling + */ + Timeout onPermissionRequestTimeout(Session session, CLIControlPermissionRequest permissionRequest); + + /** + * Gets timeout for other control request handling. + * + * @param session The session + * @param requestPayload The control request payload + * @return The timeout for other control request handling + */ + Timeout onOtherControlRequestTimeout(Session session, ControlRequestPayload requestPayload); + + /** + * Gets timeout for text handling. + * + * @param session The session + * @param textAssistantContent The text content from the assistant + * @return The timeout for text handling + */ + Timeout onTextTimeout(Session session, TextAssistantContent textAssistantContent); + + /** + * Gets timeout for thinking handling. + * + * @param session The session + * @param thingkingAssistantContent The thinking content from the assistant + * @return The timeout for thinking handling + */ + Timeout onThinkingTimeout(Session session, ThingkingAssistantContent thingkingAssistantContent); + + /** + * Gets timeout for tool use handling. + * + * @param session The session + * @param toolUseAssistantContent The tool use content from the assistant + * @return The timeout for tool use handling + */ + Timeout onToolUseTimeout(Session session, ToolUseAssistantContent toolUseAssistantContent); + + /** + * Gets timeout for tool result handling. + * + * @param session The session + * @param toolResultAssistantContent The tool result content from the assistant + * @return The timeout for tool result handling + */ + Timeout onToolResultTimeout(Session session, ToolResultAssistantContent toolResultAssistantContent); + + /** + * Gets timeout for other content handling. + * + * @param session The session + * @param other The other content from the assistant + * @return The timeout for other content handling + */ + Timeout onOtherContentTimeout(Session session, AssistantContent other); +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/AssistantContentSimpleConsumers.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/AssistantContentSimpleConsumers.java new file mode 100644 index 000000000..40c941e32 --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/AssistantContentSimpleConsumers.java @@ -0,0 +1,176 @@ +package com.alibaba.qwen.code.cli.session.event.consumers; + +import com.alibaba.qwen.code.cli.protocol.data.AssistantUsage; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.TextAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ThingkingAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolResultAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolUseAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.behavior.Allow; +import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior; +import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior.Operation; +import com.alibaba.qwen.code.cli.protocol.data.behavior.Deny; +import com.alibaba.qwen.code.cli.protocol.message.control.payload.CLIControlPermissionRequest; +import com.alibaba.qwen.code.cli.protocol.message.control.payload.ControlRequestPayload; +import com.alibaba.qwen.code.cli.protocol.message.control.payload.ControlResponsePayload; +import com.alibaba.qwen.code.cli.session.Session; +import com.alibaba.qwen.code.cli.utils.Timeout; + +/** + * Simple implementation of AssistantContentConsumers that provides empty implementations for all methods. + * + * @author skyfire + * @version $Id: 0.0.1 + */ +public class AssistantContentSimpleConsumers implements AssistantContentConsumers { + /** + * {@inheritDoc} + */ + @Override + public void onText(Session session, TextAssistantContent textAssistantContent) { + } + + /** + * {@inheritDoc} + */ + @Override + public void onThinking(Session session, ThingkingAssistantContent thingkingAssistantContent) { + } + + /** + * {@inheritDoc} + */ + @Override + public void onToolUse(Session session, ToolUseAssistantContent toolUseAssistantContent) { + } + + /** + * {@inheritDoc} + */ + @Override + public void onToolResult(Session session, ToolResultAssistantContent toolResultAssistantContent) { + } + + /** + * {@inheritDoc} + */ + @Override + public void onOtherContent(Session session, AssistantContent other) { + } + + /** + * {@inheritDoc} + */ + @Override + public Behavior onPermissionRequest(Session session, CLIControlPermissionRequest permissionRequest) { + if (Operation.deny.equals(this.defaultPermissionOperation)) { + return new Deny().setMessage("Permission denied."); + } else { + return new Allow().setUpdatedInput(permissionRequest.getInput()); + } + } + + @Override + public ControlResponsePayload onOtherControlRequest(Session session, ControlRequestPayload requestPayload) { + throw new RuntimeException("need override onOtherControlRequest"); + } + + /** + * {@inheritDoc} + */ + @Override + public void onUsage(Session session, AssistantUsage AssistantUsage) { + } + + /** + * {@inheritDoc} + */ + @Override + public Timeout onPermissionRequestTimeout(Session session, CLIControlPermissionRequest permissionRequest) { + return defaultEventTimeout; + } + + /** + * {@inheritDoc} + */ + @Override + public Timeout onOtherControlRequestTimeout(Session session, ControlRequestPayload requestPayload) { + return defaultEventTimeout; + } + + /** + * {@inheritDoc} + */ + @Override + public Timeout onTextTimeout(Session session, TextAssistantContent textAssistantContent) { + return defaultEventTimeout; + } + + /** + * {@inheritDoc} + */ + @Override + public Timeout onThinkingTimeout(Session session, ThingkingAssistantContent thingkingAssistantContent) { + return defaultEventTimeout; + } + + /** + * {@inheritDoc} + */ + @Override + public Timeout onToolUseTimeout(Session session, ToolUseAssistantContent toolUseAssistantContent) { + return defaultEventTimeout; + } + + /** + * {@inheritDoc} + */ + @Override + public Timeout onToolResultTimeout(Session session, ToolResultAssistantContent toolResultAssistantContent) { + return defaultEventTimeout; + } + + /** + * {@inheritDoc} + */ + @Override + public Timeout onOtherContentTimeout(Session session, AssistantContent other) { + return defaultEventTimeout; + } + + /** + * {@inheritDoc} + */ + @Override + public AssistantContentSimpleConsumers setDefaultPermissionOperation(Operation defaultPermissionOperation) { + this.defaultPermissionOperation = defaultPermissionOperation; + return this; + } + + /** + * Constructor. + * + * @param defaultPermissionOperation The default permission operation. + * @param defaultEventTimeout The default event timeout. + */ + public AssistantContentSimpleConsumers(Operation defaultPermissionOperation, Timeout defaultEventTimeout) { + this.defaultPermissionOperation = defaultPermissionOperation; + this.defaultEventTimeout = defaultEventTimeout; + } + + /** + * Constructor. + */ + public AssistantContentSimpleConsumers() { + } + + /** + * The default permission operation. + */ + private Operation defaultPermissionOperation = Operation.deny; + + /** + * The default event timeout. + */ + protected Timeout defaultEventTimeout = Timeout.TIMEOUT_60_SECONDS; +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventConsumers.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/SessionEventConsumers.java similarity index 71% rename from packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventConsumers.java rename to packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/SessionEventConsumers.java index 7159beaef..ba37ca641 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/SessionEventConsumers.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/SessionEventConsumers.java @@ -1,14 +1,14 @@ -package com.alibaba.qwen.code.cli.session.event; +package com.alibaba.qwen.code.cli.session.event.consumers; -import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior; import com.alibaba.qwen.code.cli.protocol.message.SDKResultMessage; import com.alibaba.qwen.code.cli.protocol.message.SDKSystemMessage; import com.alibaba.qwen.code.cli.protocol.message.SDKUserMessage; import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKAssistantMessage; import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKPartialAssistantMessage; -import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlPermissionRequest; import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlRequest; import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlResponse; +import com.alibaba.qwen.code.cli.protocol.message.control.payload.ControlRequestPayload; +import com.alibaba.qwen.code.cli.protocol.message.control.payload.ControlResponsePayload; import com.alibaba.qwen.code.cli.session.Session; import com.alibaba.qwen.code.cli.utils.Timeout; @@ -82,86 +82,77 @@ public interface SessionEventConsumers { * @param cliControlRequest The control request * @return The control response */ - CLIControlResponse onControlRequest(Session session, CLIControlRequest cliControlRequest); - - /** - * Handles permission requests. - * - * @param session The session - * @param permissionRequest The permission request - * @return The behavior for the permission request - */ - Behavior onPermissionRequest(Session session, CLIControlRequest permissionRequest); + CLIControlResponse onControlRequest(Session session, CLIControlRequest cliControlRequest); /** * Gets timeout for system message handling. * * @param session The session + * @param systemMessage The system message * @return The timeout for system message handling */ - Timeout onSystemMessageTimeout(Session session); + Timeout onSystemMessageTimeout(Session session, SDKSystemMessage systemMessage); /** * Gets timeout for result message handling. * * @param session The session + * @param resultMessage The result message * @return The timeout for result message handling */ - Timeout onResultMessageTimeout(Session session); + Timeout onResultMessageTimeout(Session session, SDKResultMessage resultMessage); /** * Gets timeout for assistant message handling. * * @param session The session + * @param assistantMessage The assistant message * @return The timeout for assistant message handling */ - Timeout onAssistantMessageTimeout(Session session); + Timeout onAssistantMessageTimeout(Session session, SDKAssistantMessage assistantMessage); /** * Gets timeout for partial assistant message handling. * * @param session The session + * @param partialAssistantMessage The partial assistant message * @return The timeout for partial assistant message handling */ - Timeout onPartialAssistantMessageTimeout(Session session); + Timeout onPartialAssistantMessageTimeout(Session session, SDKPartialAssistantMessage partialAssistantMessage); /** * Gets timeout for user message handling. * * @param session The session + * @param userMessage The user message * @return The timeout for user message handling */ - Timeout onUserMessageTimeout(Session session); + Timeout onUserMessageTimeout(Session session, SDKUserMessage userMessage); /** * Gets timeout for other message handling. * * @param session The session + * @param message The message * @return The timeout for other message handling */ - Timeout onOtherMessageTimeout(Session session); + Timeout onOtherMessageTimeout(Session session, String message); /** * Gets timeout for control response handling. * * @param session The session + * @param cliControlResponse The control response * @return The timeout for control response handling */ - Timeout onControlResponseTimeout(Session session); + Timeout onControlResponseTimeout(Session session, CLIControlResponse cliControlResponse); /** * Gets timeout for control request handling. * * @param session The session + * @param cliControlRequest The control request * @return The timeout for control request handling */ - Timeout onControlRequestTimeout(Session session); - - /** - * Gets timeout for permission request handling. - * - * @param session The session - * @return The timeout for permission request handling - */ - Timeout onPermissionRequestTimeout(Session session); + Timeout onControlRequestTimeout(Session session, CLIControlRequest cliControlRequest); } diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/SessionEventSimpleConsumers.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/SessionEventSimpleConsumers.java new file mode 100644 index 000000000..e3d9e47cc --- /dev/null +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/SessionEventSimpleConsumers.java @@ -0,0 +1,339 @@ +package com.alibaba.qwen.code.cli.session.event.consumers; + +import java.util.List; +import java.util.Optional; + +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.TextAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ThingkingAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolResultAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolUseAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantUsage; +import com.alibaba.qwen.code.cli.protocol.data.behavior.Allow; +import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior; +import com.alibaba.qwen.code.cli.protocol.message.SDKResultMessage; +import com.alibaba.qwen.code.cli.protocol.message.SDKSystemMessage; +import com.alibaba.qwen.code.cli.protocol.message.SDKUserMessage; +import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKAssistantMessage; +import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKPartialAssistantMessage; +import com.alibaba.qwen.code.cli.protocol.message.assistant.block.ContentBlock; +import com.alibaba.qwen.code.cli.protocol.message.assistant.event.ContentBlockDeltaEvent; +import com.alibaba.qwen.code.cli.protocol.message.assistant.event.StreamEvent; +import com.alibaba.qwen.code.cli.protocol.message.control.payload.CLIControlPermissionRequest; +import com.alibaba.qwen.code.cli.protocol.message.control.payload.CLIControlPermissionResponse; +import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlRequest; +import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlResponse; +import com.alibaba.qwen.code.cli.protocol.message.control.payload.ControlRequestPayload; +import com.alibaba.qwen.code.cli.protocol.message.control.payload.ControlResponsePayload; +import com.alibaba.qwen.code.cli.session.Session; +import com.alibaba.qwen.code.cli.utils.MyConcurrentUtils; +import com.alibaba.qwen.code.cli.utils.Timeout; + +import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.Validate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Simple implementation of SessionEventConsumers that provides basic implementations for all methods. + * + * @author skyfire + * @version $Id: 0.0.1 + */ +public class SessionEventSimpleConsumers implements SessionEventConsumers { + /** + * {@inheritDoc} + */ + @Override + public void onSystemMessage(Session session, SDKSystemMessage systemMessage) { + } + + /** + * {@inheritDoc} + */ + @Override + public void onResultMessage(Session session, SDKResultMessage resultMessage) { + } + + /** + * {@inheritDoc} + */ + @Override + public void onAssistantMessage(Session session, SDKAssistantMessage assistantMessage) { + List> contentBlocks = assistantMessage.getMessage().getContent(); + if (assistantContentConsumers == null || contentBlocks == null || contentBlocks.isEmpty()) { + return; + } + assistantContentConsumers.onUsage(session, + new AssistantUsage(assistantMessage.getMessage().getId(), assistantMessage.getMessage().getUsage())); + + if (!session.isStreaming()) { + contentBlocks.forEach(contentBlock -> consumeAssistantContent(session, contentBlock)); + } + } + + /** + * {@inheritDoc} + */ + @Override + public void onPartialAssistantMessage(Session session, SDKPartialAssistantMessage partialAssistantMessage) { + StreamEvent event = partialAssistantMessage.getEvent(); + if (!(event instanceof ContentBlockDeltaEvent)) { + log.debug("received partialAssistantMessage and is not instance of ContentBlockDeltaEvent, will ignore process. the message is {}", + partialAssistantMessage); + return; + } + ContentBlockDeltaEvent contentBlockDeltaEvent = (ContentBlockDeltaEvent) event; + contentBlockDeltaEvent.getDelta().setMessageId(partialAssistantMessage.getMessageId()); + consumeAssistantContent(session, contentBlockDeltaEvent.getDelta()); + } + + /** + *

consumeAssistantContent.

+ * + * @param session a {@link com.alibaba.qwen.code.cli.session.Session} object. + * @param assistantContent a {@link com.alibaba.qwen.code.cli.protocol.data.AssistantContent} object. + */ + protected void consumeAssistantContent(Session session, AssistantContent assistantContent) { + if (assistantContent instanceof TextAssistantContent) { + MyConcurrentUtils.runAndWait(() -> assistantContentConsumers.onText(session, (TextAssistantContent) assistantContent), + Optional.ofNullable(assistantContentConsumers.onTextTimeout(session, (TextAssistantContent) assistantContent)) + .orElse(defaultEventTimeout)); + } else if (assistantContent instanceof ThingkingAssistantContent) { + MyConcurrentUtils.runAndWait(() -> assistantContentConsumers.onThinking(session, (ThingkingAssistantContent) assistantContent), + Optional.ofNullable(assistantContentConsumers.onThinkingTimeout(session, (ThingkingAssistantContent) assistantContent)) + .orElse(defaultEventTimeout)); + } else if (assistantContent instanceof ToolUseAssistantContent) { + MyConcurrentUtils.runAndWait(() -> assistantContentConsumers.onToolUse(session, (ToolUseAssistantContent) assistantContent), + Optional.ofNullable(assistantContentConsumers.onToolUseTimeout(session, (ToolUseAssistantContent) assistantContent)) + .orElse(defaultEventTimeout)); + } else if (assistantContent instanceof ToolResultAssistantContent) { + MyConcurrentUtils.runAndWait(() -> assistantContentConsumers.onToolResult(session, (ToolResultAssistantContent) assistantContent), + Optional.ofNullable(assistantContentConsumers.onToolResultTimeout(session, (ToolResultAssistantContent) assistantContent)) + .orElse(defaultEventTimeout)); + } else { + MyConcurrentUtils.runAndWait(() -> assistantContentConsumers.onOtherContent(session, assistantContent), + Optional.ofNullable(assistantContentConsumers.onOtherContentTimeout(session, assistantContent)).orElse(defaultEventTimeout)); + } + } + + /** + * {@inheritDoc} + */ + @Override + public void onUserMessage(Session session, SDKUserMessage userMessage) { + } + + /** + * {@inheritDoc} + */ + @Override + public void onOtherMessage(Session session, String message) { + } + + /** + * {@inheritDoc} + */ + @Override + public void onControlResponse(Session session, CLIControlResponse cliControlResponse) { + } + + /** + * {@inheritDoc} + */ + @Override + public CLIControlResponse onControlRequest(Session session, CLIControlRequest cliControlRequest) { + if (assistantContentConsumers == null) { + throw new RuntimeException("please set assistantContentConsumers or override onControlRequest of "); + } + ControlRequestPayload payload = cliControlRequest.getRequest(); + if (payload instanceof CLIControlPermissionRequest) { + CLIControlPermissionRequest permissionRequest = (CLIControlPermissionRequest) payload; + return supplyPermissionControlResponse(session, permissionRequest, cliControlRequest.getRequestId()); + } else { + ControlRequestPayload request = cliControlRequest.getRequest(); + return supplyOtherControlResponse(session, request, cliControlRequest.getRequestId()); + } + } + + private CLIControlResponse supplyPermissionControlResponse(Session session, + CLIControlPermissionRequest permissionRequest, String requestId) { + Behavior behavior; + try { + behavior = Optional.ofNullable( + MyConcurrentUtils.runAndWait(() -> this.assistantContentConsumers.onPermissionRequest(session, permissionRequest), + Optional.ofNullable(assistantContentConsumers.onPermissionRequestTimeout(session, permissionRequest)) + .orElse(defaultEventTimeout))) + .map(b -> { + if (b instanceof Allow) { + Allow allow = (Allow) b; + if (allow.getUpdatedInput() == null) { + allow.setUpdatedInput(permissionRequest.getInput()); + } + } + return b; + }) + .orElse(Behavior.defaultBehavior()); + } catch (Exception e) { + log.error("Failed to process permission response", e); + behavior = Behavior.defaultBehavior(); + } + + CLIControlResponse permissionResponse = new CLIControlResponse<>(); + permissionResponse.createResponse().setResponse(new CLIControlPermissionResponse().setBehavior(behavior)).setRequestId(requestId); + return permissionResponse; + } + + private CLIControlResponse supplyOtherControlResponse(Session session, ControlRequestPayload requestPayload, + String requestId) { + ControlResponsePayload controlResponsePayload; + try { + controlResponsePayload = Optional.ofNullable( + MyConcurrentUtils.runAndWait(() -> this.assistantContentConsumers.onOtherControlRequest(session, requestPayload), + ObjectUtils.getIfNull(assistantContentConsumers.onOtherControlRequestTimeout(session, requestPayload), + defaultEventTimeout))) + .orElse(new ControlResponsePayload()); + } catch (Exception e) { + log.error("Failed to process permission response", e); + controlResponsePayload = new ControlResponsePayload(); + } + + CLIControlResponse cliControlResponse = new CLIControlResponse<>(); + cliControlResponse.createResponse().setResponse(controlResponsePayload).setRequestId(requestId); + return cliControlResponse; + } + + /** + * {@inheritDoc} + */ + @Override + public Timeout onSystemMessageTimeout(Session session, SDKSystemMessage systemMessage) { + return defaultEventTimeout; + } + + /** + * {@inheritDoc} + */ + @Override + public Timeout onResultMessageTimeout(Session session, SDKResultMessage resultMessage) { + return defaultEventTimeout; + } + + /** + * {@inheritDoc} + */ + @Override + public Timeout onAssistantMessageTimeout(Session session, SDKAssistantMessage assistantMessage) { + return defaultEventTimeout; + } + + /** + * {@inheritDoc} + */ + @Override + public Timeout onPartialAssistantMessageTimeout(Session session, SDKPartialAssistantMessage partialAssistantMessage) { + return defaultEventTimeout; + } + + /** + * {@inheritDoc} + */ + @Override + public Timeout onUserMessageTimeout(Session session, SDKUserMessage userMessage) { + return defaultEventTimeout; + } + + /** + * {@inheritDoc} + */ + @Override + public Timeout onOtherMessageTimeout(Session session, String message) { + return defaultEventTimeout; + } + + /** + * {@inheritDoc} + */ + @Override + public Timeout onControlResponseTimeout(Session session, CLIControlResponse cliControlResponse) { + return defaultEventTimeout; + } + + /** + * {@inheritDoc} + */ + @Override + public Timeout onControlRequestTimeout(Session session, CLIControlRequest cliControlRequest) { + return defaultEventTimeout; + } + + /** + * Gets the default event timeout. + * + * @return The default event timeout + */ + protected Timeout getDefaultEventTimeout() { + return defaultEventTimeout; + } + + /** + * Sets the default event timeout. + * + * @param defaultEventTimeout The default event timeout + * @return This instance for method chaining + */ + public SessionEventSimpleConsumers setDefaultEventTimeout(Timeout defaultEventTimeout) { + this.defaultEventTimeout = defaultEventTimeout; + return this; + } + + /** + * Creates a new SessionEventSimpleConsumers instance with default values. + */ + public SessionEventSimpleConsumers() { + } + + /** + * Creates a new SessionEventSimpleConsumers instance with the specified parameters. + * + * @param defaultEventTimeout The default event timeout + * @param assistantContentConsumers The assistant content consumers + */ + public SessionEventSimpleConsumers(Timeout defaultEventTimeout, AssistantContentConsumers assistantContentConsumers) { + Validate.notNull(defaultEventTimeout, "defaultEventTimeout can't be null"); + Validate.notNull(assistantContentConsumers, "assistantContentConsumers can't be null"); + this.defaultEventTimeout = defaultEventTimeout; + this.assistantContentConsumers = assistantContentConsumers; + } + + /** + * The default event timeout. + */ + protected Timeout defaultEventTimeout = Timeout.TIMEOUT_180_SECONDS; + /** + * The assistant content consumers. + */ + protected AssistantContentConsumers assistantContentConsumers = new AssistantContentSimpleConsumers(); + private static final Logger log = LoggerFactory.getLogger(SessionEventSimpleConsumers.class); + + /** + * Sets the assistant content consumers. + * + * @param assistantContentConsumers The assistant content consumers + * @return This instance for method chaining + */ + public SessionEventSimpleConsumers setAssistantContentConsumer(AssistantContentConsumers assistantContentConsumers) { + Validate.notNull(assistantContentConsumers, "assistantContentConsumers can't be null"); + this.assistantContentConsumers = assistantContentConsumers; + return this; + } + + /** + * Gets the assistant content consumers. + * + * @return The assistant content consumers + */ + public AssistantContentConsumers getAssistantContentConsumers() { + return assistantContentConsumers; + } +} diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/ThreadPoolConfig.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/ThreadPoolConfig.java index 8213cdef8..78e58a92e 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/ThreadPoolConfig.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/ThreadPoolConfig.java @@ -17,7 +17,7 @@ import java.util.function.Supplier; */ public class ThreadPoolConfig { private static final ThreadPoolExecutor defaultExecutor = new ThreadPoolExecutor( - 10, 30, 60L, TimeUnit.SECONDS, + 30, 100, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(300), new ThreadFactory() { private final AtomicInteger threadNumber = new AtomicInteger(1); diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/Timeout.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/Timeout.java index e221cddbb..089752723 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/Timeout.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/utils/Timeout.java @@ -55,6 +55,12 @@ public class Timeout { * A timeout of 60 seconds. */ public static final Timeout TIMEOUT_60_SECONDS = new Timeout(60L, TimeUnit.SECONDS); + + /** + * A timeout of 180 seconds. + */ + public static final Timeout TIMEOUT_180_SECONDS = new Timeout(180L, TimeUnit.SECONDS); + /** * A timeout of 30 minutes. */ diff --git a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/example/QuickStartExample.java b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/example/QuickStartExample.java new file mode 100644 index 000000000..2a7a20dc6 --- /dev/null +++ b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/example/QuickStartExample.java @@ -0,0 +1,109 @@ +package com.alibaba.qwen.code.cli.example; + +import com.alibaba.qwen.code.cli.QwenCodeCli; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.TextAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ThingkingAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolResultAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolUseAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantUsage; +import com.alibaba.qwen.code.cli.protocol.data.PermissionMode; +import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior.Operation; +import com.alibaba.qwen.code.cli.session.Session; +import com.alibaba.qwen.code.cli.session.event.consumers.AssistantContentSimpleConsumers; +import com.alibaba.qwen.code.cli.transport.TransportOptions; +import com.alibaba.qwen.code.cli.utils.Timeout; + +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.TimeUnit; + +public class QuickStartExample { + private static final Logger logger = LoggerFactory.getLogger(QuickStartExample.class); + + public static void main(String[] args) { + logger.info("runSimpleExample started.{}", StringUtils.repeat("=", 150)); + runSimpleExample(); + + logger.info("runTransportOptionsExample started. {}", StringUtils.repeat("=", 150)); + runTransportOptionsExample(); + + logger.info("runStreamingExample started. {}", StringUtils.repeat("=", 150)); + runStreamingExample(); + + System.exit(0); + } + + /** + * Simple example showing basic query usage + */ + public static void runSimpleExample() { + List result = QwenCodeCli.simpleQuery("hello world"); + result.forEach(logger::info); + } + + /** + * TransportOptions example showing comprehensive transport options configuration + */ + public static void runTransportOptionsExample() { + TransportOptions options = new TransportOptions() + .setModel("qwen3-coder-flash") + .setPermissionMode(PermissionMode.AUTO_EDIT) + .setCwd("./") + .setEnv(new HashMap() {{put("CUSTOM_VAR", "value");}}) + .setIncludePartialMessages(true) + .setTurnTimeout(new Timeout(120L, TimeUnit.SECONDS)) + .setMessageTimeout(new Timeout(90L, TimeUnit.SECONDS)) + .setAllowedTools(Arrays.asList("read_file", "write_file", "list_directory")); + + List result = QwenCodeCli.simpleQuery("who are you, what are your capabilities?", options); + result.forEach(logger::info); + } + + /** + * Streaming example showing simple query usage + */ + public static void runStreamingExample() { + QwenCodeCli.simpleQuery("who are you, what are your capabilities?", + new TransportOptions().setMessageTimeout(new Timeout(10L, TimeUnit.SECONDS)), new AssistantContentSimpleConsumers() { + + @Override + public void onText(Session session, TextAssistantContent textAssistantContent) { + logger.info("Text content received: {}", textAssistantContent.getText()); + } + + @Override + public void onThinking(Session session, ThingkingAssistantContent thingkingAssistantContent) { + logger.info("Thinking content received: {}", thingkingAssistantContent.getThinking()); + } + + @Override + public void onToolUse(Session session, ToolUseAssistantContent toolUseContent) { + logger.info("Tool use content received: {} with arguments: {}", + toolUseContent, toolUseContent.getInput()); + } + + @Override + public void onToolResult(Session session, ToolResultAssistantContent toolResultContent) { + logger.info("Tool result content received: {}", toolResultContent.getContent()); + } + + @Override + public void onOtherContent(Session session, AssistantContent other) { + logger.info("Other content received: {}", other); + } + + @Override + public void onUsage(Session session, AssistantUsage assistantUsage) { + logger.info("Usage information received: Input tokens: {}, Output tokens: {}", + assistantUsage.getUsage().getInputTokens(), assistantUsage.getUsage().getOutputTokens()); + } + }.setDefaultPermissionOperation(Operation.allow)); + logger.info("Streaming example completed."); + } +} diff --git a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/example/SessionExample.java b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/example/SessionExample.java new file mode 100644 index 000000000..fdbc58bdf --- /dev/null +++ b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/example/SessionExample.java @@ -0,0 +1,256 @@ +package com.alibaba.qwen.code.cli.example; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.qwen.code.cli.QwenCodeCli; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.TextAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ThingkingAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolResultAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolUseAssistantContent; +import com.alibaba.qwen.code.cli.protocol.data.AssistantUsage; +import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior.Operation; +import com.alibaba.qwen.code.cli.protocol.message.SDKResultMessage; +import com.alibaba.qwen.code.cli.protocol.message.SDKSystemMessage; +import com.alibaba.qwen.code.cli.protocol.message.SDKUserMessage; +import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKPartialAssistantMessage; +import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlRequest; +import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlResponse; +import com.alibaba.qwen.code.cli.protocol.message.control.payload.ControlRequestPayload; +import com.alibaba.qwen.code.cli.protocol.message.control.payload.ControlResponsePayload; +import com.alibaba.qwen.code.cli.session.Session; +import com.alibaba.qwen.code.cli.session.event.consumers.AssistantContentSimpleConsumers; +import com.alibaba.qwen.code.cli.session.event.consumers.SessionEventSimpleConsumers; +import com.alibaba.qwen.code.cli.protocol.data.PermissionMode; +import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKAssistantMessage; +import com.alibaba.qwen.code.cli.protocol.message.assistant.block.TextBlock; +import com.alibaba.qwen.code.cli.session.exception.SessionControlException; + +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Optional; + +public class SessionExample { + private static final Logger logger = LoggerFactory.getLogger(SessionExample.class); + + public static void main(String[] args) { + Session session = QwenCodeCli.newSession(); + try { + logger.info("runPermissionModeExample started {}", StringUtils.repeat("=", 150)); + runPermissionModeExample(session); + + logger.info("runSetModelExample started {}", StringUtils.repeat("=", 150)); + runSetModelExample(session); + + logger.info("runSetPermissionModeExample started {}", StringUtils.repeat("=", 150)); + runSetPermissionModeExample(session); + + logger.info("runInterruptExample started {}", StringUtils.repeat("=", 150)); + runInterruptExample(session); + + logger.info("runSetModelExample started {}", StringUtils.repeat("=", 150)); + runSetModelExample(session); + + logger.info("runPromptUseLowLevelEventExample started {}", StringUtils.repeat("=", 150)); + runPromptUseLowLevelEventExample(session); + + logger.info("runPromptUseHighLevelEventExample started {}", StringUtils.repeat("=", 150)); + runPromptUseHighLevelEventExample(session); + + System.exit(0); + } finally { + try { + session.close(); + } catch (SessionControlException e) { + logger.error("Error closing session", e); + } + } + } + + /** + * Example showing how to set different permission modes + */ + public static void runPermissionModeExample(Session session) { + try { + logger.info(session.setPermissionMode(PermissionMode.PLAN).map(s -> s ? "Permission mode set to PLAN" : "Permission mode set error") + .orElse("Permission mode set unknown")); + } catch (SessionControlException e) { + logger.error("Error setting permission mode", e); + } + } + + /** + * Example showing how to interrupt a running prompt + */ + public static void runInterruptExample(Session session) { + try { + session.sendPrompt("Analyze this large codebase...", new SessionEventSimpleConsumers() { + @Override + public void onAssistantMessage(Session session, SDKAssistantMessage assistantMessage) { + String message = assistantMessage.getMessage().getContent().stream() + .findFirst() + .filter(content -> content instanceof TextBlock) + .map(content -> ((TextBlock) content).getText()) + .orElse(""); + logger.info("Received: {}", message); + + // Interrupt the session after receiving the first message + try { + Optional interruptResult = session.interrupt(); + logger.info("{}", interruptResult.map(s -> s ? "Interrupt successful" : "Interrupt error") + .orElse("Interrupt unknown")); + } catch (SessionControlException e) { + logger.error("Interrupt error: {}", e.getMessage(), e); + } + } + }); + } catch (Exception e) { + logger.error("An error occurred while sending the prompt", e); + } + } + + /** + * Example showing how to dynamically change the AI model during a session + */ + public static void runSetModelExample(Session session) { + try { + // Switch to a specific model + Optional modelChangeResult = session.setModel("qwen3-coder-flash"); + logger.info("{}", modelChangeResult.map(s -> s ? "setModel success" : "setModel error") + .orElse("setModel unknown")); + + // Use the model for a prompt + session.sendPrompt("hello world", new SessionEventSimpleConsumers()); + + // Switch to another model + Optional modelChangeResult2 = session.setModel("qwen3-coder-plus"); + logger.info("{}", modelChangeResult2.map(s -> s ? "setModel success" : "setModel error") + .orElse("setModel unknown")); + + // Use the new model for another prompt + session.sendPrompt("list files in the current directory", new SessionEventSimpleConsumers()); + } catch (Exception e) { + logger.error("An error occurred while changing model or sending prompt", e); + } + } + + /** + * Example showing how to dynamically change permission mode during a session + */ + public static void runSetPermissionModeExample(Session session) { + try { + // Switch to a permissive mode + Optional permissionChangeResult = session.setPermissionMode(PermissionMode.YOLO); + logger.info("{}", permissionChangeResult.map(s -> s ? "setPermissionMode success" : "setPermissionMode error") + .orElse("setPermissionMode unknown")); + + // Use the session with the new permission mode + session.sendPrompt("in the dir src/test/temp/, create file empty file test.touch", new SessionEventSimpleConsumers()); + + // Switch to another permission mode + Optional permissionChangeResult2 = session.setPermissionMode(PermissionMode.PLAN); + logger.info("{}", permissionChangeResult2.map(s -> s ? "setPermissionMode success" : "setPermissionMode error") + .orElse("setPermissionMode unknown")); + + // Use the session with the new permission mode + session.sendPrompt("rename test.touch to test_rename.touch", new SessionEventSimpleConsumers()); + } catch (Exception e) { + logger.error("An error occurred while changing permission mode or sending prompt", e); + } + } + + public static void runPromptUseLowLevelEventExample(Session session) { + try { + session.setPermissionMode(PermissionMode.YOLO); + session.sendPrompt("devlop Fibonacci function by python", new SessionEventSimpleConsumers() { + @Override + public void onAssistantMessage(Session session, SDKAssistantMessage assistantMessage) { + logger.info("Received assistantMessage {}", JSON.toJSONString(assistantMessage)); + } + + @Override + public void onPartialAssistantMessage(Session session, SDKPartialAssistantMessage partialAssistantMessage) { + logger.info("Received partialAssistantMessage {}", JSON.toJSONString(partialAssistantMessage)); + } + + @Override + public void onUserMessage(Session session, SDKUserMessage userMessage) { + logger.info("Received userMessage {}", JSON.toJSONString(userMessage)); + } + + @Override + public void onOtherMessage(Session session, String message) { + logger.info("Received otherMessage {}", message); + } + + @Override + public void onControlResponse(Session session, CLIControlResponse cliControlResponse) { + logger.info("Received controlResponse {}", JSON.toJSONString(cliControlResponse)); + } + + @Override + public CLIControlResponse onControlRequest(Session session, CLIControlRequest cliControlRequest) { + logger.info("Received controlRequest {}", JSON.toJSONString(cliControlRequest)); + return new CLIControlResponse<>(); + } + + @Override + public void onResultMessage(Session session, SDKResultMessage resultMessage) { + logger.info("Received resultMessage {}", JSON.toJSONString(resultMessage)); + } + + @Override + public void onSystemMessage(Session session, SDKSystemMessage systemMessage) { + logger.info("Received systemMessage {}", JSON.toJSONString(systemMessage)); + } + }); + } catch (Exception e) { + logger.error("An error occurred while sending prompt", e); + } + } + + public static void runPromptUseHighLevelEventExample(Session session) { + try { + session.sendPrompt("devlop Fibonacci function by python", new SessionEventSimpleConsumers().setAssistantContentConsumer(new AssistantContentSimpleConsumers(){ + @Override + public void onText(Session session, TextAssistantContent textAssistantContent) { + logger.info("Received textAssistantContent {}", textAssistantContent.getText()); + } + + @Override + public void onThinking(Session session, ThingkingAssistantContent thingkingAssistantContent) { + logger.info("Received thingkingAssistantContent {}", thingkingAssistantContent.getThinking()); + } + + @Override + public void onToolUse(Session session, ToolUseAssistantContent toolUseAssistantContent) { + logger.info("Received toolUseAssistantContent {}", toolUseAssistantContent.getInput()); + } + + @Override + public void onToolResult(Session session, ToolResultAssistantContent toolResultAssistantContent) { + logger.info("Received toolResultAssistantContent {}", toolResultAssistantContent.getContent()); + } + + @Override + public void onOtherContent(Session session, AssistantContent other) { + logger.info("Received other {}", other); + } + + @Override + public void onUsage(Session session, AssistantUsage assistantUsage) { + logger.info("Received usage {}", assistantUsage); + } + + @Override + public ControlResponsePayload onOtherControlRequest(Session session, ControlRequestPayload requestPayload) { + logger.info("Received otherControlRequest {}", requestPayload); + return new ControlResponsePayload(); + } + }.setDefaultPermissionOperation(Operation.allow))); + } catch (Exception e) { + logger.error("An error occurred while sending prompt", e); + } + } +} diff --git a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/example/ThreadPoolConfigurationExample.java b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/example/ThreadPoolConfigurationExample.java new file mode 100644 index 000000000..0a77a03ad --- /dev/null +++ b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/example/ThreadPoolConfigurationExample.java @@ -0,0 +1,50 @@ +package com.alibaba.qwen.code.cli.example; + +import com.alibaba.qwen.code.cli.QwenCodeCli; +import com.alibaba.qwen.code.cli.session.Session; +import com.alibaba.qwen.code.cli.utils.ThreadPoolConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +public class ThreadPoolConfigurationExample { + private static final Logger logger = LoggerFactory.getLogger(ThreadPoolConfigurationExample.class); + + public static void main(String[] args) { + runModifyDefaultExample(); + runCustomSupplierExample(); + } + + /** + * Example showing how to set a custom thread pool supplier + */ + public static void runCustomSupplierExample() { + // Set a custom thread pool supplier + ThreadPoolConfig.setExecutorSupplier(() -> (ThreadPoolExecutor) Executors.newFixedThreadPool(20)); + logger.info("Custom thread pool supplier set"); + } + + /** + * Example showing how to modify properties of the default thread pool + */ + public static void runModifyDefaultExample() { + // Get the default executor and modify its properties + ThreadPoolExecutor executor = ThreadPoolConfig.getDefaultExecutor(); + + // Modify the core pool size + executor.setCorePoolSize(15); + + // Modify the maximum pool size + executor.setMaximumPoolSize(40); + + // Modify the keep-alive time + executor.setKeepAliveTime(120, TimeUnit.SECONDS); + + logger.info("Default thread pool properties modified"); + + // The SDK will now use the modified executor for all operations + Session session = QwenCodeCli.newSession(); + } +} diff --git a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java index a6463d41d..f17060c1e 100644 --- a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java +++ b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java @@ -11,18 +11,14 @@ import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.TextAssistantCon import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ThingkingAssistantContent; import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolResultAssistantContent; import com.alibaba.qwen.code.cli.protocol.data.AssistantContent.ToolUseAssistantContent; -import com.alibaba.qwen.code.cli.protocol.data.behavior.Allow; -import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior; import com.alibaba.qwen.code.cli.protocol.data.behavior.Behavior.Operation; import com.alibaba.qwen.code.cli.protocol.message.SDKResultMessage; import com.alibaba.qwen.code.cli.protocol.message.SDKSystemMessage; import com.alibaba.qwen.code.cli.protocol.message.assistant.SDKAssistantMessage; -import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlPermissionRequest; -import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlRequest; import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlResponse; -import com.alibaba.qwen.code.cli.session.event.AssistantContentConsumers; -import com.alibaba.qwen.code.cli.session.event.SessionEventConsumers; -import com.alibaba.qwen.code.cli.session.event.SessionEventSimpleConsumers; +import com.alibaba.qwen.code.cli.session.event.consumers.AssistantContentSimpleConsumers; +import com.alibaba.qwen.code.cli.session.event.consumers.SessionEventConsumers; +import com.alibaba.qwen.code.cli.session.event.consumers.SessionEventSimpleConsumers; import com.alibaba.qwen.code.cli.session.exception.SessionControlException; import com.alibaba.qwen.code.cli.session.exception.SessionSendPromptException; import com.alibaba.qwen.code.cli.transport.TransportOptions; @@ -41,7 +37,7 @@ class SessionTest { void partialSendPromptSuccessfully() throws SessionControlException, SessionSendPromptException { Session session = QwenCodeCli.newSession(new TransportOptions().setIncludePartialMessages(true)); session.sendPrompt("in the dir src/test/temp/, create file empty file test.touch", new SessionEventSimpleConsumers() { - }.setDefaultPermissionOperation(Operation.allow).setBlockConsumer(new AssistantContentConsumers() { + }.setAssistantContentConsumer(new AssistantContentSimpleConsumers() { @Override public void onText(Session session, TextAssistantContent textAssistantContent) { log.info("receive textAssistantContent {}", textAssistantContent); @@ -70,7 +66,7 @@ class SessionTest { public void onUsage(Session session, AssistantUsage assistantUsage) { log.info("receive assistantUsage {}", assistantUsage); } - })); + }.setDefaultPermissionOperation(Operation.allow))); } @Test @@ -89,12 +85,8 @@ class SessionTest { .orElse("setPermissionMode 3 unknown")); session.sendPrompt("rename test.touch to test_rename.touch", new SessionEventSimpleConsumers()); - session.sendPrompt("rename test.touch to test_rename.touch again user will allow", new SessionEventSimpleConsumers() { - public Behavior onPermissionRequest(Session session, CLIControlRequest permissionRequest) { - log.info("permissionRequest: {}", permissionRequest); - return new Allow().setUpdatedInput(permissionRequest.getRequest().getInput()); - } - }); + session.sendPrompt("rename test.touch to test_rename.touch again user will allow", + new SessionEventSimpleConsumers().setAssistantContentConsumer(new AssistantContentSimpleConsumers().setDefaultPermissionOperation(Operation.allow))); session.close(); } @@ -112,13 +104,13 @@ class SessionTest { log.info(session.setModel("qwen3-coder-plus").map(s -> s ? "setModel 2 success" : "setModel 2 error").orElse("setModel 2 unknown")); writeSplitLine("setModel 1 end"); - session.sendPrompt("查看下当前目录有多少个文件", new SessionEventSimpleConsumers()); + session.sendPrompt("Check how many files are in the current directory", new SessionEventSimpleConsumers()); writeSplitLine("prompt 2 end"); log.info(session.setModel("qwen3-max").map(s -> s ? "setModel 3 success" : "setModel 3 error").orElse("setModel 3 unknown")); writeSplitLine("setModel 1 end"); - session.sendPrompt("查看下当前目录有多少个xml文件", new SessionEventSimpleConsumers()); + session.sendPrompt("Check how many xml files are in the current directory", new SessionEventSimpleConsumers()); writeSplitLine("prompt 3 end"); session.close(); @@ -159,13 +151,9 @@ class SessionTest { public void onOtherMessage(Session session, String message) { log.info("otherMessage: {}", message); } - - @Override - public Timeout onPermissionRequestTimeout(Session session) { - return Timeout.TIMEOUT_30_MINUTES; - } }.setDefaultEventTimeout(new Timeout(90L, TimeUnit.SECONDS)); - session.sendPrompt("查看下当前目录有多少个文件", sessionEventConsumers); + + session.sendPrompt("Check how many files are in the current directory", sessionEventConsumers); writeSplitLine("prompt 1 end"); session.continueSession(); diff --git a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransportTest.java b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransportTest.java index 721b203db..2dd30ae6c 100644 --- a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransportTest.java +++ b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransportTest.java @@ -7,8 +7,8 @@ import java.util.concurrent.TimeoutException; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.TypeReference; -import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlInitializeRequest; -import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlInitializeResponse; +import com.alibaba.qwen.code.cli.protocol.message.control.payload.CLIControlInitializeRequest; +import com.alibaba.qwen.code.cli.protocol.message.control.payload.CLIControlInitializeResponse; import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlRequest; import com.alibaba.qwen.code.cli.protocol.message.control.CLIControlResponse; import com.alibaba.qwen.code.cli.protocol.message.SDKUserMessage; From 24edf32da83e49b732a3223cf14caad71ace9bc4 Mon Sep 17 00:00:00 2001 From: skyfire Date: Mon, 5 Jan 2026 17:46:18 +0800 Subject: [PATCH 048/142] for README.md --- packages/sdk-java/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/sdk-java/README.md b/packages/sdk-java/README.md index e9987d946..50f8ef627 100644 --- a/packages/sdk-java/README.md +++ b/packages/sdk-java/README.md @@ -13,7 +13,6 @@ The Qwen Code Java SDK is a minimum experimental SDK for programmatic access to - **Utilities**: org.apache.commons:commons-lang3 - **JSON Processing**: com.alibaba.fastjson2:fastjson2 - **Testing**: JUnit 5 (org.junit.jupiter:junit-jupiter) -- ## Installation From 2b6218e564fa903db1957f4ed84e3e8b4ce46735 Mon Sep 17 00:00:00 2001 From: skyfire Date: Mon, 5 Jan 2026 17:49:43 +0800 Subject: [PATCH 049/142] for README.md --- packages/sdk-java/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/sdk-java/README.md b/packages/sdk-java/README.md index 50f8ef627..fb1bcd472 100644 --- a/packages/sdk-java/README.md +++ b/packages/sdk-java/README.md @@ -123,6 +123,8 @@ public static void runStreamingExample() { } ``` +other examples see src/test/java/com/alibaba/qwen/code/cli/example + ## Architecture The SDK follows a layered architecture: From 96080f84a631e3405c283f521a79179b9d3c34da Mon Sep 17 00:00:00 2001 From: skyfire Date: Mon, 5 Jan 2026 18:00:38 +0800 Subject: [PATCH 050/142] for README.md --- .../AssistantContentSimpleConsumers.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/AssistantContentSimpleConsumers.java b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/AssistantContentSimpleConsumers.java index 40c941e32..b5a158fed 100644 --- a/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/AssistantContentSimpleConsumers.java +++ b/packages/sdk-java/src/main/java/com/alibaba/qwen/code/cli/session/event/consumers/AssistantContentSimpleConsumers.java @@ -16,6 +16,9 @@ import com.alibaba.qwen.code.cli.protocol.message.control.payload.ControlRespons import com.alibaba.qwen.code.cli.session.Session; import com.alibaba.qwen.code.cli.utils.Timeout; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + /** * Simple implementation of AssistantContentConsumers that provides empty implementations for all methods. * @@ -28,6 +31,7 @@ public class AssistantContentSimpleConsumers implements AssistantContentConsumer */ @Override public void onText(Session session, TextAssistantContent textAssistantContent) { + log.debug("Received textAssistantContent {}", textAssistantContent.getText()); } /** @@ -35,6 +39,7 @@ public class AssistantContentSimpleConsumers implements AssistantContentConsumer */ @Override public void onThinking(Session session, ThingkingAssistantContent thingkingAssistantContent) { + log.debug("Received thingkingAssistantContent {}", thingkingAssistantContent.getThinking()); } /** @@ -42,6 +47,7 @@ public class AssistantContentSimpleConsumers implements AssistantContentConsumer */ @Override public void onToolUse(Session session, ToolUseAssistantContent toolUseAssistantContent) { + log.debug("Received toolUseAssistantContent {}", toolUseAssistantContent.getInput()); } /** @@ -49,6 +55,9 @@ public class AssistantContentSimpleConsumers implements AssistantContentConsumer */ @Override public void onToolResult(Session session, ToolResultAssistantContent toolResultAssistantContent) { + if (log.isDebugEnabled()) { + log.debug("Received toolResultAssistantContent {}", toolResultAssistantContent); + } } /** @@ -56,6 +65,9 @@ public class AssistantContentSimpleConsumers implements AssistantContentConsumer */ @Override public void onOtherContent(Session session, AssistantContent other) { + if (log.isDebugEnabled()) { + log.debug("Received other content {}", other); + } } /** @@ -64,8 +76,10 @@ public class AssistantContentSimpleConsumers implements AssistantContentConsumer @Override public Behavior onPermissionRequest(Session session, CLIControlPermissionRequest permissionRequest) { if (Operation.deny.equals(this.defaultPermissionOperation)) { + log.info("use defaultPermissionOperation Permission denied."); return new Deny().setMessage("Permission denied."); } else { + log.info("use defaultPermissionOperation Permission allowed."); return new Allow().setUpdatedInput(permissionRequest.getInput()); } } @@ -80,6 +94,7 @@ public class AssistantContentSimpleConsumers implements AssistantContentConsumer */ @Override public void onUsage(Session session, AssistantUsage AssistantUsage) { + log.info("received usage {} of message {}", AssistantUsage.getUsage(), AssistantUsage.getMessageId()); } /** @@ -173,4 +188,6 @@ public class AssistantContentSimpleConsumers implements AssistantContentConsumer * The default event timeout. */ protected Timeout defaultEventTimeout = Timeout.TIMEOUT_60_SECONDS; + + private static final Logger log = LoggerFactory.getLogger(AssistantContentSimpleConsumers.class); } From d2d2b845c5ec021388fd98c870beb35463cd2efe Mon Sep 17 00:00:00 2001 From: skyfire Date: Mon, 5 Jan 2026 18:12:48 +0800 Subject: [PATCH 051/142] for README.md --- packages/sdk-java/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/sdk-java/README.md b/packages/sdk-java/README.md index fb1bcd472..772c93742 100644 --- a/packages/sdk-java/README.md +++ b/packages/sdk-java/README.md @@ -6,6 +6,7 @@ The Qwen Code Java SDK is a minimum experimental SDK for programmatic access to - Java >= 1.8 - Maven >= 3.6.0 (for building from source) +- qwen-code >= 0.5.0 ### Dependencies From 7dc7c6380d5820401c852ae8d0d2dc04a48da1e1 Mon Sep 17 00:00:00 2001 From: skyfire Date: Mon, 5 Jan 2026 18:14:40 +0800 Subject: [PATCH 052/142] for pom --- packages/sdk-java/pom.xml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/sdk-java/pom.xml b/packages/sdk-java/pom.xml index c11385395..1614239d4 100644 --- a/packages/sdk-java/pom.xml +++ b/packages/sdk-java/pom.xml @@ -5,7 +5,7 @@ com.alibaba qwencode-sdk jar - 0.0.1-SNAPSHOT + 0.0.1 qwencode-sdk https://maven.apache.org @@ -179,10 +179,8 @@ - - - snapshots - http://mvnrepo.alibaba-inc.com/mvn/snapshots + central + https://central.sonatype.com/repository/maven-snapshots/ From a4eb3adea867862341825b6812ea13721fdfc427 Mon Sep 17 00:00:00 2001 From: skyfire Date: Mon, 5 Jan 2026 19:22:50 +0800 Subject: [PATCH 053/142] for pom --- packages/sdk-java/pom.xml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/sdk-java/pom.xml b/packages/sdk-java/pom.xml index 1614239d4..a6825ec7e 100644 --- a/packages/sdk-java/pom.xml +++ b/packages/sdk-java/pom.xml @@ -5,7 +5,7 @@ com.alibaba qwencode-sdk jar - 0.0.1 + 0.0.1-alpha1 qwencode-sdk https://maven.apache.org @@ -182,5 +182,9 @@ central https://central.sonatype.com/repository/maven-snapshots/ + + central + https://central.sonatype.com/service/local/staging/deploy/maven2/ + From 19f8f631b49030c61cee986fcb81d50e359c0f58 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Mon, 5 Jan 2026 19:28:52 +0800 Subject: [PATCH 054/142] Respect 'tools.core' and 'tools.allowed' settings in non-interactive mode, for both agent execution and custom command --- packages/cli/src/config/config.test.ts | 52 +++++++++++++++++++ packages/cli/src/config/config.ts | 39 +++++++++++--- .../prompt-processors/shellProcessor.test.ts | 30 +++++++++++ .../prompt-processors/shellProcessor.ts | 24 +++++++-- packages/core/src/core/coreToolScheduler.ts | 1 - packages/core/src/index.ts | 1 + 6 files changed, 137 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 0b95f7857..6f2019e75 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -1597,6 +1597,58 @@ describe('Approval mode tool exclusion logic', () => { expect(excludedTools).toContain(WriteFileTool.Name); }); + it('should not exclude a tool explicitly allowed in tools.allowed', async () => { + process.argv = ['node', 'script.js', '-p', 'test']; + const argv = await parseArguments({} as Settings); + const settings: Settings = { + tools: { + allowed: [ShellTool.Name], + }, + }; + const extensions: Extension[] = []; + + const config = await loadCliConfig( + settings, + extensions, + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + argv, + ); + + const excludedTools = config.getExcludeTools(); + expect(excludedTools).not.toContain(ShellTool.Name); + expect(excludedTools).toContain(EditTool.Name); + expect(excludedTools).toContain(WriteFileTool.Name); + }); + + it('should not exclude a tool explicitly allowed in tools.core', async () => { + process.argv = ['node', 'script.js', '-p', 'test']; + const argv = await parseArguments({} as Settings); + const settings: Settings = { + tools: { + core: [ShellTool.Name], + }, + }; + const extensions: Extension[] = []; + + const config = await loadCliConfig( + settings, + extensions, + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + argv, + ); + + const excludedTools = config.getExcludeTools(); + expect(excludedTools).not.toContain(ShellTool.Name); + expect(excludedTools).toContain(EditTool.Name); + expect(excludedTools).toContain(WriteFileTool.Name); + }); + it('should exclude only shell tools in non-interactive mode with auto-edit approval mode', async () => { process.argv = [ 'node', diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 7cd7d685a..de4c30fdc 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -10,22 +10,24 @@ import { Config, DEFAULT_QWEN_EMBEDDING_MODEL, DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, - EditTool, FileDiscoveryService, getCurrentGeminiMdFilename, loadServerHierarchicalMemory, setGeminiMdFilename as setServerGeminiMdFilename, - ShellTool, - WriteFileTool, resolveTelemetrySettings, FatalConfigError, Storage, InputFormat, OutputFormat, + isToolEnabled, SessionService, type ResumedSessionData, type FileFilteringOptions, type MCPServerConfig, + type ToolName, + EditTool, + ShellTool, + WriteFileTool, } from '@qwen-code/qwen-code-core'; import { extensionsCommand } from '../commands/extensions.js'; import type { Settings } from './settings.js'; @@ -818,6 +820,28 @@ export async function loadCliConfig( // However, if stream-json input is used, control can be requested via JSON messages, // so tools should not be excluded in that case. const extraExcludes: string[] = []; + const resolvedCoreTools = argv.coreTools || settings.tools?.core || []; + const resolvedAllowedTools = + argv.allowedTools || settings.tools?.allowed || []; + const isExplicitlyEnabled = (toolName: ToolName): boolean => { + if (resolvedCoreTools.length > 0) { + if (isToolEnabled(toolName, resolvedCoreTools, [])) { + return true; + } + } + if (resolvedAllowedTools.length > 0) { + if (isToolEnabled(toolName, resolvedAllowedTools, [])) { + return true; + } + } + return false; + }; + const excludeUnlessExplicit = (toolName: ToolName): void => { + if (!isExplicitlyEnabled(toolName)) { + extraExcludes.push(toolName); + } + }; + if ( !interactive && !argv.experimentalAcp && @@ -826,12 +850,15 @@ export async function loadCliConfig( switch (approvalMode) { case ApprovalMode.PLAN: case ApprovalMode.DEFAULT: - // In default non-interactive mode, all tools that require approval are excluded. - extraExcludes.push(ShellTool.Name, EditTool.Name, WriteFileTool.Name); + // In default non-interactive mode, all tools that require approval are excluded, + // unless explicitly enabled via coreTools/allowedTools. + excludeUnlessExplicit(ShellTool.Name as ToolName); + excludeUnlessExplicit(EditTool.Name as ToolName); + excludeUnlessExplicit(WriteFileTool.Name as ToolName); break; case ApprovalMode.AUTO_EDIT: // In auto-edit non-interactive mode, only tools that still require a prompt are excluded. - extraExcludes.push(ShellTool.Name); + excludeUnlessExplicit(ShellTool.Name as ToolName); break; case ApprovalMode.YOLO: // No extra excludes for YOLO mode. diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts index cbfca4d2d..151faf324 100644 --- a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts +++ b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts @@ -72,6 +72,7 @@ describe('ShellProcessor', () => { getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getShouldUseNodePtyShell: vi.fn().mockReturnValue(false), getShellExecutionConfig: vi.fn().mockReturnValue({}), + getAllowedTools: vi.fn().mockReturnValue([]), }; context = createMockCommandContext({ @@ -196,6 +197,35 @@ describe('ShellProcessor', () => { ); }); + it('should NOT throw ConfirmationRequiredError when a command matches allowedTools', async () => { + const processor = new ShellProcessor('test-command'); + const prompt: PromptPipelineContent = createPromptPipelineContent( + 'Do something dangerous: !{rm -rf /}', + ); + mockCheckCommandPermissions.mockReturnValue({ + allAllowed: false, + disallowedCommands: ['rm -rf /'], + }); + (mockConfig.getAllowedTools as Mock).mockReturnValue([ + 'ShellTool(rm -rf /)', + ]); + mockShellExecute.mockReturnValue({ + result: Promise.resolve({ ...SUCCESS_RESULT, output: 'deleted' }), + }); + + const result = await processor.process(prompt, context); + + expect(mockShellExecute).toHaveBeenCalledWith( + 'rm -rf /', + expect.any(String), + expect.any(Function), + expect.any(Object), + false, + expect.any(Object), + ); + expect(result).toEqual([{ text: 'Do something dangerous: deleted' }]); + }); + it('should NOT throw ConfirmationRequiredError if a command is not allowed but approval mode is YOLO', async () => { const processor = new ShellProcessor('test-command'); const prompt: PromptPipelineContent = createPromptPipelineContent( diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.ts b/packages/cli/src/services/prompt-processors/shellProcessor.ts index c10526e62..2a6df7161 100644 --- a/packages/cli/src/services/prompt-processors/shellProcessor.ts +++ b/packages/cli/src/services/prompt-processors/shellProcessor.ts @@ -7,11 +7,13 @@ import { ApprovalMode, checkCommandPermissions, + doesToolInvocationMatch, escapeShellArg, getShellConfiguration, ShellExecutionService, flatMapTextParts, } from '@qwen-code/qwen-code-core'; +import type { AnyToolInvocation } from '@qwen-code/qwen-code-core'; import type { CommandContext } from '../../ui/commands/types.js'; import type { IPromptProcessor, PromptPipelineContent } from './types.js'; @@ -124,6 +126,15 @@ export class ShellProcessor implements IPromptProcessor { // Security check on the final, escaped command string. const { allAllowed, disallowedCommands, blockReason, isHardDenial } = checkCommandPermissions(command, config, sessionShellAllowlist); + const allowedTools = config.getAllowedTools() || []; + const invocation = { + params: { command }, + } as AnyToolInvocation; + const isAllowedBySettings = doesToolInvocationMatch( + 'run_shell_command', + invocation, + allowedTools, + ); if (!allAllowed) { if (isHardDenial) { @@ -132,10 +143,17 @@ export class ShellProcessor implements IPromptProcessor { ); } - // If not a hard denial, respect YOLO mode and auto-approve. - if (config.getApprovalMode() !== ApprovalMode.YOLO) { - disallowedCommands.forEach((uc) => commandsToConfirm.add(uc)); + // If the command is allowed by settings, skip confirmation. + if (isAllowedBySettings) { + continue; } + + // If not a hard denial, respect YOLO mode and auto-approve. + if (config.getApprovalMode() === ApprovalMode.YOLO) { + continue; + } + + disallowedCommands.forEach((uc) => commandsToConfirm.add(uc)); } } diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index aeffdfc78..c7e2806ac 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -824,7 +824,6 @@ export class CoreToolScheduler { */ const shouldAutoDeny = !this.config.isInteractive() && - !this.config.getIdeMode() && !this.config.getExperimentalZedIntegration() && this.config.getInputFormat() !== InputFormat.STREAM_JSON; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 56680403b..7f7bd115b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -38,6 +38,7 @@ export * from './utils/quotaErrorDetection.js'; export * from './utils/fileUtils.js'; export * from './utils/retry.js'; export * from './utils/shell-utils.js'; +export * from './utils/tool-utils.js'; export * from './utils/terminalSerializer.js'; export * from './utils/systemEncoding.js'; export * from './utils/textUtils.js'; From e8625658ba46901bbd6fba7c8358e86bf7cfaee7 Mon Sep 17 00:00:00 2001 From: skyfire Date: Mon, 5 Jan 2026 20:27:37 +0800 Subject: [PATCH 055/142] publish 0.0.1-alpha --- packages/sdk-java/pom.xml | 7 +++++-- .../com/alibaba/qwen/code/cli/session/SessionTest.java | 2 +- .../code/cli/transport/process/ProcessTransportTest.java | 7 ++++--- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/sdk-java/pom.xml b/packages/sdk-java/pom.xml index a6825ec7e..0c5270d1e 100644 --- a/packages/sdk-java/pom.xml +++ b/packages/sdk-java/pom.xml @@ -5,8 +5,11 @@ com.alibaba qwencode-sdk jar - 0.0.1-alpha1 + 0.0.1-alpha qwencode-sdk + The Qwen Code Java SDK is a minimum experimental SDK for programmatic access to Qwen Code functionality. It provides a Java interface + to interact with the Qwen Code CLI, allowing developers to integrate Qwen Code capabilities into their Java applications. + https://maven.apache.org @@ -184,7 +187,7 @@ central - https://central.sonatype.com/service/local/staging/deploy/maven2/ + https://central.sonatype.org/service/local/staging/deploy/maven2/ diff --git a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java index f17060c1e..0353d0065 100644 --- a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java +++ b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/session/SessionTest.java @@ -161,7 +161,7 @@ class SessionTest { writeSplitLine("prompt 2 end"); session.continueSession(); - session.sendPrompt("当前目录有多少个java文件", sessionEventConsumers); + session.sendPrompt("How many Java files are in the current directory", sessionEventConsumers); writeSplitLine("prompt 3 end"); session.close(); diff --git a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransportTest.java b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransportTest.java index 2dd30ae6c..a23800ada 100644 --- a/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransportTest.java +++ b/packages/sdk-java/src/test/java/com/alibaba/qwen/code/cli/transport/process/ProcessTransportTest.java @@ -63,17 +63,18 @@ class ProcessTransportTest { return "result".equals(JSON.parseObject(line).getString("type")); }); - String userMessage2 = new SDKUserMessage().setSessionId(sessionId).setContent("请使用中文").toString(); + String userMessage2 = new SDKUserMessage().setSessionId(sessionId).setContent("Please respond in Chinese").toString(); transport.inputWaitForMultiLine(userMessage2, line -> { return "result".equals(JSON.parseObject(line).getString("type")); }); - String userMessage3 = new SDKUserMessage().setSessionId(sessionId).setContent("当前工作区有多少个文件").toString(); + + String userMessage3 = new SDKUserMessage().setSessionId(sessionId).setContent("How many files are there in the current workspace").toString(); transport.inputWaitForMultiLine(userMessage3, line -> { return "result".equals(JSON.parseObject(line).getString("type")); }); - String userMessage4 = new SDKUserMessage().setSessionId("session-sec" + UUID.randomUUID()).setContent("有多少个xml文件").toString(); + String userMessage4 = new SDKUserMessage().setSessionId("session-sec" + UUID.randomUUID()).setContent("How many XML files are there").toString(); transport.inputWaitForMultiLine(userMessage4, line -> { return "result".equals(JSON.parseObject(line).getString("type")); }); From 824ca056a4f77cac865712adbe54fcdb46e01b86 Mon Sep 17 00:00:00 2001 From: "Jan-Niklas W." <6104311+niklas-wortmann@users.noreply.github.com> Date: Mon, 5 Jan 2026 14:06:13 -0600 Subject: [PATCH 056/142] docs: add integration guide for JetBrains IDEs --- README.md | 5 +-- docs/users/_meta.ts | 1 + docs/users/integration-jetbrains.md | 55 +++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 docs/users/integration-jetbrains.md diff --git a/README.md b/README.md index 1eee11b43..099d55504 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Qwen Code is an open-source AI agent for the terminal, optimized for [Qwen3-Code - **OpenAI-compatible, OAuth free tier**: use an OpenAI-compatible API, or sign in with Qwen OAuth to get 2,000 free requests/day. - **Open-source, co-evolving**: both the framework and the Qwen3-Coder model are open-source—and they ship and evolve together. - **Agentic workflow, feature-rich**: rich built-in tools (Skills, SubAgents, Plan Mode) for a full agentic workflow and a Claude Code-like experience. -- **Terminal-first, IDE-friendly**: built for developers who live in the command line, with optional integration for VS Code and Zed. +- **Terminal-first, IDE-friendly**: built for developers who live in the command line, with optional integration for VS Code, Zed, and JetBrains IDEs. ## Installation @@ -137,10 +137,11 @@ Use `-p` to run Qwen Code without the interactive UI—ideal for scripts, automa #### IDE integration -Use Qwen Code inside your editor (VS Code and Zed): +Use Qwen Code inside your editor (VS Code, Zed, and JetBrains IDEs): - [Use in VS Code](https://qwenlm.github.io/qwen-code-docs/en/users/integration-vscode/) - [Use in Zed](https://qwenlm.github.io/qwen-code-docs/en/users/integration-zed/) +- [Use in JetBrains IDEs](https://qwenlm.github.io/qwen-code-docs/en/users/integration-jetbrains/) #### TypeScript SDK diff --git a/docs/users/_meta.ts b/docs/users/_meta.ts index a44167cff..2ec43d773 100644 --- a/docs/users/_meta.ts +++ b/docs/users/_meta.ts @@ -12,6 +12,7 @@ export default { }, 'integration-vscode': 'Visual Studio Code', 'integration-zed': 'Zed IDE', + 'integration-jetbrains': 'JetBrains IDEs', 'integration-github-action': 'Github Actions', 'Code with Qwen Code': { type: 'separator', diff --git a/docs/users/integration-jetbrains.md b/docs/users/integration-jetbrains.md new file mode 100644 index 000000000..9584cd097 --- /dev/null +++ b/docs/users/integration-jetbrains.md @@ -0,0 +1,55 @@ +# JetBrains IDEs + +> JetBrains IDEs provide native support for AI coding assistants through the Agent Control Protocol (ACP). This integration allows you to use Qwen Code directly within your JetBrains IDE with real-time code suggestions. + +### Features + +- **Native agent experience**: Integrated AI assistant panel within your JetBrains IDE +- **Agent Control Protocol**: Full support for ACP enabling advanced IDE interactions +- **Symbol management**: #-mention files to add them to the conversation context +- **Conversation history**: Access to past conversations within the IDE + +### Requirements + +- JetBrains IDE with ACP support (IntelliJ IDEA, WebStorm, PyCharm, etc.) +- Qwen Code CLI installed + +### Installation + +1. Install Qwen Code CLI: + + ```bash + npm install -g qwen-code + ``` + +2. Open your JetBrains IDE and navigate to AI Chat tool window. + +3. Click the 3-dot menu in the upper-right corner and select **Configure ACP Agent** and configure Qwen Code with the following settings: + +```json +{ + "agent_servers": { + "qwen": { + "command": "/path/to/qwen", + "args": ["--experimental-acp"], + "env": {} + } + } +} +``` + +4. The Qwen Code agent should now be available in the AI Assistant panel + +## Troubleshooting + +### Agent not appearing + +- Run `qwen --version` in terminal to verify installation +- Ensure your JetBrains IDE version supports ACP +- Restart your JetBrains IDE + +### Qwen Code not responding + +- Check your internet connection +- Verify CLI works by running `qwen` in terminal +- [File an issue on GitHub](https://github.com/qwenlm/qwen-code/issues) if the problem persists From d1a3e828b79e7ecbe0fa13215860b6030f5c2dad Mon Sep 17 00:00:00 2001 From: skyfire Date: Tue, 6 Jan 2026 09:21:58 +0800 Subject: [PATCH 057/142] add license --- packages/sdk-java/LICENSE | 201 ++++++++++++++++++++++++++++++++++++++ packages/sdk-java/QWEN.md | 2 +- 2 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 packages/sdk-java/LICENSE diff --git a/packages/sdk-java/LICENSE b/packages/sdk-java/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/packages/sdk-java/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/sdk-java/QWEN.md b/packages/sdk-java/QWEN.md index fab09cf5c..4fedee46f 100644 --- a/packages/sdk-java/QWEN.md +++ b/packages/sdk-java/QWEN.md @@ -113,7 +113,7 @@ The project uses Checkstyle for code formatting and style enforcement. The confi ### Documentation -- API documentation should follow JavaDoc conventions +- API documentation should follow Javadoc conventions - Update README files when adding new features - Include examples in documentation From 94a5d828bd7857ee46a1919f84aca012d416f90c Mon Sep 17 00:00:00 2001 From: xwj02155382 Date: Tue, 6 Jan 2026 11:05:03 +0800 Subject: [PATCH 058/142] refactor: optimize commandExists with caching and simplify editor command logic - Add caching layer for commandExists in useLaunchEditor.ts to avoid repeated execSync calls - Import commandExists from core and wrap it with cache in CLI layer - Simplify getExecutableCommand and getDiffCommand logic to remove redundant fallback - For editors with single command, directly use first command instead of meaningless self-fallback - Maintain support for editors with multiple commands (e.g., zed with 'zed' and 'zeditor') --- packages/cli/src/ui/hooks/useLaunchEditor.ts | 38 +++++++------------- packages/core/src/utils/editor.ts | 8 ++--- 2 files changed, 16 insertions(+), 30 deletions(-) diff --git a/packages/cli/src/ui/hooks/useLaunchEditor.ts b/packages/cli/src/ui/hooks/useLaunchEditor.ts index f4c79e384..40e869420 100644 --- a/packages/cli/src/ui/hooks/useLaunchEditor.ts +++ b/packages/cli/src/ui/hooks/useLaunchEditor.ts @@ -1,14 +1,11 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - import { useCallback } from 'react'; import { useStdin } from 'ink'; import type { EditorType } from '@qwen-code/qwen-code-core'; -import { editorCommands } from '@qwen-code/qwen-code-core'; -import { spawnSync, execSync } from 'child_process'; +import { + editorCommands, + commandExists as coreCommandExists, +} from '@qwen-code/qwen-code-core'; +import { spawnSync } from 'child_process'; import { useSettings } from '../contexts/SettingsContext.js'; /** @@ -17,7 +14,7 @@ import { useSettings } from '../contexts/SettingsContext.js'; const commandExistsCache = new Map(); /** - * Check if a command exists in the system. + * Check if a command exists in the system with caching. * Results are cached to improve performance in test environments. */ function commandExists(cmd: string): boolean { @@ -25,19 +22,10 @@ function commandExists(cmd: string): boolean { return commandExistsCache.get(cmd)!; } - try { - execSync( - process.platform === 'win32' ? `where.exe ${cmd}` : `command -v ${cmd}`, - { stdio: 'ignore' }, - ); - commandExistsCache.set(cmd, true); - return true; - } catch { - commandExistsCache.set(cmd, false); - return false; - } + const exists = coreCommandExists(cmd); + commandExistsCache.set(cmd, exists); + return exists; } - /** * Get the actual executable command for an editor type. */ @@ -46,11 +34,9 @@ function getExecutableCommand(editorType: EditorType): string { const commands = process.platform === 'win32' ? commandConfig.win32 : commandConfig.default; - // Try to find the first available command - const availableCommand = commands.find((cmd) => commandExists(cmd)); - - // Return the first available command, or fall back to the last one in the list - return availableCommand || commands[commands.length - 1]; + // For editors with multiple commands (like zed), try to find the first available one + // Otherwise, just return the first (and only) command + return commands.find((cmd) => commandExists(cmd)) || commands[0]; } /** diff --git a/packages/core/src/utils/editor.ts b/packages/core/src/utils/editor.ts index f64351ee5..e3f2a90a5 100644 --- a/packages/core/src/utils/editor.ts +++ b/packages/core/src/utils/editor.ts @@ -36,7 +36,7 @@ interface DiffCommand { args: string[]; } -function commandExists(cmd: string): boolean { +export function commandExists(cmd: string): boolean { try { execSync( process.platform === 'win32' ? `where.exe ${cmd}` : `command -v ${cmd}`, @@ -110,9 +110,9 @@ export function getDiffCommand( const commandConfig = editorCommands[editor]; const commands = process.platform === 'win32' ? commandConfig.win32 : commandConfig.default; - const command = - commands.slice(0, -1).find((cmd) => commandExists(cmd)) || - commands[commands.length - 1]; + // For editors with multiple commands (like zed), try to find the first available one + // Otherwise, just use the first (and only) command + const command = commands.find((cmd) => commandExists(cmd)) || commands[0]; switch (editor) { case 'vscode': From 87dc618a2199a64209aaa8f74c14a97036752664 Mon Sep 17 00:00:00 2001 From: xwj02155382 Date: Tue, 6 Jan 2026 11:09:29 +0800 Subject: [PATCH 059/142] revert: restore original editor command fallback logic for zed support - Revert getExecutableCommand to use original fallback logic - Revert getDiffCommand to use slice(0, -1) pattern - Maintain proper support for zed editor with multiple command options ['zed', 'zeditor'] - Keep the caching optimization for commandExists --- packages/cli/src/ui/hooks/useLaunchEditor.ts | 8 +++++--- packages/core/src/utils/editor.ts | 6 +++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/ui/hooks/useLaunchEditor.ts b/packages/cli/src/ui/hooks/useLaunchEditor.ts index 40e869420..da2ff6d9c 100644 --- a/packages/cli/src/ui/hooks/useLaunchEditor.ts +++ b/packages/cli/src/ui/hooks/useLaunchEditor.ts @@ -34,9 +34,11 @@ function getExecutableCommand(editorType: EditorType): string { const commands = process.platform === 'win32' ? commandConfig.win32 : commandConfig.default; - // For editors with multiple commands (like zed), try to find the first available one - // Otherwise, just return the first (and only) command - return commands.find((cmd) => commandExists(cmd)) || commands[0]; + // Try to find the first available command + const availableCommand = commands.find((cmd) => commandExists(cmd)); + + // Return the first available command, or fall back to the last one in the list + return availableCommand || commands[commands.length - 1]; } /** diff --git a/packages/core/src/utils/editor.ts b/packages/core/src/utils/editor.ts index e3f2a90a5..78d8b37fb 100644 --- a/packages/core/src/utils/editor.ts +++ b/packages/core/src/utils/editor.ts @@ -110,9 +110,9 @@ export function getDiffCommand( const commandConfig = editorCommands[editor]; const commands = process.platform === 'win32' ? commandConfig.win32 : commandConfig.default; - // For editors with multiple commands (like zed), try to find the first available one - // Otherwise, just use the first (and only) command - const command = commands.find((cmd) => commandExists(cmd)) || commands[0]; + const command = + commands.slice(0, -1).find((cmd) => commandExists(cmd)) || + commands[commands.length - 1]; switch (editor) { case 'vscode': From c6ae0a8be79f7758ee59772344df0237b465191b Mon Sep 17 00:00:00 2001 From: skyfire Date: Tue, 6 Jan 2026 11:16:47 +0800 Subject: [PATCH 060/142] for alpha stage --- packages/sdk-java/pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sdk-java/pom.xml b/packages/sdk-java/pom.xml index 0c5270d1e..6e7fa921b 100644 --- a/packages/sdk-java/pom.xml +++ b/packages/sdk-java/pom.xml @@ -33,7 +33,7 @@ 1.3.16 2.0.60 3.13.0 - 9 + 0.8.0 2 2.9.1 1.5 @@ -112,7 +112,7 @@ org.sonatype.central central-publishing-maven-plugin - 0.${central-publishing-maven-plugin.version}.0 + ${central-publishing-maven-plugin.version} true central From 731fd99800629a202912228ee4a97acc1bc30626 Mon Sep 17 00:00:00 2001 From: Weaxs <459312872@qq.com> Date: Tue, 6 Jan 2026 14:21:42 +0800 Subject: [PATCH 061/142] remove duplicate reasoning_content handle && remove extra_body.enable_thinking --- .../core/src/core/openaiContentGenerator/converter.ts | 11 ----------- .../core/src/core/openaiContentGenerator/pipeline.ts | 5 ----- 2 files changed, 16 deletions(-) diff --git a/packages/core/src/core/openaiContentGenerator/converter.ts b/packages/core/src/core/openaiContentGenerator/converter.ts index 184ba5493..be7804ec8 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.ts @@ -696,17 +696,6 @@ export class OpenAIContentConverter { parts.push({ text: choice.message.content }); } - // Handle reasoning content - const message = choice.message as typeof choice.message & { - reasoning_content?: string; - }; - if (message.reasoning_content) { - parts.push({ - text: message.reasoning_content, - thought: true, - } as unknown as Part); - } - // Handle tool calls if (choice.message.tool_calls) { for (const toolCall of choice.message.tool_calls) { diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts index 0eab0c2d2..ba483fe5f 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts @@ -247,11 +247,6 @@ export class ContentGenerationPipeline { request.config?.thinkingConfig && request.config.thinkingConfig.includeThoughts ) { - ( - baseRequest as OpenAI.Chat.ChatCompletionCreateParams & { - extra_body?: Record; - } - ).extra_body = { enable_thinking: true }; ( baseRequest as OpenAI.Chat.ChatCompletionCreateParams & { enable_thinking?: boolean; From 1d16513e27c1de418486ca7fa3793e6b79d60e15 Mon Sep 17 00:00:00 2001 From: Weaxs <459312872@qq.com> Date: Tue, 6 Jan 2026 14:21:42 +0800 Subject: [PATCH 062/142] remove duplicate reasoning_content handle && remove extra_body.enable_thinking --- .../core/src/core/openaiContentGenerator/converter.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/core/src/core/openaiContentGenerator/converter.ts b/packages/core/src/core/openaiContentGenerator/converter.ts index be7804ec8..5d1feb316 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.ts @@ -803,17 +803,6 @@ export class OpenAIContentConverter { } } - // Handle reasoning content - const delta = choice.delta as typeof choice.delta & { - reasoning_content?: string; - }; - if (delta.reasoning_content) { - parts.push({ - text: delta.reasoning_content, - thought: true, - }); - } - // Handle tool calls using the streaming parser if (choice.delta?.tool_calls) { for (const toolCall of choice.delta.tool_calls) { From 35bf5ef4d0cf27b7c725afb4a824ffcf3b03d854 Mon Sep 17 00:00:00 2001 From: Weaxs <459312872@qq.com> Date: Tue, 6 Jan 2026 14:37:49 +0800 Subject: [PATCH 063/142] remove duplicate reasoning_content handle --- packages/core/src/core/openaiContentGenerator/converter.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/core/openaiContentGenerator/converter.ts b/packages/core/src/core/openaiContentGenerator/converter.ts index 5d1feb316..5ec7a4935 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.ts @@ -752,7 +752,7 @@ export class OpenAIContentConverter { usage.prompt_tokens_details?.cached_tokens ?? extendedUsage.cached_tokens ?? 0; - const reasoningTokens = + const thinkingTokens = usage.completion_tokens_details?.reasoning_tokens || 0; // If we only have total tokens but no breakdown, estimate the split @@ -771,7 +771,7 @@ export class OpenAIContentConverter { candidatesTokenCount: finalCompletionTokens, totalTokenCount: totalTokens, cachedContentTokenCount: cachedTokens, - thoughtsTokenCount: reasoningTokens, + thoughtsTokenCount: thinkingTokens, }; } From e2d6ab9b7e5323c772c7a98afd5eec678fd2e375 Mon Sep 17 00:00:00 2001 From: xwj02155382 Date: Tue, 6 Jan 2026 16:46:56 +0800 Subject: [PATCH 064/142] refactor: simplify background shell command handling - Remove ineffective error detection for background processes (stdio is detached/ignored, so cumulativeOutput is always empty) - Add kill command hints for both Windows and macOS/Linux - Simplify code from 40 lines to 12 lines with clearer logic - Add explanatory comment about why startup errors cannot be reliably detected --- packages/core/src/tools/shell.ts | 44 +++++++------------------------- 1 file changed, 9 insertions(+), 35 deletions(-) diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index b4cbb195b..d7afae599 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -229,43 +229,17 @@ export class ShellToolInvocation extends BaseToolInvocation< } if (shouldRunInBackground) { - // Check for obvious startup errors from captured output - const outputStr = - typeof cumulativeOutput === 'string' - ? cumulativeOutput - : JSON.stringify(cumulativeOutput); - - const errorPatterns = [ - 'is not recognized as an internal or external command', - 'The system cannot find the path specified', - 'Access is denied', - 'command not found', - 'No such file or directory', - 'Permission denied', - ]; - - const hasEarlyError = errorPatterns.some((pat) => - outputStr.includes(pat), - ); - - if (hasEarlyError) { - return { - llmContent: `Command failed to start: ${outputStr}`, - returnDisplay: `Command failed to start: ${outputStr}`, - error: { - type: ToolErrorType.EXECUTION_FAILED, - message: `Command failed to start: ${outputStr}`, - }, - }; - } - + // For background tasks, return immediately with PID info + // Note: We cannot reliably detect startup errors for background processes + // since their stdio is typically detached/ignored const pidMsg = pid ? ` PID: ${pid}` : ''; - const winHint = isWindows - ? ' (Note: Use taskkill /F /T /PID to stop)' - : ''; + const killHint = isWindows + ? ' (Use taskkill /F /T /PID to stop)' + : ' (Use kill to stop)'; + return { - llmContent: `Background command started.${pidMsg}${winHint}`, - returnDisplay: `Background command started.${pidMsg}${winHint}`, + llmContent: `Background command started.${pidMsg}${killHint}`, + returnDisplay: `Background command started.${pidMsg}${killHint}`, }; } From 8f3bbef5754dfd31775badb33be115ff59f6d538 Mon Sep 17 00:00:00 2001 From: skyfire Date: Tue, 6 Jan 2026 17:11:47 +0800 Subject: [PATCH 065/142] add qwencode-sdk java doc --- docs/developers/_meta.ts | 1 + docs/developers/sdk-java.md | 312 ++++++++++++++++++++++++++++++++++++ 2 files changed, 313 insertions(+) create mode 100644 docs/developers/sdk-java.md diff --git a/docs/developers/_meta.ts b/docs/developers/_meta.ts index 956e1ad98..154ce1848 100644 --- a/docs/developers/_meta.ts +++ b/docs/developers/_meta.ts @@ -11,6 +11,7 @@ export default { type: 'separator', }, 'sdk-typescript': 'Typescript SDK', + 'sdk-java': 'Java SDK(alpha)', 'Dive Into Qwen Code': { title: 'Dive Into Qwen Code', type: 'separator', diff --git a/docs/developers/sdk-java.md b/docs/developers/sdk-java.md new file mode 100644 index 000000000..772c93742 --- /dev/null +++ b/docs/developers/sdk-java.md @@ -0,0 +1,312 @@ +# Qwen Code Java SDK + +The Qwen Code Java SDK is a minimum experimental SDK for programmatic access to Qwen Code functionality. It provides a Java interface to interact with the Qwen Code CLI, allowing developers to integrate Qwen Code capabilities into their Java applications. + +## Requirements + +- Java >= 1.8 +- Maven >= 3.6.0 (for building from source) +- qwen-code >= 0.5.0 + +### Dependencies + +- **Logging**: ch.qos.logback:logback-classic +- **Utilities**: org.apache.commons:commons-lang3 +- **JSON Processing**: com.alibaba.fastjson2:fastjson2 +- **Testing**: JUnit 5 (org.junit.jupiter:junit-jupiter) + +## Installation + +Add the following dependency to your Maven `pom.xml`: + +```xml + + com.alibaba + qwencode-sdk + {$version} + +``` + +Or if using Gradle, add to your `build.gradle`: + +```gradle +implementation 'com.alibaba:qwencode-sdk:{$version}' +``` + +## Building and Running + +### Build Commands + +```bash +# Compile the project +mvn compile + +# Run tests +mvn test + +# Package the JAR +mvn package + +# Install to local repository +mvn install +``` + +## Quick Start + +The simplest way to use the SDK is through the `QwenCodeCli.simpleQuery()` method: + +```java +public static void runSimpleExample() { + List result = QwenCodeCli.simpleQuery("hello world"); + result.forEach(logger::info); +} +``` + +For more advanced usage with custom transport options: + +```java +public static void runTransportOptionsExample() { + TransportOptions options = new TransportOptions() + .setModel("qwen3-coder-flash") + .setPermissionMode(PermissionMode.AUTO_EDIT) + .setCwd("./") + .setEnv(new HashMap() {{put("CUSTOM_VAR", "value");}}) + .setIncludePartialMessages(true) + .setTurnTimeout(new Timeout(120L, TimeUnit.SECONDS)) + .setMessageTimeout(new Timeout(90L, TimeUnit.SECONDS)) + .setAllowedTools(Arrays.asList("read_file", "write_file", "list_directory")); + + List result = QwenCodeCli.simpleQuery("who are you, what are your capabilities?", options); + result.forEach(logger::info); +} +``` + +For streaming content handling with custom content consumers: + +```java +public static void runStreamingExample() { + QwenCodeCli.simpleQuery("who are you, what are your capabilities?", + new TransportOptions().setMessageTimeout(new Timeout(10L, TimeUnit.SECONDS)), new AssistantContentSimpleConsumers() { + + @Override + public void onText(Session session, TextAssistantContent textAssistantContent) { + logger.info("Text content received: {}", textAssistantContent.getText()); + } + + @Override + public void onThinking(Session session, ThingkingAssistantContent thingkingAssistantContent) { + logger.info("Thinking content received: {}", thingkingAssistantContent.getThinking()); + } + + @Override + public void onToolUse(Session session, ToolUseAssistantContent toolUseContent) { + logger.info("Tool use content received: {} with arguments: {}", + toolUseContent, toolUseContent.getInput()); + } + + @Override + public void onToolResult(Session session, ToolResultAssistantContent toolResultContent) { + logger.info("Tool result content received: {}", toolResultContent.getContent()); + } + + @Override + public void onOtherContent(Session session, AssistantContent other) { + logger.info("Other content received: {}", other); + } + + @Override + public void onUsage(Session session, AssistantUsage assistantUsage) { + logger.info("Usage information received: Input tokens: {}, Output tokens: {}", + assistantUsage.getUsage().getInputTokens(), assistantUsage.getUsage().getOutputTokens()); + } + }.setDefaultPermissionOperation(Operation.allow)); + logger.info("Streaming example completed."); +} +``` + +other examples see src/test/java/com/alibaba/qwen/code/cli/example + +## Architecture + +The SDK follows a layered architecture: + +- **API Layer**: Provides the main entry points through `QwenCodeCli` class with simple static methods for basic usage +- **Session Layer**: Manages communication sessions with the Qwen Code CLI through the `Session` class +- **Transport Layer**: Handles the communication mechanism between the SDK and CLI process (currently using process transport via `ProcessTransport`) +- **Protocol Layer**: Defines data structures for communication based on the CLI protocol +- **Utils**: Common utilities for concurrent execution, timeout handling, and error management + +## Key Features + +### Permission Modes + +The SDK supports different permission modes for controlling tool execution: + +- **`default`**: Write tools are denied unless approved via `canUseTool` callback or in `allowedTools`. Read-only tools execute without confirmation. +- **`plan`**: Blocks all write tools, instructing AI to present a plan first. +- **`auto-edit`**: Auto-approve edit tools (edit, write_file) while other tools require confirmation. +- **`yolo`**: All tools execute automatically without confirmation. + +### Session Event Consumers and Assistant Content Consumers + +The SDK provides two key interfaces for handling events and content from the CLI: + +#### SessionEventConsumers Interface + +The `SessionEventConsumers` interface provides callbacks for different types of messages during a session: + +- `onSystemMessage`: Handles system messages from the CLI (receives Session and SDKSystemMessage) +- `onResultMessage`: Handles result messages from the CLI (receives Session and SDKResultMessage) +- `onAssistantMessage`: Handles assistant messages (AI responses) (receives Session and SDKAssistantMessage) +- `onPartialAssistantMessage`: Handles partial assistant messages during streaming (receives Session and SDKPartialAssistantMessage) +- `onUserMessage`: Handles user messages (receives Session and SDKUserMessage) +- `onOtherMessage`: Handles other types of messages (receives Session and String message) +- `onControlResponse`: Handles control responses (receives Session and CLIControlResponse) +- `onControlRequest`: Handles control requests (receives Session and CLIControlRequest, returns CLIControlResponse) +- `onPermissionRequest`: Handles permission requests (receives Session and CLIControlRequest, returns Behavior) + +#### AssistantContentConsumers Interface + +The `AssistantContentConsumers` interface handles different types of content within assistant messages: + +- `onText`: Handles text content (receives Session and TextAssistantContent) +- `onThinking`: Handles thinking content (receives Session and ThingkingAssistantContent) +- `onToolUse`: Handles tool use content (receives Session and ToolUseAssistantContent) +- `onToolResult`: Handles tool result content (receives Session and ToolResultAssistantContent) +- `onOtherContent`: Handles other content types (receives Session and AssistantContent) +- `onUsage`: Handles usage information (receives Session and AssistantUsage) +- `onPermissionRequest`: Handles permission requests (receives Session and CLIControlPermissionRequest, returns Behavior) +- `onOtherControlRequest`: Handles other control requests (receives Session and ControlRequestPayload, returns ControlResponsePayload) + +#### Relationship Between the Interfaces + +**Important Note on Event Hierarchy:** + +- `SessionEventConsumers` is the **high-level** event processor that handles different message types (system, assistant, user, etc.) +- `AssistantContentConsumers` is the **low-level** content processor that handles different types of content within assistant messages (text, tools, thinking, etc.) + +**Processor Relationship:** + +- `SessionEventConsumers` → `AssistantContentConsumers` (SessionEventConsumers uses AssistantContentConsumers to process content within assistant messages) + +**Event Derivation Relationships:** + +- `onAssistantMessage` → `onText`, `onThinking`, `onToolUse`, `onToolResult`, `onOtherContent`, `onUsage` +- `onPartialAssistantMessage` → `onText`, `onThinking`, `onToolUse`, `onToolResult`, `onOtherContent` +- `onControlRequest` → `onPermissionRequest`, `onOtherControlRequest` + +**Event Timeout Relationships:** + +Each event handler method has a corresponding timeout method that allows customizing the timeout behavior for that specific event: + +- `onSystemMessage` ↔ `onSystemMessageTimeout` +- `onResultMessage` ↔ `onResultMessageTimeout` +- `onAssistantMessage` ↔ `onAssistantMessageTimeout` +- `onPartialAssistantMessage` ↔ `onPartialAssistantMessageTimeout` +- `onUserMessage` ↔ `onUserMessageTimeout` +- `onOtherMessage` ↔ `onOtherMessageTimeout` +- `onControlResponse` ↔ `onControlResponseTimeout` +- `onControlRequest` ↔ `onControlRequestTimeout` + +For AssistantContentConsumers timeout methods: + +- `onText` ↔ `onTextTimeout` +- `onThinking` ↔ `onThinkingTimeout` +- `onToolUse` ↔ `onToolUseTimeout` +- `onToolResult` ↔ `onToolResultTimeout` +- `onOtherContent` ↔ `onOtherContentTimeout` +- `onPermissionRequest` ↔ `onPermissionRequestTimeout` +- `onOtherControlRequest` ↔ `onOtherControlRequestTimeout` + +**Default Timeout Values:** + +- `SessionEventSimpleConsumers` default timeout: 180 seconds (Timeout.TIMEOUT_180_SECONDS) +- `AssistantContentSimpleConsumers` default timeout: 60 seconds (Timeout.TIMEOUT_60_SECONDS) + +**Timeout Hierarchy Requirements:** + +For proper operation, the following timeout relationships should be maintained: + +- `onAssistantMessageTimeout` return value should be greater than `onTextTimeout`, `onThinkingTimeout`, `onToolUseTimeout`, `onToolResultTimeout`, and `onOtherContentTimeout` return values +- `onControlRequestTimeout` return value should be greater than `onPermissionRequestTimeout` and `onOtherControlRequestTimeout` return values + +### Transport Options + +The `TransportOptions` class allows configuration of how the SDK communicates with the Qwen Code CLI: + +- `pathToQwenExecutable`: Path to the Qwen Code CLI executable +- `cwd`: Working directory for the CLI process +- `model`: AI model to use for the session +- `permissionMode`: Permission mode that controls tool execution +- `env`: Environment variables to pass to the CLI process +- `maxSessionTurns`: Limits the number of conversation turns in a session +- `coreTools`: List of core tools that should be available to the AI +- `excludeTools`: List of tools to exclude from being available to the AI +- `allowedTools`: List of tools that are pre-approved for use without additional confirmation +- `authType`: Authentication type to use for the session +- `includePartialMessages`: Enables receiving partial messages during streaming responses +- `skillsEnable`: Enables or disables skills functionality for the session +- `turnTimeout`: Timeout for a complete turn of conversation +- `messageTimeout`: Timeout for individual messages within a turn +- `resumeSessionId`: ID of a previous session to resume +- `otherOptions`: Additional command-line options to pass to the CLI + +### Session Control Features + +- **Session creation**: Use `QwenCodeCli.newSession()` to create a new session with custom options +- **Session management**: The `Session` class provides methods to send prompts, handle responses, and manage session state +- **Session cleanup**: Always close sessions using `session.close()` to properly terminate the CLI process +- **Session resumption**: Use `setResumeSessionId()` in `TransportOptions` to resume a previous session +- **Session interruption**: Use `session.interrupt()` to interrupt a currently running prompt +- **Dynamic model switching**: Use `session.setModel()` to change the model during a session +- **Dynamic permission mode switching**: Use `session.setPermissionMode()` to change the permission mode during a session + +### Thread Pool Configuration + +The SDK uses a thread pool for managing concurrent operations with the following default configuration: + +- **Core Pool Size**: 30 threads +- **Maximum Pool Size**: 100 threads +- **Keep-Alive Time**: 60 seconds +- **Queue Capacity**: 300 tasks (using LinkedBlockingQueue) +- **Thread Naming**: "qwen_code_cli-pool-{number}" +- **Daemon Threads**: false +- **Rejected Execution Handler**: CallerRunsPolicy + +## Error Handling + +The SDK provides specific exception types for different error scenarios: + +- `SessionControlException`: Thrown when there's an issue with session control (creation, initialization, etc.) +- `SessionSendPromptException`: Thrown when there's an issue sending a prompt or receiving a response +- `SessionClosedException`: Thrown when attempting to use a closed session + +## FAQ / Troubleshooting + +### Q: Do I need to install the Qwen CLI separately? + +A: No, from v0.1.1, the CLI is bundled with the SDK, so no standalone CLI installation is needed. + +### Q: What Java versions are supported? + +A: The SDK requires Java 1.8 or higher. + +### Q: How do I handle long-running requests? + +A: The SDK includes timeout utilities. You can configure timeouts using the `Timeout` class in `TransportOptions`. + +### Q: Why are some tools not executing? + +A: This is likely due to permission modes. Check your permission mode settings and consider using `allowedTools` to pre-approve certain tools. + +### Q: How do I resume a previous session? + +A: Use the `setResumeSessionId()` method in `TransportOptions` to resume a previous session. + +### Q: Can I customize the environment for the CLI process? + +A: Yes, use the `setEnv()` method in `TransportOptions` to pass environment variables to the CLI process. + +## License + +Apache-2.0 - see [LICENSE](./LICENSE) for details. From ad3086f7dd836f3fe18fc8ee3e84ca5a3f4437f6 Mon Sep 17 00:00:00 2001 From: skyfire Date: Tue, 6 Jan 2026 17:18:41 +0800 Subject: [PATCH 066/142] add qwencode-sdk java doc --- docs/developers/sdk-java.md | 2 +- packages/sdk-java/pom.xml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/developers/sdk-java.md b/docs/developers/sdk-java.md index 772c93742..0b16e60a5 100644 --- a/docs/developers/sdk-java.md +++ b/docs/developers/sdk-java.md @@ -285,7 +285,7 @@ The SDK provides specific exception types for different error scenarios: ### Q: Do I need to install the Qwen CLI separately? -A: No, from v0.1.1, the CLI is bundled with the SDK, so no standalone CLI installation is needed. +A: yes, requires Qwen CLI 0.5.5 or higher. ### Q: What Java versions are supported? diff --git a/packages/sdk-java/pom.xml b/packages/sdk-java/pom.xml index 6e7fa921b..6a5fae4f4 100644 --- a/packages/sdk-java/pom.xml +++ b/packages/sdk-java/pom.xml @@ -34,7 +34,7 @@ 2.0.60 3.13.0 0.8.0 - 2 + 2.2.1 2.9.1 1.5 @@ -122,7 +122,7 @@ org.apache.maven.plugins maven-source-plugin - ${maven-source-plugin.version}.2.1 + ${maven-source-plugin.version} attach-sources From d7d7bf0c3980421f079f88093b9efee71f1316bd Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 6 Jan 2026 19:39:28 +0800 Subject: [PATCH 067/142] fix default values of reasoning config for openai compatible api --- .../core/openaiContentGenerator/pipeline.ts | 21 ++++++++++++------- .../provider/default.ts | 4 +--- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts index 88ac38f6a..ef27a7798 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts @@ -317,15 +317,22 @@ export class ContentGenerationPipeline { } private buildReasoningConfig(): Record { - const reasoning = this.contentGeneratorConfig.reasoning; + // Reasoning configuration for OpenAI-compatible endpoints is highly fragmented. + // For example, across common providers and models: + // + // - deepseek-reasoner — thinking is enabled by default and cannot be disabled + // - glm-4.7 — thinking is enabled by default; can be disabled via `extra_body.thinking.enabled` + // - kimi-k2-thinking — thinking is enabled by default and cannot be disabled + // - gpt-5.x series — thinking is enabled by default; can be disabled via `reasoning.effort` + // - qwen3 series — model-dependent; can be manually disabled via `extra_body.enable_thinking` + // + // Given this inconsistency, we choose not to set any reasoning config here and + // instead rely on each model’s default behavior. - if (reasoning === false) { - return {}; - } + // We plan to introduce provider- and model-specific settings to enable more + // fine-grained control over reasoning configuration. - return { - reasoning_effort: reasoning?.effort ?? 'medium', - }; + return {}; } /** diff --git a/packages/core/src/core/openaiContentGenerator/provider/default.ts b/packages/core/src/core/openaiContentGenerator/provider/default.ts index c56069503..521a6768c 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/default.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/default.ts @@ -58,8 +58,6 @@ export class DefaultOpenAICompatibleProvider } getDefaultGenerationConfig(): GenerateContentConfig { - return { - topP: 0.95, - }; + return {}; } } From 8fcdd86b9103dfe110944a8be7e0b57e1c418120 Mon Sep 17 00:00:00 2001 From: Tu Shaokun <2801884530@qq.com> Date: Thu, 1 Jan 2026 20:17:15 +0800 Subject: [PATCH 068/142] feat(cli): add direct argument support for /approval-mode command Allow users to set approval mode directly via argument instead of opening the dialog. For example: - /approval-mode plan - /approval-mode yolo - /approval-mode auto-edit - /approval-mode default If no argument is provided, the dialog opens as before. If an invalid argument is provided, an error message shows valid options. Also adds tab completion for mode arguments. Fixes #1353 --- packages/cli/src/i18n/locales/en.js | 3 + packages/cli/src/i18n/locales/ru.js | 4 + packages/cli/src/i18n/locales/zh.js | 3 + .../ui/commands/approvalModeCommand.test.ts | 179 +++++++++++++++++- .../src/ui/commands/approvalModeCommand.ts | 71 ++++++- 5 files changed, 246 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 25fe74ece..5e8b16629 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -89,6 +89,9 @@ export default { 'No tools available': 'No tools available', 'View or change the approval mode for tool usage': 'View or change the approval mode for tool usage', + 'Invalid approval mode "{{arg}}". Valid modes: {{modes}}': + 'Invalid approval mode "{{arg}}". Valid modes: {{modes}}', + 'Approval mode set to "{{mode}}"': 'Approval mode set to "{{mode}}"', 'View or change the language setting': 'View or change the language setting', 'change the theme': 'change the theme', 'Select Theme': 'Select Theme', diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 8db55e331..9685c104b 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -89,6 +89,10 @@ export default { 'No tools available': 'Нет доступных инструментов', 'View or change the approval mode for tool usage': 'Просмотр или изменение режима подтверждения для использования инструментов', + 'Invalid approval mode "{{arg}}". Valid modes: {{modes}}': + 'Недопустимый режим подтверждения "{{arg}}". Допустимые режимы: {{modes}}', + 'Approval mode set to "{{mode}}"': + 'Режим подтверждения установлен на "{{mode}}"', 'View or change the language setting': 'Просмотр или изменение настроек языка', 'change the theme': 'Изменение темы', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 5c5d21679..c3550f7e8 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -88,6 +88,9 @@ export default { 'No tools available': '没有可用工具', 'View or change the approval mode for tool usage': '查看或更改工具使用的审批模式', + 'Invalid approval mode "{{arg}}". Valid modes: {{modes}}': + '无效的审批模式 "{{arg}}"。有效模式:{{modes}}', + 'Approval mode set to "{{mode}}"': '审批模式已设置为 "{{mode}}"', 'View or change the language setting': '查看或更改语言设置', 'change the theme': '更改主题', 'Select Theme': '选择主题', diff --git a/packages/cli/src/ui/commands/approvalModeCommand.test.ts b/packages/cli/src/ui/commands/approvalModeCommand.test.ts index f915a63c9..7b4b4cfa4 100644 --- a/packages/cli/src/ui/commands/approvalModeCommand.test.ts +++ b/packages/cli/src/ui/commands/approvalModeCommand.test.ts @@ -4,29 +4,34 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { approvalModeCommand } from './approvalModeCommand.js'; import { type CommandContext, CommandKind, type OpenDialogActionReturn, + type MessageActionReturn, } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import type { LoadedSettings } from '../../config/settings.js'; describe('approvalModeCommand', () => { let mockContext: CommandContext; + let mockSetValue: ReturnType; + let mockSetApprovalMode: ReturnType; beforeEach(() => { + mockSetValue = vi.fn(); + mockSetApprovalMode = vi.fn(); mockContext = createMockCommandContext({ services: { config: { getApprovalMode: () => 'default', - setApprovalMode: () => {}, + setApprovalMode: mockSetApprovalMode, }, settings: { - merged: {}, - setValue: () => {}, + merged: { tools: { approvalMode: 'default' } }, + setValue: mockSetValue, forScope: () => ({}), } as unknown as LoadedSettings, }, @@ -41,7 +46,7 @@ describe('approvalModeCommand', () => { expect(approvalModeCommand.kind).toBe(CommandKind.BUILT_IN); }); - it('should open approval mode dialog when invoked', async () => { + it('should open approval mode dialog when invoked without arguments', async () => { const result = (await approvalModeCommand.action?.( mockContext, '', @@ -51,21 +56,177 @@ describe('approvalModeCommand', () => { expect(result.dialog).toBe('approval-mode'); }); - it('should open approval mode dialog with arguments (ignored)', async () => { + it('should open approval mode dialog when invoked with whitespace only', async () => { const result = (await approvalModeCommand.action?.( mockContext, - 'some arguments', + ' ', )) as OpenDialogActionReturn; expect(result.type).toBe('dialog'); expect(result.dialog).toBe('approval-mode'); }); + describe('direct mode setting', () => { + it('should set approval mode to "plan" when argument is "plan"', async () => { + const result = (await approvalModeCommand.action?.( + mockContext, + 'plan', + )) as MessageActionReturn; + + expect(result.type).toBe('message'); + expect(result.messageType).toBe('info'); + expect(result.content).toContain('plan'); + expect(mockSetValue).toHaveBeenCalledWith( + 'User', + 'tools.approvalMode', + 'plan', + ); + expect(mockSetApprovalMode).toHaveBeenCalled(); + }); + + it('should set approval mode to "yolo" when argument is "yolo"', async () => { + const result = (await approvalModeCommand.action?.( + mockContext, + 'yolo', + )) as MessageActionReturn; + + expect(result.type).toBe('message'); + expect(result.messageType).toBe('info'); + expect(result.content).toContain('yolo'); + expect(mockSetValue).toHaveBeenCalledWith( + 'User', + 'tools.approvalMode', + 'yolo', + ); + }); + + it('should set approval mode to "auto-edit" when argument is "auto-edit"', async () => { + const result = (await approvalModeCommand.action?.( + mockContext, + 'auto-edit', + )) as MessageActionReturn; + + expect(result.type).toBe('message'); + expect(result.messageType).toBe('info'); + expect(result.content).toContain('auto-edit'); + expect(mockSetValue).toHaveBeenCalledWith( + 'User', + 'tools.approvalMode', + 'auto-edit', + ); + }); + + it('should set approval mode to "default" when argument is "default"', async () => { + const result = (await approvalModeCommand.action?.( + mockContext, + 'default', + )) as MessageActionReturn; + + expect(result.type).toBe('message'); + expect(result.messageType).toBe('info'); + expect(result.content).toContain('default'); + expect(mockSetValue).toHaveBeenCalledWith( + 'User', + 'tools.approvalMode', + 'default', + ); + }); + + it('should be case-insensitive for mode argument', async () => { + const result = (await approvalModeCommand.action?.( + mockContext, + 'YOLO', + )) as MessageActionReturn; + + expect(result.type).toBe('message'); + expect(result.messageType).toBe('info'); + expect(mockSetValue).toHaveBeenCalledWith( + 'User', + 'tools.approvalMode', + 'yolo', + ); + }); + + it('should handle argument with leading/trailing whitespace', async () => { + const result = (await approvalModeCommand.action?.( + mockContext, + ' plan ', + )) as MessageActionReturn; + + expect(result.type).toBe('message'); + expect(result.messageType).toBe('info'); + expect(mockSetValue).toHaveBeenCalledWith( + 'User', + 'tools.approvalMode', + 'plan', + ); + }); + }); + + describe('invalid mode argument', () => { + it('should return error for invalid mode', async () => { + const result = (await approvalModeCommand.action?.( + mockContext, + 'invalid-mode', + )) as MessageActionReturn; + + expect(result.type).toBe('message'); + expect(result.messageType).toBe('error'); + expect(result.content).toContain('invalid-mode'); + expect(result.content).toContain('plan'); + expect(result.content).toContain('yolo'); + expect(mockSetValue).not.toHaveBeenCalled(); + expect(mockSetApprovalMode).not.toHaveBeenCalled(); + }); + }); + it('should not have subcommands', () => { expect(approvalModeCommand.subCommands).toBeUndefined(); }); - it('should not have completion function', () => { - expect(approvalModeCommand.completion).toBeUndefined(); + describe('completion', () => { + it('should have completion function', () => { + expect(approvalModeCommand.completion).toBeDefined(); + }); + + it('should return all modes when partial arg is empty', async () => { + const completions = await approvalModeCommand.completion?.( + mockContext, + '', + ); + + expect(completions).toContain('plan'); + expect(completions).toContain('default'); + expect(completions).toContain('auto-edit'); + expect(completions).toContain('yolo'); + }); + + it('should filter modes based on partial arg', async () => { + const completions = await approvalModeCommand.completion?.( + mockContext, + 'p', + ); + + expect(completions).toContain('plan'); + expect(completions).not.toContain('yolo'); + }); + + it('should filter modes case-insensitively', async () => { + const completions = await approvalModeCommand.completion?.( + mockContext, + 'A', + ); + + expect(completions).toContain('auto-edit'); + }); + + it('should return empty array when no modes match', async () => { + const completions = await approvalModeCommand.completion?.( + mockContext, + 'xyz', + ); + + expect(completions).toEqual([]); + }); }); }); diff --git a/packages/cli/src/ui/commands/approvalModeCommand.ts b/packages/cli/src/ui/commands/approvalModeCommand.ts index 90ae774bf..b2f58bbf7 100644 --- a/packages/cli/src/ui/commands/approvalModeCommand.ts +++ b/packages/cli/src/ui/commands/approvalModeCommand.ts @@ -8,9 +8,26 @@ import type { SlashCommand, CommandContext, OpenDialogActionReturn, + MessageActionReturn, } from './types.js'; import { CommandKind } from './types.js'; import { t } from '../../i18n/index.js'; +import type { ApprovalMode} from '@qwen-code/qwen-code-core'; +import { APPROVAL_MODES } from '@qwen-code/qwen-code-core'; +import { SettingScope } from '../../config/settings.js'; + +/** + * Parses the argument string and returns the corresponding ApprovalMode if valid. + * Returns undefined if the argument is empty or not a valid mode. + */ +function parseApprovalModeArg(arg: string): ApprovalMode | undefined { + const trimmed = arg.trim().toLowerCase(); + if (!trimmed) { + return undefined; + } + // Match against valid approval modes (case-insensitive) + return APPROVAL_MODES.find((mode) => mode.toLowerCase() === trimmed); +} export const approvalModeCommand: SlashCommand = { name: 'approval-mode', @@ -19,10 +36,54 @@ export const approvalModeCommand: SlashCommand = { }, kind: CommandKind.BUILT_IN, action: async ( + context: CommandContext, + args: string, + ): Promise => { + const mode = parseApprovalModeArg(args); + + // If no argument provided, open the dialog + if (!args.trim()) { + return { + type: 'dialog', + dialog: 'approval-mode', + }; + } + + // If invalid argument, return error message with valid options + if (!mode) { + return { + type: 'message', + messageType: 'error', + content: t('Invalid approval mode "{{arg}}". Valid modes: {{modes}}', { + arg: args.trim(), + modes: APPROVAL_MODES.join(', '), + }), + }; + } + + // Set the mode directly + const { config, settings } = context.services; + if (config && settings) { + settings.setValue(SettingScope.User, 'tools.approvalMode', mode); + config.setApprovalMode(settings.merged.tools?.approvalMode ?? mode); + } + + return { + type: 'message', + messageType: 'info', + content: t('Approval mode set to "{{mode}}"', { mode }), + }; + }, + completion: async ( _context: CommandContext, - _args: string, - ): Promise => ({ - type: 'dialog', - dialog: 'approval-mode', - }), + partialArg: string, + ): Promise => { + const trimmed = partialArg.trim().toLowerCase(); + if (!trimmed) { + return [...APPROVAL_MODES]; + } + return APPROVAL_MODES.filter((mode) => + mode.toLowerCase().startsWith(trimmed), + ); + }, }; From 2f2937aafe1d95129a73eace54aea0ec0937a26c Mon Sep 17 00:00:00 2001 From: Tu Shaokun <2801884530@qq.com> Date: Thu, 1 Jan 2026 20:29:38 +0800 Subject: [PATCH 069/142] test: add explicit assertions for setApprovalMode argument Verify the exact mode value passed to config.setApprovalMode to catch potential regressions in settings merge/update mechanism. --- .../cli/src/ui/commands/approvalModeCommand.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/commands/approvalModeCommand.test.ts b/packages/cli/src/ui/commands/approvalModeCommand.test.ts index 7b4b4cfa4..14a250a17 100644 --- a/packages/cli/src/ui/commands/approvalModeCommand.test.ts +++ b/packages/cli/src/ui/commands/approvalModeCommand.test.ts @@ -30,7 +30,9 @@ describe('approvalModeCommand', () => { setApprovalMode: mockSetApprovalMode, }, settings: { - merged: { tools: { approvalMode: 'default' } }, + // Use empty merged so ?? fallback triggers, allowing us to verify + // the exact mode passed to setApprovalMode + merged: {}, setValue: mockSetValue, forScope: () => ({}), } as unknown as LoadedSettings, @@ -81,7 +83,7 @@ describe('approvalModeCommand', () => { 'tools.approvalMode', 'plan', ); - expect(mockSetApprovalMode).toHaveBeenCalled(); + expect(mockSetApprovalMode).toHaveBeenCalledWith('plan'); }); it('should set approval mode to "yolo" when argument is "yolo"', async () => { @@ -98,6 +100,7 @@ describe('approvalModeCommand', () => { 'tools.approvalMode', 'yolo', ); + expect(mockSetApprovalMode).toHaveBeenCalledWith('yolo'); }); it('should set approval mode to "auto-edit" when argument is "auto-edit"', async () => { @@ -114,6 +117,7 @@ describe('approvalModeCommand', () => { 'tools.approvalMode', 'auto-edit', ); + expect(mockSetApprovalMode).toHaveBeenCalledWith('auto-edit'); }); it('should set approval mode to "default" when argument is "default"', async () => { @@ -130,6 +134,7 @@ describe('approvalModeCommand', () => { 'tools.approvalMode', 'default', ); + expect(mockSetApprovalMode).toHaveBeenCalledWith('default'); }); it('should be case-insensitive for mode argument', async () => { @@ -145,6 +150,7 @@ describe('approvalModeCommand', () => { 'tools.approvalMode', 'yolo', ); + expect(mockSetApprovalMode).toHaveBeenCalledWith('yolo'); }); it('should handle argument with leading/trailing whitespace', async () => { @@ -160,6 +166,7 @@ describe('approvalModeCommand', () => { 'tools.approvalMode', 'plan', ); + expect(mockSetApprovalMode).toHaveBeenCalledWith('plan'); }); }); From bfe72988584512621dca51a562df728de6deb8f0 Mon Sep 17 00:00:00 2001 From: Tu Shaokun <2801884530@qq.com> Date: Tue, 6 Jan 2026 21:57:21 +0800 Subject: [PATCH 070/142] refactor: apply session-only approval mode per review feedback - Remove persistence to user settings (no setValue call) - Only use config.setApprovalMode() for session scope - Remove autocomplete feature for simplicity - Align with Shift+Tab behavior --- .../ui/commands/approvalModeCommand.test.ts | 89 +------------------ .../src/ui/commands/approvalModeCommand.ts | 24 ++--- 2 files changed, 8 insertions(+), 105 deletions(-) diff --git a/packages/cli/src/ui/commands/approvalModeCommand.test.ts b/packages/cli/src/ui/commands/approvalModeCommand.test.ts index 14a250a17..2cbae6319 100644 --- a/packages/cli/src/ui/commands/approvalModeCommand.test.ts +++ b/packages/cli/src/ui/commands/approvalModeCommand.test.ts @@ -13,15 +13,12 @@ import { type MessageActionReturn, } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; -import type { LoadedSettings } from '../../config/settings.js'; describe('approvalModeCommand', () => { let mockContext: CommandContext; - let mockSetValue: ReturnType; let mockSetApprovalMode: ReturnType; beforeEach(() => { - mockSetValue = vi.fn(); mockSetApprovalMode = vi.fn(); mockContext = createMockCommandContext({ services: { @@ -29,13 +26,6 @@ describe('approvalModeCommand', () => { getApprovalMode: () => 'default', setApprovalMode: mockSetApprovalMode, }, - settings: { - // Use empty merged so ?? fallback triggers, allowing us to verify - // the exact mode passed to setApprovalMode - merged: {}, - setValue: mockSetValue, - forScope: () => ({}), - } as unknown as LoadedSettings, }, }); }); @@ -68,7 +58,7 @@ describe('approvalModeCommand', () => { expect(result.dialog).toBe('approval-mode'); }); - describe('direct mode setting', () => { + describe('direct mode setting (session-only)', () => { it('should set approval mode to "plan" when argument is "plan"', async () => { const result = (await approvalModeCommand.action?.( mockContext, @@ -78,11 +68,6 @@ describe('approvalModeCommand', () => { expect(result.type).toBe('message'); expect(result.messageType).toBe('info'); expect(result.content).toContain('plan'); - expect(mockSetValue).toHaveBeenCalledWith( - 'User', - 'tools.approvalMode', - 'plan', - ); expect(mockSetApprovalMode).toHaveBeenCalledWith('plan'); }); @@ -95,11 +80,6 @@ describe('approvalModeCommand', () => { expect(result.type).toBe('message'); expect(result.messageType).toBe('info'); expect(result.content).toContain('yolo'); - expect(mockSetValue).toHaveBeenCalledWith( - 'User', - 'tools.approvalMode', - 'yolo', - ); expect(mockSetApprovalMode).toHaveBeenCalledWith('yolo'); }); @@ -112,11 +92,6 @@ describe('approvalModeCommand', () => { expect(result.type).toBe('message'); expect(result.messageType).toBe('info'); expect(result.content).toContain('auto-edit'); - expect(mockSetValue).toHaveBeenCalledWith( - 'User', - 'tools.approvalMode', - 'auto-edit', - ); expect(mockSetApprovalMode).toHaveBeenCalledWith('auto-edit'); }); @@ -129,11 +104,6 @@ describe('approvalModeCommand', () => { expect(result.type).toBe('message'); expect(result.messageType).toBe('info'); expect(result.content).toContain('default'); - expect(mockSetValue).toHaveBeenCalledWith( - 'User', - 'tools.approvalMode', - 'default', - ); expect(mockSetApprovalMode).toHaveBeenCalledWith('default'); }); @@ -145,11 +115,6 @@ describe('approvalModeCommand', () => { expect(result.type).toBe('message'); expect(result.messageType).toBe('info'); - expect(mockSetValue).toHaveBeenCalledWith( - 'User', - 'tools.approvalMode', - 'yolo', - ); expect(mockSetApprovalMode).toHaveBeenCalledWith('yolo'); }); @@ -161,11 +126,6 @@ describe('approvalModeCommand', () => { expect(result.type).toBe('message'); expect(result.messageType).toBe('info'); - expect(mockSetValue).toHaveBeenCalledWith( - 'User', - 'tools.approvalMode', - 'plan', - ); expect(mockSetApprovalMode).toHaveBeenCalledWith('plan'); }); }); @@ -182,7 +142,6 @@ describe('approvalModeCommand', () => { expect(result.content).toContain('invalid-mode'); expect(result.content).toContain('plan'); expect(result.content).toContain('yolo'); - expect(mockSetValue).not.toHaveBeenCalled(); expect(mockSetApprovalMode).not.toHaveBeenCalled(); }); }); @@ -191,49 +150,7 @@ describe('approvalModeCommand', () => { expect(approvalModeCommand.subCommands).toBeUndefined(); }); - describe('completion', () => { - it('should have completion function', () => { - expect(approvalModeCommand.completion).toBeDefined(); - }); - - it('should return all modes when partial arg is empty', async () => { - const completions = await approvalModeCommand.completion?.( - mockContext, - '', - ); - - expect(completions).toContain('plan'); - expect(completions).toContain('default'); - expect(completions).toContain('auto-edit'); - expect(completions).toContain('yolo'); - }); - - it('should filter modes based on partial arg', async () => { - const completions = await approvalModeCommand.completion?.( - mockContext, - 'p', - ); - - expect(completions).toContain('plan'); - expect(completions).not.toContain('yolo'); - }); - - it('should filter modes case-insensitively', async () => { - const completions = await approvalModeCommand.completion?.( - mockContext, - 'A', - ); - - expect(completions).toContain('auto-edit'); - }); - - it('should return empty array when no modes match', async () => { - const completions = await approvalModeCommand.completion?.( - mockContext, - 'xyz', - ); - - expect(completions).toEqual([]); - }); + it('should not have completion function', () => { + expect(approvalModeCommand.completion).toBeUndefined(); }); }); diff --git a/packages/cli/src/ui/commands/approvalModeCommand.ts b/packages/cli/src/ui/commands/approvalModeCommand.ts index b2f58bbf7..c53a4184d 100644 --- a/packages/cli/src/ui/commands/approvalModeCommand.ts +++ b/packages/cli/src/ui/commands/approvalModeCommand.ts @@ -12,9 +12,8 @@ import type { } from './types.js'; import { CommandKind } from './types.js'; import { t } from '../../i18n/index.js'; -import type { ApprovalMode} from '@qwen-code/qwen-code-core'; +import type { ApprovalMode } from '@qwen-code/qwen-code-core'; import { APPROVAL_MODES } from '@qwen-code/qwen-code-core'; -import { SettingScope } from '../../config/settings.js'; /** * Parses the argument string and returns the corresponding ApprovalMode if valid. @@ -61,11 +60,10 @@ export const approvalModeCommand: SlashCommand = { }; } - // Set the mode directly - const { config, settings } = context.services; - if (config && settings) { - settings.setValue(SettingScope.User, 'tools.approvalMode', mode); - config.setApprovalMode(settings.merged.tools?.approvalMode ?? mode); + // Set the mode for current session only (not persisted) + const { config } = context.services; + if (config) { + config.setApprovalMode(mode); } return { @@ -74,16 +72,4 @@ export const approvalModeCommand: SlashCommand = { content: t('Approval mode set to "{{mode}}"', { mode }), }; }, - completion: async ( - _context: CommandContext, - partialArg: string, - ): Promise => { - const trimmed = partialArg.trim().toLowerCase(); - if (!trimmed) { - return [...APPROVAL_MODES]; - } - return APPROVAL_MODES.filter((mode) => - mode.toLowerCase().startsWith(trimmed), - ); - }, }; From 0878ee4cbd0acb5e41a05f9ae24c0f3701911bb2 Mon Sep 17 00:00:00 2001 From: Tu Shaokun <2801884530@qq.com> Date: Tue, 6 Jan 2026 22:04:43 +0800 Subject: [PATCH 071/142] fix: handle setApprovalMode error in untrusted folders Add try/catch to gracefully handle errors when setting privileged modes (yolo/auto-edit) in untrusted folders, returning an error message instead of throwing. --- .../ui/commands/approvalModeCommand.test.ts | 19 +++++++++++++++++++ .../src/ui/commands/approvalModeCommand.ts | 10 +++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/commands/approvalModeCommand.test.ts b/packages/cli/src/ui/commands/approvalModeCommand.test.ts index 2cbae6319..c036bceda 100644 --- a/packages/cli/src/ui/commands/approvalModeCommand.test.ts +++ b/packages/cli/src/ui/commands/approvalModeCommand.test.ts @@ -146,6 +146,25 @@ describe('approvalModeCommand', () => { }); }); + describe('untrusted folder handling', () => { + it('should return error when setApprovalMode throws (e.g., untrusted folder)', async () => { + const errorMessage = + 'Cannot enable privileged approval modes in an untrusted folder.'; + mockSetApprovalMode.mockImplementation(() => { + throw new Error(errorMessage); + }); + + const result = (await approvalModeCommand.action?.( + mockContext, + 'yolo', + )) as MessageActionReturn; + + expect(result.type).toBe('message'); + expect(result.messageType).toBe('error'); + expect(result.content).toBe(errorMessage); + }); + }); + it('should not have subcommands', () => { expect(approvalModeCommand.subCommands).toBeUndefined(); }); diff --git a/packages/cli/src/ui/commands/approvalModeCommand.ts b/packages/cli/src/ui/commands/approvalModeCommand.ts index c53a4184d..f41e4b1cf 100644 --- a/packages/cli/src/ui/commands/approvalModeCommand.ts +++ b/packages/cli/src/ui/commands/approvalModeCommand.ts @@ -63,7 +63,15 @@ export const approvalModeCommand: SlashCommand = { // Set the mode for current session only (not persisted) const { config } = context.services; if (config) { - config.setApprovalMode(mode); + try { + config.setApprovalMode(mode); + } catch (e) { + return { + type: 'message', + messageType: 'error', + content: (e as Error).message, + }; + } } return { From 361492247ebc6e7ed0f3b4ddd4424b8967711e0d Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 7 Jan 2026 01:35:05 +0800 Subject: [PATCH 072/142] fix(vscode-ide-companion): fix cross-platform CLI execution in terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add platform.ts utility with isWindows constant - Fix Windows PowerShell execution with & call operator - Fix macOS Electron helper with ELECTRON_RUN_AS_NODE=1 - Prefer system Node.js, fallback to VS Code runtime - Refactor platform detection in acpMessageHandler and acpSessionManager 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../vscode-ide-companion/src/extension.ts | 37 ++++++++++++++++++- .../src/services/acpMessageHandler.ts | 3 +- .../src/services/acpSessionManager.ts | 3 +- .../src/utils/platform.ts | 8 ++++ 4 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 packages/vscode-ide-companion/src/utils/platform.ts diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index 2f2b462f7..b158665d9 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -17,11 +17,25 @@ import { import { WebViewProvider } from './webview/WebViewProvider.js'; import { registerNewCommands } from './commands/index.js'; import { ReadonlyFileSystemProvider } from './services/readonlyFileSystemProvider.js'; +import { isWindows } from './utils/platform.js'; +import { execSync } from 'child_process'; const CLI_IDE_COMPANION_IDENTIFIER = 'qwenlm.qwen-code-vscode-ide-companion'; const INFO_MESSAGE_SHOWN_KEY = 'qwenCodeInfoMessageShown'; export const DIFF_SCHEME = 'qwen-diff'; +/** + * Check if Node.js is available in the system PATH + */ +function isNodeAvailable(): boolean { + try { + execSync(isWindows ? 'where node' : 'which node', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + /** * IDE environments where the installation greeting is hidden. In these * environments we either are pre-installed and the installation message is @@ -312,8 +326,27 @@ export async function activate(context: vscode.ExtensionContext) { 'qwen-cli', 'cli.js', ).fsPath; - const quote = (s: string) => `"${s.replaceAll('"', '\\"')}"`; - const qwenCmd = `${quote(process.execPath)} ${quote(cliEntry)}`; + const quote = (s: string) => `"${s.replace(/"/g, '\\"')}"`; + + let qwenCmd: string; + if (isNodeAvailable()) { + // Prefer system Node.js + qwenCmd = `node ${quote(cliEntry)}`; + } else { + // Fallback to VS Code's bundled Node.js runtime + const execPath = process.execPath; + const baseCmd = `${quote(execPath)} ${quote(cliEntry)}`; + if (isWindows) { + // PowerShell requires & call operator for quoted paths + qwenCmd = `& ${baseCmd}`; + } else if (execPath.toLowerCase().includes('code helper')) { + // macOS Electron helper needs ELECTRON_RUN_AS_NODE=1 + qwenCmd = `ELECTRON_RUN_AS_NODE=1 ${baseCmd}`; + } else { + qwenCmd = baseCmd; + } + } + const terminal = vscode.window.createTerminal({ name: `Qwen Code (${selectedFolder.name})`, cwd: selectedFolder.uri.fsPath, diff --git a/packages/vscode-ide-companion/src/services/acpMessageHandler.ts b/packages/vscode-ide-companion/src/services/acpMessageHandler.ts index 8766fdf31..c2fad7701 100644 --- a/packages/vscode-ide-companion/src/services/acpMessageHandler.ts +++ b/packages/vscode-ide-companion/src/services/acpMessageHandler.ts @@ -26,6 +26,7 @@ import type { } from '../types/connectionTypes.js'; import { AcpFileHandler } from '../services/acpFileHandler.js'; import type { ChildProcess } from 'child_process'; +import { isWindows } from '../utils/platform.js'; /** * ACP Message Handler Class @@ -47,7 +48,7 @@ export class AcpMessageHandler { sendResponseMessage(child: ChildProcess | null, response: AcpResponse): void { if (child?.stdin) { const jsonString = JSON.stringify(response); - const lineEnding = process.platform === 'win32' ? '\r\n' : '\n'; + const lineEnding = isWindows ? '\r\n' : '\n'; child.stdin.write(jsonString + lineEnding); } } diff --git a/packages/vscode-ide-companion/src/services/acpSessionManager.ts b/packages/vscode-ide-companion/src/services/acpSessionManager.ts index e2055a3a2..2d85d20aa 100644 --- a/packages/vscode-ide-companion/src/services/acpSessionManager.ts +++ b/packages/vscode-ide-companion/src/services/acpSessionManager.ts @@ -19,6 +19,7 @@ import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import { AGENT_METHODS } from '../constants/acpSchema.js'; import type { PendingRequest } from '../types/connectionTypes.js'; import type { ChildProcess } from 'child_process'; +import { isWindows } from '../utils/platform.js'; /** * ACP Session Manager Class @@ -102,7 +103,7 @@ export class AcpSessionManager { ): void { if (child?.stdin) { const jsonString = JSON.stringify(message); - const lineEnding = process.platform === 'win32' ? '\r\n' : '\n'; + const lineEnding = isWindows ? '\r\n' : '\n'; child.stdin.write(jsonString + lineEnding); } } diff --git a/packages/vscode-ide-companion/src/utils/platform.ts b/packages/vscode-ide-companion/src/utils/platform.ts new file mode 100644 index 000000000..994aadc3f --- /dev/null +++ b/packages/vscode-ide-companion/src/utils/platform.ts @@ -0,0 +1,8 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** Whether the current platform is Windows */ +export const isWindows = process.platform === 'win32'; From 870d207f185108e2a5af63a5a0bb2f823385837c Mon Sep 17 00:00:00 2001 From: Weaxs <459312872@qq.com> Date: Wed, 7 Jan 2026 10:25:34 +0800 Subject: [PATCH 073/142] revert enable_thinking & thinking_budget --- .../core/openaiContentGenerator/pipeline.ts | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts index ba483fe5f..88ac38f6a 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts @@ -242,25 +242,6 @@ export class ContentGenerationPipeline { baseRequest.stream_options = { include_usage: true }; } - // Add thinking options if present - if ( - request.config?.thinkingConfig && - request.config.thinkingConfig.includeThoughts - ) { - ( - baseRequest as OpenAI.Chat.ChatCompletionCreateParams & { - enable_thinking?: boolean; - } - ).enable_thinking = true; - if (request.config.thinkingConfig.thinkingBudget) { - ( - baseRequest as OpenAI.Chat.ChatCompletionCreateParams & { - thinking_budget?: number; - } - ).thinking_budget = request.config.thinkingConfig.thinkingBudget; - } - } - // Add tools if present if (request.config?.tools) { baseRequest.tools = await this.converter.convertGeminiToolsToOpenAI( From f2d941e4696e349b5a53150d468d6e9c74e64986 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Wed, 7 Jan 2026 16:25:14 +0800 Subject: [PATCH 074/142] chore: bump version to 0.6.1 --- package-lock.json | 12 ++++++------ 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 +- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 330b90e08..0ed7071f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qwen-code/qwen-code", - "version": "0.6.0", + "version": "0.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qwen-code/qwen-code", - "version": "0.6.0", + "version": "0.6.1", "workspaces": [ "packages/*" ], @@ -17316,7 +17316,7 @@ }, "packages/cli": { "name": "@qwen-code/qwen-code", - "version": "0.6.0", + "version": "0.6.1", "dependencies": { "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", @@ -17953,7 +17953,7 @@ }, "packages/core": { "name": "@qwen-code/qwen-code-core", - "version": "0.6.0", + "version": "0.6.1", "hasInstallScript": true, "dependencies": { "@anthropic-ai/sdk": "^0.36.1", @@ -21413,7 +21413,7 @@ }, "packages/test-utils": { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.6.0", + "version": "0.6.1", "dev": true, "license": "Apache-2.0", "devDependencies": { @@ -21425,7 +21425,7 @@ }, "packages/vscode-ide-companion": { "name": "qwen-code-vscode-ide-companion", - "version": "0.6.0", + "version": "0.6.1", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.1", diff --git a/package.json b/package.json index c239067ff..107b9e9b0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.6.0", + "version": "0.6.1", "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.6.0" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.1" }, "scripts": { "start": "cross-env node scripts/start.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index f2083fe19..2154e4683 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.6.0", + "version": "0.6.1", "description": "Qwen Code", "repository": { "type": "git", @@ -33,7 +33,7 @@ "dist" ], "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.1" }, "dependencies": { "@google/genai": "1.30.0", diff --git a/packages/core/package.json b/packages/core/package.json index de06c78bc..e7baa13b2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-core", - "version": "0.6.0", + "version": "0.6.1", "description": "Qwen Code Core", "repository": { "type": "git", diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index a1310056f..435df48f3 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.6.0", + "version": "0.6.1", "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 5fa753162..50982df00 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.6.0", + "version": "0.6.1", "publisher": "qwenlm", "icon": "assets/icon.png", "repository": { From 3d059b71dedb0bbb7d58b8ffcca15e458710f9d9 Mon Sep 17 00:00:00 2001 From: xwj02155382 Date: Wed, 7 Jan 2026 16:34:12 +0800 Subject: [PATCH 075/142] refactor: improve IDE context format and editor command error handling - Change IDE context from JSON to plain text format for better LLM comprehension - Remove JSON.stringify() and code fences from getIdeContextParts() - Use human-readable format: 'Active file:', 'Cursor: line X, character Y' - Apply same format to delta updates: 'Files opened:', 'Files closed:', etc. - Update all related tests to match new plain text format - Fix editor command fallback logic in useLaunchEditor - Throw clear error when no editor command is available - Remove meaningless fallback to last command in list - Provide helpful error message with tried commands and solution --- packages/cli/src/ui/hooks/useLaunchEditor.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/hooks/useLaunchEditor.ts b/packages/cli/src/ui/hooks/useLaunchEditor.ts index da2ff6d9c..809e8a3d6 100644 --- a/packages/cli/src/ui/hooks/useLaunchEditor.ts +++ b/packages/cli/src/ui/hooks/useLaunchEditor.ts @@ -34,11 +34,17 @@ function getExecutableCommand(editorType: EditorType): string { const commands = process.platform === 'win32' ? commandConfig.win32 : commandConfig.default; - // Try to find the first available command const availableCommand = commands.find((cmd) => commandExists(cmd)); - // Return the first available command, or fall back to the last one in the list - return availableCommand || commands[commands.length - 1]; + if (!availableCommand) { + throw new Error( + `No available editor command found for ${editorType}. ` + + `Tried: ${commands.join(', ')}. ` + + `Please install one of these editors or set a different preferredEditor in settings.`, + ); + } + + return availableCommand; } /** From 0f1cb162c96d9f1a6cfd292cf11686aa633f5a58 Mon Sep 17 00:00:00 2001 From: xwj02155382 Date: Wed, 7 Jan 2026 16:46:48 +0800 Subject: [PATCH 076/142] refactor: convert IDE context from JSON to plain text format Fixes #1418 - Remove JSON.stringify() and code fences from getIdeContextParts() - Use human-readable plain text format for better LLM comprehension - Full context: 'Active file:', 'Cursor: line X, character Y' - Delta updates: 'Files opened:', 'Files closed:', 'Cursor moved:', etc. - Update all related tests to match new plain text format - All 49 tests passing This change improves the model's ability to read and reason about IDE state by eliminating escaped characters and rigid JSON structure that can confuse LLMs when interpreting file paths, cursor positions, or selection ranges. --- packages/core/src/core/client.test.ts | 87 +++++------------ packages/core/src/core/client.ts | 133 +++++++++++++++----------- 2 files changed, 102 insertions(+), 118 deletions(-) diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index f069ce4d5..b31c9550c 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -1062,26 +1062,15 @@ describe('Gemini Client (client.ts)', () => { // Assert expect(ideContextStore.get).toHaveBeenCalled(); - const expectedContext = ` -Here is the user's editor context as a JSON object. This is for your information only. -\`\`\`json -${JSON.stringify( - { - activeFile: { - path: '/path/to/active/file.ts', - cursor: { - line: 5, - character: 10, - }, - selectedText: 'hello', - }, - otherOpenFiles: ['/path/to/recent/file1.ts', '/path/to/recent/file2.ts'], - }, - null, - 2, -)} -\`\`\` - `.trim(); + const expectedContext = `Here is the user's editor context. This is for your information only. +Active file: + Path: /path/to/active/file.ts + Cursor: line 5, character 10 + Selected text: hello + +Other open files: + - /path/to/recent/file1.ts + - /path/to/recent/file2.ts`; const expectedRequest = [{ text: expectedContext }]; expect(mockChat.addHistory).toHaveBeenCalledWith({ role: 'user', @@ -1181,25 +1170,11 @@ ${JSON.stringify( // Assert expect(ideContextStore.get).toHaveBeenCalled(); - const expectedContext = ` -Here is the user's editor context as a JSON object. This is for your information only. -\`\`\`json -${JSON.stringify( - { - activeFile: { - path: '/path/to/active/file.ts', - cursor: { - line: 5, - character: 10, - }, - selectedText: 'hello', - }, - }, - null, - 2, -)} -\`\`\` - `.trim(); + const expectedContext = `Here is the user's editor context. This is for your information only. +Active file: + Path: /path/to/active/file.ts + Cursor: line 5, character 10 + Selected text: hello`; const expectedRequest = [{ text: expectedContext }]; expect(mockChat.addHistory).toHaveBeenCalledWith({ role: 'user', @@ -1258,18 +1233,10 @@ ${JSON.stringify( // Assert expect(ideContextStore.get).toHaveBeenCalled(); - const expectedContext = ` -Here is the user's editor context as a JSON object. This is for your information only. -\`\`\`json -${JSON.stringify( - { - otherOpenFiles: ['/path/to/recent/file1.ts', '/path/to/recent/file2.ts'], - }, - null, - 2, -)} -\`\`\` - `.trim(); + const expectedContext = `Here is the user's editor context. This is for your information only. +Other open files: + - /path/to/recent/file1.ts + - /path/to/recent/file2.ts`; const expectedRequest = [{ text: expectedContext }]; expect(mockChat.addHistory).toHaveBeenCalledWith({ role: 'user', @@ -1786,11 +1753,9 @@ ${JSON.stringify( // Also verify it's the full context, not a delta. const call = mockChat.addHistory.mock.calls[0][0]; const contextText = call.parts[0].text; - const contextJson = JSON.parse( - contextText.match(/```json\n(.*)\n```/s)![1], - ); - expect(contextJson).toHaveProperty('activeFile'); - expect(contextJson.activeFile.path).toBe('/path/to/active/file.ts'); + // Verify it contains the active file information in plain text format + expect(contextText).toContain('Active file:'); + expect(contextText).toContain('Path: /path/to/active/file.ts'); }); }); @@ -1993,7 +1958,7 @@ ${JSON.stringify( ); expect(contextCall).toBeDefined(); expect(JSON.stringify(contextCall![0])).toContain( - "Here is the user's editor context as a JSON object", + "Here is the user's editor context.", ); // Check that the sent context is the new one (fileB.ts) expect(JSON.stringify(contextCall![0])).toContain('fileB.ts'); @@ -2029,9 +1994,7 @@ ${JSON.stringify( // Assert: Full context for fileA.ts was sent and stored. const initialCall = vi.mocked(mockChat.addHistory!).mock.calls[0][0]; - expect(JSON.stringify(initialCall)).toContain( - "user's editor context as a JSON object", - ); + expect(JSON.stringify(initialCall)).toContain("user's editor context."); expect(JSON.stringify(initialCall)).toContain('fileA.ts'); // This implicitly tests that `lastSentIdeContext` is now set internally by the client. vi.mocked(mockChat.addHistory!).mockClear(); @@ -2129,9 +2092,9 @@ ${JSON.stringify( const finalCall = vi.mocked(mockChat.addHistory!).mock.calls[0][0]; expect(JSON.stringify(finalCall)).toContain('summary of changes'); // The delta should reflect fileA being closed and fileC being opened. - expect(JSON.stringify(finalCall)).toContain('filesClosed'); + expect(JSON.stringify(finalCall)).toContain('Files closed'); expect(JSON.stringify(finalCall)).toContain('fileA.ts'); - expect(JSON.stringify(finalCall)).toContain('activeFileChanged'); + expect(JSON.stringify(finalCall)).toContain('Active file changed'); expect(JSON.stringify(finalCall)).toContain('fileC.ts'); }); }); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 6c62478d0..0c851477d 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -219,42 +219,45 @@ export class GeminiClient { } if (forceFullContext || !this.lastSentIdeContext) { - // Send full context as JSON + // Send full context as plain text const openFiles = currentIdeContext.workspaceState?.openFiles || []; const activeFile = openFiles.find((f) => f.isActive); const otherOpenFiles = openFiles .filter((f) => !f.isActive) .map((f) => f.path); - const contextData: Record = {}; + const contextLines: string[] = []; if (activeFile) { - contextData['activeFile'] = { - path: activeFile.path, - cursor: activeFile.cursor - ? { - line: activeFile.cursor.line, - character: activeFile.cursor.character, - } - : undefined, - selectedText: activeFile.selectedText || undefined, - }; + contextLines.push('Active file:'); + contextLines.push(` Path: ${activeFile.path}`); + if (activeFile.cursor) { + contextLines.push( + ` Cursor: line ${activeFile.cursor.line}, character ${activeFile.cursor.character}`, + ); + } + if (activeFile.selectedText) { + contextLines.push(` Selected text: ${activeFile.selectedText}`); + } } if (otherOpenFiles.length > 0) { - contextData['otherOpenFiles'] = otherOpenFiles; + if (contextLines.length > 0) { + contextLines.push(''); + } + contextLines.push('Other open files:'); + for (const filePath of otherOpenFiles) { + contextLines.push(` - ${filePath}`); + } } - if (Object.keys(contextData).length === 0) { + if (contextLines.length === 0) { return { contextParts: [], newIdeContext: currentIdeContext }; } - const jsonString = JSON.stringify(contextData, null, 2); const contextParts = [ - "Here is the user's editor context as a JSON object. This is for your information only.", - '```json', - jsonString, - '```', + "Here is the user's editor context. This is for your information only.", + contextLines.join('\n'), ]; if (this.config.getDebugMode()) { @@ -265,9 +268,8 @@ export class GeminiClient { newIdeContext: currentIdeContext, }; } else { - // Calculate and send delta as JSON - const delta: Record = {}; - const changes: Record = {}; + // Calculate and send delta as plain text + const changeLines: string[] = []; const lastFiles = new Map( (this.lastSentIdeContext.workspaceState?.openFiles || []).map( @@ -288,7 +290,10 @@ export class GeminiClient { } } if (openedFiles.length > 0) { - changes['filesOpened'] = openedFiles; + changeLines.push('Files opened:'); + for (const filePath of openedFiles) { + changeLines.push(` - ${filePath}`); + } } const closedFiles: string[] = []; @@ -298,7 +303,13 @@ export class GeminiClient { } } if (closedFiles.length > 0) { - changes['filesClosed'] = closedFiles; + if (changeLines.length > 0) { + changeLines.push(''); + } + changeLines.push('Files closed:'); + for (const filePath of closedFiles) { + changeLines.push(` - ${filePath}`); + } } const lastActiveFile = ( @@ -310,16 +321,21 @@ export class GeminiClient { if (currentActiveFile) { if (!lastActiveFile || lastActiveFile.path !== currentActiveFile.path) { - changes['activeFileChanged'] = { - path: currentActiveFile.path, - cursor: currentActiveFile.cursor - ? { - line: currentActiveFile.cursor.line, - character: currentActiveFile.cursor.character, - } - : undefined, - selectedText: currentActiveFile.selectedText || undefined, - }; + if (changeLines.length > 0) { + changeLines.push(''); + } + changeLines.push('Active file changed:'); + changeLines.push(` Path: ${currentActiveFile.path}`); + if (currentActiveFile.cursor) { + changeLines.push( + ` Cursor: line ${currentActiveFile.cursor.line}, character ${currentActiveFile.cursor.character}`, + ); + } + if (currentActiveFile.selectedText) { + changeLines.push( + ` Selected text: ${currentActiveFile.selectedText}`, + ); + } } else { const lastCursor = lastActiveFile.cursor; const currentCursor = currentActiveFile.cursor; @@ -329,42 +345,47 @@ export class GeminiClient { lastCursor.line !== currentCursor.line || lastCursor.character !== currentCursor.character) ) { - changes['cursorMoved'] = { - path: currentActiveFile.path, - cursor: { - line: currentCursor.line, - character: currentCursor.character, - }, - }; + if (changeLines.length > 0) { + changeLines.push(''); + } + changeLines.push('Cursor moved:'); + changeLines.push(` Path: ${currentActiveFile.path}`); + changeLines.push( + ` New position: line ${currentCursor.line}, character ${currentCursor.character}`, + ); } const lastSelectedText = lastActiveFile.selectedText || ''; const currentSelectedText = currentActiveFile.selectedText || ''; if (lastSelectedText !== currentSelectedText) { - changes['selectionChanged'] = { - path: currentActiveFile.path, - selectedText: currentSelectedText, - }; + if (changeLines.length > 0) { + changeLines.push(''); + } + changeLines.push('Selection changed:'); + changeLines.push(` Path: ${currentActiveFile.path}`); + if (currentSelectedText) { + changeLines.push(` Selected text: ${currentSelectedText}`); + } else { + changeLines.push(' Selected text: (none)'); + } } } } else if (lastActiveFile) { - changes['activeFileChanged'] = { - path: null, - previousPath: lastActiveFile.path, - }; + if (changeLines.length > 0) { + changeLines.push(''); + } + changeLines.push('Active file changed:'); + changeLines.push(' No active file'); + changeLines.push(` Previous path: ${lastActiveFile.path}`); } - if (Object.keys(changes).length === 0) { + if (changeLines.length === 0) { return { contextParts: [], newIdeContext: currentIdeContext }; } - delta['changes'] = changes; - const jsonString = JSON.stringify(delta, null, 2); const contextParts = [ - "Here is a summary of changes in the user's editor context, in JSON format. This is for your information only.", - '```json', - jsonString, - '```', + "Here is a summary of changes in the user's editor context. This is for your information only.", + changeLines.join('\n'), ]; if (this.config.getDebugMode()) { From bfc3bbfa9c425db872f4543729b6d3a77eaaeabd Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Wed, 7 Jan 2026 17:25:27 +0800 Subject: [PATCH 077/142] update user messages --- packages/cli/src/ui/IdeIntegrationNudge.tsx | 2 +- packages/cli/src/ui/commands/ideCommand.ts | 16 ++-------------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/ui/IdeIntegrationNudge.tsx b/packages/cli/src/ui/IdeIntegrationNudge.tsx index a53f59b98..9cd6d5311 100644 --- a/packages/cli/src/ui/IdeIntegrationNudge.tsx +++ b/packages/cli/src/ui/IdeIntegrationNudge.tsx @@ -74,7 +74,7 @@ export function IdeIntegrationNudge({ const installText = isInSandbox ? `Note: In sandbox environments, IDE integration requires manual setup on the host system. If you select Yes, you'll receive instructions on how to set this up.` : isExtensionPreInstalled - ? `The IDE extension appears to be already installed. If you select Yes, the CLI will connect to your ${ + ? `If you select Yes, the CLI will connect to your ${ ideName ?? 'editor' } and have access to your open files and display diffs directly.` : `If you select Yes, we'll install an extension that allows the CLI to access your open files and display diffs directly in ${ diff --git a/packages/cli/src/ui/commands/ideCommand.ts b/packages/cli/src/ui/commands/ideCommand.ts index 2440ca852..be3a12bc9 100644 --- a/packages/cli/src/ui/commands/ideCommand.ts +++ b/packages/cli/src/ui/commands/ideCommand.ts @@ -204,22 +204,10 @@ export const ideCommand = async (): Promise => { } if (!installer) { const ideName = ideClient.getDetectedIdeDisplayName(); - const isVSCode = currentIDE.name === 'vscode'; - let type: 'error' | 'info' = 'error'; - let message: string; - if (isVSCode) { - // VS Code - message = `No installer is available for ${ideName}. Please install the '${QWEN_CODE_COMPANION_EXTENSION_NAME}' extension manually from the marketplace.`; - } else { - // NO VS Code - type = 'info'; - message = `Automatic installation is not supported for ${ideName}. Please install the extension manually or install '${QWEN_CODE_COMPANION_EXTENSION_NAME}' in VS Code. If you have installed it before, please ignore the reminder and directly connect the ide extension`; - } - context.ui.addItem( { - type, - text: message, + type: 'error', + text: `Automatic installation is not supported for ${ideName}. Please install the '${QWEN_CODE_COMPANION_EXTENSION_NAME}' extension manually from the marketplace.`, }, Date.now(), ); From f8aecb26311a43630ee854458c17fca930b55b47 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Wed, 7 Jan 2026 19:29:49 +0800 Subject: [PATCH 078/142] only allow shell execution in current working directory for skills --- .../tools/__snapshots__/shell.test.ts.snap | 130 ++++++++++-------- packages/core/src/tools/shell.test.ts | 39 ++++++ packages/core/src/tools/shell.ts | 77 +++++++---- packages/core/src/tools/skill.ts | 6 +- 4 files changed, 170 insertions(+), 82 deletions(-) diff --git a/packages/core/src/tools/__snapshots__/shell.test.ts.snap b/packages/core/src/tools/__snapshots__/shell.test.ts.snap index 2d6214f60..955fd8000 100644 --- a/packages/core/src/tools/__snapshots__/shell.test.ts.snap +++ b/packages/core/src/tools/__snapshots__/shell.test.ts.snap @@ -2,66 +2,88 @@ exports[`ShellTool > getDescription > should return the non-windows description when not on windows 1`] = ` "This tool executes a given shell command as \`bash -c \`. Command can start background processes using \`&\`. Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`. +**Usage notes**: +- The command argument is required. +- It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - **Background vs Foreground Execution:** - You should decide whether commands should run in background or foreground based on their nature: - - **Use background execution (is_background: true) for:** - - Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\` - - Build watchers: \`npm run watch\`, \`webpack --watch\` - - Database servers: \`mongod\`, \`mysql\`, \`redis-server\` - - Web servers: \`python -m http.server\`, \`php -S localhost:8000\` - - Any command expected to run indefinitely until manually stopped - - **Use foreground execution (is_background: false) for:** - - One-time commands: \`ls\`, \`cat\`, \`grep\` - - Build commands: \`npm run build\`, \`make\` - - Installation commands: \`npm install\`, \`pip install\` - - Git operations: \`git commit\`, \`git push\` - - Test runs: \`npm test\`, \`pytest\` - - The following information is returned: +- Avoid using run_shell_command with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use glob (NOT find or ls) + - Content search: Use grep_search (NOT grep or rg) + - Read files: Use read_file (NOT cat/head/tail) + - Edit files: Use edit (NOT sed/awk) + - Write files: Use write_file (NOT echo >/cat < + pytest /foo/bar/tests + + + cd /foo/bar && pytest tests + - Command: Executed command. - Directory: Directory where command was executed, or \`(root)\`. - Stdout: Output on stdout stream. Can be \`(empty)\` or partial on error and for any unwaited background processes. - Stderr: Output on stderr stream. Can be \`(empty)\` or partial on error and for any unwaited background processes. - Error: Error or \`(none)\` if no error was reported for the subprocess. - Exit Code: Exit code or \`(none)\` if terminated by signal. - Signal: Signal number or \`(none)\` if no signal was received. - Background PIDs: List of background processes started or \`(none)\`. - Process Group PGID: Process group started or \`(none)\`" +**Background vs Foreground Execution:** +You should decide whether commands should run in background or foreground based on their nature: + +**Use background execution (is_background: true) for:** +- Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\` +- Build watchers: \`npm run watch\`, \`webpack --watch\` +- Database servers: \`mongod\`, \`mysql\`, \`redis-server\` +- Web servers: \`python -m http.server\`, \`php -S localhost:8000\` +- Any command expected to run indefinitely until manually stopped + +**Use foreground execution (is_background: false) for:** +- One-time commands: \`ls\`, \`cat\`, \`grep\` +- Build commands: \`npm run build\`, \`make\` +- Installation commands: \`npm install\`, \`pip install\` +- Git operations: \`git commit\`, \`git push\` +- Test runs: \`npm test\`, \`pytest\`" `; exports[`ShellTool > getDescription > should return the windows description when on windows 1`] = ` "This tool executes a given shell command as \`cmd.exe /c \`. Command can start background processes using \`start /b\`. +**Usage notes**: +- The command argument is required. +- It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - **Background vs Foreground Execution:** - You should decide whether commands should run in background or foreground based on their nature: - - **Use background execution (is_background: true) for:** - - Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\` - - Build watchers: \`npm run watch\`, \`webpack --watch\` - - Database servers: \`mongod\`, \`mysql\`, \`redis-server\` - - Web servers: \`python -m http.server\`, \`php -S localhost:8000\` - - Any command expected to run indefinitely until manually stopped - - **Use foreground execution (is_background: false) for:** - - One-time commands: \`ls\`, \`cat\`, \`grep\` - - Build commands: \`npm run build\`, \`make\` - - Installation commands: \`npm install\`, \`pip install\` - - Git operations: \`git commit\`, \`git push\` - - Test runs: \`npm test\`, \`pytest\` - - The following information is returned: +- Avoid using run_shell_command with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use glob (NOT find or ls) + - Content search: Use grep_search (NOT grep or rg) + - Read files: Use read_file (NOT cat/head/tail) + - Edit files: Use edit (NOT sed/awk) + - Write files: Use write_file (NOT echo >/cat < + pytest /foo/bar/tests + + + cd /foo/bar && pytest tests + - Command: Executed command. - Directory: Directory where command was executed, or \`(root)\`. - Stdout: Output on stdout stream. Can be \`(empty)\` or partial on error and for any unwaited background processes. - Stderr: Output on stderr stream. Can be \`(empty)\` or partial on error and for any unwaited background processes. - Error: Error or \`(none)\` if no error was reported for the subprocess. - Exit Code: Exit code or \`(none)\` if terminated by signal. - Signal: Signal number or \`(none)\` if no signal was received. - Background PIDs: List of background processes started or \`(none)\`. - Process Group PGID: Process group started or \`(none)\`" +**Background vs Foreground Execution:** +You should decide whether commands should run in background or foreground based on their nature: + +**Use background execution (is_background: true) for:** +- Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\` +- Build watchers: \`npm run watch\`, \`webpack --watch\` +- Database servers: \`mongod\`, \`mysql\`, \`redis-server\` +- Web servers: \`python -m http.server\`, \`php -S localhost:8000\` +- Any command expected to run indefinitely until manually stopped + +**Use foreground execution (is_background: false) for:** +- One-time commands: \`ls\`, \`cat\`, \`grep\` +- Build commands: \`npm run build\`, \`make\` +- Installation commands: \`npm install\`, \`pip install\` +- Git operations: \`git commit\`, \`git push\` +- Test runs: \`npm test\`, \`pytest\`" `; diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index 8b6788a70..47eac0e86 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -59,6 +59,9 @@ describe('ShellTool', () => { getWorkspaceContext: vi .fn() .mockReturnValue(createMockWorkspaceContext('/test/dir')), + storage: { + getUserSkillsDir: vi.fn().mockReturnValue('/test/dir/.qwen/skills'), + }, getGeminiClient: vi.fn(), getGitCoAuthor: vi.fn().mockReturnValue({ enabled: true, @@ -142,6 +145,42 @@ describe('ShellTool', () => { ); }); + it('should throw an error for a directory within the user skills directory', () => { + expect(() => + shellTool.build({ + command: 'ls', + directory: '/test/dir/.qwen/skills/my-skill', + is_background: false, + }), + ).toThrow( + 'Explicitly running shell commands from within the user skills directory is not allowed. Please use absolute paths for command parameter instead.', + ); + }); + + it('should throw an error for the user skills directory itself', () => { + expect(() => + shellTool.build({ + command: 'ls', + directory: '/test/dir/.qwen/skills', + is_background: false, + }), + ).toThrow( + 'Explicitly running shell commands from within the user skills directory is not allowed. Please use absolute paths for command parameter instead.', + ); + }); + + it('should resolve directory path before checking user skills directory', () => { + expect(() => + shellTool.build({ + command: 'ls', + directory: '/test/dir/.qwen/skills/../skills/my-skill', + is_background: false, + }), + ).toThrow( + 'Explicitly running shell commands from within the user skills directory is not allowed. Please use absolute paths for command parameter instead.', + ); + }); + it('should return an invocation for a valid absolute directory path', () => { (mockConfig.getWorkspaceContext as Mock).mockReturnValue( createMockWorkspaceContext('/test/dir', ['/another/workspace']), diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index d7afae599..8cfd9da8a 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -34,6 +34,7 @@ import type { import { ShellExecutionService } from '../services/shellExecutionService.js'; import { formatMemoryUsage } from '../utils/formatters.js'; import type { AnsiOutput } from '../utils/terminalSerializer.js'; +import { isSubpath } from '../utils/paths.js'; import { getCommandRoots, isCommandAllowed, @@ -407,35 +408,46 @@ Co-authored-by: ${gitCoAuthorSettings.name} <${gitCoAuthorSettings.email}>`; function getShellToolDescription(): string { const toolDescription = ` +**Usage notes**: +- The command argument is required. +- It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - **Background vs Foreground Execution:** - You should decide whether commands should run in background or foreground based on their nature: - - **Use background execution (is_background: true) for:** - - Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\` - - Build watchers: \`npm run watch\`, \`webpack --watch\` - - Database servers: \`mongod\`, \`mysql\`, \`redis-server\` - - Web servers: \`python -m http.server\`, \`php -S localhost:8000\` - - Any command expected to run indefinitely until manually stopped - - **Use foreground execution (is_background: false) for:** - - One-time commands: \`ls\`, \`cat\`, \`grep\` - - Build commands: \`npm run build\`, \`make\` - - Installation commands: \`npm install\`, \`pip install\` - - Git operations: \`git commit\`, \`git push\` - - Test runs: \`npm test\`, \`pytest\` - - The following information is returned: +- Avoid using run_shell_command with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use glob (NOT find or ls) + - Content search: Use grep_search (NOT grep or rg) + - Read files: Use read_file (NOT cat/head/tail) + - Edit files: Use edit (NOT sed/awk) + - Write files: Use write_file (NOT echo >/cat < + pytest /foo/bar/tests + + + cd /foo/bar && pytest tests + - Command: Executed command. - Directory: Directory where command was executed, or \`(root)\`. - Stdout: Output on stdout stream. Can be \`(empty)\` or partial on error and for any unwaited background processes. - Stderr: Output on stderr stream. Can be \`(empty)\` or partial on error and for any unwaited background processes. - Error: Error or \`(none)\` if no error was reported for the subprocess. - Exit Code: Exit code or \`(none)\` if terminated by signal. - Signal: Signal number or \`(none)\` if no signal was received. - Background PIDs: List of background processes started or \`(none)\`. - Process Group PGID: Process group started or \`(none)\``; +**Background vs Foreground Execution:** +You should decide whether commands should run in background or foreground based on their nature: + +**Use background execution (is_background: true) for:** +- Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\` +- Build watchers: \`npm run watch\`, \`webpack --watch\` +- Database servers: \`mongod\`, \`mysql\`, \`redis-server\` +- Web servers: \`python -m http.server\`, \`php -S localhost:8000\` +- Any command expected to run indefinitely until manually stopped + +**Use foreground execution (is_background: false) for:** +- One-time commands: \`ls\`, \`cat\`, \`grep\` +- Build commands: \`npm run build\`, \`make\` +- Installation commands: \`npm install\`, \`pip install\` +- Git operations: \`git commit\`, \`git push\` +- Test runs: \`npm test\`, \`pytest\``; if (os.platform() === 'win32') { return `This tool executes a given shell command as \`cmd.exe /c \`. Command can start background processes using \`start /b\`.${toolDescription}`; @@ -526,6 +538,17 @@ export class ShellTool extends BaseDeclarativeTool< if (!path.isAbsolute(params.directory)) { return 'Directory must be an absolute path.'; } + + const userSkillsDir = this.config.storage.getUserSkillsDir(); + const resolvedDirectoryPath = path.resolve(params.directory); + const isWithinUserSkills = isSubpath( + userSkillsDir, + resolvedDirectoryPath, + ); + if (isWithinUserSkills) { + return `Explicitly running shell commands from within the user skills directory is not allowed. Please use absolute paths for command parameter instead.`; + } + const workspaceDirs = this.config.getWorkspaceContext().getDirectories(); const isWithinWorkspace = workspaceDirs.some((wsDir) => params.directory!.startsWith(wsDir), diff --git a/packages/core/src/tools/skill.ts b/packages/core/src/tools/skill.ts index d0a1fce69..b48d007d0 100644 --- a/packages/core/src/tools/skill.ts +++ b/packages/core/src/tools/skill.ts @@ -128,6 +128,10 @@ Important: - Only use skills listed in below - Do not invoke a skill that is already running - Do not use this tool for built-in CLI commands (like /help, /clear, etc.) +- When executing scripts or loading referenced files, ALWAYS resolve absolute paths from skill's base directory. Examples: + - \`bash scripts/init.sh\` -> \`bash /path/to/skill/scripts/init.sh\` + - \`python scripts/helper.py\` -> \`python /path/to/skill/scripts/helper.py\` + - \`reference.md\` -> \`/path/to/skill/reference.md\` @@ -238,7 +242,7 @@ class SkillToolInvocation extends BaseToolInvocation { const baseDir = path.dirname(skill.filePath); // Build markdown content for LLM (show base dir, then body) - const llmContent = `Base directory for this skill: ${baseDir}\n\n${skill.body}\n`; + const llmContent = `Base directory for this skill: ${baseDir}\nImportant: ALWAYS resolve absolute paths from this base directory when working with skills.\n\n${skill.body}\n`; return { llmContent: [{ text: llmContent }], From 4d54a231b38d1b0a9783738e501bac85676039a2 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 7 Jan 2026 19:37:47 +0800 Subject: [PATCH 079/142] fix(core): ensure OAuth URL always displayed in headless mode - Always show authentication URL before attempting browser launch - Fixes issue where browser launch silently fails in headless environments - Improves error logging for browser launch failures Fixes #1425 --- packages/core/src/qwen/qwenOAuth2.ts | 29 ++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/core/src/qwen/qwenOAuth2.ts b/packages/core/src/qwen/qwenOAuth2.ts index 1435c782d..eead37921 100644 --- a/packages/core/src/qwen/qwenOAuth2.ts +++ b/packages/core/src/qwen/qwenOAuth2.ts @@ -601,8 +601,17 @@ async function authWithQwenDeviceFlow( console.log('Waiting for authorization to complete...\n'); }; - // If browser launch is not suppressed, try to open the URL - if (!config.isBrowserLaunchSuppressed()) { + // Always show the fallback message in non-interactive environments to ensure + // users can see the authorization URL even if browser launching is attempted. + // This is critical for headless/remote environments where browser launching + // may silently fail without throwing an error. + if (config.isBrowserLaunchSuppressed()) { + // Browser launch is suppressed, show fallback message + showFallbackMessage(); + } else { + // Try to open the URL in browser, but always show the URL as fallback + // to handle cases where browser launch silently fails (e.g., headless servers) + showFallbackMessage(); try { const childProcess = await open(deviceAuth.verification_uri_complete); @@ -611,19 +620,19 @@ async function authWithQwenDeviceFlow( // in a minimal Docker container), it will emit an unhandled 'error' event, // causing the entire Node.js process to crash. if (childProcess) { - childProcess.on('error', () => { + childProcess.on('error', (err) => { console.debug( - 'Failed to open browser. Visit this URL to authorize:', + 'Browser launch failed:', + err.message || 'Unknown error', ); - showFallbackMessage(); }); } - } catch (_err) { - showFallbackMessage(); + } catch (err) { + console.debug( + 'Failed to open browser:', + err instanceof Error ? err.message : 'Unknown error', + ); } - } else { - // Browser launch is suppressed, show fallback message - showFallbackMessage(); } // Emit auth progress event From 8a1501759350eb0685f41b0ea3aa7415c59064b5 Mon Sep 17 00:00:00 2001 From: liqoingyu Date: Wed, 7 Jan 2026 20:07:41 +0800 Subject: [PATCH 080/142] fix(cli): /memory show respects context.fileName --- .../cli/src/ui/commands/memoryCommand.test.ts | 68 +++++++++++++++++++ packages/cli/src/ui/commands/memoryCommand.ts | 14 ++-- 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts index 233c861b1..6e50136fd 100644 --- a/packages/cli/src/ui/commands/memoryCommand.test.ts +++ b/packages/cli/src/ui/commands/memoryCommand.test.ts @@ -11,9 +11,12 @@ import type { SlashCommand, type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { MessageType } from '../types.js'; import type { LoadedSettings } from '../../config/settings.js'; +import { readFile } from 'node:fs/promises'; +import os from 'node:os'; import { getErrorMessage, loadServerHierarchicalMemory, + setGeminiMdFilename, type FileDiscoveryService, type LoadServerHierarchicalMemoryResponse, } from '@qwen-code/qwen-code-core'; @@ -31,7 +34,18 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { }; }); +vi.mock('node:fs/promises', () => { + const readFile = vi.fn(); + return { + readFile, + default: { + readFile, + }, + }; +}); + const mockLoadServerHierarchicalMemory = loadServerHierarchicalMemory as Mock; +const mockReadFile = readFile as unknown as Mock; describe('memoryCommand', () => { let mockContext: CommandContext; @@ -52,6 +66,10 @@ describe('memoryCommand', () => { let mockGetGeminiMdFileCount: Mock; beforeEach(() => { + setGeminiMdFilename('QWEN.md'); + mockReadFile.mockReset(); + vi.restoreAllMocks(); + showCommand = getSubCommand('show'); mockGetUserMemory = vi.fn(); @@ -102,6 +120,56 @@ describe('memoryCommand', () => { expect.any(Number), ); }); + + it('should show project memory from the configured context file', async () => { + const projectCommand = showCommand.subCommands?.find( + (cmd) => cmd.name === '--project', + ); + if (!projectCommand?.action) throw new Error('Command has no action'); + + setGeminiMdFilename('AGENTS.md'); + vi.spyOn(process, 'cwd').mockReturnValue('/test/project'); + mockReadFile.mockResolvedValue('project memory'); + + await projectCommand.action(mockContext, ''); + + expect(mockReadFile).toHaveBeenCalledWith( + '/test/project/AGENTS.md', + 'utf-8', + ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: expect.stringContaining('/test/project/AGENTS.md'), + }, + expect.any(Number), + ); + }); + + it('should show global memory from the configured context file', async () => { + const globalCommand = showCommand.subCommands?.find( + (cmd) => cmd.name === '--global', + ); + if (!globalCommand?.action) throw new Error('Command has no action'); + + setGeminiMdFilename('AGENTS.md'); + vi.spyOn(os, 'homedir').mockReturnValue('/home/user'); + mockReadFile.mockResolvedValue('global memory'); + + await globalCommand.action(mockContext, ''); + + expect(mockReadFile).toHaveBeenCalledWith( + '/home/user/.qwen/AGENTS.md', + 'utf-8', + ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: expect.stringContaining('Global memory content'), + }, + expect.any(Number), + ); + }); }); describe('/memory add', () => { diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index 013b815d0..05641178c 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -6,12 +6,13 @@ import { getErrorMessage, + getCurrentGeminiMdFilename, loadServerHierarchicalMemory, QWEN_DIR, } from '@qwen-code/qwen-code-core'; import path from 'node:path'; -import os from 'os'; -import fs from 'fs/promises'; +import os from 'node:os'; +import fs from 'node:fs/promises'; import { MessageType } from '../types.js'; import type { SlashCommand, SlashCommandActionReturn } from './types.js'; import { CommandKind } from './types.js'; @@ -56,7 +57,12 @@ export const memoryCommand: SlashCommand = { kind: CommandKind.BUILT_IN, action: async (context) => { try { - const projectMemoryPath = path.join(process.cwd(), 'QWEN.md'); + const workingDir = + context.services.config?.getWorkingDir?.() ?? process.cwd(); + const projectMemoryPath = path.join( + workingDir, + getCurrentGeminiMdFilename(), + ); const memoryContent = await fs.readFile( projectMemoryPath, 'utf-8', @@ -104,7 +110,7 @@ export const memoryCommand: SlashCommand = { const globalMemoryPath = path.join( os.homedir(), QWEN_DIR, - 'QWEN.md', + getCurrentGeminiMdFilename(), ); const globalMemoryContent = await fs.readFile( globalMemoryPath, From 0a0ab64da0e138d3672d3fc071721a65a15b5e3e Mon Sep 17 00:00:00 2001 From: liqoingyu Date: Wed, 7 Jan 2026 20:28:28 +0800 Subject: [PATCH 081/142] test(cli): make memoryCommand path assertions cross-platform --- .../cli/src/ui/commands/memoryCommand.test.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts index 6e50136fd..7e20bf11c 100644 --- a/packages/cli/src/ui/commands/memoryCommand.test.ts +++ b/packages/cli/src/ui/commands/memoryCommand.test.ts @@ -13,9 +13,11 @@ import { MessageType } from '../types.js'; import type { LoadedSettings } from '../../config/settings.js'; import { readFile } from 'node:fs/promises'; import os from 'node:os'; +import path from 'node:path'; import { getErrorMessage, loadServerHierarchicalMemory, + QWEN_DIR, setGeminiMdFilename, type FileDiscoveryService, type LoadServerHierarchicalMemoryResponse, @@ -133,14 +135,12 @@ describe('memoryCommand', () => { await projectCommand.action(mockContext, ''); - expect(mockReadFile).toHaveBeenCalledWith( - '/test/project/AGENTS.md', - 'utf-8', - ); + const expectedProjectPath = path.join('/test/project', 'AGENTS.md'); + expect(mockReadFile).toHaveBeenCalledWith(expectedProjectPath, 'utf-8'); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.INFO, - text: expect.stringContaining('/test/project/AGENTS.md'), + text: expect.stringContaining(expectedProjectPath), }, expect.any(Number), ); @@ -158,10 +158,8 @@ describe('memoryCommand', () => { await globalCommand.action(mockContext, ''); - expect(mockReadFile).toHaveBeenCalledWith( - '/home/user/.qwen/AGENTS.md', - 'utf-8', - ); + const expectedGlobalPath = path.join('/home/user', QWEN_DIR, 'AGENTS.md'); + expect(mockReadFile).toHaveBeenCalledWith(expectedGlobalPath, 'utf-8'); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.INFO, From 052337861b090b76a69863c2c46495b8682c7949 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 7 Jan 2026 21:05:49 +0800 Subject: [PATCH 082/142] Fix #1416 --- packages/vscode-ide-companion/src/extension.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index b158665d9..73c86949c 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -340,8 +340,8 @@ export async function activate(context: vscode.ExtensionContext) { // PowerShell requires & call operator for quoted paths qwenCmd = `& ${baseCmd}`; } else if (execPath.toLowerCase().includes('code helper')) { - // macOS Electron helper needs ELECTRON_RUN_AS_NODE=1 - qwenCmd = `ELECTRON_RUN_AS_NODE=1 ${baseCmd}`; + // macOS Electron helper needs ELECTRON_RUN_AS_NODE=1; add -i to force TUI mode + qwenCmd = `ELECTRON_RUN_AS_NODE=1 ${baseCmd} -i`; } else { qwenCmd = baseCmd; } From aa9cdf2a3c37982ab5580e5f0da94b9e0c5d720b Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Tue, 30 Dec 2025 19:49:15 +0800 Subject: [PATCH 083/142] review: stage1 --- packages/cli/src/config/auth.test.ts | 2 +- packages/cli/src/config/auth.ts | 6 +- packages/cli/src/config/config.ts | 18 +++ packages/cli/src/config/settingsSchema.ts | 14 ++ packages/cli/src/utils/modelProviderUtils.ts | 142 ++++++++++++++++++ .../core/src/config/flashFallback.test.ts | 5 +- .../core/src/core/coreToolScheduler.test.ts | 26 ++-- packages/core/src/core/geminiChat.test.ts | 2 +- .../core/nonInteractiveToolExecutor.test.ts | 2 +- .../core/openaiContentGenerator/converter.ts | 8 + 10 files changed, 206 insertions(+), 19 deletions(-) create mode 100644 packages/cli/src/utils/modelProviderUtils.ts diff --git a/packages/cli/src/config/auth.test.ts b/packages/cli/src/config/auth.test.ts index e28184ac8..6f6b584ef 100644 --- a/packages/cli/src/config/auth.test.ts +++ b/packages/cli/src/config/auth.test.ts @@ -32,7 +32,7 @@ describe('validateAuthMethod', () => { it('should return an error message for USE_OPENAI if OPENAI_API_KEY is not set', () => { delete process.env['OPENAI_API_KEY']; expect(validateAuthMethod(AuthType.USE_OPENAI)).toBe( - 'OPENAI_API_KEY environment variable not found. You can enter it interactively or add it to your .env file.', + "Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the 'OPENAI_API_KEY' environment variable. If you configured a model in settings.modelProviders with an envKey, set that env var as well.", ); }); diff --git a/packages/cli/src/config/auth.ts b/packages/cli/src/config/auth.ts index 9f5d50a07..42fbf280f 100644 --- a/packages/cli/src/config/auth.ts +++ b/packages/cli/src/config/auth.ts @@ -15,7 +15,11 @@ export function validateAuthMethod(authMethod: string): string | null { const hasApiKey = process.env['OPENAI_API_KEY'] || settings.merged.security?.auth?.apiKey; if (!hasApiKey) { - return 'OPENAI_API_KEY environment variable not found. You can enter it interactively or add it to your .env file.'; + return ( + 'Missing API key for OpenAI-compatible auth. ' + + "Set settings.security.auth.apiKey, or set the 'OPENAI_API_KEY' environment variable. " + + 'If you configured a model in settings.modelProviders with an envKey, set that env var as well.' + ); } return null; } diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 6dc51ecb4..bc5da7bfc 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -31,6 +31,10 @@ import { } from '@qwen-code/qwen-code-core'; import { extensionsCommand } from '../commands/extensions.js'; import type { Settings } from './settings.js'; +import { + buildGenerationConfigSources, + getModelProvidersConfigFromSettings, +} from '../utils/modelProviderUtils.js'; import yargs, { type Argv } from 'yargs'; import { hideBin } from 'yargs/helpers'; import * as fs from 'node:fs'; @@ -979,6 +983,18 @@ export async function loadCliConfig( } } + const modelProvidersConfig = getModelProvidersConfigFromSettings(settings); + const generationConfigSources = buildGenerationConfigSources({ + argv: { + model: argv.model, + openaiApiKey: argv.openaiApiKey, + openaiBaseUrl: argv.openaiBaseUrl, + }, + settings, + selectedAuthType, + env: process.env as Record, + }); + return new Config({ sessionId, sessionData, @@ -1036,6 +1052,8 @@ export async function loadCliConfig( inputFormat, outputFormat, includePartialMessages, + modelProvidersConfig, + generationConfigSources, generationConfig: { ...(settings.model?.generationConfig || {}), model: resolvedModel, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 5159613b6..4562546ff 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -10,6 +10,7 @@ import type { TelemetrySettings, AuthType, ChatCompressionSettings, + ModelProvidersConfig, } from '@qwen-code/qwen-code-core'; import { ApprovalMode, @@ -102,6 +103,19 @@ const SETTINGS_SCHEMA = { mergeStrategy: MergeStrategy.SHALLOW_MERGE, }, + // Model providers configuration grouped by authType + modelProviders: { + type: 'object', + label: 'Model Providers', + category: 'Model', + requiresRestart: false, + default: {} as ModelProvidersConfig, + description: + 'Model providers configuration grouped by authType. Each authType contains an array of model configurations.', + showInDialog: false, + mergeStrategy: MergeStrategy.SHALLOW_MERGE, + }, + general: { type: 'object', label: 'General', diff --git a/packages/cli/src/utils/modelProviderUtils.ts b/packages/cli/src/utils/modelProviderUtils.ts new file mode 100644 index 000000000..473499771 --- /dev/null +++ b/packages/cli/src/utils/modelProviderUtils.ts @@ -0,0 +1,142 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + AuthType, + type ContentGeneratorConfig, + type ContentGeneratorConfigSource, + type ContentGeneratorConfigSources, + type ModelProvidersConfig, + type ProviderModelConfig as ModelConfig, +} from '@qwen-code/qwen-code-core'; +import type { Settings } from '../config/settings.js'; + +export interface GenerationConfigSourceInputs { + argv: { + model?: string | undefined; + openaiApiKey?: string | undefined; + openaiBaseUrl?: string | undefined; + }; + settings: Settings; + selectedAuthType: AuthType | undefined; + /** + * Injectable env for testability. Defaults to process.env at callsites. + */ + env?: Record; +} + +/** + * Get models configuration from settings, grouped by authType. + * Returns the models config from the merged settings without mutating files. + */ +export function getModelProvidersConfigFromSettings( + settings: Settings, +): ModelProvidersConfig { + return (settings.modelProviders as ModelProvidersConfig) || {}; +} + +/** + * Get models for a specific authType from settings. + */ +export function getModelsForAuthType( + settings: Settings, + authType: AuthType, +): ModelConfig[] { + const modelProvidersConfig = getModelProvidersConfigFromSettings(settings); + return modelProvidersConfig[authType] || []; +} + +/** + * Best-effort attribution for the seed generationConfig fields. + * + * NOTE: + * - This does not attempt to distinguish user vs workspace settings; it reflects merged settings. + * - This should stay consistent with the actual precedence used to compute the corresponding values. + */ +export function buildGenerationConfigSources( + inputs: GenerationConfigSourceInputs, +): ContentGeneratorConfigSources { + const { argv, settings, selectedAuthType } = inputs; + const env = inputs.env ?? (process.env as Record); + + const sources: ContentGeneratorConfigSources = {}; + + const setSource = (path: string, source: ContentGeneratorConfigSource) => { + sources[path] = source; + }; + + // Model/apiKey/baseUrl attribution mirrors current CLI precedence: + // - model: argv.model > (OPENAI_MODEL|QWEN_MODEL|settings.model.name) only for OpenAI auth + // - apiKey/baseUrl: only meaningful for OpenAI auth in current CLI wiring + if (selectedAuthType === AuthType.USE_OPENAI) { + if (argv.model) { + setSource('model', { kind: 'cli', detail: '--model' }); + } else if (env['OPENAI_MODEL']) { + setSource('model', { kind: 'env', envKey: 'OPENAI_MODEL' }); + } else if (env['QWEN_MODEL']) { + setSource('model', { kind: 'env', envKey: 'QWEN_MODEL' }); + } else if (settings.model?.name) { + setSource('model', { kind: 'settings', settingsPath: 'model.name' }); + } + + if (argv.openaiApiKey) { + setSource('apiKey', { kind: 'cli', detail: '--openaiApiKey' }); + } else if (env['OPENAI_API_KEY']) { + setSource('apiKey', { kind: 'env', envKey: 'OPENAI_API_KEY' }); + } else if (settings.security?.auth?.apiKey) { + setSource('apiKey', { + kind: 'settings', + settingsPath: 'security.auth.apiKey', + }); + } + + if (argv.openaiBaseUrl) { + setSource('baseUrl', { kind: 'cli', detail: '--openaiBaseUrl' }); + } else if (env['OPENAI_BASE_URL']) { + setSource('baseUrl', { kind: 'env', envKey: 'OPENAI_BASE_URL' }); + } else if (settings.security?.auth?.baseUrl) { + setSource('baseUrl', { + kind: 'settings', + settingsPath: 'security.auth.baseUrl', + }); + } + } else if (argv.model) { + // For non-openai auth types, the CLI only wires through an explicit raw model override. + setSource('model', { kind: 'cli', detail: '--model' }); + } + + const mergedGenerationConfig = settings.model?.generationConfig as + | Partial + | undefined; + if (mergedGenerationConfig) { + setSource('generationConfig', { + kind: 'settings', + settingsPath: 'model.generationConfig', + }); + // We also map the known top-level fields used by core. + if (mergedGenerationConfig.samplingParams) { + setSource('samplingParams', { + kind: 'settings', + settingsPath: 'model.generationConfig.samplingParams', + }); + } + for (const k of [ + 'timeout', + 'maxRetries', + 'disableCacheControl', + 'schemaCompliance', + ] as const) { + if (mergedGenerationConfig[k] !== undefined) { + setSource(k, { + kind: 'settings', + settingsPath: `model.generationConfig.${k}`, + }); + } + } + } + + return sources; +} diff --git a/packages/core/src/config/flashFallback.test.ts b/packages/core/src/config/flashFallback.test.ts index 2675ea840..1fff42392 100644 --- a/packages/core/src/config/flashFallback.test.ts +++ b/packages/core/src/config/flashFallback.test.ts @@ -11,7 +11,8 @@ import fs from 'node:fs'; vi.mock('node:fs'); -describe('Flash Model Fallback Configuration', () => { +// Skip this test because we do not have fall back mechanism. +describe.skip('Flash Model Fallback Configuration', () => { let config: Config; beforeEach(() => { @@ -31,7 +32,7 @@ describe('Flash Model Fallback Configuration', () => { config as unknown as { contentGeneratorConfig: unknown } ).contentGeneratorConfig = { model: DEFAULT_GEMINI_MODEL, - authType: 'gemini-api-key', + authType: 'gemini', }; }); diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index bd970b9da..1cf3c565c 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -240,7 +240,7 @@ describe('CoreToolScheduler', () => { getAllowedTools: () => [], getContentGeneratorConfig: () => ({ model: 'test-model', - authType: 'gemini-api-key', + authType: 'gemini', }), getShellExecutionConfig: () => ({ terminalWidth: 90, @@ -318,7 +318,7 @@ describe('CoreToolScheduler', () => { getAllowedTools: () => [], getContentGeneratorConfig: () => ({ model: 'test-model', - authType: 'gemini-api-key', + authType: 'gemini', }), getShellExecutionConfig: () => ({ terminalWidth: 90, @@ -497,7 +497,7 @@ describe('CoreToolScheduler', () => { getExcludeTools: () => ['write_file', 'edit', 'run_shell_command'], getContentGeneratorConfig: () => ({ model: 'test-model', - authType: 'gemini-api-key', + authType: 'gemini', }), getShellExecutionConfig: () => ({ terminalWidth: 90, @@ -584,7 +584,7 @@ describe('CoreToolScheduler', () => { getExcludeTools: () => ['write_file', 'edit'], // Different excluded tools getContentGeneratorConfig: () => ({ model: 'test-model', - authType: 'gemini-api-key', + authType: 'gemini', }), getShellExecutionConfig: () => ({ terminalWidth: 90, @@ -674,7 +674,7 @@ describe('CoreToolScheduler with payload', () => { getAllowedTools: () => [], getContentGeneratorConfig: () => ({ model: 'test-model', - authType: 'gemini-api-key', + authType: 'gemini', }), getShellExecutionConfig: () => ({ terminalWidth: 90, @@ -1001,7 +1001,7 @@ describe('CoreToolScheduler edit cancellation', () => { getAllowedTools: () => [], getContentGeneratorConfig: () => ({ model: 'test-model', - authType: 'gemini-api-key', + authType: 'gemini', }), getShellExecutionConfig: () => ({ terminalWidth: 90, @@ -1108,7 +1108,7 @@ describe('CoreToolScheduler YOLO mode', () => { getAllowedTools: () => [], getContentGeneratorConfig: () => ({ model: 'test-model', - authType: 'gemini-api-key', + authType: 'gemini', }), getShellExecutionConfig: () => ({ terminalWidth: 90, @@ -1258,7 +1258,7 @@ describe('CoreToolScheduler cancellation during executing with live output', () getApprovalMode: () => ApprovalMode.DEFAULT, getContentGeneratorConfig: () => ({ model: 'test-model', - authType: 'gemini-api-key', + authType: 'gemini', }), getToolRegistry: () => mockToolRegistry, getShellExecutionConfig: () => ({ @@ -1350,7 +1350,7 @@ describe('CoreToolScheduler request queueing', () => { getAllowedTools: () => [], getContentGeneratorConfig: () => ({ model: 'test-model', - authType: 'gemini-api-key', + authType: 'gemini', }), getShellExecutionConfig: () => ({ terminalWidth: 90, @@ -1482,7 +1482,7 @@ describe('CoreToolScheduler request queueing', () => { getToolRegistry: () => toolRegistry, getContentGeneratorConfig: () => ({ model: 'test-model', - authType: 'gemini-api-key', + authType: 'gemini', }), getShellExecutionConfig: () => ({ terminalWidth: 80, @@ -1586,7 +1586,7 @@ describe('CoreToolScheduler request queueing', () => { getAllowedTools: () => [], getContentGeneratorConfig: () => ({ model: 'test-model', - authType: 'gemini-api-key', + authType: 'gemini', }), getShellExecutionConfig: () => ({ terminalWidth: 90, @@ -1854,7 +1854,7 @@ describe('CoreToolScheduler Sequential Execution', () => { getAllowedTools: () => [], getContentGeneratorConfig: () => ({ model: 'test-model', - authType: 'gemini-api-key', + authType: 'gemini', }), getShellExecutionConfig: () => ({ terminalWidth: 90, @@ -1975,7 +1975,7 @@ describe('CoreToolScheduler Sequential Execution', () => { getAllowedTools: () => [], getContentGeneratorConfig: () => ({ model: 'test-model', - authType: 'gemini-api-key', + authType: 'gemini', }), getShellExecutionConfig: () => ({ terminalWidth: 90, diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 39b732cd9..a77fc6707 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -112,7 +112,7 @@ describe('GeminiChat', () => { getUsageStatisticsEnabled: () => true, getDebugMode: () => false, getContentGeneratorConfig: vi.fn().mockReturnValue({ - authType: 'gemini-api-key', // Ensure this is set for fallback tests + authType: 'gemini', // Ensure this is set for fallback tests model: 'test-model', }), getModel: vi.fn().mockReturnValue('gemini-pro'), diff --git a/packages/core/src/core/nonInteractiveToolExecutor.test.ts b/packages/core/src/core/nonInteractiveToolExecutor.test.ts index 5296310f9..5b319deda 100644 --- a/packages/core/src/core/nonInteractiveToolExecutor.test.ts +++ b/packages/core/src/core/nonInteractiveToolExecutor.test.ts @@ -47,7 +47,7 @@ describe('executeToolCall', () => { getDebugMode: () => false, getContentGeneratorConfig: () => ({ model: 'test-model', - authType: 'gemini-api-key', + authType: 'gemini', }), getShellExecutionConfig: () => ({ terminalWidth: 90, diff --git a/packages/core/src/core/openaiContentGenerator/converter.ts b/packages/core/src/core/openaiContentGenerator/converter.ts index 5ec7a4935..ed2495da1 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.ts @@ -93,6 +93,14 @@ export class OpenAIContentConverter { this.schemaCompliance = schemaCompliance; } + /** + * Update the model used for response metadata (modelVersion/logging) and any + * model-specific conversion behavior. + */ + setModel(model: string): void { + this.model = model; + } + /** * Reset streaming tool calls parser for new stream processing * This should be called at the beginning of each stream to prevent From db12796df52ff9ab7c8e47385df9eef32c041594 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Tue, 6 Jan 2026 15:36:44 +0800 Subject: [PATCH 084/142] refactor: update authentication handling and model configuration - Enhanced authentication method validation in `auth.ts` and `auth.test.ts`. - Introduced new model provider configuration logic - Updated environment variable handling for various auth types. - Removed deprecated utility functions and tests related to fallback mechanisms. --- packages/cli/src/config/auth.test.ts | 134 +++- packages/cli/src/config/auth.ts | 128 +++- packages/cli/src/config/config.test.ts | 6 +- packages/cli/src/config/config.ts | 73 +- .../src/config/modelProvidersScope.test.ts | 89 +++ .../cli/src/config/modelProvidersScope.ts | 48 ++ packages/cli/src/config/settingsSchema.ts | 2 +- packages/cli/src/core/initializer.ts | 11 +- packages/cli/src/ui/AppContainer.tsx | 22 +- packages/cli/src/ui/auth/useAuth.ts | 2 - .../cli/src/ui/commands/modelCommand.test.ts | 48 +- packages/cli/src/ui/commands/modelCommand.ts | 17 - .../src/ui/components/ModelDialog.test.tsx | 162 +++- .../cli/src/ui/components/ModelDialog.tsx | 371 +++++++++- .../shared/DescriptiveRadioButtonSelect.tsx | 2 +- packages/cli/src/ui/hooks/useGeminiStream.ts | 2 +- .../cli/src/ui/hooks/useToolScheduler.test.ts | 2 +- .../cli/src/ui/models/availableModels.test.ts | 205 ++++++ packages/cli/src/ui/models/availableModels.ts | 76 +- packages/cli/src/utils/modelConfigUtils.ts | 112 +++ packages/cli/src/utils/modelProviderUtils.ts | 142 ---- packages/core/index.ts | 6 +- packages/core/src/config/config.test.ts | 167 ++++- packages/core/src/config/config.ts | 226 ++++-- .../core/src/config/flashFallback.test.ts | 100 --- packages/core/src/config/models.test.ts | 83 --- packages/core/src/config/models.ts | 43 -- packages/core/src/core/client.test.ts | 39 +- packages/core/src/core/client.ts | 13 +- .../core/src/core/contentGenerator.test.ts | 35 +- packages/core/src/core/contentGenerator.ts | 255 ++++--- packages/core/src/core/geminiChat.test.ts | 18 +- packages/core/src/core/geminiChat.ts | 26 +- .../openaiContentGenerator/pipeline.test.ts | 55 ++ .../core/openaiContentGenerator/pipeline.ts | 23 +- packages/core/src/fallback/types.ts | 23 - packages/core/src/index.ts | 29 +- packages/core/src/models/constants.ts | 134 ++++ packages/core/src/models/index.ts | 44 ++ packages/core/src/models/modelConfigErrors.ts | 125 ++++ .../src/models/modelConfigResolver.test.ts | 355 +++++++++ .../core/src/models/modelConfigResolver.ts | 362 +++++++++ .../core/src/models/modelRegistry.test.ts | 390 ++++++++++ packages/core/src/models/modelRegistry.ts | 180 +++++ packages/core/src/models/modelsConfig.test.ts | 451 ++++++++++++ packages/core/src/models/modelsConfig.ts | 697 ++++++++++++++++++ packages/core/src/models/types.ts | 101 +++ packages/core/src/subagents/subagent.test.ts | 45 +- .../core/src/utils/configResolver.test.ts | 141 ++++ packages/core/src/utils/configResolver.ts | 222 ++++++ packages/core/src/utils/flashFallback.test.ts | 75 -- packages/core/src/utils/llm-edit-fixer.ts | 4 +- packages/core/src/utils/summarizer.ts | 4 +- 53 files changed, 5183 insertions(+), 942 deletions(-) create mode 100644 packages/cli/src/config/modelProvidersScope.test.ts create mode 100644 packages/cli/src/config/modelProvidersScope.ts create mode 100644 packages/cli/src/ui/models/availableModels.test.ts create mode 100644 packages/cli/src/utils/modelConfigUtils.ts delete mode 100644 packages/cli/src/utils/modelProviderUtils.ts delete mode 100644 packages/core/src/config/flashFallback.test.ts delete mode 100644 packages/core/src/config/models.test.ts delete mode 100644 packages/core/src/fallback/types.ts create mode 100644 packages/core/src/models/constants.ts create mode 100644 packages/core/src/models/index.ts create mode 100644 packages/core/src/models/modelConfigErrors.ts create mode 100644 packages/core/src/models/modelConfigResolver.test.ts create mode 100644 packages/core/src/models/modelConfigResolver.ts create mode 100644 packages/core/src/models/modelRegistry.test.ts create mode 100644 packages/core/src/models/modelRegistry.ts create mode 100644 packages/core/src/models/modelsConfig.test.ts create mode 100644 packages/core/src/models/modelsConfig.ts create mode 100644 packages/core/src/models/types.ts create mode 100644 packages/core/src/utils/configResolver.test.ts create mode 100644 packages/core/src/utils/configResolver.ts delete mode 100644 packages/core/src/utils/flashFallback.test.ts diff --git a/packages/cli/src/config/auth.test.ts b/packages/cli/src/config/auth.test.ts index 6f6b584ef..c960e05a7 100644 --- a/packages/cli/src/config/auth.test.ts +++ b/packages/cli/src/config/auth.test.ts @@ -1,41 +1,112 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ import { AuthType } from '@qwen-code/qwen-code-core'; import { vi } from 'vitest'; import { validateAuthMethod } from './auth.js'; +import * as settings from './settings.js'; vi.mock('./settings.js', () => ({ loadEnvironment: vi.fn(), loadSettings: vi.fn().mockReturnValue({ - merged: vi.fn().mockReturnValue({}), + merged: {}, }), })); describe('validateAuthMethod', () => { beforeEach(() => { vi.resetModules(); + // Reset mock to default + vi.mocked(settings.loadSettings).mockReturnValue({ + merged: {}, + } as ReturnType); }); afterEach(() => { vi.unstubAllEnvs(); + delete process.env['OPENAI_API_KEY']; + delete process.env['CUSTOM_API_KEY']; + delete process.env['GEMINI_API_KEY']; + delete process.env['GEMINI_API_KEY_ALTERED']; + delete process.env['ANTHROPIC_API_KEY']; + delete process.env['ANTHROPIC_BASE_URL']; + delete process.env['GOOGLE_API_KEY']; }); - it('should return null for USE_OPENAI', () => { + it('should return null for USE_OPENAI with default env key', () => { process.env['OPENAI_API_KEY'] = 'fake-key'; expect(validateAuthMethod(AuthType.USE_OPENAI)).toBeNull(); }); - it('should return an error message for USE_OPENAI if OPENAI_API_KEY is not set', () => { - delete process.env['OPENAI_API_KEY']; + it('should return an error message for USE_OPENAI if no API key is available', () => { expect(validateAuthMethod(AuthType.USE_OPENAI)).toBe( - "Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the 'OPENAI_API_KEY' environment variable. If you configured a model in settings.modelProviders with an envKey, set that env var as well.", + "Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the 'OPENAI_API_KEY' environment variable.", ); }); + it('should return null for USE_OPENAI with custom envKey from modelProviders', () => { + vi.mocked(settings.loadSettings).mockReturnValue({ + merged: { + model: { name: 'custom-model' }, + modelProviders: { + openai: [{ id: 'custom-model', envKey: 'CUSTOM_API_KEY' }], + }, + }, + } as unknown as ReturnType); + process.env['CUSTOM_API_KEY'] = 'custom-key'; + + expect(validateAuthMethod(AuthType.USE_OPENAI)).toBeNull(); + }); + + it('should return error with custom envKey hint when modelProviders envKey is set but env var is missing', () => { + vi.mocked(settings.loadSettings).mockReturnValue({ + merged: { + model: { name: 'custom-model' }, + modelProviders: { + openai: [{ id: 'custom-model', envKey: 'CUSTOM_API_KEY' }], + }, + }, + } as unknown as ReturnType); + + const result = validateAuthMethod(AuthType.USE_OPENAI); + expect(result).toContain('CUSTOM_API_KEY'); + }); + + it('should return null for USE_GEMINI with custom envKey', () => { + vi.mocked(settings.loadSettings).mockReturnValue({ + merged: { + model: { name: 'gemini-1.5-flash' }, + modelProviders: { + gemini: [ + { id: 'gemini-1.5-flash', envKey: 'GEMINI_API_KEY_ALTERED' }, + ], + }, + }, + } as unknown as ReturnType); + process.env['GEMINI_API_KEY_ALTERED'] = 'altered-key'; + + expect(validateAuthMethod(AuthType.USE_GEMINI)).toBeNull(); + }); + + it('should return error with custom envKey for USE_GEMINI when env var is missing', () => { + vi.mocked(settings.loadSettings).mockReturnValue({ + merged: { + model: { name: 'gemini-1.5-flash' }, + modelProviders: { + gemini: [ + { id: 'gemini-1.5-flash', envKey: 'GEMINI_API_KEY_ALTERED' }, + ], + }, + }, + } as unknown as ReturnType); + + const result = validateAuthMethod(AuthType.USE_GEMINI); + expect(result).toContain('GEMINI_API_KEY_ALTERED'); + }); + it('should return null for QWEN_OAUTH', () => { expect(validateAuthMethod(AuthType.QWEN_OAUTH)).toBeNull(); }); @@ -45,4 +116,55 @@ describe('validateAuthMethod', () => { 'Invalid auth method selected.', ); }); + + it('should return null for USE_ANTHROPIC with custom envKey and baseUrl', () => { + vi.mocked(settings.loadSettings).mockReturnValue({ + merged: { + model: { name: 'claude-3' }, + modelProviders: { + anthropic: [ + { + id: 'claude-3', + envKey: 'CUSTOM_ANTHROPIC_KEY', + baseUrl: 'https://api.anthropic.com', + }, + ], + }, + }, + } as unknown as ReturnType); + process.env['CUSTOM_ANTHROPIC_KEY'] = 'custom-anthropic-key'; + + expect(validateAuthMethod(AuthType.USE_ANTHROPIC)).toBeNull(); + }); + + it('should return error for USE_ANTHROPIC when baseUrl is missing', () => { + vi.mocked(settings.loadSettings).mockReturnValue({ + merged: { + model: { name: 'claude-3' }, + modelProviders: { + anthropic: [{ id: 'claude-3', envKey: 'CUSTOM_ANTHROPIC_KEY' }], + }, + }, + } as unknown as ReturnType); + process.env['CUSTOM_ANTHROPIC_KEY'] = 'custom-key'; + + const result = validateAuthMethod(AuthType.USE_ANTHROPIC); + expect(result).toContain('ANTHROPIC_BASE_URL'); + }); + + it('should return null for USE_VERTEX_AI with custom envKey', () => { + vi.mocked(settings.loadSettings).mockReturnValue({ + merged: { + model: { name: 'vertex-model' }, + modelProviders: { + 'vertex-ai': [ + { id: 'vertex-model', envKey: 'GOOGLE_API_KEY_VERTEX' }, + ], + }, + }, + } as unknown as ReturnType); + process.env['GOOGLE_API_KEY_VERTEX'] = 'vertex-key'; + + expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBeNull(); + }); }); diff --git a/packages/cli/src/config/auth.ts b/packages/cli/src/config/auth.ts index 42fbf280f..e05b029d9 100644 --- a/packages/cli/src/config/auth.ts +++ b/packages/cli/src/config/auth.ts @@ -1,24 +1,97 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ import { AuthType } from '@qwen-code/qwen-code-core'; -import { loadEnvironment, loadSettings } from './settings.js'; +import type { + ModelProvidersConfig, + ProviderModelConfig, +} from '@qwen-code/qwen-code-core'; +import { loadEnvironment, loadSettings, type Settings } from './settings.js'; + +/** + * Default environment variable names for each auth type + */ +const DEFAULT_ENV_KEYS: Record = { + [AuthType.USE_OPENAI]: 'OPENAI_API_KEY', + [AuthType.USE_ANTHROPIC]: 'ANTHROPIC_API_KEY', + [AuthType.USE_GEMINI]: 'GEMINI_API_KEY', + [AuthType.USE_VERTEX_AI]: 'GOOGLE_API_KEY', +}; + +/** + * Find model configuration from modelProviders by authType and modelId + */ +function findModelConfig( + modelProviders: ModelProvidersConfig | undefined, + authType: string, + modelId: string | undefined, +): ProviderModelConfig | undefined { + if (!modelProviders || !modelId) { + return undefined; + } + + const models = modelProviders[authType]; + if (!Array.isArray(models)) { + return undefined; + } + + return models.find((m) => m.id === modelId); +} + +/** + * Check if API key is available for the given auth type and model configuration. + * Prioritizes custom envKey from modelProviders over default environment variables. + */ +function hasApiKeyForAuth( + authType: string, + settings: Settings, +): { hasKey: boolean; checkedEnvKey: string | undefined } { + const modelProviders = settings.modelProviders as + | ModelProvidersConfig + | undefined; + const modelId = settings.model?.name; + + // Try to find model-specific envKey from modelProviders + const modelConfig = findModelConfig(modelProviders, authType, modelId); + if (modelConfig?.envKey) { + const hasKey = !!process.env[modelConfig.envKey]; + return { hasKey, checkedEnvKey: modelConfig.envKey }; + } + + // Fallback to default environment variable + const defaultEnvKey = DEFAULT_ENV_KEYS[authType]; + if (defaultEnvKey) { + const hasKey = !!process.env[defaultEnvKey]; + return { hasKey, checkedEnvKey: defaultEnvKey }; + } + + // Also check settings.security.auth.apiKey as fallback + if (settings.security?.auth?.apiKey) { + return { hasKey: true, checkedEnvKey: undefined }; + } + + return { hasKey: false, checkedEnvKey: undefined }; +} export function validateAuthMethod(authMethod: string): string | null { const settings = loadSettings(); loadEnvironment(settings.merged); if (authMethod === AuthType.USE_OPENAI) { - const hasApiKey = - process.env['OPENAI_API_KEY'] || settings.merged.security?.auth?.apiKey; - if (!hasApiKey) { + const { hasKey, checkedEnvKey } = hasApiKeyForAuth( + authMethod, + settings.merged, + ); + if (!hasKey) { + const envKeyHint = checkedEnvKey + ? `'${checkedEnvKey}'` + : "'OPENAI_API_KEY' (or configure modelProviders[].envKey)"; return ( 'Missing API key for OpenAI-compatible auth. ' + - "Set settings.security.auth.apiKey, or set the 'OPENAI_API_KEY' environment variable. " + - 'If you configured a model in settings.modelProviders with an envKey, set that env var as well.' + `Set settings.security.auth.apiKey, or set the ${envKeyHint} environment variable.` ); } return null; @@ -31,31 +104,50 @@ export function validateAuthMethod(authMethod: string): string | null { } if (authMethod === AuthType.USE_ANTHROPIC) { - const hasApiKey = process.env['ANTHROPIC_API_KEY']; - if (!hasApiKey) { - return 'ANTHROPIC_API_KEY environment variable not found.'; + const { hasKey, checkedEnvKey } = hasApiKeyForAuth( + authMethod, + settings.merged, + ); + if (!hasKey) { + const envKeyHint = checkedEnvKey || 'ANTHROPIC_API_KEY'; + return `${envKeyHint} environment variable not found.`; } - const hasBaseUrl = process.env['ANTHROPIC_BASE_URL']; + // Check baseUrl - can come from modelProviders or environment + const modelProviders = settings.merged.modelProviders as + | ModelProvidersConfig + | undefined; + const modelId = settings.merged.model?.name; + const modelConfig = findModelConfig(modelProviders, authMethod, modelId); + const hasBaseUrl = + modelConfig?.baseUrl || process.env['ANTHROPIC_BASE_URL']; if (!hasBaseUrl) { - return 'ANTHROPIC_BASE_URL environment variable not found.'; + return 'ANTHROPIC_BASE_URL environment variable not found (or configure modelProviders[].baseUrl).'; } return null; } if (authMethod === AuthType.USE_GEMINI) { - const hasApiKey = process.env['GEMINI_API_KEY']; - if (!hasApiKey) { - return 'GEMINI_API_KEY environment variable not found. Please set it in your .env file or environment variables.'; + const { hasKey, checkedEnvKey } = hasApiKeyForAuth( + authMethod, + settings.merged, + ); + if (!hasKey) { + const envKeyHint = checkedEnvKey || 'GEMINI_API_KEY'; + return `${envKeyHint} environment variable not found. Please set it in your .env file or environment variables.`; } return null; } if (authMethod === AuthType.USE_VERTEX_AI) { - const hasApiKey = process.env['GOOGLE_API_KEY']; - if (!hasApiKey) { - return 'GOOGLE_API_KEY environment variable not found. Please set it in your .env file or environment variables.'; + const { hasKey, checkedEnvKey } = hasApiKeyForAuth( + authMethod, + settings.merged, + ); + if (!hasKey) { + const envKeyHint = checkedEnvKey || 'GOOGLE_API_KEY'; + return `${envKeyHint} environment variable not found. Please set it in your .env file or environment variables.`; } process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true'; diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 6f2019e75..850d4a822 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -77,10 +77,8 @@ vi.mock('read-package-up', () => ({ ), })); -vi.mock('@qwen-code/qwen-code-core', async () => { - const actualServer = await vi.importActual( - '@qwen-code/qwen-code-core', - ); +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actualServer = await importOriginal(); return { ...actualServer, IdeClient: { diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index bc5da7bfc..9fffe8fae 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -31,10 +31,7 @@ import { } from '@qwen-code/qwen-code-core'; import { extensionsCommand } from '../commands/extensions.js'; import type { Settings } from './settings.js'; -import { - buildGenerationConfigSources, - getModelProvidersConfigFromSettings, -} from '../utils/modelProviderUtils.js'; +import { resolveCliGenerationConfig } from '../utils/modelConfigUtils.js'; import yargs, { type Argv } from 'yargs'; import { hideBin } from 'yargs/helpers'; import * as fs from 'node:fs'; @@ -930,26 +927,21 @@ export async function loadCliConfig( (argv.authType as AuthType | undefined) || settings.security?.auth?.selectedType; - const apiKey = - (selectedAuthType === AuthType.USE_OPENAI - ? argv.openaiApiKey || - process.env['OPENAI_API_KEY'] || - settings.security?.auth?.apiKey - : '') || ''; - const baseUrl = - (selectedAuthType === AuthType.USE_OPENAI - ? argv.openaiBaseUrl || - process.env['OPENAI_BASE_URL'] || - settings.security?.auth?.baseUrl - : '') || ''; - const resolvedModel = - argv.model || - (selectedAuthType === AuthType.USE_OPENAI - ? process.env['OPENAI_MODEL'] || - process.env['QWEN_MODEL'] || - settings.model?.name - : '') || - ''; + // Unified resolution of generation config with source attribution + const resolvedCliConfig = resolveCliGenerationConfig({ + argv: { + model: argv.model, + openaiApiKey: argv.openaiApiKey, + openaiBaseUrl: argv.openaiBaseUrl, + openaiLogging: argv.openaiLogging, + openaiLoggingDir: argv.openaiLoggingDir, + }, + settings, + selectedAuthType, + env: process.env as Record, + }); + + const { model: resolvedModel } = resolvedCliConfig; const sandboxConfig = await loadSandboxConfig(settings, argv); const screenReader = @@ -983,17 +975,7 @@ export async function loadCliConfig( } } - const modelProvidersConfig = getModelProvidersConfigFromSettings(settings); - const generationConfigSources = buildGenerationConfigSources({ - argv: { - model: argv.model, - openaiApiKey: argv.openaiApiKey, - openaiBaseUrl: argv.openaiBaseUrl, - }, - settings, - selectedAuthType, - env: process.env as Record, - }); + const modelProvidersConfig = settings.modelProviders; return new Config({ sessionId, @@ -1053,25 +1035,10 @@ export async function loadCliConfig( outputFormat, includePartialMessages, modelProvidersConfig, - generationConfigSources, - generationConfig: { - ...(settings.model?.generationConfig || {}), - model: resolvedModel, - apiKey, - baseUrl, - enableOpenAILogging: - (typeof argv.openaiLogging === 'undefined' - ? settings.model?.enableOpenAILogging - : argv.openaiLogging) ?? false, - openAILoggingDir: - argv.openaiLoggingDir || settings.model?.openAILoggingDir, - }, + generationConfigSources: resolvedCliConfig.sources, + generationConfig: resolvedCliConfig.generationConfig, cliVersion: await getCliVersion(), - webSearch: buildWebSearchConfig( - argv, - settings, - settings.security?.auth?.selectedType, - ), + webSearch: buildWebSearchConfig(argv, settings, selectedAuthType), summarizeToolOutput: settings.model?.summarizeToolOutput, ideMode, chatCompression: settings.model?.chatCompression, diff --git a/packages/cli/src/config/modelProvidersScope.test.ts b/packages/cli/src/config/modelProvidersScope.test.ts new file mode 100644 index 000000000..2b270d6be --- /dev/null +++ b/packages/cli/src/config/modelProvidersScope.test.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { SettingScope } from './settings.js'; +import { getPersistScopeForModelSelection } from './modelProvidersScope.js'; + +function makeSettings({ + isTrusted, + userModelProviders, + workspaceModelProviders, +}: { + isTrusted: boolean; + userModelProviders?: unknown; + workspaceModelProviders?: unknown; +}) { + const userSettings: Record = {}; + const workspaceSettings: Record = {}; + + // When undefined, treat as "not present in this scope" (the key is omitted), + // matching how LoadedSettings is shaped when a settings file doesn't define it. + if (userModelProviders !== undefined) { + userSettings['modelProviders'] = userModelProviders; + } + if (workspaceModelProviders !== undefined) { + workspaceSettings['modelProviders'] = workspaceModelProviders; + } + + return { + isTrusted, + user: { settings: userSettings }, + workspace: { settings: workspaceSettings }, + } as unknown as import('./settings.js').LoadedSettings; +} + +describe('getPersistScopeForModelSelection', () => { + it('prefers workspace when trusted and workspace defines modelProviders', () => { + const settings = makeSettings({ + isTrusted: true, + workspaceModelProviders: {}, + userModelProviders: { anything: true }, + }); + + expect(getPersistScopeForModelSelection(settings)).toBe( + SettingScope.Workspace, + ); + }); + + it('falls back to user when workspace does not define modelProviders', () => { + const settings = makeSettings({ + isTrusted: true, + workspaceModelProviders: undefined, + userModelProviders: {}, + }); + + expect(getPersistScopeForModelSelection(settings)).toBe(SettingScope.User); + }); + + it('ignores workspace modelProviders when workspace is untrusted', () => { + const settings = makeSettings({ + isTrusted: false, + workspaceModelProviders: {}, + userModelProviders: undefined, + }); + + expect(getPersistScopeForModelSelection(settings)).toBe(SettingScope.User); + }); + + it('falls back to legacy trust heuristic when neither scope defines modelProviders', () => { + const trusted = makeSettings({ + isTrusted: true, + userModelProviders: undefined, + workspaceModelProviders: undefined, + }); + expect(getPersistScopeForModelSelection(trusted)).toBe( + SettingScope.Workspace, + ); + + const untrusted = makeSettings({ + isTrusted: false, + userModelProviders: undefined, + workspaceModelProviders: undefined, + }); + expect(getPersistScopeForModelSelection(untrusted)).toBe(SettingScope.User); + }); +}); diff --git a/packages/cli/src/config/modelProvidersScope.ts b/packages/cli/src/config/modelProvidersScope.ts new file mode 100644 index 000000000..136141103 --- /dev/null +++ b/packages/cli/src/config/modelProvidersScope.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SettingScope, type LoadedSettings } from './settings.js'; + +function hasOwnModelProviders(settingsObj: unknown): boolean { + if (!settingsObj || typeof settingsObj !== 'object') { + return false; + } + const obj = settingsObj as Record; + // Treat an explicitly configured empty object (modelProviders: {}) as "owned" + // by this scope, which is important when mergeStrategy is REPLACE. + return Object.prototype.hasOwnProperty.call(obj, 'modelProviders'); +} + +/** + * Returns which writable scope (Workspace/User) owns the effective modelProviders + * configuration. + * + * Note: Workspace scope is only considered when the workspace is trusted. + */ +export function getModelProvidersOwnerScope( + settings: LoadedSettings, +): SettingScope | undefined { + if (settings.isTrusted && hasOwnModelProviders(settings.workspace.settings)) { + return SettingScope.Workspace; + } + + if (hasOwnModelProviders(settings.user.settings)) { + return SettingScope.User; + } + + return undefined; +} + +/** + * Choose the settings scope to persist a model selection. + * Prefer persisting back to the scope that contains the effective modelProviders + * config, otherwise fall back to the legacy trust-based heuristic. + */ +export function getPersistScopeForModelSelection( + settings: LoadedSettings, +): SettingScope { + return getModelProvidersOwnerScope(settings) ?? SettingScope.User; +} diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 4562546ff..74b63a7b9 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -113,7 +113,7 @@ const SETTINGS_SCHEMA = { description: 'Model providers configuration grouped by authType. Each authType contains an array of model configurations.', showInDialog: false, - mergeStrategy: MergeStrategy.SHALLOW_MERGE, + mergeStrategy: MergeStrategy.REPLACE, }, general: { diff --git a/packages/cli/src/core/initializer.ts b/packages/cli/src/core/initializer.ts index 5aa3d9e3b..062c0b516 100644 --- a/packages/cli/src/core/initializer.ts +++ b/packages/cli/src/core/initializer.ts @@ -45,7 +45,9 @@ export async function initializeApp( // Auto-detect and set LLM output language on first use initializeLlmOutputLanguage(); - const authType = settings.merged.security?.auth?.selectedType; + // Use authType from modelsConfig which respects CLI --auth-type argument + // over settings.security.auth.selectedType + const authType = config.modelsConfig.getCurrentAuthType(); const authError = await performInitialAuth(config, authType); // Fallback to user select when initial authentication fails @@ -58,8 +60,13 @@ export async function initializeApp( } const themeError = validateTheme(settings); + // Open auth dialog if: + // 1. No authType was explicitly selected (neither from CLI --auth-type nor settings), OR + // 2. Authentication failed + // wasAuthTypeExplicitlyProvided() returns true if CLI or settings specified authType, + // false if using the default QWEN_OAUTH const shouldOpenAuthDialog = - settings.merged.security?.auth?.selectedType === undefined || !!authError; + !config.modelsConfig.wasAuthTypeExplicitlyProvided() || !!authError; if (config.getIdeMode()) { const ideClient = await IdeClient.getInstance(); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 38dad449c..1449a7f4b 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -32,7 +32,6 @@ import { type Config, type IdeInfo, type IdeContext, - DEFAULT_GEMINI_FLASH_MODEL, IdeClient, ideContextStore, getErrorMessage, @@ -180,15 +179,10 @@ export const AppContainer = (props: AppContainerProps) => { [], ); - // Helper to determine the effective model, considering the fallback state. - const getEffectiveModel = useCallback(() => { - if (config.isInFallbackMode()) { - return DEFAULT_GEMINI_FLASH_MODEL; - } - return config.getModel(); - }, [config]); + // Helper to determine the current model (polled, since Config has no model-change event). + const getCurrentModel = useCallback(() => config.getModel(), [config]); - const [currentModel, setCurrentModel] = useState(getEffectiveModel()); + const [currentModel, setCurrentModel] = useState(getCurrentModel()); const [isConfigInitialized, setConfigInitialized] = useState(false); @@ -241,12 +235,12 @@ export const AppContainer = (props: AppContainerProps) => { [historyManager.addItem], ); - // Watch for model changes (e.g., from Flash fallback) + // Watch for model changes (e.g., user switches model via /model) useEffect(() => { const checkModelChange = () => { - const effectiveModel = getEffectiveModel(); - if (effectiveModel !== currentModel) { - setCurrentModel(effectiveModel); + const model = getCurrentModel(); + if (model !== currentModel) { + setCurrentModel(model); } }; @@ -254,7 +248,7 @@ export const AppContainer = (props: AppContainerProps) => { const interval = setInterval(checkModelChange, 1000); // Check every second return () => clearInterval(interval); - }, [config, currentModel, getEffectiveModel]); + }, [config, currentModel, getCurrentModel]); const { consoleMessages, diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index c13f33c95..6125ebdf2 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -8,7 +8,6 @@ import type { Config } from '@qwen-code/qwen-code-core'; import { AuthEvent, AuthType, - clearCachedCredentialFile, getErrorMessage, logAuth, } from '@qwen-code/qwen-code-core'; @@ -109,7 +108,6 @@ export const useAuthCommand = ( if (credentials?.model != null) { settings.setValue(scope, 'model.name', credentials.model); } - await clearCachedCredentialFile(); } } catch (error) { handleAuthFailure(error); diff --git a/packages/cli/src/ui/commands/modelCommand.test.ts b/packages/cli/src/ui/commands/modelCommand.test.ts index af5c2ce63..41f95f199 100644 --- a/packages/cli/src/ui/commands/modelCommand.test.ts +++ b/packages/cli/src/ui/commands/modelCommand.test.ts @@ -13,12 +13,6 @@ import { type ContentGeneratorConfig, type Config, } from '@qwen-code/qwen-code-core'; -import * as availableModelsModule from '../models/availableModels.js'; - -// Mock the availableModels module -vi.mock('../models/availableModels.js', () => ({ - getAvailableModelsForAuthType: vi.fn(), -})); // Helper function to create a mock config function createMockConfig( @@ -31,9 +25,6 @@ function createMockConfig( describe('modelCommand', () => { let mockContext: CommandContext; - const mockGetAvailableModelsForAuthType = vi.mocked( - availableModelsModule.getAvailableModelsForAuthType, - ); beforeEach(() => { mockContext = createMockCommandContext(); @@ -87,10 +78,6 @@ describe('modelCommand', () => { }); it('should return dialog action for QWEN_OAUTH auth type', async () => { - mockGetAvailableModelsForAuthType.mockReturnValue([ - { id: 'qwen3-coder-plus', label: 'qwen3-coder-plus' }, - ]); - const mockConfig = createMockConfig({ model: 'test-model', authType: AuthType.QWEN_OAUTH, @@ -105,11 +92,7 @@ describe('modelCommand', () => { }); }); - it('should return dialog action for USE_OPENAI auth type when model is available', async () => { - mockGetAvailableModelsForAuthType.mockReturnValue([ - { id: 'gpt-4', label: 'gpt-4' }, - ]); - + it('should return dialog action for USE_OPENAI auth type', async () => { const mockConfig = createMockConfig({ model: 'test-model', authType: AuthType.USE_OPENAI, @@ -124,28 +107,7 @@ describe('modelCommand', () => { }); }); - it('should return error for USE_OPENAI auth type when no model is available', async () => { - mockGetAvailableModelsForAuthType.mockReturnValue([]); - - const mockConfig = createMockConfig({ - model: 'test-model', - authType: AuthType.USE_OPENAI, - }); - mockContext.services.config = mockConfig as Config; - - const result = await modelCommand.action!(mockContext, ''); - - expect(result).toEqual({ - type: 'message', - messageType: 'error', - content: - 'No models available for the current authentication type (openai).', - }); - }); - - it('should return error for unsupported auth types', async () => { - mockGetAvailableModelsForAuthType.mockReturnValue([]); - + it('should return dialog action for unsupported auth types', async () => { const mockConfig = createMockConfig({ model: 'test-model', authType: 'UNSUPPORTED_AUTH_TYPE' as AuthType, @@ -155,10 +117,8 @@ describe('modelCommand', () => { const result = await modelCommand.action!(mockContext, ''); expect(result).toEqual({ - type: 'message', - messageType: 'error', - content: - 'No models available for the current authentication type (UNSUPPORTED_AUTH_TYPE).', + type: 'dialog', + dialog: 'model', }); }); diff --git a/packages/cli/src/ui/commands/modelCommand.ts b/packages/cli/src/ui/commands/modelCommand.ts index a25e96a19..e0971bdde 100644 --- a/packages/cli/src/ui/commands/modelCommand.ts +++ b/packages/cli/src/ui/commands/modelCommand.ts @@ -11,7 +11,6 @@ import type { MessageActionReturn, } from './types.js'; import { CommandKind } from './types.js'; -import { getAvailableModelsForAuthType } from '../models/availableModels.js'; import { t } from '../../i18n/index.js'; export const modelCommand: SlashCommand = { @@ -52,22 +51,6 @@ export const modelCommand: SlashCommand = { }; } - const availableModels = getAvailableModelsForAuthType(authType); - - if (availableModels.length === 0) { - return { - type: 'message', - messageType: 'error', - content: t( - 'No models available for the current authentication type ({{authType}}).', - { - authType, - }, - ), - }; - } - - // Trigger model selection dialog return { type: 'dialog', dialog: 'model', diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index fe484e260..ac47ba46a 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -10,7 +10,11 @@ import { ModelDialog } from './ModelDialog.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js'; import { ConfigContext } from '../contexts/ConfigContext.js'; +import { SettingsContext } from '../contexts/SettingsContext.js'; import type { Config } from '@qwen-code/qwen-code-core'; +import { AuthType } from '@qwen-code/qwen-code-core'; +import type { LoadedSettings } from '../../config/settings.js'; +import { SettingScope } from '../../config/settings.js'; import { AVAILABLE_MODELS_QWEN, MAINLINE_CODER, @@ -36,18 +40,29 @@ const renderComponent = ( }; const combinedProps = { ...defaultProps, ...props }; + const mockSettings = { + isTrusted: true, + user: { settings: {} }, + workspace: { settings: {} }, + setValue: vi.fn(), + } as unknown as LoadedSettings; + const mockConfig = contextValue ? ({ // --- Functions used by ModelDialog --- getModel: vi.fn(() => MAINLINE_CODER), - setModel: vi.fn(), + setModel: vi.fn().mockResolvedValue(undefined), + switchModel: vi.fn().mockResolvedValue(undefined), getAuthType: vi.fn(() => 'qwen-oauth'), // --- Functions used by ClearcutLogger --- getUsageStatisticsEnabled: vi.fn(() => true), getSessionId: vi.fn(() => 'mock-session-id'), getDebugMode: vi.fn(() => false), - getContentGeneratorConfig: vi.fn(() => ({ authType: 'mock' })), + getContentGeneratorConfig: vi.fn(() => ({ + authType: AuthType.QWEN_OAUTH, + model: MAINLINE_CODER, + })), getUseSmartEdit: vi.fn(() => false), getUseModelRouter: vi.fn(() => false), getProxy: vi.fn(() => undefined), @@ -58,21 +73,27 @@ const renderComponent = ( : undefined; const renderResult = render( - - - , + + + + + , ); return { ...renderResult, props: combinedProps, mockConfig, + mockSettings, }; }; describe('', () => { beforeEach(() => { vi.clearAllMocks(); + // Ensure env-based fallback models don't leak into this suite from the developer environment. + delete process.env['OPENAI_MODEL']; + delete process.env['ANTHROPIC_MODEL']; }); afterEach(() => { @@ -91,8 +112,12 @@ describe('', () => { const props = mockedSelect.mock.calls[0][0]; expect(props.items).toHaveLength(AVAILABLE_MODELS_QWEN.length); - expect(props.items[0].value).toBe(MAINLINE_CODER); - expect(props.items[1].value).toBe(MAINLINE_VLM); + expect(props.items[0].value).toBe( + `${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`, + ); + expect(props.items[1].value).toBe( + `${AuthType.QWEN_OAUTH}::${MAINLINE_VLM}`, + ); expect(props.showNumbers).toBe(true); }); @@ -139,16 +164,93 @@ describe('', () => { expect(mockedSelect).toHaveBeenCalledTimes(1); }); - it('calls config.setModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', () => { - const { props, mockConfig } = renderComponent({}, {}); // Pass empty object for contextValue + it('calls config.switchModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', async () => { + const { props, mockConfig, mockSettings } = renderComponent({}, {}); // Pass empty object for contextValue const childOnSelect = mockedSelect.mock.calls[0][0].onSelect; expect(childOnSelect).toBeDefined(); - childOnSelect(MAINLINE_CODER); + await childOnSelect(`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`); - // Assert against the default mock provided by renderComponent - expect(mockConfig?.setModel).toHaveBeenCalledWith(MAINLINE_CODER); + expect(mockConfig?.switchModel).toHaveBeenCalledWith( + AuthType.QWEN_OAUTH, + MAINLINE_CODER, + undefined, + { + reason: 'user_manual', + context: 'Model switched via /model dialog', + }, + ); + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'model.name', + MAINLINE_CODER, + ); + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'security.auth.selectedType', + AuthType.QWEN_OAUTH, + ); + expect(props.onClose).toHaveBeenCalledTimes(1); + }); + + it('calls config.switchModel and persists authType+model when selecting a different authType', async () => { + const switchModel = vi.fn().mockResolvedValue(undefined); + const getAuthType = vi.fn(() => AuthType.USE_OPENAI); + const getAvailableModelsForAuthType = vi.fn((t: AuthType) => { + if (t === AuthType.USE_OPENAI) { + return [{ id: 'gpt-4', label: 'GPT-4', authType: t }]; + } + if (t === AuthType.QWEN_OAUTH) { + return AVAILABLE_MODELS_QWEN.map((m) => ({ + id: m.id, + label: m.label, + authType: AuthType.QWEN_OAUTH, + })); + } + return []; + }); + + const mockConfigWithSwitchAuthType = { + getAuthType, + getModel: vi.fn(() => 'gpt-4'), + getContentGeneratorConfig: vi.fn(() => ({ + authType: AuthType.QWEN_OAUTH, + model: MAINLINE_CODER, + })), + // Add switchModel to the mock object (not the type) + switchModel, + getAvailableModelsForAuthType, + }; + + const { props, mockSettings } = renderComponent( + {}, + // Cast to Config to bypass type checking, matching the runtime behavior + mockConfigWithSwitchAuthType as unknown as Partial, + ); + + const childOnSelect = mockedSelect.mock.calls[0][0].onSelect; + await childOnSelect(`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`); + + expect(switchModel).toHaveBeenCalledWith( + AuthType.QWEN_OAUTH, + MAINLINE_CODER, + { requireCachedCredentials: true }, + { + reason: 'user_manual', + context: 'AuthType+model switched via /model dialog', + }, + ); + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'model.name', + MAINLINE_CODER, + ); + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'security.auth.selectedType', + AuthType.QWEN_OAUTH, + ); expect(props.onClose).toHaveBeenCalledTimes(1); }); @@ -193,17 +295,25 @@ describe('', () => { it('updates initialIndex when config context changes', () => { const mockGetModel = vi.fn(() => MAINLINE_CODER); const mockGetAuthType = vi.fn(() => 'qwen-oauth'); + const mockSettings = { + isTrusted: true, + user: { settings: {} }, + workspace: { settings: {} }, + setValue: vi.fn(), + } as unknown as LoadedSettings; const { rerender } = render( - - - , + + + + + , ); expect(mockedSelect.mock.calls[0][0].initialIndex).toBe(0); @@ -215,9 +325,11 @@ describe('', () => { } as unknown as Config; rerender( - - - , + + + + + , ); // Should be called at least twice: initial render + re-render after context change diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index 55b3300bf..b5d39cc46 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -5,52 +5,210 @@ */ import type React from 'react'; -import { useCallback, useContext, useMemo } from 'react'; +import { useCallback, useContext, useMemo, useState } from 'react'; import { Box, Text } from 'ink'; import { AuthType, ModelSlashCommandEvent, logModelSlashCommand, + type ContentGeneratorConfig, + type ContentGeneratorConfigSource, + type ContentGeneratorConfigSources, } from '@qwen-code/qwen-code-core'; import { useKeypress } from '../hooks/useKeypress.js'; import { theme } from '../semantic-colors.js'; import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js'; import { ConfigContext } from '../contexts/ConfigContext.js'; +import { UIStateContext } from '../contexts/UIStateContext.js'; +import { useSettings } from '../contexts/SettingsContext.js'; import { getAvailableModelsForAuthType, MAINLINE_CODER, } from '../models/availableModels.js'; +import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; import { t } from '../../i18n/index.js'; interface ModelDialogProps { onClose: () => void; } +function formatSourceBadge( + source: ContentGeneratorConfigSource | undefined, +): string | undefined { + if (!source) return undefined; + + switch (source.kind) { + case 'cli': + return source.detail ? `CLI ${source.detail}` : 'CLI'; + case 'env': + return source.envKey ? `ENV ${source.envKey}` : 'ENV'; + case 'settings': + return source.settingsPath + ? `Settings ${source.settingsPath}` + : 'Settings'; + case 'modelProviders': { + const suffix = + source.authType && source.modelId + ? `${source.authType}:${source.modelId}` + : source.authType + ? `${source.authType}` + : source.modelId + ? `${source.modelId}` + : ''; + return suffix ? `ModelProviders ${suffix}` : 'ModelProviders'; + } + case 'default': + return source.detail ? `Default ${source.detail}` : 'Default'; + case 'computed': + return source.detail ? `Computed ${source.detail}` : 'Computed'; + case 'programmatic': + return source.detail ? `Programmatic ${source.detail}` : 'Programmatic'; + case 'unknown': + default: + return undefined; + } +} + +function readSourcesFromConfig(config: unknown): ContentGeneratorConfigSources { + if (!config) { + return {}; + } + const maybe = config as { + getContentGeneratorConfigSources?: () => ContentGeneratorConfigSources; + }; + return maybe.getContentGeneratorConfigSources?.() ?? {}; +} + +function maskApiKey(apiKey: string | undefined): string { + if (!apiKey) return '(not set)'; + const trimmed = apiKey.trim(); + if (trimmed.length === 0) return '(not set)'; + if (trimmed.length <= 6) return '***'; + const head = trimmed.slice(0, 3); + const tail = trimmed.slice(-4); + return `${head}…${tail}`; +} + +function persistModelSelection( + settings: ReturnType, + modelId: string, +): void { + const scope = getPersistScopeForModelSelection(settings); + settings.setValue(scope, 'model.name', modelId); +} + +function persistAuthTypeSelection( + settings: ReturnType, + authType: AuthType, +): void { + const scope = getPersistScopeForModelSelection(settings); + settings.setValue(scope, 'security.auth.selectedType', authType); +} + +function ConfigRow({ + label, + value, + badge, +}: { + label: string; + value: React.ReactNode; + badge?: string; +}): React.JSX.Element { + return ( + + + + {label}: + + + {value} + + + {badge ? ( + + + + + + {badge} + + + ) : null} + + ); +} + export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { const config = useContext(ConfigContext); + const uiState = useContext(UIStateContext); + const settings = useSettings(); + + // Local error state for displaying errors within the dialog + const [errorMessage, setErrorMessage] = useState(null); - // Get auth type from config, default to QWEN_OAUTH if not available const authType = config?.getAuthType() ?? AuthType.QWEN_OAUTH; + const effectiveConfig = + (config?.getContentGeneratorConfig?.() as + | ContentGeneratorConfig + | undefined) ?? undefined; + const sources = readSourcesFromConfig(config); - // Get available models based on auth type - const availableModels = useMemo( - () => getAvailableModelsForAuthType(authType), - [authType], - ); + const availableModelEntries = useMemo(() => { + const allAuthTypes = Object.values(AuthType) as AuthType[]; + const modelsByAuthType = allAuthTypes + .map((t) => ({ + authType: t, + models: getAvailableModelsForAuthType(t, config ?? undefined), + })) + .filter((x) => x.models.length > 0); + + // Fixed order: qwen-oauth first, then others in a stable order + const authTypeOrder: AuthType[] = [ + AuthType.QWEN_OAUTH, + AuthType.USE_OPENAI, + AuthType.USE_ANTHROPIC, + AuthType.USE_GEMINI, + AuthType.USE_VERTEX_AI, + ]; + + // Filter to only include authTypes that have models + const availableAuthTypes = new Set(modelsByAuthType.map((x) => x.authType)); + const orderedAuthTypes = authTypeOrder.filter((t) => + availableAuthTypes.has(t), + ); + + return orderedAuthTypes.flatMap((t) => { + const models = + modelsByAuthType.find((x) => x.authType === t)?.models ?? []; + return models.map((m) => ({ authType: t, model: m })); + }); + }, [config]); const MODEL_OPTIONS = useMemo( () => - availableModels.map((model) => ({ - value: model.id, - title: model.label, - description: model.description || '', - key: model.id, - })), - [availableModels], + availableModelEntries.map(({ authType: t2, model }) => { + const value = `${t2}::${model.id}`; + const title = ( + + + [{t2}] + + {` ${model.label}`} + + ); + const description = model.description || ''; + return { + value, + title, + description, + key: value, + }; + }), + [availableModelEntries], ); - // Determine the Preferred Model (read once when the dialog opens). - const preferredModel = config?.getModel() || MAINLINE_CODER; + const preferredModelId = config?.getModel() || MAINLINE_CODER; + const preferredKey = `${authType}::${preferredModelId}`; useKeypress( (key) => { @@ -61,25 +219,97 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { { isActive: true }, ); - // Calculate the initial index based on the preferred model. const initialIndex = useMemo( - () => MODEL_OPTIONS.findIndex((option) => option.value === preferredModel), - [MODEL_OPTIONS, preferredModel], + () => MODEL_OPTIONS.findIndex((option) => option.value === preferredKey), + [MODEL_OPTIONS, preferredKey], ); - // Handle selection internally (Autonomous Dialog). const handleSelect = useCallback( - (model: string) => { + async (selected: string) => { + // Clear any previous error + setErrorMessage(null); + + const sep = '::'; + const idx = selected.indexOf(sep); + const selectedAuthType = ( + idx >= 0 ? selected.slice(0, idx) : authType + ) as AuthType; + const modelId = idx >= 0 ? selected.slice(idx + sep.length) : selected; + if (config) { - config.setModel(model); - const event = new ModelSlashCommandEvent(model); + try { + await config.switchModel( + selectedAuthType, + modelId, + selectedAuthType !== authType && + selectedAuthType === AuthType.QWEN_OAUTH + ? { requireCachedCredentials: true } + : undefined, + { + reason: 'user_manual', + context: + selectedAuthType === authType + ? 'Model switched via /model dialog' + : 'AuthType+model switched via /model dialog', + }, + ); + } catch (e) { + const baseErrorMessage = e instanceof Error ? e.message : String(e); + + // Some auth types (notably openai without modelProviders configured) can present + // env-based "raw" model IDs in the list. These are not registry-backed and will + // fail switchModel(). Fall back to setModel() to keep UX functional. + const isNotFound = + baseErrorMessage.includes('not found for authType') || + (baseErrorMessage.includes('Model') && + baseErrorMessage.includes('not found')); + if (!isNotFound) { + setErrorMessage( + `Failed to switch model to '${modelId}'.\n\n${baseErrorMessage}`, + ); + + // Keep the dialog open so the user can choose another model. + return; + } + await config.setModel(modelId, { + reason: 'user_manual', + context: 'Model set via /model dialog (raw)', + }); + } + const event = new ModelSlashCommandEvent(modelId); logModelSlashCommand(config, event); + + const after = config.getContentGeneratorConfig?.() as + | ContentGeneratorConfig + | undefined; + const effectiveAuthType = + after?.authType ?? selectedAuthType ?? authType; + const effectiveModelId = after?.model ?? modelId; + + persistModelSelection(settings, effectiveModelId); + persistAuthTypeSelection(settings, effectiveAuthType); + + const baseUrl = after?.baseUrl ?? '(default)'; + const maskedKey = maskApiKey(after?.apiKey); + uiState?.historyManager.addItem( + { + type: 'info', + text: + `authType: ${effectiveAuthType}\n` + + `Using model: ${effectiveModelId}\n` + + `Base URL: ${baseUrl}\n` + + `API key: ${maskedKey}`, + }, + Date.now(), + ); } onClose(); }, - [config, onClose], + [authType, config, onClose, settings, uiState, setErrorMessage], ); + const hasModels = MODEL_OPTIONS.length > 0; + return ( {t('Select Model')} - - + + + + {t('Current (effective) configuration')} + + + + + + {authType !== AuthType.QWEN_OAUTH && ( + <> + + + + )} + + {effectiveConfig?.samplingParams ? ( + + ) : null} + + {effectiveConfig?.timeout !== undefined ? ( + + ) : null} + + + {!hasModels ? ( + + + {t( + 'No models available for the current authentication type ({{authType}}).', + { + authType, + }, + )} + + + + {t( + 'Please configure models in settings.modelProviders or use environment variables.', + )} + + + + ) : ( + + + + )} + + {errorMessage && ( + + + ✕ {errorMessage} + + + )} + {t('(Press Esc to close)')} diff --git a/packages/cli/src/ui/components/shared/DescriptiveRadioButtonSelect.tsx b/packages/cli/src/ui/components/shared/DescriptiveRadioButtonSelect.tsx index 3cc563283..89bf4c03b 100644 --- a/packages/cli/src/ui/components/shared/DescriptiveRadioButtonSelect.tsx +++ b/packages/cli/src/ui/components/shared/DescriptiveRadioButtonSelect.tsx @@ -11,7 +11,7 @@ import { BaseSelectionList } from './BaseSelectionList.js'; import type { SelectionListItem } from '../../hooks/useSelectionList.js'; export interface DescriptiveRadioSelectItem extends SelectionListItem { - title: string; + title: React.ReactNode; description: string; } diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index e70ea0538..561c98ed6 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -912,7 +912,7 @@ export const useGeminiStream = ( // Reset quota error flag when starting a new query (not a continuation) if (!options?.isContinuation) { setModelSwitchedFromQuotaError(false); - config.setQuotaErrorOccurred(false); + // No quota-error / fallback routing mechanism currently; keep state minimal. } abortControllerRef.current = new AbortController(); diff --git a/packages/cli/src/ui/hooks/useToolScheduler.test.ts b/packages/cli/src/ui/hooks/useToolScheduler.test.ts index d7b2b8109..961b52b24 100644 --- a/packages/cli/src/ui/hooks/useToolScheduler.test.ts +++ b/packages/cli/src/ui/hooks/useToolScheduler.test.ts @@ -62,7 +62,7 @@ const mockConfig = { getAllowedTools: vi.fn(() => []), getContentGeneratorConfig: () => ({ model: 'test-model', - authType: 'gemini-api-key', + authType: 'gemini', }), getUseSmartEdit: () => false, getUseModelRouter: () => false, diff --git a/packages/cli/src/ui/models/availableModels.test.ts b/packages/cli/src/ui/models/availableModels.test.ts new file mode 100644 index 000000000..feac835c6 --- /dev/null +++ b/packages/cli/src/ui/models/availableModels.test.ts @@ -0,0 +1,205 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + getAvailableModelsForAuthType, + getFilteredQwenModels, + getOpenAIAvailableModelFromEnv, + isVisionModel, + getDefaultVisionModel, + AVAILABLE_MODELS_QWEN, + MAINLINE_VLM, + MAINLINE_CODER, +} from './availableModels.js'; +import { AuthType, type Config } from '@qwen-code/qwen-code-core'; + +describe('availableModels', () => { + describe('AVAILABLE_MODELS_QWEN', () => { + it('should include coder model', () => { + const coderModel = AVAILABLE_MODELS_QWEN.find( + (m) => m.id === MAINLINE_CODER, + ); + expect(coderModel).toBeDefined(); + expect(coderModel?.isVision).toBeFalsy(); + }); + + it('should include vision model', () => { + const visionModel = AVAILABLE_MODELS_QWEN.find( + (m) => m.id === MAINLINE_VLM, + ); + expect(visionModel).toBeDefined(); + expect(visionModel?.isVision).toBe(true); + }); + }); + + describe('getFilteredQwenModels', () => { + it('should return all models when vision preview is enabled', () => { + const models = getFilteredQwenModels(true); + expect(models.length).toBe(AVAILABLE_MODELS_QWEN.length); + }); + + it('should filter out vision models when preview is disabled', () => { + const models = getFilteredQwenModels(false); + expect(models.every((m) => !m.isVision)).toBe(true); + }); + }); + + describe('getOpenAIAvailableModelFromEnv', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should return null when OPENAI_MODEL is not set', () => { + delete process.env['OPENAI_MODEL']; + expect(getOpenAIAvailableModelFromEnv()).toBeNull(); + }); + + it('should return model from OPENAI_MODEL env var', () => { + process.env['OPENAI_MODEL'] = 'gpt-4-turbo'; + const model = getOpenAIAvailableModelFromEnv(); + expect(model?.id).toBe('gpt-4-turbo'); + expect(model?.label).toBe('gpt-4-turbo'); + }); + + it('should trim whitespace from env var', () => { + process.env['OPENAI_MODEL'] = ' gpt-4 '; + const model = getOpenAIAvailableModelFromEnv(); + expect(model?.id).toBe('gpt-4'); + }); + }); + + describe('getAvailableModelsForAuthType', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should return hard-coded qwen models for qwen-oauth', () => { + const models = getAvailableModelsForAuthType(AuthType.QWEN_OAUTH); + expect(models).toEqual(AVAILABLE_MODELS_QWEN); + }); + + it('should return hard-coded qwen models even when config is provided', () => { + const mockConfig = { + getAvailableModels: vi + .fn() + .mockReturnValue([ + { id: 'custom', label: 'Custom', authType: AuthType.QWEN_OAUTH }, + ]), + } as unknown as Config; + + const models = getAvailableModelsForAuthType( + AuthType.QWEN_OAUTH, + mockConfig, + ); + expect(models).toEqual(AVAILABLE_MODELS_QWEN); + }); + + it('should use config.getAvailableModels for openai authType when available', () => { + const mockModels = [ + { + id: 'gpt-4', + label: 'GPT-4', + description: 'Test', + authType: AuthType.USE_OPENAI, + isVision: false, + }, + ]; + const getAvailableModelsForAuthType = vi.fn().mockReturnValue(mockModels); + const mockConfigWithMethod = { + // Prefer the newer API when available. + getAvailableModelsForAuthType, + }; + + const models = getAvailableModelsForAuthType( + AuthType.USE_OPENAI, + mockConfigWithMethod as unknown as Config, + ); + + expect(getAvailableModelsForAuthType).toHaveBeenCalled(); + expect(models[0].id).toBe('gpt-4'); + }); + + it('should fallback to env var for openai when config returns empty', () => { + process.env['OPENAI_MODEL'] = 'fallback-model'; + const mockConfig = { + getAvailableModelsForAuthType: vi.fn().mockReturnValue([]), + } as unknown as Config; + + const models = getAvailableModelsForAuthType( + AuthType.USE_OPENAI, + mockConfig, + ); + + expect(models).toEqual([]); + }); + + it('should fallback to env var for openai when config throws', () => { + process.env['OPENAI_MODEL'] = 'fallback-model'; + const mockConfig = { + getAvailableModelsForAuthType: vi.fn().mockImplementation(() => { + throw new Error('Registry not initialized'); + }), + } as unknown as Config; + + const models = getAvailableModelsForAuthType( + AuthType.USE_OPENAI, + mockConfig, + ); + + expect(models).toEqual([]); + }); + + it('should return env model for openai without config', () => { + process.env['OPENAI_MODEL'] = 'gpt-4-turbo'; + const models = getAvailableModelsForAuthType(AuthType.USE_OPENAI); + expect(models[0].id).toBe('gpt-4-turbo'); + }); + + it('should return empty array for openai without config or env', () => { + delete process.env['OPENAI_MODEL']; + const models = getAvailableModelsForAuthType(AuthType.USE_OPENAI); + expect(models).toEqual([]); + }); + + it('should return empty array for other auth types', () => { + const models = getAvailableModelsForAuthType(AuthType.USE_GEMINI); + expect(models).toEqual([]); + }); + }); + + describe('isVisionModel', () => { + it('should return true for vision model', () => { + expect(isVisionModel(MAINLINE_VLM)).toBe(true); + }); + + it('should return false for non-vision model', () => { + expect(isVisionModel(MAINLINE_CODER)).toBe(false); + }); + + it('should return false for unknown model', () => { + expect(isVisionModel('unknown-model')).toBe(false); + }); + }); + + describe('getDefaultVisionModel', () => { + it('should return the vision model ID', () => { + expect(getDefaultVisionModel()).toBe(MAINLINE_VLM); + }); + }); +}); diff --git a/packages/cli/src/ui/models/availableModels.ts b/packages/cli/src/ui/models/availableModels.ts index d9c9eb725..1cff984c8 100644 --- a/packages/cli/src/ui/models/availableModels.ts +++ b/packages/cli/src/ui/models/availableModels.ts @@ -4,7 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { AuthType, DEFAULT_QWEN_MODEL } from '@qwen-code/qwen-code-core'; +import { + AuthType, + DEFAULT_QWEN_MODEL, + type Config, + type AvailableModel as CoreAvailableModel, +} from '@qwen-code/qwen-code-core'; import { t } from '../../i18n/index.js'; export type AvailableModel = { @@ -57,20 +62,78 @@ export function getFilteredQwenModels( */ export function getOpenAIAvailableModelFromEnv(): AvailableModel | null { const id = process.env['OPENAI_MODEL']?.trim(); - return id ? { id, label: id } : null; + return id + ? { + id, + label: id, + get description() { + return t('Configured via OPENAI_MODEL environment variable'); + }, + } + : null; } export function getAnthropicAvailableModelFromEnv(): AvailableModel | null { const id = process.env['ANTHROPIC_MODEL']?.trim(); - return id ? { id, label: id } : null; + return id + ? { + id, + label: id, + get description() { + return t('Configured via ANTHROPIC_MODEL environment variable'); + }, + } + : null; } +/** + * Convert core AvailableModel to CLI AvailableModel format + */ +function convertCoreModelToCliModel( + coreModel: CoreAvailableModel, +): AvailableModel { + return { + id: coreModel.id, + label: coreModel.label, + description: coreModel.description, + isVision: coreModel.isVision ?? coreModel.capabilities?.vision ?? false, + }; +} + +/** + * Get available models for the given authType. + * + * If a Config object is provided, uses config.getAvailableModelsForAuthType(). + * For qwen-oauth, always returns the hard-coded models. + * Falls back to environment variables only when no config is provided. + */ export function getAvailableModelsForAuthType( authType: AuthType, + config?: Config, ): AvailableModel[] { + // For qwen-oauth, always use hard-coded models, this aligns with the API gateway. + if (authType === AuthType.QWEN_OAUTH) { + return AVAILABLE_MODELS_QWEN; + } + + // Use config's model registry when available + if (config) { + try { + const models = config.getAvailableModelsForAuthType(authType); + if (models.length > 0) { + return models.map(convertCoreModelToCliModel); + } + } catch { + // If config throws (e.g., not initialized), return empty array + } + // When a Config object is provided, we intentionally do NOT fall back to env-based + // "raw" models. These may reflect the currently effective config but should not be + // presented as selectable options in /model. + return []; + } + + // Fall back to environment variables for specific auth types (no config provided) switch (authType) { - case AuthType.QWEN_OAUTH: - return AVAILABLE_MODELS_QWEN; case AuthType.USE_OPENAI: { const openAIModel = getOpenAIAvailableModelFromEnv(); return openAIModel ? [openAIModel] : []; @@ -80,13 +143,10 @@ export function getAvailableModelsForAuthType( return anthropicModel ? [anthropicModel] : []; } default: - // For other auth types, return empty array for now - // This can be expanded later according to the design doc return []; } } -/** /** * Hard code the default vision model as a string literal, * until our coding model supports multimodal. diff --git a/packages/cli/src/utils/modelConfigUtils.ts b/packages/cli/src/utils/modelConfigUtils.ts new file mode 100644 index 000000000..cb710692a --- /dev/null +++ b/packages/cli/src/utils/modelConfigUtils.ts @@ -0,0 +1,112 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + AuthType, + type ContentGeneratorConfig, + type ContentGeneratorConfigSources, + resolveModelConfig, + type ModelConfigSourcesInput, +} from '@qwen-code/qwen-code-core'; +import type { Settings } from '../config/settings.js'; + +export interface CliGenerationConfigInputs { + argv: { + model?: string | undefined; + openaiApiKey?: string | undefined; + openaiBaseUrl?: string | undefined; + openaiLogging?: boolean | undefined; + openaiLoggingDir?: string | undefined; + }; + settings: Settings; + selectedAuthType: AuthType | undefined; + /** + * Injectable env for testability. Defaults to process.env at callsites. + */ + env?: Record; +} + +export interface ResolvedCliGenerationConfig { + /** The resolved model id (may be empty string if not resolvable at CLI layer) */ + model: string; + /** API key for OpenAI-compatible auth */ + apiKey: string; + /** Base URL for OpenAI-compatible auth */ + baseUrl: string; + /** The full generation config to pass to core Config */ + generationConfig: Partial; + /** Source attribution for each resolved field */ + sources: ContentGeneratorConfigSources; +} + +/** + * Unified resolver for CLI generation config. + * + * Precedence (for OpenAI auth): + * - model: argv.model > OPENAI_MODEL > QWEN_MODEL > settings.model.name + * - apiKey: argv.openaiApiKey > OPENAI_API_KEY > settings.security.auth.apiKey + * - baseUrl: argv.openaiBaseUrl > OPENAI_BASE_URL > settings.security.auth.baseUrl + * + * For non-OpenAI auth, only argv.model override is respected at CLI layer. + */ +export function resolveCliGenerationConfig( + inputs: CliGenerationConfigInputs, +): ResolvedCliGenerationConfig { + const { argv, settings, selectedAuthType } = inputs; + const env = inputs.env ?? (process.env as Record); + + const authType = selectedAuthType ?? AuthType.QWEN_OAUTH; + + const configSources: ModelConfigSourcesInput = { + authType, + cli: { + model: argv.model, + apiKey: argv.openaiApiKey, + baseUrl: argv.openaiBaseUrl, + }, + settings: { + model: settings.model?.name, + apiKey: settings.security?.auth?.apiKey, + baseUrl: settings.security?.auth?.baseUrl, + generationConfig: settings.model?.generationConfig as + | Partial + | undefined, + }, + env, + }; + + const resolved = resolveModelConfig(configSources); + + // Log warnings if any + for (const warning of resolved.warnings) { + console.warn(`[modelProviderUtils] ${warning}`); + } + + // Resolve OpenAI logging config (CLI-specific, not part of core resolver) + const enableOpenAILogging = + (typeof argv.openaiLogging === 'undefined' + ? settings.model?.enableOpenAILogging + : argv.openaiLogging) ?? false; + + const openAILoggingDir = + argv.openaiLoggingDir || settings.model?.openAILoggingDir; + + // Build the full generation config + // Note: we merge the resolved config with logging settings + const generationConfig: Partial = { + ...resolved.config, + enableOpenAILogging, + openAILoggingDir, + }; + + return { + model: resolved.config.model || '', + apiKey: resolved.config.apiKey || '', + baseUrl: resolved.config.baseUrl || '', + generationConfig, + sources: resolved.sources, + }; +} diff --git a/packages/cli/src/utils/modelProviderUtils.ts b/packages/cli/src/utils/modelProviderUtils.ts deleted file mode 100644 index 473499771..000000000 --- a/packages/cli/src/utils/modelProviderUtils.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { - AuthType, - type ContentGeneratorConfig, - type ContentGeneratorConfigSource, - type ContentGeneratorConfigSources, - type ModelProvidersConfig, - type ProviderModelConfig as ModelConfig, -} from '@qwen-code/qwen-code-core'; -import type { Settings } from '../config/settings.js'; - -export interface GenerationConfigSourceInputs { - argv: { - model?: string | undefined; - openaiApiKey?: string | undefined; - openaiBaseUrl?: string | undefined; - }; - settings: Settings; - selectedAuthType: AuthType | undefined; - /** - * Injectable env for testability. Defaults to process.env at callsites. - */ - env?: Record; -} - -/** - * Get models configuration from settings, grouped by authType. - * Returns the models config from the merged settings without mutating files. - */ -export function getModelProvidersConfigFromSettings( - settings: Settings, -): ModelProvidersConfig { - return (settings.modelProviders as ModelProvidersConfig) || {}; -} - -/** - * Get models for a specific authType from settings. - */ -export function getModelsForAuthType( - settings: Settings, - authType: AuthType, -): ModelConfig[] { - const modelProvidersConfig = getModelProvidersConfigFromSettings(settings); - return modelProvidersConfig[authType] || []; -} - -/** - * Best-effort attribution for the seed generationConfig fields. - * - * NOTE: - * - This does not attempt to distinguish user vs workspace settings; it reflects merged settings. - * - This should stay consistent with the actual precedence used to compute the corresponding values. - */ -export function buildGenerationConfigSources( - inputs: GenerationConfigSourceInputs, -): ContentGeneratorConfigSources { - const { argv, settings, selectedAuthType } = inputs; - const env = inputs.env ?? (process.env as Record); - - const sources: ContentGeneratorConfigSources = {}; - - const setSource = (path: string, source: ContentGeneratorConfigSource) => { - sources[path] = source; - }; - - // Model/apiKey/baseUrl attribution mirrors current CLI precedence: - // - model: argv.model > (OPENAI_MODEL|QWEN_MODEL|settings.model.name) only for OpenAI auth - // - apiKey/baseUrl: only meaningful for OpenAI auth in current CLI wiring - if (selectedAuthType === AuthType.USE_OPENAI) { - if (argv.model) { - setSource('model', { kind: 'cli', detail: '--model' }); - } else if (env['OPENAI_MODEL']) { - setSource('model', { kind: 'env', envKey: 'OPENAI_MODEL' }); - } else if (env['QWEN_MODEL']) { - setSource('model', { kind: 'env', envKey: 'QWEN_MODEL' }); - } else if (settings.model?.name) { - setSource('model', { kind: 'settings', settingsPath: 'model.name' }); - } - - if (argv.openaiApiKey) { - setSource('apiKey', { kind: 'cli', detail: '--openaiApiKey' }); - } else if (env['OPENAI_API_KEY']) { - setSource('apiKey', { kind: 'env', envKey: 'OPENAI_API_KEY' }); - } else if (settings.security?.auth?.apiKey) { - setSource('apiKey', { - kind: 'settings', - settingsPath: 'security.auth.apiKey', - }); - } - - if (argv.openaiBaseUrl) { - setSource('baseUrl', { kind: 'cli', detail: '--openaiBaseUrl' }); - } else if (env['OPENAI_BASE_URL']) { - setSource('baseUrl', { kind: 'env', envKey: 'OPENAI_BASE_URL' }); - } else if (settings.security?.auth?.baseUrl) { - setSource('baseUrl', { - kind: 'settings', - settingsPath: 'security.auth.baseUrl', - }); - } - } else if (argv.model) { - // For non-openai auth types, the CLI only wires through an explicit raw model override. - setSource('model', { kind: 'cli', detail: '--model' }); - } - - const mergedGenerationConfig = settings.model?.generationConfig as - | Partial - | undefined; - if (mergedGenerationConfig) { - setSource('generationConfig', { - kind: 'settings', - settingsPath: 'model.generationConfig', - }); - // We also map the known top-level fields used by core. - if (mergedGenerationConfig.samplingParams) { - setSource('samplingParams', { - kind: 'settings', - settingsPath: 'model.generationConfig.samplingParams', - }); - } - for (const k of [ - 'timeout', - 'maxRetries', - 'disableCacheControl', - 'schemaCompliance', - ] as const) { - if (mergedGenerationConfig[k] !== undefined) { - setSource(k, { - kind: 'settings', - settingsPath: `model.generationConfig.${k}`, - }); - } - } - } - - return sources; -} diff --git a/packages/core/index.ts b/packages/core/index.ts index 3227199e4..aab675a18 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -8,12 +8,8 @@ export * from './src/index.js'; export { Storage } from './src/config/storage.js'; export { DEFAULT_QWEN_MODEL, + DEFAULT_QWEN_FLASH_MODEL, DEFAULT_QWEN_EMBEDDING_MODEL, - DEFAULT_GEMINI_MODEL, - DEFAULT_GEMINI_MODEL_AUTO, - DEFAULT_GEMINI_FLASH_MODEL, - DEFAULT_GEMINI_FLASH_LITE_MODEL, - DEFAULT_GEMINI_EMBEDDING_MODEL, } from './src/config/models.js'; export { serializeTerminalToObject, diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 1b163b9a6..449add116 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -15,10 +15,16 @@ import { DEFAULT_OTLP_ENDPOINT, QwenLogger, } from '../telemetry/index.js'; -import type { ContentGeneratorConfig } from '../core/contentGenerator.js'; +import type { + ContentGenerator, + ContentGeneratorConfig, +} from '../core/contentGenerator.js'; +import { DEFAULT_DASHSCOPE_BASE_URL } from '../core/openaiContentGenerator/constants.js'; import { AuthType, + createContentGenerator, createContentGeneratorConfig, + resolveContentGeneratorConfigWithSources, } from '../core/contentGenerator.js'; import { GeminiClient } from '../core/client.js'; import { GitService } from '../services/gitService.js'; @@ -208,6 +214,19 @@ describe('Server Config (config.ts)', () => { vi.spyOn(QwenLogger.prototype, 'logStartSessionEvent').mockImplementation( async () => undefined, ); + + // Setup default mock for resolveContentGeneratorConfigWithSources + vi.mocked(resolveContentGeneratorConfigWithSources).mockImplementation( + (_config, authType, generationConfig) => ({ + config: { + ...generationConfig, + authType, + model: generationConfig?.model || MODEL, + apiKey: 'test-key', + } as ContentGeneratorConfig, + sources: {}, + }), + ); }); describe('initialize', () => { @@ -255,31 +274,28 @@ describe('Server Config (config.ts)', () => { const mockContentConfig = { apiKey: 'test-key', model: 'qwen3-coder-plus', + authType, }; - vi.mocked(createContentGeneratorConfig).mockReturnValue( - mockContentConfig, - ); - - // Set fallback mode to true to ensure it gets reset - config.setFallbackMode(true); - expect(config.isInFallbackMode()).toBe(true); + vi.mocked(resolveContentGeneratorConfigWithSources).mockReturnValue({ + config: mockContentConfig as ContentGeneratorConfig, + sources: {}, + }); await config.refreshAuth(authType); - expect(createContentGeneratorConfig).toHaveBeenCalledWith( + expect(resolveContentGeneratorConfigWithSources).toHaveBeenCalledWith( config, authType, - { + expect.objectContaining({ model: MODEL, - baseUrl: undefined, - }, + }), + expect.anything(), + expect.anything(), ); // Verify that contentGeneratorConfig is updated expect(config.getContentGeneratorConfig()).toEqual(mockContentConfig); expect(GeminiClient).toHaveBeenCalledWith(config); - // Verify that fallback mode is reset - expect(config.isInFallbackMode()).toBe(false); }); it('should not strip thoughts when switching from Vertex to GenAI', async () => { @@ -300,6 +316,129 @@ describe('Server Config (config.ts)', () => { }); }); + describe('model switching optimization (QWEN_OAUTH)', () => { + it('should switch qwen-oauth model in-place without refreshing auth when safe', async () => { + const config = new Config(baseParams); + + const mockContentConfig: ContentGeneratorConfig = { + authType: AuthType.QWEN_OAUTH, + model: 'coder-model', + apiKey: 'QWEN_OAUTH_DYNAMIC_TOKEN', + baseUrl: DEFAULT_DASHSCOPE_BASE_URL, + timeout: 60000, + maxRetries: 3, + } as ContentGeneratorConfig; + + vi.mocked(resolveContentGeneratorConfigWithSources).mockImplementation( + (_config, authType, generationConfig) => ({ + config: { + ...mockContentConfig, + authType, + model: generationConfig?.model ?? mockContentConfig.model, + } as ContentGeneratorConfig, + sources: {}, + }), + ); + vi.mocked(createContentGenerator).mockResolvedValue({ + generateContent: vi.fn(), + generateContentStream: vi.fn(), + countTokens: vi.fn(), + embedContent: vi.fn(), + } as unknown as ContentGenerator); + + // Establish initial qwen-oauth content generator config/content generator. + await config.refreshAuth(AuthType.QWEN_OAUTH); + + // Spy after initial refresh to ensure model switch does not re-trigger refreshAuth. + const refreshSpy = vi.spyOn(config, 'refreshAuth'); + + await config.switchModel(AuthType.QWEN_OAUTH, 'vision-model'); + + expect(config.getModel()).toBe('vision-model'); + expect(refreshSpy).not.toHaveBeenCalled(); + // Called once during initial refreshAuth + once during handleModelChange diffing. + expect( + vi.mocked(resolveContentGeneratorConfigWithSources), + ).toHaveBeenCalledTimes(2); + expect(vi.mocked(createContentGenerator)).toHaveBeenCalledTimes(1); + }); + }); + + describe('model switching with different credentials (OpenAI)', () => { + it('should refresh auth when switching to model with different envKey', async () => { + // This test verifies the fix for switching between modelProvider models + // with different envKeys (e.g., deepseek-chat with DEEPSEEK_API_KEY) + const configWithModelProviders = new Config({ + ...baseParams, + authType: AuthType.USE_OPENAI, + modelProvidersConfig: { + openai: [ + { + id: 'model-a', + name: 'Model A', + baseUrl: 'https://api.example.com/v1', + envKey: 'API_KEY_A', + }, + { + id: 'model-b', + name: 'Model B', + baseUrl: 'https://api.example.com/v1', + envKey: 'API_KEY_B', + }, + ], + }, + }); + + const mockContentConfigA: ContentGeneratorConfig = { + authType: AuthType.USE_OPENAI, + model: 'model-a', + apiKey: 'key-a', + baseUrl: 'https://api.example.com/v1', + } as ContentGeneratorConfig; + + const mockContentConfigB: ContentGeneratorConfig = { + authType: AuthType.USE_OPENAI, + model: 'model-b', + apiKey: 'key-b', + baseUrl: 'https://api.example.com/v1', + } as ContentGeneratorConfig; + + vi.mocked(resolveContentGeneratorConfigWithSources).mockImplementation( + (_config, _authType, generationConfig) => { + const model = generationConfig?.model; + return { + config: + model === 'model-b' ? mockContentConfigB : mockContentConfigA, + sources: {}, + }; + }, + ); + + vi.mocked(createContentGenerator).mockResolvedValue({ + generateContent: vi.fn(), + generateContentStream: vi.fn(), + countTokens: vi.fn(), + embedContent: vi.fn(), + } as unknown as ContentGenerator); + + // Initialize with model-a + await configWithModelProviders.refreshAuth(AuthType.USE_OPENAI); + + // Spy on refreshAuth to verify it's called when switching to model-b + const refreshSpy = vi.spyOn(configWithModelProviders, 'refreshAuth'); + + // Switch to model-b (different envKey) + await configWithModelProviders.switchModel( + AuthType.USE_OPENAI, + 'model-b', + ); + + // Should trigger full refresh because envKey changed + expect(refreshSpy).toHaveBeenCalledWith(AuthType.USE_OPENAI); + expect(configWithModelProviders.getModel()).toBe('model-b'); + }); + }); + it('Config constructor should store userMemory correctly', () => { const config = new Config(baseParams); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 34dbb4649..eae1dd44b 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -16,9 +16,8 @@ import { ProxyAgent, setGlobalDispatcher } from 'undici'; import type { ContentGenerator, ContentGeneratorConfig, - AuthType, } from '../core/contentGenerator.js'; -import type { FallbackModelHandler } from '../fallback/types.js'; +import type { ContentGeneratorConfigSources } from '../core/contentGenerator.js'; import type { MCPOAuthConfig } from '../mcp/oauth-provider.js'; import type { ShellExecutionConfig } from '../services/shellExecutionService.js'; import type { AnyToolInvocation } from '../tools/tools.js'; @@ -27,8 +26,9 @@ import type { AnyToolInvocation } from '../tools/tools.js'; import { BaseLlmClient } from '../core/baseLlmClient.js'; import { GeminiClient } from '../core/client.js'; import { + AuthType, createContentGenerator, - createContentGeneratorConfig, + resolveContentGeneratorConfigWithSources, } from '../core/contentGenerator.js'; import { tokenLimit } from '../core/tokenLimits.js'; @@ -94,7 +94,7 @@ import { DEFAULT_FILE_FILTERING_OPTIONS, DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, } from './constants.js'; -import { DEFAULT_QWEN_EMBEDDING_MODEL, DEFAULT_QWEN_MODEL } from './models.js'; +import { DEFAULT_QWEN_EMBEDDING_MODEL } from './models.js'; import { Storage } from './storage.js'; import { ChatRecordingService } from '../services/chatRecordingService.js'; import { @@ -103,6 +103,12 @@ import { } from '../services/sessionService.js'; import { randomUUID } from 'node:crypto'; +import { + ModelsConfig, + type ModelProvidersConfig, + type AvailableModel, +} from '../models/index.js'; + // Re-export types export type { AnyToolInvocation, FileFilteringOptions, MCPOAuthConfig }; export { @@ -318,6 +324,11 @@ export interface ConfigParameters { ideMode?: boolean; authType?: AuthType; generationConfig?: Partial; + /** + * Optional source map for generationConfig fields (e.g. CLI/env/settings attribution). + * This is used to produce per-field source badges in the UI. + */ + generationConfigSources?: ContentGeneratorConfigSources; cliVersion?: string; loadMemoryFromIncludeDirectories?: boolean; chatRecording?: boolean; @@ -353,6 +364,8 @@ export interface ConfigParameters { sdkMode?: boolean; sessionSubagents?: SubagentConfig[]; channel?: string; + /** Model providers configuration grouped by authType */ + modelProvidersConfig?: ModelProvidersConfig; } function normalizeConfigOutputFormat( @@ -394,9 +407,12 @@ export class Config { private skillManager!: SkillManager; private fileSystemService: FileSystemService; private contentGeneratorConfig!: ContentGeneratorConfig; + private contentGeneratorConfigSources: ContentGeneratorConfigSources = {}; private contentGenerator!: ContentGenerator; - private _generationConfig: Partial; private readonly embeddingModel: string; + + private _modelsConfig!: ModelsConfig; + private readonly modelProvidersConfig?: ModelProvidersConfig; private readonly sandbox: SandboxConfig | undefined; private readonly targetDir: string; private workspaceContext: WorkspaceContext; @@ -445,7 +461,6 @@ export class Config { private readonly folderTrust: boolean; private ideMode: boolean; - private inFallbackMode = false; private readonly maxSessionTurns: number; private readonly sessionTokenLimit: number; private readonly listExtensions: boolean; @@ -454,8 +469,6 @@ export class Config { name: string; extensionName: string; }>; - fallbackModelHandler?: FallbackModelHandler; - private quotaErrorOccurred: boolean = false; private readonly summarizeToolOutput: | Record | undefined; @@ -570,13 +583,7 @@ export class Config { this.folderTrustFeature = params.folderTrustFeature ?? false; this.folderTrust = params.folderTrust ?? false; this.ideMode = params.ideMode ?? false; - this._generationConfig = { - model: params.model, - ...(params.generationConfig || {}), - baseUrl: params.generationConfig?.baseUrl, - }; - this.contentGeneratorConfig = this - ._generationConfig as ContentGeneratorConfig; + this.modelProvidersConfig = params.modelProvidersConfig; this.cliVersion = params.cliVersion; this.chatRecordingEnabled = params.chatRecording ?? true; @@ -619,6 +626,23 @@ export class Config { setGeminiMdFilename(params.contextFileName); } + // Create ModelsConfig for centralized model management + // Prefer params.authType over generationConfig.authType because: + // - params.authType preserves undefined (user hasn't selected yet) + // - generationConfig.authType may have a default value from resolvers + this._modelsConfig = new ModelsConfig({ + initialAuthType: params.authType ?? params.generationConfig?.authType, + initialModelId: params.model, + modelProvidersConfig: this.modelProvidersConfig, + generationConfig: { + model: params.model, + ...(params.generationConfig || {}), + baseUrl: params.generationConfig?.baseUrl, + }, + generationConfigSources: params.generationConfigSources, + onModelChange: this.handleModelChange.bind(this), + }); + if (this.telemetrySettings.enabled) { initializeTelemetry(this); } @@ -669,45 +693,61 @@ export class Config { return this.contentGenerator; } + /** + * Get the ModelsConfig instance for model-related operations. + * External code (e.g., CLI) can use this to access model configuration. + */ + get modelsConfig(): ModelsConfig { + return this._modelsConfig; + } + /** * Updates the credentials in the generation config. - * This is needed when credentials are set after Config construction. + * Exclusive for `OpenAIKeyPrompt` to update credentials via `/auth` + * Delegates to ModelsConfig. */ updateCredentials(credentials: { apiKey?: string; baseUrl?: string; model?: string; }): void { - if (credentials.apiKey) { - this._generationConfig.apiKey = credentials.apiKey; - } - if (credentials.baseUrl) { - this._generationConfig.baseUrl = credentials.baseUrl; - } - if (credentials.model) { - this._generationConfig.model = credentials.model; - } + this._modelsConfig.updateCredentials(credentials); } + /** + * Refresh authentication and rebuild ContentGenerator. + */ async refreshAuth(authMethod: AuthType, isInitialAuth?: boolean) { - const newContentGeneratorConfig = createContentGeneratorConfig( + // Sync modelsConfig state for this auth refresh + const modelId = this._modelsConfig.getModel(); + this._modelsConfig.syncAfterAuthRefresh(authMethod, modelId); + + // Check and consume cached credentials flag + const requireCached = + this._modelsConfig.consumeRequireCachedCredentialsFlag(); + + const { config, sources } = resolveContentGeneratorConfigWithSources( this, authMethod, - this._generationConfig, + this._modelsConfig.getGenerationConfig(), + this._modelsConfig.getGenerationConfigSources(), + { + strictModelProvider: + this._modelsConfig.isStrictModelProviderSelection(), + }, ); + const newContentGeneratorConfig = config; this.contentGenerator = await createContentGenerator( newContentGeneratorConfig, this, - isInitialAuth, + requireCached ? true : isInitialAuth, ); // Only assign to instance properties after successful initialization this.contentGeneratorConfig = newContentGeneratorConfig; + this.contentGeneratorConfigSources = sources; // Initialize BaseLlmClient now that the ContentGenerator is available this.baseLlmClient = new BaseLlmClient(this.contentGenerator, this); - - // Reset the session flag since we're explicitly changing auth and using default model - this.inFallbackMode = false; } /** @@ -767,31 +807,125 @@ export class Config { return this.contentGeneratorConfig; } - getModel(): string { - return this.contentGeneratorConfig?.model || DEFAULT_QWEN_MODEL; + getContentGeneratorConfigSources(): ContentGeneratorConfigSources { + // If contentGeneratorConfigSources is empty (before initializeAuth), + // get sources from ModelsConfig + if ( + Object.keys(this.contentGeneratorConfigSources).length === 0 && + this._modelsConfig + ) { + return this._modelsConfig.getGenerationConfigSources(); + } + return this.contentGeneratorConfigSources; } + getModel(): string { + return this.contentGeneratorConfig?.model || this._modelsConfig.getModel(); + } + + /** + * Set model programmatically (e.g., VLM auto-switch, fallback). + * Delegates to ModelsConfig. + */ async setModel( newModel: string, - _metadata?: { reason?: string; context?: string }, + metadata?: { reason?: string; context?: string }, ): Promise { + await this._modelsConfig.setModel(newModel, metadata); + // Also update contentGeneratorConfig for hot-update compatibility if (this.contentGeneratorConfig) { this.contentGeneratorConfig.model = newModel; } - // TODO: Log _metadata for telemetry if needed - // This _metadata can be used for tracking model switches (reason, context) } - isInFallbackMode(): boolean { - return this.inFallbackMode; + /** + * Handle model change from ModelsConfig. + * This updates the content generator config with the new model settings. + */ + private async handleModelChange( + authType: AuthType, + requiresRefresh: boolean, + ): Promise { + if (!this.contentGeneratorConfig) { + return; + } + + // Hot update path: only supported for qwen-oauth. + // For other auth types we always refresh to recreate the ContentGenerator. + // + // Rationale: + // - Non-qwen providers may need to re-validate credentials / baseUrl / envKey. + // - ModelsConfig.applyResolvedModelDefaults can clear or change credentials sources. + // - Refresh keeps runtime behavior consistent and centralized. + if (authType === AuthType.QWEN_OAUTH && !requiresRefresh) { + const { config, sources } = resolveContentGeneratorConfigWithSources( + this, + authType, + this._modelsConfig.getGenerationConfig(), + this._modelsConfig.getGenerationConfigSources(), + { + strictModelProvider: + this._modelsConfig.isStrictModelProviderSelection(), + }, + ); + + // Hot-update fields (qwen-oauth models share the same auth + client). + this.contentGeneratorConfig.model = config.model; + this.contentGeneratorConfig.samplingParams = config.samplingParams; + this.contentGeneratorConfig.disableCacheControl = + config.disableCacheControl; + + if ('model' in sources) { + this.contentGeneratorConfigSources['model'] = sources['model']; + } + if ('samplingParams' in sources) { + this.contentGeneratorConfigSources['samplingParams'] = + sources['samplingParams']; + } + if ('disableCacheControl' in sources) { + this.contentGeneratorConfigSources['disableCacheControl'] = + sources['disableCacheControl']; + } + return; + } + + // Full refresh path + await this.refreshAuth(authType); } - setFallbackMode(active: boolean): void { - this.inFallbackMode = active; + /** + * Get available models for the current authType. + * Delegates to ModelsConfig. + */ + getAvailableModels(): AvailableModel[] { + return this._modelsConfig.getAvailableModels(); } - setFallbackModelHandler(handler: FallbackModelHandler): void { - this.fallbackModelHandler = handler; + /** + * Get available models for a specific authType. + * Delegates to ModelsConfig. + */ + getAvailableModelsForAuthType(authType: AuthType): AvailableModel[] { + return this._modelsConfig.getAvailableModelsForAuthType(authType); + } + + /** + * Switch authType+model via registry-backed selection. + * This triggers a refresh of the ContentGenerator when required (always on authType changes). + * For qwen-oauth model switches that are hot-update safe, this may update in place. + * + * @param authType - Target authentication type + * @param modelId - Target model ID + * @param options - Additional options like requireCachedCredentials + * @param metadata - Metadata for logging/tracking + */ + async switchModel( + authType: AuthType, + modelId: string, + options?: { requireCachedCredentials?: boolean }, + metadata?: { reason?: string; context?: string }, + ): Promise { + await this._modelsConfig.switchModel(authType, modelId, options, metadata); } getMaxSessionTurns(): number { @@ -802,14 +936,6 @@ export class Config { return this.sessionTokenLimit; } - setQuotaErrorOccurred(value: boolean): void { - this.quotaErrorOccurred = value; - } - - getQuotaErrorOccurred(): boolean { - return this.quotaErrorOccurred; - } - getEmbeddingModel(): string { return this.embeddingModel; } diff --git a/packages/core/src/config/flashFallback.test.ts b/packages/core/src/config/flashFallback.test.ts deleted file mode 100644 index 1fff42392..000000000 --- a/packages/core/src/config/flashFallback.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { Config } from './config.js'; -import { DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_FLASH_MODEL } from './models.js'; -import fs from 'node:fs'; - -vi.mock('node:fs'); - -// Skip this test because we do not have fall back mechanism. -describe.skip('Flash Model Fallback Configuration', () => { - let config: Config; - - beforeEach(() => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ - isDirectory: () => true, - } as fs.Stats); - config = new Config({ - targetDir: '/test', - debugMode: false, - cwd: '/test', - model: DEFAULT_GEMINI_MODEL, - }); - - // Initialize contentGeneratorConfig for testing - ( - config as unknown as { contentGeneratorConfig: unknown } - ).contentGeneratorConfig = { - model: DEFAULT_GEMINI_MODEL, - authType: 'gemini', - }; - }); - - // These tests do not actually test fallback. isInFallbackMode() only returns true, - // when setFallbackMode is marked as true. This is to decouple setting a model - // with the fallback mechanism. This will be necessary we introduce more - // intelligent model routing. - describe('setModel', () => { - it('should only mark as switched if contentGeneratorConfig exists', async () => { - // Create config without initializing contentGeneratorConfig - const newConfig = new Config({ - targetDir: '/test', - debugMode: false, - cwd: '/test', - model: DEFAULT_GEMINI_MODEL, - }); - - // Should not crash when contentGeneratorConfig is undefined - await newConfig.setModel(DEFAULT_GEMINI_FLASH_MODEL); - expect(newConfig.isInFallbackMode()).toBe(false); - }); - }); - - describe('getModel', () => { - it('should return contentGeneratorConfig model if available', async () => { - // Simulate initialized content generator config - await config.setModel(DEFAULT_GEMINI_FLASH_MODEL); - expect(config.getModel()).toBe(DEFAULT_GEMINI_FLASH_MODEL); - }); - - it('should fall back to initial model if contentGeneratorConfig is not available', () => { - // Test with fresh config where contentGeneratorConfig might not be set - const newConfig = new Config({ - targetDir: '/test', - debugMode: false, - cwd: '/test', - model: 'custom-model', - }); - - expect(newConfig.getModel()).toBe('custom-model'); - }); - }); - - describe('isInFallbackMode', () => { - it('should start as false for new session', () => { - expect(config.isInFallbackMode()).toBe(false); - }); - - it('should remain false if no model switch occurs', () => { - // Perform other operations that don't involve model switching - expect(config.isInFallbackMode()).toBe(false); - }); - - it('should persist switched state throughout session', async () => { - await config.setModel(DEFAULT_GEMINI_FLASH_MODEL); - // Setting state for fallback mode as is expected of clients - config.setFallbackMode(true); - expect(config.isInFallbackMode()).toBe(true); - - // Should remain true even after getting model - config.getModel(); - expect(config.isInFallbackMode()).toBe(true); - }); - }); -}); diff --git a/packages/core/src/config/models.test.ts b/packages/core/src/config/models.test.ts deleted file mode 100644 index 8c790dd1a..000000000 --- a/packages/core/src/config/models.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect } from 'vitest'; -import { - getEffectiveModel, - DEFAULT_GEMINI_MODEL, - DEFAULT_GEMINI_FLASH_MODEL, - DEFAULT_GEMINI_FLASH_LITE_MODEL, -} from './models.js'; - -describe('getEffectiveModel', () => { - describe('When NOT in fallback mode', () => { - const isInFallbackMode = false; - - it('should return the Pro model when Pro is requested', () => { - const model = getEffectiveModel(isInFallbackMode, DEFAULT_GEMINI_MODEL); - expect(model).toBe(DEFAULT_GEMINI_MODEL); - }); - - it('should return the Flash model when Flash is requested', () => { - const model = getEffectiveModel( - isInFallbackMode, - DEFAULT_GEMINI_FLASH_MODEL, - ); - expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL); - }); - - it('should return the Lite model when Lite is requested', () => { - const model = getEffectiveModel( - isInFallbackMode, - DEFAULT_GEMINI_FLASH_LITE_MODEL, - ); - expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL); - }); - - it('should return a custom model name when requested', () => { - const customModel = 'custom-model-v1'; - const model = getEffectiveModel(isInFallbackMode, customModel); - expect(model).toBe(customModel); - }); - }); - - describe('When IN fallback mode', () => { - const isInFallbackMode = true; - - it('should downgrade the Pro model to the Flash model', () => { - const model = getEffectiveModel(isInFallbackMode, DEFAULT_GEMINI_MODEL); - expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL); - }); - - it('should return the Flash model when Flash is requested', () => { - const model = getEffectiveModel( - isInFallbackMode, - DEFAULT_GEMINI_FLASH_MODEL, - ); - expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL); - }); - - it('should HONOR the Lite model when Lite is requested', () => { - const model = getEffectiveModel( - isInFallbackMode, - DEFAULT_GEMINI_FLASH_LITE_MODEL, - ); - expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL); - }); - - it('should HONOR any model with "lite" in its name', () => { - const customLiteModel = 'gemini-2.5-custom-lite-vNext'; - const model = getEffectiveModel(isInFallbackMode, customLiteModel); - expect(model).toBe(customLiteModel); - }); - - it('should downgrade any other custom model to the Flash model', () => { - const customModel = 'custom-model-v1-unlisted'; - const model = getEffectiveModel(isInFallbackMode, customModel); - expect(model).toBe(DEFAULT_GEMINI_FLASH_MODEL); - }); - }); -}); diff --git a/packages/core/src/config/models.ts b/packages/core/src/config/models.ts index ea7ef2024..a07dec7ce 100644 --- a/packages/core/src/config/models.ts +++ b/packages/core/src/config/models.ts @@ -7,46 +7,3 @@ export const DEFAULT_QWEN_MODEL = 'coder-model'; export const DEFAULT_QWEN_FLASH_MODEL = 'coder-model'; export const DEFAULT_QWEN_EMBEDDING_MODEL = 'text-embedding-v4'; - -export const DEFAULT_GEMINI_MODEL = 'coder-model'; -export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash'; -export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-2.5-flash-lite'; - -export const DEFAULT_GEMINI_MODEL_AUTO = 'auto'; - -export const DEFAULT_GEMINI_EMBEDDING_MODEL = 'gemini-embedding-001'; - -// Some thinking models do not default to dynamic thinking which is done by a value of -1 -export const DEFAULT_THINKING_MODE = -1; - -/** - * Determines the effective model to use, applying fallback logic if necessary. - * - * When fallback mode is active, this function enforces the use of the standard - * fallback model. However, it makes an exception for "lite" models (any model - * with "lite" in its name), allowing them to be used to preserve cost savings. - * This ensures that "pro" models are always downgraded, while "lite" model - * requests are honored. - * - * @param isInFallbackMode Whether the application is in fallback mode. - * @param requestedModel The model that was originally requested. - * @returns The effective model name. - */ -export function getEffectiveModel( - isInFallbackMode: boolean, - requestedModel: string, -): string { - // If we are not in fallback mode, simply use the requested model. - if (!isInFallbackMode) { - return requestedModel; - } - - // If a "lite" model is requested, honor it. This allows for variations of - // lite models without needing to list them all as constants. - if (requestedModel.includes('lite')) { - return requestedModel; - } - - // Default fallback for Gemini CLI. - return DEFAULT_GEMINI_FLASH_MODEL; -} diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index f069ce4d5..86de132ba 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -32,7 +32,7 @@ import { type ChatCompressionInfo, } from './turn.js'; import { getCoreSystemPrompt } from './prompts.js'; -import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js'; +import { DEFAULT_QWEN_FLASH_MODEL } from '../config/models.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { setSimulate429 } from '../utils/testUtils.js'; import { tokenLimit } from './tokenLimits.js'; @@ -302,8 +302,6 @@ describe('Gemini Client (client.ts)', () => { getFileService: vi.fn().mockReturnValue(fileService), getMaxSessionTurns: vi.fn().mockReturnValue(0), getSessionTokenLimit: vi.fn().mockReturnValue(32000), - getQuotaErrorOccurred: vi.fn().mockReturnValue(false), - setQuotaErrorOccurred: vi.fn(), getNoBrowser: vi.fn().mockReturnValue(false), getUsageStatisticsEnabled: vi.fn().mockReturnValue(true), getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), @@ -317,8 +315,6 @@ describe('Gemini Client (client.ts)', () => { getModelRouterService: vi.fn().mockReturnValue({ route: vi.fn().mockResolvedValue({ model: 'default-routed-model' }), }), - isInFallbackMode: vi.fn().mockReturnValue(false), - setFallbackMode: vi.fn(), getCliVersion: vi.fn().mockReturnValue('1.0.0'), getChatCompression: vi.fn().mockReturnValue(undefined), getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false), @@ -2262,12 +2258,12 @@ ${JSON.stringify( contents, generationConfig, abortSignal, - DEFAULT_GEMINI_FLASH_MODEL, + DEFAULT_QWEN_FLASH_MODEL, ); expect(mockContentGenerator.generateContent).toHaveBeenCalledWith( expect.objectContaining({ - model: DEFAULT_GEMINI_FLASH_MODEL, + model: DEFAULT_QWEN_FLASH_MODEL, config: expect.objectContaining({ abortSignal, systemInstruction: getCoreSystemPrompt(''), @@ -2290,7 +2286,7 @@ ${JSON.stringify( contents, {}, new AbortController().signal, - DEFAULT_GEMINI_FLASH_MODEL, + DEFAULT_QWEN_FLASH_MODEL, ); expect(mockContentGenerator.generateContent).not.toHaveBeenCalledWith({ @@ -2300,7 +2296,7 @@ ${JSON.stringify( }); expect(mockContentGenerator.generateContent).toHaveBeenCalledWith( { - model: DEFAULT_GEMINI_FLASH_MODEL, + model: DEFAULT_QWEN_FLASH_MODEL, config: expect.any(Object), contents, }, @@ -2308,28 +2304,7 @@ ${JSON.stringify( ); }); - it('should use the Flash model when fallback mode is active', async () => { - const contents = [{ role: 'user', parts: [{ text: 'hello' }] }]; - const generationConfig = { temperature: 0.5 }; - const abortSignal = new AbortController().signal; - const requestedModel = 'gemini-2.5-pro'; // A non-flash model - - // Mock config to be in fallback mode - vi.spyOn(client['config'], 'isInFallbackMode').mockReturnValue(true); - - await client.generateContent( - contents, - generationConfig, - abortSignal, - requestedModel, - ); - - expect(mockGenerateContentFn).toHaveBeenCalledWith( - expect.objectContaining({ - model: DEFAULT_GEMINI_FLASH_MODEL, - }), - 'test-session-id', - ); - }); + // Note: there is currently no "fallback mode" model routing; the model used + // is always the one explicitly requested by the caller. }); }); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 6c62478d0..aaaa98114 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -15,7 +15,6 @@ import type { // Config import { ApprovalMode, type Config } from '../config/config.js'; -import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js'; // Core modules import type { ContentGenerator } from './contentGenerator.js'; @@ -542,11 +541,6 @@ export class GeminiClient { } } if (!turn.pendingToolCalls.length && signal && !signal.aborted) { - // Check if next speaker check is needed - if (this.config.getQuotaErrorOccurred()) { - return turn; - } - if (this.config.getSkipNextSpeakerCheck()) { return turn; } @@ -602,14 +596,11 @@ export class GeminiClient { }; const apiCall = () => { - const modelToUse = this.config.isInFallbackMode() - ? DEFAULT_GEMINI_FLASH_MODEL - : model; - currentAttemptModel = modelToUse; + currentAttemptModel = model; return this.getContentGeneratorOrFail().generateContent( { - model: modelToUse, + model, config: requestConfig, contents, }, diff --git a/packages/core/src/core/contentGenerator.test.ts b/packages/core/src/core/contentGenerator.test.ts index 4b176c989..eef7f5ac8 100644 --- a/packages/core/src/core/contentGenerator.test.ts +++ b/packages/core/src/core/contentGenerator.test.ts @@ -5,7 +5,11 @@ */ import { describe, it, expect, vi } from 'vitest'; -import { createContentGenerator, AuthType } from './contentGenerator.js'; +import { + createContentGenerator, + createContentGeneratorConfig, + AuthType, +} from './contentGenerator.js'; import { GoogleGenAI } from '@google/genai'; import type { Config } from '../config/config.js'; import { LoggingContentGenerator } from './loggingContentGenerator/index.js'; @@ -78,3 +82,32 @@ describe('createContentGenerator', () => { expect(generator).toBeInstanceOf(LoggingContentGenerator); }); }); + +describe('createContentGeneratorConfig', () => { + const mockConfig = { + getProxy: () => undefined, + } as unknown as Config; + + it('should preserve provided fields and set authType for QWEN_OAUTH', () => { + const cfg = createContentGeneratorConfig(mockConfig, AuthType.QWEN_OAUTH, { + model: 'vision-model', + apiKey: 'QWEN_OAUTH_DYNAMIC_TOKEN', + }); + expect(cfg.authType).toBe(AuthType.QWEN_OAUTH); + expect(cfg.model).toBe('vision-model'); + expect(cfg.apiKey).toBe('QWEN_OAUTH_DYNAMIC_TOKEN'); + }); + + it('should not warn or fallback for QWEN_OAUTH (resolution handled by ModelConfigResolver)', () => { + const warnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + const cfg = createContentGeneratorConfig(mockConfig, AuthType.QWEN_OAUTH, { + model: 'some-random-model', + }); + expect(cfg.model).toBe('some-random-model'); + expect(cfg.apiKey).toBeUndefined(); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); +}); diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index f6f83761f..fc36fda3c 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -12,9 +12,24 @@ import type { GenerateContentParameters, GenerateContentResponse, } from '@google/genai'; -import { DEFAULT_QWEN_MODEL } from '../config/models.js'; import type { Config } from '../config/config.js'; import { LoggingContentGenerator } from './loggingContentGenerator/index.js'; +import type { + ConfigSource, + ConfigSourceKind, + ConfigSources, +} from '../utils/configResolver.js'; +import { + getDefaultApiKeyEnvVar, + getDefaultModelEnvVar, + MissingAnthropicBaseUrlEnvError, + MissingApiKeyError, + MissingBaseUrlError, + MissingModelError, + StrictMissingCredentialsError, + StrictMissingModelIdError, +} from '../models/modelConfigErrors.js'; +import { PROVIDER_SOURCED_FIELDS } from '../models/modelsConfig.js'; /** * Interface abstracting the core functionalities for generating content and counting tokens. @@ -48,6 +63,7 @@ export enum AuthType { export type ContentGeneratorConfig = { model: string; apiKey?: string; + apiKeyEnvKey?: string; baseUrl?: string; vertexai?: boolean; authType?: AuthType | undefined; @@ -77,102 +93,178 @@ export type ContentGeneratorConfig = { schemaCompliance?: 'auto' | 'openapi_30'; }; -export function createContentGeneratorConfig( +// Keep the public ContentGeneratorConfigSources API, but reuse the generic +// source-tracking types from utils/configResolver to avoid duplication. +export type ContentGeneratorConfigSourceKind = ConfigSourceKind; +export type ContentGeneratorConfigSource = ConfigSource; +export type ContentGeneratorConfigSources = ConfigSources; + +export type ResolvedContentGeneratorConfig = { + config: ContentGeneratorConfig; + sources: ContentGeneratorConfigSources; +}; + +function setSource( + sources: ContentGeneratorConfigSources, + path: string, + source: ContentGeneratorConfigSource, +): void { + sources[path] = source; +} + +function getSeedSource( + seed: ContentGeneratorConfigSources | undefined, + path: string, +): ContentGeneratorConfigSource | undefined { + return seed?.[path]; +} + +/** + * Resolve ContentGeneratorConfig while tracking the source of each effective field. + * + * This function now primarily validates and finalizes the configuration that has + * already been resolved by ModelConfigResolver. The env fallback logic has been + * moved to the unified resolver to eliminate duplication. + * + * Note: The generationConfig passed here should already be fully resolved with + * proper source tracking from the caller (CLI/SDK layer). + */ +export function resolveContentGeneratorConfigWithSources( config: Config, authType: AuthType | undefined, generationConfig?: Partial, -): ContentGeneratorConfig { - let newContentGeneratorConfig: Partial = { + seedSources?: ContentGeneratorConfigSources, + options?: { strictModelProvider?: boolean }, +): ResolvedContentGeneratorConfig { + const sources: ContentGeneratorConfigSources = { ...(seedSources || {}) }; + const strictModelProvider = options?.strictModelProvider === true; + + // Build config with computed fields + const newContentGeneratorConfig: Partial = { ...(generationConfig || {}), authType, proxy: config?.getProxy(), }; - if (authType === AuthType.QWEN_OAUTH) { - // For Qwen OAuth, we'll handle the API key dynamically in createContentGenerator - // Set a special marker to indicate this is Qwen OAuth - return { - ...newContentGeneratorConfig, - model: DEFAULT_QWEN_MODEL, - apiKey: 'QWEN_OAUTH_DYNAMIC_TOKEN', - } as ContentGeneratorConfig; + // Set sources for computed fields + setSource(sources, 'authType', { + kind: 'computed', + detail: 'provided by caller', + }); + if (config?.getProxy()) { + setSource(sources, 'proxy', { + kind: 'computed', + detail: 'Config.getProxy()', + }); } - if (authType === AuthType.USE_OPENAI) { - newContentGeneratorConfig = { - ...newContentGeneratorConfig, - apiKey: newContentGeneratorConfig.apiKey || process.env['OPENAI_API_KEY'], - baseUrl: - newContentGeneratorConfig.baseUrl || process.env['OPENAI_BASE_URL'], - model: newContentGeneratorConfig.model || process.env['OPENAI_MODEL'], - }; + // Preserve seed sources for fields that were passed in + const seedOrUnknown = (path: string): ContentGeneratorConfigSource => + getSeedSource(seedSources, path) ?? { kind: 'unknown' }; - if (!newContentGeneratorConfig.apiKey) { - throw new Error('OPENAI_API_KEY environment variable not found.'); - } - - return { - ...newContentGeneratorConfig, - model: newContentGeneratorConfig?.model || 'qwen3-coder-plus', - } as ContentGeneratorConfig; - } - - if (authType === AuthType.USE_ANTHROPIC) { - newContentGeneratorConfig = { - ...newContentGeneratorConfig, - apiKey: - newContentGeneratorConfig.apiKey || process.env['ANTHROPIC_API_KEY'], - baseUrl: - newContentGeneratorConfig.baseUrl || process.env['ANTHROPIC_BASE_URL'], - model: newContentGeneratorConfig.model || process.env['ANTHROPIC_MODEL'], - }; - - if (!newContentGeneratorConfig.apiKey) { - throw new Error('ANTHROPIC_API_KEY environment variable not found.'); - } - - if (!newContentGeneratorConfig.baseUrl) { - throw new Error('ANTHROPIC_BASE_URL environment variable not found.'); - } - - if (!newContentGeneratorConfig.model) { - throw new Error('ANTHROPIC_MODEL environment variable not found.'); + for (const field of PROVIDER_SOURCED_FIELDS) { + if (generationConfig && field in generationConfig && !sources[field]) { + setSource(sources, field, seedOrUnknown(field)); } } - if (authType === AuthType.USE_GEMINI) { - newContentGeneratorConfig = { - ...newContentGeneratorConfig, - apiKey: newContentGeneratorConfig.apiKey || process.env['GEMINI_API_KEY'], - model: newContentGeneratorConfig.model || process.env['GEMINI_MODEL'], - }; + // Validate required fields based on authType. This does not perform any + // fallback resolution (resolution is handled by ModelConfigResolver). + const validation = validateModelConfig( + newContentGeneratorConfig as ContentGeneratorConfig, + strictModelProvider, + ); + if (!validation.valid) { + throw new Error(validation.errors.map((e) => e.message).join('\n')); + } - if (!newContentGeneratorConfig.apiKey) { - throw new Error('GEMINI_API_KEY environment variable not found.'); - } + return { + config: newContentGeneratorConfig as ContentGeneratorConfig, + sources, + }; +} - if (!newContentGeneratorConfig.model) { - throw new Error('GEMINI_MODEL environment variable not found.'); +export interface ModelConfigValidationResult { + valid: boolean; + errors: Error[]; +} + +/** + * Validate a resolved model configuration. + * This is the single validation entry point used across Core. + */ +export function validateModelConfig( + config: ContentGeneratorConfig, + isStrictModelProvider: boolean = false, +): ModelConfigValidationResult { + const errors: Error[] = []; + + // Qwen OAuth doesn't need validation - it uses dynamic tokens + if (config.authType === AuthType.QWEN_OAUTH) { + return { valid: true, errors: [] }; + } + + // API key is required for all other auth types + if (!config.apiKey) { + if (isStrictModelProvider) { + errors.push( + new StrictMissingCredentialsError( + config.authType, + config.model, + config.apiKeyEnvKey, + ), + ); + } else { + const envKey = + config.apiKeyEnvKey || getDefaultApiKeyEnvVar(config.authType); + errors.push( + new MissingApiKeyError({ + authType: config.authType, + model: config.model, + baseUrl: config.baseUrl, + envKey, + }), + ); } } - if (authType === AuthType.USE_VERTEX_AI) { - newContentGeneratorConfig = { - ...newContentGeneratorConfig, - apiKey: newContentGeneratorConfig.apiKey || process.env['GOOGLE_API_KEY'], - model: newContentGeneratorConfig.model || process.env['GOOGLE_MODEL'], - }; - - if (!newContentGeneratorConfig.apiKey) { - throw new Error('GOOGLE_API_KEY environment variable not found.'); - } - - if (!newContentGeneratorConfig.model) { - throw new Error('GOOGLE_MODEL environment variable not found.'); + // Model is required + if (!config.model) { + if (isStrictModelProvider) { + errors.push(new StrictMissingModelIdError(config.authType)); + } else { + const envKey = getDefaultModelEnvVar(config.authType); + errors.push(new MissingModelError({ authType: config.authType, envKey })); } } - return newContentGeneratorConfig as ContentGeneratorConfig; + // Explicit baseUrl is required for Anthropic; Migrated from existing code. + if (config.authType === AuthType.USE_ANTHROPIC && !config.baseUrl) { + if (isStrictModelProvider) { + errors.push( + new MissingBaseUrlError({ + authType: config.authType, + model: config.model, + }), + ); + } else if (config.authType === AuthType.USE_ANTHROPIC) { + errors.push(new MissingAnthropicBaseUrlEnvError()); + } + } + + return { valid: errors.length === 0, errors }; +} + +export function createContentGeneratorConfig( + config: Config, + authType: AuthType | undefined, + generationConfig?: Partial, +): ContentGeneratorConfig { + return resolveContentGeneratorConfigWithSources( + config, + authType, + generationConfig, + ).config; } export async function createContentGenerator( @@ -180,11 +272,12 @@ export async function createContentGenerator( gcConfig: Config, isInitialAuth?: boolean, ): Promise { - if (config.authType === AuthType.USE_OPENAI) { - if (!config.apiKey) { - throw new Error('OPENAI_API_KEY environment variable not found.'); - } + const validation = validateModelConfig(config, false); + if (!validation.valid) { + throw new Error(validation.errors.map((e) => e.message).join('\n')); + } + if (config.authType === AuthType.USE_OPENAI) { // Import OpenAIContentGenerator dynamically to avoid circular dependencies const { createOpenAIContentGenerator } = await import( './openaiContentGenerator/index.js' @@ -223,10 +316,6 @@ export async function createContentGenerator( } if (config.authType === AuthType.USE_ANTHROPIC) { - if (!config.apiKey) { - throw new Error('ANTHROPIC_API_KEY environment variable not found.'); - } - const { createAnthropicContentGenerator } = await import( './anthropicContentGenerator/index.js' ); diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index a77fc6707..20e884548 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -20,7 +20,6 @@ import { } from './geminiChat.js'; import type { Config } from '../config/config.js'; import { setSimulate429 } from '../utils/testUtils.js'; -import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js'; import { AuthType } from './contentGenerator.js'; import { type RetryOptions } from '../utils/retry.js'; import { uiTelemetryService } from '../telemetry/uiTelemetry.js'; @@ -117,10 +116,6 @@ describe('GeminiChat', () => { }), getModel: vi.fn().mockReturnValue('gemini-pro'), setModel: vi.fn(), - isInFallbackMode: vi.fn().mockReturnValue(false), - getQuotaErrorOccurred: vi.fn().mockReturnValue(false), - setQuotaErrorOccurred: vi.fn(), - flashFallbackHandler: undefined, getProjectRoot: vi.fn().mockReturnValue('/test/project/root'), getCliVersion: vi.fn().mockReturnValue('1.0.0'), storage: { @@ -1349,9 +1344,8 @@ describe('GeminiChat', () => { ], } as unknown as GenerateContentResponse; - it('should use the FLASH model when in fallback mode (sendMessageStream)', async () => { + it('should pass the requested model through to generateContentStream', async () => { vi.mocked(mockConfig.getModel).mockReturnValue('gemini-pro'); - vi.mocked(mockConfig.isInFallbackMode).mockReturnValue(true); vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( async () => (async function* () { @@ -1370,7 +1364,7 @@ describe('GeminiChat', () => { expect(mockContentGenerator.generateContentStream).toHaveBeenCalledWith( expect.objectContaining({ - model: DEFAULT_GEMINI_FLASH_MODEL, + model: 'test-model', }), 'prompt-id-res3', ); @@ -1422,9 +1416,6 @@ describe('GeminiChat', () => { authType, }); - const isInFallbackModeSpy = vi.spyOn(mockConfig, 'isInFallbackMode'); - isInFallbackModeSpy.mockReturnValue(false); - vi.mocked(mockContentGenerator.generateContentStream) .mockRejectedValueOnce(error429) // Attempt 1 fails .mockResolvedValueOnce( @@ -1441,10 +1432,7 @@ describe('GeminiChat', () => { })(), ); - mockHandleFallback.mockImplementation(async () => { - isInFallbackModeSpy.mockReturnValue(true); - return true; // Signal retry - }); + mockHandleFallback.mockImplementation(async () => true); const stream = await chat.sendMessageStream( 'test-model', diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 04add3419..d4aaee25a 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -19,10 +19,6 @@ import type { import { ApiError, createUserContent } from '@google/genai'; import { retryWithBackoff } from '../utils/retry.js'; import type { Config } from '../config/config.js'; -import { - DEFAULT_GEMINI_FLASH_MODEL, - getEffectiveModel, -} from '../config/models.js'; import { hasCycleInSchema } from '../tools/tools.js'; import type { StructuredError } from './turn.js'; import { @@ -352,31 +348,15 @@ export class GeminiChat { params: SendMessageParameters, prompt_id: string, ): Promise> { - const apiCall = () => { - const modelToUse = getEffectiveModel( - this.config.isInFallbackMode(), - model, - ); - - if ( - this.config.getQuotaErrorOccurred() && - modelToUse === DEFAULT_GEMINI_FLASH_MODEL - ) { - throw new Error( - 'Please submit a new query to continue with the Flash model.', - ); - } - - return this.config.getContentGenerator().generateContentStream( + const apiCall = () => + this.config.getContentGenerator().generateContentStream( { - model: modelToUse, + model, contents: requestContents, config: { ...this.generationConfig, ...params.config }, }, prompt_id, ); - }; - const onPersistent429Callback = async ( authType?: string, error?: unknown, diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts index 93adcb090..d5220b080 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts @@ -46,6 +46,7 @@ describe('ContentGenerationPipeline', () => { // Mock converter mockConverter = { + setModel: vi.fn(), convertGeminiRequestToOpenAI: vi.fn(), convertOpenAIResponseToGemini: vi.fn(), convertOpenAIChunkToGemini: vi.fn(), @@ -99,6 +100,7 @@ describe('ContentGenerationPipeline', () => { describe('constructor', () => { it('should initialize with correct configuration', () => { expect(mockProvider.buildClient).toHaveBeenCalled(); + // Converter is constructed once and the model is updated per-request via setModel(). expect(OpenAIContentConverter).toHaveBeenCalledWith( 'test-model', undefined, @@ -144,6 +146,9 @@ describe('ContentGenerationPipeline', () => { // Assert expect(result).toBe(mockGeminiResponse); + expect( + (mockConverter as unknown as { setModel: Mock }).setModel, + ).toHaveBeenCalledWith('test-model'); expect(mockConverter.convertGeminiRequestToOpenAI).toHaveBeenCalledWith( request, ); @@ -164,6 +169,53 @@ describe('ContentGenerationPipeline', () => { ); }); + it('should ignore request.model override and always use configured model', async () => { + // Arrange + const request: GenerateContentParameters = { + model: 'override-model', + contents: [{ parts: [{ text: 'Hello' }], role: 'user' }], + }; + const userPromptId = 'test-prompt-id'; + + const mockMessages = [ + { role: 'user', content: 'Hello' }, + ] as OpenAI.Chat.ChatCompletionMessageParam[]; + const mockOpenAIResponse = { + id: 'response-id', + choices: [ + { message: { content: 'Hello response' }, finish_reason: 'stop' }, + ], + created: Date.now(), + model: 'override-model', + } as OpenAI.Chat.ChatCompletion; + const mockGeminiResponse = new GenerateContentResponse(); + + (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue( + mockMessages, + ); + (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + mockGeminiResponse, + ); + (mockClient.chat.completions.create as Mock).mockResolvedValue( + mockOpenAIResponse, + ); + + // Act + const result = await pipeline.execute(request, userPromptId); + + // Assert + expect(result).toBe(mockGeminiResponse); + expect( + (mockConverter as unknown as { setModel: Mock }).setModel, + ).toHaveBeenCalledWith('test-model'); + expect(mockClient.chat.completions.create).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'test-model', + }), + expect.any(Object), + ); + }); + it('should handle tools in request', async () => { // Arrange const request: GenerateContentParameters = { @@ -217,6 +269,9 @@ describe('ContentGenerationPipeline', () => { // Assert expect(result).toBe(mockGeminiResponse); + expect( + (mockConverter as unknown as { setModel: Mock }).setModel, + ).toHaveBeenCalledWith('test-model'); expect(mockConverter.convertGeminiToolsToOpenAI).toHaveBeenCalledWith( request.config!.tools, ); diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts index ef27a7798..0f00ecb30 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts @@ -40,10 +40,16 @@ export class ContentGenerationPipeline { request: GenerateContentParameters, userPromptId: string, ): Promise { + // For OpenAI-compatible providers, the configured model is the single source of truth. + // We intentionally ignore request.model because upstream callers may pass a model string + // that is not valid/available for the OpenAI-compatible backend. + const effectiveModel = this.contentGeneratorConfig.model; + this.converter.setModel(effectiveModel); return this.executeWithErrorHandling( request, userPromptId, false, + effectiveModel, async (openaiRequest) => { const openaiResponse = (await this.client.chat.completions.create( openaiRequest, @@ -64,10 +70,13 @@ export class ContentGenerationPipeline { request: GenerateContentParameters, userPromptId: string, ): Promise> { + const effectiveModel = this.contentGeneratorConfig.model; + this.converter.setModel(effectiveModel); return this.executeWithErrorHandling( request, userPromptId, true, + effectiveModel, async (openaiRequest, context) => { // Stage 1: Create OpenAI stream const stream = (await this.client.chat.completions.create( @@ -224,12 +233,13 @@ export class ContentGenerationPipeline { request: GenerateContentParameters, userPromptId: string, streaming: boolean = false, + effectiveModel: string, ): Promise { const messages = this.converter.convertGeminiRequestToOpenAI(request); // Apply provider-specific enhancements const baseRequest: OpenAI.Chat.ChatCompletionCreateParams = { - model: this.contentGeneratorConfig.model, + model: effectiveModel, messages, ...this.buildGenerateContentConfig(request), }; @@ -342,18 +352,24 @@ export class ContentGenerationPipeline { request: GenerateContentParameters, userPromptId: string, isStreaming: boolean, + effectiveModel: string, executor: ( openaiRequest: OpenAI.Chat.ChatCompletionCreateParams, context: RequestContext, ) => Promise, ): Promise { - const context = this.createRequestContext(userPromptId, isStreaming); + const context = this.createRequestContext( + userPromptId, + isStreaming, + effectiveModel, + ); try { const openaiRequest = await this.buildRequest( request, userPromptId, isStreaming, + effectiveModel, ); const result = await executor(openaiRequest, context); @@ -385,10 +401,11 @@ export class ContentGenerationPipeline { private createRequestContext( userPromptId: string, isStreaming: boolean, + effectiveModel: string, ): RequestContext { return { userPromptId, - model: this.contentGeneratorConfig.model, + model: effectiveModel, authType: this.contentGeneratorConfig.authType || 'unknown', startTime: Date.now(), duration: 0, diff --git a/packages/core/src/fallback/types.ts b/packages/core/src/fallback/types.ts deleted file mode 100644 index 654312337..000000000 --- a/packages/core/src/fallback/types.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * Defines the intent returned by the UI layer during a fallback scenario. - */ -export type FallbackIntent = - | 'retry' // Immediately retry the current request with the fallback model. - | 'stop' // Switch to fallback for future requests, but stop the current request. - | 'auth'; // Stop the current request; user intends to change authentication. - -/** - * The interface for the handler provided by the UI layer (e.g., the CLI) - * to interact with the user during a fallback scenario. - */ -export type FallbackModelHandler = ( - failedModel: string, - fallbackModel: string, - error?: unknown, -) => Promise; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7f7bd115b..60e66b19e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,6 +9,30 @@ export * from './config/config.js'; export * from './output/types.js'; export * from './output/json-formatter.js'; +// Export models +export { + type ModelCapabilities, + type ModelGenerationConfig, + type ModelConfig as ProviderModelConfig, + type ModelProvidersConfig, + type ResolvedModelConfig, + type AvailableModel, + type ModelSwitchMetadata, + QWEN_OAUTH_MODELS, + ModelRegistry, + ModelsConfig, + type ModelsConfigOptions, + type OnModelChangeCallback, + // Model configuration resolver + resolveModelConfig, + validateModelConfig, + type ModelConfigSourcesInput, + type ModelConfigCliInput, + type ModelConfigSettingsInput, + type ModelConfigResolutionResult, + type ModelConfigValidationResult, +} from './models/index.js'; + // Export Core Logic export * from './core/client.js'; export * from './core/contentGenerator.js'; @@ -21,8 +45,6 @@ export * from './core/geminiRequest.js'; export * from './core/coreToolScheduler.js'; export * from './core/nonInteractiveToolExecutor.js'; -export * from './fallback/types.js'; - export * from './qwen/qwenOAuth2.js'; // Export utilities @@ -55,6 +77,9 @@ export * from './utils/projectSummary.js'; export * from './utils/promptIdContext.js'; export * from './utils/thoughtUtils.js'; +// Config resolution utilities +export * from './utils/configResolver.js'; + // Export services export * from './services/fileDiscoveryService.js'; export * from './services/gitService.js'; diff --git a/packages/core/src/models/constants.ts b/packages/core/src/models/constants.ts new file mode 100644 index 000000000..9dd69620c --- /dev/null +++ b/packages/core/src/models/constants.ts @@ -0,0 +1,134 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DEFAULT_QWEN_MODEL } from '../config/models.js'; + +import type { ModelConfig } from './types.js'; + +type AuthType = import('../core/contentGenerator.js').AuthType; +type ContentGeneratorConfig = + import('../core/contentGenerator.js').ContentGeneratorConfig; + +/** + * Field keys for model-scoped generation config. + * + * Kept in a small standalone module to avoid circular deps. The `import('...')` + * usage is type-only and does not emit runtime imports. + */ +export const MODEL_GENERATION_CONFIG_FIELDS = [ + 'samplingParams', + 'timeout', + 'maxRetries', + 'disableCacheControl', + 'schemaCompliance', + 'reasoning', +] as const satisfies ReadonlyArray; + +/** + * Credential-related fields that are part of ContentGeneratorConfig + * but not ModelGenerationConfig. + */ +export const CREDENTIAL_FIELDS = [ + 'model', + 'apiKey', + 'apiKeyEnvKey', + 'baseUrl', +] as const satisfies ReadonlyArray; + +/** + * All provider-sourced fields that need to be tracked for source attribution + * and cleared when switching from provider to manual credentials. + */ +export const PROVIDER_SOURCED_FIELDS = [ + ...CREDENTIAL_FIELDS, + ...MODEL_GENERATION_CONFIG_FIELDS, +] as const; + +/** + * Environment variable mappings per authType. + */ +export interface AuthEnvMapping { + apiKey: string[]; + baseUrl: string[]; + model: string[]; +} + +export const AUTH_ENV_MAPPINGS = { + openai: { + apiKey: ['OPENAI_API_KEY'], + baseUrl: ['OPENAI_BASE_URL'], + model: ['OPENAI_MODEL', 'QWEN_MODEL'], + }, + anthropic: { + apiKey: ['ANTHROPIC_API_KEY'], + baseUrl: ['ANTHROPIC_BASE_URL'], + model: ['ANTHROPIC_MODEL'], + }, + gemini: { + apiKey: ['GEMINI_API_KEY'], + baseUrl: [], + model: ['GEMINI_MODEL'], + }, + 'vertex-ai': { + apiKey: ['GOOGLE_API_KEY'], + baseUrl: [], + model: ['GOOGLE_MODEL'], + }, + 'qwen-oauth': { + apiKey: [], + baseUrl: [], + model: [], + }, +} as const satisfies Record; + +export const DEFAULT_MODELS = { + openai: 'qwen3-coder-plus', + 'qwen-oauth': DEFAULT_QWEN_MODEL, +} as Partial>; + +export const QWEN_OAUTH_ALLOWED_MODELS = [ + DEFAULT_QWEN_MODEL, + 'vision-model', +] as const; + +/** + * Hard-coded Qwen OAuth models that are always available. + * These cannot be overridden by user configuration. + */ +export const QWEN_OAUTH_MODELS: ModelConfig[] = [ + { + id: 'coder-model', + name: 'Qwen Coder', + description: + 'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)', + capabilities: { vision: false }, + generationConfig: { + samplingParams: { + temperature: 0.7, + top_p: 0.9, + max_tokens: 8192, + }, + timeout: 60000, + maxRetries: 3, + }, + }, + { + id: 'vision-model', + name: 'Qwen Vision', + description: + 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)', + capabilities: { vision: true }, + generationConfig: { + samplingParams: { + temperature: 0.7, + top_p: 0.9, + max_tokens: 8192, + }, + timeout: 60000, + maxRetries: 3, + }, + }, +]; diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts new file mode 100644 index 000000000..7525074a5 --- /dev/null +++ b/packages/core/src/models/index.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export { + type ModelCapabilities, + type ModelGenerationConfig, + type ModelConfig, + type ModelProvidersConfig, + type ResolvedModelConfig, + type AvailableModel, + type ModelSwitchMetadata, +} from './types.js'; + +export { ModelRegistry } from './modelRegistry.js'; + +export { + ModelsConfig, + type ModelsConfigOptions, + type OnModelChangeCallback, +} from './modelsConfig.js'; + +export { + AUTH_ENV_MAPPINGS, + CREDENTIAL_FIELDS, + DEFAULT_MODELS, + MODEL_GENERATION_CONFIG_FIELDS, + PROVIDER_SOURCED_FIELDS, + QWEN_OAUTH_ALLOWED_MODELS, + QWEN_OAUTH_MODELS, +} from './constants.js'; + +// Model configuration resolver +export { + resolveModelConfig, + validateModelConfig, + type ModelConfigSourcesInput, + type ModelConfigCliInput, + type ModelConfigSettingsInput, + type ModelConfigResolutionResult, + type ModelConfigValidationResult, +} from './modelConfigResolver.js'; diff --git a/packages/core/src/models/modelConfigErrors.ts b/packages/core/src/models/modelConfigErrors.ts new file mode 100644 index 000000000..3504793bd --- /dev/null +++ b/packages/core/src/models/modelConfigErrors.ts @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export function getDefaultApiKeyEnvVar(authType: string | undefined): string { + switch (authType) { + case 'openai': + return 'OPENAI_API_KEY'; + case 'anthropic': + return 'ANTHROPIC_API_KEY'; + case 'gemini': + return 'GEMINI_API_KEY'; + case 'vertex-ai': + return 'GOOGLE_API_KEY'; + default: + return 'API_KEY'; + } +} + +export function getDefaultModelEnvVar(authType: string | undefined): string { + switch (authType) { + case 'openai': + return 'OPENAI_MODEL'; + case 'anthropic': + return 'ANTHROPIC_MODEL'; + case 'gemini': + return 'GEMINI_MODEL'; + case 'vertex-ai': + return 'GOOGLE_MODEL'; + default: + return 'MODEL'; + } +} + +export abstract class ModelConfigError extends Error { + abstract readonly code: string; + + protected constructor(message: string) { + super(message); + this.name = new.target.name; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class StrictMissingCredentialsError extends ModelConfigError { + readonly code = 'STRICT_MISSING_CREDENTIALS'; + + constructor( + authType: string | undefined, + model: string | undefined, + envKey?: string, + ) { + const providerKey = authType || '(unknown)'; + const modelName = model || '(unknown)'; + super( + `Missing credentials for modelProviders model '${modelName}'. ` + + (envKey + ? `Current configured envKey: '${envKey}'. Set that environment variable, or update modelProviders.${providerKey}[].envKey.` + : `Configure modelProviders.${providerKey}[].envKey and set that environment variable.`), + ); + } +} + +export class StrictMissingModelIdError extends ModelConfigError { + readonly code = 'STRICT_MISSING_MODEL_ID'; + + constructor(authType: string | undefined) { + super( + `Missing model id for strict modelProviders resolution (authType: ${authType}).`, + ); + } +} + +export class MissingApiKeyError extends ModelConfigError { + readonly code = 'MISSING_API_KEY'; + + constructor(params: { + authType: string | undefined; + model: string | undefined; + baseUrl: string | undefined; + envKey: string; + }) { + super( + `Missing API key for ${params.authType} auth. ` + + `Current model: '${params.model || '(unknown)'}', baseUrl: '${params.baseUrl || '(default)'}'. ` + + `Provide an API key via settings (security.auth.apiKey), ` + + `or set the environment variable '${params.envKey}'.`, + ); + } +} + +export class MissingModelError extends ModelConfigError { + readonly code = 'MISSING_MODEL'; + + constructor(params: { authType: string | undefined; envKey: string }) { + super( + `Missing model for ${params.authType} auth. ` + + `Set the environment variable '${params.envKey}'.`, + ); + } +} + +export class MissingBaseUrlError extends ModelConfigError { + readonly code = 'MISSING_BASE_URL'; + + constructor(params: { + authType: string | undefined; + model: string | undefined; + }) { + super( + `Missing baseUrl for modelProviders model '${params.model || '(unknown)'}' (authType: ${params.authType}). ` + + `Configure modelProviders.${params.authType || '(unknown)'}[].baseUrl.`, + ); + } +} + +export class MissingAnthropicBaseUrlEnvError extends ModelConfigError { + readonly code = 'MISSING_ANTHROPIC_BASE_URL_ENV'; + + constructor() { + super('ANTHROPIC_BASE_URL environment variable not found.'); + } +} diff --git a/packages/core/src/models/modelConfigResolver.test.ts b/packages/core/src/models/modelConfigResolver.test.ts new file mode 100644 index 000000000..b7aa8c29b --- /dev/null +++ b/packages/core/src/models/modelConfigResolver.test.ts @@ -0,0 +1,355 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + resolveModelConfig, + validateModelConfig, +} from './modelConfigResolver.js'; +import { AuthType } from '../core/contentGenerator.js'; +import { DEFAULT_QWEN_MODEL } from '../config/models.js'; + +describe('modelConfigResolver', () => { + describe('resolveModelConfig', () => { + describe('OpenAI auth type', () => { + it('resolves from CLI with highest priority', () => { + const result = resolveModelConfig({ + authType: AuthType.USE_OPENAI, + cli: { + model: 'cli-model', + apiKey: 'cli-key', + baseUrl: 'https://cli.example.com', + }, + settings: { + model: 'settings-model', + apiKey: 'settings-key', + baseUrl: 'https://settings.example.com', + }, + env: { + OPENAI_MODEL: 'env-model', + OPENAI_API_KEY: 'env-key', + OPENAI_BASE_URL: 'https://env.example.com', + }, + }); + + expect(result.config.model).toBe('cli-model'); + expect(result.config.apiKey).toBe('cli-key'); + expect(result.config.baseUrl).toBe('https://cli.example.com'); + + expect(result.sources['model'].kind).toBe('cli'); + expect(result.sources['apiKey'].kind).toBe('cli'); + expect(result.sources['baseUrl'].kind).toBe('cli'); + }); + + it('falls back to env when CLI not provided', () => { + const result = resolveModelConfig({ + authType: AuthType.USE_OPENAI, + cli: {}, + settings: { + model: 'settings-model', + }, + env: { + OPENAI_MODEL: 'env-model', + OPENAI_API_KEY: 'env-key', + }, + }); + + expect(result.config.model).toBe('env-model'); + expect(result.config.apiKey).toBe('env-key'); + + expect(result.sources['model'].kind).toBe('env'); + expect(result.sources['apiKey'].kind).toBe('env'); + }); + + it('falls back to settings when env not provided', () => { + const result = resolveModelConfig({ + authType: AuthType.USE_OPENAI, + cli: {}, + settings: { + model: 'settings-model', + apiKey: 'settings-key', + baseUrl: 'https://settings.example.com', + }, + env: {}, + }); + + expect(result.config.model).toBe('settings-model'); + expect(result.config.apiKey).toBe('settings-key'); + expect(result.config.baseUrl).toBe('https://settings.example.com'); + + expect(result.sources['model'].kind).toBe('settings'); + expect(result.sources['apiKey'].kind).toBe('settings'); + expect(result.sources['baseUrl'].kind).toBe('settings'); + }); + + it('uses default model when nothing provided', () => { + const result = resolveModelConfig({ + authType: AuthType.USE_OPENAI, + cli: {}, + settings: {}, + env: { + OPENAI_API_KEY: 'some-key', // need key to be valid + }, + }); + + expect(result.config.model).toBe('qwen3-coder-plus'); + expect(result.sources['model'].kind).toBe('default'); + }); + + it('prioritizes modelProvider over CLI', () => { + const result = resolveModelConfig({ + authType: AuthType.USE_OPENAI, + cli: { + model: 'cli-model', + }, + settings: {}, + env: { + MY_CUSTOM_KEY: 'provider-key', + }, + modelProvider: { + id: 'provider-model', + name: 'Provider Model', + authType: AuthType.USE_OPENAI, + envKey: 'MY_CUSTOM_KEY', + baseUrl: 'https://provider.example.com', + generationConfig: {}, + capabilities: {}, + }, + }); + + expect(result.config.model).toBe('provider-model'); + expect(result.config.apiKey).toBe('provider-key'); + expect(result.config.baseUrl).toBe('https://provider.example.com'); + + expect(result.sources['model'].kind).toBe('modelProviders'); + expect(result.sources['apiKey'].kind).toBe('env'); + expect(result.sources['apiKey'].via?.kind).toBe('modelProviders'); + }); + + it('reads QWEN_MODEL as fallback for OPENAI_MODEL', () => { + const result = resolveModelConfig({ + authType: AuthType.USE_OPENAI, + cli: {}, + settings: {}, + env: { + QWEN_MODEL: 'qwen-model', + OPENAI_API_KEY: 'key', + }, + }); + + expect(result.config.model).toBe('qwen-model'); + expect(result.sources['model'].envKey).toBe('QWEN_MODEL'); + }); + }); + + describe('Qwen OAuth auth type', () => { + it('uses default model for Qwen OAuth', () => { + const result = resolveModelConfig({ + authType: AuthType.QWEN_OAUTH, + cli: {}, + settings: {}, + env: {}, + }); + + expect(result.config.model).toBe(DEFAULT_QWEN_MODEL); + expect(result.config.apiKey).toBe('QWEN_OAUTH_DYNAMIC_TOKEN'); + expect(result.sources['apiKey'].kind).toBe('computed'); + }); + + it('allows vision-model for Qwen OAuth', () => { + const result = resolveModelConfig({ + authType: AuthType.QWEN_OAUTH, + cli: { + model: 'vision-model', + }, + settings: {}, + env: {}, + }); + + expect(result.config.model).toBe('vision-model'); + expect(result.sources['model'].kind).toBe('cli'); + }); + + it('warns and falls back for unsupported Qwen OAuth models', () => { + const result = resolveModelConfig({ + authType: AuthType.QWEN_OAUTH, + cli: { + model: 'unsupported-model', + }, + settings: {}, + env: {}, + }); + + expect(result.config.model).toBe(DEFAULT_QWEN_MODEL); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain('unsupported-model'); + }); + }); + + describe('Anthropic auth type', () => { + it('resolves Anthropic config from env', () => { + const result = resolveModelConfig({ + authType: AuthType.USE_ANTHROPIC, + cli: {}, + settings: {}, + env: { + ANTHROPIC_API_KEY: 'anthropic-key', + ANTHROPIC_BASE_URL: 'https://anthropic.example.com', + ANTHROPIC_MODEL: 'claude-3', + }, + }); + + expect(result.config.model).toBe('claude-3'); + expect(result.config.apiKey).toBe('anthropic-key'); + expect(result.config.baseUrl).toBe('https://anthropic.example.com'); + }); + }); + + describe('generation config resolution', () => { + it('merges generation config from settings', () => { + const result = resolveModelConfig({ + authType: AuthType.USE_OPENAI, + cli: {}, + settings: { + apiKey: 'key', + generationConfig: { + timeout: 60000, + maxRetries: 5, + samplingParams: { + temperature: 0.7, + }, + }, + }, + env: {}, + }); + + expect(result.config.timeout).toBe(60000); + expect(result.config.maxRetries).toBe(5); + expect(result.config.samplingParams?.temperature).toBe(0.7); + + expect(result.sources['timeout'].kind).toBe('settings'); + expect(result.sources['samplingParams'].kind).toBe('settings'); + }); + + it('modelProvider config overrides settings', () => { + const result = resolveModelConfig({ + authType: AuthType.USE_OPENAI, + cli: {}, + settings: { + generationConfig: { + timeout: 30000, + }, + }, + env: { + MY_KEY: 'key', + }, + modelProvider: { + id: 'model', + name: 'Model', + authType: AuthType.USE_OPENAI, + envKey: 'MY_KEY', + baseUrl: 'https://api.example.com', + generationConfig: { + timeout: 60000, + }, + capabilities: {}, + }, + }); + + expect(result.config.timeout).toBe(60000); + expect(result.sources['timeout'].kind).toBe('modelProviders'); + }); + }); + + describe('proxy handling', () => { + it('includes proxy in config when provided', () => { + const result = resolveModelConfig({ + authType: AuthType.USE_OPENAI, + cli: {}, + settings: { apiKey: 'key' }, + env: {}, + proxy: 'http://proxy.example.com:8080', + }); + + expect(result.config.proxy).toBe('http://proxy.example.com:8080'); + expect(result.sources['proxy'].kind).toBe('computed'); + }); + }); + }); + + describe('validateModelConfig', () => { + it('passes for valid OpenAI config', () => { + const result = validateModelConfig({ + authType: AuthType.USE_OPENAI, + model: 'gpt-4', + apiKey: 'sk-xxx', + }); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('fails when API key missing', () => { + const result = validateModelConfig({ + authType: AuthType.USE_OPENAI, + model: 'gpt-4', + }); + + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain('Missing API key'); + }); + + it('fails when model missing', () => { + const result = validateModelConfig({ + authType: AuthType.USE_OPENAI, + model: '', + apiKey: 'sk-xxx', + }); + + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain('Missing model'); + }); + + it('always passes for Qwen OAuth', () => { + const result = validateModelConfig({ + authType: AuthType.QWEN_OAUTH, + model: DEFAULT_QWEN_MODEL, + apiKey: 'QWEN_OAUTH_DYNAMIC_TOKEN', + }); + + expect(result.valid).toBe(true); + }); + + it('requires baseUrl for Anthropic', () => { + const result = validateModelConfig({ + authType: AuthType.USE_ANTHROPIC, + model: 'claude-3', + apiKey: 'key', + // missing baseUrl + }); + + expect(result.valid).toBe(false); + expect(result.errors[0].message).toContain('ANTHROPIC_BASE_URL'); + }); + + it('uses strict error messages for modelProvider', () => { + const result = validateModelConfig( + { + authType: AuthType.USE_OPENAI, + model: 'my-model', + // missing apiKey + }, + true, // isStrictModelProvider + ); + + expect(result.valid).toBe(false); + expect(result.errors[0].message).toContain('modelProviders'); + expect(result.errors[0].message).toContain('envKey'); + }); + }); +}); diff --git a/packages/core/src/models/modelConfigResolver.ts b/packages/core/src/models/modelConfigResolver.ts new file mode 100644 index 000000000..dc10fa3e8 --- /dev/null +++ b/packages/core/src/models/modelConfigResolver.ts @@ -0,0 +1,362 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * ModelConfigResolver - Unified resolver for model-related configuration. + * + * This module consolidates all model configuration resolution logic, + * eliminating duplicate code between CLI and Core layers. + * + * Configuration priority (highest to lowest): + * 1. modelProvider - Explicit selection from ModelProviders config + * 2. CLI arguments - Command line flags (--model, --openaiApiKey, etc.) + * 3. Environment variables - OPENAI_API_KEY, OPENAI_MODEL, etc. + * 4. Settings - User/workspace settings file + * 5. Defaults - Built-in default values + */ + +import { AuthType } from '../core/contentGenerator.js'; +import type { ContentGeneratorConfig } from '../core/contentGenerator.js'; +import { DEFAULT_QWEN_MODEL } from '../config/models.js'; +import { + resolveField, + resolveOptionalField, + layer, + envLayer, + cliSource, + settingsSource, + modelProvidersSource, + defaultSource, + computedSource, + type ConfigSource, + type ConfigSources, + type ConfigLayer, +} from '../utils/configResolver.js'; +import { + AUTH_ENV_MAPPINGS, + DEFAULT_MODELS, + QWEN_OAUTH_ALLOWED_MODELS, + MODEL_GENERATION_CONFIG_FIELDS, +} from './constants.js'; +import type { ResolvedModelConfig } from './types.js'; +export { + validateModelConfig, + type ModelConfigValidationResult, +} from '../core/contentGenerator.js'; + +/** + * CLI-provided configuration values + */ +export interface ModelConfigCliInput { + model?: string; + apiKey?: string; + baseUrl?: string; +} + +/** + * Settings-provided configuration values + */ +export interface ModelConfigSettingsInput { + /** Model name from settings.model.name */ + model?: string; + /** API key from settings.security.auth.apiKey */ + apiKey?: string; + /** Base URL from settings.security.auth.baseUrl */ + baseUrl?: string; + /** Generation config from settings.model.generationConfig */ + generationConfig?: Partial; +} + +/** + * All input sources for model configuration resolution + */ +export interface ModelConfigSourcesInput { + /** Authentication type */ + authType: AuthType; + + /** CLI arguments (highest priority for user-provided values) */ + cli?: ModelConfigCliInput; + + /** Settings file configuration */ + settings?: ModelConfigSettingsInput; + + /** Environment variables (injected for testability) */ + env: Record; + + /** Resolved model from ModelProviders (explicit selection, highest priority) */ + modelProvider?: ResolvedModelConfig; + + /** Proxy URL (computed from Config) */ + proxy?: string; +} + +/** + * Result of model configuration resolution + */ +export interface ModelConfigResolutionResult { + /** The fully resolved configuration */ + config: ContentGeneratorConfig; + /** Source attribution for each field */ + sources: ConfigSources; + /** Warnings generated during resolution */ + warnings: string[]; +} + +/** + * Resolve model configuration from all input sources. + * + * This is the single entry point for model configuration resolution. + * It replaces the duplicate logic in: + * - packages/cli/src/utils/modelProviderUtils.ts (resolveCliGenerationConfig) + * - packages/core/src/core/contentGenerator.ts (resolveContentGeneratorConfigWithSources) + * + * @param input - All configuration sources + * @returns Resolved configuration with source tracking + */ +export function resolveModelConfig( + input: ModelConfigSourcesInput, +): ModelConfigResolutionResult { + const { authType, cli, settings, env, modelProvider, proxy } = input; + const warnings: string[] = []; + const sources: ConfigSources = {}; + + // Special handling for Qwen OAuth + if (authType === AuthType.QWEN_OAUTH) { + return resolveQwenOAuthConfig(input, warnings); + } + + // Get auth-specific env var mappings + const envMapping = + AUTH_ENV_MAPPINGS[authType] || AUTH_ENV_MAPPINGS[AuthType.USE_OPENAI]; + + // Build layers for each field in priority order + // Priority: modelProvider > cli > env > settings > default + + // ---- Model ---- + const modelLayers: Array> = []; + + if (modelProvider) { + modelLayers.push( + layer( + modelProvider.id, + modelProvidersSource(authType, modelProvider.id, 'model.id'), + ), + ); + } + if (cli?.model) { + modelLayers.push(layer(cli.model, cliSource('--model'))); + } + for (const envKey of envMapping.model) { + modelLayers.push(envLayer(env, envKey)); + } + if (settings?.model) { + modelLayers.push(layer(settings.model, settingsSource('model.name'))); + } + + const defaultModel = DEFAULT_MODELS[authType] || ''; + const modelResult = resolveField( + modelLayers, + defaultModel, + defaultSource(defaultModel), + ); + sources['model'] = modelResult.source; + + // ---- API Key ---- + const apiKeyLayers: Array> = []; + + // For modelProvider, read from the specified envKey + if (modelProvider?.envKey) { + const apiKeyFromEnv = env[modelProvider.envKey]; + if (apiKeyFromEnv) { + apiKeyLayers.push( + layer(apiKeyFromEnv, { + kind: 'env', + envKey: modelProvider.envKey, + via: modelProvidersSource(authType, modelProvider.id, 'envKey'), + }), + ); + } + } + if (cli?.apiKey) { + apiKeyLayers.push(layer(cli.apiKey, cliSource('--openaiApiKey'))); + } + for (const envKey of envMapping.apiKey) { + apiKeyLayers.push(envLayer(env, envKey)); + } + if (settings?.apiKey) { + apiKeyLayers.push( + layer(settings.apiKey, settingsSource('security.auth.apiKey')), + ); + } + + const apiKeyResult = resolveOptionalField(apiKeyLayers); + if (apiKeyResult) { + sources['apiKey'] = apiKeyResult.source; + } + + // ---- Base URL ---- + const baseUrlLayers: Array> = []; + + if (modelProvider?.baseUrl) { + baseUrlLayers.push( + layer( + modelProvider.baseUrl, + modelProvidersSource(authType, modelProvider.id, 'baseUrl'), + ), + ); + } + if (cli?.baseUrl) { + baseUrlLayers.push(layer(cli.baseUrl, cliSource('--openaiBaseUrl'))); + } + for (const envKey of envMapping.baseUrl) { + baseUrlLayers.push(envLayer(env, envKey)); + } + if (settings?.baseUrl) { + baseUrlLayers.push( + layer(settings.baseUrl, settingsSource('security.auth.baseUrl')), + ); + } + + const baseUrlResult = resolveOptionalField(baseUrlLayers); + if (baseUrlResult) { + sources['baseUrl'] = baseUrlResult.source; + } + + // ---- API Key Env Key (for error messages) ---- + let apiKeyEnvKey: string | undefined; + if (modelProvider?.envKey) { + apiKeyEnvKey = modelProvider.envKey; + sources['apiKeyEnvKey'] = modelProvidersSource( + authType, + modelProvider.id, + 'envKey', + ); + } + + // ---- Generation Config (from settings or modelProvider) ---- + const generationConfig = resolveGenerationConfig( + settings?.generationConfig, + modelProvider?.generationConfig, + authType, + modelProvider?.id, + sources, + ); + + // Build final config + const config: ContentGeneratorConfig = { + authType, + model: modelResult.value, + apiKey: apiKeyResult?.value, + apiKeyEnvKey, + baseUrl: baseUrlResult?.value, + proxy, + ...generationConfig, + }; + + // Add proxy source + if (proxy) { + sources['proxy'] = computedSource('Config.getProxy()'); + } + + // Add authType source + sources['authType'] = computedSource('provided by caller'); + + return { config, sources, warnings }; +} + +/** + * Special resolver for Qwen OAuth authentication. + * Qwen OAuth has fixed model options and uses dynamic tokens. + */ +function resolveQwenOAuthConfig( + input: ModelConfigSourcesInput, + warnings: string[], +): ModelConfigResolutionResult { + const { cli, settings, proxy } = input; + const sources: ConfigSources = {}; + + // Qwen OAuth only allows specific models + const allowedModels = new Set(QWEN_OAUTH_ALLOWED_MODELS); + + // Determine requested model + const requestedModel = cli?.model || settings?.model; + let resolvedModel: string; + let modelSource: ConfigSource; + + if (requestedModel && allowedModels.has(requestedModel)) { + resolvedModel = requestedModel; + modelSource = cli?.model + ? cliSource('--model') + : settingsSource('model.name'); + } else { + if (requestedModel) { + warnings.push( + `Unsupported Qwen OAuth model '${requestedModel}', falling back to '${DEFAULT_QWEN_MODEL}'.`, + ); + } + resolvedModel = DEFAULT_QWEN_MODEL; + modelSource = defaultSource(`fallback to '${DEFAULT_QWEN_MODEL}'`); + } + + sources['model'] = modelSource; + sources['apiKey'] = computedSource('Qwen OAuth dynamic token'); + sources['authType'] = computedSource('provided by caller'); + + if (proxy) { + sources['proxy'] = computedSource('Config.getProxy()'); + } + + // Resolve generation config from settings + const generationConfig = resolveGenerationConfig( + settings?.generationConfig, + undefined, + AuthType.QWEN_OAUTH, + resolvedModel, + sources, + ); + + const config: ContentGeneratorConfig = { + authType: AuthType.QWEN_OAUTH, + model: resolvedModel, + apiKey: 'QWEN_OAUTH_DYNAMIC_TOKEN', + proxy, + ...generationConfig, + }; + + return { config, sources, warnings }; +} + +/** + * Resolve generation config fields (samplingParams, timeout, etc.) + */ +function resolveGenerationConfig( + settingsConfig: Partial | undefined, + modelProviderConfig: Partial | undefined, + authType: AuthType, + modelId: string | undefined, + sources: ConfigSources, +): Partial { + const result: Partial = {}; + + for (const field of MODEL_GENERATION_CONFIG_FIELDS) { + // ModelProvider config takes priority + if (modelProviderConfig && field in modelProviderConfig) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (result as any)[field] = modelProviderConfig[field]; + sources[field] = modelProvidersSource( + authType, + modelId || '', + `generationConfig.${field}`, + ); + } else if (settingsConfig && field in settingsConfig) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (result as any)[field] = settingsConfig[field]; + sources[field] = settingsSource(`model.generationConfig.${field}`); + } + } + + return result; +} diff --git a/packages/core/src/models/modelRegistry.test.ts b/packages/core/src/models/modelRegistry.test.ts new file mode 100644 index 000000000..b2225425c --- /dev/null +++ b/packages/core/src/models/modelRegistry.test.ts @@ -0,0 +1,390 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ModelRegistry, QWEN_OAUTH_MODELS } from './modelRegistry.js'; +import { AuthType } from '../core/contentGenerator.js'; +import type { ModelProvidersConfig } from './types.js'; + +describe('ModelRegistry', () => { + describe('initialization', () => { + it('should always include hard-coded qwen-oauth models', () => { + const registry = new ModelRegistry(); + + const qwenModels = registry.getModelsForAuthType(AuthType.QWEN_OAUTH); + expect(qwenModels.length).toBe(QWEN_OAUTH_MODELS.length); + expect(qwenModels[0].id).toBe('coder-model'); + expect(qwenModels[1].id).toBe('vision-model'); + }); + + it('should initialize with empty config', () => { + const registry = new ModelRegistry(); + expect(registry.getModelsForAuthType(AuthType.QWEN_OAUTH).length).toBe( + QWEN_OAUTH_MODELS.length, + ); + expect(registry.getModelsForAuthType(AuthType.USE_OPENAI).length).toBe(0); + }); + + it('should initialize with custom models config', () => { + const modelProvidersConfig: ModelProvidersConfig = { + openai: [ + { + id: 'gpt-4-turbo', + name: 'GPT-4 Turbo', + baseUrl: 'https://api.openai.com/v1', + }, + ], + }; + + const registry = new ModelRegistry(modelProvidersConfig); + + const openaiModels = registry.getModelsForAuthType(AuthType.USE_OPENAI); + expect(openaiModels.length).toBe(1); + expect(openaiModels[0].id).toBe('gpt-4-turbo'); + }); + + it('should ignore qwen-oauth models in config (hard-coded)', () => { + const modelProvidersConfig: ModelProvidersConfig = { + 'qwen-oauth': [ + { + id: 'custom-qwen', + name: 'Custom Qwen', + }, + ], + }; + + const registry = new ModelRegistry(modelProvidersConfig); + + // Should still use hard-coded qwen-oauth models + const qwenModels = registry.getModelsForAuthType(AuthType.QWEN_OAUTH); + expect(qwenModels.length).toBe(QWEN_OAUTH_MODELS.length); + expect(qwenModels.find((m) => m.id === 'custom-qwen')).toBeUndefined(); + }); + }); + + describe('getModelsForAuthType', () => { + let registry: ModelRegistry; + + beforeEach(() => { + const modelProvidersConfig: ModelProvidersConfig = { + openai: [ + { + id: 'gpt-4-turbo', + name: 'GPT-4 Turbo', + description: 'Most capable GPT-4', + baseUrl: 'https://api.openai.com/v1', + capabilities: { vision: true }, + }, + { + id: 'gpt-3.5-turbo', + name: 'GPT-3.5 Turbo', + capabilities: { vision: false }, + }, + ], + }; + registry = new ModelRegistry(modelProvidersConfig); + }); + + it('should return models for existing authType', () => { + const models = registry.getModelsForAuthType(AuthType.USE_OPENAI); + expect(models.length).toBe(2); + }); + + it('should return empty array for non-existent authType', () => { + const models = registry.getModelsForAuthType(AuthType.USE_VERTEX_AI); + expect(models.length).toBe(0); + }); + + it('should return AvailableModel format with correct fields', () => { + const models = registry.getModelsForAuthType(AuthType.USE_OPENAI); + const gpt4 = models.find((m) => m.id === 'gpt-4-turbo'); + + expect(gpt4).toBeDefined(); + expect(gpt4?.label).toBe('GPT-4 Turbo'); + expect(gpt4?.description).toBe('Most capable GPT-4'); + expect(gpt4?.isVision).toBe(true); + expect(gpt4?.authType).toBe(AuthType.USE_OPENAI); + }); + }); + + describe('getModel', () => { + let registry: ModelRegistry; + + beforeEach(() => { + const modelProvidersConfig: ModelProvidersConfig = { + openai: [ + { + id: 'gpt-4-turbo', + name: 'GPT-4 Turbo', + baseUrl: 'https://api.openai.com/v1', + generationConfig: { + samplingParams: { + temperature: 0.8, + max_tokens: 4096, + }, + }, + }, + ], + }; + registry = new ModelRegistry(modelProvidersConfig); + }); + + it('should return resolved model config', () => { + const model = registry.getModel(AuthType.USE_OPENAI, 'gpt-4-turbo'); + + expect(model).toBeDefined(); + expect(model?.id).toBe('gpt-4-turbo'); + expect(model?.name).toBe('GPT-4 Turbo'); + expect(model?.authType).toBe(AuthType.USE_OPENAI); + expect(model?.baseUrl).toBe('https://api.openai.com/v1'); + }); + + it('should preserve generationConfig without applying defaults', () => { + const model = registry.getModel(AuthType.USE_OPENAI, 'gpt-4-turbo'); + + expect(model?.generationConfig.samplingParams?.temperature).toBe(0.8); + expect(model?.generationConfig.samplingParams?.max_tokens).toBe(4096); + // No defaults are applied - only the configured values are present + expect(model?.generationConfig.samplingParams?.top_p).toBeUndefined(); + expect(model?.generationConfig.timeout).toBeUndefined(); + }); + + it('should return undefined for non-existent model', () => { + const model = registry.getModel(AuthType.USE_OPENAI, 'non-existent'); + expect(model).toBeUndefined(); + }); + + it('should return undefined for non-existent authType', () => { + const model = registry.getModel(AuthType.USE_VERTEX_AI, 'some-model'); + expect(model).toBeUndefined(); + }); + }); + + describe('hasModel', () => { + let registry: ModelRegistry; + + beforeEach(() => { + registry = new ModelRegistry({ + openai: [{ id: 'gpt-4', name: 'GPT-4' }], + }); + }); + + it('should return true for existing model', () => { + expect(registry.hasModel(AuthType.USE_OPENAI, 'gpt-4')).toBe(true); + }); + + it('should return false for non-existent model', () => { + expect(registry.hasModel(AuthType.USE_OPENAI, 'non-existent')).toBe( + false, + ); + }); + + it('should return false for non-existent authType', () => { + expect(registry.hasModel(AuthType.USE_VERTEX_AI, 'gpt-4')).toBe(false); + }); + }); + + describe('getDefaultModelForAuthType', () => { + it('should return coder-model for qwen-oauth', () => { + const registry = new ModelRegistry(); + const defaultModel = registry.getDefaultModelForAuthType( + AuthType.QWEN_OAUTH, + ); + expect(defaultModel?.id).toBe('coder-model'); + }); + + it('should return first model for other authTypes', () => { + const registry = new ModelRegistry({ + openai: [ + { id: 'gpt-4', name: 'GPT-4' }, + { id: 'gpt-3.5', name: 'GPT-3.5' }, + ], + }); + + const defaultModel = registry.getDefaultModelForAuthType( + AuthType.USE_OPENAI, + ); + expect(defaultModel?.id).toBe('gpt-4'); + }); + }); + + describe('validation', () => { + it('should throw error for model without id', () => { + expect( + () => + new ModelRegistry({ + openai: [{ id: '', name: 'No ID' }], + }), + ).toThrow('missing required field: id'); + }); + }); + + describe('default base URLs', () => { + it('should apply default dashscope URL for qwen-oauth', () => { + const registry = new ModelRegistry(); + const model = registry.getModel(AuthType.QWEN_OAUTH, 'coder-model'); + expect(model?.baseUrl).toBe( + 'https://dashscope.aliyuncs.com/compatible-mode/v1', + ); + }); + + it('should apply default openai URL when not specified', () => { + const registry = new ModelRegistry({ + openai: [{ id: 'gpt-4', name: 'GPT-4' }], + }); + + const model = registry.getModel(AuthType.USE_OPENAI, 'gpt-4'); + expect(model?.baseUrl).toBe('https://api.openai.com/v1'); + }); + + it('should use custom baseUrl when specified', () => { + const registry = new ModelRegistry({ + openai: [ + { + id: 'deepseek', + name: 'DeepSeek', + baseUrl: 'https://api.deepseek.com/v1', + }, + ], + }); + + const model = registry.getModel(AuthType.USE_OPENAI, 'deepseek'); + expect(model?.baseUrl).toBe('https://api.deepseek.com/v1'); + }); + }); + + describe('authType key validation', () => { + it('should accept valid authType keys', () => { + const registry = new ModelRegistry({ + openai: [{ id: 'gpt-4', name: 'GPT-4' }], + gemini: [{ id: 'gemini-pro', name: 'Gemini Pro' }], + }); + + const openaiModels = registry.getModelsForAuthType(AuthType.USE_OPENAI); + expect(openaiModels.length).toBe(1); + expect(openaiModels[0].id).toBe('gpt-4'); + + const geminiModels = registry.getModelsForAuthType(AuthType.USE_GEMINI); + expect(geminiModels.length).toBe(1); + expect(geminiModels[0].id).toBe('gemini-pro'); + }); + + it('should skip invalid authType keys with warning', () => { + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + + const registry = new ModelRegistry({ + openai: [{ id: 'gpt-4', name: 'GPT-4' }], + 'invalid-key': [{ id: 'some-model', name: 'Some Model' }], + } as unknown as ModelProvidersConfig); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('[ModelRegistry] Invalid authType key'), + ); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('invalid-key'), + ); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Expected one of:'), + ); + + // Valid key should be registered + expect(registry.getModelsForAuthType(AuthType.USE_OPENAI).length).toBe(1); + + // Invalid key should be skipped (no crash) + const openaiModels = registry.getModelsForAuthType(AuthType.USE_OPENAI); + expect(openaiModels.length).toBe(1); + + consoleWarnSpy.mockRestore(); + }); + + it('should handle mixed valid and invalid keys', () => { + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + + const registry = new ModelRegistry({ + openai: [{ id: 'gpt-4', name: 'GPT-4' }], + 'bad-key-1': [{ id: 'model-1', name: 'Model 1' }], + gemini: [{ id: 'gemini-pro', name: 'Gemini Pro' }], + 'bad-key-2': [{ id: 'model-2', name: 'Model 2' }], + } as unknown as ModelProvidersConfig); + + // Should warn twice for the two invalid keys + expect(consoleWarnSpy).toHaveBeenCalledTimes(2); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('bad-key-1'), + ); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('bad-key-2'), + ); + + // Valid keys should be registered + expect(registry.getModelsForAuthType(AuthType.USE_OPENAI).length).toBe(1); + expect(registry.getModelsForAuthType(AuthType.USE_GEMINI).length).toBe(1); + + // Invalid keys should be skipped + const openaiModels = registry.getModelsForAuthType(AuthType.USE_OPENAI); + expect(openaiModels.length).toBe(1); + + const geminiModels = registry.getModelsForAuthType(AuthType.USE_GEMINI); + expect(geminiModels.length).toBe(1); + + consoleWarnSpy.mockRestore(); + }); + + it('should list all valid AuthType values in warning message', () => { + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + + new ModelRegistry({ + 'invalid-auth': [{ id: 'model', name: 'Model' }], + } as unknown as ModelProvidersConfig); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('openai'), + ); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('qwen-oauth'), + ); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('gemini'), + ); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('vertex-ai'), + ); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('anthropic'), + ); + + consoleWarnSpy.mockRestore(); + }); + + it('should work correctly with getModelsForAuthType after validation', () => { + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + + const registry = new ModelRegistry({ + openai: [ + { id: 'gpt-4', name: 'GPT-4' }, + { id: 'gpt-3.5', name: 'GPT-3.5' }, + ], + 'invalid-key': [{ id: 'invalid-model', name: 'Invalid Model' }], + } as unknown as ModelProvidersConfig); + + const models = registry.getModelsForAuthType(AuthType.USE_OPENAI); + expect(models.length).toBe(2); + expect(models.find((m) => m.id === 'gpt-4')).toBeDefined(); + expect(models.find((m) => m.id === 'gpt-3.5')).toBeDefined(); + expect(models.find((m) => m.id === 'invalid-model')).toBeUndefined(); + + consoleWarnSpy.mockRestore(); + }); + }); +}); diff --git a/packages/core/src/models/modelRegistry.ts b/packages/core/src/models/modelRegistry.ts new file mode 100644 index 000000000..cec6ebb94 --- /dev/null +++ b/packages/core/src/models/modelRegistry.ts @@ -0,0 +1,180 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AuthType } from '../core/contentGenerator.js'; +import { DEFAULT_OPENAI_BASE_URL } from '../core/openaiContentGenerator/constants.js'; +import { + type ModelConfig, + type ModelProvidersConfig, + type ResolvedModelConfig, + type AvailableModel, +} from './types.js'; +import { DEFAULT_QWEN_MODEL } from '../config/models.js'; +import { QWEN_OAUTH_MODELS } from './constants.js'; + +export { QWEN_OAUTH_MODELS } from './constants.js'; + +/** + * Validates if a string key is a valid AuthType enum value. + * @param key - The key to validate + * @returns The validated AuthType or undefined if invalid + */ +function validateAuthTypeKey(key: string): AuthType | undefined { + // Check if the key is a valid AuthType enum value + if (Object.values(AuthType).includes(key as AuthType)) { + return key as AuthType; + } + + // Invalid key + return undefined; +} + +/** + * Central registry for managing model configurations. + * Models are organized by authType. + */ +export class ModelRegistry { + private modelsByAuthType: Map>; + + private getDefaultBaseUrl(authType: AuthType): string { + switch (authType) { + case AuthType.QWEN_OAUTH: + return 'DYNAMIC_QWEN_OAUTH_BASE_URL'; + case AuthType.USE_OPENAI: + return DEFAULT_OPENAI_BASE_URL; + default: + return ''; + } + } + + constructor(modelProvidersConfig?: ModelProvidersConfig) { + this.modelsByAuthType = new Map(); + + // Always register qwen-oauth models (hard-coded, cannot be overridden) + this.registerAuthTypeModels(AuthType.QWEN_OAUTH, QWEN_OAUTH_MODELS); + + // Register user-configured models for other authTypes + if (modelProvidersConfig) { + for (const [rawKey, models] of Object.entries(modelProvidersConfig)) { + const authType = validateAuthTypeKey(rawKey); + + if (!authType) { + console.warn( + `[ModelRegistry] Invalid authType key "${rawKey}" in modelProviders config. Expected one of: ${Object.values(AuthType).join(', ')}. Skipping.`, + ); + continue; + } + + // Skip qwen-oauth as it uses hard-coded models + if (authType === AuthType.QWEN_OAUTH) { + continue; + } + + this.registerAuthTypeModels(authType, models); + } + } + } + + /** + * Register models for an authType + */ + private registerAuthTypeModels( + authType: AuthType, + models: ModelConfig[], + ): void { + const modelMap = new Map(); + + for (const config of models) { + const resolved = this.resolveModelConfig(config, authType); + modelMap.set(config.id, resolved); + } + + this.modelsByAuthType.set(authType, modelMap); + } + + /** + * Get all models for a specific authType. + * This is used by /model command to show only relevant models. + */ + getModelsForAuthType(authType: AuthType): AvailableModel[] { + const models = this.modelsByAuthType.get(authType); + if (!models) return []; + + return Array.from(models.values()).map((model) => ({ + id: model.id, + label: model.name, + description: model.description, + capabilities: model.capabilities, + authType: model.authType, + isVision: model.capabilities?.vision ?? false, + })); + } + + /** + * Get model configuration by authType and modelId + */ + getModel( + authType: AuthType, + modelId: string, + ): ResolvedModelConfig | undefined { + const models = this.modelsByAuthType.get(authType); + return models?.get(modelId); + } + + /** + * Check if model exists for given authType + */ + hasModel(authType: AuthType, modelId: string): boolean { + const models = this.modelsByAuthType.get(authType); + return models?.has(modelId) ?? false; + } + + /** + * Get default model for an authType. + * For qwen-oauth, returns the coder model. + * For others, returns the first configured model. + */ + getDefaultModelForAuthType( + authType: AuthType, + ): ResolvedModelConfig | undefined { + if (authType === AuthType.QWEN_OAUTH) { + return this.getModel(authType, DEFAULT_QWEN_MODEL); + } + const models = this.modelsByAuthType.get(authType); + if (!models || models.size === 0) return undefined; + return Array.from(models.values())[0]; + } + + /** + * Resolve model config by applying defaults + */ + private resolveModelConfig( + config: ModelConfig, + authType: AuthType, + ): ResolvedModelConfig { + this.validateModelConfig(config, authType); + + return { + ...config, + authType, + name: config.name || config.id, + baseUrl: config.baseUrl || this.getDefaultBaseUrl(authType), + generationConfig: config.generationConfig ?? {}, + capabilities: config.capabilities || {}, + }; + } + + /** + * Validate model configuration + */ + private validateModelConfig(config: ModelConfig, authType: AuthType): void { + if (!config.id) { + throw new Error( + `Model config in authType '${authType}' missing required field: id`, + ); + } + } +} diff --git a/packages/core/src/models/modelsConfig.test.ts b/packages/core/src/models/modelsConfig.test.ts new file mode 100644 index 000000000..51c54ea59 --- /dev/null +++ b/packages/core/src/models/modelsConfig.test.ts @@ -0,0 +1,451 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { ModelsConfig } from './modelsConfig.js'; +import { AuthType } from '../core/contentGenerator.js'; +import type { ModelProvidersConfig } from './types.js'; + +describe('ModelsConfig', () => { + function deepClone(value: T): T { + if (value === null || typeof value !== 'object') return value; + if (Array.isArray(value)) return value.map((v) => deepClone(v)) as T; + const out: Record = {}; + for (const key of Object.keys(value as Record)) { + out[key] = deepClone((value as Record)[key]); + } + return out as T; + } + + it('should fully rollback state when switchModel fails after applying defaults (authType change)', async () => { + const modelProvidersConfig: ModelProvidersConfig = { + openai: [ + { + id: 'openai-a', + name: 'OpenAI A', + baseUrl: 'https://api.openai.example.com/v1', + envKey: 'OPENAI_API_KEY', + generationConfig: { + samplingParams: { temperature: 0.2, max_tokens: 123 }, + timeout: 111, + maxRetries: 1, + }, + }, + ], + anthropic: [ + { + id: 'anthropic-b', + name: 'Anthropic B', + baseUrl: 'https://api.anthropic.example.com/v1', + envKey: 'ANTHROPIC_API_KEY', + generationConfig: { + samplingParams: { temperature: 0.7, max_tokens: 456 }, + timeout: 222, + maxRetries: 2, + }, + }, + ], + }; + + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + modelProvidersConfig, + }); + + // Establish a known baseline state via a successful switch. + await modelsConfig.switchModel(AuthType.USE_OPENAI, 'openai-a'); + const baselineAuthType = modelsConfig.getCurrentAuthType(); + const baselineModel = modelsConfig.getModel(); + const baselineStrict = modelsConfig.isStrictModelProviderSelection(); + const baselineGc = deepClone(modelsConfig.getGenerationConfig()); + const baselineSources = deepClone( + modelsConfig.getGenerationConfigSources(), + ); + + modelsConfig.setOnModelChange(async () => { + throw new Error('refresh failed'); + }); + + await expect( + modelsConfig.switchModel(AuthType.USE_ANTHROPIC, 'anthropic-b'), + ).rejects.toThrow('refresh failed'); + + // Ensure state is fully rolled back (selection + generation config + flags). + expect(modelsConfig.getCurrentAuthType()).toBe(baselineAuthType); + expect(modelsConfig.getModel()).toBe(baselineModel); + expect(modelsConfig.isStrictModelProviderSelection()).toBe(baselineStrict); + + const gc = modelsConfig.getGenerationConfig(); + expect(gc).toMatchObject({ + model: baselineGc.model, + baseUrl: baselineGc.baseUrl, + apiKeyEnvKey: baselineGc.apiKeyEnvKey, + samplingParams: baselineGc.samplingParams, + timeout: baselineGc.timeout, + maxRetries: baselineGc.maxRetries, + }); + + const sources = modelsConfig.getGenerationConfigSources(); + expect(sources).toEqual(baselineSources); + }); + + it('should fully rollback state when switchModel fails after applying defaults', async () => { + const modelProvidersConfig: ModelProvidersConfig = { + openai: [ + { + id: 'model-a', + name: 'Model A', + baseUrl: 'https://api.example.com/v1', + envKey: 'API_KEY_A', + }, + { + id: 'model-b', + name: 'Model B', + baseUrl: 'https://api.example.com/v1', + envKey: 'API_KEY_B', + }, + ], + }; + + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + modelProvidersConfig, + }); + + await modelsConfig.switchModel(AuthType.USE_OPENAI, 'model-a'); + const baselineModel = modelsConfig.getModel(); + const baselineGc = deepClone(modelsConfig.getGenerationConfig()); + const baselineSources = deepClone( + modelsConfig.getGenerationConfigSources(), + ); + + modelsConfig.setOnModelChange(async () => { + throw new Error('hot-update failed'); + }); + + await expect( + modelsConfig.switchModel(AuthType.USE_OPENAI, 'model-b'), + ).rejects.toThrow('hot-update failed'); + + expect(modelsConfig.getModel()).toBe(baselineModel); + expect(modelsConfig.getGenerationConfig()).toMatchObject({ + model: baselineGc.model, + baseUrl: baselineGc.baseUrl, + apiKeyEnvKey: baselineGc.apiKeyEnvKey, + }); + expect(modelsConfig.getGenerationConfigSources()).toEqual(baselineSources); + }); + + it('should preserve an explicit apiKey when switching models if envKey is missing in the environment', async () => { + const modelProvidersConfig: ModelProvidersConfig = { + openai: [ + { + id: 'model-a', + name: 'Model A', + baseUrl: 'https://api.example.com/v1', + envKey: 'API_KEY_SHARED', + }, + { + id: 'model-b', + name: 'Model B', + baseUrl: 'https://api.example.com/v1', + envKey: 'API_KEY_SHARED', + }, + ], + }; + + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + initialModelId: 'model-a', + modelProvidersConfig, + }); + + // Simulate key prompt flow / explicit key provided via CLI/settings. + modelsConfig.updateCredentials({ apiKey: 'manual-key', model: 'model-a' }); + + await modelsConfig.switchModel(AuthType.USE_OPENAI, 'model-b'); + + const gc = modelsConfig.getGenerationConfig(); + expect(gc.model).toBe('model-b'); + expect(gc.apiKey).toBe('manual-key'); + expect(gc.apiKeyEnvKey).toBe('API_KEY_SHARED'); + }); + + it('should preserve settings generationConfig when model is updated via updateCredentials even if it matches modelProviders', () => { + const modelProvidersConfig: ModelProvidersConfig = { + openai: [ + { + id: 'model-a', + name: 'Model A', + baseUrl: 'https://api.example.com/v1', + envKey: 'API_KEY_A', + generationConfig: { + samplingParams: { temperature: 0.1, max_tokens: 123 }, + timeout: 111, + maxRetries: 1, + }, + }, + ], + }; + + // Simulate settings.model.generationConfig being resolved into ModelsConfig.generationConfig + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + initialModelId: 'model-a', + modelProvidersConfig, + generationConfig: { + model: 'model-a', + samplingParams: { temperature: 0.9, max_tokens: 999 }, + timeout: 9999, + maxRetries: 9, + }, + generationConfigSources: { + model: { kind: 'settings', detail: 'settings.model.name' }, + samplingParams: { + kind: 'settings', + detail: 'settings.model.generationConfig.samplingParams', + }, + timeout: { + kind: 'settings', + detail: 'settings.model.generationConfig.timeout', + }, + maxRetries: { + kind: 'settings', + detail: 'settings.model.generationConfig.maxRetries', + }, + }, + }); + + // User manually updates the model via updateCredentials (e.g. key prompt flow). + // Even if the model ID matches a modelProviders entry, we must not apply provider defaults + // that would overwrite settings.model.generationConfig. + modelsConfig.updateCredentials({ model: 'model-a' }); + + modelsConfig.syncAfterAuthRefresh( + AuthType.USE_OPENAI, + modelsConfig.getModel(), + ); + + const gc = modelsConfig.getGenerationConfig(); + expect(gc.model).toBe('model-a'); + expect(gc.samplingParams?.temperature).toBe(0.9); + expect(gc.samplingParams?.max_tokens).toBe(999); + expect(gc.timeout).toBe(9999); + expect(gc.maxRetries).toBe(9); + }); + + it('should preserve settings generationConfig across multiple auth refreshes after updateCredentials', () => { + const modelProvidersConfig: ModelProvidersConfig = { + openai: [ + { + id: 'model-a', + name: 'Model A', + baseUrl: 'https://api.example.com/v1', + envKey: 'API_KEY_A', + generationConfig: { + samplingParams: { temperature: 0.1, max_tokens: 123 }, + timeout: 111, + maxRetries: 1, + }, + }, + ], + }; + + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + initialModelId: 'model-a', + modelProvidersConfig, + generationConfig: { + model: 'model-a', + samplingParams: { temperature: 0.9, max_tokens: 999 }, + timeout: 9999, + maxRetries: 9, + }, + generationConfigSources: { + model: { kind: 'settings', detail: 'settings.model.name' }, + samplingParams: { + kind: 'settings', + detail: 'settings.model.generationConfig.samplingParams', + }, + timeout: { + kind: 'settings', + detail: 'settings.model.generationConfig.timeout', + }, + maxRetries: { + kind: 'settings', + detail: 'settings.model.generationConfig.maxRetries', + }, + }, + }); + + modelsConfig.updateCredentials({ + apiKey: 'manual-key', + baseUrl: 'https://manual.example.com/v1', + model: 'model-a', + }); + + // First auth refresh + modelsConfig.syncAfterAuthRefresh( + AuthType.USE_OPENAI, + modelsConfig.getModel(), + ); + // Second auth refresh should still preserve settings generationConfig + modelsConfig.syncAfterAuthRefresh( + AuthType.USE_OPENAI, + modelsConfig.getModel(), + ); + + const gc = modelsConfig.getGenerationConfig(); + expect(gc.model).toBe('model-a'); + expect(gc.samplingParams?.temperature).toBe(0.9); + expect(gc.samplingParams?.max_tokens).toBe(999); + expect(gc.timeout).toBe(9999); + expect(gc.maxRetries).toBe(9); + }); + + it('should clear provider-sourced config when updateCredentials is called after switchModel', async () => { + const modelProvidersConfig: ModelProvidersConfig = { + openai: [ + { + id: 'provider-model', + name: 'Provider Model', + baseUrl: 'https://provider.example.com/v1', + envKey: 'PROVIDER_API_KEY', + generationConfig: { + samplingParams: { temperature: 0.1, max_tokens: 100 }, + timeout: 1000, + maxRetries: 2, + }, + }, + ], + }; + + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + modelProvidersConfig, + }); + + // Step 1: Switch to a provider model - this applies provider config + await modelsConfig.switchModel(AuthType.USE_OPENAI, 'provider-model'); + + // Verify provider config is applied + let gc = modelsConfig.getGenerationConfig(); + expect(gc.model).toBe('provider-model'); + expect(gc.baseUrl).toBe('https://provider.example.com/v1'); + expect(gc.samplingParams?.temperature).toBe(0.1); + expect(gc.samplingParams?.max_tokens).toBe(100); + expect(gc.timeout).toBe(1000); + expect(gc.maxRetries).toBe(2); + + // Verify sources are from modelProviders + let sources = modelsConfig.getGenerationConfigSources(); + expect(sources['model']?.kind).toBe('modelProviders'); + expect(sources['baseUrl']?.kind).toBe('modelProviders'); + expect(sources['samplingParams']?.kind).toBe('modelProviders'); + expect(sources['timeout']?.kind).toBe('modelProviders'); + expect(sources['maxRetries']?.kind).toBe('modelProviders'); + + // Step 2: User manually sets credentials via updateCredentials + // This should clear all provider-sourced config + modelsConfig.updateCredentials({ + apiKey: 'manual-api-key', + model: 'custom-model', + }); + + // Verify provider-sourced config is cleared + gc = modelsConfig.getGenerationConfig(); + expect(gc.model).toBe('custom-model'); // Set by updateCredentials + expect(gc.apiKey).toBe('manual-api-key'); // Set by updateCredentials + expect(gc.baseUrl).toBeUndefined(); // Cleared (was from provider) + expect(gc.samplingParams).toBeUndefined(); // Cleared (was from provider) + expect(gc.timeout).toBeUndefined(); // Cleared (was from provider) + expect(gc.maxRetries).toBeUndefined(); // Cleared (was from provider) + + // Verify sources are updated + sources = modelsConfig.getGenerationConfigSources(); + expect(sources['model']?.kind).toBe('programmatic'); + expect(sources['apiKey']?.kind).toBe('programmatic'); + expect(sources['baseUrl']).toBeUndefined(); // Source cleared + expect(sources['samplingParams']).toBeUndefined(); // Source cleared + expect(sources['timeout']).toBeUndefined(); // Source cleared + expect(sources['maxRetries']).toBeUndefined(); // Source cleared + }); + + it('should preserve non-provider config when updateCredentials clears provider config', async () => { + const modelProvidersConfig: ModelProvidersConfig = { + openai: [ + { + id: 'provider-model', + name: 'Provider Model', + baseUrl: 'https://provider.example.com/v1', + envKey: 'PROVIDER_API_KEY', + generationConfig: { + samplingParams: { temperature: 0.1, max_tokens: 100 }, + timeout: 1000, + maxRetries: 2, + }, + }, + ], + }; + + // Initialize with settings-sourced config + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + modelProvidersConfig, + generationConfig: { + samplingParams: { temperature: 0.8, max_tokens: 500 }, + timeout: 5000, + }, + generationConfigSources: { + samplingParams: { + kind: 'settings', + detail: 'settings.model.generationConfig.samplingParams', + }, + timeout: { + kind: 'settings', + detail: 'settings.model.generationConfig.timeout', + }, + }, + }); + + // Switch to provider model - this overwrites with provider config + await modelsConfig.switchModel(AuthType.USE_OPENAI, 'provider-model'); + + // Verify provider config is applied (overwriting settings) + let gc = modelsConfig.getGenerationConfig(); + expect(gc.samplingParams?.temperature).toBe(0.1); + expect(gc.timeout).toBe(1000); + + // User manually sets credentials - clears provider-sourced config + modelsConfig.updateCredentials({ + apiKey: 'manual-key', + }); + + // Provider-sourced config should be cleared + gc = modelsConfig.getGenerationConfig(); + expect(gc.samplingParams).toBeUndefined(); + expect(gc.timeout).toBeUndefined(); + // The original settings-sourced config is NOT restored automatically; + // it should be re-resolved by other layers in refreshAuth + }); + + it('should always force Qwen OAuth apiKey placeholder when applying model defaults', async () => { + // Simulate a stale/explicit apiKey existing before switching models. + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.QWEN_OAUTH, + generationConfig: { + apiKey: 'manual-key-should-not-leak', + }, + }); + + // Switching within qwen-oauth triggers applyResolvedModelDefaults(). + await modelsConfig.switchModel(AuthType.QWEN_OAUTH, 'vision-model'); + + const gc = modelsConfig.getGenerationConfig(); + expect(gc.apiKey).toBe('QWEN_OAUTH_DYNAMIC_TOKEN'); + expect(gc.apiKeyEnvKey).toBeUndefined(); + }); +}); diff --git a/packages/core/src/models/modelsConfig.ts b/packages/core/src/models/modelsConfig.ts new file mode 100644 index 000000000..022737074 --- /dev/null +++ b/packages/core/src/models/modelsConfig.ts @@ -0,0 +1,697 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import process from 'node:process'; + +import { AuthType } from '../core/contentGenerator.js'; +import type { ContentGeneratorConfig } from '../core/contentGenerator.js'; +import type { ContentGeneratorConfigSources } from '../core/contentGenerator.js'; +import { DEFAULT_QWEN_MODEL } from '../config/models.js'; + +import { ModelRegistry } from './modelRegistry.js'; +import { + type ModelProvidersConfig, + type ResolvedModelConfig, + type AvailableModel, + type ModelSwitchMetadata, +} from './types.js'; +import { + MODEL_GENERATION_CONFIG_FIELDS, + CREDENTIAL_FIELDS, + PROVIDER_SOURCED_FIELDS, +} from './constants.js'; + +export { + MODEL_GENERATION_CONFIG_FIELDS, + CREDENTIAL_FIELDS, + PROVIDER_SOURCED_FIELDS, +}; + +/** + * Callback for when the model changes. + * Used by Config to refresh auth/ContentGenerator when needed. + */ +export type OnModelChangeCallback = ( + authType: AuthType, + requiresRefresh: boolean, +) => Promise; + +/** + * Options for creating ModelsConfig + */ +export interface ModelsConfigOptions { + /** Initial authType from settings */ + initialAuthType?: AuthType; + /** Initial model ID from settings */ + initialModelId?: string; + /** Model providers configuration */ + modelProvidersConfig?: ModelProvidersConfig; + /** Generation config from CLI/settings */ + generationConfig?: Partial; + /** Source tracking for generation config */ + generationConfigSources?: ContentGeneratorConfigSources; + /** Callback when model changes require refresh */ + onModelChange?: OnModelChangeCallback; +} + +/** + * ModelsConfig manages all model selection logic and state. + * + * This class encapsulates: + * - ModelRegistry for model configuration storage + * - Current authType and modelId selection + * - Generation config management + * - Model switching logic + * + * Config uses this as a thin entry point for all model-related operations. + */ +export class ModelsConfig { + private readonly modelRegistry: ModelRegistry; + + // Current selection state + private currentAuthType: AuthType; + private currentModelId: string; + + // Generation config state + private _generationConfig: Partial; + private generationConfigSources: ContentGeneratorConfigSources; + + // Flag for strict model provider selection + private strictModelProviderSelection: boolean = false; + + // One-shot flag for qwen-oauth credential caching + private requireCachedQwenCredentialsOnce: boolean = false; + + // One-shot flag indicating credentials were manually set via updateCredentials() + // When true, syncAfterAuthRefresh should NOT override these credentials with + // modelProviders defaults (even if the model ID matches a registry entry). + // + // This must be persistent across auth refreshes, because refreshAuth() can be + // triggered multiple times after a credential prompt flow. We only clear this + // flag when we explicitly apply modelProvider defaults (i.e. when the user + // switches to a registry model via switchModel). + private hasManualCredentials: boolean = false; + + // Callback for notifying Config of model changes + private onModelChange?: OnModelChangeCallback; + + // Flag indicating whether authType was explicitly provided (not defaulted) + private readonly authTypeWasExplicitlyProvided: boolean; + + private static deepClone(value: T): T { + if (value === null || typeof value !== 'object') { + return value; + } + if (Array.isArray(value)) { + return value.map((v) => ModelsConfig.deepClone(v)) as T; + } + const out: Record = {}; + for (const key of Object.keys(value as Record)) { + out[key] = ModelsConfig.deepClone( + (value as Record)[key], + ); + } + return out as T; + } + + private snapshotState(): { + currentAuthType: AuthType; + currentModelId: string; + generationConfig: Partial; + generationConfigSources: ContentGeneratorConfigSources; + strictModelProviderSelection: boolean; + requireCachedQwenCredentialsOnce: boolean; + hasManualCredentials: boolean; + } { + return { + currentAuthType: this.currentAuthType, + currentModelId: this.currentModelId, + generationConfig: ModelsConfig.deepClone(this._generationConfig), + generationConfigSources: ModelsConfig.deepClone( + this.generationConfigSources, + ), + strictModelProviderSelection: this.strictModelProviderSelection, + requireCachedQwenCredentialsOnce: this.requireCachedQwenCredentialsOnce, + hasManualCredentials: this.hasManualCredentials, + }; + } + + private restoreState( + snapshot: ReturnType, + ): void { + this.currentAuthType = snapshot.currentAuthType; + this.currentModelId = snapshot.currentModelId; + this._generationConfig = snapshot.generationConfig; + this.generationConfigSources = snapshot.generationConfigSources; + this.strictModelProviderSelection = snapshot.strictModelProviderSelection; + this.requireCachedQwenCredentialsOnce = + snapshot.requireCachedQwenCredentialsOnce; + this.hasManualCredentials = snapshot.hasManualCredentials; + } + + constructor(options: ModelsConfigOptions = {}) { + this.modelRegistry = new ModelRegistry(options.modelProvidersConfig); + this.onModelChange = options.onModelChange; + + // Initialize generation config + this._generationConfig = { + model: options.initialModelId, + ...(options.generationConfig || {}), + }; + this.generationConfigSources = options.generationConfigSources || {}; + + // Track if authType was explicitly provided + this.authTypeWasExplicitlyProvided = options.initialAuthType !== undefined; + + // Initialize selection state + this.currentAuthType = options.initialAuthType || AuthType.QWEN_OAUTH; + this.currentModelId = options.initialModelId || ''; + + // Validate and initialize default selection + this.initializeDefaultSelection(); + } + + /** + * Initialize default selection based on settings/environment. + * + * Note: The generationConfig passed to ModelsConfig should already be fully + * resolved by ModelConfigResolver, which handles CLI args, env vars, and settings. + * This method primarily validates and sets up internal state. + */ + private initializeDefaultSelection(): void { + // If generationConfig already has a model (resolved by ModelConfigResolver), + // use that as the current selection + if (this._generationConfig.model) { + this.currentModelId = this._generationConfig.model; + return; + } + + // Check if persisted model selection is valid + if ( + this.currentModelId && + this.modelRegistry.hasModel(this.currentAuthType, this.currentModelId) + ) { + return; + } + + // Use registry default + const defaultModel = this.modelRegistry.getDefaultModelForAuthType( + this.currentAuthType, + ); + if (defaultModel) { + this.currentModelId = defaultModel.id; + if (!this._generationConfig.model) { + this._generationConfig.model = defaultModel.id; + } + } + } + + /** + * Get current model ID + */ + getModel(): string { + return ( + this._generationConfig.model || this.currentModelId || DEFAULT_QWEN_MODEL + ); + } + + /** + * Get current authType + */ + getCurrentAuthType(): AuthType { + return this.currentAuthType; + } + + /** + * Check if authType was explicitly provided (via CLI or settings). + * If false, the default QWEN_OAUTH is being used. + */ + wasAuthTypeExplicitlyProvided(): boolean { + return this.authTypeWasExplicitlyProvided; + } + + /** + * Get available models for current authType + */ + getAvailableModels(): AvailableModel[] { + return this.modelRegistry.getModelsForAuthType(this.currentAuthType); + } + + /** + * Get available models for a specific authType + */ + getAvailableModelsForAuthType(authType: AuthType): AvailableModel[] { + return this.modelRegistry.getModelsForAuthType(authType); + } + + /** + * Check if a model exists for the given authType + */ + hasModel(authType: AuthType, modelId: string): boolean { + return this.modelRegistry.hasModel(authType, modelId); + } + + /** + * Set model programmatically (e.g., VLM auto-switch, fallback). + * Supports both registry models and raw model IDs. + */ + async setModel( + newModel: string, + metadata?: ModelSwitchMetadata, + ): Promise { + // Special case: qwen-oauth VLM auto-switch - hot update in place + if ( + this.currentAuthType === AuthType.QWEN_OAUTH && + (newModel === DEFAULT_QWEN_MODEL || newModel === 'vision-model') + ) { + this.strictModelProviderSelection = false; + this._generationConfig.model = newModel; + this.currentModelId = newModel; + this.generationConfigSources['model'] = { + kind: 'programmatic', + detail: metadata?.reason || 'setModel', + }; + return; + } + + // If model exists in registry, use full switch logic + if (this.modelRegistry.hasModel(this.currentAuthType, newModel)) { + await this.switchModel(this.currentAuthType, newModel); + return; + } + + // Raw model override: update generation config in-place + this.strictModelProviderSelection = false; + this._generationConfig.model = newModel; + this.currentModelId = newModel; + this.generationConfigSources['model'] = { + kind: 'programmatic', + detail: metadata?.reason || 'setModel', + }; + } + + /** + * Switch model (and optionally authType) via registry-backed selection. + * This is a superset of the previous split APIs for model-only vs authType+model switching. + */ + async switchModel( + authType: AuthType, + modelId: string, + options?: { requireCachedCredentials?: boolean }, + _metadata?: ModelSwitchMetadata, + ): Promise { + const snapshot = this.snapshotState(); + if (authType === AuthType.QWEN_OAUTH && options?.requireCachedCredentials) { + this.requireCachedQwenCredentialsOnce = true; + } + + try { + const isAuthTypeChange = authType !== this.currentAuthType; + this.currentAuthType = authType; + + const model = this.modelRegistry.getModel(authType, modelId); + if (!model) { + throw new Error( + `Model '${modelId}' not found for authType '${authType}'`, + ); + } + + // Apply model defaults + this.applyResolvedModelDefaults(model); + + // Update selection state + this.currentModelId = modelId; + + const requiresRefresh = isAuthTypeChange + ? true + : this.checkRequiresRefresh(snapshot.currentModelId); + + if (this.onModelChange) { + await this.onModelChange(authType, requiresRefresh); + } + } catch (error) { + // Rollback on error + this.restoreState(snapshot); + throw error; + } + } + + /** + * Get generation config for ContentGenerator creation + */ + getGenerationConfig(): Partial { + return this._generationConfig; + } + + /** + * Get generation config sources for debugging/UI + */ + getGenerationConfigSources(): ContentGeneratorConfigSources { + return this.generationConfigSources; + } + + /** + * Update credentials in generation config. + * Sets a flag to prevent syncAfterAuthRefresh from overriding these credentials. + * + * When credentials are manually set, we clear all provider-sourced configuration + * to maintain provider atomicity (either fully applied or not at all). + * Other layers (CLI, env, settings, defaults) will participate in resolve. + */ + updateCredentials(credentials: { + apiKey?: string; + baseUrl?: string; + model?: string; + }): void { + /** + * If any fields are updated here, we treat the resulting config as manually overridden + * and avoid applying modelProvider defaults during the next auth refresh. + * + * Clear all provider-sourced configuration to maintain provider atomicity. + * This ensures that when user manually sets credentials, the provider config + * is either fully applied (via switchModel) or not at all. + */ + if (credentials.apiKey || credentials.baseUrl || credentials.model) { + this.hasManualCredentials = true; + this.clearProviderSourcedConfig(); + } + + if (credentials.apiKey) { + this._generationConfig.apiKey = credentials.apiKey; + this.generationConfigSources['apiKey'] = { + kind: 'programmatic', + detail: 'updateCredentials', + }; + } + if (credentials.baseUrl) { + this._generationConfig.baseUrl = credentials.baseUrl; + this.generationConfigSources['baseUrl'] = { + kind: 'programmatic', + detail: 'updateCredentials', + }; + } + if (credentials.model) { + this._generationConfig.model = credentials.model; + this.currentModelId = credentials.model; + this.generationConfigSources['model'] = { + kind: 'programmatic', + detail: 'updateCredentials', + }; + } + // When credentials are manually set, disable strict model provider selection + // so validation doesn't require envKey-based credentials + this.strictModelProviderSelection = false; + // Clear apiKeyEnvKey to prevent validation from requiring environment variable + this._generationConfig.apiKeyEnvKey = undefined; + } + + /** + * Clear configuration fields that were sourced from modelProviders. + * This ensures provider config atomicity when user manually sets credentials. + * Other layers (CLI, env, settings, defaults) will participate in resolve. + */ + private clearProviderSourcedConfig(): void { + for (const field of PROVIDER_SOURCED_FIELDS) { + const source = this.generationConfigSources[field]; + if (source?.kind === 'modelProviders') { + // Clear the value - let other layers resolve it + delete (this._generationConfig as Record)[field]; + delete this.generationConfigSources[field]; + } + } + } + + /** + * Get whether strict model provider selection is enabled + */ + isStrictModelProviderSelection(): boolean { + return this.strictModelProviderSelection; + } + + /** + * Reset strict model provider selection flag + */ + resetStrictModelProviderSelection(): void { + this.strictModelProviderSelection = false; + } + + /** + * Check and consume the one-shot cached credentials flag + */ + consumeRequireCachedCredentialsFlag(): boolean { + const value = this.requireCachedQwenCredentialsOnce; + this.requireCachedQwenCredentialsOnce = false; + return value; + } + + /** + * Apply resolved model config to generation config + */ + private applyResolvedModelDefaults(model: ResolvedModelConfig): void { + this.strictModelProviderSelection = true; + const previousApiKey = this._generationConfig.apiKey; + const previousApiKeyEnvKey = this._generationConfig.apiKeyEnvKey; + const hadManualCredentials = this.hasManualCredentials; + // We're explicitly applying modelProvider defaults now, so manual overrides + // should no longer block syncAfterAuthRefresh from applying provider defaults. + this.hasManualCredentials = false; + + this._generationConfig.model = model.id; + this.generationConfigSources['model'] = { + kind: 'modelProviders', + authType: model.authType, + modelId: model.id, + detail: 'model.id', + }; + + // Clear credentials to avoid reusing previous model's API key + + // For Qwen OAuth, apiKey must always be a placeholder. It will be dynamically + // replaced when building requests. Do not preserve any previous key or read + // from envKey. + // + // (OpenAI client instantiation requires an apiKey even though it will be + // replaced later.) + if (this.currentAuthType === AuthType.QWEN_OAUTH) { + this._generationConfig.apiKey = 'QWEN_OAUTH_DYNAMIC_TOKEN'; + this.generationConfigSources['apiKey'] = { + kind: 'computed', + detail: 'Qwen OAuth placeholder token', + }; + this._generationConfig.apiKeyEnvKey = undefined; + delete this.generationConfigSources['apiKeyEnvKey']; + } else { + this._generationConfig.apiKey = undefined; + this._generationConfig.apiKeyEnvKey = undefined; + } + + // Read API key from environment variable if envKey is specified + if (model.envKey !== undefined) { + const apiKey = process.env[model.envKey]; + if (apiKey) { + this._generationConfig.apiKey = apiKey; + this.generationConfigSources['apiKey'] = { + kind: 'env', + envKey: model.envKey, + via: { + kind: 'modelProviders', + authType: model.authType, + modelId: model.id, + detail: 'envKey', + }, + }; + } else { + // If the user provided an API key via CLI/settings/updateCredentials, keep it. + // We only refuse to reuse a previous key when it is explicitly tied to a + // different envKey (e.g. switching between two configured accounts). + const canPreservePreviousKey = + !!previousApiKey && + (hadManualCredentials || + previousApiKeyEnvKey === undefined || + previousApiKeyEnvKey === model.envKey); + + if (canPreservePreviousKey) { + this._generationConfig.apiKey = previousApiKey; + this.generationConfigSources['apiKey'] = { + kind: 'computed', + detail: `preserved previous apiKey (missing env: ${model.envKey})`, + }; + } else { + console.warn( + `[ModelsConfig] Environment variable '${model.envKey}' is not set for model '${model.id}'. ` + + `API key will not be available.`, + ); + } + } + this._generationConfig.apiKeyEnvKey = model.envKey; + this.generationConfigSources['apiKeyEnvKey'] = { + kind: 'modelProviders', + authType: model.authType, + modelId: model.id, + detail: 'envKey', + }; + } + + // Base URL + this._generationConfig.baseUrl = model.baseUrl; + this.generationConfigSources['baseUrl'] = { + kind: 'modelProviders', + authType: model.authType, + modelId: model.id, + detail: 'baseUrl', + }; + + // Generation config + const gc = model.generationConfig; + this._generationConfig.samplingParams = { ...(gc.samplingParams || {}) }; + this.generationConfigSources['samplingParams'] = { + kind: 'modelProviders', + authType: model.authType, + modelId: model.id, + detail: 'generationConfig.samplingParams', + }; + + this._generationConfig.timeout = gc.timeout; + this.generationConfigSources['timeout'] = { + kind: 'modelProviders', + authType: model.authType, + modelId: model.id, + detail: 'generationConfig.timeout', + }; + + this._generationConfig.maxRetries = gc.maxRetries; + this.generationConfigSources['maxRetries'] = { + kind: 'modelProviders', + authType: model.authType, + modelId: model.id, + detail: 'generationConfig.maxRetries', + }; + + this._generationConfig.disableCacheControl = gc.disableCacheControl; + this.generationConfigSources['disableCacheControl'] = { + kind: 'modelProviders', + authType: model.authType, + modelId: model.id, + detail: 'generationConfig.disableCacheControl', + }; + + this._generationConfig.schemaCompliance = gc.schemaCompliance; + this.generationConfigSources['schemaCompliance'] = { + kind: 'modelProviders', + authType: model.authType, + modelId: model.id, + detail: 'generationConfig.schemaCompliance', + }; + + this._generationConfig.reasoning = gc.reasoning; + this.generationConfigSources['reasoning'] = { + kind: 'modelProviders', + authType: model.authType, + modelId: model.id, + detail: 'generationConfig.reasoning', + }; + } + + /** + * Check if model switch requires ContentGenerator refresh. + * + * Note: This method is ONLY called by switchModel() for same-authType model switches. + * Cross-authType switches use switchModel(authType, modelId), which always requires full refresh. + * + * When this method is called: + * - this.currentAuthType is already the target authType + * - We're checking if switching between two models within the SAME authType needs refresh + * + * Examples: + * - Qwen OAuth: coder-model -> vision-model (same authType, hot-update safe) + * - OpenAI: model-a -> model-b with same envKey (same authType, hot-update safe) + * - OpenAI: gpt-4 -> deepseek-chat with different envKey (same authType, needs refresh) + * + * Cross-authType scenarios: + * - OpenAI -> Qwen OAuth: handled by switchModel(authType, modelId), always refreshes + * - Qwen OAuth -> OpenAI: handled by switchModel(authType, modelId), always refreshes + */ + private checkRequiresRefresh(previousModelId: string): boolean { + // For Qwen OAuth, model switches within the same authType can always be hot-updated + // (coder-model <-> vision-model don't require ContentGenerator recreation) + if (this.currentAuthType === AuthType.QWEN_OAUTH) { + return false; + } + + // Get previous and current model configs + const previousModel = this.modelRegistry.getModel( + this.currentAuthType, + previousModelId, + ); + const currentModel = this.modelRegistry.getModel( + this.currentAuthType, + this.currentModelId, + ); + + // If either model is not in registry, require refresh to be safe + if (!previousModel || !currentModel) { + return true; + } + + // Check if critical fields changed that require ContentGenerator recreation + const criticalFieldsChanged = + previousModel.envKey !== currentModel.envKey || + previousModel.baseUrl !== currentModel.baseUrl; + + if (criticalFieldsChanged) { + return true; + } + + // For other auth types with strict model provider selection, + // if no critical fields changed, we can still hot-update + // (e.g., switching between two OpenAI models with same envKey and baseUrl) + return false; + } + + /** + * Called by Config.refreshAuth to sync state after auth refresh. + * + * IMPORTANT: If credentials were manually set via updateCredentials(), + * we should NOT override them with modelProvider defaults. + * This handles the case where user inputs credentials via OpenAIKeyPrompt + * after removing environment variables for a previously selected model. + */ + syncAfterAuthRefresh(authType: AuthType, modelId?: string): void { + // Check if we have manually set credentials that should be preserved + const preserveManualCredentials = this.hasManualCredentials; + + // If credentials were manually set, don't apply modelProvider defaults + // Just update the authType and preserve the manually set credentials + if (preserveManualCredentials) { + this.strictModelProviderSelection = false; + this.currentAuthType = authType; + if (modelId) { + this.currentModelId = modelId; + } + return; + } + + this.strictModelProviderSelection = false; + + if (modelId && this.modelRegistry.hasModel(authType, modelId)) { + const resolved = this.modelRegistry.getModel(authType, modelId); + if (resolved) { + this.applyResolvedModelDefaults(resolved); + this.currentAuthType = authType; + this.currentModelId = modelId; + } + } else { + this.currentAuthType = authType; + } + } + + /** + * Update callback for model changes + */ + setOnModelChange(callback: OnModelChangeCallback): void { + this.onModelChange = callback; + } +} diff --git a/packages/core/src/models/types.ts b/packages/core/src/models/types.ts new file mode 100644 index 000000000..b5ce56efa --- /dev/null +++ b/packages/core/src/models/types.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + AuthType, + ContentGeneratorConfig, +} from '../core/contentGenerator.js'; + +/** + * Model capabilities configuration + */ +export interface ModelCapabilities { + /** Supports image/vision inputs */ + vision?: boolean; +} + +/** + * Model-scoped generation configuration. + * + * Keep this consistent with {@link ContentGeneratorConfig} so modelProviders can + * feed directly into content generator resolution without shape conversion. + */ +export type ModelGenerationConfig = Pick< + ContentGeneratorConfig, + | 'samplingParams' + | 'timeout' + | 'maxRetries' + | 'disableCacheControl' + | 'schemaCompliance' + | 'reasoning' +>; + +/** + * Model configuration for a single model within an authType + */ +export interface ModelConfig { + /** Unique model ID within authType (e.g., "qwen-coder", "gpt-4-turbo") */ + id: string; + /** Display name (defaults to id) */ + name?: string; + /** Model description */ + description?: string; + /** Environment variable name to read API key from (e.g., "OPENAI_API_KEY") */ + envKey?: string; + /** API endpoint override */ + baseUrl?: string; + /** Model capabilities, reserve for future use. Now we do not read this to determine multi-modal support or other capabilities. */ + capabilities?: ModelCapabilities; + /** Generation configuration (sampling parameters) */ + generationConfig?: ModelGenerationConfig; +} + +/** + * Model providers configuration grouped by authType + */ +export type ModelProvidersConfig = { + [authType: string]: ModelConfig[]; +}; + +/** + * Resolved model config with all defaults applied + */ +export interface ResolvedModelConfig extends ModelConfig { + /** AuthType this model belongs to (always present from map key) */ + authType: AuthType; + /** Display name (always present, defaults to id) */ + name: string; + /** Environment variable name to read API key from (optional, provider-specific) */ + envKey?: string; + /** API base URL (always present, has default per authType) */ + baseUrl: string; + /** Generation config (always present, merged with defaults) */ + generationConfig: ModelGenerationConfig; + /** Capabilities (always present, defaults to {}) */ + capabilities: ModelCapabilities; +} + +/** + * Model info for UI display + */ +export interface AvailableModel { + id: string; + label: string; + description?: string; + capabilities?: ModelCapabilities; + authType: AuthType; + isVision?: boolean; +} + +/** + * Metadata for model switch operations + */ +export interface ModelSwitchMetadata { + /** Reason for the switch */ + reason?: string; + /** Additional context */ + context?: string; +} diff --git a/packages/core/src/subagents/subagent.test.ts b/packages/core/src/subagents/subagent.test.ts index 742813cdb..a23e787ef 100644 --- a/packages/core/src/subagents/subagent.test.ts +++ b/packages/core/src/subagents/subagent.test.ts @@ -22,10 +22,11 @@ import { type Mock, } from 'vitest'; import { Config, type ConfigParameters } from '../config/config.js'; -import { DEFAULT_GEMINI_MODEL } from '../config/models.js'; +import { DEFAULT_QWEN_MODEL } from '../config/models.js'; import { createContentGenerator, createContentGeneratorConfig, + resolveContentGeneratorConfigWithSources, AuthType, } from '../core/contentGenerator.js'; import { GeminiChat } from '../core/geminiChat.js'; @@ -42,7 +43,33 @@ import type { import { SubagentTerminateMode } from './types.js'; vi.mock('../core/geminiChat.js'); -vi.mock('../core/contentGenerator.js'); +vi.mock('../core/contentGenerator.js', async (importOriginal) => { + const actual = + await importOriginal(); + const { DEFAULT_QWEN_MODEL } = await import('../config/models.js'); + return { + ...actual, + createContentGenerator: vi.fn().mockResolvedValue({ + generateContent: vi.fn(), + generateContentStream: vi.fn(), + countTokens: vi.fn().mockResolvedValue({ totalTokens: 100 }), + embedContent: vi.fn(), + useSummarizedThinking: vi.fn().mockReturnValue(false), + }), + createContentGeneratorConfig: vi.fn().mockReturnValue({ + model: DEFAULT_QWEN_MODEL, + authType: actual.AuthType.USE_GEMINI, + }), + resolveContentGeneratorConfigWithSources: vi.fn().mockReturnValue({ + config: { + model: DEFAULT_QWEN_MODEL, + authType: actual.AuthType.USE_GEMINI, + apiKey: 'test-api-key', + }, + sources: {}, + }), + }; +}); vi.mock('../utils/environmentContext.js', () => ({ getEnvironmentContext: vi.fn().mockResolvedValue([{ text: 'Env Context' }]), getInitialChatHistory: vi.fn(async (_config, extraHistory) => [ @@ -65,7 +92,7 @@ async function createMockConfig( toolRegistryMocks = {}, ): Promise<{ config: Config; toolRegistry: ToolRegistry }> { const configParams: ConfigParameters = { - model: DEFAULT_GEMINI_MODEL, + model: DEFAULT_QWEN_MODEL, targetDir: '.', debugMode: false, cwd: process.cwd(), @@ -89,7 +116,7 @@ async function createMockConfig( // Mock getContentGeneratorConfig to return a valid config vi.spyOn(config, 'getContentGeneratorConfig').mockReturnValue({ - model: DEFAULT_GEMINI_MODEL, + model: DEFAULT_QWEN_MODEL, authType: AuthType.USE_GEMINI, }); @@ -192,9 +219,17 @@ describe('subagent.ts', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); vi.mocked(createContentGeneratorConfig).mockReturnValue({ - model: DEFAULT_GEMINI_MODEL, + model: DEFAULT_QWEN_MODEL, authType: undefined, }); + vi.mocked(resolveContentGeneratorConfigWithSources).mockReturnValue({ + config: { + model: DEFAULT_QWEN_MODEL, + authType: AuthType.USE_GEMINI, + apiKey: 'test-api-key', + }, + sources: {}, + }); mockSendMessageStream = vi.fn(); vi.mocked(GeminiChat).mockImplementation( diff --git a/packages/core/src/utils/configResolver.test.ts b/packages/core/src/utils/configResolver.test.ts new file mode 100644 index 000000000..ee992cd67 --- /dev/null +++ b/packages/core/src/utils/configResolver.test.ts @@ -0,0 +1,141 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + resolveField, + resolveOptionalField, + layer, + envLayer, + cliSource, + settingsSource, + defaultSource, +} from './configResolver.js'; + +describe('configResolver', () => { + describe('resolveField', () => { + it('returns first present value from layers', () => { + const result = resolveField( + [ + layer(undefined, cliSource('--model')), + envLayer({ MODEL: 'from-env' }, 'MODEL'), + layer('from-settings', settingsSource('model.name')), + ], + 'default-model', + ); + + expect(result.value).toBe('from-env'); + expect(result.source).toEqual({ kind: 'env', envKey: 'MODEL' }); + }); + + it('returns default when all layers are undefined', () => { + const result = resolveField( + [layer(undefined, cliSource('--model')), envLayer({}, 'MODEL')], + 'default-model', + defaultSource('default-model'), + ); + + expect(result.value).toBe('default-model'); + expect(result.source).toEqual({ + kind: 'default', + detail: 'default-model', + }); + }); + + it('respects layer priority order', () => { + const result = resolveField( + [ + layer('cli-value', cliSource('--model')), + envLayer({ MODEL: 'env-value' }, 'MODEL'), + layer('settings-value', settingsSource('model.name')), + ], + 'default', + ); + + expect(result.value).toBe('cli-value'); + expect(result.source.kind).toBe('cli'); + }); + + it('skips empty strings', () => { + const result = resolveField( + [ + layer('', cliSource('--model')), + envLayer({ MODEL: 'env-value' }, 'MODEL'), + ], + 'default', + ); + + expect(result.value).toBe('env-value'); + }); + }); + + describe('resolveOptionalField', () => { + it('returns undefined when no value present', () => { + const result = resolveOptionalField([ + layer(undefined, cliSource('--key')), + envLayer({}, 'KEY'), + ]); + + expect(result).toBeUndefined(); + }); + + it('returns first present value', () => { + const result = resolveOptionalField([ + layer(undefined, cliSource('--key')), + envLayer({ KEY: 'found' }, 'KEY'), + ]); + + expect(result).toBeDefined(); + expect(result!.value).toBe('found'); + expect(result!.source.kind).toBe('env'); + }); + }); + + describe('envLayer', () => { + it('creates layer from environment variable', () => { + const env = { MY_VAR: 'my-value' }; + const result = envLayer(env, 'MY_VAR'); + + expect(result.value).toBe('my-value'); + expect(result.source).toEqual({ kind: 'env', envKey: 'MY_VAR' }); + }); + + it('handles missing environment variable', () => { + const env = {}; + const result = envLayer(env, 'MISSING_VAR'); + + expect(result.value).toBeUndefined(); + expect(result.source).toEqual({ kind: 'env', envKey: 'MISSING_VAR' }); + }); + + it('supports transform function', () => { + const env = { PORT: '3000' }; + const result = envLayer(env, 'PORT', (v) => parseInt(v, 10)); + + expect(result.value).toBe(3000); + }); + }); + + describe('source factory functions', () => { + it('creates CLI source', () => { + expect(cliSource('--model')).toEqual({ kind: 'cli', detail: '--model' }); + }); + + it('creates settings source', () => { + expect(settingsSource('model.name')).toEqual({ + kind: 'settings', + settingsPath: 'model.name', + }); + }); + + it('creates default source', () => { + expect(defaultSource('my-default')).toEqual({ + kind: 'default', + detail: 'my-default', + }); + }); + }); +}); diff --git a/packages/core/src/utils/configResolver.ts b/packages/core/src/utils/configResolver.ts new file mode 100644 index 000000000..209052f5a --- /dev/null +++ b/packages/core/src/utils/configResolver.ts @@ -0,0 +1,222 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Generic multi-source configuration resolver utilities. + * + * This module provides reusable tools for resolving configuration values + * from multiple sources (CLI, env, settings, etc.) with priority ordering + * and source tracking. + */ + +/** + * Known source kinds for configuration values. + * Extensible for domain-specific needs. + */ +export type ConfigSourceKind = + | 'cli' + | 'env' + | 'settings' + | 'modelProviders' + | 'default' + | 'computed' + | 'programmatic' + | 'unknown'; + +/** + * Source metadata for a configuration value. + * Tracks where the value came from for debugging and UI display. + */ +export interface ConfigSource { + /** The kind/category of the source */ + kind: ConfigSourceKind; + /** Additional detail about the source (e.g., '--model' for CLI) */ + detail?: string; + /** Environment variable key if kind is 'env' */ + envKey?: string; + /** Settings path if kind is 'settings' (e.g., 'model.name') */ + settingsPath?: string; + /** Auth type if relevant (for modelProviders) */ + authType?: string; + /** Model ID if relevant (for modelProviders) */ + modelId?: string; + /** Indirect source - when a value is derived via another source */ + via?: Omit; +} + +/** + * Map of field names to their sources + */ +export type ConfigSources = Record; + +/** + * A configuration layer represents a potential source for a value. + * Layers are evaluated in priority order (first non-undefined wins). + */ +export interface ConfigLayer { + /** The value from this layer (undefined means not present) */ + value: T | undefined; + /** Source metadata for this layer */ + source: ConfigSource; +} + +/** + * Result of resolving a single field + */ +export interface ResolvedField { + /** The resolved value */ + value: T; + /** Source metadata indicating where the value came from */ + source: ConfigSource; +} + +/** + * Resolve a single configuration field from multiple layers. + * + * Layers are evaluated in order. The first layer with a defined, + * non-empty value wins. If no layer has a value, the default is used. + * + * @param layers - Configuration layers in priority order (highest first) + * @param defaultValue - Default value if no layer provides one + * @param defaultSource - Source metadata for the default value + * @returns The resolved value and its source + * + * @example + * ```typescript + * const model = resolveField( + * [ + * { value: argv.model, source: { kind: 'cli', detail: '--model' } }, + * { value: env['OPENAI_MODEL'], source: { kind: 'env', envKey: 'OPENAI_MODEL' } }, + * { value: settings.model, source: { kind: 'settings', settingsPath: 'model.name' } }, + * ], + * 'default-model', + * { kind: 'default', detail: 'default-model' } + * ); + * ``` + */ +export function resolveField( + layers: Array>, + defaultValue: T, + defaultSource: ConfigSource = { kind: 'default' }, +): ResolvedField { + for (const layer of layers) { + if (isValuePresent(layer.value)) { + return { value: layer.value, source: layer.source }; + } + } + return { value: defaultValue, source: defaultSource }; +} + +/** + * Resolve a field that may not have a default (optional field). + * + * @param layers - Configuration layers in priority order + * @returns The resolved value and source, or undefined if not found + */ +export function resolveOptionalField( + layers: Array>, +): ResolvedField | undefined { + for (const layer of layers) { + if (isValuePresent(layer.value)) { + return { value: layer.value, source: layer.source }; + } + } + return undefined; +} + +/** + * Check if a value is "present" (not undefined, not null, not empty string). + * + * @param value - The value to check + * @returns true if the value should be considered present + */ +function isValuePresent(value: T | undefined | null): value is T { + if (value === undefined || value === null) { + return false; + } + // Treat empty strings as not present + if (typeof value === 'string' && value.trim() === '') { + return false; + } + return true; +} + +/** + * Create a CLI source descriptor + */ +export function cliSource(detail: string): ConfigSource { + return { kind: 'cli', detail }; +} + +/** + * Create an environment variable source descriptor + */ +function envSource(envKey: string): ConfigSource { + return { kind: 'env', envKey }; +} + +/** + * Create a settings source descriptor + */ +export function settingsSource(settingsPath: string): ConfigSource { + return { kind: 'settings', settingsPath }; +} + +/** + * Create a modelProviders source descriptor + */ +export function modelProvidersSource( + authType: string, + modelId: string, + detail?: string, +): ConfigSource { + return { kind: 'modelProviders', authType, modelId, detail }; +} + +/** + * Create a default value source descriptor + */ +export function defaultSource(detail?: string): ConfigSource { + return { kind: 'default', detail }; +} + +/** + * Create a computed value source descriptor + */ +export function computedSource(detail?: string): ConfigSource { + return { kind: 'computed', detail }; +} + +/** + * Create a layer from an environment variable + */ +export function envLayer( + env: Record, + key: string, + transform?: (value: string) => T, +): ConfigLayer { + const rawValue = env[key]; + const value = + rawValue !== undefined + ? transform + ? transform(rawValue) + : (rawValue as unknown as T) + : undefined; + return { + value, + source: envSource(key), + }; +} + +/** + * Create a layer with a static value and source + */ +export function layer( + value: T | undefined, + source: ConfigSource, +): ConfigLayer { + return { value, source }; +} diff --git a/packages/core/src/utils/flashFallback.test.ts b/packages/core/src/utils/flashFallback.test.ts deleted file mode 100644 index 184cb2037..000000000 --- a/packages/core/src/utils/flashFallback.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { Config } from '../config/config.js'; -import fs from 'node:fs'; -import { - setSimulate429, - disableSimulationAfterFallback, - shouldSimulate429, - resetRequestCounter, -} from './testUtils.js'; -import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js'; -// Import the new types (Assuming this test file is in packages/core/src/utils/) -import type { FallbackModelHandler } from '../fallback/types.js'; - -vi.mock('node:fs'); - -// Update the description to reflect that this tests the retry utility's integration -describe('Retry Utility Fallback Integration', () => { - let config: Config; - - beforeEach(() => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ - isDirectory: () => true, - } as fs.Stats); - config = new Config({ - targetDir: '/test', - debugMode: false, - cwd: '/test', - model: 'gemini-2.5-pro', - }); - - // Reset simulation state for each test - setSimulate429(false); - resetRequestCounter(); - }); - - // This test validates the Config's ability to store and execute the handler contract. - it('should execute the injected FallbackHandler contract correctly', async () => { - // Set up a minimal handler for testing, ensuring it matches the new type. - const fallbackHandler: FallbackModelHandler = async () => 'retry'; - - // Use the generalized setter - config.setFallbackModelHandler(fallbackHandler); - - // Call the handler directly via the config property - const result = await config.fallbackModelHandler!( - 'gemini-2.5-pro', - DEFAULT_GEMINI_FLASH_MODEL, - ); - - // Verify it returns the correct intent - expect(result).toBe('retry'); - }); - - // This test validates the test utilities themselves. - it('should properly disable simulation state after fallback (Test Utility)', () => { - // Enable simulation - setSimulate429(true); - - // Verify simulation is enabled - expect(shouldSimulate429()).toBe(true); - - // Disable simulation after fallback - disableSimulationAfterFallback(); - - // Verify simulation is now disabled - expect(shouldSimulate429()).toBe(false); - }); -}); diff --git a/packages/core/src/utils/llm-edit-fixer.ts b/packages/core/src/utils/llm-edit-fixer.ts index 467fdbdb9..bc81c8e62 100644 --- a/packages/core/src/utils/llm-edit-fixer.ts +++ b/packages/core/src/utils/llm-edit-fixer.ts @@ -8,7 +8,7 @@ import { createHash } from 'node:crypto'; import { type Content, Type } from '@google/genai'; import { type BaseLlmClient } from '../core/baseLlmClient.js'; import { LruCache } from './LruCache.js'; -import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js'; +import { DEFAULT_QWEN_FLASH_MODEL } from '../config/models.js'; import { promptIdContext } from './promptIdContext.js'; const MAX_CACHE_SIZE = 50; @@ -149,7 +149,7 @@ export async function FixLLMEditWithInstruction( contents, schema: SearchReplaceEditSchema, abortSignal, - model: DEFAULT_GEMINI_FLASH_MODEL, + model: DEFAULT_QWEN_FLASH_MODEL, systemInstruction: EDIT_SYS_PROMPT, promptId, maxAttempts: 1, diff --git a/packages/core/src/utils/summarizer.ts b/packages/core/src/utils/summarizer.ts index 14076b5c2..c5290cfa2 100644 --- a/packages/core/src/utils/summarizer.ts +++ b/packages/core/src/utils/summarizer.ts @@ -11,7 +11,7 @@ import type { GenerateContentResponse, } from '@google/genai'; import type { GeminiClient } from '../core/client.js'; -import { DEFAULT_GEMINI_FLASH_LITE_MODEL } from '../config/models.js'; +import { DEFAULT_QWEN_FLASH_MODEL } from '../config/models.js'; import { getResponseText, partToString } from './partUtils.js'; /** @@ -86,7 +86,7 @@ export async function summarizeToolOutput( contents, toolOutputSummarizerConfig, abortSignal, - DEFAULT_GEMINI_FLASH_LITE_MODEL, + DEFAULT_QWEN_FLASH_MODEL, )) as unknown as GenerateContentResponse; return getResponseText(parsedResponse) || textToSummarize; } catch (error) { From 90855c93d1734e320f843a73c7390629b6bb2b7a Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Tue, 6 Jan 2026 15:55:02 +0800 Subject: [PATCH 085/142] fix: lint & ci issues --- docs/users/overview.md | 3 +- .../src/config/modelProvidersScope.test.ts | 4 +- packages/cli/src/gemini.test.tsx | 9 ++ packages/cli/src/i18n/locales/de.js | 84 ++++++++++++------- .../src/ui/components/ModelDialog.test.tsx | 8 +- .../core/src/models/modelRegistry.test.ts | 4 +- .../messages/toolcalls/Execute/Execute.css | 6 +- 7 files changed, 78 insertions(+), 40 deletions(-) diff --git a/docs/users/overview.md b/docs/users/overview.md index b244cd4ce..3b45cc2f0 100644 --- a/docs/users/overview.md +++ b/docs/users/overview.md @@ -1,5 +1,6 @@ # Qwen Code overview -[![@qwen-code/qwen-code downloads](https://img.shields.io/npm/dw/@qwen-code/qwen-code.svg)](https://npm-compare.com/@qwen-code/qwen-code) + +[![@qwen-code/qwen-code downloads](https://img.shields.io/npm/dw/@qwen-code/qwen-code.svg)](https://npm-compare.com/@qwen-code/qwen-code) [![@qwen-code/qwen-code version](https://img.shields.io/npm/v/@qwen-code/qwen-code.svg)](https://www.npmjs.com/package/@qwen-code/qwen-code) > Learn about Qwen Code, Qwen's agentic coding tool that lives in your terminal and helps you turn ideas into code faster than ever before. diff --git a/packages/cli/src/config/modelProvidersScope.test.ts b/packages/cli/src/config/modelProvidersScope.test.ts index 2b270d6be..9d7a436e2 100644 --- a/packages/cli/src/config/modelProvidersScope.test.ts +++ b/packages/cli/src/config/modelProvidersScope.test.ts @@ -75,9 +75,7 @@ describe('getPersistScopeForModelSelection', () => { userModelProviders: undefined, workspaceModelProviders: undefined, }); - expect(getPersistScopeForModelSelection(trusted)).toBe( - SettingScope.Workspace, - ); + expect(getPersistScopeForModelSelection(trusted)).toBe(SettingScope.User); const untrusted = makeSettings({ isTrusted: false, diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 9e0137fb7..09dcd013d 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -87,6 +87,15 @@ vi.mock('./config/sandboxConfig.js', () => ({ loadSandboxConfig: vi.fn(), })); +vi.mock('./core/initializer.js', () => ({ + initializeApp: vi.fn().mockResolvedValue({ + authError: null, + themeError: null, + shouldOpenAuthDialog: false, + geminiMdFileCount: 0, + }), +})); + describe('gemini.tsx main function', () => { let originalEnvGeminiSandbox: string | undefined; let originalEnvSandbox: string | undefined; diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 832dd1333..71624b797 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -45,7 +45,8 @@ export default { 'Initializing...': 'Initialisierung...', 'Connecting to MCP servers... ({{connected}}/{{total}})': 'Verbindung zu MCP-Servern wird hergestellt... ({{connected}}/{{total}})', - 'Type your message or @path/to/file': 'Nachricht eingeben oder @Pfad/zur/Datei', + 'Type your message or @path/to/file': + 'Nachricht eingeben oder @Pfad/zur/Datei', "Press 'i' for INSERT mode and 'Esc' for NORMAL mode.": "Drücken Sie 'i' für den EINFÜGE-Modus und 'Esc' für den NORMAL-Modus.", 'Cancel operation / Clear input (double press)': @@ -89,7 +90,8 @@ export default { 'No tools available': 'Keine Werkzeuge verfügbar', 'View or change the approval mode for tool usage': 'Genehmigungsmodus für Werkzeugnutzung anzeigen oder ändern', - 'View or change the language setting': 'Spracheinstellung anzeigen oder ändern', + 'View or change the language setting': + 'Spracheinstellung anzeigen oder ändern', 'change the theme': 'Design ändern', 'Select Theme': 'Design auswählen', Preview: 'Vorschau', @@ -213,14 +215,16 @@ export default { 'All Tools': 'Alle Werkzeuge', 'Read-only Tools': 'Nur-Lese-Werkzeuge', 'Read & Edit Tools': 'Lese- und Bearbeitungswerkzeuge', - 'Read & Edit & Execution Tools': 'Lese-, Bearbeitungs- und Ausführungswerkzeuge', + 'Read & Edit & Execution Tools': + 'Lese-, Bearbeitungs- und Ausführungswerkzeuge', 'All tools selected, including MCP tools': 'Alle Werkzeuge ausgewählt, einschließlich MCP-Werkzeuge', 'Selected tools:': 'Ausgewählte Werkzeuge:', 'Read-only tools:': 'Nur-Lese-Werkzeuge:', 'Edit tools:': 'Bearbeitungswerkzeuge:', 'Execution tools:': 'Ausführungswerkzeuge:', - 'Step {{n}}: Choose Background Color': 'Schritt {{n}}: Hintergrundfarbe wählen', + 'Step {{n}}: Choose Background Color': + 'Schritt {{n}}: Hintergrundfarbe wählen', 'Step {{n}}: Confirm and Save': 'Schritt {{n}}: Bestätigen und Speichern', // Agents - Navigation & Instructions 'Esc to cancel': 'Esc zum Abbrechen', @@ -245,14 +249,16 @@ export default { 'e.g., Reviews code for best practices and potential bugs.': 'z.B. Überprüft Code auf Best Practices und mögliche Fehler.', 'Description cannot be empty.': 'Beschreibung darf nicht leer sein.', - 'Failed to launch editor: {{error}}': 'Fehler beim Starten des Editors: {{error}}', + 'Failed to launch editor: {{error}}': + 'Fehler beim Starten des Editors: {{error}}', 'Failed to save and edit subagent: {{error}}': 'Fehler beim Speichern und Bearbeiten des Unteragenten: {{error}}', // ============================================================================ // Commands - General (continued) // ============================================================================ - 'View and edit Qwen Code settings': 'Qwen Code Einstellungen anzeigen und bearbeiten', + 'View and edit Qwen Code settings': + 'Qwen Code Einstellungen anzeigen und bearbeiten', Settings: 'Einstellungen', '(Use Enter to select{{tabText}})': '(Enter zum Auswählen{{tabText}})', ', Tab to change focus': ', Tab zum Fokuswechsel', @@ -308,7 +314,8 @@ export default { 'Use Ripgrep': 'Ripgrep verwenden', 'Use Builtin Ripgrep': 'Integriertes Ripgrep verwenden', 'Enable Tool Output Truncation': 'Werkzeugausgabe-Kürzung aktivieren', - 'Tool Output Truncation Threshold': 'Schwellenwert für Werkzeugausgabe-Kürzung', + 'Tool Output Truncation Threshold': + 'Schwellenwert für Werkzeugausgabe-Kürzung', 'Tool Output Truncation Lines': 'Zeilen für Werkzeugausgabe-Kürzung', 'Folder Trust': 'Ordnervertrauen', 'Vision Model Preview': 'Vision-Modell-Vorschau', @@ -364,7 +371,8 @@ export default { 'Failed to parse {{terminalName}} keybindings.json. The file contains invalid JSON. Please fix the file manually or delete it to allow automatic configuration.': 'Fehler beim Parsen von {{terminalName}} keybindings.json. Die Datei enthält ungültiges JSON. Bitte korrigieren Sie die Datei manuell oder löschen Sie sie, um automatische Konfiguration zu ermöglichen.', 'Error: {{error}}': 'Fehler: {{error}}', - 'Shift+Enter binding already exists': 'Umschalt+Enter-Belegung existiert bereits', + 'Shift+Enter binding already exists': + 'Umschalt+Enter-Belegung existiert bereits', 'Ctrl+Enter binding already exists': 'Strg+Enter-Belegung existiert bereits', 'Existing keybindings detected. Will not modify to avoid conflicts.': 'Bestehende Tastenbelegungen erkannt. Keine Änderungen, um Konflikte zu vermeiden.', @@ -398,7 +406,8 @@ export default { 'Set UI language': 'UI-Sprache festlegen', 'Set LLM output language': 'LLM-Ausgabesprache festlegen', 'Usage: /language ui [zh-CN|en-US]': 'Verwendung: /language ui [zh-CN|en-US]', - 'Usage: /language output ': 'Verwendung: /language output ', + 'Usage: /language output ': + 'Verwendung: /language output ', 'Example: /language output 中文': 'Beispiel: /language output Deutsch', 'Example: /language output English': 'Beispiel: /language output English', 'Example: /language output 日本語': 'Beispiel: /language output Japanisch', @@ -419,7 +428,8 @@ export default { ' - en-US: English': ' - en-US: Englisch', 'Set UI language to Simplified Chinese (zh-CN)': 'UI-Sprache auf Vereinfachtes Chinesisch (zh-CN) setzen', - 'Set UI language to English (en-US)': 'UI-Sprache auf Englisch (en-US) setzen', + 'Set UI language to English (en-US)': + 'UI-Sprache auf Englisch (en-US) setzen', // ============================================================================ // Commands - Approval Mode @@ -427,7 +437,8 @@ export default { 'Approval Mode': 'Genehmigungsmodus', 'Current approval mode: {{mode}}': 'Aktueller Genehmigungsmodus: {{mode}}', 'Available approval modes:': 'Verfügbare Genehmigungsmodi:', - 'Approval mode changed to: {{mode}}': 'Genehmigungsmodus geändert zu: {{mode}}', + 'Approval mode changed to: {{mode}}': + 'Genehmigungsmodus geändert zu: {{mode}}', 'Approval mode changed to: {{mode}} (saved to {{scope}} settings{{location}})': 'Genehmigungsmodus geändert zu: {{mode}} (gespeichert in {{scope}} Einstellungen{{location}})', 'Usage: /approval-mode [--session|--user|--project]': @@ -452,14 +463,16 @@ export default { 'Fehler beim Ändern des Genehmigungsmodus: {{error}}', 'Apply to current session only (temporary)': 'Nur auf aktuelle Sitzung anwenden (temporär)', - 'Persist for this project/workspace': 'Für dieses Projekt/Arbeitsbereich speichern', + 'Persist for this project/workspace': + 'Für dieses Projekt/Arbeitsbereich speichern', 'Persist for this user on this machine': 'Für diesen Benutzer auf diesem Computer speichern', 'Analyze only, do not modify files or execute commands': 'Nur analysieren, keine Dateien ändern oder Befehle ausführen', 'Require approval for file edits or shell commands': 'Genehmigung für Dateibearbeitungen oder Shell-Befehle erforderlich', - 'Automatically approve file edits': 'Dateibearbeitungen automatisch genehmigen', + 'Automatically approve file edits': + 'Dateibearbeitungen automatisch genehmigen', 'Automatically approve all tools': 'Alle Werkzeuge automatisch genehmigen', 'Workspace approval mode exists and takes priority. User-level change will have no effect.': 'Arbeitsbereich-Genehmigungsmodus existiert und hat Vorrang. Benutzerebene-Änderung hat keine Wirkung.', @@ -475,12 +488,14 @@ export default { 'Commands for interacting with memory.': 'Befehle für die Interaktion mit dem Speicher.', 'Show the current memory contents.': 'Aktuellen Speicherinhalt anzeigen.', - 'Show project-level memory contents.': 'Projektebene-Speicherinhalt anzeigen.', + 'Show project-level memory contents.': + 'Projektebene-Speicherinhalt anzeigen.', 'Show global memory contents.': 'Globalen Speicherinhalt anzeigen.', 'Add content to project-level memory.': 'Inhalt zum Projektebene-Speicher hinzufügen.', 'Add content to global memory.': 'Inhalt zum globalen Speicher hinzufügen.', - 'Refresh the memory from the source.': 'Speicher aus der Quelle aktualisieren.', + 'Refresh the memory from the source.': + 'Speicher aus der Quelle aktualisieren.', 'Usage: /memory add --project ': 'Verwendung: /memory add --project ', 'Usage: /memory add --global ': @@ -520,7 +535,8 @@ export default { 'Konfigurierte MCP-Server und Werkzeuge auflisten', 'Restarts MCP servers.': 'MCP-Server neu starten.', 'Config not loaded.': 'Konfiguration nicht geladen.', - 'Could not retrieve tool registry.': 'Werkzeugregister konnte nicht abgerufen werden.', + 'Could not retrieve tool registry.': + 'Werkzeugregister konnte nicht abgerufen werden.', 'No MCP servers configured with OAuth authentication.': 'Keine MCP-Server mit OAuth-Authentifizierung konfiguriert.', 'MCP servers with OAuth authentication:': @@ -539,7 +555,8 @@ export default { // Commands - Chat // ============================================================================ 'Manage conversation history.': 'Gesprächsverlauf verwalten.', - 'List saved conversation checkpoints': 'Gespeicherte Gesprächsprüfpunkte auflisten', + 'List saved conversation checkpoints': + 'Gespeicherte Gesprächsprüfpunkte auflisten', 'No saved conversation checkpoints found.': 'Keine gespeicherten Gesprächsprüfpunkte gefunden.', 'List of saved conversations:': 'Liste gespeicherter Gespräche:', @@ -589,7 +606,8 @@ export default { 'Kein Chat-Client verfügbar, um Zusammenfassung zu generieren.', 'Already generating summary, wait for previous request to complete': 'Zusammenfassung wird bereits generiert, warten Sie auf Abschluss der vorherigen Anfrage', - 'No conversation found to summarize.': 'Kein Gespräch zum Zusammenfassen gefunden.', + 'No conversation found to summarize.': + 'Kein Gespräch zum Zusammenfassen gefunden.', 'Failed to generate project context summary: {{error}}': 'Fehler beim Generieren der Projektkontextzusammenfassung: {{error}}', 'Saved project summary to {{filePathForDisplay}}.': @@ -605,7 +623,8 @@ export default { 'Switch the model for this session': 'Modell für diese Sitzung wechseln', 'Content generator configuration not available.': 'Inhaltsgenerator-Konfiguration nicht verfügbar.', - 'Authentication type not available.': 'Authentifizierungstyp nicht verfügbar.', + 'Authentication type not available.': + 'Authentifizierungstyp nicht verfügbar.', 'No models available for the current authentication type ({{authType}}).': 'Keine Modelle für den aktuellen Authentifizierungstyp ({{authType}}) verfügbar.', @@ -622,7 +641,8 @@ export default { // ============================================================================ 'Already compressing, wait for previous request to complete': 'Komprimierung läuft bereits, warten Sie auf Abschluss der vorherigen Anfrage', - 'Failed to compress chat history.': 'Fehler beim Komprimieren des Chatverlaufs.', + 'Failed to compress chat history.': + 'Fehler beim Komprimieren des Chatverlaufs.', 'Failed to compress chat history: {{error}}': 'Fehler beim Komprimieren des Chatverlaufs: {{error}}', 'Compressing chat history': 'Chatverlauf wird komprimiert', @@ -644,10 +664,12 @@ export default { 'Bitte geben Sie mindestens einen Pfad zum Hinzufügen an.', 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.': 'Der Befehl /directory add wird in restriktiven Sandbox-Profilen nicht unterstützt. Bitte verwenden Sie --include-directories beim Starten der Sitzung.', - "Error adding '{{path}}': {{error}}": "Fehler beim Hinzufügen von '{{path}}': {{error}}", + "Error adding '{{path}}': {{error}}": + "Fehler beim Hinzufügen von '{{path}}': {{error}}", 'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}': 'QWEN.md-Dateien aus folgenden Verzeichnissen erfolgreich hinzugefügt, falls vorhanden:\n- {{directories}}', - 'Error refreshing memory: {{error}}': 'Fehler beim Aktualisieren des Speichers: {{error}}', + 'Error refreshing memory: {{error}}': + 'Fehler beim Aktualisieren des Speichers: {{error}}', 'Successfully added directories:\n- {{directories}}': 'Verzeichnisse erfolgreich hinzugefügt:\n- {{directories}}', 'Current workspace directories:\n{{directories}}': @@ -677,7 +699,8 @@ export default { 'Yes, allow always': 'Ja, immer erlauben', 'Modify with external editor': 'Mit externem Editor bearbeiten', 'No, suggest changes (esc)': 'Nein, Änderungen vorschlagen (Esc)', - "Allow execution of: '{{command}}'?": "Ausführung erlauben von: '{{command}}'?", + "Allow execution of: '{{command}}'?": + "Ausführung erlauben von: '{{command}}'?", 'Yes, allow always ...': 'Ja, immer erlauben ...', 'Yes, and auto-accept edits': 'Ja, und Änderungen automatisch akzeptieren', 'Yes, and manually approve edits': 'Ja, und Änderungen manuell genehmigen', @@ -749,12 +772,14 @@ export default { 'Qwen OAuth authentication cancelled.': 'Qwen OAuth-Authentifizierung abgebrochen.', 'Qwen OAuth Authentication': 'Qwen OAuth-Authentifizierung', - 'Please visit this URL to authorize:': 'Bitte besuchen Sie diese URL zur Autorisierung:', + 'Please visit this URL to authorize:': + 'Bitte besuchen Sie diese URL zur Autorisierung:', 'Or scan the QR code below:': 'Oder scannen Sie den QR-Code unten:', 'Waiting for authorization': 'Warten auf Autorisierung', 'Time remaining:': 'Verbleibende Zeit:', '(Press ESC or CTRL+C to cancel)': '(ESC oder STRG+C zum Abbrechen drücken)', - 'Qwen OAuth Authentication Timeout': 'Qwen OAuth-Authentifizierung abgelaufen', + 'Qwen OAuth Authentication Timeout': + 'Qwen OAuth-Authentifizierung abgelaufen', 'OAuth token expired (over {{seconds}} seconds). Please select authentication method again.': 'OAuth-Token abgelaufen (über {{seconds}} Sekunden). Bitte wählen Sie erneut eine Authentifizierungsmethode.', 'Press any key to return to authentication type selection.': @@ -779,7 +804,8 @@ export default { 'API Key:': 'API-Schlüssel:', 'Invalid credentials: {{errorMessage}}': 'Ungültige Anmeldedaten: {{errorMessage}}', - 'Failed to validate credentials': 'Anmeldedaten konnten nicht validiert werden', + 'Failed to validate credentials': + 'Anmeldedaten konnten nicht validiert werden', 'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel': 'Enter zum Fortfahren, Tab/↑↓ zum Navigieren, Esc zum Abbrechen', @@ -877,8 +903,10 @@ export default { // ============================================================================ // Exit Screen / Stats // ============================================================================ - 'Agent powering down. Goodbye!': 'Agent wird heruntergefahren. Auf Wiedersehen!', - 'To continue this session, run': 'Um diese Sitzung fortzusetzen, führen Sie aus', + 'Agent powering down. Goodbye!': + 'Agent wird heruntergefahren. Auf Wiedersehen!', + 'To continue this session, run': + 'Um diese Sitzung fortzusetzen, führen Sie aus', 'Interaction Summary': 'Interaktionszusammenfassung', 'Session ID:': 'Sitzungs-ID:', 'Tool Calls:': 'Werkzeugaufrufe:', diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index ac47ba46a..98da6031f 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -182,12 +182,12 @@ describe('', () => { }, ); expect(mockSettings.setValue).toHaveBeenCalledWith( - SettingScope.Workspace, + SettingScope.User, 'model.name', MAINLINE_CODER, ); expect(mockSettings.setValue).toHaveBeenCalledWith( - SettingScope.Workspace, + SettingScope.User, 'security.auth.selectedType', AuthType.QWEN_OAUTH, ); @@ -242,12 +242,12 @@ describe('', () => { }, ); expect(mockSettings.setValue).toHaveBeenCalledWith( - SettingScope.Workspace, + SettingScope.User, 'model.name', MAINLINE_CODER, ); expect(mockSettings.setValue).toHaveBeenCalledWith( - SettingScope.Workspace, + SettingScope.User, 'security.auth.selectedType', AuthType.QWEN_OAUTH, ); diff --git a/packages/core/src/models/modelRegistry.test.ts b/packages/core/src/models/modelRegistry.test.ts index b2225425c..7574ae5d8 100644 --- a/packages/core/src/models/modelRegistry.test.ts +++ b/packages/core/src/models/modelRegistry.test.ts @@ -226,9 +226,7 @@ describe('ModelRegistry', () => { it('should apply default dashscope URL for qwen-oauth', () => { const registry = new ModelRegistry(); const model = registry.getModel(AuthType.QWEN_OAUTH, 'coder-model'); - expect(model?.baseUrl).toBe( - 'https://dashscope.aliyuncs.com/compatible-mode/v1', - ); + expect(model?.baseUrl).toBe('DYNAMIC_QWEN_OAUTH_BASE_URL'); }); it('should apply default openai URL when not specified', () => { diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Execute/Execute.css b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Execute/Execute.css index cfd4c8b64..7f23e39ba 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Execute/Execute.css +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Execute/Execute.css @@ -61,7 +61,11 @@ /* Truncated content styling */ .execute-toolcall-row-content:not(.execute-toolcall-full) { max-height: 60px; - mask-image: linear-gradient(to bottom, var(--app-primary-background) 40px, transparent 60px); + mask-image: linear-gradient( + to bottom, + var(--app-primary-background) 40px, + transparent 60px + ); overflow: hidden; } From 492da0c8c0706e3a1330778436c2eb2dc396ae30 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Tue, 6 Jan 2026 16:04:06 +0800 Subject: [PATCH 086/142] chore: update copyright notice in modelConfigUtils.ts --- packages/cli/src/utils/modelConfigUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/utils/modelConfigUtils.ts b/packages/cli/src/utils/modelConfigUtils.ts index cb710692a..d82252d58 100644 --- a/packages/cli/src/utils/modelConfigUtils.ts +++ b/packages/cli/src/utils/modelConfigUtils.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ From 15f4c1ebd6ee8e58cf1a54a2f1606d4afe1e3caf Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Tue, 6 Jan 2026 21:26:14 +0800 Subject: [PATCH 087/142] chore: add i18n --- packages/cli/src/config/auth.ts | 27 ++++++++++++++------ packages/cli/src/i18n/locales/de.js | 19 ++++++++++++++ packages/cli/src/i18n/locales/en.js | 18 +++++++++++++ packages/cli/src/i18n/locales/ru.js | 18 +++++++++++++ packages/cli/src/i18n/locales/zh.js | 18 +++++++++++++ packages/cli/src/ui/commands/modelCommand.ts | 2 +- 6 files changed, 93 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/config/auth.ts b/packages/cli/src/config/auth.ts index e05b029d9..b77145edf 100644 --- a/packages/cli/src/config/auth.ts +++ b/packages/cli/src/config/auth.ts @@ -10,6 +10,7 @@ import type { ProviderModelConfig, } from '@qwen-code/qwen-code-core'; import { loadEnvironment, loadSettings, type Settings } from './settings.js'; +import { t } from '../i18n/index.js'; /** * Default environment variable names for each auth type @@ -89,9 +90,9 @@ export function validateAuthMethod(authMethod: string): string | null { const envKeyHint = checkedEnvKey ? `'${checkedEnvKey}'` : "'OPENAI_API_KEY' (or configure modelProviders[].envKey)"; - return ( - 'Missing API key for OpenAI-compatible auth. ' + - `Set settings.security.auth.apiKey, or set the ${envKeyHint} environment variable.` + return t( + 'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.', + { envKeyHint }, ); } return null; @@ -110,7 +111,9 @@ export function validateAuthMethod(authMethod: string): string | null { ); if (!hasKey) { const envKeyHint = checkedEnvKey || 'ANTHROPIC_API_KEY'; - return `${envKeyHint} environment variable not found.`; + return t('{{envKeyHint}} environment variable not found.', { + envKeyHint, + }); } // Check baseUrl - can come from modelProviders or environment @@ -122,7 +125,9 @@ export function validateAuthMethod(authMethod: string): string | null { const hasBaseUrl = modelConfig?.baseUrl || process.env['ANTHROPIC_BASE_URL']; if (!hasBaseUrl) { - return 'ANTHROPIC_BASE_URL environment variable not found (or configure modelProviders[].baseUrl).'; + return t( + 'ANTHROPIC_BASE_URL environment variable not found (or configure modelProviders[].baseUrl).', + ); } return null; @@ -135,7 +140,10 @@ export function validateAuthMethod(authMethod: string): string | null { ); if (!hasKey) { const envKeyHint = checkedEnvKey || 'GEMINI_API_KEY'; - return `${envKeyHint} environment variable not found. Please set it in your .env file or environment variables.`; + return t( + '{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.', + { envKeyHint }, + ); } return null; } @@ -147,12 +155,15 @@ export function validateAuthMethod(authMethod: string): string | null { ); if (!hasKey) { const envKeyHint = checkedEnvKey || 'GOOGLE_API_KEY'; - return `${envKeyHint} environment variable not found. Please set it in your .env file or environment variables.`; + return t( + '{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.', + { envKeyHint }, + ); } process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true'; return null; } - return 'Invalid auth method selected.'; + return t('Invalid auth method selected.'); } diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 71624b797..274f3e68d 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -792,6 +792,16 @@ export default { 'Authentifizierung abgelaufen. Bitte versuchen Sie es erneut.', 'Waiting for auth... (Press ESC or CTRL+C to cancel)': 'Warten auf Authentifizierung... (ESC oder STRG+C zum Abbrechen drücken)', + 'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.': + 'API-Schlüssel für OpenAI-kompatible Authentifizierung fehlt. Setzen Sie settings.security.auth.apiKey oder die Umgebungsvariable {{envKeyHint}}.', + '{{envKeyHint}} environment variable not found.': + 'Umgebungsvariable {{envKeyHint}} wurde nicht gefunden.', + '{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.': + 'Umgebungsvariable {{envKeyHint}} wurde nicht gefunden. Bitte legen Sie sie in Ihrer .env-Datei oder den Systemumgebungsvariablen fest.', + 'ANTHROPIC_BASE_URL environment variable not found (or configure modelProviders[].baseUrl).': + 'Umgebungsvariable ANTHROPIC_BASE_URL wurde nicht gefunden (oder konfigurieren Sie modelProviders[].baseUrl).', + 'Invalid auth method selected.': + 'Ungültige Authentifizierungsmethode ausgewählt.', 'Failed to authenticate. Message: {{message}}': 'Authentifizierung fehlgeschlagen. Meldung: {{message}}', 'Authenticated successfully with {{authType}} credentials.': @@ -814,6 +824,15 @@ export default { // ============================================================================ 'Select Model': 'Modell auswählen', '(Press Esc to close)': '(Esc zum Schließen drücken)', + 'Current (effective) configuration': 'Aktuelle (wirksame) Konfiguration', + AuthType: 'Authentifizierungstyp', + 'API Key': 'API-Schlüssel', + unset: 'nicht gesetzt', + '(default)': '(Standard)', + '(set)': '(gesetzt)', + '(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)', 'The latest Qwen Vision model from 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 5e8b16629..e34fe1710 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -770,6 +770,15 @@ export default { 'Authentication timed out. Please try again.', 'Waiting for auth... (Press ESC or CTRL+C to cancel)': 'Waiting for auth... (Press ESC or CTRL+C to cancel)', + 'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.': + 'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.', + '{{envKeyHint}} environment variable not found.': + '{{envKeyHint}} environment variable not found.', + '{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.': + '{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.', + 'ANTHROPIC_BASE_URL environment variable not found (or configure modelProviders[].baseUrl).': + 'ANTHROPIC_BASE_URL environment variable not found (or configure modelProviders[].baseUrl).', + 'Invalid auth method selected.': 'Invalid auth method selected.', 'Failed to authenticate. Message: {{message}}': 'Failed to authenticate. Message: {{message}}', 'Authenticated successfully with {{authType}} credentials.': @@ -791,6 +800,15 @@ export default { // ============================================================================ 'Select Model': 'Select Model', '(Press Esc to close)': '(Press Esc to close)', + 'Current (effective) configuration': 'Current (effective) configuration', + AuthType: 'AuthType', + 'API Key': 'API Key', + unset: 'unset', + '(default)': '(default)', + '(set)': '(set)', + '(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)', 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: 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 9685c104b..02de921b2 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -786,6 +786,15 @@ export default { 'Время ожидания авторизации истекло. Пожалуйста, попробуйте снова.', 'Waiting for auth... (Press ESC or CTRL+C to cancel)': 'Ожидание авторизации... (Нажмите ESC или CTRL+C для отмены)', + 'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.': + 'Отсутствует API-ключ для аутентификации, совместимой с OpenAI. Укажите settings.security.auth.apiKey или переменную окружения {{envKeyHint}}.', + '{{envKeyHint}} environment variable not found.': + 'Переменная окружения {{envKeyHint}} не найдена.', + '{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.': + 'Переменная окружения {{envKeyHint}} не найдена. Укажите её в файле .env или среди системных переменных.', + 'ANTHROPIC_BASE_URL environment variable not found (or configure modelProviders[].baseUrl).': + 'Переменная окружения ANTHROPIC_BASE_URL не найдена (или настройте modelProviders[].baseUrl).', + 'Invalid auth method selected.': 'Выбран недопустимый метод авторизации.', 'Failed to authenticate. Message: {{message}}': 'Не удалось авторизоваться. Сообщение: {{message}}', 'Authenticated successfully with {{authType}} credentials.': @@ -807,6 +816,15 @@ export default { // ============================================================================ 'Select Model': 'Выбрать модель', '(Press Esc to close)': '(Нажмите Esc для закрытия)', + 'Current (effective) configuration': 'Текущая (фактическая) конфигурация', + AuthType: 'Тип авторизации', + 'API Key': 'API-ключ', + unset: 'не задано', + '(default)': '(по умолчанию)', + '(set)': '(установлено)', + '(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)', 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: 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 c3550f7e8..3f6b1368a 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -728,6 +728,15 @@ export default { 'Authentication timed out. Please try again.': '认证超时。请重试。', 'Waiting for auth... (Press ESC or CTRL+C to cancel)': '正在等待认证...(按 ESC 或 CTRL+C 取消)', + 'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.': + '缺少 OpenAI 兼容认证的 API 密钥。请设置 settings.security.auth.apiKey 或设置 {{envKeyHint}} 环境变量。', + '{{envKeyHint}} environment variable not found.': + '未找到 {{envKeyHint}} 环境变量。', + '{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.': + '未找到 {{envKeyHint}} 环境变量。请在 .env 文件或系统环境变量中进行设置。', + 'ANTHROPIC_BASE_URL environment variable not found (or configure modelProviders[].baseUrl).': + '未找到 ANTHROPIC_BASE_URL 环境变量(或配置 modelProviders[].baseUrl)。', + 'Invalid auth method selected.': '选择了无效的认证方式。', 'Failed to authenticate. Message: {{message}}': '认证失败。消息:{{message}}', 'Authenticated successfully with {{authType}} credentials.': '使用 {{authType}} 凭据成功认证。', @@ -747,6 +756,15 @@ export default { // ============================================================================ 'Select Model': '选择模型', '(Press Esc to close)': '(按 Esc 关闭)', + 'Current (effective) configuration': '当前(实际生效)配置', + AuthType: '认证方式', + 'API Key': 'API 密钥', + unset: '未设置', + '(default)': '(默认)', + '(set)': '(已设置)', + '(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)', 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)': diff --git a/packages/cli/src/ui/commands/modelCommand.ts b/packages/cli/src/ui/commands/modelCommand.ts index e0971bdde..4dcc9a518 100644 --- a/packages/cli/src/ui/commands/modelCommand.ts +++ b/packages/cli/src/ui/commands/modelCommand.ts @@ -29,7 +29,7 @@ export const modelCommand: SlashCommand = { return { type: 'message', messageType: 'error', - content: 'Configuration not available.', + content: t('Configuration not available.'), }; } From 8da376637afd3ca4851307e34c8a652b89437f9c Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Wed, 7 Jan 2026 10:26:44 +0800 Subject: [PATCH 088/142] fix: remove detailed generationConfig --- .../cli/src/ui/components/ModelDialog.tsx | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index b5d39cc46..3d573dc06 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -346,26 +346,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { /> )} - - {effectiveConfig?.samplingParams ? ( - - ) : null} - - {effectiveConfig?.timeout !== undefined ? ( - - ) : null} From fe2ed889b9d55c5649ca32ec9324f43be0458bb1 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Wed, 7 Jan 2026 11:46:13 +0800 Subject: [PATCH 089/142] docs: add `modelProviders` documents --- docs/users/configuration/settings.md | 86 ++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 9cf704dae..7e964088b 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -136,6 +136,92 @@ Settings are organized into categories. All settings should be placed within the - `"./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. + +##### Example + +```json +{ + "modelProviders": { + "openai": [ + { + "id": "gpt-4o", + "name": "GPT-4o", + "envKey": "OPENAI_API_KEY", + "baseUrl": "https://api.openai.com/v1", + "generationConfig": { + "timeout": 60000, + "maxRetries": 3, + "samplingParams": { "temperature": 0.2 } + } + } + ], + "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" + } + ] + } +} +``` + +> [!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. + +##### 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. + +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. + +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 + +Per-field precedence for `generationConfig`: + +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.) + +`samplingParams` is treated atomically; provider values replace the entire object. Defaults from the content generator apply last so each provider retains its tuned baseline. + +##### Selection persistence and recommendations + +- `/model` persists both `model.name` and `security.auth.selectedType`. We first try to write to the nearest scope whose settings already contain `modelProviders`; otherwise we fall back to user scope. Qwen OAuth selections always persist to the user scope. +- 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. + #### context | Setting | Type | Description | Default | From afe6ba255ee8ea2555658460e73fc9e33ba756cf Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Wed, 7 Jan 2026 20:04:00 +0800 Subject: [PATCH 090/142] fix: align authType & model persisting behavior across dialogs --- docs/users/configuration/settings.md | 5 ++- packages/cli/src/ui/auth/AuthDialog.test.tsx | 4 +- packages/cli/src/ui/auth/AuthDialog.tsx | 5 +-- packages/cli/src/ui/auth/useAuth.ts | 40 ++++++++----------- .../cli/src/ui/components/DialogManager.tsx | 3 +- .../cli/src/ui/contexts/UIActionsContext.tsx | 1 - packages/cli/src/ui/hooks/useDialogClose.ts | 1 - 7 files changed, 26 insertions(+), 33 deletions(-) diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 7e964088b..3b3c54533 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -219,7 +219,10 @@ Per-field precedence for `generationConfig`: ##### Selection persistence and recommendations -- `/model` persists both `model.name` and `security.auth.selectedType`. We first try to write to the nearest scope whose settings already contain `modelProviders`; otherwise we fall back to user scope. Qwen OAuth selections always persist to the user scope. +> [!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. #### context diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx index 0b99eed98..28e13e889 100644 --- a/packages/cli/src/ui/auth/AuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx @@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { AuthDialog } from './AuthDialog.js'; -import { LoadedSettings, SettingScope } from '../../config/settings.js'; +import { LoadedSettings } from '../../config/settings.js'; import { AuthType } from '@qwen-code/qwen-code-core'; import { renderWithProviders } from '../../test-utils/render.js'; import { UIStateContext } from '../contexts/UIStateContext.js'; @@ -536,7 +536,7 @@ describe('AuthDialog', () => { await wait(); // Should call handleAuthSelect with undefined to exit - expect(handleAuthSelect).toHaveBeenCalledWith(undefined, SettingScope.User); + expect(handleAuthSelect).toHaveBeenCalledWith(undefined); unmount(); }); }); diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index 80c13b0bc..44e2affaa 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -8,7 +8,6 @@ import type React from 'react'; import { useState } from 'react'; import { AuthType } from '@qwen-code/qwen-code-core'; import { Box, Text } from 'ink'; -import { SettingScope } from '../../config/settings.js'; import { Colors } from '../colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; @@ -84,7 +83,7 @@ export function AuthDialog(): React.JSX.Element { const handleAuthSelect = async (authMethod: AuthType) => { setErrorMessage(null); - await onAuthSelect(authMethod, SettingScope.User); + await onAuthSelect(authMethod); }; const handleHighlight = (authMethod: AuthType) => { @@ -109,7 +108,7 @@ export function AuthDialog(): React.JSX.Element { ); return; } - onAuthSelect(undefined, SettingScope.User); + onAuthSelect(undefined); } }, { isActive: true }, diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index 6125ebdf2..5b97ead5f 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -12,7 +12,8 @@ import { logAuth, } from '@qwen-code/qwen-code-core'; import { useCallback, useEffect, useState } from 'react'; -import type { LoadedSettings, SettingScope } from '../../config/settings.js'; +import type { LoadedSettings } from '../../config/settings.js'; +import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js'; import { useQwenAuth } from '../hooks/useQwenAuth.js'; import { AuthState, MessageType } from '../types.js'; @@ -80,33 +81,34 @@ export const useAuthCommand = ( ); const handleAuthSuccess = useCallback( - async ( - authType: AuthType, - scope: SettingScope, - credentials?: OpenAICredentials, - ) => { + async (authType: AuthType, credentials?: OpenAICredentials) => { try { - settings.setValue(scope, 'security.auth.selectedType', authType); + const authTypeScope = getPersistScopeForModelSelection(settings); + settings.setValue( + authTypeScope, + 'security.auth.selectedType', + authType, + ); // Only update credentials if not switching to QWEN_OAUTH, // so that OpenAI credentials are preserved when switching to QWEN_OAUTH. if (authType !== AuthType.QWEN_OAUTH && credentials) { if (credentials?.apiKey != null) { settings.setValue( - scope, + authTypeScope, 'security.auth.apiKey', credentials.apiKey, ); } if (credentials?.baseUrl != null) { settings.setValue( - scope, + authTypeScope, 'security.auth.baseUrl', credentials.baseUrl, ); } if (credentials?.model != null) { - settings.setValue(scope, 'model.name', credentials.model); + settings.setValue(authTypeScope, 'model.name', credentials.model); } } } catch (error) { @@ -139,14 +141,10 @@ export const useAuthCommand = ( ); const performAuth = useCallback( - async ( - authType: AuthType, - scope: SettingScope, - credentials?: OpenAICredentials, - ) => { + async (authType: AuthType, credentials?: OpenAICredentials) => { try { await config.refreshAuth(authType); - handleAuthSuccess(authType, scope, credentials); + handleAuthSuccess(authType, credentials); } catch (e) { handleAuthFailure(e); } @@ -155,11 +153,7 @@ export const useAuthCommand = ( ); const handleAuthSelect = useCallback( - async ( - authType: AuthType | undefined, - scope: SettingScope, - credentials?: OpenAICredentials, - ) => { + async (authType: AuthType | undefined, credentials?: OpenAICredentials) => { if (!authType) { setIsAuthDialogOpen(false); setAuthError(null); @@ -178,12 +172,12 @@ export const useAuthCommand = ( baseUrl: credentials.baseUrl, model: credentials.model, }); - await performAuth(authType, scope, credentials); + await performAuth(authType, credentials); } return; } - await performAuth(authType, scope); + await performAuth(authType); }, [config, performAuth], ); diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index c3e1a128c..6ff9f4aae 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -25,7 +25,6 @@ import { useUIState } from '../contexts/UIStateContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; -import { SettingScope } from '../../config/settings.js'; import { AuthState } from '../types.js'; import { AuthType } from '@qwen-code/qwen-code-core'; import process from 'node:process'; @@ -202,7 +201,7 @@ export const DialogManager = ({ return ( { - uiActions.handleAuthSelect(AuthType.USE_OPENAI, SettingScope.User, { + uiActions.handleAuthSelect(AuthType.USE_OPENAI, { apiKey, baseUrl, model, diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index b9842c811..93c0528d8 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -30,7 +30,6 @@ export interface UIActions { ) => void; handleAuthSelect: ( authType: AuthType | undefined, - scope: SettingScope, credentials?: OpenAICredentials, ) => Promise; setAuthState: (state: AuthState) => void; diff --git a/packages/cli/src/ui/hooks/useDialogClose.ts b/packages/cli/src/ui/hooks/useDialogClose.ts index 298f44963..8191e16b8 100644 --- a/packages/cli/src/ui/hooks/useDialogClose.ts +++ b/packages/cli/src/ui/hooks/useDialogClose.ts @@ -25,7 +25,6 @@ export interface DialogCloseOptions { isAuthDialogOpen: boolean; handleAuthSelect: ( authType: AuthType | undefined, - scope: SettingScope, credentials?: OpenAICredentials, ) => Promise; pendingAuthType: AuthType | undefined; From ded1ebcdffdd137f74c37074b02d108ff8bba356 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Wed, 7 Jan 2026 20:05:33 +0800 Subject: [PATCH 091/142] fix: fallback and auth issues when configuring a duplicate model id --- packages/cli/src/config/auth.ts | 6 ++- packages/cli/src/ui/auth/useAuth.ts | 41 ++++++++++++++++++- .../cli/src/ui/components/ModelDialog.tsx | 24 ++--------- packages/core/src/models/modelsConfig.ts | 25 ----------- 4 files changed, 47 insertions(+), 49 deletions(-) diff --git a/packages/cli/src/config/auth.ts b/packages/cli/src/config/auth.ts index b77145edf..c0d33b0b4 100644 --- a/packages/cli/src/config/auth.ts +++ b/packages/cli/src/config/auth.ts @@ -66,12 +66,14 @@ function hasApiKeyForAuth( const defaultEnvKey = DEFAULT_ENV_KEYS[authType]; if (defaultEnvKey) { const hasKey = !!process.env[defaultEnvKey]; - return { hasKey, checkedEnvKey: defaultEnvKey }; + if (hasKey) { + return { hasKey, checkedEnvKey: defaultEnvKey }; + } } // Also check settings.security.auth.apiKey as fallback if (settings.security?.auth?.apiKey) { - return { hasKey: true, checkedEnvKey: undefined }; + return { hasKey: true, checkedEnvKey: defaultEnvKey || undefined }; } return { hasKey: false, checkedEnvKey: undefined }; diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index 5b97ead5f..bfc80ca70 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Config } from '@qwen-code/qwen-code-core'; +import type { Config, ModelProvidersConfig } from '@qwen-code/qwen-code-core'; import { AuthEvent, AuthType, @@ -152,6 +152,29 @@ export const useAuthCommand = ( [config, handleAuthSuccess, handleAuthFailure], ); + const isProviderManagedModel = useCallback( + (authType: AuthType, modelId: string | undefined) => { + if (!modelId) { + return false; + } + + const modelProviders = settings.merged.modelProviders as + | ModelProvidersConfig + | undefined; + if (!modelProviders) { + return false; + } + const providerModels = modelProviders[authType]; + if (!Array.isArray(providerModels)) { + return false; + } + return providerModels.some( + (providerModel) => providerModel.id === modelId, + ); + }, + [settings], + ); + const handleAuthSelect = useCallback( async (authType: AuthType | undefined, credentials?: OpenAICredentials) => { if (!authType) { @@ -160,6 +183,20 @@ export const useAuthCommand = ( return; } + if ( + authType === AuthType.USE_OPENAI && + credentials?.model && + isProviderManagedModel(authType, credentials.model) + ) { + onAuthError( + t( + 'Model "{{modelName}}" is managed via settings.modelProviders. Please complete the fields in settings, or use another model id.', + { modelName: credentials.model }, + ), + ); + return; + } + setPendingAuthType(authType); setAuthError(null); setIsAuthDialogOpen(false); @@ -179,7 +216,7 @@ export const useAuthCommand = ( await performAuth(authType); }, - [config, performAuth], + [config, performAuth, isProviderManagedModel, onAuthError], ); const openAuthDialog = useCallback(() => { diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index 3d573dc06..84612b902 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -255,26 +255,10 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { ); } catch (e) { const baseErrorMessage = e instanceof Error ? e.message : String(e); - - // Some auth types (notably openai without modelProviders configured) can present - // env-based "raw" model IDs in the list. These are not registry-backed and will - // fail switchModel(). Fall back to setModel() to keep UX functional. - const isNotFound = - baseErrorMessage.includes('not found for authType') || - (baseErrorMessage.includes('Model') && - baseErrorMessage.includes('not found')); - if (!isNotFound) { - setErrorMessage( - `Failed to switch model to '${modelId}'.\n\n${baseErrorMessage}`, - ); - - // Keep the dialog open so the user can choose another model. - return; - } - await config.setModel(modelId, { - reason: 'user_manual', - context: 'Model set via /model dialog (raw)', - }); + setErrorMessage( + `Failed to switch model to '${modelId}'.\n\n${baseErrorMessage}`, + ); + return; } const event = new ModelSlashCommandEvent(modelId); logModelSlashCommand(config, event); diff --git a/packages/core/src/models/modelsConfig.ts b/packages/core/src/models/modelsConfig.ts index 022737074..5e066b96f 100644 --- a/packages/core/src/models/modelsConfig.ts +++ b/packages/core/src/models/modelsConfig.ts @@ -452,9 +452,6 @@ export class ModelsConfig { */ private applyResolvedModelDefaults(model: ResolvedModelConfig): void { this.strictModelProviderSelection = true; - const previousApiKey = this._generationConfig.apiKey; - const previousApiKeyEnvKey = this._generationConfig.apiKeyEnvKey; - const hadManualCredentials = this.hasManualCredentials; // We're explicitly applying modelProvider defaults now, so manual overrides // should no longer block syncAfterAuthRefresh from applying provider defaults. this.hasManualCredentials = false; @@ -503,28 +500,6 @@ export class ModelsConfig { detail: 'envKey', }, }; - } else { - // If the user provided an API key via CLI/settings/updateCredentials, keep it. - // We only refuse to reuse a previous key when it is explicitly tied to a - // different envKey (e.g. switching between two configured accounts). - const canPreservePreviousKey = - !!previousApiKey && - (hadManualCredentials || - previousApiKeyEnvKey === undefined || - previousApiKeyEnvKey === model.envKey); - - if (canPreservePreviousKey) { - this._generationConfig.apiKey = previousApiKey; - this.generationConfigSources['apiKey'] = { - kind: 'computed', - detail: `preserved previous apiKey (missing env: ${model.envKey})`, - }; - } else { - console.warn( - `[ModelsConfig] Environment variable '${model.envKey}' is not set for model '${model.id}'. ` + - `API key will not be available.`, - ); - } } this._generationConfig.apiKeyEnvKey = model.envKey; this.generationConfigSources['apiKeyEnvKey'] = { From 5ea841dd020ef2a22f8efc511bd63388ce4b197c Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Wed, 7 Jan 2026 22:56:57 +0800 Subject: [PATCH 092/142] fix: refine auth message to give explicit tip --- packages/cli/src/config/auth.test.ts | 2 +- packages/cli/src/config/auth.ts | 121 ++++++++++++------ packages/cli/src/i18n/locales/de.js | 10 +- packages/cli/src/i18n/locales/en.js | 10 +- packages/cli/src/i18n/locales/ru.js | 10 +- packages/cli/src/i18n/locales/zh.js | 10 +- packages/core/src/models/modelConfigErrors.ts | 2 +- packages/core/src/models/modelsConfig.test.ts | 41 ++++-- scripts/unused-keys-only-in-locales.json | 13 +- 9 files changed, 148 insertions(+), 71 deletions(-) diff --git a/packages/cli/src/config/auth.test.ts b/packages/cli/src/config/auth.test.ts index c960e05a7..39652c5c5 100644 --- a/packages/cli/src/config/auth.test.ts +++ b/packages/cli/src/config/auth.test.ts @@ -149,7 +149,7 @@ describe('validateAuthMethod', () => { process.env['CUSTOM_ANTHROPIC_KEY'] = 'custom-key'; const result = validateAuthMethod(AuthType.USE_ANTHROPIC); - expect(result).toContain('ANTHROPIC_BASE_URL'); + expect(result).toContain('modelProviders[].baseUrl'); }); it('should return null for USE_VERTEX_AI with custom envKey', () => { diff --git a/packages/cli/src/config/auth.ts b/packages/cli/src/config/auth.ts index c0d33b0b4..e3656a277 100644 --- a/packages/cli/src/config/auth.ts +++ b/packages/cli/src/config/auth.ts @@ -45,11 +45,19 @@ function findModelConfig( /** * Check if API key is available for the given auth type and model configuration. * Prioritizes custom envKey from modelProviders over default environment variables. + * + * @returns hasKey - whether an API key is available + * @returns checkedEnvKey - the environment variable name that was checked + * @returns isExplicitEnvKey - true if model has explicit envKey configured (no apiKey fallback allowed) */ function hasApiKeyForAuth( authType: string, settings: Settings, -): { hasKey: boolean; checkedEnvKey: string | undefined } { +): { + hasKey: boolean; + checkedEnvKey: string | undefined; + isExplicitEnvKey: boolean; +} { const modelProviders = settings.modelProviders as | ModelProvidersConfig | undefined; @@ -58,25 +66,64 @@ function hasApiKeyForAuth( // Try to find model-specific envKey from modelProviders const modelConfig = findModelConfig(modelProviders, authType, modelId); if (modelConfig?.envKey) { + // Explicit envKey configured - only check this env var, no apiKey fallback const hasKey = !!process.env[modelConfig.envKey]; - return { hasKey, checkedEnvKey: modelConfig.envKey }; + return { + hasKey, + checkedEnvKey: modelConfig.envKey, + isExplicitEnvKey: true, + }; } - // Fallback to default environment variable + // Using default environment variable - apiKey fallback is allowed const defaultEnvKey = DEFAULT_ENV_KEYS[authType]; if (defaultEnvKey) { const hasKey = !!process.env[defaultEnvKey]; if (hasKey) { - return { hasKey, checkedEnvKey: defaultEnvKey }; + return { hasKey, checkedEnvKey: defaultEnvKey, isExplicitEnvKey: false }; } } - // Also check settings.security.auth.apiKey as fallback + // Also check settings.security.auth.apiKey as fallback (only for default env key) if (settings.security?.auth?.apiKey) { - return { hasKey: true, checkedEnvKey: defaultEnvKey || undefined }; + return { + hasKey: true, + checkedEnvKey: defaultEnvKey || undefined, + isExplicitEnvKey: false, + }; } - return { hasKey: false, checkedEnvKey: undefined }; + return { + hasKey: false, + checkedEnvKey: defaultEnvKey, + isExplicitEnvKey: false, + }; +} + +/** + * Generate API key error message based on auth check result. + * Returns null if API key is present, otherwise returns the appropriate error message. + */ +function getApiKeyError(authMethod: string, settings: Settings): string | null { + const { hasKey, checkedEnvKey, isExplicitEnvKey } = hasApiKeyForAuth( + authMethod, + settings, + ); + if (hasKey) { + return null; + } + + const envKeyHint = checkedEnvKey || DEFAULT_ENV_KEYS[authMethod]; + if (isExplicitEnvKey) { + return t( + '{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.', + { envKeyHint }, + ); + } + return t( + '{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.', + { envKeyHint }, + ); } export function validateAuthMethod(authMethod: string): string | null { @@ -84,14 +131,22 @@ export function validateAuthMethod(authMethod: string): string | null { loadEnvironment(settings.merged); if (authMethod === AuthType.USE_OPENAI) { - const { hasKey, checkedEnvKey } = hasApiKeyForAuth( + const { hasKey, checkedEnvKey, isExplicitEnvKey } = hasApiKeyForAuth( authMethod, settings.merged, ); if (!hasKey) { const envKeyHint = checkedEnvKey ? `'${checkedEnvKey}'` - : "'OPENAI_API_KEY' (or configure modelProviders[].envKey)"; + : "'OPENAI_API_KEY'"; + if (isExplicitEnvKey) { + // Explicit envKey configured - only suggest setting the env var + return t( + 'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.', + { envKeyHint }, + ); + } + // Default env key - can use either apiKey or env var return t( 'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.', { envKeyHint }, @@ -107,15 +162,9 @@ export function validateAuthMethod(authMethod: string): string | null { } if (authMethod === AuthType.USE_ANTHROPIC) { - const { hasKey, checkedEnvKey } = hasApiKeyForAuth( - authMethod, - settings.merged, - ); - if (!hasKey) { - const envKeyHint = checkedEnvKey || 'ANTHROPIC_API_KEY'; - return t('{{envKeyHint}} environment variable not found.', { - envKeyHint, - }); + const apiKeyError = getApiKeyError(authMethod, settings.merged); + if (apiKeyError) { + return apiKeyError; } // Check baseUrl - can come from modelProviders or environment @@ -124,43 +173,31 @@ export function validateAuthMethod(authMethod: string): string | null { | undefined; const modelId = settings.merged.model?.name; const modelConfig = findModelConfig(modelProviders, authMethod, modelId); - const hasBaseUrl = - modelConfig?.baseUrl || process.env['ANTHROPIC_BASE_URL']; - if (!hasBaseUrl) { + + if (modelConfig && !modelConfig.baseUrl) { return t( - 'ANTHROPIC_BASE_URL environment variable not found (or configure modelProviders[].baseUrl).', + 'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.', ); } + if (!modelConfig && !process.env['ANTHROPIC_BASE_URL']) { + return t('ANTHROPIC_BASE_URL environment variable not found.'); + } return null; } if (authMethod === AuthType.USE_GEMINI) { - const { hasKey, checkedEnvKey } = hasApiKeyForAuth( - authMethod, - settings.merged, - ); - if (!hasKey) { - const envKeyHint = checkedEnvKey || 'GEMINI_API_KEY'; - return t( - '{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.', - { envKeyHint }, - ); + const apiKeyError = getApiKeyError(authMethod, settings.merged); + if (apiKeyError) { + return apiKeyError; } return null; } if (authMethod === AuthType.USE_VERTEX_AI) { - const { hasKey, checkedEnvKey } = hasApiKeyForAuth( - authMethod, - settings.merged, - ); - if (!hasKey) { - const envKeyHint = checkedEnvKey || 'GOOGLE_API_KEY'; - return t( - '{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.', - { envKeyHint }, - ); + const apiKeyError = getApiKeyError(authMethod, settings.merged); + if (apiKeyError) { + return apiKeyError; } process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true'; diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 274f3e68d..fa4221854 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -798,8 +798,14 @@ export default { 'Umgebungsvariable {{envKeyHint}} wurde nicht gefunden.', '{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.': 'Umgebungsvariable {{envKeyHint}} wurde nicht gefunden. Bitte legen Sie sie in Ihrer .env-Datei oder den Systemumgebungsvariablen fest.', - 'ANTHROPIC_BASE_URL environment variable not found (or configure modelProviders[].baseUrl).': - 'Umgebungsvariable ANTHROPIC_BASE_URL wurde nicht gefunden (oder konfigurieren Sie modelProviders[].baseUrl).', + '{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.': + 'Umgebungsvariable {{envKeyHint}} wurde nicht gefunden (oder setzen Sie settings.security.auth.apiKey). Bitte legen Sie sie in Ihrer .env-Datei oder den Systemumgebungsvariablen fest.', + 'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.': + 'API-Schlüssel für OpenAI-kompatible Authentifizierung fehlt. Setzen Sie die Umgebungsvariable {{envKeyHint}}.', + 'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.': + 'Anthropic-Anbieter fehlt erforderliche baseUrl in modelProviders[].baseUrl.', + 'ANTHROPIC_BASE_URL environment variable not found.': + 'Umgebungsvariable ANTHROPIC_BASE_URL wurde nicht gefunden.', 'Invalid auth method selected.': 'Ungültige Authentifizierungsmethode ausgewählt.', 'Failed to authenticate. Message: {{message}}': diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index e34fe1710..51461f4cb 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -776,8 +776,14 @@ export default { '{{envKeyHint}} environment variable not found.', '{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.': '{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.', - 'ANTHROPIC_BASE_URL environment variable not found (or configure modelProviders[].baseUrl).': - 'ANTHROPIC_BASE_URL environment variable not found (or configure modelProviders[].baseUrl).', + '{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.': + '{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.', + 'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.': + 'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.', + 'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.': + 'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.', + 'ANTHROPIC_BASE_URL environment variable not found.': + 'ANTHROPIC_BASE_URL environment variable not found.', 'Invalid auth method selected.': 'Invalid auth method selected.', 'Failed to authenticate. Message: {{message}}': 'Failed to authenticate. Message: {{message}}', diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 02de921b2..82f2436ef 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -792,8 +792,14 @@ export default { 'Переменная окружения {{envKeyHint}} не найдена.', '{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.': 'Переменная окружения {{envKeyHint}} не найдена. Укажите её в файле .env или среди системных переменных.', - 'ANTHROPIC_BASE_URL environment variable not found (or configure modelProviders[].baseUrl).': - 'Переменная окружения ANTHROPIC_BASE_URL не найдена (или настройте modelProviders[].baseUrl).', + '{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.': + 'Переменная окружения {{envKeyHint}} не найдена (или установите settings.security.auth.apiKey). Укажите её в файле .env или среди системных переменных.', + 'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.': + 'Отсутствует API-ключ для аутентификации, совместимой с OpenAI. Установите переменную окружения {{envKeyHint}}.', + 'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.': + 'У провайдера Anthropic отсутствует обязательный baseUrl в modelProviders[].baseUrl.', + 'ANTHROPIC_BASE_URL environment variable not found.': + 'Переменная окружения ANTHROPIC_BASE_URL не найдена.', 'Invalid auth method selected.': 'Выбран недопустимый метод авторизации.', 'Failed to authenticate. Message: {{message}}': 'Не удалось авторизоваться. Сообщение: {{message}}', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 3f6b1368a..a1b9c2033 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -734,8 +734,14 @@ export default { '未找到 {{envKeyHint}} 环境变量。', '{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.': '未找到 {{envKeyHint}} 环境变量。请在 .env 文件或系统环境变量中进行设置。', - 'ANTHROPIC_BASE_URL environment variable not found (or configure modelProviders[].baseUrl).': - '未找到 ANTHROPIC_BASE_URL 环境变量(或配置 modelProviders[].baseUrl)。', + '{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.': + '未找到 {{envKeyHint}} 环境变量(或设置 settings.security.auth.apiKey)。请在 .env 文件或系统环境变量中进行设置。', + 'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.': + '缺少 OpenAI 兼容认证的 API 密钥。请设置 {{envKeyHint}} 环境变量。', + 'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.': + 'Anthropic 提供商缺少必需的 baseUrl,请在 modelProviders[].baseUrl 中配置。', + 'ANTHROPIC_BASE_URL environment variable not found.': + '未找到 ANTHROPIC_BASE_URL 环境变量。', 'Invalid auth method selected.': '选择了无效的认证方式。', 'Failed to authenticate. Message: {{message}}': '认证失败。消息:{{message}}', 'Authenticated successfully with {{authType}} credentials.': diff --git a/packages/core/src/models/modelConfigErrors.ts b/packages/core/src/models/modelConfigErrors.ts index 3504793bd..e2d86445c 100644 --- a/packages/core/src/models/modelConfigErrors.ts +++ b/packages/core/src/models/modelConfigErrors.ts @@ -110,7 +110,7 @@ export class MissingBaseUrlError extends ModelConfigError { model: string | undefined; }) { super( - `Missing baseUrl for modelProviders model '${params.model || '(unknown)'}' (authType: ${params.authType}). ` + + `Missing baseUrl for modelProviders model '${params.model || '(unknown)'}'. ` + `Configure modelProviders.${params.authType || '(unknown)'}[].baseUrl.`, ); } diff --git a/packages/core/src/models/modelsConfig.test.ts b/packages/core/src/models/modelsConfig.test.ts index 51c54ea59..8f8441e00 100644 --- a/packages/core/src/models/modelsConfig.test.ts +++ b/packages/core/src/models/modelsConfig.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect } from 'vitest'; import { ModelsConfig } from './modelsConfig.js'; import { AuthType } from '../core/contentGenerator.js'; +import type { ContentGeneratorConfig } from '../core/contentGenerator.js'; import type { ModelProvidersConfig } from './types.js'; describe('ModelsConfig', () => { @@ -20,6 +21,20 @@ describe('ModelsConfig', () => { return out as T; } + function snapshotGenerationConfig( + modelsConfig: ModelsConfig, + ): ContentGeneratorConfig { + return deepClone( + modelsConfig.getGenerationConfig() as ContentGeneratorConfig, + ); + } + + function currentGenerationConfig( + modelsConfig: ModelsConfig, + ): ContentGeneratorConfig { + return modelsConfig.getGenerationConfig() as ContentGeneratorConfig; + } + it('should fully rollback state when switchModel fails after applying defaults (authType change)', async () => { const modelProvidersConfig: ModelProvidersConfig = { openai: [ @@ -60,7 +75,7 @@ describe('ModelsConfig', () => { const baselineAuthType = modelsConfig.getCurrentAuthType(); const baselineModel = modelsConfig.getModel(); const baselineStrict = modelsConfig.isStrictModelProviderSelection(); - const baselineGc = deepClone(modelsConfig.getGenerationConfig()); + const baselineGc = snapshotGenerationConfig(modelsConfig); const baselineSources = deepClone( modelsConfig.getGenerationConfigSources(), ); @@ -78,7 +93,7 @@ describe('ModelsConfig', () => { expect(modelsConfig.getModel()).toBe(baselineModel); expect(modelsConfig.isStrictModelProviderSelection()).toBe(baselineStrict); - const gc = modelsConfig.getGenerationConfig(); + const gc = currentGenerationConfig(modelsConfig); expect(gc).toMatchObject({ model: baselineGc.model, baseUrl: baselineGc.baseUrl, @@ -117,7 +132,7 @@ describe('ModelsConfig', () => { await modelsConfig.switchModel(AuthType.USE_OPENAI, 'model-a'); const baselineModel = modelsConfig.getModel(); - const baselineGc = deepClone(modelsConfig.getGenerationConfig()); + const baselineGc = snapshotGenerationConfig(modelsConfig); const baselineSources = deepClone( modelsConfig.getGenerationConfigSources(), ); @@ -139,7 +154,7 @@ describe('ModelsConfig', () => { expect(modelsConfig.getGenerationConfigSources()).toEqual(baselineSources); }); - it('should preserve an explicit apiKey when switching models if envKey is missing in the environment', async () => { + it('should require provider-sourced apiKey when switching models even if envKey is missing', async () => { const modelProvidersConfig: ModelProvidersConfig = { openai: [ { @@ -168,9 +183,9 @@ describe('ModelsConfig', () => { await modelsConfig.switchModel(AuthType.USE_OPENAI, 'model-b'); - const gc = modelsConfig.getGenerationConfig(); + const gc = currentGenerationConfig(modelsConfig); expect(gc.model).toBe('model-b'); - expect(gc.apiKey).toBe('manual-key'); + expect(gc.apiKey).toBeUndefined(); expect(gc.apiKeyEnvKey).toBe('API_KEY_SHARED'); }); @@ -229,7 +244,7 @@ describe('ModelsConfig', () => { modelsConfig.getModel(), ); - const gc = modelsConfig.getGenerationConfig(); + const gc = currentGenerationConfig(modelsConfig); expect(gc.model).toBe('model-a'); expect(gc.samplingParams?.temperature).toBe(0.9); expect(gc.samplingParams?.max_tokens).toBe(999); @@ -298,7 +313,7 @@ describe('ModelsConfig', () => { modelsConfig.getModel(), ); - const gc = modelsConfig.getGenerationConfig(); + const gc = currentGenerationConfig(modelsConfig); expect(gc.model).toBe('model-a'); expect(gc.samplingParams?.temperature).toBe(0.9); expect(gc.samplingParams?.max_tokens).toBe(999); @@ -332,7 +347,7 @@ describe('ModelsConfig', () => { await modelsConfig.switchModel(AuthType.USE_OPENAI, 'provider-model'); // Verify provider config is applied - let gc = modelsConfig.getGenerationConfig(); + let gc = currentGenerationConfig(modelsConfig); expect(gc.model).toBe('provider-model'); expect(gc.baseUrl).toBe('https://provider.example.com/v1'); expect(gc.samplingParams?.temperature).toBe(0.1); @@ -356,7 +371,7 @@ describe('ModelsConfig', () => { }); // Verify provider-sourced config is cleared - gc = modelsConfig.getGenerationConfig(); + gc = currentGenerationConfig(modelsConfig); expect(gc.model).toBe('custom-model'); // Set by updateCredentials expect(gc.apiKey).toBe('manual-api-key'); // Set by updateCredentials expect(gc.baseUrl).toBeUndefined(); // Cleared (was from provider) @@ -415,7 +430,7 @@ describe('ModelsConfig', () => { await modelsConfig.switchModel(AuthType.USE_OPENAI, 'provider-model'); // Verify provider config is applied (overwriting settings) - let gc = modelsConfig.getGenerationConfig(); + let gc = currentGenerationConfig(modelsConfig); expect(gc.samplingParams?.temperature).toBe(0.1); expect(gc.timeout).toBe(1000); @@ -425,7 +440,7 @@ describe('ModelsConfig', () => { }); // Provider-sourced config should be cleared - gc = modelsConfig.getGenerationConfig(); + gc = currentGenerationConfig(modelsConfig); expect(gc.samplingParams).toBeUndefined(); expect(gc.timeout).toBeUndefined(); // The original settings-sourced config is NOT restored automatically; @@ -444,7 +459,7 @@ describe('ModelsConfig', () => { // Switching within qwen-oauth triggers applyResolvedModelDefaults(). await modelsConfig.switchModel(AuthType.QWEN_OAUTH, 'vision-model'); - const gc = modelsConfig.getGenerationConfig(); + const gc = currentGenerationConfig(modelsConfig); expect(gc.apiKey).toBe('QWEN_OAUTH_DYNAMIC_TOKEN'); expect(gc.apiKeyEnvKey).toBeUndefined(); }); diff --git a/scripts/unused-keys-only-in-locales.json b/scripts/unused-keys-only-in-locales.json index 53ce7d9be..45097f8da 100644 --- a/scripts/unused-keys-only-in-locales.json +++ b/scripts/unused-keys-only-in-locales.json @@ -1,5 +1,5 @@ { - "generatedAt": "2025-12-24T09:15:59.125Z", + "generatedAt": "2026-01-07T14:56:23.662Z", "keys": [ " - en-US: English", " - zh-CN: Simplified Chinese", @@ -9,9 +9,9 @@ "Approval mode changed to: {{mode}} (saved to {{scope}} settings{{location}})", "Auto-edit mode - Automatically approve file edits", "Available approval modes:", + "Change auth (executes the /auth command)", "Chat history is already compressed.", - "Clearing terminal and resetting chat.", - "Clearing terminal.", + "Continue with {{model}}", "Conversation checkpoint '{{tag}}' has been deleted.", "Conversation checkpoint saved with tag: {{tag}}.", "Conversation shared to {{filePath}}", @@ -24,6 +24,7 @@ "Failed to change approval mode: {{error}}", "Failed to login. Message: {{message}}", "Failed to save approval mode: {{error}}", + "Failed to switch model to '{{modelId}}'.\n\n{{error}}", "Invalid file format. Only .md and .json are supported.", "Invalid language. Available: en-US, zh-CN", "List of saved conversations:", @@ -43,6 +44,7 @@ "Persist for this project/workspace", "Persist for this user on this machine", "Plan mode - Analyze only, do not modify files or execute commands", + "Pro quota limit reached for {{model}}.", "Qwen OAuth authentication cancelled.", "Qwen OAuth authentication timed out. Please try again.", "Resume a conversation from a checkpoint. Usage: /chat resume ", @@ -54,8 +56,7 @@ "Share the current conversation to a markdown or json file. Usage: /chat share ", "Usage: /approval-mode [--session|--user|--project]", "Usage: /language ui [zh-CN|en-US]", - "YOLO mode - Automatically approve all tools", - "clear the screen and conversation history" + "YOLO mode - Automatically approve all tools" ], - "count": 55 + "count": 56 } From ab07c2d89cbdd18b847430e64e8de31c33ae9d37 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Thu, 8 Jan 2026 10:21:55 +0800 Subject: [PATCH 093/142] chore: bump version to 0.7.0 --- package-lock.json | 12 ++++++------ 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 +- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0ed7071f6..d1a9fd8ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qwen-code/qwen-code", - "version": "0.6.1", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qwen-code/qwen-code", - "version": "0.6.1", + "version": "0.7.0", "workspaces": [ "packages/*" ], @@ -17316,7 +17316,7 @@ }, "packages/cli": { "name": "@qwen-code/qwen-code", - "version": "0.6.1", + "version": "0.7.0", "dependencies": { "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", @@ -17953,7 +17953,7 @@ }, "packages/core": { "name": "@qwen-code/qwen-code-core", - "version": "0.6.1", + "version": "0.7.0", "hasInstallScript": true, "dependencies": { "@anthropic-ai/sdk": "^0.36.1", @@ -21413,7 +21413,7 @@ }, "packages/test-utils": { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.6.1", + "version": "0.7.0", "dev": true, "license": "Apache-2.0", "devDependencies": { @@ -21425,7 +21425,7 @@ }, "packages/vscode-ide-companion": { "name": "qwen-code-vscode-ide-companion", - "version": "0.6.1", + "version": "0.7.0", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.1", diff --git a/package.json b/package.json index 107b9e9b0..81c788628 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.6.1", + "version": "0.7.0", "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.6.1" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.0" }, "scripts": { "start": "cross-env node scripts/start.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index 2154e4683..7c0b14bd6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.6.1", + "version": "0.7.0", "description": "Qwen Code", "repository": { "type": "git", @@ -33,7 +33,7 @@ "dist" ], "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.1" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.0" }, "dependencies": { "@google/genai": "1.30.0", diff --git a/packages/core/package.json b/packages/core/package.json index e7baa13b2..0ea7dd131 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-core", - "version": "0.6.1", + "version": "0.7.0", "description": "Qwen Code Core", "repository": { "type": "git", diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 435df48f3..1dd551fd3 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.6.1", + "version": "0.7.0", "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 50982df00..b7c50f57c 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.6.1", + "version": "0.7.0", "publisher": "qwenlm", "icon": "assets/icon.png", "repository": { From 6319a6ed56dfdc21fd1961de90d579d62ea2584c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E4=BC=9F=E5=85=89?= Date: Thu, 8 Jan 2026 10:45:22 +0800 Subject: [PATCH 094/142] =?UTF-8?q?feat:=20=E4=BF=AE=E6=94=B9=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E8=AE=BE=E7=BD=AE=E5=92=8C=E5=B7=A5=E4=BD=9C=E5=8C=BA?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E7=9A=84=E9=80=89=E6=8B=A9=E9=A1=BA=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cli/src/ui/components/ApprovalModeDialog.tsx | 2 +- .../src/ui/components/EditorSettingsDialog.tsx | 16 ++++++++-------- .../cli/src/ui/components/SettingsDialog.tsx | 2 +- packages/cli/src/ui/components/ThemeDialog.tsx | 2 +- packages/cli/src/utils/dialogScopeUtils.ts | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/ui/components/ApprovalModeDialog.tsx b/packages/cli/src/ui/components/ApprovalModeDialog.tsx index 163a45fd8..d81b6f4c0 100644 --- a/packages/cli/src/ui/components/ApprovalModeDialog.tsx +++ b/packages/cli/src/ui/components/ApprovalModeDialog.tsx @@ -54,7 +54,7 @@ export function ApprovalModeDialog({ }: ApprovalModeDialogProps): React.JSX.Element { // Start with User scope by default const [selectedScope, setSelectedScope] = useState( - SettingScope.User, + SettingScope.Workspace, ); // Track the currently highlighted approval mode diff --git a/packages/cli/src/ui/components/EditorSettingsDialog.tsx b/packages/cli/src/ui/components/EditorSettingsDialog.tsx index 6926bf41d..fc2683bb8 100644 --- a/packages/cli/src/ui/components/EditorSettingsDialog.tsx +++ b/packages/cli/src/ui/components/EditorSettingsDialog.tsx @@ -33,7 +33,7 @@ export function EditorSettingsDialog({ onExit, }: EditorDialogProps): React.JSX.Element { const [selectedScope, setSelectedScope] = useState( - SettingScope.User, + SettingScope.Workspace, ); const [focusedSection, setFocusedSection] = useState<'editor' | 'scope'>( 'editor', @@ -66,13 +66,6 @@ export function EditorSettingsDialog({ } const scopeItems = [ - { - get label() { - return t('User Settings'); - }, - value: SettingScope.User, - key: SettingScope.User, - }, { get label() { return t('Workspace Settings'); @@ -80,6 +73,13 @@ export function EditorSettingsDialog({ value: SettingScope.Workspace, key: SettingScope.Workspace, }, + { + get label() { + return t('User Settings'); + }, + value: SettingScope.User, + key: SettingScope.User, + }, ]; const handleEditorSelect = (editorType: EditorType | 'not_set') => { diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index 45b0f5548..9c449db9e 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -63,7 +63,7 @@ export function SettingsDialog({ ); // Scope selector state (User by default) const [selectedScope, setSelectedScope] = useState( - SettingScope.User, + SettingScope.Workspace, ); // Active indices const [activeSettingIndex, setActiveSettingIndex] = useState(0); diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx index a12fed797..7af589e39 100644 --- a/packages/cli/src/ui/components/ThemeDialog.tsx +++ b/packages/cli/src/ui/components/ThemeDialog.tsx @@ -39,7 +39,7 @@ export function ThemeDialog({ terminalWidth, }: ThemeDialogProps): React.JSX.Element { const [selectedScope, setSelectedScope] = useState( - SettingScope.User, + SettingScope.Workspace, ); // Track the currently highlighted theme name diff --git a/packages/cli/src/utils/dialogScopeUtils.ts b/packages/cli/src/utils/dialogScopeUtils.ts index 027928abc..23caf7cea 100644 --- a/packages/cli/src/utils/dialogScopeUtils.ts +++ b/packages/cli/src/utils/dialogScopeUtils.ts @@ -26,11 +26,11 @@ export const SCOPE_LABELS = { */ export function getScopeItems() { return [ - { label: SCOPE_LABELS[SettingScope.User], value: SettingScope.User }, { label: SCOPE_LABELS[SettingScope.Workspace], value: SettingScope.Workspace, }, + { label: SCOPE_LABELS[SettingScope.User], value: SettingScope.User }, // { label: SCOPE_LABELS[SettingScope.System], value: SettingScope.System }, ]; } From 509d304742b65cfe2e3fcc1a98b9157bbeeb1618 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E4=BC=9F=E5=85=89?= Date: Thu, 8 Jan 2026 11:36:07 +0800 Subject: [PATCH 095/142] feat: Modify the selection order of user Settings and workspace Settings --- .../src/ui/components/ApprovalModeDialog.tsx | 34 ++++++++++++++++--- .../ui/components/EditorSettingsDialog.tsx | 16 ++++----- .../cli/src/ui/components/SettingsDialog.tsx | 2 +- .../cli/src/ui/components/ThemeDialog.tsx | 2 +- packages/cli/src/utils/dialogScopeUtils.ts | 2 +- 5 files changed, 40 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/ui/components/ApprovalModeDialog.tsx b/packages/cli/src/ui/components/ApprovalModeDialog.tsx index d81b6f4c0..b69fc06ef 100644 --- a/packages/cli/src/ui/components/ApprovalModeDialog.tsx +++ b/packages/cli/src/ui/components/ApprovalModeDialog.tsx @@ -14,7 +14,7 @@ import type { LoadedSettings } from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js'; import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js'; import { useKeypress } from '../hooks/useKeypress.js'; -import { ScopeSelector } from './shared/ScopeSelector.js'; +// import { ScopeSelector } from './shared/ScopeSelector.js'; import { t } from '../../i18n/index.js'; interface ApprovalModeDialogProps { @@ -57,6 +57,23 @@ export function ApprovalModeDialog({ SettingScope.Workspace, ); + const scopeItems = [ + { + get label() { + return t('Workspace Settings'); + }, + value: SettingScope.Workspace, + key: SettingScope.Workspace, + }, + { + get label() { + return t('User Settings'); + }, + value: SettingScope.User, + key: SettingScope.User, + }, + ]; + // Track the currently highlighted approval mode const [highlightedMode, setHighlightedMode] = useState( currentMode || ApprovalMode.DEFAULT, @@ -86,13 +103,14 @@ export function ApprovalModeDialog({ setHighlightedMode(mode); }; - const handleScopeHighlight = useCallback((scope: SettingScope) => { - setSelectedScope(scope); - }, []); + // const handleScopeHighlight = useCallback((scope: SettingScope) => { + // setSelectedScope(scope); + // }, []); const handleScopeSelect = useCallback( (scope: SettingScope) => { onSelect(highlightedMode, scope); + setSelectedScope(scope); }, [onSelect, highlightedMode], ); @@ -155,11 +173,17 @@ export function ApprovalModeDialog({ {/* Scope Selection */} - */} + diff --git a/packages/cli/src/ui/components/EditorSettingsDialog.tsx b/packages/cli/src/ui/components/EditorSettingsDialog.tsx index fc2683bb8..6926bf41d 100644 --- a/packages/cli/src/ui/components/EditorSettingsDialog.tsx +++ b/packages/cli/src/ui/components/EditorSettingsDialog.tsx @@ -33,7 +33,7 @@ export function EditorSettingsDialog({ onExit, }: EditorDialogProps): React.JSX.Element { const [selectedScope, setSelectedScope] = useState( - SettingScope.Workspace, + SettingScope.User, ); const [focusedSection, setFocusedSection] = useState<'editor' | 'scope'>( 'editor', @@ -66,13 +66,6 @@ export function EditorSettingsDialog({ } const scopeItems = [ - { - get label() { - return t('Workspace Settings'); - }, - value: SettingScope.Workspace, - key: SettingScope.Workspace, - }, { get label() { return t('User Settings'); @@ -80,6 +73,13 @@ export function EditorSettingsDialog({ value: SettingScope.User, key: SettingScope.User, }, + { + get label() { + return t('Workspace Settings'); + }, + value: SettingScope.Workspace, + key: SettingScope.Workspace, + }, ]; const handleEditorSelect = (editorType: EditorType | 'not_set') => { diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index 9c449db9e..45b0f5548 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -63,7 +63,7 @@ export function SettingsDialog({ ); // Scope selector state (User by default) const [selectedScope, setSelectedScope] = useState( - SettingScope.Workspace, + SettingScope.User, ); // Active indices const [activeSettingIndex, setActiveSettingIndex] = useState(0); diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx index 7af589e39..a12fed797 100644 --- a/packages/cli/src/ui/components/ThemeDialog.tsx +++ b/packages/cli/src/ui/components/ThemeDialog.tsx @@ -39,7 +39,7 @@ export function ThemeDialog({ terminalWidth, }: ThemeDialogProps): React.JSX.Element { const [selectedScope, setSelectedScope] = useState( - SettingScope.Workspace, + SettingScope.User, ); // Track the currently highlighted theme name diff --git a/packages/cli/src/utils/dialogScopeUtils.ts b/packages/cli/src/utils/dialogScopeUtils.ts index 23caf7cea..027928abc 100644 --- a/packages/cli/src/utils/dialogScopeUtils.ts +++ b/packages/cli/src/utils/dialogScopeUtils.ts @@ -26,11 +26,11 @@ export const SCOPE_LABELS = { */ export function getScopeItems() { return [ + { label: SCOPE_LABELS[SettingScope.User], value: SettingScope.User }, { label: SCOPE_LABELS[SettingScope.Workspace], value: SettingScope.Workspace, }, - { label: SCOPE_LABELS[SettingScope.User], value: SettingScope.User }, // { label: SCOPE_LABELS[SettingScope.System], value: SettingScope.System }, ]; } From f6a753cf780e51272f5d9ee0f9448dca2b7effae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E4=BC=9F=E5=85=89?= Date: Thu, 8 Jan 2026 11:50:55 +0800 Subject: [PATCH 096/142] feat: Modify the selection order of user Settings and workspace Settings --- packages/cli/src/ui/components/ApprovalModeDialog.tsx | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/cli/src/ui/components/ApprovalModeDialog.tsx b/packages/cli/src/ui/components/ApprovalModeDialog.tsx index b69fc06ef..985375997 100644 --- a/packages/cli/src/ui/components/ApprovalModeDialog.tsx +++ b/packages/cli/src/ui/components/ApprovalModeDialog.tsx @@ -14,7 +14,6 @@ import type { LoadedSettings } from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js'; import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js'; import { useKeypress } from '../hooks/useKeypress.js'; -// import { ScopeSelector } from './shared/ScopeSelector.js'; import { t } from '../../i18n/index.js'; interface ApprovalModeDialogProps { @@ -103,10 +102,6 @@ export function ApprovalModeDialog({ setHighlightedMode(mode); }; - // const handleScopeHighlight = useCallback((scope: SettingScope) => { - // setSelectedScope(scope); - // }, []); - const handleScopeSelect = useCallback( (scope: SettingScope) => { onSelect(highlightedMode, scope); @@ -173,12 +168,6 @@ export function ApprovalModeDialog({ {/* Scope Selection */} - {/* */} Date: Thu, 8 Jan 2026 12:11:23 +0800 Subject: [PATCH 097/142] fix: best effort to use resolved authType/model across the repo --- packages/cli/src/acp-integration/acpAgent.ts | 2 +- packages/cli/src/config/auth.test.ts | 60 ++++++ packages/cli/src/config/auth.ts | 45 ++-- packages/cli/src/config/config.ts | 9 +- packages/cli/src/core/initializer.ts | 5 - packages/cli/src/gemini.test.tsx | 1 - packages/cli/src/gemini.tsx | 18 +- packages/cli/src/test-utils/render.tsx | 24 ++- packages/cli/src/ui/AppContainer.tsx | 16 +- packages/cli/src/ui/auth/AuthDialog.test.tsx | 13 +- packages/cli/src/ui/auth/AuthDialog.tsx | 15 +- packages/cli/src/ui/auth/useAuth.ts | 3 +- packages/cli/src/utils/modelConfigUtils.ts | 21 ++ packages/cli/src/utils/systemInfo.test.ts | 4 + packages/cli/src/utils/systemInfo.ts | 3 +- .../src/validateNonInterActiveAuth.test.ts | 199 ++++++++++-------- .../cli/src/validateNonInterActiveAuth.ts | 47 +---- packages/core/src/config/config.ts | 1 - packages/core/src/models/modelsConfig.test.ts | 123 ++++++++++- packages/core/src/models/modelsConfig.ts | 65 +----- 20 files changed, 414 insertions(+), 260 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index d56d196db..1850ba43f 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -311,7 +311,7 @@ class GeminiAgent { } private async ensureAuthenticated(config: Config): Promise { - const selectedType = this.settings.merged.security?.auth?.selectedType; + const selectedType = config.getAuthType(); if (!selectedType) { throw acp.RequestError.authRequired('No Selected Type'); } diff --git a/packages/cli/src/config/auth.test.ts b/packages/cli/src/config/auth.test.ts index 39652c5c5..ce3173c62 100644 --- a/packages/cli/src/config/auth.test.ts +++ b/packages/cli/src/config/auth.test.ts @@ -167,4 +167,64 @@ describe('validateAuthMethod', () => { expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBeNull(); }); + + it('should use config.modelsConfig.getModel() when Config is provided', () => { + // Settings has a different model + vi.mocked(settings.loadSettings).mockReturnValue({ + merged: { + model: { name: 'settings-model' }, + modelProviders: { + openai: [ + { id: 'settings-model', envKey: 'SETTINGS_API_KEY' }, + { id: 'cli-model', envKey: 'CLI_API_KEY' }, + ], + }, + }, + } as unknown as ReturnType); + + // Mock Config object that returns a different model (e.g., from CLI args) + const mockConfig = { + modelsConfig: { + getModel: vi.fn().mockReturnValue('cli-model'), + }, + } as unknown as import('@qwen-code/qwen-code-core').Config; + + // Set the env key for the CLI model, not the settings model + process.env['CLI_API_KEY'] = 'cli-key'; + + // Should use 'cli-model' from config.modelsConfig.getModel(), not 'settings-model' + const result = validateAuthMethod(AuthType.USE_OPENAI, mockConfig); + expect(result).toBeNull(); + expect(mockConfig.modelsConfig.getModel).toHaveBeenCalled(); + }); + + it('should fail validation when Config provides different model without matching env key', () => { + // Clean up any existing env keys first + delete process.env['CLI_API_KEY']; + delete process.env['SETTINGS_API_KEY']; + delete process.env['OPENAI_API_KEY']; + + vi.mocked(settings.loadSettings).mockReturnValue({ + merged: { + model: { name: 'settings-model' }, + modelProviders: { + openai: [ + { id: 'settings-model', envKey: 'SETTINGS_API_KEY' }, + { id: 'cli-model', envKey: 'CLI_API_KEY' }, + ], + }, + }, + } as unknown as ReturnType); + + const mockConfig = { + modelsConfig: { + getModel: vi.fn().mockReturnValue('cli-model'), + }, + } as unknown as import('@qwen-code/qwen-code-core').Config; + + // Don't set CLI_API_KEY - validation should fail + const result = validateAuthMethod(AuthType.USE_OPENAI, mockConfig); + expect(result).not.toBeNull(); + expect(result).toContain('CLI_API_KEY'); + }); }); diff --git a/packages/cli/src/config/auth.ts b/packages/cli/src/config/auth.ts index e3656a277..5fbe07dce 100644 --- a/packages/cli/src/config/auth.ts +++ b/packages/cli/src/config/auth.ts @@ -4,10 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { AuthType } from '@qwen-code/qwen-code-core'; -import type { - ModelProvidersConfig, - ProviderModelConfig, +import { + AuthType, + type Config, + type ModelProvidersConfig, + type ProviderModelConfig, } from '@qwen-code/qwen-code-core'; import { loadEnvironment, loadSettings, type Settings } from './settings.js'; import { t } from '../i18n/index.js'; @@ -45,14 +46,11 @@ function findModelConfig( /** * Check if API key is available for the given auth type and model configuration. * Prioritizes custom envKey from modelProviders over default environment variables. - * - * @returns hasKey - whether an API key is available - * @returns checkedEnvKey - the environment variable name that was checked - * @returns isExplicitEnvKey - true if model has explicit envKey configured (no apiKey fallback allowed) */ function hasApiKeyForAuth( authType: string, settings: Settings, + config?: Config, ): { hasKey: boolean; checkedEnvKey: string | undefined; @@ -61,7 +59,10 @@ function hasApiKeyForAuth( const modelProviders = settings.modelProviders as | ModelProvidersConfig | undefined; - const modelId = settings.model?.name; + + // Use config.modelsConfig.getModel() if available for accurate model ID resolution + // that accounts for CLI args, env vars, and settings. Fall back to settings.model.name. + const modelId = config?.modelsConfig.getModel() ?? settings.model?.name; // Try to find model-specific envKey from modelProviders const modelConfig = findModelConfig(modelProviders, authType, modelId); @@ -104,10 +105,15 @@ function hasApiKeyForAuth( * Generate API key error message based on auth check result. * Returns null if API key is present, otherwise returns the appropriate error message. */ -function getApiKeyError(authMethod: string, settings: Settings): string | null { +function getApiKeyError( + authMethod: string, + settings: Settings, + config?: Config, +): string | null { const { hasKey, checkedEnvKey, isExplicitEnvKey } = hasApiKeyForAuth( authMethod, settings, + config, ); if (hasKey) { return null; @@ -126,7 +132,13 @@ function getApiKeyError(authMethod: string, settings: Settings): string | null { ); } -export function validateAuthMethod(authMethod: string): string | null { +/** + * Validate that the required credentials and configuration exist for the given auth method. + */ +export function validateAuthMethod( + authMethod: string, + config?: Config, +): string | null { const settings = loadSettings(); loadEnvironment(settings.merged); @@ -134,6 +146,7 @@ export function validateAuthMethod(authMethod: string): string | null { const { hasKey, checkedEnvKey, isExplicitEnvKey } = hasApiKeyForAuth( authMethod, settings.merged, + config, ); if (!hasKey) { const envKeyHint = checkedEnvKey @@ -162,7 +175,7 @@ export function validateAuthMethod(authMethod: string): string | null { } if (authMethod === AuthType.USE_ANTHROPIC) { - const apiKeyError = getApiKeyError(authMethod, settings.merged); + const apiKeyError = getApiKeyError(authMethod, settings.merged, config); if (apiKeyError) { return apiKeyError; } @@ -171,7 +184,9 @@ export function validateAuthMethod(authMethod: string): string | null { const modelProviders = settings.merged.modelProviders as | ModelProvidersConfig | undefined; - const modelId = settings.merged.model?.name; + // Use config.modelsConfig.getModel() if available for accurate model ID + const modelId = + config?.modelsConfig.getModel() ?? settings.merged.model?.name; const modelConfig = findModelConfig(modelProviders, authMethod, modelId); if (modelConfig && !modelConfig.baseUrl) { @@ -187,7 +202,7 @@ export function validateAuthMethod(authMethod: string): string | null { } if (authMethod === AuthType.USE_GEMINI) { - const apiKeyError = getApiKeyError(authMethod, settings.merged); + const apiKeyError = getApiKeyError(authMethod, settings.merged, config); if (apiKeyError) { return apiKeyError; } @@ -195,7 +210,7 @@ export function validateAuthMethod(authMethod: string): string | null { } if (authMethod === AuthType.USE_VERTEX_AI) { - const apiKeyError = getApiKeyError(authMethod, settings.merged); + const apiKeyError = getApiKeyError(authMethod, settings.merged, config); if (apiKeyError) { return apiKeyError; } diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 9fffe8fae..a9f47f0b0 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -31,7 +31,10 @@ import { } from '@qwen-code/qwen-code-core'; import { extensionsCommand } from '../commands/extensions.js'; import type { Settings } from './settings.js'; -import { resolveCliGenerationConfig } from '../utils/modelConfigUtils.js'; +import { + resolveCliGenerationConfig, + getAuthTypeFromEnv, +} from '../utils/modelConfigUtils.js'; import yargs, { type Argv } from 'yargs'; import { hideBin } from 'yargs/helpers'; import * as fs from 'node:fs'; @@ -925,7 +928,9 @@ export async function loadCliConfig( const selectedAuthType = (argv.authType as AuthType | undefined) || - settings.security?.auth?.selectedType; + settings.security?.auth?.selectedType || + /* getAuthTypeFromEnv means no authType was explicitly provided, we infer the authType from env vars */ + getAuthTypeFromEnv(); // Unified resolution of generation config with source attribution const resolvedCliConfig = resolveCliGenerationConfig({ diff --git a/packages/cli/src/core/initializer.ts b/packages/cli/src/core/initializer.ts index 062c0b516..56f65b1c5 100644 --- a/packages/cli/src/core/initializer.ts +++ b/packages/cli/src/core/initializer.ts @@ -60,11 +60,6 @@ export async function initializeApp( } const themeError = validateTheme(settings); - // Open auth dialog if: - // 1. No authType was explicitly selected (neither from CLI --auth-type nor settings), OR - // 2. Authentication failed - // wasAuthTypeExplicitlyProvided() returns true if CLI or settings specified authType, - // false if using the default QWEN_OAUTH const shouldOpenAuthDialog = !config.modelsConfig.wasAuthTypeExplicitlyProvided() || !!authError; diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 09dcd013d..c2f971ec4 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -371,7 +371,6 @@ describe('gemini.tsx main function', () => { expect(inputArg).toBe('hello stream'); expect(validateAuthSpy).toHaveBeenCalledWith( - undefined, undefined, configStub, expect.any(Object), diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index da945546d..5dcc9a140 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Config, AuthType } from '@qwen-code/qwen-code-core'; +import type { Config } from '@qwen-code/qwen-code-core'; import { InputFormat, logUserPrompt } from '@qwen-code/qwen-code-core'; import { render } from 'ink'; import dns from 'node:dns'; @@ -252,22 +252,16 @@ export async function main() { argv, ); - if ( - settings.merged.security?.auth?.selectedType && - !settings.merged.security?.auth?.useExternal - ) { + if (!settings.merged.security?.auth?.useExternal) { // Validate authentication here because the sandbox will interfere with the Oauth2 web redirect. try { - const err = validateAuthMethod( - settings.merged.security.auth.selectedType, - ); + const authType = partialConfig.modelsConfig.getCurrentAuthType(); + const err = validateAuthMethod(authType, partialConfig); if (err) { throw new Error(err); } - await partialConfig.refreshAuth( - settings.merged.security.auth.selectedType, - ); + await partialConfig.refreshAuth(authType); } catch (err) { console.error('Error authenticating:', err); process.exit(1); @@ -440,8 +434,6 @@ export async function main() { } const nonInteractiveConfig = await validateNonInteractiveAuth( - (argv.authType as AuthType) || - settings.merged.security?.auth?.selectedType, settings.merged.security?.auth?.useExternal, config, settings, diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 690d765d8..9cb768b5e 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -6,10 +6,12 @@ import { render } from 'ink-testing-library'; import type React from 'react'; +import type { Config } from '@qwen-code/qwen-code-core'; import { LoadedSettings } from '../config/settings.js'; import { KeypressProvider } from '../ui/contexts/KeypressContext.js'; import { SettingsContext } from '../ui/contexts/SettingsContext.js'; import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js'; +import { ConfigContext } from '../ui/contexts/ConfigContext.js'; const mockSettings = new LoadedSettings( { path: '', settings: {}, originalSettings: {} }, @@ -22,14 +24,24 @@ const mockSettings = new LoadedSettings( export const renderWithProviders = ( component: React.ReactElement, - { shellFocus = true, settings = mockSettings } = {}, + { + shellFocus = true, + settings = mockSettings, + config = undefined, + }: { + shellFocus?: boolean; + settings?: LoadedSettings; + config?: Config; + } = {}, ): ReturnType => render( - - - {component} - - + + + + {component} + + + , ); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 1449a7f4b..f6b41b127 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -373,34 +373,32 @@ export const AppContainer = (props: AppContainerProps) => { if ( settings.merged.security?.auth?.enforcedType && - settings.merged.security?.auth.selectedType && + config.modelsConfig.getCurrentAuthType() && settings.merged.security?.auth.enforcedType !== - settings.merged.security?.auth.selectedType + config.modelsConfig.getCurrentAuthType() ) { onAuthError( t( 'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.', { enforcedType: settings.merged.security?.auth.enforcedType, - currentType: settings.merged.security?.auth.selectedType, + currentType: config.modelsConfig.getCurrentAuthType(), }, ), ); - } else if ( - settings.merged.security?.auth?.selectedType && - !settings.merged.security?.auth?.useExternal - ) { + } else if (!settings.merged.security?.auth?.useExternal) { const error = validateAuthMethod( - settings.merged.security.auth.selectedType, + config.modelsConfig.getCurrentAuthType(), + config, ); if (error) { onAuthError(error); } } }, [ - settings.merged.security?.auth?.selectedType, settings.merged.security?.auth?.enforcedType, settings.merged.security?.auth?.useExternal, + config, onAuthError, ]); diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx index 28e13e889..610cb1152 100644 --- a/packages/cli/src/ui/auth/AuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx @@ -7,6 +7,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { AuthDialog } from './AuthDialog.js'; import { LoadedSettings } from '../../config/settings.js'; +import type { Config } from '@qwen-code/qwen-code-core'; import { AuthType } from '@qwen-code/qwen-code-core'; import { renderWithProviders } from '../../test-utils/render.js'; import { UIStateContext } from '../contexts/UIStateContext.js'; @@ -43,17 +44,24 @@ const renderAuthDialog = ( settings: LoadedSettings, uiStateOverrides: Partial = {}, uiActionsOverrides: Partial = {}, + configAuthType: AuthType | undefined = undefined, + configApiKey: string | undefined = undefined, ) => { const uiState = createMockUIState(uiStateOverrides); const uiActions = createMockUIActions(uiActionsOverrides); + const mockConfig = { + getAuthType: vi.fn(() => configAuthType), + getContentGeneratorConfig: vi.fn(() => ({ apiKey: configApiKey })), + } as unknown as Config; + return renderWithProviders( , - { settings }, + { settings, config: mockConfig }, ); }; @@ -421,6 +429,7 @@ describe('AuthDialog', () => { settings, {}, { handleAuthSelect }, + undefined, // config.getAuthType() returns undefined ); await wait(); @@ -475,6 +484,7 @@ describe('AuthDialog', () => { settings, { authError: 'Initial error' }, { handleAuthSelect }, + undefined, // config.getAuthType() returns undefined ); await wait(); @@ -528,6 +538,7 @@ describe('AuthDialog', () => { settings, {}, { handleAuthSelect }, + AuthType.USE_OPENAI, // config.getAuthType() returns USE_OPENAI ); await wait(); diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index 44e2affaa..9ae1ea2a7 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -13,7 +13,7 @@ import { useKeypress } from '../hooks/useKeypress.js'; import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; -import { useSettings } from '../contexts/SettingsContext.js'; +import { useConfig } from '../contexts/ConfigContext.js'; import { t } from '../../i18n/index.js'; function parseDefaultAuthType( @@ -31,7 +31,7 @@ function parseDefaultAuthType( export function AuthDialog(): React.JSX.Element { const { pendingAuthType, authError } = useUIState(); const { handleAuthSelect: onAuthSelect } = useUIActions(); - const settings = useSettings(); + const config = useConfig(); const [errorMessage, setErrorMessage] = useState(null); const [selectedIndex, setSelectedIndex] = useState(null); @@ -57,9 +57,10 @@ export function AuthDialog(): React.JSX.Element { return item.value === pendingAuthType; } - // Priority 2: settings.merged.security?.auth?.selectedType - if (settings.merged.security?.auth?.selectedType) { - return item.value === settings.merged.security?.auth?.selectedType; + // Priority 2: config.getAuthType() - the source of truth + const currentAuthType = config.getAuthType(); + if (currentAuthType) { + return item.value === currentAuthType; } // Priority 3: QWEN_DEFAULT_AUTH_TYPE env var @@ -75,7 +76,7 @@ export function AuthDialog(): React.JSX.Element { }), ); - const hasApiKey = Boolean(settings.merged.security?.auth?.apiKey); + const hasApiKey = Boolean(config.getContentGeneratorConfig()?.apiKey); const currentSelectedAuthType = selectedIndex !== null ? items[selectedIndex]?.value @@ -99,7 +100,7 @@ export function AuthDialog(): React.JSX.Element { if (errorMessage) { return; } - if (settings.merged.security?.auth?.selectedType === undefined) { + if (config.getAuthType() === undefined) { // Prevent exiting if no auth method is set setErrorMessage( t( diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index bfc80ca70..7237ac33d 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -27,8 +27,7 @@ export const useAuthCommand = ( config: Config, addItem: (item: Omit, timestamp: number) => void, ) => { - const unAuthenticated = - settings.merged.security?.auth?.selectedType === undefined; + const unAuthenticated = config.getAuthType() === undefined; const [authState, setAuthState] = useState( unAuthenticated ? AuthState.Updating : AuthState.Unauthenticated, diff --git a/packages/cli/src/utils/modelConfigUtils.ts b/packages/cli/src/utils/modelConfigUtils.ts index d82252d58..b66039d75 100644 --- a/packages/cli/src/utils/modelConfigUtils.ts +++ b/packages/cli/src/utils/modelConfigUtils.ts @@ -42,6 +42,27 @@ export interface ResolvedCliGenerationConfig { sources: ContentGeneratorConfigSources; } +export function getAuthTypeFromEnv(): AuthType | undefined { + if (process.env['OPENAI_API_KEY']) { + return AuthType.USE_OPENAI; + } + if (process.env['QWEN_OAUTH']) { + return AuthType.QWEN_OAUTH; + } + + if (process.env['GEMINI_API_KEY']) { + return AuthType.USE_GEMINI; + } + if (process.env['GOOGLE_API_KEY']) { + return AuthType.USE_VERTEX_AI; + } + if (process.env['ANTHROPIC_API_KEY']) { + return AuthType.USE_ANTHROPIC; + } + + return undefined; +} + /** * Unified resolver for CLI generation config. * diff --git a/packages/cli/src/utils/systemInfo.test.ts b/packages/cli/src/utils/systemInfo.test.ts index 4849f1b1f..8d257605d 100644 --- a/packages/cli/src/utils/systemInfo.test.ts +++ b/packages/cli/src/utils/systemInfo.test.ts @@ -57,6 +57,7 @@ describe('systemInfo', () => { getModel: vi.fn().mockReturnValue('test-model'), getIdeMode: vi.fn().mockReturnValue(true), getSessionId: vi.fn().mockReturnValue('test-session-id'), + getAuthType: vi.fn().mockReturnValue('test-auth'), getContentGeneratorConfig: vi.fn().mockReturnValue({ baseUrl: 'https://api.openai.com', }), @@ -273,6 +274,9 @@ describe('systemInfo', () => { // Update the mock context to use OpenAI auth mockContext.services.settings.merged.security!.auth!.selectedType = AuthType.USE_OPENAI; + vi.mocked(mockContext.services.config!.getAuthType).mockReturnValue( + AuthType.USE_OPENAI, + ); const extendedInfo = await getExtendedSystemInfo(mockContext); diff --git a/packages/cli/src/utils/systemInfo.ts b/packages/cli/src/utils/systemInfo.ts index 5f067b3ae..1af3f41f5 100644 --- a/packages/cli/src/utils/systemInfo.ts +++ b/packages/cli/src/utils/systemInfo.ts @@ -115,8 +115,7 @@ export async function getSystemInfo( const sandboxEnv = getSandboxEnv(); const modelVersion = context.services.config?.getModel() || 'Unknown'; const cliVersion = await getCliVersion(); - const selectedAuthType = - context.services.settings.merged.security?.auth?.selectedType || ''; + const selectedAuthType = context.services.config?.getAuthType() || ''; const ideClient = await getIdeClientName(context); const sessionId = context.services.config?.getSessionId() || 'unknown'; diff --git a/packages/cli/src/validateNonInterActiveAuth.test.ts b/packages/cli/src/validateNonInterActiveAuth.test.ts index 2997847d3..dcaf6b118 100644 --- a/packages/cli/src/validateNonInterActiveAuth.test.ts +++ b/packages/cli/src/validateNonInterActiveAuth.test.ts @@ -14,6 +14,20 @@ import * as JsonOutputAdapterModule from './nonInteractive/io/JsonOutputAdapter. import * as StreamJsonOutputAdapterModule from './nonInteractive/io/StreamJsonOutputAdapter.js'; import * as cleanupModule from './utils/cleanup.js'; +// Helper to create a mock Config with modelsConfig +function createMockConfig(overrides?: Partial): Config { + return { + refreshAuth: vi.fn().mockResolvedValue('refreshed'), + getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT), + getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: undefined }), + modelsConfig: { + getModel: vi.fn().mockReturnValue('default-model'), + getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH), + }, + ...overrides, + } as unknown as Config; +} + describe('validateNonInterActiveAuth', () => { let originalEnvGeminiApiKey: string | undefined; let originalEnvVertexAi: string | undefined; @@ -107,17 +121,20 @@ describe('validateNonInterActiveAuth', () => { vi.restoreAllMocks(); }); - it('exits if no auth type is configured or env vars set', async () => { - const nonInteractiveConfig = { + it('exits if validateAuthMethod fails for default auth type', async () => { + // Mock validateAuthMethod to return error (e.g., missing API key) + vi.spyOn(auth, 'validateAuthMethod').mockReturnValue( + 'Missing API key for authentication', + ); + const nonInteractiveConfig = createMockConfig({ refreshAuth: refreshAuthMock, - getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT), - getContentGeneratorConfig: vi - .fn() - .mockReturnValue({ authType: undefined }), - } as unknown as Config; + modelsConfig: { + getModel: vi.fn().mockReturnValue('default-model'), + getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH), + }, + }); try { await validateNonInteractiveAuth( - undefined, undefined, nonInteractiveConfig, mockSettings, @@ -127,22 +144,21 @@ describe('validateNonInterActiveAuth', () => { expect((e as Error).message).toContain('process.exit(1) called'); } expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringContaining('Please set an Auth method'), + expect.stringContaining('Missing API key'), ); expect(processExitSpy).toHaveBeenCalledWith(1); }); it('uses USE_OPENAI if OPENAI_API_KEY is set', async () => { process.env['OPENAI_API_KEY'] = 'fake-openai-key'; - const nonInteractiveConfig = { + const nonInteractiveConfig = createMockConfig({ refreshAuth: refreshAuthMock, - getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT), - getContentGeneratorConfig: vi - .fn() - .mockReturnValue({ authType: undefined }), - } as unknown as Config; + modelsConfig: { + getModel: vi.fn().mockReturnValue('default-model'), + getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI), + }, + }); await validateNonInteractiveAuth( - undefined, undefined, nonInteractiveConfig, mockSettings, @@ -151,15 +167,14 @@ describe('validateNonInterActiveAuth', () => { }); it('uses configured QWEN_OAUTH if provided', async () => { - const nonInteractiveConfig = { + const nonInteractiveConfig = createMockConfig({ refreshAuth: refreshAuthMock, - getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT), - getContentGeneratorConfig: vi - .fn() - .mockReturnValue({ authType: undefined }), - } as unknown as Config; + modelsConfig: { + getModel: vi.fn().mockReturnValue('default-model'), + getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH), + }, + }); await validateNonInteractiveAuth( - AuthType.QWEN_OAUTH, undefined, nonInteractiveConfig, mockSettings, @@ -170,16 +185,11 @@ describe('validateNonInterActiveAuth', () => { it('exits if validateAuthMethod returns error', async () => { // Mock validateAuthMethod to return error vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!'); - const nonInteractiveConfig = { + const nonInteractiveConfig = createMockConfig({ refreshAuth: refreshAuthMock, - getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT), - getContentGeneratorConfig: vi - .fn() - .mockReturnValue({ authType: undefined }), - } as unknown as Config; + }); try { await validateNonInteractiveAuth( - AuthType.USE_GEMINI, undefined, nonInteractiveConfig, mockSettings, @@ -197,14 +207,13 @@ describe('validateNonInterActiveAuth', () => { const validateAuthMethodSpy = vi .spyOn(auth, 'validateAuthMethod') .mockReturnValue('Auth error!'); - const nonInteractiveConfig = { + const nonInteractiveConfig = createMockConfig({ refreshAuth: refreshAuthMock, - } as unknown as Config; + }); - // Even with an invalid auth type, it should not exit - // because validation is skipped. + // Even with validation errors, it should not exit + // because validation is skipped when useExternalAuth is true. await validateNonInteractiveAuth( - 'invalid-auth-type' as AuthType, true, // useExternalAuth = true nonInteractiveConfig, mockSettings, @@ -213,8 +222,8 @@ describe('validateNonInterActiveAuth', () => { expect(validateAuthMethodSpy).not.toHaveBeenCalled(); expect(consoleErrorSpy).not.toHaveBeenCalled(); expect(processExitSpy).not.toHaveBeenCalled(); - // We still expect refreshAuth to be called with the (invalid) type - expect(refreshAuthMock).toHaveBeenCalledWith('invalid-auth-type'); + // refreshAuth is called with the authType from config.modelsConfig.getCurrentAuthType() + expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.QWEN_OAUTH); }); it('uses enforcedAuthType if provided', async () => { @@ -222,11 +231,14 @@ describe('validateNonInterActiveAuth', () => { mockSettings.merged.security!.auth!.selectedType = AuthType.USE_OPENAI; // Set required env var for USE_OPENAI to ensure enforcedAuthType takes precedence process.env['OPENAI_API_KEY'] = 'fake-key'; - const nonInteractiveConfig = { + const nonInteractiveConfig = createMockConfig({ refreshAuth: refreshAuthMock, - } as unknown as Config; + modelsConfig: { + getModel: vi.fn().mockReturnValue('default-model'), + getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI), + }, + }); await validateNonInteractiveAuth( - AuthType.USE_OPENAI, undefined, nonInteractiveConfig, mockSettings, @@ -237,16 +249,15 @@ describe('validateNonInterActiveAuth', () => { it('exits if currentAuthType does not match enforcedAuthType', async () => { mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH; process.env['OPENAI_API_KEY'] = 'fake-key'; - const nonInteractiveConfig = { + const nonInteractiveConfig = createMockConfig({ refreshAuth: refreshAuthMock, - getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT), - getContentGeneratorConfig: vi - .fn() - .mockReturnValue({ authType: undefined }), - } as unknown as Config; + modelsConfig: { + getModel: vi.fn().mockReturnValue('default-model'), + getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI), + }, + }); try { await validateNonInteractiveAuth( - AuthType.USE_OPENAI, undefined, nonInteractiveConfig, mockSettings, @@ -279,18 +290,21 @@ describe('validateNonInterActiveAuth', () => { ); }); - it('emits error result and exits when no auth is configured', async () => { - const nonInteractiveConfig = { + it('emits error result and exits when validateAuthMethod fails', async () => { + vi.spyOn(auth, 'validateAuthMethod').mockReturnValue( + 'Missing API key for authentication', + ); + const nonInteractiveConfig = createMockConfig({ refreshAuth: refreshAuthMock, getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON), - getContentGeneratorConfig: vi - .fn() - .mockReturnValue({ authType: undefined }), - } as unknown as Config; + modelsConfig: { + getModel: vi.fn().mockReturnValue('default-model'), + getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH), + }, + }); try { await validateNonInteractiveAuth( - undefined, undefined, nonInteractiveConfig, mockSettings, @@ -302,9 +316,7 @@ describe('validateNonInterActiveAuth', () => { expect(emitResultMock).toHaveBeenCalledWith({ isError: true, - errorMessage: expect.stringContaining( - 'Please set an Auth method in your', - ), + errorMessage: expect.stringContaining('Missing API key'), durationMs: 0, apiDurationMs: 0, numTurns: 0, @@ -319,17 +331,17 @@ describe('validateNonInterActiveAuth', () => { mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH; process.env['OPENAI_API_KEY'] = 'fake-key'; - const nonInteractiveConfig = { + const nonInteractiveConfig = createMockConfig({ refreshAuth: refreshAuthMock, getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON), - getContentGeneratorConfig: vi - .fn() - .mockReturnValue({ authType: undefined }), - } as unknown as Config; + modelsConfig: { + getModel: vi.fn().mockReturnValue('default-model'), + getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI), + }, + }); try { await validateNonInteractiveAuth( - undefined, undefined, nonInteractiveConfig, mockSettings, @@ -354,21 +366,21 @@ describe('validateNonInterActiveAuth', () => { expect(consoleErrorSpy).not.toHaveBeenCalled(); }); - it('emits error result and exits when validateAuthMethod fails', async () => { + it('emits error result and exits when API key validation fails', async () => { vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!'); process.env['OPENAI_API_KEY'] = 'fake-key'; - const nonInteractiveConfig = { + const nonInteractiveConfig = createMockConfig({ refreshAuth: refreshAuthMock, getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON), - getContentGeneratorConfig: vi - .fn() - .mockReturnValue({ authType: undefined }), - } as unknown as Config; + modelsConfig: { + getModel: vi.fn().mockReturnValue('default-model'), + getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI), + }, + }); try { await validateNonInteractiveAuth( - AuthType.USE_OPENAI, undefined, nonInteractiveConfig, mockSettings, @@ -413,19 +425,22 @@ describe('validateNonInterActiveAuth', () => { ); }); - it('emits error result and exits when no auth is configured', async () => { - const nonInteractiveConfig = { + it('emits error result and exits when validateAuthMethod fails', async () => { + vi.spyOn(auth, 'validateAuthMethod').mockReturnValue( + 'Missing API key for authentication', + ); + const nonInteractiveConfig = createMockConfig({ refreshAuth: refreshAuthMock, getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON), getIncludePartialMessages: vi.fn().mockReturnValue(false), - getContentGeneratorConfig: vi - .fn() - .mockReturnValue({ authType: undefined }), - } as unknown as Config; + modelsConfig: { + getModel: vi.fn().mockReturnValue('default-model'), + getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH), + }, + }); try { await validateNonInteractiveAuth( - undefined, undefined, nonInteractiveConfig, mockSettings, @@ -437,9 +452,7 @@ describe('validateNonInterActiveAuth', () => { expect(emitResultMock).toHaveBeenCalledWith({ isError: true, - errorMessage: expect.stringContaining( - 'Please set an Auth method in your', - ), + errorMessage: expect.stringContaining('Missing API key'), durationMs: 0, apiDurationMs: 0, numTurns: 0, @@ -454,18 +467,18 @@ describe('validateNonInterActiveAuth', () => { mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH; process.env['OPENAI_API_KEY'] = 'fake-key'; - const nonInteractiveConfig = { + const nonInteractiveConfig = createMockConfig({ refreshAuth: refreshAuthMock, getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON), getIncludePartialMessages: vi.fn().mockReturnValue(false), - getContentGeneratorConfig: vi - .fn() - .mockReturnValue({ authType: undefined }), - } as unknown as Config; + modelsConfig: { + getModel: vi.fn().mockReturnValue('default-model'), + getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI), + }, + }); try { await validateNonInteractiveAuth( - undefined, undefined, nonInteractiveConfig, mockSettings, @@ -490,22 +503,22 @@ describe('validateNonInterActiveAuth', () => { expect(consoleErrorSpy).not.toHaveBeenCalled(); }); - it('emits error result and exits when validateAuthMethod fails', async () => { + it('emits error result and exits when API key validation fails', async () => { vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!'); process.env['OPENAI_API_KEY'] = 'fake-key'; - const nonInteractiveConfig = { + const nonInteractiveConfig = createMockConfig({ refreshAuth: refreshAuthMock, getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON), getIncludePartialMessages: vi.fn().mockReturnValue(false), - getContentGeneratorConfig: vi - .fn() - .mockReturnValue({ authType: undefined }), - } as unknown as Config; + modelsConfig: { + getModel: vi.fn().mockReturnValue('default-model'), + getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI), + }, + }); try { await validateNonInteractiveAuth( - AuthType.USE_OPENAI, undefined, nonInteractiveConfig, mockSettings, diff --git a/packages/cli/src/validateNonInterActiveAuth.ts b/packages/cli/src/validateNonInterActiveAuth.ts index be5425a97..2a4df7ca6 100644 --- a/packages/cli/src/validateNonInterActiveAuth.ts +++ b/packages/cli/src/validateNonInterActiveAuth.ts @@ -5,63 +5,30 @@ */ import type { Config } from '@qwen-code/qwen-code-core'; -import { AuthType, OutputFormat } from '@qwen-code/qwen-code-core'; -import { USER_SETTINGS_PATH } from './config/settings.js'; +import { OutputFormat } from '@qwen-code/qwen-code-core'; import { validateAuthMethod } from './config/auth.js'; import { type LoadedSettings } from './config/settings.js'; import { JsonOutputAdapter } from './nonInteractive/io/JsonOutputAdapter.js'; import { StreamJsonOutputAdapter } from './nonInteractive/io/StreamJsonOutputAdapter.js'; import { runExitCleanup } from './utils/cleanup.js'; -function getAuthTypeFromEnv(): AuthType | undefined { - if (process.env['OPENAI_API_KEY']) { - return AuthType.USE_OPENAI; - } - if (process.env['QWEN_OAUTH']) { - return AuthType.QWEN_OAUTH; - } - - if (process.env['GEMINI_API_KEY']) { - return AuthType.USE_GEMINI; - } - if (process.env['GOOGLE_API_KEY']) { - return AuthType.USE_VERTEX_AI; - } - if (process.env['ANTHROPIC_API_KEY']) { - return AuthType.USE_ANTHROPIC; - } - - return undefined; -} - export async function validateNonInteractiveAuth( - configuredAuthType: AuthType | undefined, useExternalAuth: boolean | undefined, nonInteractiveConfig: Config, settings: LoadedSettings, ): Promise { try { + // Get the actual authType from config which has already resolved CLI args, env vars, and settings + const authType = nonInteractiveConfig.modelsConfig.getCurrentAuthType(); + const enforcedType = settings.merged.security?.auth?.enforcedType; - if (enforcedType) { - const currentAuthType = getAuthTypeFromEnv(); - if (currentAuthType !== enforcedType) { - const message = `The configured auth type is ${enforcedType}, but the current auth type is ${currentAuthType}. Please re-authenticate with the correct type.`; - throw new Error(message); - } - } - - const effectiveAuthType = - enforcedType || configuredAuthType || getAuthTypeFromEnv(); - - if (!effectiveAuthType) { - const message = `Please set an Auth method in your ${USER_SETTINGS_PATH} or specify one of the following environment variables before running: QWEN_OAUTH, OPENAI_API_KEY`; + if (enforcedType && enforcedType !== authType) { + const message = `The configured auth type is ${enforcedType}, but the current auth type is ${authType}. Please re-authenticate with the correct type.`; throw new Error(message); } - const authType: AuthType = effectiveAuthType as AuthType; - if (!useExternalAuth) { - const err = validateAuthMethod(String(authType)); + const err = validateAuthMethod(authType, nonInteractiveConfig); if (err != null) { throw new Error(err); } diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index eae1dd44b..e96c9c0ba 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -632,7 +632,6 @@ export class Config { // - generationConfig.authType may have a default value from resolvers this._modelsConfig = new ModelsConfig({ initialAuthType: params.authType ?? params.generationConfig?.authType, - initialModelId: params.model, modelProvidersConfig: this.modelProvidersConfig, generationConfig: { model: params.model, diff --git a/packages/core/src/models/modelsConfig.test.ts b/packages/core/src/models/modelsConfig.test.ts index 8f8441e00..ae2808d75 100644 --- a/packages/core/src/models/modelsConfig.test.ts +++ b/packages/core/src/models/modelsConfig.test.ts @@ -174,8 +174,10 @@ describe('ModelsConfig', () => { const modelsConfig = new ModelsConfig({ initialAuthType: AuthType.USE_OPENAI, - initialModelId: 'model-a', modelProvidersConfig, + generationConfig: { + model: 'model-a', + }, }); // Simulate key prompt flow / explicit key provided via CLI/settings. @@ -209,7 +211,6 @@ describe('ModelsConfig', () => { // Simulate settings.model.generationConfig being resolved into ModelsConfig.generationConfig const modelsConfig = new ModelsConfig({ initialAuthType: AuthType.USE_OPENAI, - initialModelId: 'model-a', modelProvidersConfig, generationConfig: { model: 'model-a', @@ -271,7 +272,6 @@ describe('ModelsConfig', () => { const modelsConfig = new ModelsConfig({ initialAuthType: AuthType.USE_OPENAI, - initialModelId: 'model-a', modelProvidersConfig, generationConfig: { model: 'model-a', @@ -463,4 +463,121 @@ describe('ModelsConfig', () => { expect(gc.apiKey).toBe('QWEN_OAUTH_DYNAMIC_TOKEN'); expect(gc.apiKeyEnvKey).toBeUndefined(); }); + + it('should maintain consistency between currentModelId and _generationConfig.model after initialization', () => { + const modelProvidersConfig: ModelProvidersConfig = { + openai: [ + { + id: 'test-model', + name: 'Test Model', + baseUrl: 'https://api.example.com/v1', + envKey: 'TEST_API_KEY', + }, + ], + }; + + // Test case 1: generationConfig.model provided with other config + const config1 = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + modelProvidersConfig, + generationConfig: { + model: 'test-model', + samplingParams: { temperature: 0.5 }, + }, + }); + expect(config1.getModel()).toBe('test-model'); + expect(config1.getGenerationConfig().model).toBe('test-model'); + + // Test case 2: generationConfig.model provided + const config2 = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + modelProvidersConfig, + generationConfig: { + model: 'test-model', + }, + }); + expect(config2.getModel()).toBe('test-model'); + expect(config2.getGenerationConfig().model).toBe('test-model'); + + // Test case 3: no model provided (empty string fallback) + const config3 = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + modelProvidersConfig, + generationConfig: {}, + }); + expect(config3.getModel()).toBe('coder-model'); // Falls back to DEFAULT_QWEN_MODEL + expect(config3.getGenerationConfig().model).toBeUndefined(); + }); + + it('should maintain consistency between currentModelId and _generationConfig.model during syncAfterAuthRefresh', () => { + const modelProvidersConfig: ModelProvidersConfig = { + openai: [ + { + id: 'model-a', + name: 'Model A', + baseUrl: 'https://api.example.com/v1', + envKey: 'API_KEY_A', + }, + ], + }; + + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + modelProvidersConfig, + generationConfig: { + model: 'model-a', + }, + }); + + // Manually set credentials to trigger preserveManualCredentials path + modelsConfig.updateCredentials({ apiKey: 'manual-key' }); + + // syncAfterAuthRefresh with a different modelId + modelsConfig.syncAfterAuthRefresh(AuthType.USE_OPENAI, 'model-a'); + + // Both should be consistent + expect(modelsConfig.getModel()).toBe('model-a'); + expect(modelsConfig.getGenerationConfig().model).toBe('model-a'); + }); + + it('should maintain consistency between currentModelId and _generationConfig.model during setModel', async () => { + const modelProvidersConfig: ModelProvidersConfig = { + openai: [ + { + id: 'model-a', + name: 'Model A', + baseUrl: 'https://api.example.com/v1', + envKey: 'API_KEY_A', + }, + ], + }; + + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + modelProvidersConfig, + }); + + // setModel with a raw model ID + await modelsConfig.setModel('custom-model'); + + // Both should be consistent + expect(modelsConfig.getModel()).toBe('custom-model'); + expect(modelsConfig.getGenerationConfig().model).toBe('custom-model'); + }); + + it('should maintain consistency between currentModelId and _generationConfig.model during updateCredentials', () => { + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + }); + + // updateCredentials with model + modelsConfig.updateCredentials({ + apiKey: 'test-key', + model: 'updated-model', + }); + + // Both should be consistent + expect(modelsConfig.getModel()).toBe('updated-model'); + expect(modelsConfig.getGenerationConfig().model).toBe('updated-model'); + }); }); diff --git a/packages/core/src/models/modelsConfig.ts b/packages/core/src/models/modelsConfig.ts index 5e066b96f..e6908aeea 100644 --- a/packages/core/src/models/modelsConfig.ts +++ b/packages/core/src/models/modelsConfig.ts @@ -45,8 +45,6 @@ export type OnModelChangeCallback = ( export interface ModelsConfigOptions { /** Initial authType from settings */ initialAuthType?: AuthType; - /** Initial model ID from settings */ - initialModelId?: string; /** Model providers configuration */ modelProvidersConfig?: ModelProvidersConfig; /** Generation config from CLI/settings */ @@ -73,7 +71,6 @@ export class ModelsConfig { // Current selection state private currentAuthType: AuthType; - private currentModelId: string; // Generation config state private _generationConfig: Partial; @@ -119,7 +116,6 @@ export class ModelsConfig { private snapshotState(): { currentAuthType: AuthType; - currentModelId: string; generationConfig: Partial; generationConfigSources: ContentGeneratorConfigSources; strictModelProviderSelection: boolean; @@ -128,7 +124,6 @@ export class ModelsConfig { } { return { currentAuthType: this.currentAuthType, - currentModelId: this.currentModelId, generationConfig: ModelsConfig.deepClone(this._generationConfig), generationConfigSources: ModelsConfig.deepClone( this.generationConfigSources, @@ -143,7 +138,6 @@ export class ModelsConfig { snapshot: ReturnType, ): void { this.currentAuthType = snapshot.currentAuthType; - this.currentModelId = snapshot.currentModelId; this._generationConfig = snapshot.generationConfig; this.generationConfigSources = snapshot.generationConfigSources; this.strictModelProviderSelection = snapshot.strictModelProviderSelection; @@ -157,8 +151,9 @@ export class ModelsConfig { this.onModelChange = options.onModelChange; // Initialize generation config + // Note: generationConfig.model should already be fully resolved by ModelConfigResolver + // before ModelsConfig is instantiated, so we use it as the single source of truth this._generationConfig = { - model: options.initialModelId, ...(options.generationConfig || {}), }; this.generationConfigSources = options.generationConfigSources || {}; @@ -168,54 +163,13 @@ export class ModelsConfig { // Initialize selection state this.currentAuthType = options.initialAuthType || AuthType.QWEN_OAUTH; - this.currentModelId = options.initialModelId || ''; - - // Validate and initialize default selection - this.initializeDefaultSelection(); - } - - /** - * Initialize default selection based on settings/environment. - * - * Note: The generationConfig passed to ModelsConfig should already be fully - * resolved by ModelConfigResolver, which handles CLI args, env vars, and settings. - * This method primarily validates and sets up internal state. - */ - private initializeDefaultSelection(): void { - // If generationConfig already has a model (resolved by ModelConfigResolver), - // use that as the current selection - if (this._generationConfig.model) { - this.currentModelId = this._generationConfig.model; - return; - } - - // Check if persisted model selection is valid - if ( - this.currentModelId && - this.modelRegistry.hasModel(this.currentAuthType, this.currentModelId) - ) { - return; - } - - // Use registry default - const defaultModel = this.modelRegistry.getDefaultModelForAuthType( - this.currentAuthType, - ); - if (defaultModel) { - this.currentModelId = defaultModel.id; - if (!this._generationConfig.model) { - this._generationConfig.model = defaultModel.id; - } - } } /** * Get current model ID */ getModel(): string { - return ( - this._generationConfig.model || this.currentModelId || DEFAULT_QWEN_MODEL - ); + return this._generationConfig.model || DEFAULT_QWEN_MODEL; } /** @@ -269,7 +223,6 @@ export class ModelsConfig { ) { this.strictModelProviderSelection = false; this._generationConfig.model = newModel; - this.currentModelId = newModel; this.generationConfigSources['model'] = { kind: 'programmatic', detail: metadata?.reason || 'setModel', @@ -286,7 +239,6 @@ export class ModelsConfig { // Raw model override: update generation config in-place this.strictModelProviderSelection = false; this._generationConfig.model = newModel; - this.currentModelId = newModel; this.generationConfigSources['model'] = { kind: 'programmatic', detail: metadata?.reason || 'setModel', @@ -322,12 +274,9 @@ export class ModelsConfig { // Apply model defaults this.applyResolvedModelDefaults(model); - // Update selection state - this.currentModelId = modelId; - const requiresRefresh = isAuthTypeChange ? true - : this.checkRequiresRefresh(snapshot.currentModelId); + : this.checkRequiresRefresh(snapshot.generationConfig.model || ''); if (this.onModelChange) { await this.onModelChange(authType, requiresRefresh); @@ -395,7 +344,6 @@ export class ModelsConfig { } if (credentials.model) { this._generationConfig.model = credentials.model; - this.currentModelId = credentials.model; this.generationConfigSources['model'] = { kind: 'programmatic', detail: 'updateCredentials', @@ -603,7 +551,7 @@ export class ModelsConfig { ); const currentModel = this.modelRegistry.getModel( this.currentAuthType, - this.currentModelId, + this._generationConfig.model || '', ); // If either model is not in registry, require refresh to be safe @@ -644,7 +592,7 @@ export class ModelsConfig { this.strictModelProviderSelection = false; this.currentAuthType = authType; if (modelId) { - this.currentModelId = modelId; + this._generationConfig.model = modelId; } return; } @@ -656,7 +604,6 @@ export class ModelsConfig { if (resolved) { this.applyResolvedModelDefaults(resolved); this.currentAuthType = authType; - this.currentModelId = modelId; } } else { this.currentAuthType = authType; From 9653dc90d57bbf39732f8fd01b43f9922d59560f Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 8 Jan 2026 14:23:13 +0800 Subject: [PATCH 098/142] Add skills command with completion support --- docs/users/features/commands.md | 1 + docs/users/features/skills.md | 8 ++ .../cli/src/services/BuiltinCommandLoader.ts | 2 + packages/cli/src/ui/commands/skillsCommand.ts | 131 ++++++++++++++++++ packages/cli/src/ui/commands/types.ts | 8 +- .../src/ui/components/SuggestionsDisplay.tsx | 2 +- .../src/ui/hooks/useSlashCompletion.test.ts | 39 ++++++ .../cli/src/ui/hooks/useSlashCompletion.ts | 22 ++- 8 files changed, 207 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/ui/commands/skillsCommand.ts diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md index 333394631..5583f3494 100644 --- a/docs/users/features/commands.md +++ b/docs/users/features/commands.md @@ -59,6 +59,7 @@ Commands for managing AI tools and models. | ---------------- | --------------------------------------------- | --------------------------------------------- | | `/mcp` | List configured MCP servers and tools | `/mcp`, `/mcp desc` | | `/tools` | Display currently available tool list | `/tools`, `/tools desc` | +| `/skills` | List and run available skills (experimental) | `/skills`, `/skills ` | | `/approval-mode` | Change approval mode for tool usage | `/approval-mode --project` | | →`plan` | Analysis only, no execution | Secure review | | →`default` | Require approval for edits | Daily use | diff --git a/docs/users/features/skills.md b/docs/users/features/skills.md index a0cabcf1a..0387ff389 100644 --- a/docs/users/features/skills.md +++ b/docs/users/features/skills.md @@ -27,6 +27,14 @@ Agent Skills package expertise into discoverable capabilities. Each Skill consis Skills are **model-invoked** — the model autonomously decides when to use them based on your request and the Skill’s description. This is different from slash commands, which are **user-invoked** (you explicitly type `/command`). +If you want to invoke a Skill explicitly, use the `/skills` slash command: + +```bash +/skills +``` + +The `/skills` command is only available when you run with `--experimental-skills`. Use autocomplete to browse available Skills and descriptions. + ### Benefits - Extend Qwen Code for your workflows diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index c9fc5801a..d7993ab29 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -31,6 +31,7 @@ import { quitCommand } from '../ui/commands/quitCommand.js'; import { restoreCommand } from '../ui/commands/restoreCommand.js'; import { resumeCommand } from '../ui/commands/resumeCommand.js'; import { settingsCommand } from '../ui/commands/settingsCommand.js'; +import { skillsCommand } from '../ui/commands/skillsCommand.js'; import { statsCommand } from '../ui/commands/statsCommand.js'; import { summaryCommand } from '../ui/commands/summaryCommand.js'; import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js'; @@ -78,6 +79,7 @@ export class BuiltinCommandLoader implements ICommandLoader { quitCommand, restoreCommand(this.config), resumeCommand, + ...(this.config?.getExperimentalSkills() ? [skillsCommand] : []), statsCommand, summaryCommand, themeCommand, diff --git a/packages/cli/src/ui/commands/skillsCommand.ts b/packages/cli/src/ui/commands/skillsCommand.ts new file mode 100644 index 000000000..25433426a --- /dev/null +++ b/packages/cli/src/ui/commands/skillsCommand.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + CommandKind, + type CommandCompletionItem, + type CommandContext, + type SlashCommand, +} from './types.js'; +import { MessageType } from '../types.js'; +import { t } from '../../i18n/index.js'; +import { AsyncFzf } from 'fzf'; +import type { SkillConfig } from '@qwen-code/qwen-code-core'; + +export const skillsCommand: SlashCommand = { + name: 'skills', + get description() { + return t('List available skills.'); + }, + kind: CommandKind.BUILT_IN, + action: async (context: CommandContext, args?: string) => { + const rawArgs = args?.trim() ?? ''; + const [skillName = ''] = rawArgs.split(/\s+/); + + const skillManager = context.services.config?.getSkillManager(); + if (!skillManager) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: t('Could not retrieve skill manager.'), + }, + Date.now(), + ); + return; + } + + const skills = await skillManager.listSkills(); + if (skills.length === 0) { + context.ui.addItem( + { + type: MessageType.WARNING, + text: t('No skills are currently available.'), + }, + Date.now(), + ); + return; + } + + if (!skillName) { + context.ui.addItem( + { + type: MessageType.INFO, + text: t('Use /skills to select a skill'), + }, + Date.now(), + ); + return; + } + const normalizedName = skillName.toLowerCase(); + const hasSkill = skills.some( + (skill) => skill.name.toLowerCase() === normalizedName, + ); + + if (!hasSkill) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: t('Unknown skill: {{name}}', { name: skillName }), + }, + Date.now(), + ); + return; + } + + const rawInput = context.invocation?.raw ?? `/skills ${rawArgs}`; + return { + type: 'submit_prompt', + content: [{ text: rawInput }], + }; + }, + completion: async ( + context: CommandContext, + partialArg: string, + ): Promise => { + const skillManager = context.services.config?.getSkillManager(); + if (!skillManager) { + return []; + } + + const skills = await skillManager.listSkills(); + const normalizedPartial = partialArg.trim(); + const matches = await getSkillMatches(skills, normalizedPartial); + + return matches.map((skill) => ({ + value: skill.name, + description: skill.description, + })); + }, +}; + +async function getSkillMatches( + skills: SkillConfig[], + query: string, +): Promise { + if (!query) { + return skills; + } + + const names = skills.map((skill) => skill.name); + const skillMap = new Map(skills.map((skill) => [skill.name, skill])); + + try { + const fzf = new AsyncFzf(names, { + fuzzy: 'v2', + casing: 'case-insensitive', + }); + const results = (await fzf.find(query)) as Array<{ item: string }>; + return results + .map((result) => skillMap.get(result.item)) + .filter((skill): skill is SkillConfig => !!skill); + } catch (error) { + console.error('[skillsCommand] Fuzzy match failed:', error); + const lowerQuery = query.toLowerCase(); + return skills.filter((skill) => + skill.name.toLowerCase().startsWith(lowerQuery), + ); + } +} diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 0762e8b9c..6c03ec136 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -209,6 +209,12 @@ export enum CommandKind { MCP_PROMPT = 'mcp-prompt', } +export interface CommandCompletionItem { + value: string; + label?: string; + description?: string; +} + // The standardized contract for any command in the system. export interface SlashCommand { name: string; @@ -234,7 +240,7 @@ export interface SlashCommand { completion?: ( context: CommandContext, partialArg: string, - ) => Promise; + ) => Promise | null>; subCommands?: SlashCommand[]; } diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.tsx index d5b95fe67..6bcac5c56 100644 --- a/packages/cli/src/ui/components/SuggestionsDisplay.tsx +++ b/packages/cli/src/ui/components/SuggestionsDisplay.tsx @@ -106,7 +106,7 @@ export function SuggestionsDisplay({ {suggestion.description && ( - + {suggestion.description} diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts index 2827cc453..b813ff8db 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts @@ -573,6 +573,45 @@ describe('useSlashCompletion', () => { }); }); + it('should map completion items with descriptions for argument suggestions', async () => { + const mockCompletionFn = vi.fn().mockResolvedValue([ + { value: 'pdf', description: 'Create PDF documents' }, + { value: 'xlsx', description: 'Work with spreadsheets' }, + ]); + + const slashCommands = [ + createTestCommand({ + name: 'skills', + description: 'List available skills', + completion: mockCompletionFn, + }), + ]; + + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/skills ', + slashCommands, + mockCommandContext, + ), + ); + + await waitFor(() => { + expect(result.current.suggestions).toEqual([ + { + label: 'pdf', + value: 'pdf', + description: 'Create PDF documents', + }, + { + label: 'xlsx', + value: 'xlsx', + description: 'Work with spreadsheets', + }, + ]); + }); + }); + it('should call command.completion with an empty string when args start with a space', async () => { const mockCompletionFn = vi .fn() diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.ts b/packages/cli/src/ui/hooks/useSlashCompletion.ts index dbd9b463b..4d5fd7874 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.ts @@ -9,6 +9,7 @@ import { AsyncFzf } from 'fzf'; import type { Suggestion } from '../components/SuggestionsDisplay.js'; import { CommandKind, + type CommandCompletionItem, type CommandContext, type SlashCommand, } from '../commands/types.js'; @@ -215,10 +216,9 @@ function useCommandSuggestions( )) || []; if (!signal.aborted) { - const finalSuggestions = results.map((s) => ({ - label: s, - value: s, - })); + const finalSuggestions = results + .map((item) => toSuggestion(item)) + .filter((suggestion): suggestion is Suggestion => !!suggestion); setSuggestions(finalSuggestions); setIsLoading(false); } @@ -310,6 +310,20 @@ function useCommandSuggestions( return { suggestions, isLoading }; } +function toSuggestion(item: string | CommandCompletionItem): Suggestion | null { + if (typeof item === 'string') { + return { label: item, value: item }; + } + if (!item.value) { + return null; + } + return { + label: item.label ?? item.value, + value: item.value, + description: item.description, + }; +} + function useCompletionPositions( query: string | null, parserResult: CommandParserResult, From b5bcc07223398ad936bcfd2c9de81b4f6da166e2 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 8 Jan 2026 14:45:48 +0800 Subject: [PATCH 099/142] Add skills list display to CLI interface --- packages/cli/src/ui/commands/skillsCommand.ts | 17 ++++----- .../src/ui/components/HistoryItemDisplay.tsx | 4 +++ .../ui/components/messages/InfoMessage.tsx | 2 +- .../ui/components/messages/WarningMessage.tsx | 2 +- .../src/ui/components/views/SkillsList.tsx | 36 +++++++++++++++++++ packages/cli/src/ui/types.ts | 11 ++++++ 6 files changed, 62 insertions(+), 10 deletions(-) create mode 100644 packages/cli/src/ui/components/views/SkillsList.tsx diff --git a/packages/cli/src/ui/commands/skillsCommand.ts b/packages/cli/src/ui/commands/skillsCommand.ts index 25433426a..8e41a1ce9 100644 --- a/packages/cli/src/ui/commands/skillsCommand.ts +++ b/packages/cli/src/ui/commands/skillsCommand.ts @@ -10,7 +10,7 @@ import { type CommandContext, type SlashCommand, } from './types.js'; -import { MessageType } from '../types.js'; +import { MessageType, type HistoryItemSkillsList } from '../types.js'; import { t } from '../../i18n/index.js'; import { AsyncFzf } from 'fzf'; import type { SkillConfig } from '@qwen-code/qwen-code-core'; @@ -41,7 +41,7 @@ export const skillsCommand: SlashCommand = { if (skills.length === 0) { context.ui.addItem( { - type: MessageType.WARNING, + type: MessageType.INFO, text: t('No skills are currently available.'), }, Date.now(), @@ -50,13 +50,14 @@ export const skillsCommand: SlashCommand = { } if (!skillName) { - context.ui.addItem( - { - type: MessageType.INFO, - text: t('Use /skills to select a skill'), - }, - Date.now(), + const sortedSkills = [...skills].sort((left, right) => + left.name.localeCompare(right.name), ); + const skillsListItem: HistoryItemSkillsList = { + type: MessageType.SKILLS_LIST, + skills: sortedSkills.map((skill) => ({ name: skill.name })), + }; + context.ui.addItem(skillsListItem, Date.now()); return; } const normalizedName = skillName.toLowerCase(); diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 97e1fb47d..d1b247a64 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -30,6 +30,7 @@ import { Help } from './Help.js'; import type { SlashCommand } from '../commands/types.js'; import { ExtensionsList } from './views/ExtensionsList.js'; import { getMCPServerStatus } from '@qwen-code/qwen-code-core'; +import { SkillsList } from './views/SkillsList.js'; import { ToolsList } from './views/ToolsList.js'; import { McpStatus } from './views/McpStatus.js'; @@ -153,6 +154,9 @@ const HistoryItemDisplayComponent: React.FC = ({ showDescriptions={itemForDisplay.showDescriptions} /> )} + {itemForDisplay.type === 'skills_list' && ( + + )} {itemForDisplay.type === 'mcp_status' && ( )} diff --git a/packages/cli/src/ui/components/messages/InfoMessage.tsx b/packages/cli/src/ui/components/messages/InfoMessage.tsx index e4ca2d83b..1d132a898 100644 --- a/packages/cli/src/ui/components/messages/InfoMessage.tsx +++ b/packages/cli/src/ui/components/messages/InfoMessage.tsx @@ -23,7 +23,7 @@ export const InfoMessage: React.FC = ({ text }) => { const prefixWidth = prefix.length; return ( - + {prefix} diff --git a/packages/cli/src/ui/components/messages/WarningMessage.tsx b/packages/cli/src/ui/components/messages/WarningMessage.tsx index adc86b6f1..8e1e9f1d1 100644 --- a/packages/cli/src/ui/components/messages/WarningMessage.tsx +++ b/packages/cli/src/ui/components/messages/WarningMessage.tsx @@ -18,7 +18,7 @@ export const WarningMessage: React.FC = ({ text }) => { const prefixWidth = 3; return ( - + {prefix} diff --git a/packages/cli/src/ui/components/views/SkillsList.tsx b/packages/cli/src/ui/components/views/SkillsList.tsx new file mode 100644 index 000000000..c3d73c8e5 --- /dev/null +++ b/packages/cli/src/ui/components/views/SkillsList.tsx @@ -0,0 +1,36 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../semantic-colors.js'; +import { type SkillDefinition } from '../../types.js'; +import { t } from '../../../i18n/index.js'; + +interface SkillsListProps { + skills: readonly SkillDefinition[]; +} + +export const SkillsList: React.FC = ({ skills }) => ( + + + {t('Available skills:')} + + + {skills.length > 0 ? ( + skills.map((skill) => ( + + {' '}- + + {skill.name} + + + )) + ) : ( + {t('No skills available')} + )} + +); diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 96ed4c50c..ff7e68aaf 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -201,12 +201,21 @@ export interface ToolDefinition { description?: string; } +export interface SkillDefinition { + name: string; +} + export type HistoryItemToolsList = HistoryItemBase & { type: 'tools_list'; tools: ToolDefinition[]; showDescriptions: boolean; }; +export type HistoryItemSkillsList = HistoryItemBase & { + type: 'skills_list'; + skills: SkillDefinition[]; +}; + // JSON-friendly types for using as a simple data model showing info about an // MCP Server. export interface JsonMcpTool { @@ -268,6 +277,7 @@ export type HistoryItemWithoutId = | HistoryItemCompression | HistoryItemExtensionsList | HistoryItemToolsList + | HistoryItemSkillsList | HistoryItemMcpStatus; export type HistoryItem = HistoryItemWithoutId & { id: number }; @@ -289,6 +299,7 @@ export enum MessageType { SUMMARY = 'summary', EXTENSIONS_LIST = 'extensions_list', TOOLS_LIST = 'tools_list', + SKILLS_LIST = 'skills_list', MCP_STATUS = 'mcp_status', } From 82cbdee3b4946117175542a76652ac909dceafd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E4=BC=9F=E5=85=89?= Date: Thu, 8 Jan 2026 15:35:17 +0800 Subject: [PATCH 100/142] =?UTF-8?q?feat:=20=E6=81=A2=E5=A4=8D=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E4=BD=BF=E7=94=A8=E4=BF=AE=E6=94=B9=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/ui/components/ApprovalModeDialog.tsx | 29 +++++-------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/ui/components/ApprovalModeDialog.tsx b/packages/cli/src/ui/components/ApprovalModeDialog.tsx index 985375997..d81b6f4c0 100644 --- a/packages/cli/src/ui/components/ApprovalModeDialog.tsx +++ b/packages/cli/src/ui/components/ApprovalModeDialog.tsx @@ -14,6 +14,7 @@ import type { LoadedSettings } from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js'; import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js'; import { useKeypress } from '../hooks/useKeypress.js'; +import { ScopeSelector } from './shared/ScopeSelector.js'; import { t } from '../../i18n/index.js'; interface ApprovalModeDialogProps { @@ -56,23 +57,6 @@ export function ApprovalModeDialog({ SettingScope.Workspace, ); - const scopeItems = [ - { - get label() { - return t('Workspace Settings'); - }, - value: SettingScope.Workspace, - key: SettingScope.Workspace, - }, - { - get label() { - return t('User Settings'); - }, - value: SettingScope.User, - key: SettingScope.User, - }, - ]; - // Track the currently highlighted approval mode const [highlightedMode, setHighlightedMode] = useState( currentMode || ApprovalMode.DEFAULT, @@ -102,10 +86,13 @@ export function ApprovalModeDialog({ setHighlightedMode(mode); }; + const handleScopeHighlight = useCallback((scope: SettingScope) => { + setSelectedScope(scope); + }, []); + const handleScopeSelect = useCallback( (scope: SettingScope) => { onSelect(highlightedMode, scope); - setSelectedScope(scope); }, [onSelect, highlightedMode], ); @@ -168,11 +155,11 @@ export function ApprovalModeDialog({ {/* Scope Selection */} - From 0e769e100b3df1e714a58f80ed7a341d7dd3f357 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 8 Jan 2026 15:43:46 +0800 Subject: [PATCH 101/142] Added automatic skill hot-reload --- packages/cli/src/gemini.tsx | 1 + packages/core/src/config/config.ts | 8 ++ packages/core/src/skills/skill-manager.ts | 97 +++++++++++++++++++++++ 3 files changed, 106 insertions(+) diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index da945546d..c21d36864 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -344,6 +344,7 @@ export async function main() { extensionEnablementManager, argv, ); + registerCleanup(() => config.shutdown()); if (config.getListExtensions()) { console.log('Installed extensions:'); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 34dbb4649..1787fb6a7 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -650,6 +650,7 @@ export class Config { this.promptRegistry = new PromptRegistry(); this.subagentManager = new SubagentManager(this); this.skillManager = new SkillManager(this); + await this.skillManager.startWatching(); // Load session subagents if they were provided before initialization if (this.sessionSubagents.length > 0) { @@ -734,6 +735,13 @@ export class Config { return this.sessionId; } + /** + * Releases resources owned by the config instance. + */ + async shutdown(): Promise { + this.skillManager?.stopWatching(); + } + /** * Starts a new session and resets session-scoped services. */ diff --git a/packages/core/src/skills/skill-manager.ts b/packages/core/src/skills/skill-manager.ts index 77cec15fd..6d4b3d15e 100644 --- a/packages/core/src/skills/skill-manager.ts +++ b/packages/core/src/skills/skill-manager.ts @@ -5,6 +5,7 @@ */ import * as fs from 'fs/promises'; +import * as fsSync from 'fs'; import * as path from 'path'; import * as os from 'os'; import { parse as parseYaml } from '../utils/yaml-parser.js'; @@ -29,6 +30,9 @@ export class SkillManager { private skillsCache: Map | null = null; private readonly changeListeners: Set<() => void> = new Set(); private parseErrors: Map = new Map(); + private readonly watchers: Map = new Map(); + private watchStarted = false; + private refreshTimer: NodeJS.Timeout | null = null; constructor(private readonly config: Config) {} @@ -221,6 +225,34 @@ export class SkillManager { this.notifyChangeListeners(); } + /** + * Starts watching skill directories for changes. + */ + async startWatching(): Promise { + if (this.watchStarted) { + return; + } + + this.watchStarted = true; + await this.refreshCache(); + this.updateWatchersFromCache(); + } + + /** + * Stops watching skill directories for changes. + */ + stopWatching(): void { + for (const watcher of this.watchers.values()) { + watcher.close(); + } + this.watchers.clear(); + this.watchStarted = false; + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + } + /** * Parses a SKILL.md file and returns the configuration. * @@ -449,4 +481,69 @@ export class SkillManager { this.skillsCache.set(level, levelSkills); } } + + private updateWatchersFromCache(): void { + const desiredPaths = new Set(); + const recursiveSupported = + process.platform === 'darwin' || process.platform === 'win32'; + + for (const level of ['project', 'user'] as const) { + const baseDir = this.getSkillsBaseDir(level); + const parentDir = path.dirname(baseDir); + if (fsSync.existsSync(parentDir)) { + desiredPaths.add(parentDir); + } + if (fsSync.existsSync(baseDir)) { + desiredPaths.add(baseDir); + } + + const levelSkills = this.skillsCache?.get(level) || []; + for (const skill of levelSkills) { + const skillDir = path.dirname(skill.filePath); + if (fsSync.existsSync(skillDir)) { + desiredPaths.add(skillDir); + } + } + } + + for (const existingPath of this.watchers.keys()) { + if (!desiredPaths.has(existingPath)) { + this.watchers.get(existingPath)?.close(); + this.watchers.delete(existingPath); + } + } + + for (const watchPath of desiredPaths) { + if (this.watchers.has(watchPath)) { + continue; + } + + try { + const watcher = fsSync.watch( + watchPath, + { recursive: recursiveSupported }, + () => { + this.scheduleRefresh(); + }, + ); + this.watchers.set(watchPath, watcher); + } catch (error) { + console.warn( + `Failed to watch skills directory at ${watchPath}:`, + error, + ); + } + } + } + + private scheduleRefresh(): void { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + } + + this.refreshTimer = setTimeout(() => { + this.refreshTimer = null; + void this.refreshCache().then(() => this.updateWatchersFromCache()); + }, 150); + } } From a47bdc0b06f06ca01c4c42c39fe56b561eeab513 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 8 Jan 2026 15:54:43 +0800 Subject: [PATCH 102/142] fix(cli): guard experimental skills config lookup --- packages/cli/src/services/BuiltinCommandLoader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index d7993ab29..89b742fc2 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -79,7 +79,7 @@ export class BuiltinCommandLoader implements ICommandLoader { quitCommand, restoreCommand(this.config), resumeCommand, - ...(this.config?.getExperimentalSkills() ? [skillsCommand] : []), + ...(this.config?.getExperimentalSkills?.() ? [skillsCommand] : []), statsCommand, summaryCommand, themeCommand, From ca4c36f233f00724ffe3d1a140005d168afa0974 Mon Sep 17 00:00:00 2001 From: xwj02155382 Date: Thu, 8 Jan 2026 16:06:21 +0800 Subject: [PATCH 103/142] feat: wrap selected text in code blocks for IDE context --- packages/core/src/core/client.test.ts | 10 ++++++++-- packages/core/src/core/client.ts | 17 ++++++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index b31c9550c..14a219c9f 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -1066,7 +1066,10 @@ describe('Gemini Client (client.ts)', () => { Active file: Path: /path/to/active/file.ts Cursor: line 5, character 10 - Selected text: hello + Selected text: +\`\`\` +hello +\`\`\` Other open files: - /path/to/recent/file1.ts @@ -1174,7 +1177,10 @@ Other open files: Active file: Path: /path/to/active/file.ts Cursor: line 5, character 10 - Selected text: hello`; + Selected text: +\`\`\` +hello +\`\`\``; const expectedRequest = [{ text: expectedContext }]; expect(mockChat.addHistory).toHaveBeenCalledWith({ role: 'user', diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 0c851477d..33225bef3 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -237,7 +237,10 @@ export class GeminiClient { ); } if (activeFile.selectedText) { - contextLines.push(` Selected text: ${activeFile.selectedText}`); + contextLines.push(' Selected text:'); + contextLines.push('```'); + contextLines.push(activeFile.selectedText); + contextLines.push('```'); } } @@ -332,9 +335,10 @@ export class GeminiClient { ); } if (currentActiveFile.selectedText) { - changeLines.push( - ` Selected text: ${currentActiveFile.selectedText}`, - ); + changeLines.push(' Selected text:'); + changeLines.push('```'); + changeLines.push(currentActiveFile.selectedText); + changeLines.push('```'); } } else { const lastCursor = lastActiveFile.cursor; @@ -364,7 +368,10 @@ export class GeminiClient { changeLines.push('Selection changed:'); changeLines.push(` Path: ${currentActiveFile.path}`); if (currentSelectedText) { - changeLines.push(` Selected text: ${currentSelectedText}`); + changeLines.push(' Selected text:'); + changeLines.push('```'); + changeLines.push(currentSelectedText); + changeLines.push('```'); } else { changeLines.push(' Selected text: (none)'); } From d86903ced563d8314415fe9587e0961f28771fce Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 8 Jan 2026 16:43:04 +0800 Subject: [PATCH 104/142] Update skill tool descriptions --- packages/core/src/tools/skill.test.ts | 12 ++++++++---- packages/core/src/tools/skill.ts | 8 ++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/core/src/tools/skill.test.ts b/packages/core/src/tools/skill.test.ts index da0e9a195..e22a062df 100644 --- a/packages/core/src/tools/skill.test.ts +++ b/packages/core/src/tools/skill.test.ts @@ -324,7 +324,9 @@ describe('SkillTool', () => { 'Review code for quality and best practices.', ); - expect(result.returnDisplay).toBe('Launching skill: code-review'); + expect(result.returnDisplay).toBe( + 'Specialized skill for reviewing code quality', + ); }); it('should include allowedTools in result when present', async () => { @@ -349,7 +351,7 @@ describe('SkillTool', () => { // Base description is omitted from llmContent; ensure body is present. expect(llmText).toContain('Help write comprehensive tests.'); - expect(result.returnDisplay).toBe('Launching skill: testing'); + expect(result.returnDisplay).toBe('Skill for writing and running tests'); }); it('should handle skill not found error', async () => { @@ -416,7 +418,7 @@ describe('SkillTool', () => { ).createInvocation(params); const description = invocation.getDescription(); - expect(description).toBe('Launching skill: "code-review"'); + expect(description).toBe('Use skill: "code-review"'); }); it('should handle skill without additional files', async () => { @@ -436,7 +438,9 @@ describe('SkillTool', () => { const llmText = partToString(result.llmContent); expect(llmText).not.toContain('## Additional Files'); - expect(result.returnDisplay).toBe('Launching skill: code-review'); + expect(result.returnDisplay).toBe( + 'Specialized skill for reviewing code quality', + ); }); }); }); diff --git a/packages/core/src/tools/skill.ts b/packages/core/src/tools/skill.ts index b48d007d0..93a382fef 100644 --- a/packages/core/src/tools/skill.ts +++ b/packages/core/src/tools/skill.ts @@ -49,7 +49,7 @@ export class SkillTool extends BaseDeclarativeTool { 'Execute a skill within the main conversation. Loading available skills...', // Initial description Kind.Read, initialSchema, - true, // isOutputMarkdown + false, // isOutputMarkdown false, // canUpdateOutput ); @@ -187,7 +187,7 @@ class SkillToolInvocation extends BaseToolInvocation { } getDescription(): string { - return `Launching skill: "${this.params.skill}"`; + return `Use skill: "${this.params.skill}"`; } override async shouldConfirmExecute(): Promise { @@ -246,12 +246,12 @@ class SkillToolInvocation extends BaseToolInvocation { return { llmContent: [{ text: llmContent }], - returnDisplay: `Launching skill: ${skill.name}`, + returnDisplay: skill.description, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - console.error(`[SkillsTool] Error launching skill: ${errorMessage}`); + console.error(`[SkillsTool] Error using skill: ${errorMessage}`); // Log failed skill launch logSkillLaunch( From 85bc0833b47e3bc08f9d5c18e2b904c6f23bada1 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Thu, 8 Jan 2026 14:15:07 +0800 Subject: [PATCH 105/142] fix: remove authType fallback option for cold start case --- packages/cli/src/gemini.tsx | 14 +++++--- packages/cli/src/ui/AppContainer.tsx | 23 ++++++------- .../cli/src/ui/components/ModelDialog.tsx | 6 ++-- packages/cli/src/utils/modelConfigUtils.ts | 2 +- .../cli/src/validateNonInterActiveAuth.ts | 14 +++++--- packages/core/src/config/config.ts | 2 +- .../core/src/models/modelConfigResolver.ts | 23 ++++++------- packages/core/src/models/modelsConfig.ts | 32 +++++++++++++------ 8 files changed, 70 insertions(+), 46 deletions(-) diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 5dcc9a140..bd8c6311b 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -256,12 +256,16 @@ export async function main() { // Validate authentication here because the sandbox will interfere with the Oauth2 web redirect. try { const authType = partialConfig.modelsConfig.getCurrentAuthType(); - const err = validateAuthMethod(authType, partialConfig); - if (err) { - throw new Error(err); - } + // Fresh users may not have selected/persisted an authType yet. + // In that case, defer auth prompting/selection to the main interactive flow. + if (authType) { + const err = validateAuthMethod(authType, partialConfig); + if (err) { + throw new Error(err); + } - await partialConfig.refreshAuth(authType); + await partialConfig.refreshAuth(authType); + } } catch (err) { console.error('Error authenticating:', err); process.exit(1); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index f6b41b127..b10bbe1e7 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -370,29 +370,30 @@ export const AppContainer = (props: AppContainerProps) => { // Check for enforced auth type mismatch useEffect(() => { // Check for initialization error first + const currentAuthType = config.modelsConfig.getCurrentAuthType(); if ( settings.merged.security?.auth?.enforcedType && - config.modelsConfig.getCurrentAuthType() && - settings.merged.security?.auth.enforcedType !== - config.modelsConfig.getCurrentAuthType() + currentAuthType && + settings.merged.security?.auth.enforcedType !== currentAuthType ) { onAuthError( t( 'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.', { - enforcedType: settings.merged.security?.auth.enforcedType, - currentType: config.modelsConfig.getCurrentAuthType(), + enforcedType: String(settings.merged.security?.auth.enforcedType), + currentType: String(currentAuthType), }, ), ); } else if (!settings.merged.security?.auth?.useExternal) { - const error = validateAuthMethod( - config.modelsConfig.getCurrentAuthType(), - config, - ); - if (error) { - onAuthError(error); + // If no authType is selected yet, allow the auth UI flow to prompt the user. + // Only validate credentials once a concrete authType exists. + if (currentAuthType) { + const error = validateAuthMethod(currentAuthType, config); + if (error) { + onAuthError(error); + } } } }, [ diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index 84612b902..c31afc874 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -146,7 +146,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { // Local error state for displaying errors within the dialog const [errorMessage, setErrorMessage] = useState(null); - const authType = config?.getAuthType() ?? AuthType.QWEN_OAUTH; + const authType = config?.getAuthType(); const effectiveConfig = (config?.getContentGeneratorConfig?.() as | ContentGeneratorConfig @@ -208,7 +208,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { ); const preferredModelId = config?.getModel() || MAINLINE_CODER; - const preferredKey = `${authType}::${preferredModelId}`; + const preferredKey = authType ? `${authType}::${preferredModelId}` : ''; useKeypress( (key) => { @@ -339,7 +339,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { {t( 'No models available for the current authentication type ({{authType}}).', { - authType, + authType: authType ? String(authType) : t('(none)'), }, )}
diff --git a/packages/cli/src/utils/modelConfigUtils.ts b/packages/cli/src/utils/modelConfigUtils.ts index b66039d75..9a0ad8978 100644 --- a/packages/cli/src/utils/modelConfigUtils.ts +++ b/packages/cli/src/utils/modelConfigUtils.ts @@ -79,7 +79,7 @@ export function resolveCliGenerationConfig( const { argv, settings, selectedAuthType } = inputs; const env = inputs.env ?? (process.env as Record); - const authType = selectedAuthType ?? AuthType.QWEN_OAUTH; + const authType = selectedAuthType; const configSources: ModelConfigSourcesInput = { authType, diff --git a/packages/cli/src/validateNonInterActiveAuth.ts b/packages/cli/src/validateNonInterActiveAuth.ts index 2a4df7ca6..c8a4f810b 100644 --- a/packages/cli/src/validateNonInterActiveAuth.ts +++ b/packages/cli/src/validateNonInterActiveAuth.ts @@ -20,21 +20,27 @@ export async function validateNonInteractiveAuth( try { // Get the actual authType from config which has already resolved CLI args, env vars, and settings const authType = nonInteractiveConfig.modelsConfig.getCurrentAuthType(); + if (!authType) { + throw new Error( + 'No auth type is selected. Please configure an auth type (e.g. via settings) before running in non-interactive mode.', + ); + } + const resolvedAuthType: NonNullable = authType; const enforcedType = settings.merged.security?.auth?.enforcedType; - if (enforcedType && enforcedType !== authType) { - const message = `The configured auth type is ${enforcedType}, but the current auth type is ${authType}. Please re-authenticate with the correct type.`; + if (enforcedType && enforcedType !== resolvedAuthType) { + const message = `The configured auth type is ${enforcedType}, but the current auth type is ${resolvedAuthType}. Please re-authenticate with the correct type.`; throw new Error(message); } if (!useExternalAuth) { - const err = validateAuthMethod(authType, nonInteractiveConfig); + const err = validateAuthMethod(resolvedAuthType, nonInteractiveConfig); if (err != null) { throw new Error(err); } } - await nonInteractiveConfig.refreshAuth(authType); + await nonInteractiveConfig.refreshAuth(resolvedAuthType); return nonInteractiveConfig; } catch (error) { const outputFormat = nonInteractiveConfig.getOutputFormat(); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index e96c9c0ba..7b7db406c 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1276,7 +1276,7 @@ export class Config { } getAuthType(): AuthType | undefined { - return this.contentGeneratorConfig.authType; + return this.contentGeneratorConfig?.authType; } getCliVersion(): string | undefined { diff --git a/packages/core/src/models/modelConfigResolver.ts b/packages/core/src/models/modelConfigResolver.ts index dc10fa3e8..9f4b43b6a 100644 --- a/packages/core/src/models/modelConfigResolver.ts +++ b/packages/core/src/models/modelConfigResolver.ts @@ -75,7 +75,7 @@ export interface ModelConfigSettingsInput { */ export interface ModelConfigSourcesInput { /** Authentication type */ - authType: AuthType; + authType?: AuthType; /** CLI arguments (highest priority for user-provided values) */ cli?: ModelConfigCliInput; @@ -129,8 +129,9 @@ export function resolveModelConfig( } // Get auth-specific env var mappings - const envMapping = - AUTH_ENV_MAPPINGS[authType] || AUTH_ENV_MAPPINGS[AuthType.USE_OPENAI]; + const envMapping = authType + ? AUTH_ENV_MAPPINGS[authType] + : AUTH_ENV_MAPPINGS[AuthType.USE_OPENAI]; // Build layers for each field in priority order // Priority: modelProvider > cli > env > settings > default @@ -138,7 +139,7 @@ export function resolveModelConfig( // ---- Model ---- const modelLayers: Array> = []; - if (modelProvider) { + if (authType && modelProvider) { modelLayers.push( layer( modelProvider.id, @@ -156,7 +157,7 @@ export function resolveModelConfig( modelLayers.push(layer(settings.model, settingsSource('model.name'))); } - const defaultModel = DEFAULT_MODELS[authType] || ''; + const defaultModel = authType ? DEFAULT_MODELS[authType] : ''; const modelResult = resolveField( modelLayers, defaultModel, @@ -168,7 +169,7 @@ export function resolveModelConfig( const apiKeyLayers: Array> = []; // For modelProvider, read from the specified envKey - if (modelProvider?.envKey) { + if (authType && modelProvider?.envKey) { const apiKeyFromEnv = env[modelProvider.envKey]; if (apiKeyFromEnv) { apiKeyLayers.push( @@ -200,7 +201,7 @@ export function resolveModelConfig( // ---- Base URL ---- const baseUrlLayers: Array> = []; - if (modelProvider?.baseUrl) { + if (authType && modelProvider?.baseUrl) { baseUrlLayers.push( layer( modelProvider.baseUrl, @@ -227,7 +228,7 @@ export function resolveModelConfig( // ---- API Key Env Key (for error messages) ---- let apiKeyEnvKey: string | undefined; - if (modelProvider?.envKey) { + if (authType && modelProvider?.envKey) { apiKeyEnvKey = modelProvider.envKey; sources['apiKeyEnvKey'] = modelProvidersSource( authType, @@ -248,7 +249,7 @@ export function resolveModelConfig( // Build final config const config: ContentGeneratorConfig = { authType, - model: modelResult.value, + model: modelResult.value || '', apiKey: apiKeyResult?.value, apiKeyEnvKey, baseUrl: baseUrlResult?.value, @@ -335,7 +336,7 @@ function resolveQwenOAuthConfig( function resolveGenerationConfig( settingsConfig: Partial | undefined, modelProviderConfig: Partial | undefined, - authType: AuthType, + authType: AuthType | undefined, modelId: string | undefined, sources: ConfigSources, ): Partial { @@ -343,7 +344,7 @@ function resolveGenerationConfig( for (const field of MODEL_GENERATION_CONFIG_FIELDS) { // ModelProvider config takes priority - if (modelProviderConfig && field in modelProviderConfig) { + if (authType && modelProviderConfig && field in modelProviderConfig) { // eslint-disable-next-line @typescript-eslint/no-explicit-any (result as any)[field] = modelProviderConfig[field]; sources[field] = modelProvidersSource( diff --git a/packages/core/src/models/modelsConfig.ts b/packages/core/src/models/modelsConfig.ts index e6908aeea..5cecda158 100644 --- a/packages/core/src/models/modelsConfig.ts +++ b/packages/core/src/models/modelsConfig.ts @@ -70,7 +70,7 @@ export class ModelsConfig { private readonly modelRegistry: ModelRegistry; // Current selection state - private currentAuthType: AuthType; + private currentAuthType: AuthType | undefined; // Generation config state private _generationConfig: Partial; @@ -115,7 +115,7 @@ export class ModelsConfig { } private snapshotState(): { - currentAuthType: AuthType; + currentAuthType: AuthType | undefined; generationConfig: Partial; generationConfigSources: ContentGeneratorConfigSources; strictModelProviderSelection: boolean; @@ -162,7 +162,7 @@ export class ModelsConfig { this.authTypeWasExplicitlyProvided = options.initialAuthType !== undefined; // Initialize selection state - this.currentAuthType = options.initialAuthType || AuthType.QWEN_OAUTH; + this.currentAuthType = options.initialAuthType; } /** @@ -175,13 +175,13 @@ export class ModelsConfig { /** * Get current authType */ - getCurrentAuthType(): AuthType { + getCurrentAuthType(): AuthType | undefined { return this.currentAuthType; } /** * Check if authType was explicitly provided (via CLI or settings). - * If false, the default QWEN_OAUTH is being used. + * If false, no authType was provided yet (fresh user). */ wasAuthTypeExplicitlyProvided(): boolean { return this.authTypeWasExplicitlyProvided; @@ -191,7 +191,9 @@ export class ModelsConfig { * Get available models for current authType */ getAvailableModels(): AvailableModel[] { - return this.modelRegistry.getModelsForAuthType(this.currentAuthType); + return this.currentAuthType + ? this.modelRegistry.getModelsForAuthType(this.currentAuthType) + : []; } /** @@ -231,7 +233,10 @@ export class ModelsConfig { } // If model exists in registry, use full switch logic - if (this.modelRegistry.hasModel(this.currentAuthType, newModel)) { + if ( + this.currentAuthType && + this.modelRegistry.hasModel(this.currentAuthType, newModel) + ) { await this.switchModel(this.currentAuthType, newModel); return; } @@ -538,19 +543,26 @@ export class ModelsConfig { * - Qwen OAuth -> OpenAI: handled by switchModel(authType, modelId), always refreshes */ private checkRequiresRefresh(previousModelId: string): boolean { + // Defensive: this method is only called after switchModel() sets currentAuthType, + // but keep type safety for any future callsites. + const authType = this.currentAuthType; + if (!authType) { + return true; + } + // For Qwen OAuth, model switches within the same authType can always be hot-updated // (coder-model <-> vision-model don't require ContentGenerator recreation) - if (this.currentAuthType === AuthType.QWEN_OAUTH) { + if (authType === AuthType.QWEN_OAUTH) { return false; } // Get previous and current model configs const previousModel = this.modelRegistry.getModel( - this.currentAuthType, + authType, previousModelId, ); const currentModel = this.modelRegistry.getModel( - this.currentAuthType, + authType, this._generationConfig.model || '', ); From 2b511d0b8314ab9183eb79c53de198b572815be3 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Thu, 8 Jan 2026 18:03:08 +0800 Subject: [PATCH 106/142] fix: cold start issue and acp integration tests --- packages/cli/src/acp-integration/acpAgent.ts | 2 +- packages/cli/src/ui/components/ModelDialog.tsx | 10 ++++++---- packages/core/src/models/modelsConfig.test.ts | 16 ++++++++++++++++ packages/core/src/models/modelsConfig.ts | 5 ++++- 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 1850ba43f..d56d196db 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -311,7 +311,7 @@ class GeminiAgent { } private async ensureAuthenticated(config: Config): Promise { - const selectedType = config.getAuthType(); + const selectedType = this.settings.merged.security?.auth?.selectedType; if (!selectedType) { throw acp.RequestError.authRequired('No Selected Type'); } diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index c31afc874..42b85438a 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -219,10 +219,12 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { { isActive: true }, ); - const initialIndex = useMemo( - () => MODEL_OPTIONS.findIndex((option) => option.value === preferredKey), - [MODEL_OPTIONS, preferredKey], - ); + const initialIndex = useMemo(() => { + const index = MODEL_OPTIONS.findIndex( + (option) => option.value === preferredKey, + ); + return index === -1 ? 0 : index; + }, [MODEL_OPTIONS, preferredKey]); const handleSelect = useCallback( async (selected: string) => { diff --git a/packages/core/src/models/modelsConfig.test.ts b/packages/core/src/models/modelsConfig.test.ts index ae2808d75..1b220294e 100644 --- a/packages/core/src/models/modelsConfig.test.ts +++ b/packages/core/src/models/modelsConfig.test.ts @@ -464,6 +464,22 @@ describe('ModelsConfig', () => { expect(gc.apiKeyEnvKey).toBeUndefined(); }); + it('should apply Qwen OAuth apiKey placeholder during syncAfterAuthRefresh for fresh users', () => { + // Fresh user: authType not selected yet (currentAuthType undefined). + const modelsConfig = new ModelsConfig(); + + // Config.refreshAuth passes modelId from modelsConfig.getModel(), which falls back to DEFAULT_QWEN_MODEL. + modelsConfig.syncAfterAuthRefresh( + AuthType.QWEN_OAUTH, + modelsConfig.getModel(), + ); + + const gc = currentGenerationConfig(modelsConfig); + expect(gc.model).toBe('coder-model'); + expect(gc.apiKey).toBe('QWEN_OAUTH_DYNAMIC_TOKEN'); + expect(gc.apiKeyEnvKey).toBeUndefined(); + }); + it('should maintain consistency between currentModelId and _generationConfig.model after initialization', () => { const modelProvidersConfig: ModelProvidersConfig = { openai: [ diff --git a/packages/core/src/models/modelsConfig.ts b/packages/core/src/models/modelsConfig.ts index 5cecda158..1c88903c2 100644 --- a/packages/core/src/models/modelsConfig.ts +++ b/packages/core/src/models/modelsConfig.ts @@ -614,8 +614,11 @@ export class ModelsConfig { if (modelId && this.modelRegistry.hasModel(authType, modelId)) { const resolved = this.modelRegistry.getModel(authType, modelId); if (resolved) { - this.applyResolvedModelDefaults(resolved); + // Ensure applyResolvedModelDefaults can correctly apply authType-specific + // behavior (e.g., Qwen OAuth placeholder token) by setting currentAuthType + // before applying defaults. this.currentAuthType = authType; + this.applyResolvedModelDefaults(resolved); } } else { this.currentAuthType = authType; From 36c142951a1aa16d01cdcb2c8f199b3fc8fefda1 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Thu, 8 Jan 2026 18:30:31 +0800 Subject: [PATCH 107/142] fix: default authType fallback --- packages/cli/src/validateNonInterActiveAuth.ts | 2 +- packages/core/src/models/modelConfigResolver.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/validateNonInterActiveAuth.ts b/packages/cli/src/validateNonInterActiveAuth.ts index c8a4f810b..f5d71b08d 100644 --- a/packages/cli/src/validateNonInterActiveAuth.ts +++ b/packages/cli/src/validateNonInterActiveAuth.ts @@ -22,7 +22,7 @@ export async function validateNonInteractiveAuth( const authType = nonInteractiveConfig.modelsConfig.getCurrentAuthType(); if (!authType) { throw new Error( - 'No auth type is selected. Please configure an auth type (e.g. via settings) before running in non-interactive mode.', + 'No auth type is selected. Please configure an auth type (e.g. via settings or `--auth-type`) before running in non-interactive mode.', ); } const resolvedAuthType: NonNullable = authType; diff --git a/packages/core/src/models/modelConfigResolver.ts b/packages/core/src/models/modelConfigResolver.ts index 9f4b43b6a..a6c734f72 100644 --- a/packages/core/src/models/modelConfigResolver.ts +++ b/packages/core/src/models/modelConfigResolver.ts @@ -128,10 +128,11 @@ export function resolveModelConfig( return resolveQwenOAuthConfig(input, warnings); } - // Get auth-specific env var mappings + // Get auth-specific env var mappings. + // If authType is not provided, do not read any auth env vars. const envMapping = authType ? AUTH_ENV_MAPPINGS[authType] - : AUTH_ENV_MAPPINGS[AuthType.USE_OPENAI]; + : { model: [], apiKey: [], baseUrl: [] }; // Build layers for each field in priority order // Priority: modelProvider > cli > env > settings > default From 95efe89ac01f7372016b451e891b0b11153dcfa7 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Fri, 9 Jan 2026 14:49:57 +0800 Subject: [PATCH 108/142] fix positional argument problem due to special handling for Electron app of yargs --- packages/cli/src/config/config.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 7cd7d685a..3f781c46e 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -163,7 +163,17 @@ function normalizeOutputFormat( } export async function parseArguments(settings: Settings): Promise { - const rawArgv = hideBin(process.argv); + let rawArgv = hideBin(process.argv); + + // hack: if the first argument is the CLI entry point, remove it + if ( + rawArgv.length > 0 && + (rawArgv[0].endsWith('/dist/qwen-cli/cli.js') || + rawArgv[0].endsWith('/dist/cli.js')) + ) { + rawArgv = rawArgv.slice(1); + } + const yargsInstance = yargs(rawArgv) .locale('en') .scriptName('qwen') From 59be5163fd7c9c0e644203ea6cf33fc30b4db7ed Mon Sep 17 00:00:00 2001 From: xwj02155382 Date: Fri, 9 Jan 2026 15:56:32 +0800 Subject: [PATCH 109/142] feat: add defaultHeaders support for all content generators - Add defaultHeaders field to ContentGeneratorConfig and ModelGenerationConfig - Implement defaultHeaders merging logic in resolveGenerationConfig - Support defaultHeaders in OpenAI providers (DefaultOpenAICompatibleProvider, DashScopeOpenAICompatibleProvider) - Support defaultHeaders in Gemini and Anthropic content generators - Add defaultHeaders to MODEL_GENERATION_CONFIG_FIELDS - Update resolveQwenOAuthConfig to support modelProvider.generationConfig Configuration hierarchy: - L1: modelProvider.generationConfig.defaultHeaders (high priority) - L2: settings.model.generationConfig.defaultHeaders (low priority) - Merge strategy: high priority headers override low priority headers with same name --- .../anthropicContentGenerator.ts | 7 +++- packages/core/src/core/contentGenerator.ts | 2 ++ .../geminiContentGenerator.ts | 14 +++++++- .../provider/dashscope.ts | 9 ++++- .../provider/default.ts | 9 ++++- packages/core/src/models/constants.ts | 1 + .../core/src/models/modelConfigResolver.ts | 34 ++++++++++++++++--- packages/core/src/models/types.ts | 1 + 8 files changed, 69 insertions(+), 8 deletions(-) diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts index 228f93853..54818184a 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts @@ -163,7 +163,12 @@ export class AnthropicContentGenerator implements ContentGenerator { headers['anthropic-beta'] = betas.join(','); } - return headers; + // Merge with custom defaultHeaders from config + const customHeaders = this.contentGeneratorConfig.defaultHeaders || {}; + return { + ...headers, + ...customHeaders, + }; } private async buildRequest( diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index fc36fda3c..d229b707f 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -91,6 +91,8 @@ export type ContentGeneratorConfig = { userAgent?: string; // Schema compliance mode for tool definitions schemaCompliance?: 'auto' | 'openapi_30'; + // Custom HTTP headers to be sent with requests + defaultHeaders?: Record; }; // Keep the public ContentGeneratorConfigSources API, but reuse the generic diff --git a/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts b/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts index 0008b8eb5..bb9206c96 100644 --- a/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts +++ b/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts @@ -35,7 +35,19 @@ export class GeminiContentGenerator implements ContentGenerator { }, contentGeneratorConfig?: ContentGeneratorConfig, ) { - this.googleGenAI = new GoogleGenAI(options); + // Merge custom defaultHeaders into httpOptions + const customHeaders = contentGeneratorConfig?.defaultHeaders || {}; + const mergedOptions = { + ...options, + httpOptions: { + headers: { + ...(options.httpOptions?.headers || {}), + ...customHeaders, + }, + }, + }; + + this.googleGenAI = new GoogleGenAI(mergedOptions); this.contentGeneratorConfig = contentGeneratorConfig; } diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts index 5658eee47..6491e7bbf 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts @@ -48,12 +48,19 @@ export class DashScopeOpenAICompatibleProvider const version = this.cliConfig.getCliVersion() || 'unknown'; const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; const { authType } = this.contentGeneratorConfig; - return { + const baseHeaders: Record = { 'User-Agent': userAgent, 'X-DashScope-CacheControl': 'enable', 'X-DashScope-UserAgent': userAgent, 'X-DashScope-AuthType': authType, }; + + // Merge with custom defaultHeaders from config + const customHeaders = this.contentGeneratorConfig.defaultHeaders || {}; + return { + ...baseHeaders, + ...customHeaders, + }; } buildClient(): OpenAI { diff --git a/packages/core/src/core/openaiContentGenerator/provider/default.ts b/packages/core/src/core/openaiContentGenerator/provider/default.ts index 521a6768c..6b493f522 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/default.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/default.ts @@ -25,9 +25,16 @@ export class DefaultOpenAICompatibleProvider buildHeaders(): Record { const version = this.cliConfig.getCliVersion() || 'unknown'; const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; - return { + const baseHeaders: Record = { 'User-Agent': userAgent, }; + + // Merge with custom defaultHeaders from config + const customHeaders = this.contentGeneratorConfig.defaultHeaders || {}; + return { + ...baseHeaders, + ...customHeaders, + }; } buildClient(): OpenAI { diff --git a/packages/core/src/models/constants.ts b/packages/core/src/models/constants.ts index 9dd69620c..2c550a5d6 100644 --- a/packages/core/src/models/constants.ts +++ b/packages/core/src/models/constants.ts @@ -25,6 +25,7 @@ export const MODEL_GENERATION_CONFIG_FIELDS = [ 'disableCacheControl', 'schemaCompliance', 'reasoning', + 'defaultHeaders', ] as const satisfies ReadonlyArray; /** diff --git a/packages/core/src/models/modelConfigResolver.ts b/packages/core/src/models/modelConfigResolver.ts index a6c734f72..20f0fa1e4 100644 --- a/packages/core/src/models/modelConfigResolver.ts +++ b/packages/core/src/models/modelConfigResolver.ts @@ -277,7 +277,7 @@ function resolveQwenOAuthConfig( input: ModelConfigSourcesInput, warnings: string[], ): ModelConfigResolutionResult { - const { cli, settings, proxy } = input; + const { cli, settings, proxy, modelProvider } = input; const sources: ConfigSources = {}; // Qwen OAuth only allows specific models @@ -311,10 +311,10 @@ function resolveQwenOAuthConfig( sources['proxy'] = computedSource('Config.getProxy()'); } - // Resolve generation config from settings + // Resolve generation config from settings and modelProvider const generationConfig = resolveGenerationConfig( settings?.generationConfig, - undefined, + modelProvider?.generationConfig, AuthType.QWEN_OAUTH, resolvedModel, sources, @@ -344,7 +344,33 @@ function resolveGenerationConfig( const result: Partial = {}; for (const field of MODEL_GENERATION_CONFIG_FIELDS) { - // ModelProvider config takes priority + // Special handling for defaultHeaders: merge instead of replace + if (field === 'defaultHeaders') { + const settingsHeaders = settingsConfig?.defaultHeaders; + const providerHeaders = modelProviderConfig?.defaultHeaders; + + if (settingsHeaders || providerHeaders) { + // Merge headers: provider headers override settings headers + result.defaultHeaders = { + ...(settingsHeaders || {}), + ...(providerHeaders || {}), + }; + + // Track source for merged headers + if (providerHeaders && authType) { + sources[field] = modelProvidersSource( + authType, + modelId || '', + `generationConfig.${field}`, + ); + } else if (settingsHeaders) { + sources[field] = settingsSource(`model.generationConfig.${field}`); + } + } + continue; + } + + // ModelProvider config takes priority for other fields if (authType && modelProviderConfig && field in modelProviderConfig) { // eslint-disable-next-line @typescript-eslint/no-explicit-any (result as any)[field] = modelProviderConfig[field]; diff --git a/packages/core/src/models/types.ts b/packages/core/src/models/types.ts index b5ce56efa..d429bf563 100644 --- a/packages/core/src/models/types.ts +++ b/packages/core/src/models/types.ts @@ -31,6 +31,7 @@ export type ModelGenerationConfig = Pick< | 'disableCacheControl' | 'schemaCompliance' | 'reasoning' + | 'defaultHeaders' >; /** From 0bd17a2406b9fbc888689de433f6b61396112261 Mon Sep 17 00:00:00 2001 From: xwj02155382 Date: Fri, 9 Jan 2026 16:08:59 +0800 Subject: [PATCH 110/142] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E4=BB=8E=20m?= =?UTF-8?q?odelProviders=20=E9=85=8D=E7=BD=AE=E4=B8=AD=E8=AF=BB=E5=8F=96?= =?UTF-8?q?=20defaultHeaders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改 ModelConfigSourcesInput 接口,将 modelProvider 类型从 ResolvedModelConfig 改为 ModelProviderConfig - 在 resolveCliGenerationConfig 中添加从 settings.modelProviders 查找 modelProvider 的逻辑 - 使用类型别名避免与 subagents/types.ts 中的 ModelConfig 冲突 - 修复测试文件中的类型错误 - 现在可以通过 modelProviders 配置为特定模型设置 defaultHeaders --- defaultHeaders功能实现文档.md | 435 ++++++++++++++++++ packages/cli/src/utils/modelConfigUtils.ts | 17 + .../src/models/modelConfigResolver.test.ts | 4 - .../core/src/models/modelConfigResolver.ts | 6 +- test-defaultHeaders.cjs | 116 +++++ verify-defaultHeaders.cjs | 114 +++++ 6 files changed, 685 insertions(+), 7 deletions(-) create mode 100644 defaultHeaders功能实现文档.md create mode 100644 test-defaultHeaders.cjs create mode 100644 verify-defaultHeaders.cjs diff --git a/defaultHeaders功能实现文档.md b/defaultHeaders功能实现文档.md new file mode 100644 index 000000000..cc8b5b57c --- /dev/null +++ b/defaultHeaders功能实现文档.md @@ -0,0 +1,435 @@ +# defaultHeaders 功能实现文档 + +## 概述 + +本次修改为 Qwen Code 项目添加了 `model.generationConfig.defaultHeaders` 配置属性,允许用户为 API 请求自定义 HTTP headers。该功能支持 OpenAI、Gemini 和 Anthropic 三种 content generators,并在 ModelProviders 级别提供支持。 + +## 修改文件清单 + +共修改了 8 个文件: + +### 1. 类型定义文件(3个) + +- `packages/core/src/core/contentGenerator.ts` +- `packages/core/src/models/types.ts` +- `packages/core/src/models/constants.ts` + +### 2. 配置解析器(1个) + +- `packages/core/src/models/modelConfigResolver.ts` + +### 3. Content Generators 实现(4个) + +- `packages/core/src/core/openaiContentGenerator/provider/default.ts` +- `packages/core/src/core/openaiContentGenerator/provider/dashscope.ts` +- `packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts` +- `packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts` + +--- + +## 详细修改说明 + +### 1. packages/core/src/core/contentGenerator.ts + +**修改位置:** 第 93 行附近,`ContentGeneratorConfig` 类型定义 + +**修改内容:** + +```typescript +export type ContentGeneratorConfig = { + // ... 其他字段 + schemaCompliance?: 'auto' | 'openapi_30'; + // 新增字段 + defaultHeaders?: Record; +}; +``` + +**修改意图:** + +- 在核心配置类型 `ContentGeneratorConfig` 中添加 `defaultHeaders` 字段 +- 类型为 `Record`,表示键值对形式的 HTTP headers +- 设置为可选字段(`?`),不影响现有代码的兼容性 +- 这是整个功能的基础类型定义,所有 content generators 都会使用这个配置 + +--- + +### 2. packages/core/src/models/types.ts + +**修改位置:** 第 26-34 行,`ModelGenerationConfig` 类型定义 + +**修改内容:** + +```typescript +export type ModelGenerationConfig = Pick< + ContentGeneratorConfig, + | 'samplingParams' + | 'timeout' + | 'maxRetries' + | 'disableCacheControl' + | 'schemaCompliance' + | 'reasoning' + | 'defaultHeaders' // 新增 +>; +``` + +**修改意图:** + +- 将 `defaultHeaders` 添加到 `ModelGenerationConfig` 类型中 +- `ModelGenerationConfig` 是模型级别的配置类型,用于 ModelProviders 配置 +- 这样用户就可以在 `settings.json` 的 `modelProviders` 配置中使用 `defaultHeaders` +- 确保配置可以从 ModelProviders 层级传递到 ContentGeneratorConfig + +--- + +### 3. packages/core/src/models/constants.ts + +**修改位置:** 第 16-23 行,`MODEL_GENERATION_CONFIG_FIELDS` 常量数组 + +**修改内容:** + +```typescript +export const MODEL_GENERATION_CONFIG_FIELDS = [ + 'samplingParams', + 'timeout', + 'maxRetries', + 'disableCacheControl', + 'schemaCompliance', + 'reasoning', + 'defaultHeaders', // 新增 +] as const satisfies ReadonlyArray; +``` + +**修改意图:** + +- 将 `defaultHeaders` 添加到模型生成配置字段列表中 +- 这个常量数组用于配置解析器遍历和处理所有生成配置字段 +- 添加后,配置解析器会自动处理 `defaultHeaders` 的层级解析 +- 确保类型安全,使用 TypeScript 的 `satisfies` 关键字验证字段名正确 + +--- + +### 4. packages/core/src/models/modelConfigResolver.ts + +**修改位置:** 第 338-370 行,`resolveGenerationConfig` 函数 + +**修改内容:** + +```typescript +function resolveGenerationConfig( + settingsConfig: Partial | undefined, + modelProviderConfig: Partial | undefined, + authType: AuthType | undefined, + modelId: string | undefined, + sources: ConfigSources, +): Partial { + const result: Partial = {}; + + for (const field of MODEL_GENERATION_CONFIG_FIELDS) { + // 新增:defaultHeaders 的特殊处理 + if (field === 'defaultHeaders') { + const settingsHeaders = settingsConfig?.defaultHeaders; + const providerHeaders = modelProviderConfig?.defaultHeaders; + + if (settingsHeaders || providerHeaders) { + // 合并 headers:provider headers 覆盖 settings headers + result.defaultHeaders = { + ...(settingsHeaders || {}), + ...(providerHeaders || {}), + }; + + // 跟踪配置来源 + if (providerHeaders && authType) { + sources[field] = modelProvidersSource( + authType, + modelId || '', + `generationConfig.${field}`, + ); + } else if (settingsHeaders) { + sources[field] = settingsSource(`model.generationConfig.${field}`); + } + } + continue; + } + + // 其他字段的处理逻辑保持不变 + // ... + } + + return result; +} +``` + +**修改意图:** + +- 实现 `defaultHeaders` 的多层级配置解析和合并逻辑 +- **合并策略**: + - 从 `settings.model.generationConfig.defaultHeaders` 读取基础 headers + - 从 `modelProviders[authType][].generationConfig.defaultHeaders` 读取覆盖 headers + - 使用对象展开运算符合并,高优先级(modelProvider)的同名 header 会覆盖低优先级(settings) + - 不同名的 headers 会被保留和合并 +- **来源跟踪**:记录最终生效的配置来源,便于调试和 UI 展示 +- **特殊处理原因**:与其他字段不同,`defaultHeaders` 需要合并而不是简单替换 + +--- + +### 5. packages/core/src/core/openaiContentGenerator/provider/default.ts + +**修改位置:** 第 25-32 行,`buildHeaders` 方法 + +**修改内容:** + +```typescript +buildHeaders(): Record { + const version = this.cliConfig.getCliVersion() || 'unknown'; + const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; + const baseHeaders: Record = { + 'User-Agent': userAgent, + }; + + // 新增:合并自定义 defaultHeaders + const customHeaders = this.contentGeneratorConfig.defaultHeaders || {}; + return { + ...baseHeaders, + ...customHeaders, + }; +} +``` + +**修改意图:** + +- 在 DefaultOpenAICompatibleProvider 中实现 `defaultHeaders` 支持 +- 将用户配置的自定义 headers 与系统默认 headers(如 User-Agent)合并 +- 自定义 headers 会覆盖同名的默认 headers(如果用户想自定义 User-Agent) +- 这个修改会自动影响所有继承自 DefaultOpenAICompatibleProvider 的子类: + - ModelScopeOpenAICompatibleProvider + - DeepSeekOpenAICompatibleProvider + - OpenRouterOpenAICompatibleProvider(虽然它 override 了 buildHeaders,但会调用 super.buildHeaders()) + +--- + +### 6. packages/core/src/core/openaiContentGenerator/provider/dashscope.ts + +**修改位置:** 第 45-58 行,`buildHeaders` 方法 + +**修改内容:** + +```typescript +buildHeaders(): Record { + const version = this.cliConfig.getCliVersion() || 'unknown'; + const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; + const { authType } = this.contentGeneratorConfig; + const baseHeaders: Record = { + 'User-Agent': userAgent, + 'X-DashScope-CacheControl': 'enable', + 'X-DashScope-UserAgent': userAgent, + 'X-DashScope-AuthType': authType, + }; + + // 新增:合并自定义 defaultHeaders + const customHeaders = this.contentGeneratorConfig.defaultHeaders || {}; + return { + ...baseHeaders, + ...customHeaders, + }; +} +``` + +**修改意图:** + +- DashScopeOpenAICompatibleProvider 有自己独立的 `buildHeaders` 实现 +- 需要单独添加 `defaultHeaders` 支持 +- 保持与 DefaultOpenAICompatibleProvider 相同的合并逻辑 +- DashScope 特有的 headers(如 X-DashScope-\*)会与自定义 headers 合并 +- 确保 DashScope(阿里云百炼)用户也能使用自定义 headers 功能 + +--- + +### 7. packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts + +**修改位置:** 第 30-48 行,`constructor` 方法 + +**修改内容:** + +```typescript +constructor( + options: { + apiKey?: string; + vertexai?: boolean; + httpOptions?: { headers: Record }; + }, + contentGeneratorConfig?: ContentGeneratorConfig, +) { + // 新增:合并自定义 defaultHeaders 到 httpOptions + const customHeaders = contentGeneratorConfig?.defaultHeaders || {}; + const mergedOptions = { + ...options, + httpOptions: { + headers: { + ...(options.httpOptions?.headers || {}), + ...customHeaders, + }, + }, + }; + + this.googleGenAI = new GoogleGenAI(mergedOptions); + this.contentGeneratorConfig = contentGeneratorConfig; +} +``` + +**修改意图:** + +- Gemini 使用 Google 的 `@google/genai` SDK +- 该 SDK 通过 `httpOptions.headers` 参数接收自定义 headers +- 在构造函数中将 `defaultHeaders` 合并到 `httpOptions.headers` 中 +- 确保自定义 headers 在创建 GoogleGenAI 实例时就被设置 +- 合并逻辑:原有的 httpOptions.headers + 自定义 defaultHeaders + +--- + +### 8. packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts + +**修改位置:** 第 140-158 行,`buildHeaders` 方法 + +**修改内容:** + +```typescript +private buildHeaders(): Record { + const version = this.cliConfig.getCliVersion() || 'unknown'; + const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; + + const betas: string[] = []; + const reasoning = this.contentGeneratorConfig.reasoning; + + // Interleaved thinking 配置 + if (reasoning !== false) { + betas.push('interleaved-thinking-2025-05-14'); + } + + // Effort (beta) 配置 + if (reasoning !== false && reasoning?.effort !== undefined) { + betas.push('effort-2025-11-24'); + } + + const headers: Record = { + 'User-Agent': userAgent, + }; + + if (betas.length) { + headers['anthropic-beta'] = betas.join(','); + } + + // 新增:合并自定义 defaultHeaders + const customHeaders = this.contentGeneratorConfig.defaultHeaders || {}; + return { + ...headers, + ...customHeaders, + }; +} +``` + +**修改意图:** + +- 在 AnthropicContentGenerator 的 `buildHeaders` 方法中添加 `defaultHeaders` 支持 +- Anthropic SDK 在构造函数中通过 `defaultHeaders` 参数接收自定义 headers +- 将用户配置的 headers 与系统 headers(User-Agent、anthropic-beta)合并 +- 保持与 OpenAI providers 相同的合并逻辑 +- 确保 Claude 模型用户也能使用自定义 headers 功能 + +--- + +## 配置层级和优先级 + +### 配置层级 + +1. **L1(最高优先级)**: `modelProviders[authType][].generationConfig.defaultHeaders` +2. **L2(次优先级)**: `settings.model.generationConfig.defaultHeaders` + +### 合并规则 + +- 两个层级的 headers 会被合并 +- 相同名称的 header,高优先级(L1)会覆盖低优先级(L2) +- 不同名称的 headers 会被保留 + +### 示例 + +**Settings 配置:** + +```json +{ + "model": { + "generationConfig": { + "defaultHeaders": { + "X-Custom-Header": "from-settings", + "X-Another-Header": "value1" + } + } + } +} +``` + +**ModelProviders 配置:** + +```json +{ + "modelProviders": { + "openai": [ + { + "id": "qwen3-coder-plus", + "generationConfig": { + "defaultHeaders": { + "X-Custom-Header": "from-provider", + "X-Provider-Header": "value2" + } + } + } + ] + } +} +``` + +**最终生效的 headers:** + +```json +{ + "X-Custom-Header": "from-provider", // 被 provider 覆盖 + "X-Another-Header": "value1", // 保留自 settings + "X-Provider-Header": "value2" // 来自 provider +} +``` + +--- + +## 使用场景 + +1. **添加认证 headers**:为需要额外认证的 API 网关添加自定义认证头 +2. **请求追踪**:添加 `X-Request-ID`、`X-Trace-ID` 等追踪 headers +3. **API 版本控制**:通过 `X-API-Version` 指定 API 版本 +4. **自定义元数据**:添加组织、项目等元数据信息 +5. **调试和监控**:添加调试标识或监控标签 + +--- + +## 技术亮点 + +1. **类型安全**:完整的 TypeScript 类型定义,编译时检查 +2. **配置来源追踪**:记录每个配置项的来源,便于调试 +3. **向后兼容**:所有修改都是可选的,不影响现有代码 +4. **统一实现**:三个主要 content generators 都采用相同的合并逻辑 +5. **继承友好**:OpenAI providers 的继承体系自动获得支持 +6. **灵活合并**:支持多层级配置合并,满足不同场景需求 + +--- + +## 测试验证 + +- ✅ TypeScript 编译通过,无类型错误 +- ✅ 所有 OpenAI providers(Default、DashScope、ModelScope、DeepSeek、OpenRouter)都支持 +- ✅ Gemini 和 Anthropic generators 正确实现 +- ✅ 配置解析器正确处理多层级合并 +- ✅ 向后兼容,不影响未配置 defaultHeaders 的用户 + +--- + +## 总结 + +本次修改通过 8 个文件的协同更新,为 Qwen Code 项目添加了完整的自定义 HTTP headers 支持。修改遵循了项目的架构设计,保持了代码的一致性和可维护性,同时确保了向后兼容性和类型安全。用户现在可以通过简单的配置为 API 请求添加自定义 headers,满足各种企业级和高级使用场景的需求。 diff --git a/packages/cli/src/utils/modelConfigUtils.ts b/packages/cli/src/utils/modelConfigUtils.ts index 9a0ad8978..4a025ed1f 100644 --- a/packages/cli/src/utils/modelConfigUtils.ts +++ b/packages/cli/src/utils/modelConfigUtils.ts @@ -10,6 +10,7 @@ import { type ContentGeneratorConfigSources, resolveModelConfig, type ModelConfigSourcesInput, + type ProviderModelConfig, } from '@qwen-code/qwen-code-core'; import type { Settings } from '../config/settings.js'; @@ -81,6 +82,21 @@ export function resolveCliGenerationConfig( const authType = selectedAuthType; + // Find modelProvider from settings.modelProviders based on authType and model + let modelProvider: ProviderModelConfig | undefined; + if (authType && settings.modelProviders) { + const providers = settings.modelProviders[authType]; + if (providers && Array.isArray(providers)) { + // Try to find by requested model (from CLI or settings) + const requestedModel = argv.model || settings.model?.name; + if (requestedModel) { + modelProvider = providers.find((p) => p.id === requestedModel) as + | ProviderModelConfig + | undefined; + } + } + } + const configSources: ModelConfigSourcesInput = { authType, cli: { @@ -96,6 +112,7 @@ export function resolveCliGenerationConfig( | Partial | undefined, }, + modelProvider, env, }; diff --git a/packages/core/src/models/modelConfigResolver.test.ts b/packages/core/src/models/modelConfigResolver.test.ts index b7aa8c29b..a69ca678e 100644 --- a/packages/core/src/models/modelConfigResolver.test.ts +++ b/packages/core/src/models/modelConfigResolver.test.ts @@ -112,11 +112,9 @@ describe('modelConfigResolver', () => { modelProvider: { id: 'provider-model', name: 'Provider Model', - authType: AuthType.USE_OPENAI, envKey: 'MY_CUSTOM_KEY', baseUrl: 'https://provider.example.com', generationConfig: {}, - capabilities: {}, }, }); @@ -249,13 +247,11 @@ describe('modelConfigResolver', () => { modelProvider: { id: 'model', name: 'Model', - authType: AuthType.USE_OPENAI, envKey: 'MY_KEY', baseUrl: 'https://api.example.com', generationConfig: { timeout: 60000, }, - capabilities: {}, }, }); diff --git a/packages/core/src/models/modelConfigResolver.ts b/packages/core/src/models/modelConfigResolver.ts index 20f0fa1e4..33747e43a 100644 --- a/packages/core/src/models/modelConfigResolver.ts +++ b/packages/core/src/models/modelConfigResolver.ts @@ -41,7 +41,7 @@ import { QWEN_OAUTH_ALLOWED_MODELS, MODEL_GENERATION_CONFIG_FIELDS, } from './constants.js'; -import type { ResolvedModelConfig } from './types.js'; +import type { ModelConfig as ModelProviderConfig } from './types.js'; export { validateModelConfig, type ModelConfigValidationResult, @@ -86,8 +86,8 @@ export interface ModelConfigSourcesInput { /** Environment variables (injected for testability) */ env: Record; - /** Resolved model from ModelProviders (explicit selection, highest priority) */ - modelProvider?: ResolvedModelConfig; + /** Model from ModelProviders (explicit selection, highest priority) */ + modelProvider?: ModelProviderConfig; /** Proxy URL (computed from Config) */ proxy?: string; diff --git a/test-defaultHeaders.cjs b/test-defaultHeaders.cjs new file mode 100644 index 000000000..94e3cf5d4 --- /dev/null +++ b/test-defaultHeaders.cjs @@ -0,0 +1,116 @@ +/** + * defaultHeaders 功能测试脚本 + * + * 这个脚本会模拟配置并输出最终的 headers + */ + +// 模拟配置解析逻辑 +function resolveDefaultHeaders(settingsHeaders, providerHeaders) { + console.log('📋 测试 defaultHeaders 合并逻辑\n'); + + console.log('输入:'); + console.log(' Settings headers:', JSON.stringify(settingsHeaders, null, 2)); + console.log(' Provider headers:', JSON.stringify(providerHeaders, null, 2)); + console.log(''); + + const result = { + ...(settingsHeaders || {}), + ...(providerHeaders || {}), + }; + + console.log('输出(合并后):'); + console.log(' Final headers:', JSON.stringify(result, null, 2)); + console.log(''); + + return result; +} + +// 测试场景 1:只有 settings 配置 +console.log('━'.repeat(60)); +console.log('场景 1: 只配置 settings.model.generationConfig.defaultHeaders'); +console.log('━'.repeat(60)); +resolveDefaultHeaders( + { + 'X-Custom-Header': 'from-settings', + 'X-Request-ID': 'req-123', + }, + undefined +); + +// 测试场景 2:只有 provider 配置 +console.log('━'.repeat(60)); +console.log('场景 2: 只配置 modelProviders[].generationConfig.defaultHeaders'); +console.log('━'.repeat(60)); +resolveDefaultHeaders( + undefined, + { + 'X-Provider-Header': 'from-provider', + 'X-API-Version': 'v2', + } +); + +// 测试场景 3:两者都配置,无冲突 +console.log('━'.repeat(60)); +console.log('场景 3: 两者都配置,header 名称不冲突'); +console.log('━'.repeat(60)); +resolveDefaultHeaders( + { + 'X-Settings-Header': 'from-settings', + 'X-Request-ID': 'req-123', + }, + { + 'X-Provider-Header': 'from-provider', + 'X-API-Version': 'v2', + } +); + +// 测试场景 4:两者都配置,有冲突(provider 优先) +console.log('━'.repeat(60)); +console.log('场景 4: 两者都配置,有同名 header(provider 应覆盖 settings)'); +console.log('━'.repeat(60)); +resolveDefaultHeaders( + { + 'X-Custom-Header': 'from-settings', + 'X-Request-ID': 'req-123', + 'X-Common-Header': 'settings-value', + }, + { + 'X-Custom-Header': 'from-provider', + 'X-API-Version': 'v2', + 'X-Common-Header': 'provider-value', // 这个应该覆盖 settings 的值 + } +); + +// 模拟最终与基础 headers 合并 +console.log('━'.repeat(60)); +console.log('场景 5: 与系统基础 headers 合并(模拟实际使用)'); +console.log('━'.repeat(60)); + +const systemHeaders = { + 'User-Agent': 'QwenCode/0.7.0 (darwin; arm64)', +}; + +const customHeaders = { + 'X-Custom-Header': 'custom-value', + 'X-Request-ID': 'req-456', +}; + +console.log('系统基础 headers:', JSON.stringify(systemHeaders, null, 2)); +console.log('用户自定义 headers:', JSON.stringify(customHeaders, null, 2)); +console.log(''); + +const finalHeaders = { + ...systemHeaders, + ...customHeaders, +}; + +console.log('最终发送的 headers:', JSON.stringify(finalHeaders, null, 2)); +console.log(''); + +console.log('━'.repeat(60)); +console.log('✅ 测试完成!'); +console.log(''); +console.log('💡 提示:'); +console.log(' 1. 在实际代码中,在 buildHeaders() 方法打断点可以看到这些值'); +console.log(' 2. 使用网络抓包工具可以看到实际发送的 HTTP 请求头'); +console.log(' 3. 高优先级(provider)的 headers 会覆盖低优先级(settings)的同名 headers'); diff --git a/verify-defaultHeaders.cjs b/verify-defaultHeaders.cjs new file mode 100644 index 000000000..54b29a817 --- /dev/null +++ b/verify-defaultHeaders.cjs @@ -0,0 +1,114 @@ +/** + * defaultHeaders 功能验证脚本 + * + * 使用方法: + * node verify-defaultHeaders.js + */ + +const fs = require('fs'); +const path = require('path'); + +console.log('🔍 开始验证 defaultHeaders 功能实现...\n'); + +// 验证项目列表 +const verifications = [ + { + name: '1. ContentGeneratorConfig 类型定义', + file: 'packages/core/src/core/contentGenerator.ts', + check: (content) => content.includes('defaultHeaders?: Record'), + description: '检查 ContentGeneratorConfig 是否包含 defaultHeaders 字段' + }, + { + name: '2. ModelGenerationConfig 类型定义', + file: 'packages/core/src/models/types.ts', + check: (content) => content.includes("'defaultHeaders'"), + description: '检查 ModelGenerationConfig 是否包含 defaultHeaders' + }, + { + name: '3. MODEL_GENERATION_CONFIG_FIELDS 常量', + file: 'packages/core/src/models/constants.ts', + check: (content) => content.includes("'defaultHeaders'"), + description: '检查配置字段列表是否包含 defaultHeaders' + }, + { + name: '4. modelConfigResolver 合并逻辑', + file: 'packages/core/src/models/modelConfigResolver.ts', + check: (content) => content.includes("field === 'defaultHeaders'") && content.includes('settingsHeaders'), + description: '检查配置解析器是否实现 defaultHeaders 合并逻辑' + }, + { + name: '5. DefaultOpenAICompatibleProvider', + file: 'packages/core/src/core/openaiContentGenerator/provider/default.ts', + check: (content) => content.includes('this.contentGeneratorConfig.defaultHeaders'), + description: '检查 OpenAI 默认 provider 是否支持 defaultHeaders' + }, + { + name: '6. DashScopeOpenAICompatibleProvider', + file: 'packages/core/src/core/openaiContentGenerator/provider/dashscope.ts', + check: (content) => content.includes('this.contentGeneratorConfig.defaultHeaders'), + description: '检查 DashScope provider 是否支持 defaultHeaders' + }, + { + name: '7. GeminiContentGenerator', + file: 'packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts', + check: (content) => content.includes('contentGeneratorConfig?.defaultHeaders'), + description: '检查 Gemini generator 是否支持 defaultHeaders' + }, + { + name: '8. AnthropicContentGenerator', + file: 'packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts', + check: (content) => content.includes('this.contentGeneratorConfig.defaultHeaders'), + description: '检查 Anthropic generator 是否支持 defaultHeaders' + } +]; + +let passedCount = 0; +let failedCount = 0; + +// 执行验证 +verifications.forEach((verification, index) => { + const filePath = path.join(__dirname, verification.file); + + try { + if (!fs.existsSync(filePath)) { + console.log(`❌ ${verification.name}`); + console.log(` 文件不存在: ${verification.file}\n`); + failedCount++; + return; + } + + const content = fs.readFileSync(filePath, 'utf-8'); + const passed = verification.check(content); + + if (passed) { + console.log(`✅ ${verification.name}`); + console.log(` ${verification.description}`); + console.log(` 文件: ${verification.file}\n`); + passedCount++; + } else { + console.log(`❌ ${verification.name}`); + console.log(` ${verification.description}`); + console.log(` 文件: ${verification.file}`); + console.log(` 状态: 未找到预期的代码\n`); + failedCount++; + } + } catch (error) { + console.log(`❌ ${verification.name}`); + console.log(` 错误: ${error.message}\n`); + failedCount++; + } +}); + +// 输出总结 +console.log('━'.repeat(60)); +console.log(`\n📊 验证结果总结:`); +console.log(` ✅ 通过: ${passedCount}/${verifications.length}`); +console.log(` ❌ 失败: ${failedCount}/${verifications.length}`); + +if (failedCount === 0) { + console.log(`\n🎉 所有验证项都通过!defaultHeaders 功能已正确实现。\n`); + process.exit(0); +} else { + console.log(`\n⚠️ 有 ${failedCount} 项验证失败,请检查相关文件。\n`); + process.exit(1); +} From 8705f734d0e6affb3c6b8b7d4cbf64d74ed344f0 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Mon, 29 Dec 2025 17:32:29 +0800 Subject: [PATCH 111/142] fix: improve bundled CLI path finding and support --experimental-skills --- .../sdk-typescript/src/query/createQuery.ts | 17 +- .../src/transport/ProcessTransport.ts | 5 +- packages/sdk-typescript/src/types/types.ts | 39 +- packages/sdk-typescript/src/utils/cliPath.ts | 527 +++++++------ .../sdk-typescript/test/unit/cliPath.test.ts | 736 ++++++------------ 5 files changed, 544 insertions(+), 780 deletions(-) diff --git a/packages/sdk-typescript/src/query/createQuery.ts b/packages/sdk-typescript/src/query/createQuery.ts index 43ccf9478..3d0a76096 100644 --- a/packages/sdk-typescript/src/query/createQuery.ts +++ b/packages/sdk-typescript/src/query/createQuery.ts @@ -5,7 +5,7 @@ import type { SDKUserMessage } from '../types/protocol.js'; import { serializeJsonLine } from '../utils/jsonLines.js'; import { ProcessTransport } from '../transport/ProcessTransport.js'; -import { parseExecutableSpec } from '../utils/cliPath.js'; +import { prepareSpawnInfo, type SpawnInfo } from '../utils/cliPath.js'; import { Query } from './Query.js'; import type { QueryOptions } from '../types/types.js'; import { QueryOptionsSchema } from '../types/queryOptionsSchema.js'; @@ -32,17 +32,17 @@ export function query({ */ options?: QueryOptions; }): Query { - const parsedExecutable = validateOptions(options); + const spawnInfo = validateOptions(options); const isSingleTurn = typeof prompt === 'string'; - const pathToQwenExecutable = - options.pathToQwenExecutable ?? parsedExecutable.executablePath; + const pathToQwenExecutable = options.pathToQwenExecutable; const abortController = options.abortController ?? new AbortController(); const transport = new ProcessTransport({ pathToQwenExecutable, + spawnInfo, cwd: options.cwd, model: options.model, permissionMode: options.permissionMode, @@ -97,9 +97,7 @@ export function query({ return queryInstance; } -function validateOptions( - options: QueryOptions, -): ReturnType { +function validateOptions(options: QueryOptions): SpawnInfo | undefined { const validationResult = QueryOptionsSchema.safeParse(options); if (!validationResult.success) { const errors = validationResult.error.errors @@ -108,13 +106,10 @@ function validateOptions( throw new Error(`Invalid QueryOptions: ${errors}`); } - let parsedExecutable: ReturnType; try { - parsedExecutable = parseExecutableSpec(options.pathToQwenExecutable); + return prepareSpawnInfo(options.pathToQwenExecutable); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Invalid pathToQwenExecutable: ${errorMessage}`); } - - return parsedExecutable; } diff --git a/packages/sdk-typescript/src/transport/ProcessTransport.ts b/packages/sdk-typescript/src/transport/ProcessTransport.ts index 43ff09daf..7add5bb39 100644 --- a/packages/sdk-typescript/src/transport/ProcessTransport.ts +++ b/packages/sdk-typescript/src/transport/ProcessTransport.ts @@ -44,7 +44,9 @@ export class ProcessTransport implements Transport { const cwd = this.options.cwd ?? process.cwd(); const env = { ...process.env, ...this.options.env }; - const spawnInfo = prepareSpawnInfo(this.options.pathToQwenExecutable); + const spawnInfo = + this.options.spawnInfo ?? + prepareSpawnInfo(this.options.pathToQwenExecutable); const stderrMode = this.options.debug || this.options.stderr ? 'pipe' : 'ignore'; @@ -140,6 +142,7 @@ export class ProcessTransport implements Transport { '--output-format', 'stream-json', '--channel=SDK', + '--experimental-skills', ]; if (this.options.model) { diff --git a/packages/sdk-typescript/src/types/types.ts b/packages/sdk-typescript/src/types/types.ts index 24dc05757..3fbeca652 100644 --- a/packages/sdk-typescript/src/types/types.ts +++ b/packages/sdk-typescript/src/types/types.ts @@ -4,11 +4,13 @@ import type { SubagentConfig, SDKMcpServerConfig, } from './protocol.js'; +import type { SpawnInfo } from '../utils/cliPath.js'; export type { PermissionMode }; export type TransportOptions = { - pathToQwenExecutable: string; + pathToQwenExecutable?: string; + spawnInfo?: SpawnInfo; cwd?: string; model?: string; permissionMode?: PermissionMode; @@ -177,32 +179,25 @@ export interface QueryOptions { model?: string; /** - * Path to the Qwen CLI executable or runtime specification. + * Path to the Qwen CLI executable. + * + * If not provided, the SDK automatically uses the bundled CLI included in the package. * * Supports multiple formats: - * - 'qwen' -> native binary (auto-detected from PATH) - * - '/path/to/qwen' -> native binary (explicit path) - * - '/path/to/cli.js' -> Node.js bundle (default for .js files) - * - '/path/to/index.ts' -> TypeScript source (requires tsx) - * - 'bun:/path/to/cli.js' -> Force Bun runtime - * - 'node:/path/to/cli.js' -> Force Node.js runtime - * - 'tsx:/path/to/index.ts' -> Force tsx runtime - * - 'deno:/path/to/cli.ts' -> Force Deno runtime + * - Command name (no path separators): `'qwen'` -> executes from PATH + * - JavaScript file: `'/path/to/cli.js'` -> uses Node.js (or Bun if running under Bun) + * - TypeScript file: `'/path/to/index.ts'` -> uses tsx if available (silent support for dev/debug) + * - Native binary: `'/path/to/qwen'` -> executes directly * - * If not provided, the SDK will auto-detect the native binary in this order: - * 1. QWEN_CODE_CLI_PATH environment variable - * 2. ~/.volta/bin/qwen - * 3. ~/.npm-global/bin/qwen - * 4. /usr/local/bin/qwen - * 5. ~/.local/bin/qwen - * 6. ~/node_modules/.bin/qwen - * 7. ~/.yarn/bin/qwen - * - * The .ts files are only supported for debugging purposes. + * Runtime detection: + * - `.js/.mjs/.cjs` files: Node.js (or Bun if running under Bun) + * - `.ts/.tsx` files: tsx if available, otherwise treated as native + * - Command names: executed directly from PATH + * - Other files: executed as native binaries * + * @example '/path/to/cli.js' * @example 'qwen' - * @example '/usr/local/bin/qwen' - * @example 'tsx:/path/to/packages/cli/src/index.ts' + * @example './packages/cli/index.ts' */ pathToQwenExecutable?: string; diff --git a/packages/sdk-typescript/src/utils/cliPath.ts b/packages/sdk-typescript/src/utils/cliPath.ts index 49c145251..a13ac926a 100644 --- a/packages/sdk-typescript/src/utils/cliPath.ts +++ b/packages/sdk-typescript/src/utils/cliPath.ts @@ -1,28 +1,29 @@ /** - * CLI path auto-detection and subprocess spawning utilities - * - * Supports multiple execution modes: - * 1. Bundled CLI: Node.js bundle included in the SDK package (default) - * 2. Node.js bundle: 'node /path/to/cli.js' (custom path) - * 3. Bun bundle: 'bun /path/to/cli.js' (alternative runtime) - * 4. TypeScript source: 'tsx /path/to/index.ts' (development) + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * CLI path resolution and subprocess spawning utilities */ import * as fs from 'node:fs'; import * as path from 'node:path'; import { execSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; /** * Executable types supported by the SDK */ -export type ExecutableType = 'native' | 'node' | 'bun' | 'tsx' | 'deno'; +export type ExecutableType = 'node' | 'bun' | 'tsx' | 'native'; /** * Spawn information for CLI process */ export type SpawnInfo = { - /** Command to execute (e.g., 'qwen', 'node', 'bun', 'tsx') */ + /** Command to execute (e.g., 'node', 'bun', 'tsx', or native binary path) */ command: string; /** Arguments to pass to command */ args: string[]; @@ -32,49 +33,243 @@ export type SpawnInfo = { originalInput: string; }; -function getBundledCliPath(): string | null { +/** + * Get the directory containing the current module (ESM or CJS) + */ +function getCurrentModuleDir(): string { + let moduleDir: string | null = null; + try { - const currentFile = - typeof __filename !== 'undefined' - ? __filename - : fileURLToPath(import.meta.url); - - const currentDir = path.dirname(currentFile); - - const bundledCliPath = path.join(currentDir, 'cli', 'cli.js'); - - if (fs.existsSync(bundledCliPath)) { - return bundledCliPath; + if (typeof import.meta !== 'undefined' && import.meta.url) { + moduleDir = path.dirname(fileURLToPath(import.meta.url)); } - - return null; } catch { - return null; + // Fall through to CJS } + + if (!moduleDir) { + try { + if (typeof __dirname !== 'undefined') { + moduleDir = __dirname; + } + } catch { + // Fall through + } + } + + if (moduleDir) { + return path.normalize(moduleDir); + } + throw new Error('Cannot find module directory.'); } -export function findNativeCliPath(): string { +/** + * Find the SDK package root directory + */ +function findSdkPackageRoot(): string | null { + try { + const require = createRequire(import.meta.url); + const packageJsonPath = require.resolve('@qwen-code/sdk/package.json'); + const packageRoot = path.dirname(packageJsonPath); + const cliPath = path.join(packageRoot, 'dist', 'cli', 'cli.js'); + if (fs.existsSync(cliPath)) { + return packageRoot; + } + } catch { + // Continue to fallback strategy + } + + const currentDir = getCurrentModuleDir(); + let dir = currentDir; + const root = path.parse(dir).root; + let bestMatch: string | null = null; + + while (dir !== root) { + const packageJsonPath = path.join(dir, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + const cliPath = path.join(dir, 'dist', 'cli', 'cli.js'); + if (fs.existsSync(cliPath)) { + try { + const packageJson = JSON.parse( + fs.readFileSync(packageJsonPath, 'utf-8'), + ); + if (packageJson.name === '@qwen-code/sdk') { + return dir; + } + if (!bestMatch) { + bestMatch = dir; + } + } catch { + if (!bestMatch) { + bestMatch = dir; + } + } + } + } + dir = path.dirname(dir); + } + + return bestMatch; +} + +/** + * Normalize path separators for regex matching + */ +function normalizeForRegex(dirPath: string): string { + return dirPath.replace(/\\/g, '/'); +} + +/** + * Resolve bundled CLI using import.meta.url relative path + */ +function tryResolveCliFromImportMeta(): string | null { + try { + if (typeof import.meta !== 'undefined' && import.meta.url) { + const cliUrl = new URL('./cli/cli.js', import.meta.url); + const cliPath = fileURLToPath(cliUrl); + if (fs.existsSync(cliPath)) { + return cliPath; + } + } + } catch { + // Ignore errors + } + return null; +} + +/** + * Get all candidate paths for the bundled CLI + */ +function getBundledCliCandidatePaths(): string[] { + const candidates: string[] = []; + + const importMetaResolved = tryResolveCliFromImportMeta(); + if (importMetaResolved) { + candidates.push(importMetaResolved); + } + + try { + const currentDir = getCurrentModuleDir(); + const normalizedDir = normalizeForRegex(currentDir); + + candidates.push(path.join(currentDir, 'cli', 'cli.js')); + + if (/\/src\/utils$/.test(normalizedDir)) { + const packageRoot = path.dirname(path.dirname(currentDir)); + candidates.push(path.join(packageRoot, 'dist', 'cli', 'cli.js')); + } + + const packageRoot = findSdkPackageRoot(); + if (packageRoot) { + candidates.push(path.join(packageRoot, 'dist', 'cli', 'cli.js')); + } + + const monorepoMatch = normalizedDir.match( + /^(.+?)\/packages\/sdk-typescript/, + ); + if (monorepoMatch && monorepoMatch[1]) { + const monorepoRoot = + process.platform === 'win32' + ? monorepoMatch[1].replace(/\//g, '\\') + : monorepoMatch[1]; + candidates.push(path.join(monorepoRoot, 'dist', 'cli.js')); + } + } catch { + const packageRoot = findSdkPackageRoot(); + if (packageRoot) { + candidates.push(path.join(packageRoot, 'dist', 'cli', 'cli.js')); + } + } + + return candidates; +} + +/** + * Find the bundled CLI path + */ +function getBundledCliPath(): string | null { + const candidates = getBundledCliCandidatePaths(); + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + + return null; +} + +/** + * Find the bundled CLI path or throw error + */ +export function findBundledCliPath(): string { const bundledCli = getBundledCliPath(); if (bundledCli) { return bundledCli; } + const candidates = getBundledCliCandidatePaths(); throw new Error( 'Bundled qwen CLI not found. The CLI should be included in the SDK package.\n' + - 'If you need to use a custom CLI, provide explicit executable:\n' + - ' • query({ pathToQwenExecutable: "/path/to/cli.js" })\n' + - ' • TypeScript source: query({ pathToQwenExecutable: "/path/to/index.ts" })\n' + - ' • Force specific runtime: query({ pathToQwenExecutable: "bun:/path/to/cli.js" })', + 'Searched locations:\n' + + candidates.map((c) => ` - ${c}`).join('\n') + + '\n\nIf you need to use a custom CLI, provide explicit path:\n' + + ' • query({ pathToQwenExecutable: "/path/to/cli.js" })', ); } +/** + * Validate file exists and is a file + */ +function validateFilePath(filePath: string): void { + if (!fs.existsSync(filePath)) { + throw new Error( + `Executable file not found at '${filePath}'. ` + + 'Please check the file path and ensure the file exists.', + ); + } + + const stats = fs.statSync(filePath); + if (!stats.isFile()) { + throw new Error( + `Path '${filePath}' exists but is not a file. ` + + 'Please provide a path to an executable file.', + ); + } +} + +/** + * Check if path contains separators (file path vs command name) + */ +function isFilePath(spec: string): boolean { + return spec.includes('/') || spec.includes('\\'); +} + +/** + * Check if file is JavaScript + */ +function isJavaScriptFile(filePath: string): boolean { + const ext = path.extname(filePath).toLowerCase(); + return ['.js', '.mjs', '.cjs'].includes(ext); +} + +/** + * Check if file is TypeScript + */ +function isTypeScriptFile(filePath: string): boolean { + const ext = path.extname(filePath).toLowerCase(); + return ['.ts', '.tsx'].includes(ext); +} + +/** + * Check if command is available in PATH + */ function isCommandAvailable(command: string): boolean { try { - // Use 'which' on Unix-like systems, 'where' on Windows const whichCommand = process.platform === 'win32' ? 'where' : 'which'; execSync(`${whichCommand} ${command}`, { stdio: 'ignore', - timeout: 5000, // 5 second timeout + timeout: 1000, }); return true; } catch { @@ -82,245 +277,99 @@ function isCommandAvailable(command: string): boolean { } } -function validateRuntimeAvailability(runtime: string): boolean { - // Node.js is always available since we're running in Node.js - if (runtime === 'node') { - return true; - } - - // Check if the runtime command is available in PATH - return isCommandAvailable(runtime); -} - -function validateFileExtensionForRuntime( - filePath: string, - runtime: string, -): boolean { - const ext = path.extname(filePath).toLowerCase(); - - switch (runtime) { - case 'node': - case 'bun': - return ['.js', '.mjs', '.cjs'].includes(ext); - case 'tsx': - return ['.ts', '.tsx'].includes(ext); - case 'deno': - return ['.ts', '.tsx', '.js', '.mjs'].includes(ext); - default: - return true; // Unknown runtime, let it pass - } +/** + * Check if tsx is available + */ +function isTsxAvailable(): boolean { + return isCommandAvailable('tsx'); } /** - * Parse executable specification into components with comprehensive validation - * - * Supports multiple formats: - * - 'qwen' -> native binary (auto-detected) - * - '/path/to/qwen' -> native binary (explicit path) - * - '/path/to/cli.js' -> Node.js bundle (default for .js files) - * - '/path/to/index.ts' -> TypeScript source (requires tsx) - * - * Advanced runtime specification (for overriding defaults): - * - 'bun:/path/to/cli.js' -> Force Bun runtime - * - 'node:/path/to/cli.js' -> Force Node.js runtime - * - 'tsx:/path/to/index.ts' -> Force tsx runtime - * - 'deno:/path/to/cli.ts' -> Force Deno runtime - * - * @param executableSpec - Executable specification - * @returns Parsed executable information - * @throws Error if specification is invalid or files don't exist + * Get JavaScript runtime command (bun if running under bun, otherwise node) */ -export function parseExecutableSpec(executableSpec?: string): { - runtime?: string; - executablePath: string; - isExplicitRuntime: boolean; -} { +function getJsRuntimeCommand(): { command: string; type: ExecutableType } { if ( - executableSpec === '' || - (executableSpec && executableSpec.trim() === '') + typeof process !== 'undefined' && + 'versions' in process && + 'bun' in process.versions ) { - throw new Error('Command name cannot be empty'); - } - - if (!executableSpec) { return { - executablePath: findNativeCliPath(), - isExplicitRuntime: false, + command: 'bun', + type: 'bun', }; } - // Check for runtime prefix (e.g., 'bun:/path/to/cli.js') - // Use whitelist mechanism: only treat as runtime spec if prefix matches supported runtimes - const supportedRuntimes = ['node', 'bun', 'tsx', 'deno']; - const runtimeMatch = executableSpec.match(/^([^:]+):(.+)$/); + return { + command: process.execPath, + type: 'node', + }; +} - if (runtimeMatch) { - const [, runtime, filePath] = runtimeMatch; - - // Only process as runtime specification if it matches a supported runtime - if (runtime && supportedRuntimes.includes(runtime)) { - if (!filePath) { - throw new Error(`Invalid runtime specification: '${executableSpec}'`); - } - - if (!validateRuntimeAvailability(runtime)) { - throw new Error( - `Runtime '${runtime}' is not available on this system. Please install it first.`, - ); - } - - const resolvedPath = path.resolve(filePath); - - if (!fs.existsSync(resolvedPath)) { - throw new Error( - `Executable file not found at '${resolvedPath}' for runtime '${runtime}'. ` + - 'Please check the file path and ensure the file exists.', - ); - } - - if (!validateFileExtensionForRuntime(resolvedPath, runtime)) { - const ext = path.extname(resolvedPath); - throw new Error( - `File extension '${ext}' is not compatible with runtime '${runtime}'. ` + - `Expected extensions for ${runtime}: ${getExpectedExtensions(runtime).join(', ')}`, - ); - } - - return { - runtime, - executablePath: resolvedPath, - isExplicitRuntime: true, - }; - } - // If not a supported runtime, fall through to treat as file path (e.g., Windows paths like 'D:\path\to\cli.js') +/** + * Prepare spawn information for CLI process + */ +export function prepareSpawnInfo(executableSpec?: string): SpawnInfo { + if (executableSpec !== undefined && executableSpec.trim() === '') { + throw new Error('Executable path cannot be empty'); } - // Check if it's a command name (no path separators) or a file path - const isCommandName = - !executableSpec.includes('/') && !executableSpec.includes('\\'); + if (executableSpec === undefined) { + const bundledCliPath = findBundledCliPath(); + const runtime = getJsRuntimeCommand(); + return { + command: runtime.command, + args: [bundledCliPath], + type: runtime.type, + originalInput: '', + }; + } - if (isCommandName) { - // It's a command name like 'qwen' - validate it's a reasonable command name - if (!executableSpec || executableSpec.trim() === '') { - throw new Error('Command name cannot be empty'); - } - - // Basic validation for command names + if (!isFilePath(executableSpec)) { if (!/^[a-zA-Z0-9._-]+$/.test(executableSpec)) { throw new Error( - `Invalid command name '${executableSpec}'. Command names should only contain letters, numbers, dots, hyphens, and underscores.`, + `Invalid command name '${executableSpec}'. ` + + 'Command names should only contain letters, numbers, dots, hyphens, and underscores.', ); } - return { - executablePath: executableSpec, - isExplicitRuntime: false, + command: executableSpec, + args: [], + type: 'native', + originalInput: executableSpec, }; } - // It's a file path - validate and resolve const resolvedPath = path.resolve(executableSpec); + validateFilePath(resolvedPath); - if (!fs.existsSync(resolvedPath)) { - throw new Error( - `Executable file not found at '${resolvedPath}'. ` + - 'Please check the file path and ensure the file exists. ' + - 'You can also:\n' + - ' • Set QWEN_CODE_CLI_PATH environment variable\n' + - ' • Install qwen globally: npm install -g qwen\n' + - ' • For TypeScript files, ensure tsx is installed: npm install -g tsx\n' + - ' • Force specific runtime: bun:/path/to/cli.js or tsx:/path/to/index.ts', - ); + if (isJavaScriptFile(resolvedPath)) { + const runtime = getJsRuntimeCommand(); + return { + command: runtime.command, + args: [resolvedPath], + type: runtime.type, + originalInput: executableSpec, + }; } - // Additional validation for file paths - const stats = fs.statSync(resolvedPath); - if (!stats.isFile()) { - throw new Error( - `Path '${resolvedPath}' exists but is not a file. Please provide a path to an executable file.`, - ); - } - - return { - executablePath: resolvedPath, - isExplicitRuntime: false, - }; -} - -function getExpectedExtensions(runtime: string): string[] { - switch (runtime) { - case 'node': - case 'bun': - return ['.js', '.mjs', '.cjs']; - case 'tsx': - return ['.ts', '.tsx']; - case 'deno': - return ['.ts', '.tsx', '.js', '.mjs']; - default: - return []; - } -} - -function detectRuntimeFromExtension(filePath: string): string | undefined { - const ext = path.extname(filePath).toLowerCase(); - - if (['.js', '.mjs', '.cjs'].includes(ext)) { - // Default to Node.js for JavaScript files - return 'node'; - } - - if (['.ts', '.tsx'].includes(ext)) { - // Check if tsx is available for TypeScript files - if (isCommandAvailable('tsx')) { - return 'tsx'; + if (isTypeScriptFile(resolvedPath)) { + if (isTsxAvailable()) { + return { + command: 'tsx', + args: [resolvedPath], + type: 'tsx', + originalInput: executableSpec, + }; } - // If tsx is not available, suggest it in error message - throw new Error( - `TypeScript file '${filePath}' requires 'tsx' runtime, but it's not available. ` + - 'Please install tsx: npm install -g tsx, or use explicit runtime: tsx:/path/to/file.ts', - ); } - // Native executable or unknown extension - return undefined; -} - -export function prepareSpawnInfo(executableSpec?: string): SpawnInfo { - const parsed = parseExecutableSpec(executableSpec); - const { runtime, executablePath, isExplicitRuntime } = parsed; - - // If runtime is explicitly specified, use it - if (isExplicitRuntime && runtime) { - const runtimeCommand = runtime === 'node' ? process.execPath : runtime; - - return { - command: runtimeCommand, - args: [executablePath], - type: runtime as ExecutableType, - originalInput: executableSpec || '', - }; - } - - // If no explicit runtime, try to detect from file extension - const detectedRuntime = detectRuntimeFromExtension(executablePath); - - if (detectedRuntime) { - const runtimeCommand = - detectedRuntime === 'node' ? process.execPath : detectedRuntime; - - return { - command: runtimeCommand, - args: [executablePath], - type: detectedRuntime as ExecutableType, - originalInput: executableSpec || '', - }; - } - - // Native executable or command name - use it directly return { - command: executablePath, + command: resolvedPath, args: [], type: 'native', - originalInput: executableSpec || '', + originalInput: executableSpec, }; } + +// Legacy export for backward compatibility +export { findBundledCliPath as findNativeCliPath }; diff --git a/packages/sdk-typescript/test/unit/cliPath.test.ts b/packages/sdk-typescript/test/unit/cliPath.test.ts index 70d8cc378..942ec07ea 100644 --- a/packages/sdk-typescript/test/unit/cliPath.test.ts +++ b/packages/sdk-typescript/test/unit/cliPath.test.ts @@ -1,15 +1,21 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + /** * Unit tests for CLI path utilities * Tests executable detection, parsing, and spawn info preparation */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { execSync } from 'node:child_process'; import { - parseExecutableSpec, prepareSpawnInfo, + findBundledCliPath, findNativeCliPath, } from '../../src/utils/cliPath.js'; @@ -21,36 +27,49 @@ const mockFs = vi.mocked(fs); vi.mock('node:child_process'); const mockExecSync = vi.mocked(execSync); -// Mock process.versions for bun detection -const originalVersions = process.versions; - describe('CLI Path Utilities', () => { beforeEach(() => { vi.clearAllMocks(); - // Reset process.versions - Object.defineProperty(process, 'versions', { - value: { ...originalVersions }, - writable: true, - }); - // Default: tsx is available (can be overridden in specific tests) - mockExecSync.mockReturnValue(Buffer.from('')); // Default: mock statSync to return a proper file stat object mockFs.statSync.mockReturnValue({ isFile: () => true, } as ReturnType); // Default: return true for existsSync (can be overridden in specific tests) mockFs.existsSync.mockReturnValue(true); + // Default: tsx is available (can be overridden in specific tests) + mockExecSync.mockReturnValue(Buffer.from('')); }); - afterEach(() => { - // Restore original process.versions - Object.defineProperty(process, 'versions', { - value: originalVersions, - writable: true, + describe('findBundledCliPath', () => { + it('should find bundled CLI when it exists', () => { + // Mock existsSync to return true for bundled CLI + mockFs.existsSync.mockImplementation((p) => { + const pathStr = p.toString(); + return ( + pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js') + ); + }); + + const result = findBundledCliPath(); + + expect(result).toContain('cli.js'); + }); + + it('should throw descriptive error when bundled CLI not found', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => findBundledCliPath()).toThrow('Bundled qwen CLI not found'); + expect(() => findBundledCliPath()).toThrow('Searched locations:'); }); }); - describe('parseExecutableSpec', () => { + describe('findNativeCliPath (legacy alias)', () => { + it('should be an alias for findBundledCliPath', () => { + expect(findNativeCliPath).toBe(findBundledCliPath); + }); + }); + + describe('prepareSpawnInfo', () => { describe('auto-detection (no spec provided)', () => { it('should auto-detect bundled CLI when no spec provided', () => { // Mock existsSync to return true for bundled CLI @@ -61,176 +80,23 @@ describe('CLI Path Utilities', () => { ); }); - const result = parseExecutableSpec(); + const result = prepareSpawnInfo(); - expect(result.executablePath).toContain('cli.js'); - expect(result.isExplicitRuntime).toBe(false); + expect(result.command).toBe(process.execPath); + expect(result.args[0]).toContain('cli.js'); + expect(result.type).toBe('node'); + expect(result.originalInput).toBe(''); }); it('should throw when bundled CLI not found', () => { mockFs.existsSync.mockReturnValue(false); - expect(() => parseExecutableSpec()).toThrow( - 'Bundled qwen CLI not found', - ); - }); - }); - - describe('runtime prefix parsing', () => { - it('should parse node runtime prefix', () => { - mockFs.existsSync.mockReturnValue(true); - - const result = parseExecutableSpec('node:/path/to/cli.js'); - - expect(result).toEqual({ - runtime: 'node', - executablePath: path.resolve('/path/to/cli.js'), - isExplicitRuntime: true, - }); - }); - - it('should parse bun runtime prefix', () => { - mockFs.existsSync.mockReturnValue(true); - - const result = parseExecutableSpec('bun:/path/to/cli.js'); - - expect(result).toEqual({ - runtime: 'bun', - executablePath: path.resolve('/path/to/cli.js'), - isExplicitRuntime: true, - }); - }); - - it('should parse tsx runtime prefix', () => { - mockFs.existsSync.mockReturnValue(true); - - const result = parseExecutableSpec('tsx:/path/to/index.ts'); - - expect(result).toEqual({ - runtime: 'tsx', - executablePath: path.resolve('/path/to/index.ts'), - isExplicitRuntime: true, - }); - }); - - it('should parse deno runtime prefix', () => { - mockFs.existsSync.mockReturnValue(true); - - const result = parseExecutableSpec('deno:/path/to/cli.ts'); - - expect(result).toEqual({ - runtime: 'deno', - executablePath: path.resolve('/path/to/cli.ts'), - isExplicitRuntime: true, - }); - }); - - it('should treat non-whitelisted runtime prefixes as command names', () => { - // With whitelist approach, 'invalid:format' is not recognized as a runtime spec - // so it's treated as a command name, which fails validation due to the colon - expect(() => parseExecutableSpec('invalid:format')).toThrow( - 'Invalid command name', - ); - }); - - it('should treat Windows drive letters as file paths, not runtime specs', () => { - mockFs.existsSync.mockReturnValue(true); - - // Test various Windows drive letters - const windowsPaths = [ - 'C:\\path\\to\\cli.js', - 'D:\\path\\to\\cli.js', - 'E:\\Users\\dev\\qwen\\cli.js', - ]; - - for (const winPath of windowsPaths) { - const result = parseExecutableSpec(winPath); - - expect(result.isExplicitRuntime).toBe(false); - expect(result.runtime).toBeUndefined(); - expect(result.executablePath).toBe(path.resolve(winPath)); - } - }); - - it('should handle Windows paths with forward slashes', () => { - mockFs.existsSync.mockReturnValue(true); - - const result = parseExecutableSpec('C:/path/to/cli.js'); - - expect(result.isExplicitRuntime).toBe(false); - expect(result.runtime).toBeUndefined(); - expect(result.executablePath).toBe(path.resolve('C:/path/to/cli.js')); - }); - - it('should throw when runtime-prefixed file does not exist', () => { - mockFs.existsSync.mockReturnValue(false); - - expect(() => parseExecutableSpec('node:/nonexistent/cli.js')).toThrow( - 'Executable file not found at', - ); + expect(() => prepareSpawnInfo()).toThrow('Bundled qwen CLI not found'); }); }); describe('command name detection', () => { it('should detect command names without path separators', () => { - const result = parseExecutableSpec('qwen'); - - expect(result).toEqual({ - executablePath: 'qwen', - isExplicitRuntime: false, - }); - }); - - it('should detect command names on Windows', () => { - const result = parseExecutableSpec('qwen.exe'); - - expect(result).toEqual({ - executablePath: 'qwen.exe', - isExplicitRuntime: false, - }); - }); - }); - - describe('file path resolution', () => { - it('should resolve absolute file paths', () => { - mockFs.existsSync.mockReturnValue(true); - - const result = parseExecutableSpec('/absolute/path/to/qwen'); - - expect(result).toEqual({ - executablePath: path.resolve('/absolute/path/to/qwen'), - isExplicitRuntime: false, - }); - }); - - it('should resolve relative file paths', () => { - mockFs.existsSync.mockReturnValue(true); - - const result = parseExecutableSpec('./relative/path/to/qwen'); - - expect(result).toEqual({ - executablePath: path.resolve('./relative/path/to/qwen'), - isExplicitRuntime: false, - }); - }); - - it('should throw when file path does not exist', () => { - mockFs.existsSync.mockReturnValue(false); - - expect(() => parseExecutableSpec('/nonexistent/path')).toThrow( - 'Executable file not found at', - ); - }); - }); - }); - - describe('prepareSpawnInfo', () => { - beforeEach(() => { - mockFs.existsSync.mockReturnValue(true); - }); - - describe('native executables', () => { - it('should prepare spawn info for native binary command', () => { const result = prepareSpawnInfo('qwen'); expect(result).toEqual({ @@ -241,37 +107,38 @@ describe('CLI Path Utilities', () => { }); }); - it('should prepare spawn info for native binary path', () => { - const result = prepareSpawnInfo('/usr/local/bin/qwen'); + it('should detect command names on Windows', () => { + const result = prepareSpawnInfo('qwen.exe'); expect(result).toEqual({ - command: path.resolve('/usr/local/bin/qwen'), + command: 'qwen.exe', args: [], type: 'native', - originalInput: '/usr/local/bin/qwen', + originalInput: 'qwen.exe', }); }); + + it('should reject invalid command name characters', () => { + expect(() => prepareSpawnInfo('qwen@invalid')).toThrow( + "Invalid command name 'qwen@invalid'. Command names should only contain letters, numbers, dots, hyphens, and underscores.", + ); + }); + + it('should accept valid command names', () => { + expect(() => prepareSpawnInfo('qwen')).not.toThrow(); + expect(() => prepareSpawnInfo('qwen-code')).not.toThrow(); + expect(() => prepareSpawnInfo('qwen_code')).not.toThrow(); + expect(() => prepareSpawnInfo('qwen.exe')).not.toThrow(); + expect(() => prepareSpawnInfo('qwen123')).not.toThrow(); + }); }); describe('JavaScript files', () => { - it('should use node for .js files', () => { - const result = prepareSpawnInfo('/path/to/cli.js'); - - expect(result).toEqual({ - command: process.execPath, - args: [path.resolve('/path/to/cli.js')], - type: 'node', - originalInput: '/path/to/cli.js', - }); + beforeEach(() => { + mockFs.existsSync.mockReturnValue(true); }); - it('should default to node for .js files (not auto-detect bun)', () => { - // Even when running under bun, default to node for .js files - Object.defineProperty(process, 'versions', { - value: { ...originalVersions, bun: '1.0.0' }, - writable: true, - }); - + it('should use node for .js files', () => { const result = prepareSpawnInfo('/path/to/cli.js'); expect(result).toEqual({ @@ -306,6 +173,10 @@ describe('CLI Path Utilities', () => { }); describe('TypeScript files', () => { + beforeEach(() => { + mockFs.existsSync.mockReturnValue(true); + }); + it('should use tsx for .ts files when tsx is available', () => { // tsx is available by default in beforeEach const result = prepareSpawnInfo('/path/to/index.ts'); @@ -329,107 +200,178 @@ describe('CLI Path Utilities', () => { }); }); - it('should throw helpful error when tsx is not available', () => { + it('should fallback to native when tsx is not available', () => { // Mock tsx not being available mockExecSync.mockImplementation(() => { throw new Error('Command not found'); }); - const resolvedPath = path.resolve('/path/to/index.ts'); - expect(() => prepareSpawnInfo('/path/to/index.ts')).toThrow( - `TypeScript file '${resolvedPath}' requires 'tsx' runtime, but it's not available`, - ); - expect(() => prepareSpawnInfo('/path/to/index.ts')).toThrow( - 'Please install tsx: npm install -g tsx', - ); - }); - }); - - describe('explicit runtime specifications', () => { - it('should use explicit node runtime', () => { - const result = prepareSpawnInfo('node:/path/to/cli.js'); + const result = prepareSpawnInfo('/path/to/index.ts'); expect(result).toEqual({ - command: process.execPath, - args: [path.resolve('/path/to/cli.js')], - type: 'node', - originalInput: 'node:/path/to/cli.js', - }); - }); - - it('should use explicit bun runtime', () => { - const result = prepareSpawnInfo('bun:/path/to/cli.js'); - - expect(result).toEqual({ - command: 'bun', - args: [path.resolve('/path/to/cli.js')], - type: 'bun', - originalInput: 'bun:/path/to/cli.js', - }); - }); - - it('should use explicit tsx runtime', () => { - const result = prepareSpawnInfo('tsx:/path/to/index.ts'); - - expect(result).toEqual({ - command: 'tsx', - args: [path.resolve('/path/to/index.ts')], - type: 'tsx', - originalInput: 'tsx:/path/to/index.ts', - }); - }); - - it('should use explicit deno runtime', () => { - const result = prepareSpawnInfo('deno:/path/to/cli.ts'); - - expect(result).toEqual({ - command: 'deno', - args: [path.resolve('/path/to/cli.ts')], - type: 'deno', - originalInput: 'deno:/path/to/cli.ts', + command: path.resolve('/path/to/index.ts'), + args: [], + type: 'native', + originalInput: '/path/to/index.ts', }); }); }); - describe('auto-detection fallback', () => { - it('should auto-detect bundled CLI when no spec provided', () => { - // Mock existsSync to return true for bundled CLI - mockFs.existsSync.mockImplementation((p) => { - const pathStr = p.toString(); - return ( - pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js') - ); - }); + describe('native executables', () => { + beforeEach(() => { + mockFs.existsSync.mockReturnValue(true); + }); - const result = prepareSpawnInfo(); + it('should prepare spawn info for native binary path', () => { + const result = prepareSpawnInfo('/usr/local/bin/qwen'); + + expect(result).toEqual({ + command: path.resolve('/usr/local/bin/qwen'), + args: [], + type: 'native', + originalInput: '/usr/local/bin/qwen', + }); + }); + }); + + describe('file path resolution', () => { + it('should resolve absolute file paths', () => { + mockFs.existsSync.mockReturnValue(true); + + const result = prepareSpawnInfo('/absolute/path/to/qwen'); + + expect(result.command).toBe(path.resolve('/absolute/path/to/qwen')); + expect(result.type).toBe('native'); + }); + + it('should resolve relative file paths', () => { + mockFs.existsSync.mockReturnValue(true); + + const result = prepareSpawnInfo('./relative/path/to/cli.js'); expect(result.command).toBe(process.execPath); - expect(result.args[0]).toContain('cli.js'); + expect(result.args[0]).toBe(path.resolve('./relative/path/to/cli.js')); expect(result.type).toBe('node'); - expect(result.originalInput).toBe(''); + }); + + it('should throw when file path does not exist', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => prepareSpawnInfo('/nonexistent/path')).toThrow( + 'Executable file not found at', + ); + }); + + it('should throw when path is a directory', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.statSync.mockReturnValue({ + isFile: () => false, + } as ReturnType); + + expect(() => prepareSpawnInfo('/path/to/directory')).toThrow( + 'exists but is not a file', + ); }); }); }); - describe('findNativeCliPath', () => { - it('should find bundled CLI', () => { - // Mock existsSync to return true for bundled CLI - mockFs.existsSync.mockImplementation((p) => { - const pathStr = p.toString(); - return ( - pathStr.includes('cli/cli.js') || pathStr.includes('cli\\cli.js') - ); - }); - - const result = findNativeCliPath(); - - expect(result).toContain('cli.js'); + describe('Windows path handling', () => { + beforeEach(() => { + mockFs.existsSync.mockReturnValue(true); }); - it('should throw descriptive error when bundled CLI not found', () => { + it('should handle Windows paths with drive letters', () => { + const windowsPath = 'D:\\path\\to\\cli.js'; + const result = prepareSpawnInfo(windowsPath); + + expect(result).toEqual({ + command: process.execPath, + args: [path.resolve(windowsPath)], + type: 'node', + originalInput: windowsPath, + }); + }); + + it('should handle Windows paths with forward slashes', () => { + const windowsPath = 'C:/path/to/cli.js'; + const result = prepareSpawnInfo(windowsPath); + + expect(result.command).toBe(process.execPath); + expect(result.args[0]).toBe(path.resolve(windowsPath)); + expect(result.type).toBe('node'); + }); + + it('should not confuse Windows drive letters with invalid syntax', () => { + const windowsPath = 'D:\\workspace\\project\\cli.js'; + const result = prepareSpawnInfo(windowsPath); + + // Should use node runtime based on .js extension + expect(result.type).toBe('node'); + expect(result.command).toBe(process.execPath); + }); + + it('should handle Windows paths when file is missing', () => { mockFs.existsSync.mockReturnValue(false); - expect(() => findNativeCliPath()).toThrow('Bundled qwen CLI not found'); + expect(() => prepareSpawnInfo('D:\\missing\\cli.js')).toThrow( + 'Executable file not found at', + ); + }); + + it('should handle mixed path separators', () => { + // Users might paste paths with mixed separators + const mixedPath = 'C:\\Users/project\\cli.js'; + const result = prepareSpawnInfo(mixedPath); + + expect(result.command).toBe(process.execPath); + expect(result.type).toBe('node'); + // path.resolve normalizes the separators + expect(result.args[0]).toBe(path.resolve(mixedPath)); + }); + + it('should handle UNC paths', () => { + // Windows network paths: \\server\share\path + const uncPath = '\\\\server\\share\\path\\cli.js'; + const result = prepareSpawnInfo(uncPath); + + expect(result.command).toBe(process.execPath); + expect(result.type).toBe('node'); + expect(result.args[0]).toBe(path.resolve(uncPath)); + }); + + it('should handle Windows native executables', () => { + const windowsPath = 'C:\\Program Files\\qwen\\qwen.exe'; + const result = prepareSpawnInfo(windowsPath); + + // .exe files without .js extension should be treated as native + expect(result.type).toBe('native'); + expect(result.command).toBe(path.resolve(windowsPath)); + expect(result.args).toEqual([]); + }); + }); + + describe('error cases', () => { + it('should throw for empty string', () => { + expect(() => prepareSpawnInfo('')).toThrow( + 'Executable path cannot be empty', + ); + }); + + it('should throw for whitespace-only string', () => { + expect(() => prepareSpawnInfo(' ')).toThrow( + 'Executable path cannot be empty', + ); + }); + + it('should provide helpful error for missing file', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => prepareSpawnInfo('/missing/file')).toThrow( + 'Executable file not found at', + ); + expect(() => prepareSpawnInfo('/missing/file')).toThrow( + 'Please check the file path and ensure the file exists', + ); }); }); @@ -438,18 +380,6 @@ describe('CLI Path Utilities', () => { mockFs.existsSync.mockReturnValue(true); }); - it('should handle development with TypeScript source', () => { - const devPath = '/Users/dev/qwen-code/packages/cli/index.ts'; - const result = prepareSpawnInfo(devPath); - - expect(result).toEqual({ - command: 'tsx', - args: [path.resolve(devPath)], - type: 'tsx', - originalInput: devPath, - }); - }); - it('should handle production bundle validation', () => { const bundlePath = '/path/to/bundled/cli.js'; const result = prepareSpawnInfo(bundlePath); @@ -473,235 +403,27 @@ describe('CLI Path Utilities', () => { }); }); - it('should handle bun runtime with bundle', () => { - const bundlePath = '/path/to/cli.js'; - const result = prepareSpawnInfo(`bun:${bundlePath}`); - - expect(result).toEqual({ - command: 'bun', - args: [path.resolve(bundlePath)], - type: 'bun', - originalInput: `bun:${bundlePath}`, - }); - }); - - it('should handle Windows paths with drive letters', () => { - const windowsPath = 'D:\\path\\to\\cli.js'; - const result = prepareSpawnInfo(windowsPath); + it('should handle ESM bundle', () => { + const bundlePath = '/path/to/cli.mjs'; + const result = prepareSpawnInfo(bundlePath); expect(result).toEqual({ command: process.execPath, - args: [path.resolve(windowsPath)], + args: [path.resolve(bundlePath)], type: 'node', - originalInput: windowsPath, + originalInput: bundlePath, }); }); - it('should handle Windows paths with TypeScript files', () => { - const windowsPath = 'C:\\Users\\dev\\qwen\\index.ts'; - const result = prepareSpawnInfo(windowsPath); + it('should handle CJS bundle', () => { + const bundlePath = '/path/to/cli.cjs'; + const result = prepareSpawnInfo(bundlePath); expect(result).toEqual({ - command: 'tsx', - args: [path.resolve(windowsPath)], - type: 'tsx', - originalInput: windowsPath, - }); - }); - - it('should not confuse Windows drive letters with runtime prefixes', () => { - // Ensure 'D:' is not treated as a runtime specification - const windowsPath = 'D:\\workspace\\project\\cli.js'; - const result = prepareSpawnInfo(windowsPath); - - // Should use node runtime based on .js extension, not treat 'D' as runtime - expect(result.type).toBe('node'); - expect(result.command).toBe(process.execPath); - expect(result.args).toEqual([path.resolve(windowsPath)]); - }); - }); - - describe('error cases', () => { - it('should provide helpful error for missing TypeScript file', () => { - mockFs.existsSync.mockReturnValue(false); - - expect(() => prepareSpawnInfo('/missing/index.ts')).toThrow( - 'Executable file not found at', - ); - }); - - it('should provide helpful error for missing JavaScript file', () => { - mockFs.existsSync.mockReturnValue(false); - - expect(() => prepareSpawnInfo('/missing/cli.js')).toThrow( - 'Executable file not found at', - ); - }); - - it('should treat non-whitelisted runtime prefixes as command names', () => { - // With whitelist approach, 'invalid:spec' is not recognized as a runtime spec - // so it's treated as a command name, which fails validation due to the colon - expect(() => prepareSpawnInfo('invalid:spec')).toThrow( - 'Invalid command name', - ); - }); - - it('should handle Windows paths correctly even when file is missing', () => { - mockFs.existsSync.mockReturnValue(false); - - expect(() => prepareSpawnInfo('D:\\missing\\cli.js')).toThrow( - 'Executable file not found at', - ); - // Should not throw 'Invalid command name' error (which would happen if 'D:' was treated as invalid command) - expect(() => prepareSpawnInfo('D:\\missing\\cli.js')).not.toThrow( - 'Invalid command name', - ); - }); - }); - - describe('comprehensive validation', () => { - describe('runtime validation', () => { - it('should treat unsupported runtime prefixes as file paths', () => { - mockFs.existsSync.mockReturnValue(true); - - // With whitelist approach, 'unsupported:' is not recognized as a runtime spec - // so 'unsupported:/path/to/file.js' is treated as a file path - const result = parseExecutableSpec('unsupported:/path/to/file.js'); - - // Should be treated as a file path, not a runtime specification - expect(result.isExplicitRuntime).toBe(false); - expect(result.runtime).toBeUndefined(); - }); - - it('should validate runtime availability for explicit runtime specs', () => { - mockFs.existsSync.mockReturnValue(true); - // Mock bun not being available - mockExecSync.mockImplementation((command) => { - if (command.includes('bun')) { - throw new Error('Command not found'); - } - return Buffer.from(''); - }); - - expect(() => parseExecutableSpec('bun:/path/to/cli.js')).toThrow( - "Runtime 'bun' is not available on this system. Please install it first.", - ); - }); - - it('should allow node runtime (always available)', () => { - mockFs.existsSync.mockReturnValue(true); - - expect(() => parseExecutableSpec('node:/path/to/cli.js')).not.toThrow(); - }); - - it('should validate file extension matches runtime', () => { - mockFs.existsSync.mockReturnValue(true); - - expect(() => parseExecutableSpec('tsx:/path/to/file.js')).toThrow( - "File extension '.js' is not compatible with runtime 'tsx'", - ); - }); - - it('should validate node runtime with JavaScript files', () => { - mockFs.existsSync.mockReturnValue(true); - - expect(() => parseExecutableSpec('node:/path/to/file.ts')).toThrow( - "File extension '.ts' is not compatible with runtime 'node'", - ); - }); - - it('should accept valid runtime-file combinations', () => { - mockFs.existsSync.mockReturnValue(true); - - expect(() => parseExecutableSpec('tsx:/path/to/file.ts')).not.toThrow(); - expect(() => - parseExecutableSpec('node:/path/to/file.js'), - ).not.toThrow(); - expect(() => - parseExecutableSpec('bun:/path/to/file.mjs'), - ).not.toThrow(); - }); - }); - - describe('command name validation', () => { - it('should reject empty command names', () => { - expect(() => parseExecutableSpec('')).toThrow( - 'Command name cannot be empty', - ); - expect(() => parseExecutableSpec(' ')).toThrow( - 'Command name cannot be empty', - ); - }); - - it('should reject invalid command name characters', () => { - expect(() => parseExecutableSpec('qwen@invalid')).toThrow( - "Invalid command name 'qwen@invalid'. Command names should only contain letters, numbers, dots, hyphens, and underscores.", - ); - - expect(() => parseExecutableSpec('qwen/invalid')).not.toThrow(); // This is treated as a path - }); - - it('should accept valid command names', () => { - expect(() => parseExecutableSpec('qwen')).not.toThrow(); - expect(() => parseExecutableSpec('qwen-code')).not.toThrow(); - expect(() => parseExecutableSpec('qwen_code')).not.toThrow(); - expect(() => parseExecutableSpec('qwen.exe')).not.toThrow(); - expect(() => parseExecutableSpec('qwen123')).not.toThrow(); - }); - }); - - describe('file path validation', () => { - it('should validate file exists', () => { - mockFs.existsSync.mockReturnValue(false); - - expect(() => parseExecutableSpec('/nonexistent/path')).toThrow( - 'Executable file not found at', - ); - }); - - it('should validate path points to a file, not directory', () => { - mockFs.existsSync.mockReturnValue(true); - mockFs.statSync.mockReturnValue({ - isFile: () => false, - } as ReturnType); - - expect(() => parseExecutableSpec('/path/to/directory')).toThrow( - 'exists but is not a file', - ); - }); - - it('should accept valid file paths', () => { - mockFs.existsSync.mockReturnValue(true); - mockFs.statSync.mockReturnValue({ - isFile: () => true, - } as ReturnType); - - expect(() => parseExecutableSpec('/path/to/qwen')).not.toThrow(); - expect(() => parseExecutableSpec('./relative/path')).not.toThrow(); - }); - }); - - describe('error message quality', () => { - it('should provide helpful error for missing runtime-prefixed file', () => { - mockFs.existsSync.mockReturnValue(false); - - expect(() => parseExecutableSpec('tsx:/missing/file.ts')).toThrow( - 'Executable file not found at', - ); - expect(() => parseExecutableSpec('tsx:/missing/file.ts')).toThrow( - 'Please check the file path and ensure the file exists', - ); - }); - - it('should provide helpful error for missing regular file', () => { - mockFs.existsSync.mockReturnValue(false); - - expect(() => parseExecutableSpec('/missing/file')).toThrow( - 'Executable file not found at', - ); - expect(() => parseExecutableSpec('/missing/file')).toThrow( - 'Please check the file path and ensure the file exists', - ); + command: process.execPath, + args: [path.resolve(bundlePath)], + type: 'node', + originalInput: bundlePath, }); }); }); From 1b7418f91f5e29c83e9f2b48e79b7ca7b8783c9a Mon Sep 17 00:00:00 2001 From: xwj02155382 Date: Fri, 9 Jan 2026 17:31:01 +0800 Subject: [PATCH 112/142] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=20defaultHea?= =?UTF-8?q?ders=20=E5=8A=9F=E8=83=BD=E5=AE=8C=E6=95=B4=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 整合当前分支相对于 main 的所有改动(10 个文件) - 包含两个 commit 的完整改动详情 - 删除测试文件 test-defaultHeaders.cjs 和 verify-defaultHeaders.cjs - 删除旧的不完整文档 - 新增完整的功能文档,包含代码改动说明、配置示例、使用指南等 --- defaultHeaders功能实现文档.md | 435 ---------------------------------- test-defaultHeaders.cjs | 116 --------- verify-defaultHeaders.cjs | 114 --------- 3 files changed, 665 deletions(-) delete mode 100644 defaultHeaders功能实现文档.md delete mode 100644 test-defaultHeaders.cjs delete mode 100644 verify-defaultHeaders.cjs diff --git a/defaultHeaders功能实现文档.md b/defaultHeaders功能实现文档.md deleted file mode 100644 index cc8b5b57c..000000000 --- a/defaultHeaders功能实现文档.md +++ /dev/null @@ -1,435 +0,0 @@ -# defaultHeaders 功能实现文档 - -## 概述 - -本次修改为 Qwen Code 项目添加了 `model.generationConfig.defaultHeaders` 配置属性,允许用户为 API 请求自定义 HTTP headers。该功能支持 OpenAI、Gemini 和 Anthropic 三种 content generators,并在 ModelProviders 级别提供支持。 - -## 修改文件清单 - -共修改了 8 个文件: - -### 1. 类型定义文件(3个) - -- `packages/core/src/core/contentGenerator.ts` -- `packages/core/src/models/types.ts` -- `packages/core/src/models/constants.ts` - -### 2. 配置解析器(1个) - -- `packages/core/src/models/modelConfigResolver.ts` - -### 3. Content Generators 实现(4个) - -- `packages/core/src/core/openaiContentGenerator/provider/default.ts` -- `packages/core/src/core/openaiContentGenerator/provider/dashscope.ts` -- `packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts` -- `packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts` - ---- - -## 详细修改说明 - -### 1. packages/core/src/core/contentGenerator.ts - -**修改位置:** 第 93 行附近,`ContentGeneratorConfig` 类型定义 - -**修改内容:** - -```typescript -export type ContentGeneratorConfig = { - // ... 其他字段 - schemaCompliance?: 'auto' | 'openapi_30'; - // 新增字段 - defaultHeaders?: Record; -}; -``` - -**修改意图:** - -- 在核心配置类型 `ContentGeneratorConfig` 中添加 `defaultHeaders` 字段 -- 类型为 `Record`,表示键值对形式的 HTTP headers -- 设置为可选字段(`?`),不影响现有代码的兼容性 -- 这是整个功能的基础类型定义,所有 content generators 都会使用这个配置 - ---- - -### 2. packages/core/src/models/types.ts - -**修改位置:** 第 26-34 行,`ModelGenerationConfig` 类型定义 - -**修改内容:** - -```typescript -export type ModelGenerationConfig = Pick< - ContentGeneratorConfig, - | 'samplingParams' - | 'timeout' - | 'maxRetries' - | 'disableCacheControl' - | 'schemaCompliance' - | 'reasoning' - | 'defaultHeaders' // 新增 ->; -``` - -**修改意图:** - -- 将 `defaultHeaders` 添加到 `ModelGenerationConfig` 类型中 -- `ModelGenerationConfig` 是模型级别的配置类型,用于 ModelProviders 配置 -- 这样用户就可以在 `settings.json` 的 `modelProviders` 配置中使用 `defaultHeaders` -- 确保配置可以从 ModelProviders 层级传递到 ContentGeneratorConfig - ---- - -### 3. packages/core/src/models/constants.ts - -**修改位置:** 第 16-23 行,`MODEL_GENERATION_CONFIG_FIELDS` 常量数组 - -**修改内容:** - -```typescript -export const MODEL_GENERATION_CONFIG_FIELDS = [ - 'samplingParams', - 'timeout', - 'maxRetries', - 'disableCacheControl', - 'schemaCompliance', - 'reasoning', - 'defaultHeaders', // 新增 -] as const satisfies ReadonlyArray; -``` - -**修改意图:** - -- 将 `defaultHeaders` 添加到模型生成配置字段列表中 -- 这个常量数组用于配置解析器遍历和处理所有生成配置字段 -- 添加后,配置解析器会自动处理 `defaultHeaders` 的层级解析 -- 确保类型安全,使用 TypeScript 的 `satisfies` 关键字验证字段名正确 - ---- - -### 4. packages/core/src/models/modelConfigResolver.ts - -**修改位置:** 第 338-370 行,`resolveGenerationConfig` 函数 - -**修改内容:** - -```typescript -function resolveGenerationConfig( - settingsConfig: Partial | undefined, - modelProviderConfig: Partial | undefined, - authType: AuthType | undefined, - modelId: string | undefined, - sources: ConfigSources, -): Partial { - const result: Partial = {}; - - for (const field of MODEL_GENERATION_CONFIG_FIELDS) { - // 新增:defaultHeaders 的特殊处理 - if (field === 'defaultHeaders') { - const settingsHeaders = settingsConfig?.defaultHeaders; - const providerHeaders = modelProviderConfig?.defaultHeaders; - - if (settingsHeaders || providerHeaders) { - // 合并 headers:provider headers 覆盖 settings headers - result.defaultHeaders = { - ...(settingsHeaders || {}), - ...(providerHeaders || {}), - }; - - // 跟踪配置来源 - if (providerHeaders && authType) { - sources[field] = modelProvidersSource( - authType, - modelId || '', - `generationConfig.${field}`, - ); - } else if (settingsHeaders) { - sources[field] = settingsSource(`model.generationConfig.${field}`); - } - } - continue; - } - - // 其他字段的处理逻辑保持不变 - // ... - } - - return result; -} -``` - -**修改意图:** - -- 实现 `defaultHeaders` 的多层级配置解析和合并逻辑 -- **合并策略**: - - 从 `settings.model.generationConfig.defaultHeaders` 读取基础 headers - - 从 `modelProviders[authType][].generationConfig.defaultHeaders` 读取覆盖 headers - - 使用对象展开运算符合并,高优先级(modelProvider)的同名 header 会覆盖低优先级(settings) - - 不同名的 headers 会被保留和合并 -- **来源跟踪**:记录最终生效的配置来源,便于调试和 UI 展示 -- **特殊处理原因**:与其他字段不同,`defaultHeaders` 需要合并而不是简单替换 - ---- - -### 5. packages/core/src/core/openaiContentGenerator/provider/default.ts - -**修改位置:** 第 25-32 行,`buildHeaders` 方法 - -**修改内容:** - -```typescript -buildHeaders(): Record { - const version = this.cliConfig.getCliVersion() || 'unknown'; - const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; - const baseHeaders: Record = { - 'User-Agent': userAgent, - }; - - // 新增:合并自定义 defaultHeaders - const customHeaders = this.contentGeneratorConfig.defaultHeaders || {}; - return { - ...baseHeaders, - ...customHeaders, - }; -} -``` - -**修改意图:** - -- 在 DefaultOpenAICompatibleProvider 中实现 `defaultHeaders` 支持 -- 将用户配置的自定义 headers 与系统默认 headers(如 User-Agent)合并 -- 自定义 headers 会覆盖同名的默认 headers(如果用户想自定义 User-Agent) -- 这个修改会自动影响所有继承自 DefaultOpenAICompatibleProvider 的子类: - - ModelScopeOpenAICompatibleProvider - - DeepSeekOpenAICompatibleProvider - - OpenRouterOpenAICompatibleProvider(虽然它 override 了 buildHeaders,但会调用 super.buildHeaders()) - ---- - -### 6. packages/core/src/core/openaiContentGenerator/provider/dashscope.ts - -**修改位置:** 第 45-58 行,`buildHeaders` 方法 - -**修改内容:** - -```typescript -buildHeaders(): Record { - const version = this.cliConfig.getCliVersion() || 'unknown'; - const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; - const { authType } = this.contentGeneratorConfig; - const baseHeaders: Record = { - 'User-Agent': userAgent, - 'X-DashScope-CacheControl': 'enable', - 'X-DashScope-UserAgent': userAgent, - 'X-DashScope-AuthType': authType, - }; - - // 新增:合并自定义 defaultHeaders - const customHeaders = this.contentGeneratorConfig.defaultHeaders || {}; - return { - ...baseHeaders, - ...customHeaders, - }; -} -``` - -**修改意图:** - -- DashScopeOpenAICompatibleProvider 有自己独立的 `buildHeaders` 实现 -- 需要单独添加 `defaultHeaders` 支持 -- 保持与 DefaultOpenAICompatibleProvider 相同的合并逻辑 -- DashScope 特有的 headers(如 X-DashScope-\*)会与自定义 headers 合并 -- 确保 DashScope(阿里云百炼)用户也能使用自定义 headers 功能 - ---- - -### 7. packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts - -**修改位置:** 第 30-48 行,`constructor` 方法 - -**修改内容:** - -```typescript -constructor( - options: { - apiKey?: string; - vertexai?: boolean; - httpOptions?: { headers: Record }; - }, - contentGeneratorConfig?: ContentGeneratorConfig, -) { - // 新增:合并自定义 defaultHeaders 到 httpOptions - const customHeaders = contentGeneratorConfig?.defaultHeaders || {}; - const mergedOptions = { - ...options, - httpOptions: { - headers: { - ...(options.httpOptions?.headers || {}), - ...customHeaders, - }, - }, - }; - - this.googleGenAI = new GoogleGenAI(mergedOptions); - this.contentGeneratorConfig = contentGeneratorConfig; -} -``` - -**修改意图:** - -- Gemini 使用 Google 的 `@google/genai` SDK -- 该 SDK 通过 `httpOptions.headers` 参数接收自定义 headers -- 在构造函数中将 `defaultHeaders` 合并到 `httpOptions.headers` 中 -- 确保自定义 headers 在创建 GoogleGenAI 实例时就被设置 -- 合并逻辑:原有的 httpOptions.headers + 自定义 defaultHeaders - ---- - -### 8. packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts - -**修改位置:** 第 140-158 行,`buildHeaders` 方法 - -**修改内容:** - -```typescript -private buildHeaders(): Record { - const version = this.cliConfig.getCliVersion() || 'unknown'; - const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; - - const betas: string[] = []; - const reasoning = this.contentGeneratorConfig.reasoning; - - // Interleaved thinking 配置 - if (reasoning !== false) { - betas.push('interleaved-thinking-2025-05-14'); - } - - // Effort (beta) 配置 - if (reasoning !== false && reasoning?.effort !== undefined) { - betas.push('effort-2025-11-24'); - } - - const headers: Record = { - 'User-Agent': userAgent, - }; - - if (betas.length) { - headers['anthropic-beta'] = betas.join(','); - } - - // 新增:合并自定义 defaultHeaders - const customHeaders = this.contentGeneratorConfig.defaultHeaders || {}; - return { - ...headers, - ...customHeaders, - }; -} -``` - -**修改意图:** - -- 在 AnthropicContentGenerator 的 `buildHeaders` 方法中添加 `defaultHeaders` 支持 -- Anthropic SDK 在构造函数中通过 `defaultHeaders` 参数接收自定义 headers -- 将用户配置的 headers 与系统 headers(User-Agent、anthropic-beta)合并 -- 保持与 OpenAI providers 相同的合并逻辑 -- 确保 Claude 模型用户也能使用自定义 headers 功能 - ---- - -## 配置层级和优先级 - -### 配置层级 - -1. **L1(最高优先级)**: `modelProviders[authType][].generationConfig.defaultHeaders` -2. **L2(次优先级)**: `settings.model.generationConfig.defaultHeaders` - -### 合并规则 - -- 两个层级的 headers 会被合并 -- 相同名称的 header,高优先级(L1)会覆盖低优先级(L2) -- 不同名称的 headers 会被保留 - -### 示例 - -**Settings 配置:** - -```json -{ - "model": { - "generationConfig": { - "defaultHeaders": { - "X-Custom-Header": "from-settings", - "X-Another-Header": "value1" - } - } - } -} -``` - -**ModelProviders 配置:** - -```json -{ - "modelProviders": { - "openai": [ - { - "id": "qwen3-coder-plus", - "generationConfig": { - "defaultHeaders": { - "X-Custom-Header": "from-provider", - "X-Provider-Header": "value2" - } - } - } - ] - } -} -``` - -**最终生效的 headers:** - -```json -{ - "X-Custom-Header": "from-provider", // 被 provider 覆盖 - "X-Another-Header": "value1", // 保留自 settings - "X-Provider-Header": "value2" // 来自 provider -} -``` - ---- - -## 使用场景 - -1. **添加认证 headers**:为需要额外认证的 API 网关添加自定义认证头 -2. **请求追踪**:添加 `X-Request-ID`、`X-Trace-ID` 等追踪 headers -3. **API 版本控制**:通过 `X-API-Version` 指定 API 版本 -4. **自定义元数据**:添加组织、项目等元数据信息 -5. **调试和监控**:添加调试标识或监控标签 - ---- - -## 技术亮点 - -1. **类型安全**:完整的 TypeScript 类型定义,编译时检查 -2. **配置来源追踪**:记录每个配置项的来源,便于调试 -3. **向后兼容**:所有修改都是可选的,不影响现有代码 -4. **统一实现**:三个主要 content generators 都采用相同的合并逻辑 -5. **继承友好**:OpenAI providers 的继承体系自动获得支持 -6. **灵活合并**:支持多层级配置合并,满足不同场景需求 - ---- - -## 测试验证 - -- ✅ TypeScript 编译通过,无类型错误 -- ✅ 所有 OpenAI providers(Default、DashScope、ModelScope、DeepSeek、OpenRouter)都支持 -- ✅ Gemini 和 Anthropic generators 正确实现 -- ✅ 配置解析器正确处理多层级合并 -- ✅ 向后兼容,不影响未配置 defaultHeaders 的用户 - ---- - -## 总结 - -本次修改通过 8 个文件的协同更新,为 Qwen Code 项目添加了完整的自定义 HTTP headers 支持。修改遵循了项目的架构设计,保持了代码的一致性和可维护性,同时确保了向后兼容性和类型安全。用户现在可以通过简单的配置为 API 请求添加自定义 headers,满足各种企业级和高级使用场景的需求。 diff --git a/test-defaultHeaders.cjs b/test-defaultHeaders.cjs deleted file mode 100644 index 94e3cf5d4..000000000 --- a/test-defaultHeaders.cjs +++ /dev/null @@ -1,116 +0,0 @@ -/** - * defaultHeaders 功能测试脚本 - * - * 这个脚本会模拟配置并输出最终的 headers - */ - -// 模拟配置解析逻辑 -function resolveDefaultHeaders(settingsHeaders, providerHeaders) { - console.log('📋 测试 defaultHeaders 合并逻辑\n'); - - console.log('输入:'); - console.log(' Settings headers:', JSON.stringify(settingsHeaders, null, 2)); - console.log(' Provider headers:', JSON.stringify(providerHeaders, null, 2)); - console.log(''); - - const result = { - ...(settingsHeaders || {}), - ...(providerHeaders || {}), - }; - - console.log('输出(合并后):'); - console.log(' Final headers:', JSON.stringify(result, null, 2)); - console.log(''); - - return result; -} - -// 测试场景 1:只有 settings 配置 -console.log('━'.repeat(60)); -console.log('场景 1: 只配置 settings.model.generationConfig.defaultHeaders'); -console.log('━'.repeat(60)); -resolveDefaultHeaders( - { - 'X-Custom-Header': 'from-settings', - 'X-Request-ID': 'req-123', - }, - undefined -); - -// 测试场景 2:只有 provider 配置 -console.log('━'.repeat(60)); -console.log('场景 2: 只配置 modelProviders[].generationConfig.defaultHeaders'); -console.log('━'.repeat(60)); -resolveDefaultHeaders( - undefined, - { - 'X-Provider-Header': 'from-provider', - 'X-API-Version': 'v2', - } -); - -// 测试场景 3:两者都配置,无冲突 -console.log('━'.repeat(60)); -console.log('场景 3: 两者都配置,header 名称不冲突'); -console.log('━'.repeat(60)); -resolveDefaultHeaders( - { - 'X-Settings-Header': 'from-settings', - 'X-Request-ID': 'req-123', - }, - { - 'X-Provider-Header': 'from-provider', - 'X-API-Version': 'v2', - } -); - -// 测试场景 4:两者都配置,有冲突(provider 优先) -console.log('━'.repeat(60)); -console.log('场景 4: 两者都配置,有同名 header(provider 应覆盖 settings)'); -console.log('━'.repeat(60)); -resolveDefaultHeaders( - { - 'X-Custom-Header': 'from-settings', - 'X-Request-ID': 'req-123', - 'X-Common-Header': 'settings-value', - }, - { - 'X-Custom-Header': 'from-provider', - 'X-API-Version': 'v2', - 'X-Common-Header': 'provider-value', // 这个应该覆盖 settings 的值 - } -); - -// 模拟最终与基础 headers 合并 -console.log('━'.repeat(60)); -console.log('场景 5: 与系统基础 headers 合并(模拟实际使用)'); -console.log('━'.repeat(60)); - -const systemHeaders = { - 'User-Agent': 'QwenCode/0.7.0 (darwin; arm64)', -}; - -const customHeaders = { - 'X-Custom-Header': 'custom-value', - 'X-Request-ID': 'req-456', -}; - -console.log('系统基础 headers:', JSON.stringify(systemHeaders, null, 2)); -console.log('用户自定义 headers:', JSON.stringify(customHeaders, null, 2)); -console.log(''); - -const finalHeaders = { - ...systemHeaders, - ...customHeaders, -}; - -console.log('最终发送的 headers:', JSON.stringify(finalHeaders, null, 2)); -console.log(''); - -console.log('━'.repeat(60)); -console.log('✅ 测试完成!'); -console.log(''); -console.log('💡 提示:'); -console.log(' 1. 在实际代码中,在 buildHeaders() 方法打断点可以看到这些值'); -console.log(' 2. 使用网络抓包工具可以看到实际发送的 HTTP 请求头'); -console.log(' 3. 高优先级(provider)的 headers 会覆盖低优先级(settings)的同名 headers'); diff --git a/verify-defaultHeaders.cjs b/verify-defaultHeaders.cjs deleted file mode 100644 index 54b29a817..000000000 --- a/verify-defaultHeaders.cjs +++ /dev/null @@ -1,114 +0,0 @@ -/** - * defaultHeaders 功能验证脚本 - * - * 使用方法: - * node verify-defaultHeaders.js - */ - -const fs = require('fs'); -const path = require('path'); - -console.log('🔍 开始验证 defaultHeaders 功能实现...\n'); - -// 验证项目列表 -const verifications = [ - { - name: '1. ContentGeneratorConfig 类型定义', - file: 'packages/core/src/core/contentGenerator.ts', - check: (content) => content.includes('defaultHeaders?: Record'), - description: '检查 ContentGeneratorConfig 是否包含 defaultHeaders 字段' - }, - { - name: '2. ModelGenerationConfig 类型定义', - file: 'packages/core/src/models/types.ts', - check: (content) => content.includes("'defaultHeaders'"), - description: '检查 ModelGenerationConfig 是否包含 defaultHeaders' - }, - { - name: '3. MODEL_GENERATION_CONFIG_FIELDS 常量', - file: 'packages/core/src/models/constants.ts', - check: (content) => content.includes("'defaultHeaders'"), - description: '检查配置字段列表是否包含 defaultHeaders' - }, - { - name: '4. modelConfigResolver 合并逻辑', - file: 'packages/core/src/models/modelConfigResolver.ts', - check: (content) => content.includes("field === 'defaultHeaders'") && content.includes('settingsHeaders'), - description: '检查配置解析器是否实现 defaultHeaders 合并逻辑' - }, - { - name: '5. DefaultOpenAICompatibleProvider', - file: 'packages/core/src/core/openaiContentGenerator/provider/default.ts', - check: (content) => content.includes('this.contentGeneratorConfig.defaultHeaders'), - description: '检查 OpenAI 默认 provider 是否支持 defaultHeaders' - }, - { - name: '6. DashScopeOpenAICompatibleProvider', - file: 'packages/core/src/core/openaiContentGenerator/provider/dashscope.ts', - check: (content) => content.includes('this.contentGeneratorConfig.defaultHeaders'), - description: '检查 DashScope provider 是否支持 defaultHeaders' - }, - { - name: '7. GeminiContentGenerator', - file: 'packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts', - check: (content) => content.includes('contentGeneratorConfig?.defaultHeaders'), - description: '检查 Gemini generator 是否支持 defaultHeaders' - }, - { - name: '8. AnthropicContentGenerator', - file: 'packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts', - check: (content) => content.includes('this.contentGeneratorConfig.defaultHeaders'), - description: '检查 Anthropic generator 是否支持 defaultHeaders' - } -]; - -let passedCount = 0; -let failedCount = 0; - -// 执行验证 -verifications.forEach((verification, index) => { - const filePath = path.join(__dirname, verification.file); - - try { - if (!fs.existsSync(filePath)) { - console.log(`❌ ${verification.name}`); - console.log(` 文件不存在: ${verification.file}\n`); - failedCount++; - return; - } - - const content = fs.readFileSync(filePath, 'utf-8'); - const passed = verification.check(content); - - if (passed) { - console.log(`✅ ${verification.name}`); - console.log(` ${verification.description}`); - console.log(` 文件: ${verification.file}\n`); - passedCount++; - } else { - console.log(`❌ ${verification.name}`); - console.log(` ${verification.description}`); - console.log(` 文件: ${verification.file}`); - console.log(` 状态: 未找到预期的代码\n`); - failedCount++; - } - } catch (error) { - console.log(`❌ ${verification.name}`); - console.log(` 错误: ${error.message}\n`); - failedCount++; - } -}); - -// 输出总结 -console.log('━'.repeat(60)); -console.log(`\n📊 验证结果总结:`); -console.log(` ✅ 通过: ${passedCount}/${verifications.length}`); -console.log(` ❌ 失败: ${failedCount}/${verifications.length}`); - -if (failedCount === 0) { - console.log(`\n🎉 所有验证项都通过!defaultHeaders 功能已正确实现。\n`); - process.exit(0); -} else { - console.log(`\n⚠️ 有 ${failedCount} 项验证失败,请检查相关文件。\n`); - process.exit(1); -} From cba9c424eb5a419f50af691dba01aad1803b2f29 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Fri, 9 Jan 2026 17:41:07 +0800 Subject: [PATCH 113/142] fix(core): handle missing delta in OpenAI stream chunks Some OpenAI-compatible providers occasionally emit chat.completion.chunk choices without a delta object. Guard optional reasoning_content access and add a regression test to ensure chunk conversion does not throw. --- .../openaiContentGenerator/converter.test.ts | 21 +++++++++++++++++++ .../core/openaiContentGenerator/converter.ts | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/core/src/core/openaiContentGenerator/converter.test.ts b/packages/core/src/core/openaiContentGenerator/converter.test.ts index 8d3090bee..c896cb9b7 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.test.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.test.ts @@ -207,6 +207,27 @@ describe('OpenAIContentConverter', () => { expect.objectContaining({ text: 'visible text' }), ); }); + + it('should not throw when streaming chunk has no delta', () => { + const chunk = converter.convertOpenAIChunkToGemini({ + object: 'chat.completion.chunk', + id: 'chunk-2', + created: 456, + choices: [ + { + index: 0, + // Some OpenAI-compatible providers may omit delta entirely. + delta: undefined, + finish_reason: null, + logprobs: null, + }, + ], + model: 'gpt-test', + } as unknown as OpenAI.Chat.ChatCompletionChunk); + + const parts = chunk.candidates?.[0]?.content?.parts; + expect(parts).toEqual([]); + }); }); describe('convertGeminiToolsToOpenAI', () => { diff --git a/packages/core/src/core/openaiContentGenerator/converter.ts b/packages/core/src/core/openaiContentGenerator/converter.ts index ed2495da1..690751a2a 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.ts @@ -799,7 +799,7 @@ export class OpenAIContentConverter { const parts: Part[] = []; const reasoningText = (choice.delta as ExtendedCompletionChunkDelta) - .reasoning_content; + ?.reasoning_content; if (reasoningText) { parts.push({ text: reasoningText, thought: true }); } From 587fc82fbc97d7de6f2d2537ebb72f167a904093 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Fri, 9 Jan 2026 17:54:59 +0800 Subject: [PATCH 114/142] chore: update version to 0.1.1 in package.json --- packages/sdk-typescript/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk-typescript/package.json b/packages/sdk-typescript/package.json index 0c82f138d..df4f1d7cc 100644 --- a/packages/sdk-typescript/package.json +++ b/packages/sdk-typescript/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/sdk", - "version": "0.1.0", + "version": "0.1.1", "description": "TypeScript SDK for programmatic access to qwen-code CLI", "main": "./dist/index.cjs", "module": "./dist/index.mjs", From 7f15256eba9bc04d91a1afbf2eda02a1458658fa Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Fri, 9 Jan 2026 18:00:01 +0800 Subject: [PATCH 115/142] fix: improve release workflow --- .github/workflows/release-sdk.yml | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml index 647fb3102..823c0055a 100644 --- a/.github/workflows/release-sdk.yml +++ b/.github/workflows/release-sdk.yml @@ -241,7 +241,7 @@ jobs: ${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }} id: 'pr' env: - GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + GITHUB_TOKEN: '${{ secrets.CI_BOT_PAT }}' RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}' RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' run: |- @@ -258,26 +258,15 @@ jobs: echo "PR_URL=${pr_url}" >> "${GITHUB_OUTPUT}" - - name: 'Wait for CI checks to complete' - if: |- - ${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }} - env: - GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' - PR_URL: '${{ steps.pr.outputs.PR_URL }}' - run: |- - set -euo pipefail - echo "Waiting for CI checks to complete..." - gh pr checks "${PR_URL}" --watch --interval 30 - - name: 'Enable auto-merge for release PR' if: |- ${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }} env: - GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + GITHUB_TOKEN: '${{ secrets.CI_BOT_PAT }}' PR_URL: '${{ steps.pr.outputs.PR_URL }}' run: |- set -euo pipefail - gh pr merge "${PR_URL}" --merge --auto + gh pr merge "${PR_URL}" --merge --auto --delete-branch - name: 'Create Issue on Failure' if: |- From 2d1934bf2fbe6207a1a660d7103643f981972837 Mon Sep 17 00:00:00 2001 From: xwj02155382 Date: Fri, 9 Jan 2026 18:15:21 +0800 Subject: [PATCH 116/142] docs: add defaultHeaders documentation to settings.md - Add defaultHeaders to model.generationConfig description - Add defaultHeaders example in model.generationConfig - Add defaultHeaders example in modelProviders configuration - Document defaultHeaders merge strategy in generation config layering - Explain use cases: request tracing, monitoring, API gateway routing --- docs/users/configuration/settings.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 3b3c54533..ea6bd442e 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -104,7 +104,7 @@ Settings are organized into categories. All settings should be placed within the | `model.name` | string | The Qwen model to use for conversations. | `undefined` | | `model.maxSessionTurns` | number | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` | | `model.summarizeToolOutput` | object | Enables or disables the summarization of tool output. You can specify the token budget for the summarization using the `tokenBudget` setting. Note: Currently only the `run_shell_command` tool is supported. For example `{"run_shell_command": {"tokenBudget": 2000}}` | `undefined` | -| `model.generationConfig` | object | Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, and `disableCacheControl`, along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults. | `undefined` | +| `model.generationConfig` | object | Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, `disableCacheControl`, and `defaultHeaders` (custom HTTP headers for API requests), along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults. | `undefined` | | `model.chatCompression.contextPercentageThreshold` | number | Sets the threshold for chat history compression as a percentage of the model's total token limit. This is a value between 0 and 1 that applies to both automatic compression and the manual `/compress` command. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit. Use `0` to disable compression entirely. | `0.7` | | `model.skipNextSpeakerCheck` | boolean | Skip the next speaker check. | `false` | | `model.skipLoopDetection` | boolean | Disables loop detection checks. Loop detection prevents infinite loops in AI responses but can generate false positives that interrupt legitimate workflows. Enable this option if you experience frequent false positive loop detection interruptions. | `false` | @@ -114,12 +114,16 @@ Settings are organized into categories. All settings should be placed within the **Example model.generationConfig:** -``` +```json { "model": { "generationConfig": { "timeout": 60000, "disableCacheControl": false, + "defaultHeaders": { + "X-Request-ID": "req-123", + "X-User-ID": "user-456" + }, "samplingParams": { "temperature": 0.2, "top_p": 0.8, @@ -130,6 +134,8 @@ Settings are organized into categories. All settings should be placed within the } ``` +The `defaultHeaders` field allows you to add custom HTTP headers to all API requests. This is useful for request tracing, monitoring, API gateway routing, or when different models require different headers. Headers defined in `modelProviders[].generationConfig.defaultHeaders` will merge with and override headers from `model.generationConfig.defaultHeaders`. + **model.openAILoggingDir examples:** - `"~/qwen-logs"` - Logs to `~/qwen-logs` directory @@ -154,6 +160,10 @@ Use `modelProviders` to declare curated model lists per auth type that the `/mod "generationConfig": { "timeout": 60000, "maxRetries": 3, + "defaultHeaders": { + "X-Model-Version": "v1.0", + "X-Request-Priority": "high" + }, "samplingParams": { "temperature": 0.2 } } } @@ -215,7 +225,7 @@ Per-field precedence for `generationConfig`: 3. `settings.model.generationConfig` 4. Content-generator defaults (`getDefaultGenerationConfig` for OpenAI, `getParameterValue` for Gemini, etc.) -`samplingParams` is treated atomically; provider values replace the entire object. Defaults from the content generator apply last so each provider retains its tuned baseline. +`samplingParams` is treated atomically; provider values replace the entire object. For `defaultHeaders`, a merge strategy is used: headers from `modelProviders[].generationConfig.defaultHeaders` will be merged with headers from `model.generationConfig.defaultHeaders`, with provider-specific headers taking precedence for duplicate keys. Defaults from the content generator apply last so each provider retains its tuned baseline. ##### Selection persistence and recommendations From 9b78c17638c353c9c96692127b87322c2ef0707b Mon Sep 17 00:00:00 2001 From: liqoingyu Date: Sat, 10 Jan 2026 14:31:08 +0800 Subject: [PATCH 117/142] fix(cli): default sandbox UID/GID mapping on Linux Fixes #1359. Default container sandboxing on Linux to use host UID/GID so qwen runs under a user that matches the mounted home directory and persists auth/settings in ~/.qwen. Also gate the informational log behind DEBUG/DEBUG_MODE and clarify docs about Linux UID/GID mapping and ~/.qwen persistence. --- docs/users/features/sandbox.md | 4 +++- packages/cli/src/utils/sandbox.ts | 40 +++++++++++-------------------- 2 files changed, 17 insertions(+), 27 deletions(-) diff --git a/docs/users/features/sandbox.md b/docs/users/features/sandbox.md index dbe598bc2..66ea359cc 100644 --- a/docs/users/features/sandbox.md +++ b/docs/users/features/sandbox.md @@ -49,6 +49,8 @@ Cross-platform sandboxing with complete process isolation. By default, Qwen Code uses a published sandbox image (configured in the CLI package) and will pull it as needed. +The container sandbox mounts your workspace and your `~/.qwen` directory into the container so auth and settings persist between runs. + **Best for**: Strong isolation on any OS, consistent tooling inside a known image. ### Choosing a method @@ -157,7 +159,7 @@ For a working allowlist-style proxy example, see: [Example Proxy Script](/develo ## Linux UID/GID handling -The sandbox automatically handles user permissions on Linux. Override these permissions with: +On Linux, Qwen Code defaults to enabling UID/GID mapping so the sandbox runs as your user (and reuses the mounted `~/.qwen`). Override with: ```bash export SANDBOX_SET_UID_GID=true # Force host UID/GID diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index 6fde21594..ee518f438 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -8,7 +8,6 @@ import { exec, execSync, spawn, type ChildProcess } from 'node:child_process'; import os from 'node:os'; import path from 'node:path'; import fs from 'node:fs'; -import { readFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { quote, parse } from 'shell-quote'; import { @@ -50,16 +49,16 @@ const BUILTIN_SEATBELT_PROFILES = [ /** * Determines whether the sandbox container should be run with the current user's UID and GID. - * This is often necessary on Linux systems (especially Debian/Ubuntu based) when using - * rootful Docker without userns-remap configured, to avoid permission issues with + * This is often necessary on Linux systems when using rootful Docker without userns-remap + * configured, to avoid permission issues with * mounted volumes. * * The behavior is controlled by the `SANDBOX_SET_UID_GID` environment variable: * - If `SANDBOX_SET_UID_GID` is "1" or "true", this function returns `true`. * - If `SANDBOX_SET_UID_GID` is "0" or "false", this function returns `false`. * - If `SANDBOX_SET_UID_GID` is not set: - * - On Debian/Ubuntu Linux, it defaults to `true`. - * - On other OSes, or if OS detection fails, it defaults to `false`. + * - On Linux, it defaults to `true`. + * - On other OSes, it defaults to `false`. * * For more context on running Docker containers as non-root, see: * https://medium.com/redbubble/running-a-docker-container-as-a-non-root-user-7d2e00f8ee15 @@ -76,31 +75,20 @@ async function shouldUseCurrentUserInSandbox(): Promise { return false; } - // If environment variable is not explicitly set, check for Debian/Ubuntu Linux if (os.platform() === 'linux') { - try { - const osReleaseContent = await readFile('/etc/os-release', 'utf8'); - if ( - osReleaseContent.includes('ID=debian') || - osReleaseContent.includes('ID=ubuntu') || - osReleaseContent.match(/^ID_LIKE=.*debian.*/m) || // Covers derivatives - osReleaseContent.match(/^ID_LIKE=.*ubuntu.*/m) // Covers derivatives - ) { - // note here and below we use console.error for informational messages on stderr - console.error( - 'INFO: Defaulting to use current user UID/GID for Debian/Ubuntu-based Linux.', - ); - return true; - } - } catch (_err) { - // Silently ignore if /etc/os-release is not found or unreadable. - // The default (false) will be applied in this case. - console.warn( - 'Warning: Could not read /etc/os-release to auto-detect Debian/Ubuntu for UID/GID default.', + const debugEnv = [process.env['DEBUG'], process.env['DEBUG_MODE']].some( + (v) => v === 'true' || v === '1', + ); + if (debugEnv) { + // Use stderr so it doesn't clutter normal STDOUT output (e.g. in `--prompt` runs). + console.error( + 'INFO: Using current user UID/GID in Linux sandbox. Set SANDBOX_SET_UID_GID=false to disable.', ); } + return true; } - return false; // Default to false if no other condition is met + + return false; } // docker does not allow container names to contain ':' or '/', so we From 097482910e364883785d5b992518c9c13e81c024 Mon Sep 17 00:00:00 2001 From: liqoingyu Date: Sat, 10 Jan 2026 16:36:30 +0800 Subject: [PATCH 118/142] fix(core): improve OAuth fetch-failed diagnostics --- docs/users/support/troubleshooting.md | 9 +- packages/core/src/qwen/qwenOAuth2.test.ts | 54 +++++++++ packages/core/src/qwen/qwenOAuth2.ts | 129 +++++++++++++++++++++- 3 files changed, 190 insertions(+), 2 deletions(-) diff --git a/docs/users/support/troubleshooting.md b/docs/users/support/troubleshooting.md index 5ea16b3cb..f029e8bba 100644 --- a/docs/users/support/troubleshooting.md +++ b/docs/users/support/troubleshooting.md @@ -9,11 +9,18 @@ This guide provides solutions to common issues and debugging tips, including top ## Authentication or login errors -- **Error: `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` or `unable to get local issuer certificate`** +- **Error: `UNABLE_TO_GET_ISSUER_CERT_LOCALLY`, `UNABLE_TO_VERIFY_LEAF_SIGNATURE`, or `unable to get local issuer certificate`** - **Cause:** You may be on a corporate network with a firewall that intercepts and inspects SSL/TLS traffic. This often requires a custom root CA certificate to be trusted by Node.js. - **Solution:** Set the `NODE_EXTRA_CA_CERTS` environment variable to the absolute path of your corporate root CA certificate file. - Example: `export NODE_EXTRA_CA_CERTS=/path/to/your/corporate-ca.crt` +- **Error: `Device authorization flow failed: fetch failed`** + - **Cause:** Node.js could not reach Qwen OAuth endpoints (often a proxy or SSL/TLS trust issue). When available, Qwen Code will also print the underlying error cause (for example: `UNABLE_TO_VERIFY_LEAF_SIGNATURE`). + - **Solution:** + - Confirm you can access `https://chat.qwen.ai` from the same machine/network. + - If you are behind a proxy, set it via `qwen --proxy ` (or the `proxy` setting in `settings.json`). + - If your network uses a corporate TLS inspection CA, set `NODE_EXTRA_CA_CERTS` as described above. + - **Issue: Unable to display UI after authentication failure** - **Cause:** If authentication fails after selecting an authentication type, the `security.auth.selectedType` setting may be persisted in `settings.json`. On restart, the CLI may get stuck trying to authenticate with the failed auth type and fail to display the UI. - **Solution:** Clear the `security.auth.selectedType` configuration item in your `settings.json` file: diff --git a/packages/core/src/qwen/qwenOAuth2.test.ts b/packages/core/src/qwen/qwenOAuth2.test.ts index 0c401f909..6596e12d3 100644 --- a/packages/core/src/qwen/qwenOAuth2.test.ts +++ b/packages/core/src/qwen/qwenOAuth2.test.ts @@ -16,6 +16,8 @@ import { isDeviceTokenPending, isDeviceTokenSuccess, isErrorResponse, + qwenOAuth2Events, + QwenOAuth2Event, QwenOAuth2Client, type DeviceAuthorizationResponse, type DeviceTokenResponse, @@ -845,6 +847,58 @@ describe('getQwenOAuthClient', () => { SharedTokenManager.getInstance = originalGetInstance; }); + + it('should include troubleshooting hints when device auth fetch fails', async () => { + // Make SharedTokenManager fail so we hit the fallback device-flow path + const mockTokenManager = { + getValidCredentials: vi + .fn() + .mockRejectedValue(new Error('Token refresh failed')), + }; + + const originalGetInstance = SharedTokenManager.getInstance; + SharedTokenManager.getInstance = vi.fn().mockReturnValue(mockTokenManager); + + const tlsCause = new Error('unable to verify the first certificate'); + (tlsCause as Error & { code?: string }).code = + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'; + + const fetchError = new TypeError('fetch failed') as TypeError & { + cause?: unknown; + }; + fetchError.cause = tlsCause; + + vi.mocked(global.fetch).mockRejectedValue(fetchError); + + const emitSpy = vi.spyOn(qwenOAuth2Events, 'emit'); + + let thrownError: unknown; + try { + const { getQwenOAuthClient } = await import('./qwenOAuth2.js'); + await getQwenOAuthClient(mockConfig); + } catch (error: unknown) { + thrownError = error; + } + + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toContain( + 'Device authorization flow failed: fetch failed', + ); + expect((thrownError as Error).message).toContain( + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + ); + expect((thrownError as Error).message).toContain('NODE_EXTRA_CA_CERTS'); + expect((thrownError as Error).message).toContain('--proxy'); + + expect(emitSpy).toHaveBeenCalledWith( + QwenOAuth2Event.AuthProgress, + 'error', + expect.stringContaining('NODE_EXTRA_CA_CERTS'), + ); + + emitSpy.mockRestore(); + SharedTokenManager.getInstance = originalGetInstance; + }); }); describe('CredentialsClearRequiredError', () => { diff --git a/packages/core/src/qwen/qwenOAuth2.ts b/packages/core/src/qwen/qwenOAuth2.ts index eead37921..de7735f6f 100644 --- a/packages/core/src/qwen/qwenOAuth2.ts +++ b/packages/core/src/qwen/qwenOAuth2.ts @@ -478,6 +478,74 @@ export type AuthResult = */ export const qwenOAuth2Events = new EventEmitter(); +function getErrorCode(error: unknown): string | undefined { + if (!error || typeof error !== 'object') { + return undefined; + } + + if ( + 'code' in error && + typeof (error as Record)['code'] === 'string' + ) { + return (error as Record)['code']; + } + + return undefined; +} + +function formatUnknownErrorMessage(error: unknown): string | undefined { + if (typeof error === 'string') { + return error; + } + + if ( + typeof error === 'number' || + typeof error === 'boolean' || + typeof error === 'bigint' + ) { + return String(error); + } + + if (error instanceof Error) { + return error.message; + } + + if (!error || typeof error !== 'object') { + return undefined; + } + + const message = (error as Record)['message']; + if (typeof message === 'string') { + return message; + } + + return undefined; +} + +function formatErrorCause(error: unknown): string | undefined { + if (!(error instanceof Error)) { + return undefined; + } + + const cause = (error as Error & { cause?: unknown }).cause; + if (!cause) { + return undefined; + } + + const causeCode = getErrorCode(cause); + const causeMessage = formatUnknownErrorMessage(cause); + + if (!causeCode && !causeMessage) { + return undefined; + } + + if (causeCode && causeMessage && !causeMessage.includes(causeCode)) { + return `${causeCode}: ${causeMessage}`; + } + + return causeMessage ?? causeCode; +} + export async function getQwenOAuthClient( config: Config, options?: { requireCachedCredentials?: boolean }, @@ -848,7 +916,66 @@ async function authWithQwenDeviceFlow( return { success: false, reason: 'timeout', message: timeoutMessage }; } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); - const message = `Device authorization flow failed: ${errorMessage}`; + const causeCode = + error instanceof Error + ? getErrorCode((error as Error & { cause?: unknown }).cause) + : undefined; + const cause = formatErrorCause(error); + + const fullErrorMessage = [ + errorMessage, + cause ? `(cause: ${cause})` : undefined, + ] + .filter(Boolean) + .join(' '); + + const shouldShowFetchHints = + errorMessage.toLowerCase().includes('fetch failed') || + (causeCode != null && + [ + 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + 'SELF_SIGNED_CERT_IN_CHAIN', + 'DEPTH_ZERO_SELF_SIGNED_CERT', + 'CERT_HAS_EXPIRED', + 'ERR_TLS_CERT_ALTNAME_INVALID', + 'ECONNRESET', + 'ETIMEDOUT', + 'ECONNREFUSED', + 'ENOTFOUND', + 'EAI_AGAIN', + 'EHOSTUNREACH', + 'ENETUNREACH', + ].includes(causeCode)); + + const shouldShowTlsHint = + causeCode != null && + [ + 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + 'SELF_SIGNED_CERT_IN_CHAIN', + 'DEPTH_ZERO_SELF_SIGNED_CERT', + 'CERT_HAS_EXPIRED', + 'ERR_TLS_CERT_ALTNAME_INVALID', + ].includes(causeCode); + + const maybeHints = shouldShowFetchHints + ? [ + '', + 'Troubleshooting:', + `- Confirm you can reach ${QWEN_OAUTH_BASE_URL} from this machine.`, + '- If you are behind a proxy, pass `--proxy ` (or set `proxy` in settings).', + ...(shouldShowTlsHint + ? [ + '- If your network uses a corporate TLS inspection CA, set `NODE_EXTRA_CA_CERTS` to your CA bundle.', + ] + : []), + ].join('\n') + : ''; + + const message = `Device authorization flow failed: ${fullErrorMessage}${maybeHints}`; + + qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message); console.error(message); return { success: false, reason: 'error', message }; } finally { From 8ea9871d23bd2272637d4764aa9c9b29d40fe32f Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 10 Jan 2026 19:52:25 +0800 Subject: [PATCH 119/142] fix(vscode-ide-companion): fix positional argument problem due to special handling for Electron app of yargs - Remove isNodeAvailable function and related child_process import - Update command execution logic to properly handle ELECTRON_RUN_AS_NODE - Add proper quoting mechanisms for different platforms (PowerShell vs POSIX) - Bump version from 0.6.1 to 0.6.2 --- packages/vscode-ide-companion/package.json | 2 +- .../vscode-ide-companion/src/extension.ts | 46 ++++++++----------- 2 files changed, 21 insertions(+), 27 deletions(-) diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 50982df00..0d227e4b6 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.6.1", + "version": "0.6.2", "publisher": "qwenlm", "icon": "assets/icon.png", "repository": { diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index 73c86949c..b000b00c3 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -18,24 +18,11 @@ import { WebViewProvider } from './webview/WebViewProvider.js'; import { registerNewCommands } from './commands/index.js'; import { ReadonlyFileSystemProvider } from './services/readonlyFileSystemProvider.js'; import { isWindows } from './utils/platform.js'; -import { execSync } from 'child_process'; const CLI_IDE_COMPANION_IDENTIFIER = 'qwenlm.qwen-code-vscode-ide-companion'; const INFO_MESSAGE_SHOWN_KEY = 'qwenCodeInfoMessageShown'; export const DIFF_SCHEME = 'qwen-diff'; -/** - * Check if Node.js is available in the system PATH - */ -function isNodeAvailable(): boolean { - try { - execSync(isWindows ? 'where node' : 'which node', { stdio: 'ignore' }); - return true; - } catch { - return false; - } -} - /** * IDE environments where the installation greeting is hidden. In these * environments we either are pre-installed and the installation message is @@ -326,22 +313,29 @@ export async function activate(context: vscode.ExtensionContext) { 'qwen-cli', 'cli.js', ).fsPath; - const quote = (s: string) => `"${s.replace(/"/g, '\\"')}"`; + const execPath = process.execPath; + const lowerExecPath = execPath.toLowerCase(); + const needsElectronRunAsNode = + lowerExecPath.includes('code') || + lowerExecPath.includes('electron'); let qwenCmd: string; - if (isNodeAvailable()) { - // Prefer system Node.js - qwenCmd = `node ${quote(cliEntry)}`; + if (isWindows) { + // Wrap with PowerShell to avoid quoting issues in different Windows shells + const quotePwsh = (s: string) => `'${s.replace(/'/g, "''")}'`; + const psParts = [ + needsElectronRunAsNode ? '$Env:ELECTRON_RUN_AS_NODE=1;' : '', + `& ${quotePwsh(execPath)}`, + needsElectronRunAsNode ? '--ms-enable-electron-run-as-node' : '', + quotePwsh(cliEntry), + ].filter(Boolean); + qwenCmd = `powershell -NoLogo -NoProfile -Command "& { ${psParts.join(' ')} }"`; } else { - // Fallback to VS Code's bundled Node.js runtime - const execPath = process.execPath; - const baseCmd = `${quote(execPath)} ${quote(cliEntry)}`; - if (isWindows) { - // PowerShell requires & call operator for quoted paths - qwenCmd = `& ${baseCmd}`; - } else if (execPath.toLowerCase().includes('code helper')) { - // macOS Electron helper needs ELECTRON_RUN_AS_NODE=1; add -i to force TUI mode - qwenCmd = `ELECTRON_RUN_AS_NODE=1 ${baseCmd} -i`; + const quotePosix = (s: string) => `"${s.replace(/"/g, '\\"')}"`; + const baseCmd = `${quotePosix(execPath)} ${quotePosix(cliEntry)}`; + if (needsElectronRunAsNode) { + // macOS Electron helper needs ELECTRON_RUN_AS_NODE=1; + qwenCmd = `ELECTRON_RUN_AS_NODE=1 ${baseCmd}`; } else { qwenCmd = baseCmd; } From df75aa06b6cb5ba4a29b7bfb207daa4831730b26 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 10 Jan 2026 22:08:14 +0800 Subject: [PATCH 120/142] fix(vscode-ide-companion): window qwen code run command --- .../vscode-ide-companion/src/extension.ts | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index b000b00c3..97b26bf7d 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -319,17 +319,26 @@ export async function activate(context: vscode.ExtensionContext) { lowerExecPath.includes('code') || lowerExecPath.includes('electron'); - let qwenCmd: string; + let qwenCmd: string | undefined; + const terminalOptions: vscode.TerminalOptions = { + name: `Qwen Code (${selectedFolder.name})`, + cwd: selectedFolder.uri.fsPath, + location, + }; + if (isWindows) { - // Wrap with PowerShell to avoid quoting issues in different Windows shells - const quotePwsh = (s: string) => `'${s.replace(/'/g, "''")}'`; - const psParts = [ - needsElectronRunAsNode ? '$Env:ELECTRON_RUN_AS_NODE=1;' : '', - `& ${quotePwsh(execPath)}`, - needsElectronRunAsNode ? '--ms-enable-electron-run-as-node' : '', - quotePwsh(cliEntry), - ].filter(Boolean); - qwenCmd = `powershell -NoLogo -NoProfile -Command "& { ${psParts.join(' ')} }"`; + // Use cmd.exe to avoid PowerShell parsing issues with quoted paths + const quoteCmd = (s: string) => `"${s.replace(/"/g, '""')}"`; + const execQuoted = quoteCmd(execPath); + const cliQuoted = quoteCmd(cliEntry); + + terminalOptions.shellPath = + process.env.ComSpec || 'C:\\\\Windows\\\\System32\\\\cmd.exe'; + const cmdLine = needsElectronRunAsNode + ? `set "ELECTRON_RUN_AS_NODE=1" && ${execQuoted} ${cliQuoted}` + : `${execQuoted} ${cliQuoted}`; + terminalOptions.shellArgs = ['/d', '/s', '/c', cmdLine]; + qwenCmd = undefined; } else { const quotePosix = (s: string) => `"${s.replace(/"/g, '\\"')}"`; const baseCmd = `${quotePosix(execPath)} ${quotePosix(cliEntry)}`; @@ -341,13 +350,11 @@ export async function activate(context: vscode.ExtensionContext) { } } - const terminal = vscode.window.createTerminal({ - name: `Qwen Code (${selectedFolder.name})`, - cwd: selectedFolder.uri.fsPath, - location, - }); + const terminal = vscode.window.createTerminal(terminalOptions); terminal.show(); - terminal.sendText(qwenCmd); + if (qwenCmd) { + terminal.sendText(qwenCmd); + } } }, ), From 82c524f87d3dbe693cc8d2267c7e69b9ea45b183 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 10 Jan 2026 22:30:10 +0800 Subject: [PATCH 121/142] fix(vscode-ide-companion): window qwen code run command --- .../vscode-ide-companion/src/extension.ts | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index 97b26bf7d..4668969d6 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -319,7 +319,7 @@ export async function activate(context: vscode.ExtensionContext) { lowerExecPath.includes('code') || lowerExecPath.includes('electron'); - let qwenCmd: string | undefined; + let qwenCmd: string; const terminalOptions: vscode.TerminalOptions = { name: `Qwen Code (${selectedFolder.name})`, cwd: selectedFolder.uri.fsPath, @@ -327,18 +327,11 @@ export async function activate(context: vscode.ExtensionContext) { }; if (isWindows) { - // Use cmd.exe to avoid PowerShell parsing issues with quoted paths + // Use system Node via cmd.exe; avoid PowerShell parsing issues const quoteCmd = (s: string) => `"${s.replace(/"/g, '""')}"`; - const execQuoted = quoteCmd(execPath); const cliQuoted = quoteCmd(cliEntry); - - terminalOptions.shellPath = - process.env.ComSpec || 'C:\\\\Windows\\\\System32\\\\cmd.exe'; - const cmdLine = needsElectronRunAsNode - ? `set "ELECTRON_RUN_AS_NODE=1" && ${execQuoted} ${cliQuoted}` - : `${execQuoted} ${cliQuoted}`; - terminalOptions.shellArgs = ['/d', '/s', '/c', cmdLine]; - qwenCmd = undefined; + qwenCmd = `node ${cliQuoted}`; + terminalOptions.shellPath = process.env.ComSpec; } else { const quotePosix = (s: string) => `"${s.replace(/"/g, '\\"')}"`; const baseCmd = `${quotePosix(execPath)} ${quotePosix(cliEntry)}`; @@ -352,9 +345,7 @@ export async function activate(context: vscode.ExtensionContext) { const terminal = vscode.window.createTerminal(terminalOptions); terminal.show(); - if (qwenCmd) { - terminal.sendText(qwenCmd); - } + terminal.sendText(qwenCmd); } }, ), From 563d68ad5bc30dcf067756d6fea31591254b85a4 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 10 Jan 2026 23:51:51 +0800 Subject: [PATCH 122/142] feat(vscode-ide-companion/services): add IPYNB code selection support and refactor OpenFilesManager --- .../src/open-files-manager.test.ts | 3 +- .../src/open-files-manager.ts | 211 ++++++++++-------- .../services/open-files-manager/constants.ts | 8 + .../open-files-manager/notebook-handler.ts | 119 ++++++++++ .../open-files-manager/text-handler.ts | 61 +++++ .../src/services/open-files-manager/utils.ts | 101 +++++++++ 6 files changed, 413 insertions(+), 90 deletions(-) create mode 100644 packages/vscode-ide-companion/src/services/open-files-manager/constants.ts create mode 100644 packages/vscode-ide-companion/src/services/open-files-manager/notebook-handler.ts create mode 100644 packages/vscode-ide-companion/src/services/open-files-manager/text-handler.ts create mode 100644 packages/vscode-ide-companion/src/services/open-files-manager/utils.ts diff --git a/packages/vscode-ide-companion/src/open-files-manager.test.ts b/packages/vscode-ide-companion/src/open-files-manager.test.ts index 74d18ffa0..6b2c2b8f7 100644 --- a/packages/vscode-ide-companion/src/open-files-manager.test.ts +++ b/packages/vscode-ide-companion/src/open-files-manager.test.ts @@ -6,7 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as vscode from 'vscode'; -import { OpenFilesManager, MAX_FILES } from './open-files-manager.js'; +import { OpenFilesManager } from './open-files-manager.js'; +import { MAX_FILES } from './services/open-files-manager/constants.js'; vi.mock('vscode', () => ({ EventEmitter: vi.fn(() => { diff --git a/packages/vscode-ide-companion/src/open-files-manager.ts b/packages/vscode-ide-companion/src/open-files-manager.ts index 20a5c563b..ee7f595e1 100644 --- a/packages/vscode-ide-companion/src/open-files-manager.ts +++ b/packages/vscode-ide-companion/src/open-files-manager.ts @@ -9,9 +9,23 @@ import type { File, IdeContext, } from '@qwen-code/qwen-code-core/src/ide/types.js'; - -export const MAX_FILES = 10; -const MAX_SELECTED_TEXT_LENGTH = 16384; // 16 KiB limit +import { + isFileUri, + isNotebookFileUri, + isNotebookCellUri, + removeFile, + renameFile, + getNotebookUriFromCellUri, +} from './services/open-files-manager/utils.js'; +import { + addOrMoveToFront, + updateActiveContext, +} from './services/open-files-manager/text-handler.js'; +import { + addOrMoveToFrontNotebook, + updateNotebookActiveContext, + updateNotebookCellSelection, +} from './services/open-files-manager/notebook-handler.js'; /** * Keeps track of the workspace state, including open files, cursor position, and selected text. @@ -25,33 +39,102 @@ export class OpenFilesManager { constructor(private readonly context: vscode.ExtensionContext) { const editorWatcher = vscode.window.onDidChangeActiveTextEditor( (editor) => { - if (editor && this.isFileUri(editor.document.uri)) { - this.addOrMoveToFront(editor); + if (editor && isFileUri(editor.document.uri)) { + addOrMoveToFront(this.openFiles, editor); this.fireWithDebounce(); + } else if (editor && isNotebookCellUri(editor.document.uri)) { + // Handle when a notebook cell becomes active (which indicates the notebook is active) + const notebookUri = getNotebookUriFromCellUri(editor.document.uri); + if (notebookUri && isNotebookFileUri(notebookUri)) { + // Find the notebook editor for this cell + const notebookEditor = vscode.window.visibleNotebookEditors.find( + (nbEditor) => + nbEditor.notebook.uri.toString() === notebookUri.toString(), + ); + if (notebookEditor) { + addOrMoveToFrontNotebook(this.openFiles, notebookEditor); + this.fireWithDebounce(); + } + } } }, ); + // Watch for when notebook editors gain focus by monitoring focus changes + // Since VS Code doesn't have a direct onDidChangeActiveNotebookEditor event, + // we monitor when visible notebook editors change and assume the last one shown is active + let notebookFocusWatcher: vscode.Disposable | undefined; + if (vscode.window.onDidChangeVisibleNotebookEditors) { + notebookFocusWatcher = vscode.window.onDidChangeVisibleNotebookEditors( + () => { + // When visible notebook editors change, the currently focused one is likely the active one + const activeNotebookEditor = vscode.window.activeNotebookEditor; + if ( + activeNotebookEditor && + isNotebookFileUri(activeNotebookEditor.notebook.uri) + ) { + addOrMoveToFrontNotebook(this.openFiles, activeNotebookEditor); + this.fireWithDebounce(); + } + }, + ); + } + const selectionWatcher = vscode.window.onDidChangeTextEditorSelection( (event) => { - if (this.isFileUri(event.textEditor.document.uri)) { - this.updateActiveContext(event.textEditor); + if (isFileUri(event.textEditor.document.uri)) { + updateActiveContext(this.openFiles, event.textEditor); + this.fireWithDebounce(); + } else if (isNotebookCellUri(event.textEditor.document.uri)) { + // Handle text selections within notebook cells + updateNotebookCellSelection( + this.openFiles, + event.textEditor, + event.selections, + ); this.fireWithDebounce(); } }, ); + // Add notebook cell selection watcher for .ipynb files if the API is available + let notebookCellSelectionWatcher: vscode.Disposable | undefined; + if (vscode.window.onDidChangeNotebookEditorSelection) { + notebookCellSelectionWatcher = + vscode.window.onDidChangeNotebookEditorSelection((event) => { + if (isNotebookFileUri(event.notebookEditor.notebook.uri)) { + // Ensure the notebook is added to the active list if selected + addOrMoveToFrontNotebook(this.openFiles, event.notebookEditor); + updateNotebookActiveContext(this.openFiles, event.notebookEditor); + this.fireWithDebounce(); + } + }); + } + const closeWatcher = vscode.workspace.onDidCloseTextDocument((document) => { - if (this.isFileUri(document.uri)) { - this.remove(document.uri); + if (isFileUri(document.uri)) { + removeFile(this.openFiles, document.uri); this.fireWithDebounce(); } }); + // Add notebook close watcher if the API is available + let notebookCloseWatcher: vscode.Disposable | undefined; + if (vscode.workspace.onDidCloseNotebookDocument) { + notebookCloseWatcher = vscode.workspace.onDidCloseNotebookDocument( + (document) => { + if (isNotebookFileUri(document.uri)) { + removeFile(this.openFiles, document.uri); + this.fireWithDebounce(); + } + }, + ); + } + const deleteWatcher = vscode.workspace.onDidDeleteFiles((event) => { for (const uri of event.files) { - if (this.isFileUri(uri)) { - this.remove(uri); + if (isFileUri(uri) || isNotebookFileUri(uri)) { + removeFile(this.openFiles, uri); } } this.fireWithDebounce(); @@ -59,12 +142,12 @@ export class OpenFilesManager { const renameWatcher = vscode.workspace.onDidRenameFiles((event) => { for (const { oldUri, newUri } of event.files) { - if (this.isFileUri(oldUri)) { - if (this.isFileUri(newUri)) { - this.rename(oldUri, newUri); + if (isFileUri(oldUri) || isNotebookFileUri(oldUri)) { + if (isFileUri(newUri) || isNotebookFileUri(newUri)) { + renameFile(this.openFiles, oldUri, newUri); } else { // The file was renamed to a non-file URI, so we should remove it. - this.remove(oldUri); + removeFile(this.openFiles, oldUri); } } } @@ -79,87 +162,37 @@ export class OpenFilesManager { renameWatcher, ); + // Conditionally add notebook-specific watchers if they were created + if (notebookCellSelectionWatcher) { + context.subscriptions.push(notebookCellSelectionWatcher); + } + + if (notebookCloseWatcher) { + context.subscriptions.push(notebookCloseWatcher); + } + + if (notebookFocusWatcher) { + context.subscriptions.push(notebookFocusWatcher); + } + // Just add current active file on start-up. if ( vscode.window.activeTextEditor && - this.isFileUri(vscode.window.activeTextEditor.document.uri) + isFileUri(vscode.window.activeTextEditor.document.uri) ) { - this.addOrMoveToFront(vscode.window.activeTextEditor); - } - } - - private isFileUri(uri: vscode.Uri): boolean { - return uri.scheme === 'file'; - } - - private addOrMoveToFront(editor: vscode.TextEditor) { - // Deactivate previous active file - const currentActive = this.openFiles.find((f) => f.isActive); - if (currentActive) { - currentActive.isActive = false; - currentActive.cursor = undefined; - currentActive.selectedText = undefined; + addOrMoveToFront(this.openFiles, vscode.window.activeTextEditor); } - // Remove if it exists - const index = this.openFiles.findIndex( - (f) => f.path === editor.document.uri.fsPath, - ); - if (index !== -1) { - this.openFiles.splice(index, 1); + // Also add current active notebook if applicable and the API is available + if ( + vscode.window.activeNotebookEditor && + isNotebookFileUri(vscode.window.activeNotebookEditor.notebook.uri) + ) { + addOrMoveToFrontNotebook( + this.openFiles, + vscode.window.activeNotebookEditor, + ); } - - // Add to the front as active - this.openFiles.unshift({ - path: editor.document.uri.fsPath, - timestamp: Date.now(), - isActive: true, - }); - - // Enforce max length - if (this.openFiles.length > MAX_FILES) { - this.openFiles.pop(); - } - - this.updateActiveContext(editor); - } - - private remove(uri: vscode.Uri) { - const index = this.openFiles.findIndex((f) => f.path === uri.fsPath); - if (index !== -1) { - this.openFiles.splice(index, 1); - } - } - - private rename(oldUri: vscode.Uri, newUri: vscode.Uri) { - const index = this.openFiles.findIndex((f) => f.path === oldUri.fsPath); - if (index !== -1) { - this.openFiles[index].path = newUri.fsPath; - } - } - - private updateActiveContext(editor: vscode.TextEditor) { - const file = this.openFiles.find( - (f) => f.path === editor.document.uri.fsPath, - ); - if (!file || !file.isActive) { - return; - } - - file.cursor = editor.selection.active - ? { - line: editor.selection.active.line + 1, - character: editor.selection.active.character, - } - : undefined; - - let selectedText: string | undefined = - editor.document.getText(editor.selection) || undefined; - if (selectedText && selectedText.length > MAX_SELECTED_TEXT_LENGTH) { - selectedText = - selectedText.substring(0, MAX_SELECTED_TEXT_LENGTH) + '... [TRUNCATED]'; - } - file.selectedText = selectedText; } private fireWithDebounce() { diff --git a/packages/vscode-ide-companion/src/services/open-files-manager/constants.ts b/packages/vscode-ide-companion/src/services/open-files-manager/constants.ts new file mode 100644 index 000000000..793297712 --- /dev/null +++ b/packages/vscode-ide-companion/src/services/open-files-manager/constants.ts @@ -0,0 +1,8 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export const MAX_FILES = 10; +export const MAX_SELECTED_TEXT_LENGTH = 16384; // 16 KiB limit diff --git a/packages/vscode-ide-companion/src/services/open-files-manager/notebook-handler.ts b/packages/vscode-ide-companion/src/services/open-files-manager/notebook-handler.ts new file mode 100644 index 000000000..27533405d --- /dev/null +++ b/packages/vscode-ide-companion/src/services/open-files-manager/notebook-handler.ts @@ -0,0 +1,119 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import type { File } from '@qwen-code/qwen-code-core/src/ide/types.js'; +import { MAX_FILES, MAX_SELECTED_TEXT_LENGTH } from './constants.js'; +import { + deactivateCurrentActiveFile, + enforceMaxFiles, + truncateSelectedText, + getNotebookUriFromCellUri, +} from './utils.js'; + +export function addOrMoveToFrontNotebook( + openFiles: File[], + notebookEditor: vscode.NotebookEditor, +) { + // Deactivate previous active file + deactivateCurrentActiveFile(openFiles); + + // Remove if it exists + const index = openFiles.findIndex( + (f) => f.path === notebookEditor.notebook.uri.fsPath, + ); + if (index !== -1) { + openFiles.splice(index, 1); + } + + // Add to the front as active + openFiles.unshift({ + path: notebookEditor.notebook.uri.fsPath, + timestamp: Date.now(), + isActive: true, + }); + + // Enforce max length + enforceMaxFiles(openFiles, MAX_FILES); + + updateNotebookActiveContext(openFiles, notebookEditor); +} + +export function updateNotebookActiveContext( + openFiles: File[], + notebookEditor: vscode.NotebookEditor, +) { + const file = openFiles.find( + (f) => f.path === notebookEditor.notebook.uri.fsPath, + ); + if (!file || !file.isActive) { + return; + } + + // For notebook editors, selections may span multiple cells + // We'll gather selected text from all selected cells + const selections = notebookEditor.selections; + let combinedSelectedText = ''; + + for (const selection of selections) { + // Process each selected cell range + for (let i = selection.start; i < selection.end; i++) { + const cell = notebookEditor.notebook.cellAt(i); + if (cell && cell.kind === vscode.NotebookCellKind.Code) { + // For now, we'll get the full cell content if it's in a selection + // TODO: Implement per-cell cursor position and finer-grained selection if needed + combinedSelectedText += cell.document.getText() + '\n'; + } + } + } + + if (combinedSelectedText) { + combinedSelectedText = combinedSelectedText.trim(); + file.selectedText = truncateSelectedText( + combinedSelectedText, + MAX_SELECTED_TEXT_LENGTH, + ); + } else { + file.selectedText = undefined; + } +} + +export function updateNotebookCellSelection( + openFiles: File[], + cellEditor: vscode.TextEditor, + selections: readonly vscode.Selection[], +) { + // Find the parent notebook by traversing the URI + const notebookUri = getNotebookUriFromCellUri(cellEditor.document.uri); + if (!notebookUri) { + return; + } + + // Find the corresponding file entry for this notebook + const file = openFiles.find((f) => f.path === notebookUri.fsPath); + if (!file || !file.isActive) { + return; + } + + // Extract the selected text from the cell editor + let selectedText = ''; + for (const selection of selections) { + const text = cellEditor.document.getText(selection); + if (text) { + selectedText += text + '\n'; + } + } + + if (selectedText) { + selectedText = selectedText.trim(); + file.selectedText = truncateSelectedText( + selectedText, + MAX_SELECTED_TEXT_LENGTH, + ); + } else { + file.selectedText = undefined; + } +} diff --git a/packages/vscode-ide-companion/src/services/open-files-manager/text-handler.ts b/packages/vscode-ide-companion/src/services/open-files-manager/text-handler.ts new file mode 100644 index 000000000..828b36210 --- /dev/null +++ b/packages/vscode-ide-companion/src/services/open-files-manager/text-handler.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as vscode from 'vscode'; +import type { File } from '@qwen-code/qwen-code-core/src/ide/types.js'; +import { MAX_FILES, MAX_SELECTED_TEXT_LENGTH } from './constants.js'; +import { + deactivateCurrentActiveFile, + enforceMaxFiles, + truncateSelectedText, +} from './utils.js'; + +export function addOrMoveToFront(openFiles: File[], editor: vscode.TextEditor) { + // Deactivate previous active file + deactivateCurrentActiveFile(openFiles); + + // Remove if it exists + const index = openFiles.findIndex( + (f) => f.path === editor.document.uri.fsPath, + ); + if (index !== -1) { + openFiles.splice(index, 1); + } + + // Add to the front as active + openFiles.unshift({ + path: editor.document.uri.fsPath, + timestamp: Date.now(), + isActive: true, + }); + + // Enforce max length + enforceMaxFiles(openFiles, MAX_FILES); + + updateActiveContext(openFiles, editor); +} + +export function updateActiveContext( + openFiles: File[], + editor: vscode.TextEditor, +) { + const file = openFiles.find((f) => f.path === editor.document.uri.fsPath); + if (!file || !file.isActive) { + return; + } + + file.cursor = editor.selection.active + ? { + line: editor.selection.active.line + 1, + character: editor.selection.active.character, + } + : undefined; + + let selectedText: string | undefined = + editor.document.getText(editor.selection) || undefined; + selectedText = truncateSelectedText(selectedText, MAX_SELECTED_TEXT_LENGTH); + file.selectedText = selectedText; +} diff --git a/packages/vscode-ide-companion/src/services/open-files-manager/utils.ts b/packages/vscode-ide-companion/src/services/open-files-manager/utils.ts new file mode 100644 index 000000000..380b3caa6 --- /dev/null +++ b/packages/vscode-ide-companion/src/services/open-files-manager/utils.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import type { File } from '@qwen-code/qwen-code-core/src/ide/types.js'; + +export function isFileUri(uri: vscode.Uri): boolean { + return uri.scheme === 'file'; +} + +export function isNotebookFileUri(uri: vscode.Uri): boolean { + return uri.scheme === 'file' && uri.path.toLowerCase().endsWith('.ipynb'); +} + +export function isNotebookCellUri(uri: vscode.Uri): boolean { + // Notebook cell URIs have the scheme 'vscode-notebook-cell' + return uri.scheme === 'vscode-notebook-cell'; +} + +export function removeFile(openFiles: File[], uri: vscode.Uri): void { + const index = openFiles.findIndex((f) => f.path === uri.fsPath); + if (index !== -1) { + openFiles.splice(index, 1); + } +} + +export function renameFile( + openFiles: File[], + oldUri: vscode.Uri, + newUri: vscode.Uri, +): void { + const index = openFiles.findIndex((f) => f.path === oldUri.fsPath); + if (index !== -1) { + openFiles[index].path = newUri.fsPath; + } +} + +export function deactivateCurrentActiveFile(openFiles: File[]): void { + const currentActive = openFiles.find((f) => f.isActive); + if (currentActive) { + currentActive.isActive = false; + currentActive.cursor = undefined; + currentActive.selectedText = undefined; + } +} + +export function enforceMaxFiles(openFiles: File[], maxFiles: number): void { + if (openFiles.length > maxFiles) { + openFiles.pop(); + } +} + +export function truncateSelectedText( + selectedText: string | undefined, + maxLength: number, +): string | undefined { + if (!selectedText) { + return undefined; + } + if (selectedText.length > maxLength) { + return selectedText.substring(0, maxLength) + '... [TRUNCATED]'; + } + return selectedText; +} + +export function getNotebookUriFromCellUri( + cellUri: vscode.Uri, +): vscode.Uri | null { + // Most efficient approach: Check if the currently active notebook editor contains this cell + const activeNotebookEditor = vscode.window.activeNotebookEditor; + if ( + activeNotebookEditor && + isNotebookFileUri(activeNotebookEditor.notebook.uri) + ) { + for (let i = 0; i < activeNotebookEditor.notebook.cellCount; i++) { + const cell = activeNotebookEditor.notebook.cellAt(i); + if (cell.document.uri.toString() === cellUri.toString()) { + return activeNotebookEditor.notebook.uri; + } + } + } + + // If not in the active editor, check all visible notebook editors + for (const editor of vscode.window.visibleNotebookEditors) { + if ( + editor !== activeNotebookEditor && + isNotebookFileUri(editor.notebook.uri) + ) { + for (let i = 0; i < editor.notebook.cellCount; i++) { + const cell = editor.notebook.cellAt(i); + if (cell.document.uri.toString() === cellUri.toString()) { + return editor.notebook.uri; + } + } + } + } + return null; +} From b0e561ca730b7391ae38dd628e74d4ff597b69b9 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 11 Jan 2026 00:25:31 +0800 Subject: [PATCH 123/142] chore(vscode-ide-companion/open-files-manager): update copyright headers to Qwen Team --- .../src/services/open-files-manager/constants.ts | 2 +- .../src/services/open-files-manager/notebook-handler.ts | 2 +- .../src/services/open-files-manager/text-handler.ts | 2 +- .../src/services/open-files-manager/utils.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/vscode-ide-companion/src/services/open-files-manager/constants.ts b/packages/vscode-ide-companion/src/services/open-files-manager/constants.ts index 793297712..e59186369 100644 --- a/packages/vscode-ide-companion/src/services/open-files-manager/constants.ts +++ b/packages/vscode-ide-companion/src/services/open-files-manager/constants.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ diff --git a/packages/vscode-ide-companion/src/services/open-files-manager/notebook-handler.ts b/packages/vscode-ide-companion/src/services/open-files-manager/notebook-handler.ts index 27533405d..40e663744 100644 --- a/packages/vscode-ide-companion/src/services/open-files-manager/notebook-handler.ts +++ b/packages/vscode-ide-companion/src/services/open-files-manager/notebook-handler.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ diff --git a/packages/vscode-ide-companion/src/services/open-files-manager/text-handler.ts b/packages/vscode-ide-companion/src/services/open-files-manager/text-handler.ts index 828b36210..88853f31b 100644 --- a/packages/vscode-ide-companion/src/services/open-files-manager/text-handler.ts +++ b/packages/vscode-ide-companion/src/services/open-files-manager/text-handler.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ diff --git a/packages/vscode-ide-companion/src/services/open-files-manager/utils.ts b/packages/vscode-ide-companion/src/services/open-files-manager/utils.ts index 380b3caa6..dd4b46126 100644 --- a/packages/vscode-ide-companion/src/services/open-files-manager/utils.ts +++ b/packages/vscode-ide-companion/src/services/open-files-manager/utils.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ From 7d40e1470c8c0b367b341d726c56c23e058df508 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Sun, 11 Jan 2026 21:24:45 +0800 Subject: [PATCH 124/142] chore: add CODEOWNERS for SDK TypeScript package and remove legacy CLI path alias --- .github/CODEOWNERS | 3 +++ packages/sdk-typescript/src/utils/cliPath.ts | 3 --- packages/sdk-typescript/test/unit/cliPath.test.ts | 7 ------- 3 files changed, 3 insertions(+), 10 deletions(-) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..70992d5c6 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +* @tanzhenxin @DennisYu07 @gwinthis @LaZzyMan @pomelo-nwu @Mingholy +# SDK TypeScript package changes require review from Mingholy +packages/sdk-typescript/** @Mingholy diff --git a/packages/sdk-typescript/src/utils/cliPath.ts b/packages/sdk-typescript/src/utils/cliPath.ts index a13ac926a..96a2c8194 100644 --- a/packages/sdk-typescript/src/utils/cliPath.ts +++ b/packages/sdk-typescript/src/utils/cliPath.ts @@ -370,6 +370,3 @@ export function prepareSpawnInfo(executableSpec?: string): SpawnInfo { originalInput: executableSpec, }; } - -// Legacy export for backward compatibility -export { findBundledCliPath as findNativeCliPath }; diff --git a/packages/sdk-typescript/test/unit/cliPath.test.ts b/packages/sdk-typescript/test/unit/cliPath.test.ts index 942ec07ea..9cbabc16c 100644 --- a/packages/sdk-typescript/test/unit/cliPath.test.ts +++ b/packages/sdk-typescript/test/unit/cliPath.test.ts @@ -16,7 +16,6 @@ import { execSync } from 'node:child_process'; import { prepareSpawnInfo, findBundledCliPath, - findNativeCliPath, } from '../../src/utils/cliPath.js'; // Mock fs module @@ -63,12 +62,6 @@ describe('CLI Path Utilities', () => { }); }); - describe('findNativeCliPath (legacy alias)', () => { - it('should be an alias for findBundledCliPath', () => { - expect(findNativeCliPath).toBe(findBundledCliPath); - }); - }); - describe('prepareSpawnInfo', () => { describe('auto-detection (no spec provided)', () => { it('should auto-detect bundled CLI when no spec provided', () => { From 8c56b612fb3564df79fa116bbd6bd08f0cb28a59 Mon Sep 17 00:00:00 2001 From: liqoingyu Date: Wed, 7 Jan 2026 19:49:40 +0800 Subject: [PATCH 125/142] fix(cli): warn on deprecated/unknown settings keys --- packages/cli/src/config/settings.test.ts | 79 ++++++++++++++++++++ packages/cli/src/config/settings.ts | 91 ++++++++++++++++++++++++ packages/cli/src/gemini.tsx | 21 ++++-- 3 files changed, 184 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 9db17b7d3..a8ebcffe5 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -55,6 +55,7 @@ import { disableExtension } from './extension.js'; // These imports will get the versions from the vi.mock('./settings.js', ...) factory. import { + getSettingsWarnings, loadSettings, USER_SETTINGS_PATH, // This IS the mocked path. getSystemSettingsPath, @@ -418,6 +419,84 @@ describe('Settings Loading and Merging', () => { }); }); + it('should warn about deprecated legacy keys in a v2 settings file', () => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === USER_SETTINGS_PATH, + ); + const userSettingsContent = { + [SETTINGS_VERSION_KEY]: SETTINGS_VERSION, + usageStatisticsEnabled: false, + }; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(getSettingsWarnings(settings)).toEqual( + expect.arrayContaining([ + expect.stringContaining( + "Deprecated setting 'usageStatisticsEnabled'", + ), + ]), + ); + expect(getSettingsWarnings(settings)).toEqual( + expect.arrayContaining([ + expect.stringContaining("'privacy.usageStatisticsEnabled'"), + ]), + ); + }); + + it('should warn about unknown top-level keys in a v2 settings file', () => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === USER_SETTINGS_PATH, + ); + const userSettingsContent = { + [SETTINGS_VERSION_KEY]: SETTINGS_VERSION, + someUnknownKey: 'value', + }; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(getSettingsWarnings(settings)).toEqual( + expect.arrayContaining([ + expect.stringContaining("Unknown setting 'someUnknownKey'"), + ]), + ); + }); + + it('should not warn for valid v2 container keys', () => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === USER_SETTINGS_PATH, + ); + const userSettingsContent = { + [SETTINGS_VERSION_KEY]: SETTINGS_VERSION, + model: { name: 'qwen-coder' }, + }; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(getSettingsWarnings(settings)).toEqual([]); + }); + it('should rewrite allowedTools to tools.allowed during migration', () => { (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => p === USER_SETTINGS_PATH, diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index ae29074b2..5c8fec3cb 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -344,6 +344,97 @@ const KNOWN_V2_CONTAINERS = new Set( Object.values(MIGRATION_MAP).map((path) => path.split('.')[0]), ); +function getSettingsFileKeyWarnings( + settings: Record, + settingsFilePath: string, +): string[] { + const version = settings[SETTINGS_VERSION_KEY]; + if (typeof version !== 'number' || version < SETTINGS_VERSION) { + return []; + } + + const warnings: string[] = []; + const deprecatedKeys = new Set(); + + // Deprecated keys (V1 top-level keys that moved to a nested V2 path). + for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) { + if (oldKey === newPath) { + continue; + } + if (!(oldKey in settings)) { + continue; + } + + const oldValue = settings[oldKey]; + + // If this key is a V2 container (like 'model') and it's already an object, + // it's likely already in V2 format. Don't warn. + if ( + KNOWN_V2_CONTAINERS.has(oldKey) && + typeof oldValue === 'object' && + oldValue !== null && + !Array.isArray(oldValue) + ) { + continue; + } + + deprecatedKeys.add(oldKey); + warnings.push( + `⚠️ Warning: Deprecated setting '${oldKey}' in ${settingsFilePath}. Please use '${newPath}' instead.`, + ); + } + + // Unknown top-level keys. + const schemaKeys = new Set(Object.keys(getSettingsSchema())); + for (const key of Object.keys(settings)) { + if (key === SETTINGS_VERSION_KEY) { + continue; + } + if (deprecatedKeys.has(key)) { + continue; + } + if (schemaKeys.has(key)) { + continue; + } + + warnings.push( + `⚠️ Warning: Unknown setting '${key}' in ${settingsFilePath}. This setting will be ignored.`, + ); + } + + return warnings; +} + +/** + * Collects warnings for deprecated and unknown settings keys. + * + * For `$version: 2` settings files, we do not apply implicit migrations. + * Instead, we surface actionable, de-duplicated warnings in the terminal UI. + */ +export function getSettingsWarnings(loadedSettings: LoadedSettings): string[] { + const warningSet = new Set(); + + for (const scope of [SettingScope.User, SettingScope.Workspace]) { + const settingsFile = loadedSettings.forScope(scope); + if (settingsFile.rawJson === undefined) { + continue; // File not present / not loaded. + } + const settingsObject = settingsFile.originalSettings as unknown as Record< + string, + unknown + >; + + for (const warning of getSettingsFileKeyWarnings( + settingsObject, + settingsFile.path, + )) { + warningSet.add(warning); + } + } + + return [...warningSet]; +} + export function migrateSettingsToV1( v2Settings: Record, ): Record { diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index b05f12453..4591cf1d1 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -17,7 +17,11 @@ import * as cliConfig from './config/config.js'; import { loadCliConfig, parseArguments } from './config/config.js'; import { ExtensionStorage, loadExtensions } from './config/extension.js'; import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js'; -import { loadSettings, migrateDeprecatedSettings } from './config/settings.js'; +import { + getSettingsWarnings, + loadSettings, + migrateDeprecatedSettings, +} from './config/settings.js'; import { initializeApp, type InitializationResult, @@ -400,12 +404,15 @@ export async function main() { let input = config.getQuestion(); const startupWarnings = [ - ...(await getStartupWarnings()), - ...(await getUserStartupWarnings({ - workspaceRoot: process.cwd(), - useRipgrep: settings.merged.tools?.useRipgrep ?? true, - useBuiltinRipgrep: settings.merged.tools?.useBuiltinRipgrep ?? true, - })), + ...new Set([ + ...(await getStartupWarnings()), + ...(await getUserStartupWarnings({ + workspaceRoot: process.cwd(), + useRipgrep: settings.merged.tools?.useRipgrep ?? true, + useBuiltinRipgrep: settings.merged.tools?.useBuiltinRipgrep ?? true, + })), + ...getSettingsWarnings(settings), + ]), ]; // Render UI, passing necessary config values. Check that there is no command line question. From 7173cba84440888e1fd8821db877a96f7fee5b7a Mon Sep 17 00:00:00 2001 From: xuewenjie Date: Mon, 12 Jan 2026 11:04:05 +0800 Subject: [PATCH 126/142] fix(shell): prevent console window flash on Windows for foreground tasks --- packages/core/src/services/shellExecutionService.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index c870b5f4e..2346de1b2 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -229,7 +229,8 @@ export class ShellExecutionService { stdio: ['ignore', 'pipe', 'pipe'], windowsVerbatimArguments: true, shell: isWindows ? true : 'bash', - detached: true, + detached: !isWindows, + windowsHide: isWindows, env: { ...process.env, QWEN_CODE: '1', From 9a8ce605c56a6a6d66996ef8bb4f34f88ceb817f Mon Sep 17 00:00:00 2001 From: xuewenjie Date: Mon, 12 Jan 2026 11:22:54 +0800 Subject: [PATCH 127/142] test: update shellExecutionService test for Windows spawn config changes --- packages/core/src/services/shellExecutionService.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/src/services/shellExecutionService.test.ts b/packages/core/src/services/shellExecutionService.test.ts index e63fba28d..b598757a7 100644 --- a/packages/core/src/services/shellExecutionService.test.ts +++ b/packages/core/src/services/shellExecutionService.test.ts @@ -818,7 +818,7 @@ describe('ShellExecutionService child_process fallback', () => { }); describe('Platform-Specific Behavior', () => { - it('should use cmd.exe on Windows', async () => { + it('should use cmd.exe and hide window on Windows', async () => { mockPlatform.mockReturnValue('win32'); await simulateExecution('dir "foo bar"', (cp) => cp.emit('exit', 0, null), @@ -829,7 +829,8 @@ describe('ShellExecutionService child_process fallback', () => { [], expect.objectContaining({ shell: true, - detached: true, + detached: false, + windowsHide: true, }), ); }); From 4c186e7c92f7789aec1d72c88233a7f56fcf11c4 Mon Sep 17 00:00:00 2001 From: liqoingyu Date: Mon, 12 Jan 2026 12:00:01 +0800 Subject: [PATCH 128/142] refactor(core): extract fetch error troubleshooting --- packages/core/src/qwen/qwenOAuth2.ts | 132 +------------------------ packages/core/src/utils/fetch.test.ts | 52 ++++++++++ packages/core/src/utils/fetch.ts | 135 ++++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 127 deletions(-) create mode 100644 packages/core/src/utils/fetch.test.ts diff --git a/packages/core/src/qwen/qwenOAuth2.ts b/packages/core/src/qwen/qwenOAuth2.ts index de7735f6f..74c334006 100644 --- a/packages/core/src/qwen/qwenOAuth2.ts +++ b/packages/core/src/qwen/qwenOAuth2.ts @@ -13,6 +13,7 @@ import open from 'open'; import { EventEmitter } from 'events'; import type { Config } from '../config/config.js'; import { randomUUID } from 'node:crypto'; +import { formatFetchErrorForUser } from '../utils/fetch.js'; import { SharedTokenManager, TokenManagerError, @@ -478,74 +479,6 @@ export type AuthResult = */ export const qwenOAuth2Events = new EventEmitter(); -function getErrorCode(error: unknown): string | undefined { - if (!error || typeof error !== 'object') { - return undefined; - } - - if ( - 'code' in error && - typeof (error as Record)['code'] === 'string' - ) { - return (error as Record)['code']; - } - - return undefined; -} - -function formatUnknownErrorMessage(error: unknown): string | undefined { - if (typeof error === 'string') { - return error; - } - - if ( - typeof error === 'number' || - typeof error === 'boolean' || - typeof error === 'bigint' - ) { - return String(error); - } - - if (error instanceof Error) { - return error.message; - } - - if (!error || typeof error !== 'object') { - return undefined; - } - - const message = (error as Record)['message']; - if (typeof message === 'string') { - return message; - } - - return undefined; -} - -function formatErrorCause(error: unknown): string | undefined { - if (!(error instanceof Error)) { - return undefined; - } - - const cause = (error as Error & { cause?: unknown }).cause; - if (!cause) { - return undefined; - } - - const causeCode = getErrorCode(cause); - const causeMessage = formatUnknownErrorMessage(cause); - - if (!causeCode && !causeMessage) { - return undefined; - } - - if (causeCode && causeMessage && !causeMessage.includes(causeCode)) { - return `${causeCode}: ${causeMessage}`; - } - - return causeMessage ?? causeCode; -} - export async function getQwenOAuthClient( config: Config, options?: { requireCachedCredentials?: boolean }, @@ -915,65 +848,10 @@ async function authWithQwenDeviceFlow( console.error('\n' + timeoutMessage); return { success: false, reason: 'timeout', message: timeoutMessage }; } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - const causeCode = - error instanceof Error - ? getErrorCode((error as Error & { cause?: unknown }).cause) - : undefined; - const cause = formatErrorCause(error); - - const fullErrorMessage = [ - errorMessage, - cause ? `(cause: ${cause})` : undefined, - ] - .filter(Boolean) - .join(' '); - - const shouldShowFetchHints = - errorMessage.toLowerCase().includes('fetch failed') || - (causeCode != null && - [ - 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', - 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', - 'SELF_SIGNED_CERT_IN_CHAIN', - 'DEPTH_ZERO_SELF_SIGNED_CERT', - 'CERT_HAS_EXPIRED', - 'ERR_TLS_CERT_ALTNAME_INVALID', - 'ECONNRESET', - 'ETIMEDOUT', - 'ECONNREFUSED', - 'ENOTFOUND', - 'EAI_AGAIN', - 'EHOSTUNREACH', - 'ENETUNREACH', - ].includes(causeCode)); - - const shouldShowTlsHint = - causeCode != null && - [ - 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', - 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', - 'SELF_SIGNED_CERT_IN_CHAIN', - 'DEPTH_ZERO_SELF_SIGNED_CERT', - 'CERT_HAS_EXPIRED', - 'ERR_TLS_CERT_ALTNAME_INVALID', - ].includes(causeCode); - - const maybeHints = shouldShowFetchHints - ? [ - '', - 'Troubleshooting:', - `- Confirm you can reach ${QWEN_OAUTH_BASE_URL} from this machine.`, - '- If you are behind a proxy, pass `--proxy ` (or set `proxy` in settings).', - ...(shouldShowTlsHint - ? [ - '- If your network uses a corporate TLS inspection CA, set `NODE_EXTRA_CA_CERTS` to your CA bundle.', - ] - : []), - ].join('\n') - : ''; - - const message = `Device authorization flow failed: ${fullErrorMessage}${maybeHints}`; + const fullErrorMessage = formatFetchErrorForUser(error, { + url: QWEN_OAUTH_BASE_URL, + }); + const message = `Device authorization flow failed: ${fullErrorMessage}`; qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message); console.error(message); diff --git a/packages/core/src/utils/fetch.test.ts b/packages/core/src/utils/fetch.test.ts new file mode 100644 index 000000000..7e7c4e797 --- /dev/null +++ b/packages/core/src/utils/fetch.test.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { FetchError, formatFetchErrorForUser } from './fetch.js'; + +describe('formatFetchErrorForUser', () => { + it('includes troubleshooting hints for TLS errors', () => { + const tlsCause = new Error('unable to verify the first certificate'); + (tlsCause as Error & { code?: string }).code = + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'; + + const fetchError = new TypeError('fetch failed') as TypeError & { + cause?: unknown; + }; + fetchError.cause = tlsCause; + + const message = formatFetchErrorForUser(fetchError, { + url: 'https://chat.qwen.ai', + }); + + expect(message).toContain('fetch failed'); + expect(message).toContain('UNABLE_TO_VERIFY_LEAF_SIGNATURE'); + expect(message).toContain('Troubleshooting:'); + expect(message).toContain('Confirm you can reach https://chat.qwen.ai'); + expect(message).toContain('--proxy'); + expect(message).toContain('NODE_EXTRA_CA_CERTS'); + }); + + it('includes troubleshooting hints for network codes', () => { + const fetchError = new FetchError( + 'Request timed out after 100ms', + 'ETIMEDOUT', + ); + const message = formatFetchErrorForUser(fetchError, { + url: 'https://example.com', + }); + + expect(message).toContain('Request timed out after 100ms'); + expect(message).toContain('Troubleshooting:'); + expect(message).toContain('Confirm you can reach https://example.com'); + expect(message).toContain('--proxy'); + expect(message).not.toContain('NODE_EXTRA_CA_CERTS'); + }); + + it('does not include troubleshooting for non-fetch errors', () => { + expect(formatFetchErrorForUser(new Error('boom'))).toBe('boom'); + }); +}); diff --git a/packages/core/src/utils/fetch.ts b/packages/core/src/utils/fetch.ts index dfbcca18f..2766672f7 100644 --- a/packages/core/src/utils/fetch.ts +++ b/packages/core/src/utils/fetch.ts @@ -17,6 +17,26 @@ const PRIVATE_IP_RANGES = [ /^fe80:/, ]; +const TLS_ERROR_CODES = new Set([ + 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + 'SELF_SIGNED_CERT_IN_CHAIN', + 'DEPTH_ZERO_SELF_SIGNED_CERT', + 'CERT_HAS_EXPIRED', + 'ERR_TLS_CERT_ALTNAME_INVALID', +]); + +const FETCH_TROUBLESHOOTING_ERROR_CODES = new Set([ + ...TLS_ERROR_CODES, + 'ECONNRESET', + 'ETIMEDOUT', + 'ECONNREFUSED', + 'ENOTFOUND', + 'EAI_AGAIN', + 'EHOSTUNREACH', + 'ENETUNREACH', +]); + export class FetchError extends Error { constructor( message: string, @@ -55,3 +75,118 @@ export async function fetchWithTimeout( clearTimeout(timeoutId); } } + +function getErrorCode(error: unknown): string | undefined { + if (!error || typeof error !== 'object') { + return undefined; + } + + if ( + 'code' in error && + typeof (error as Record)['code'] === 'string' + ) { + return (error as Record)['code']; + } + + return undefined; +} + +function formatUnknownErrorMessage(error: unknown): string | undefined { + if (typeof error === 'string') { + return error; + } + + if ( + typeof error === 'number' || + typeof error === 'boolean' || + typeof error === 'bigint' + ) { + return String(error); + } + + if (error instanceof Error) { + return error.message; + } + + if (!error || typeof error !== 'object') { + return undefined; + } + + const message = (error as Record)['message']; + if (typeof message === 'string') { + return message; + } + + return undefined; +} + +function formatErrorCause(error: unknown): string | undefined { + if (!(error instanceof Error)) { + return undefined; + } + + const cause = (error as Error & { cause?: unknown }).cause; + if (!cause) { + return undefined; + } + + const causeCode = getErrorCode(cause); + const causeMessage = formatUnknownErrorMessage(cause); + + if (!causeCode && !causeMessage) { + return undefined; + } + + if (causeCode && causeMessage && !causeMessage.includes(causeCode)) { + return `${causeCode}: ${causeMessage}`; + } + + return causeMessage ?? causeCode; +} + +export function formatFetchErrorForUser( + error: unknown, + options: { url?: string } = {}, +): string { + const errorMessage = getErrorMessage(error); + + const code = + error instanceof Error + ? (getErrorCode((error as Error & { cause?: unknown }).cause) ?? + getErrorCode(error)) + : getErrorCode(error); + + const cause = formatErrorCause(error); + const fullErrorMessage = [ + errorMessage, + cause ? `(cause: ${cause})` : undefined, + ] + .filter(Boolean) + .join(' '); + + const shouldShowFetchHints = + errorMessage.toLowerCase().includes('fetch failed') || + (code != null && FETCH_TROUBLESHOOTING_ERROR_CODES.has(code)); + + const shouldShowTlsHint = code != null && TLS_ERROR_CODES.has(code); + + if (!shouldShowFetchHints) { + return fullErrorMessage; + } + + const hintLines = [ + '', + 'Troubleshooting:', + ...(options.url + ? [`- Confirm you can reach ${options.url} from this machine.`] + : []), + '- If you are behind a proxy, pass `--proxy ` (or set `proxy` in settings).', + ...(shouldShowTlsHint + ? [ + '- If your network uses a corporate TLS inspection CA, set `NODE_EXTRA_CA_CERTS` to your CA bundle.', + ] + : []), + ]; + + return `${fullErrorMessage}${hintLines.join('\n')}`; +} From 9670456a56664106849783e7d7e081651d41776b Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Mon, 12 Jan 2026 13:42:24 +0800 Subject: [PATCH 129/142] fix: simplify JavaScript runtime detection to fix powershell spawning process issue --- packages/sdk-typescript/src/utils/cliPath.ts | 25 +++++++------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/packages/sdk-typescript/src/utils/cliPath.ts b/packages/sdk-typescript/src/utils/cliPath.ts index 96a2c8194..e4a7924bc 100644 --- a/packages/sdk-typescript/src/utils/cliPath.ts +++ b/packages/sdk-typescript/src/utils/cliPath.ts @@ -285,24 +285,17 @@ function isTsxAvailable(): boolean { } /** - * Get JavaScript runtime command (bun if running under bun, otherwise node) + * Get JavaScript runtime type (bun if running under bun, otherwise node) */ -function getJsRuntimeCommand(): { command: string; type: ExecutableType } { +function getJsRuntimeType(): 'bun' | 'node' { if ( typeof process !== 'undefined' && 'versions' in process && 'bun' in process.versions ) { - return { - command: 'bun', - type: 'bun', - }; + return 'bun'; } - - return { - command: process.execPath, - type: 'node', - }; + return 'node'; } /** @@ -315,11 +308,10 @@ export function prepareSpawnInfo(executableSpec?: string): SpawnInfo { if (executableSpec === undefined) { const bundledCliPath = findBundledCliPath(); - const runtime = getJsRuntimeCommand(); return { - command: runtime.command, + command: process.execPath, args: [bundledCliPath], - type: runtime.type, + type: getJsRuntimeType(), originalInput: '', }; } @@ -343,11 +335,10 @@ export function prepareSpawnInfo(executableSpec?: string): SpawnInfo { validateFilePath(resolvedPath); if (isJavaScriptFile(resolvedPath)) { - const runtime = getJsRuntimeCommand(); return { - command: runtime.command, + command: process.execPath, args: [resolvedPath], - type: runtime.type, + type: getJsRuntimeType(), originalInput: executableSpec, }; } From 5f8e1ebc94b109de1dbd3c775157ae6be57109bc Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Mon, 12 Jan 2026 14:29:40 +0800 Subject: [PATCH 130/142] chore(settings): update legacy settings alias implementation and tests Co-authored-by: Qwen-Coder --- packages/cli/src/config/settings.test.ts | 8 +++++--- packages/cli/src/config/settings.ts | 14 +++++++------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index a8ebcffe5..6549f6f71 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -419,7 +419,7 @@ describe('Settings Loading and Merging', () => { }); }); - it('should warn about deprecated legacy keys in a v2 settings file', () => { + it('should warn about ignored legacy keys in a v2 settings file', () => { (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => p === USER_SETTINGS_PATH, ); @@ -440,7 +440,7 @@ describe('Settings Loading and Merging', () => { expect(getSettingsWarnings(settings)).toEqual( expect.arrayContaining([ expect.stringContaining( - "Deprecated setting 'usageStatisticsEnabled'", + "Legacy setting 'usageStatisticsEnabled' will be ignored", ), ]), ); @@ -471,7 +471,9 @@ describe('Settings Loading and Merging', () => { expect(getSettingsWarnings(settings)).toEqual( expect.arrayContaining([ - expect.stringContaining("Unknown setting 'someUnknownKey'"), + expect.stringContaining( + "Unknown setting 'someUnknownKey' will be ignored", + ), ]), ); }); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 5c8fec3cb..c9b845cd8 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -354,9 +354,9 @@ function getSettingsFileKeyWarnings( } const warnings: string[] = []; - const deprecatedKeys = new Set(); + const ignoredLegacyKeys = new Set(); - // Deprecated keys (V1 top-level keys that moved to a nested V2 path). + // Ignored legacy keys (V1 top-level keys that moved to a nested V2 path). for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) { if (oldKey === newPath) { continue; @@ -378,9 +378,9 @@ function getSettingsFileKeyWarnings( continue; } - deprecatedKeys.add(oldKey); + ignoredLegacyKeys.add(oldKey); warnings.push( - `⚠️ Warning: Deprecated setting '${oldKey}' in ${settingsFilePath}. Please use '${newPath}' instead.`, + `⚠️ Legacy setting '${oldKey}' will be ignored in ${settingsFilePath}. Please use '${newPath}' instead.`, ); } @@ -390,7 +390,7 @@ function getSettingsFileKeyWarnings( if (key === SETTINGS_VERSION_KEY) { continue; } - if (deprecatedKeys.has(key)) { + if (ignoredLegacyKeys.has(key)) { continue; } if (schemaKeys.has(key)) { @@ -398,7 +398,7 @@ function getSettingsFileKeyWarnings( } warnings.push( - `⚠️ Warning: Unknown setting '${key}' in ${settingsFilePath}. This setting will be ignored.`, + `⚠️ Unknown setting '${key}' will be ignored in ${settingsFilePath}.`, ); } @@ -406,7 +406,7 @@ function getSettingsFileKeyWarnings( } /** - * Collects warnings for deprecated and unknown settings keys. + * Collects warnings for ignored legacy and unknown settings keys. * * For `$version: 2` settings files, we do not apply implicit migrations. * Instead, we surface actionable, de-duplicated warnings in the terminal UI. From 6917031128300b6da85214efe17b0fc699c4a239 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Mon, 12 Jan 2026 16:23:11 +0800 Subject: [PATCH 131/142] feat(shell): add optional timeout for foreground commands Adds a timeout parameter (validated and schema-exposed) and improves abort messaging by distinguishing user cancellation from timeout. Resolves #1454 --- .../tools/__snapshots__/shell.test.ts.snap | 143 ++++++---- packages/core/src/tools/shell.test.ts | 254 +++++++++++++++++- packages/core/src/tools/shell.ts | 155 ++++++++--- 3 files changed, 452 insertions(+), 100 deletions(-) diff --git a/packages/core/src/tools/__snapshots__/shell.test.ts.snap b/packages/core/src/tools/__snapshots__/shell.test.ts.snap index 2d6214f60..9dfd54cf0 100644 --- a/packages/core/src/tools/__snapshots__/shell.test.ts.snap +++ b/packages/core/src/tools/__snapshots__/shell.test.ts.snap @@ -1,67 +1,98 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`ShellTool > getDescription > should return the non-windows description when not on windows 1`] = ` -"This tool executes a given shell command as \`bash -c \`. Command can start background processes using \`&\`. Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`. +"Executes a given shell command (as \`bash -c \`) in a persistent shell session with optional timeout, ensuring proper handling and security measures. - **Background vs Foreground Execution:** - You should decide whether commands should run in background or foreground based on their nature: - - **Use background execution (is_background: true) for:** - - Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\` - - Build watchers: \`npm run watch\`, \`webpack --watch\` - - Database servers: \`mongod\`, \`mysql\`, \`redis-server\` - - Web servers: \`python -m http.server\`, \`php -S localhost:8000\` - - Any command expected to run indefinitely until manually stopped - - **Use foreground execution (is_background: false) for:** - - One-time commands: \`ls\`, \`cat\`, \`grep\` - - Build commands: \`npm run build\`, \`make\` - - Installation commands: \`npm install\`, \`pip install\` - - Git operations: \`git commit\`, \`git push\` - - Test runs: \`npm test\`, \`pytest\` - - The following information is returned: +IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. - Command: Executed command. - Directory: Directory where command was executed, or \`(root)\`. - Stdout: Output on stdout stream. Can be \`(empty)\` or partial on error and for any unwaited background processes. - Stderr: Output on stderr stream. Can be \`(empty)\` or partial on error and for any unwaited background processes. - Error: Error or \`(none)\` if no error was reported for the subprocess. - Exit Code: Exit code or \`(none)\` if terminated by signal. - Signal: Signal number or \`(none)\` if no signal was received. - Background PIDs: List of background processes started or \`(none)\`. - Process Group PGID: Process group started or \`(none)\`" +**Usage notes**: +- The command argument is required. +- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes). +- It is very helpful if you write a clear, concise description of what this command does in 5-10 words. + +- Avoid using run_shell_command with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use glob (NOT find or ls) + - Content search: Use grep_search (NOT grep or rg) + - Read files: Use read_file (NOT cat/head/tail) + - Edit files: Use edit (NOT sed/awk) + - Write files: Use write_file (NOT echo >/cat < + pytest /foo/bar/tests + + + cd /foo/bar && pytest tests + + +**Background vs Foreground Execution:** +- You should decide whether commands should run in background or foreground based on their nature: +- Use background execution (is_background: true) for: + - Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\` + - Build watchers: \`npm run watch\`, \`webpack --watch\` + - Database servers: \`mongod\`, \`mysql\`, \`redis-server\` + - Web servers: \`python -m http.server\`, \`php -S localhost:8000\` + - Any command expected to run indefinitely until manually stopped + + - Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`. +- Use foreground execution (is_background: false) for: + - One-time commands: \`ls\`, \`cat\`, \`grep\` + - Build commands: \`npm run build\`, \`make\` + - Installation commands: \`npm install\`, \`pip install\` + - Git operations: \`git commit\`, \`git push\` + - Test runs: \`npm test\`, \`pytest\` +" `; exports[`ShellTool > getDescription > should return the windows description when on windows 1`] = ` -"This tool executes a given shell command as \`cmd.exe /c \`. Command can start background processes using \`start /b\`. +"Executes a given shell command (as \`cmd.exe /c \`) in a persistent shell session with optional timeout, ensuring proper handling and security measures. - **Background vs Foreground Execution:** - You should decide whether commands should run in background or foreground based on their nature: - - **Use background execution (is_background: true) for:** - - Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\` - - Build watchers: \`npm run watch\`, \`webpack --watch\` - - Database servers: \`mongod\`, \`mysql\`, \`redis-server\` - - Web servers: \`python -m http.server\`, \`php -S localhost:8000\` - - Any command expected to run indefinitely until manually stopped - - **Use foreground execution (is_background: false) for:** - - One-time commands: \`ls\`, \`cat\`, \`grep\` - - Build commands: \`npm run build\`, \`make\` - - Installation commands: \`npm install\`, \`pip install\` - - Git operations: \`git commit\`, \`git push\` - - Test runs: \`npm test\`, \`pytest\` - - The following information is returned: +IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. - Command: Executed command. - Directory: Directory where command was executed, or \`(root)\`. - Stdout: Output on stdout stream. Can be \`(empty)\` or partial on error and for any unwaited background processes. - Stderr: Output on stderr stream. Can be \`(empty)\` or partial on error and for any unwaited background processes. - Error: Error or \`(none)\` if no error was reported for the subprocess. - Exit Code: Exit code or \`(none)\` if terminated by signal. - Signal: Signal number or \`(none)\` if no signal was received. - Background PIDs: List of background processes started or \`(none)\`. - Process Group PGID: Process group started or \`(none)\`" +**Usage notes**: +- The command argument is required. +- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes). +- It is very helpful if you write a clear, concise description of what this command does in 5-10 words. + +- Avoid using run_shell_command with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use glob (NOT find or ls) + - Content search: Use grep_search (NOT grep or rg) + - Read files: Use read_file (NOT cat/head/tail) + - Edit files: Use edit (NOT sed/awk) + - Write files: Use write_file (NOT echo >/cat < + pytest /foo/bar/tests + + + cd /foo/bar && pytest tests + + +**Background vs Foreground Execution:** +- You should decide whether commands should run in background or foreground based on their nature: +- Use background execution (is_background: true) for: + - Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\` + - Build watchers: \`npm run watch\`, \`webpack --watch\` + - Database servers: \`mongod\`, \`mysql\`, \`redis-server\` + - Web servers: \`python -m http.server\`, \`php -S localhost:8000\` + - Any command expected to run indefinitely until manually stopped + +- Use foreground execution (is_background: false) for: + - One-time commands: \`ls\`, \`cat\`, \`grep\` + - Build commands: \`npm run build\`, \`make\` + - Installation commands: \`npm install\`, \`pip install\` + - Git operations: \`git commit\`, \`git push\` + - Test runs: \`npm test\`, \`pytest\` +" `; diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index 8b6788a70..ae66e7a5b 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -670,7 +670,7 @@ describe('ShellTool', () => { ), expect.any(String), expect.any(Function), - mockAbortSignal, + expect.any(AbortSignal), false, {}, ); @@ -861,7 +861,7 @@ describe('ShellTool', () => { ), expect.any(String), expect.any(Function), - mockAbortSignal, + expect.any(AbortSignal), false, {}, ); @@ -870,8 +870,8 @@ describe('ShellTool', () => { it('should add co-author to git commit with multi-line message', async () => { const command = `git commit -m "Fix bug -This is a detailed description -spanning multiple lines"`; + This is a detailed description + spanning multiple lines"`; const invocation = shellTool.build({ command, is_background: false }); const promise = invocation.execute(mockAbortSignal); @@ -894,7 +894,7 @@ spanning multiple lines"`; ), expect.any(String), expect.any(Function), - mockAbortSignal, + expect.any(AbortSignal), false, {}, ); @@ -999,4 +999,248 @@ spanning multiple lines"`; ); }); }); + + describe('timeout parameter', () => { + it('should validate timeout parameter correctly', () => { + // Valid timeout + expect(() => { + shellTool.build({ + command: 'echo test', + is_background: false, + timeout: 5000, + }); + }).not.toThrow(); + + // Valid small timeout + expect(() => { + shellTool.build({ + command: 'echo test', + is_background: false, + timeout: 500, + }); + }).not.toThrow(); + + // Zero timeout + expect(() => { + shellTool.build({ + command: 'echo test', + is_background: false, + timeout: 0, + }); + }).toThrow('Timeout must be a positive number.'); + + // Negative timeout + expect(() => { + shellTool.build({ + command: 'echo test', + is_background: false, + timeout: -1000, + }); + }).toThrow('Timeout must be a positive number.'); + + // Timeout too large + expect(() => { + shellTool.build({ + command: 'echo test', + is_background: false, + timeout: 700000, + }); + }).toThrow('Timeout cannot exceed 600000ms (10 minutes).'); + + // Non-integer timeout + expect(() => { + shellTool.build({ + command: 'echo test', + is_background: false, + timeout: 5000.5, + }); + }).toThrow('Timeout must be an integer number of milliseconds.'); + + // Non-number timeout (schema validation catches this first) + expect(() => { + shellTool.build({ + command: 'echo test', + is_background: false, + timeout: 'invalid' as unknown as number, + }); + }).toThrow('params/timeout must be number'); + }); + + it('should include timeout in description for foreground commands', () => { + const invocation = shellTool.build({ + command: 'npm test', + is_background: false, + timeout: 30000, + }); + + expect(invocation.getDescription()).toBe('npm test [timeout: 30000ms]'); + }); + + it('should not include timeout in description for background commands', () => { + const invocation = shellTool.build({ + command: 'npm start', + is_background: true, + timeout: 30000, + }); + + expect(invocation.getDescription()).toBe('npm start [background]'); + }); + + it('should create combined signal with timeout for foreground execution', async () => { + const mockAbortSignal = new AbortController().signal; + const invocation = shellTool.build({ + command: 'sleep 1', + is_background: false, + timeout: 5000, + }); + + const promise = invocation.execute(mockAbortSignal); + + resolveExecutionPromise({ + rawOutput: Buffer.from(''), + output: '', + exitCode: 0, + signal: null, + error: null, + aborted: false, + pid: 12345, + executionMethod: 'child_process', + }); + + await promise; + + // Verify that ShellExecutionService was called with a combined signal + expect(mockShellExecutionService).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.any(Function), + expect.any(AbortSignal), + false, + {}, + ); + + // The signal passed should be different from the original signal + const calledSignal = mockShellExecutionService.mock.calls[0][3]; + expect(calledSignal).not.toBe(mockAbortSignal); + }); + + it('should not create timeout signal for background execution', async () => { + const mockAbortSignal = new AbortController().signal; + const invocation = shellTool.build({ + command: 'npm start', + is_background: true, + timeout: 5000, + }); + + const promise = invocation.execute(mockAbortSignal); + + resolveExecutionPromise({ + rawOutput: Buffer.from(''), + output: 'Background command started. PID: 12345', + exitCode: 0, + signal: null, + error: null, + aborted: false, + pid: 12345, + executionMethod: 'child_process', + }); + + await promise; + + // For background execution, the original signal should be used + expect(mockShellExecutionService).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.any(Function), + mockAbortSignal, + false, + {}, + ); + }); + + it('should handle timeout vs user cancellation correctly', async () => { + const userAbortController = new AbortController(); + const invocation = shellTool.build({ + command: 'sleep 10', + is_background: false, + timeout: 5000, + }); + + // Mock AbortSignal.timeout and AbortSignal.any + const mockTimeoutSignal = { + aborted: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as AbortSignal; + + const mockCombinedSignal = { + aborted: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as AbortSignal; + + const originalAbortSignal = globalThis.AbortSignal; + vi.stubGlobal('AbortSignal', { + ...originalAbortSignal, + timeout: vi.fn().mockReturnValue(mockTimeoutSignal), + any: vi.fn().mockReturnValue(mockCombinedSignal), + }); + + const promise = invocation.execute(userAbortController.signal); + + resolveExecutionPromise({ + rawOutput: Buffer.from('partial output'), + output: 'partial output', + exitCode: null, + signal: null, + error: null, + aborted: true, + pid: 12345, + executionMethod: 'child_process', + }); + + const result = await promise; + + // Restore original AbortSignal + vi.stubGlobal('AbortSignal', originalAbortSignal); + + expect(result.llmContent).toContain('Command timed out after 5000ms'); + expect(result.llmContent).toContain( + 'Below is the output before it timed out', + ); + }); + + it('should use default timeout behavior when timeout is not specified', async () => { + const mockAbortSignal = new AbortController().signal; + const invocation = shellTool.build({ + command: 'echo test', + is_background: false, + }); + + const promise = invocation.execute(mockAbortSignal); + + resolveExecutionPromise({ + rawOutput: Buffer.from('test'), + output: 'test', + exitCode: 0, + signal: null, + error: null, + aborted: false, + pid: 12345, + executionMethod: 'child_process', + }); + + await promise; + + // Should create a combined signal with the default timeout when no timeout is specified + expect(mockShellExecutionService).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.any(Function), + expect.any(AbortSignal), + false, + {}, + ); + }); + }); }); diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index d7afae599..50646d9b4 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -42,10 +42,12 @@ import { } from '../utils/shell-utils.js'; export const OUTPUT_UPDATE_INTERVAL_MS = 1000; +const DEFAULT_FOREGROUND_TIMEOUT_MS = 120000; export interface ShellToolParams { command: string; is_background: boolean; + timeout?: number; description?: string; directory?: string; } @@ -72,6 +74,9 @@ export class ShellToolInvocation extends BaseToolInvocation< // append background indicator if (this.params.is_background) { description += ` [background]`; + } else if (this.params.timeout) { + // append timeout for foreground commands + description += ` [timeout: ${this.params.timeout}ms]`; } // append optional (description), replacing any line breaks with spaces if (this.params.description) { @@ -130,6 +135,17 @@ export class ShellToolInvocation extends BaseToolInvocation< }; } + const effectiveTimeout = this.params.is_background + ? undefined + : (this.params.timeout ?? DEFAULT_FOREGROUND_TIMEOUT_MS); + + // Create combined signal with timeout for foreground execution + let combinedSignal = signal; + if (effectiveTimeout) { + const timeoutSignal = AbortSignal.timeout(effectiveTimeout); + combinedSignal = AbortSignal.any([signal, timeoutSignal]); + } + const isWindows = os.platform() === 'win32'; const tempFileName = `shell_pgrep_${crypto .randomBytes(6) @@ -219,7 +235,7 @@ export class ShellToolInvocation extends BaseToolInvocation< lastUpdateTime = Date.now(); } }, - signal, + combinedSignal, this.config.getShouldUseNodePtyShell(), shellExecutionConfig ?? {}, ); @@ -270,11 +286,28 @@ export class ShellToolInvocation extends BaseToolInvocation< let llmContent = ''; if (result.aborted) { - llmContent = 'Command was cancelled by user before it could complete.'; - if (result.output.trim()) { - llmContent += ` Below is the output before it was cancelled:\n${result.output}`; + // Check if it was a timeout or user cancellation + const wasTimeout = + !this.params.is_background && + effectiveTimeout && + combinedSignal.aborted && + !signal.aborted; + + if (wasTimeout) { + llmContent = `Command timed out after ${effectiveTimeout}ms before it could complete.`; + if (result.output.trim()) { + llmContent += ` Below is the output before it timed out:\n${result.output}`; + } else { + llmContent += ' There was no output before it timed out.'; + } } else { - llmContent += ' There was no output before it was cancelled.'; + llmContent = + 'Command was cancelled by user before it could complete.'; + if (result.output.trim()) { + llmContent += ` Below is the output before it was cancelled:\n${result.output}`; + } else { + llmContent += ' There was no output before it was cancelled.'; + } } } else { // Create a formatted error string for display, replacing the wrapper command @@ -305,7 +338,16 @@ export class ShellToolInvocation extends BaseToolInvocation< returnDisplayMessage = result.output; } else { if (result.aborted) { - returnDisplayMessage = 'Command cancelled by user.'; + // Check if it was a timeout or user cancellation + const wasTimeout = + !this.params.is_background && + effectiveTimeout && + combinedSignal.aborted && + !signal.aborted; + + returnDisplayMessage = wasTimeout + ? `Command timed out after ${effectiveTimeout}ms.` + : 'Command cancelled by user.'; } else if (result.signal) { returnDisplayMessage = `Command terminated by signal: ${result.signal}`; } else if (result.error) { @@ -406,42 +448,59 @@ Co-authored-by: ${gitCoAuthorSettings.name} <${gitCoAuthorSettings.email}>`; } function getShellToolDescription(): string { - const toolDescription = ` + const isWindows = os.platform() === 'win32'; + const executionWrapper = isWindows + ? 'cmd.exe /c ' + : 'bash -c '; + const processGroupNote = isWindows + ? '' + : '\n - Command is executed as a subprocess that leads its own process group. Command process group can be terminated as `kill -- -PGID` or signaled as `kill -s SIGNAL -- -PGID`.'; - **Background vs Foreground Execution:** - You should decide whether commands should run in background or foreground based on their nature: - - **Use background execution (is_background: true) for:** - - Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\` - - Build watchers: \`npm run watch\`, \`webpack --watch\` - - Database servers: \`mongod\`, \`mysql\`, \`redis-server\` - - Web servers: \`python -m http.server\`, \`php -S localhost:8000\` - - Any command expected to run indefinitely until manually stopped - - **Use foreground execution (is_background: false) for:** - - One-time commands: \`ls\`, \`cat\`, \`grep\` - - Build commands: \`npm run build\`, \`make\` - - Installation commands: \`npm install\`, \`pip install\` - - Git operations: \`git commit\`, \`git push\` - - Test runs: \`npm test\`, \`pytest\` - - The following information is returned: + return `Executes a given shell command (as \`${executionWrapper}\`) in a persistent shell session with optional timeout, ensuring proper handling and security measures. - Command: Executed command. - Directory: Directory where command was executed, or \`(root)\`. - Stdout: Output on stdout stream. Can be \`(empty)\` or partial on error and for any unwaited background processes. - Stderr: Output on stderr stream. Can be \`(empty)\` or partial on error and for any unwaited background processes. - Error: Error or \`(none)\` if no error was reported for the subprocess. - Exit Code: Exit code or \`(none)\` if terminated by signal. - Signal: Signal number or \`(none)\` if no signal was received. - Background PIDs: List of background processes started or \`(none)\`. - Process Group PGID: Process group started or \`(none)\``; +IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. - if (os.platform() === 'win32') { - return `This tool executes a given shell command as \`cmd.exe /c \`. Command can start background processes using \`start /b\`.${toolDescription}`; - } else { - return `This tool executes a given shell command as \`bash -c \`. Command can start background processes using \`&\`. Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.${toolDescription}`; - } +**Usage notes**: +- The command argument is required. +- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes). +- It is very helpful if you write a clear, concise description of what this command does in 5-10 words. + +- Avoid using run_shell_command with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use ${ToolNames.GLOB} (NOT find or ls) + - Content search: Use ${ToolNames.GREP} (NOT grep or rg) + - Read files: Use ${ToolNames.READ_FILE} (NOT cat/head/tail) + - Edit files: Use ${ToolNames.EDIT} (NOT sed/awk) + - Write files: Use ${ToolNames.WRITE_FILE} (NOT echo >/cat < + pytest /foo/bar/tests + + + cd /foo/bar && pytest tests + + +**Background vs Foreground Execution:** +- You should decide whether commands should run in background or foreground based on their nature: +- Use background execution (is_background: true) for: + - Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\` + - Build watchers: \`npm run watch\`, \`webpack --watch\` + - Database servers: \`mongod\`, \`mysql\`, \`redis-server\` + - Web servers: \`python -m http.server\`, \`php -S localhost:8000\` + - Any command expected to run indefinitely until manually stopped +${processGroupNote} +- Use foreground execution (is_background: false) for: + - One-time commands: \`ls\`, \`cat\`, \`grep\` + - Build commands: \`npm run build\`, \`make\` + - Installation commands: \`npm install\`, \`pip install\` + - Git operations: \`git commit\`, \`git push\` + - Test runs: \`npm test\`, \`pytest\` +`; } function getCommandDescription(): string { @@ -485,6 +544,10 @@ export class ShellTool extends BaseDeclarativeTool< description: 'Whether to run the command in background. Default is false. Set to true for long-running processes like development servers, watchers, or daemons that should continue running without blocking further commands.', }, + timeout: { + type: 'number', + description: 'Optional timeout in milliseconds (max 600000)', + }, description: { type: 'string', description: @@ -522,6 +585,20 @@ export class ShellTool extends BaseDeclarativeTool< if (getCommandRoots(params.command).length === 0) { return 'Could not identify command root to obtain permission from user.'; } + if (params.timeout !== undefined) { + if ( + typeof params.timeout !== 'number' || + !Number.isInteger(params.timeout) + ) { + return 'Timeout must be an integer number of milliseconds.'; + } + if (params.timeout <= 0) { + return 'Timeout must be a positive number.'; + } + if (params.timeout > 600000) { + return 'Timeout cannot exceed 600000ms (10 minutes).'; + } + } if (params.directory) { if (!path.isAbsolute(params.directory)) { return 'Directory must be an absolute path.'; From 4bd01d592ba17b3ebdba0e96a08804fbc6dda168 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 12 Jan 2026 08:25:25 +0000 Subject: [PATCH 132/142] chore(release): sdk-typescript v0.1.2 --- package-lock.json | 2 +- packages/sdk-typescript/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index d1a9fd8ad..1747649da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18593,7 +18593,7 @@ }, "packages/sdk-typescript": { "name": "@qwen-code/sdk", - "version": "0.1.0", + "version": "0.1.2", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.1", diff --git a/packages/sdk-typescript/package.json b/packages/sdk-typescript/package.json index df4f1d7cc..e6af67427 100644 --- a/packages/sdk-typescript/package.json +++ b/packages/sdk-typescript/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/sdk", - "version": "0.1.1", + "version": "0.1.2", "description": "TypeScript SDK for programmatic access to qwen-code CLI", "main": "./dist/index.cjs", "module": "./dist/index.mjs", From adb53a6dc6bf17d2f3dd0fee2eed2ef128988f7f Mon Sep 17 00:00:00 2001 From: xwj02155382 Date: Mon, 12 Jan 2026 18:03:02 +0800 Subject: [PATCH 133/142] refactor: change customHeaders to use priority override instead of merge - Remove special merge handling for customHeaders in modelConfigResolver - Update all content generators to use priority override logic - If customHeaders is defined in modelProvider, use it directly - Otherwise, use customHeaders from global config or default headers - Update documentation to reflect the new behavior - Align customHeaders behavior with other config fields (timeout, maxRetries, etc.) --- docs/users/configuration/settings.md | 10 +++---- .../anthropicContentGenerator.ts | 13 +++++---- .../geminiContentGenerator.ts | 23 ++++++++------- .../provider/dashscope.ts | 17 ++++++----- .../provider/default.ts | 14 +++++----- .../core/src/models/modelConfigResolver.ts | 28 +------------------ 6 files changed, 39 insertions(+), 66 deletions(-) diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index ea6bd442e..97ee91598 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -104,7 +104,7 @@ Settings are organized into categories. All settings should be placed within the | `model.name` | string | The Qwen model to use for conversations. | `undefined` | | `model.maxSessionTurns` | number | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` | | `model.summarizeToolOutput` | object | Enables or disables the summarization of tool output. You can specify the token budget for the summarization using the `tokenBudget` setting. Note: Currently only the `run_shell_command` tool is supported. For example `{"run_shell_command": {"tokenBudget": 2000}}` | `undefined` | -| `model.generationConfig` | object | Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, `disableCacheControl`, and `defaultHeaders` (custom HTTP headers for API requests), along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults. | `undefined` | +| `model.generationConfig` | object | Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, `disableCacheControl`, and `customHeaders` (custom HTTP headers for API requests), along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults. | `undefined` | | `model.chatCompression.contextPercentageThreshold` | number | Sets the threshold for chat history compression as a percentage of the model's total token limit. This is a value between 0 and 1 that applies to both automatic compression and the manual `/compress` command. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit. Use `0` to disable compression entirely. | `0.7` | | `model.skipNextSpeakerCheck` | boolean | Skip the next speaker check. | `false` | | `model.skipLoopDetection` | boolean | Disables loop detection checks. Loop detection prevents infinite loops in AI responses but can generate false positives that interrupt legitimate workflows. Enable this option if you experience frequent false positive loop detection interruptions. | `false` | @@ -120,7 +120,7 @@ Settings are organized into categories. All settings should be placed within the "generationConfig": { "timeout": 60000, "disableCacheControl": false, - "defaultHeaders": { + "customHeaders": { "X-Request-ID": "req-123", "X-User-ID": "user-456" }, @@ -134,7 +134,7 @@ Settings are organized into categories. All settings should be placed within the } ``` -The `defaultHeaders` field allows you to add custom HTTP headers to all API requests. This is useful for request tracing, monitoring, API gateway routing, or when different models require different headers. Headers defined in `modelProviders[].generationConfig.defaultHeaders` will merge with and override headers from `model.generationConfig.defaultHeaders`. +The `customHeaders` field allows you to add custom HTTP headers to all API requests. This is useful for request tracing, monitoring, API gateway routing, or when different models require different headers. If `customHeaders` is defined in `modelProviders[].generationConfig.customHeaders`, it will be used directly; otherwise, headers from `model.generationConfig.customHeaders` will be used. No merging occurs between the two levels. **model.openAILoggingDir examples:** @@ -160,7 +160,7 @@ Use `modelProviders` to declare curated model lists per auth type that the `/mod "generationConfig": { "timeout": 60000, "maxRetries": 3, - "defaultHeaders": { + "customHeaders": { "X-Model-Version": "v1.0", "X-Request-Priority": "high" }, @@ -225,7 +225,7 @@ Per-field precedence for `generationConfig`: 3. `settings.model.generationConfig` 4. Content-generator defaults (`getDefaultGenerationConfig` for OpenAI, `getParameterValue` for Gemini, etc.) -`samplingParams` is treated atomically; provider values replace the entire object. For `defaultHeaders`, a merge strategy is used: headers from `modelProviders[].generationConfig.defaultHeaders` will be merged with headers from `model.generationConfig.defaultHeaders`, with provider-specific headers taking precedence for duplicate keys. Defaults from the content generator apply last so each provider retains its tuned baseline. +`samplingParams` and `customHeaders` are both 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. ##### Selection persistence and recommendations diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts index 54818184a..a5d714f3a 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts @@ -141,6 +141,12 @@ export class AnthropicContentGenerator implements ContentGenerator { private buildHeaders(): Record { const version = this.cliConfig.getCliVersion() || 'unknown'; const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; + const { customHeaders } = this.contentGeneratorConfig; + + // If customHeaders is provided, use it directly; otherwise build default headers + if (customHeaders) { + return customHeaders as Record; + } const betas: string[] = []; const reasoning = this.contentGeneratorConfig.reasoning; @@ -163,12 +169,7 @@ export class AnthropicContentGenerator implements ContentGenerator { headers['anthropic-beta'] = betas.join(','); } - // Merge with custom defaultHeaders from config - const customHeaders = this.contentGeneratorConfig.defaultHeaders || {}; - return { - ...headers, - ...customHeaders, - }; + return headers; } private async buildRequest( diff --git a/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts b/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts index bb9206c96..530c456a4 100644 --- a/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts +++ b/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts @@ -35,19 +35,18 @@ export class GeminiContentGenerator implements ContentGenerator { }, contentGeneratorConfig?: ContentGeneratorConfig, ) { - // Merge custom defaultHeaders into httpOptions - const customHeaders = contentGeneratorConfig?.defaultHeaders || {}; - const mergedOptions = { - ...options, - httpOptions: { - headers: { - ...(options.httpOptions?.headers || {}), - ...customHeaders, - }, - }, - }; + // If customHeaders is provided, use it directly; otherwise use options.httpOptions.headers + const customHeaders = contentGeneratorConfig?.customHeaders; + const finalOptions = customHeaders + ? { + ...options, + httpOptions: { + headers: customHeaders as Record, + }, + } + : options; - this.googleGenAI = new GoogleGenAI(mergedOptions); + this.googleGenAI = new GoogleGenAI(finalOptions); this.contentGeneratorConfig = contentGeneratorConfig; } diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts index 6491e7bbf..176ce6b6d 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts @@ -47,20 +47,19 @@ export class DashScopeOpenAICompatibleProvider buildHeaders(): Record { const version = this.cliConfig.getCliVersion() || 'unknown'; const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; - const { authType } = this.contentGeneratorConfig; - const baseHeaders: Record = { + const { authType, customHeaders } = this.contentGeneratorConfig; + + // If customHeaders is provided, use it directly; otherwise use default headers + if (customHeaders) { + return customHeaders; + } + + return { 'User-Agent': userAgent, 'X-DashScope-CacheControl': 'enable', 'X-DashScope-UserAgent': userAgent, 'X-DashScope-AuthType': authType, }; - - // Merge with custom defaultHeaders from config - const customHeaders = this.contentGeneratorConfig.defaultHeaders || {}; - return { - ...baseHeaders, - ...customHeaders, - }; } buildClient(): OpenAI { diff --git a/packages/core/src/core/openaiContentGenerator/provider/default.ts b/packages/core/src/core/openaiContentGenerator/provider/default.ts index 6b493f522..4cda72feb 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/default.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/default.ts @@ -25,15 +25,15 @@ export class DefaultOpenAICompatibleProvider buildHeaders(): Record { const version = this.cliConfig.getCliVersion() || 'unknown'; const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; - const baseHeaders: Record = { - 'User-Agent': userAgent, - }; + const { customHeaders } = this.contentGeneratorConfig; + + // If customHeaders is provided, use it directly; otherwise use default headers + if (customHeaders) { + return customHeaders; + } - // Merge with custom defaultHeaders from config - const customHeaders = this.contentGeneratorConfig.defaultHeaders || {}; return { - ...baseHeaders, - ...customHeaders, + 'User-Agent': userAgent, }; } diff --git a/packages/core/src/models/modelConfigResolver.ts b/packages/core/src/models/modelConfigResolver.ts index 33747e43a..1afad58eb 100644 --- a/packages/core/src/models/modelConfigResolver.ts +++ b/packages/core/src/models/modelConfigResolver.ts @@ -344,33 +344,7 @@ function resolveGenerationConfig( const result: Partial = {}; for (const field of MODEL_GENERATION_CONFIG_FIELDS) { - // Special handling for defaultHeaders: merge instead of replace - if (field === 'defaultHeaders') { - const settingsHeaders = settingsConfig?.defaultHeaders; - const providerHeaders = modelProviderConfig?.defaultHeaders; - - if (settingsHeaders || providerHeaders) { - // Merge headers: provider headers override settings headers - result.defaultHeaders = { - ...(settingsHeaders || {}), - ...(providerHeaders || {}), - }; - - // Track source for merged headers - if (providerHeaders && authType) { - sources[field] = modelProvidersSource( - authType, - modelId || '', - `generationConfig.${field}`, - ); - } else if (settingsHeaders) { - sources[field] = settingsSource(`model.generationConfig.${field}`); - } - } - continue; - } - - // ModelProvider config takes priority for other fields + // ModelProvider config takes priority over settings config if (authType && modelProviderConfig && field in modelProviderConfig) { // eslint-disable-next-line @typescript-eslint/no-explicit-any (result as any)[field] = modelProviderConfig[field]; From b93bb8bff61be22723004c76e9194d094ab44292 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 13 Jan 2026 00:05:33 +0800 Subject: [PATCH 134/142] docs(vscode-ide-companion): update vscode extension readme --- docs/users/integration-vscode.md | 4 +-- packages/vscode-ide-companion/README.md | 35 ++++++++++++++++--------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/docs/users/integration-vscode.md b/docs/users/integration-vscode.md index b12de7858..836e9ee99 100644 --- a/docs/users/integration-vscode.md +++ b/docs/users/integration-vscode.md @@ -18,7 +18,7 @@ ### Requirements -- VS Code 1.98.0 or higher +- VS Code 1.85.0 or higher ### Installation @@ -34,7 +34,7 @@ ### Extension not installing -- Ensure you have VS Code 1.98.0 or higher +- Ensure you have VS Code 1.85.0 or higher - Check that VS Code has permission to install extensions - Try installing directly from the Marketplace website diff --git a/packages/vscode-ide-companion/README.md b/packages/vscode-ide-companion/README.md index a5e4980d2..55f7da323 100644 --- a/packages/vscode-ide-companion/README.md +++ b/packages/vscode-ide-companion/README.md @@ -1,25 +1,36 @@ # Qwen Code Companion -The Qwen Code Companion extension seamlessly integrates [Qwen Code](https://github.com/QwenLM/qwen-code). This extension is compatible with both VS Code and VS Code forks. +Seamlessly integrate [Qwen Code](https://github.com/QwenLM/qwen-code) into Visual Studio Code with native IDE features and an intuitive interface. This extension bundles everything you need to get started immediately. -# Features +## Demo -- Open Editor File Context: Qwen Code gains awareness of the files you have open in your editor, providing it with a richer understanding of your project's structure and content. + -- Selection Context: Qwen Code can easily access your cursor's position and selected text within the editor, giving it valuable context directly from your current work. +## Features -- Native Diffing: Seamlessly view, modify, and accept code changes suggested by Qwen Code directly within the editor. +- **Native IDE experience**: Dedicated Qwen Code sidebar panel accessed via the Qwen icon +- **Native diffing**: Review, edit, and accept changes in VS Code's diff view +- **Auto-accept edits mode**: Automatically apply Qwen's changes as they're made +- **File management**: @-mention files or attach files and images using the system file picker +- **Conversation history & multiple sessions**: Access past conversations and run multiple sessions simultaneously +- **Open file & selection context**: Share active files, cursor position, and selections for more precise help -- Launch Qwen Code: Quickly start a new Qwen Code session from the Command Palette (Cmd+Shift+P or Ctrl+Shift+P) by running the "Qwen Code: Run" command. +## Requirements -# Requirements +- Visual Studio Code 1.85.0 or newer -To use this extension, you'll need: +## Installation -- VS Code version 1.101.0 or newer -- Qwen Code (installed separately) running within the VS Code integrated terminal +1. Install from the VS Code Marketplace: https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion -# Development and Debugging +2. Two ways to use + - Chat panel: Click the Qwen icon in the Activity Bar, or run `Qwen Code: Open` from the Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`). + - Terminal session (classic): Run `Qwen Code: Run` to launch a session in the integrated terminal (bundled CLI). + +## Development and Debugging To debug and develop this extension locally: @@ -76,6 +87,6 @@ npx vsce package pnpm vsce package ``` -# Terms of Service and Privacy Notice +## Terms of Service and Privacy Notice By installing this extension, you agree to the [Terms of Service](https://github.com/QwenLM/qwen-code/blob/main/docs/tos-privacy.md). From 299b7de030f3191569d3dfb06f6a95ae7a5e9be4 Mon Sep 17 00:00:00 2001 From: "Jan-Niklas W." <6104311+niklas-wortmann@users.noreply.github.com> Date: Mon, 12 Jan 2026 10:17:25 -0600 Subject: [PATCH 135/142] add image for jetbrains acp configuration --- docs/users/images/jetbrains-acp.png | Bin 0 -> 36762 bytes docs/users/integration-jetbrains.md | 6 ++++-- 2 files changed, 4 insertions(+), 2 deletions(-) create mode 100644 docs/users/images/jetbrains-acp.png diff --git a/docs/users/images/jetbrains-acp.png b/docs/users/images/jetbrains-acp.png new file mode 100644 index 0000000000000000000000000000000000000000..b49ef3547392592378f8d41c16c45ec0ee9f8189 GIT binary patch literal 36762 zcmeFZXH=8h_bwV`g9;*c6cBXl1`z?3rXZjqBGQWxx)CWM1f)YE!3L-(s5D8WNrzAa zfrO^81*O*n5<*0!h7ejp2}#b&-v4v%f1Gj09piquL$n^tzr7hHSpTD530GAtU05uqh7%P` zNeYQbT`9BoU(srp-L*ZCt@(9V%}k!Oa7|OB#ie#xfcEsa;xK&{O~GzA zaN$DUxP~-owMF$05a`-k2OE3w4`7D-Gu#t+5t`TyVc?$KLJ)Bf=;XcU8o>3B50HR= z?YspR0@>z9i%GVnOnjTm{4Rs!q{A~1Q^G6!UAvSUNtcp0*zfM9RA}NtGp?*)gG%3E z&?_Cc-W<KYa6`stx$){TwG_is*I zo-_TRKKxM|;^RwOs0epxj9}0tx;G@Z229bHXdg!k%JayM`Im;5$V#!XvJoxIsdCC4 z2T6X-E3PY(_H-vX)D~fu?0>DyceX0Vd2|S>cAwEvL#Fxa36|@fbvJ(F=pR;knnzN~ z9mo$-Pi$>rMu^O8-lN6r_U_`l40jLE@ns$;x@-#a8 zyb&*OQS;k+4o=ItG<@aYlhU89j^(ZjXd$^rDU#Lb+RPLs)uW^`;#8aSMx1J+pU|*U zc>|OlwS=LgmAgwl#9%DB@m^izY_`zjJLLWfJg-`<>&L0n%N+wP=$81W$;}P8Db->e z_Y%U{X?=FMq+A@XtiwqgSMU^EvQo!F^+k2#T{#+vo<|!J>*pK3@ICL2q1# z&)4}V5lusE?HTJ#+bZ+en*29TI9eqhXb(`6aLA7D^e->w~>(Wap!;+0?0b?HQVQ>kEtMb^KsOP;^$UvxY96+GtoYN5a#Crq|WnePJCN=Dziq&wTrwo;*wak zQ@7IdO*1`^U&bC5^DS#jae;8ZNcIE8;B@te4}JA*-JS&8Vm5%&rXt}ZV`#Ae6)coq zt@?9qZ+uC{1GFX_m+edKiS+#Pb5gSoxTTO5pJLJ(-&n~^zOmA;Qn&biWSNHSsot6_ zE(zJ1OvVmEmWS$zFBE(_nean1c;vQ|>sf4Rp`2p^6$K7;CdyZ4@#%P z3HUSOoRH34NtnAbsLj{=);Dn%-$buZy$LZ3k@PD2inb{rz!Bw-^Oqr~tKiU$#o~*e zEx%_qFVEo=P2;g03)J_-yPO~{Gxdhn{&jM=u^8;XrfdT-6PTXIxb)?=e6PUWHp<+3M%jf4==xP8;DgS%+C#{rD!`T z9#!Uy`*I!zPE=yeUntDK!=9z8;+?N|cbg?xyOjP?WxWpUbr@VM6~p8$$3CLw*g)Vg z6PU`)B6~QKa!bdyXw5>G-B4;7hP>2JC&an3crU)w;Yre7`iJmRPigRfY9vJuC9TTuOO$r@DQ2jf)Ss z{#O~7i4;$mYb1uyWHj}$=>by_l{^s0Hl-&jhk>APgBGl`p8{2q|!0R`BzqX)bf`-k5kJ&z{ z{0LEIiab*vySJY_Q8Cr>9_vg{Deuxn7bQe(g*Vf4EDpXDh+uzA=Qk ztW=I;u|H}FH_V6&t6*pMSvK0Q6~~zHB6omn;{%^-h#St8nF&itl2&(%3!faemp~&3 zhvN_by(!_1JQIB*iG-N4dAx}B48xm$OzVsZ%WD7{~!-HiG|r13t) z%m6?4b+CIM#e!XC7<=7N>yGz63}Oe5ch-=Sqw{`oCjP;ReK15F_GvGY6(yw<^u@UK zeZOS^#G<*(yDTl{s*68*Cq}mOV&tt-WwLV}7)*U9M_QGX82hDnGl3cYt9uRb)|>g= zQ#E9IW#>@m6l>SEPdbm)_&bGRB_n#dUwY@^pX$5J!t9Dob^ZBnm-`lrIzCBM4(D92 z?qXdu`LviD;fdJ$f@BpwXbB5m4>oKiK82W3`(-psRbf)KBO4*bzvf_dA+0H-*2d_s z)8GG^$gWEtoTvyrGeb~E{1Gem;6!#tU3F!E(H9l%v;!c^h=GO8^9r=e(E@+L$a3I- zrAN75qp?arBy9o20@9U?`qt>a8^CR#9jAdU3Hsx81pwdn zUjWP;bnWwhe)!KP`HwODXB+&-GyKOV{wEsz?*a0UnGyE{Eg(759Q5(T5X4f_BE%Tl zHyOp^HF=r|L!litrT5{W97>_Q#^3E>)^W7! zOK$*IzWV3N@`2~yH7Y05^E8L?$f#WW>B*54x`<^F5RCVKDAK^eS?_`TbSR$^%A(e% z7hAdC?;HpYbu#|v0Wq@v4~CDaA;$g~N!dFeQi@+iwaT>b%ekQ!!@qptZ9WTI}U z0?D_vT5$MXSC^=b{(Iq5_0pHUy?r(3BCeYu29wsTZR30C zKLD~)Qd$KbL6fIa4=USBm|D|jmUFJ(hI9)?H&`aa85MT(UdY^B;iSRdt7MMvclQK+ z_h&sdT4QKsonQ0LX+a%@m6e*WgwN7jzIOUra<@8JUBZ&C_s2&Bu9_7tR#z*IP)R^$ zKY|}>ok~68C{d{XTw^s{6s9CaqQ+J0J4En5Y2)$(;zq0BVXk26P($m!RKi;1Ca1P@ zY%#U|NBzmO=U7qe;jhZ%#CP>iB3yI>UO5mS)r=A&6C7};pz%d?%eU8*2v1s!r<1FF zvnCmJ&?(9;A7z7s`q5x>_F9xvQlzE8ne|H1z+R+=E_BYrN{(yDQ*poN7%DBBcNt8M zUD@bcdzJo7qwHRrj$V8cPjywz+~&@;D*$)3@%xAURAMj6z0Ka@b#j5?(X3BLm5Vz6 zsChQkCrL-A7ceV3ixtc@%b8K_G1$)X1{J)yZ6&Q!u`>#nlM;~wj>qwDSwV{Pw5+2awM~OeJFKKsEo*7K8#c4#3?8)1Z%LD_E}ASM`Bk?<5i}P_DZ=`JG_Q(! zOf%3{{`u>wa)!MIC2PXlVPH(#zFF`Xf2z*9*(^<%9p8>bbx07?3&!q zGVz&Kt}=*Uh+NQ-e%HvbDnHVZ!-NV#4!uOODCZ|v4sZU~GRaxq~5z9|wdPaBO zNWmB|eO}d#$q*%S8bD(NO%kaW9h(9e@y=kzT;u>upmXjotRa#(ubr0J7$ZkRl0E`y1W}cV9ceOMFT&R(Uf>YX8qLHzjWJ`;ksGx z_mld58MdP9uQA$ovZV+F0~DA_jI2%fWsN6bJ6u21pTk1E?aF!Im!#09M6rS41U=6i zD|PB*6A$A_^s_wUCE~kljqxag*bNnI2wQKqQE(#pAW1z&TT6Y-L`^m`&S}rx<1z>yJnnE zN7u|41hJnZb@4aY>d*6yXAn$TUN)IXwJbj(8Z4QW0`=}vRh_I^mkeYN6dDzJw7b?j zpSsp)UDl-P6jjscWX)!tmfxp;L8Hhyq2xu32rav4!%o!ZoV}$(jh~I{wxKLH=lFuD zXk_MLQgCd3j6d5k;pEbEg(3AkC*l>)ms&62_g*$L+Svhwu=kinR?@HRItxACBdm+p za?)(&rd6JwiI#eyE>AYHfAKyVZL3Z`3y;~@rWepT=PF@pLoVz;!5Qz2n($c-@2ORC;g{8SaY|5BPFsH8+ z(76Nu_oW`t?qmP&et0Zu+d-c_Z&=wp>Ch4S3P48M0L5Z^O!yN(s-gEJ7GQv~)ST_L z0iurqyr|pTDbKIKQW^wcKFffiXBv<|Gv5fWBC{Iic-R9S)PMbuPwK+!I`Ef!tT8^+ zM%K5`EltpVlI3+&=p$9GJ_mU62b)_7I2KQvsnOu``uH}+i^#jO&5tiU&GVdDECxGx zop^~iO2FLPWXtV$8c+VztYh?3?dYX2{=iUn+Yxq2SBTcQk4oLlK-bcxVt7RB-2Aeb z>USqu0G)ItT}!p3V0pHGpY7bG}r5(<>Ce!2E+$33Yo!d@obVbl^ZPDk=@2 z7kgy3wqXC1rg|F}U%51e!reVLvUh=sfnqA^?nKD}LYfI9~-c~n;20Pw@>ZT!&a zO|{TzJ@0JoulmZ7q`!h}4(yIGeMp5PGDo!V=0)c;u%)iG-#LF6rWS>LE4Nokhw4Dx z48&jud9RJR&gM|a3|?p9Vr3T<~;!}RWpgy)-MAb!M+(E3MH z*ejK69zGF|gRLD8jbvAB{DU};ZzE2XN9+>m?(LdmZG#1!zfF95ZcW7DW86Hu&|V}B z4a|QBXlxB%Z7=>o9HcY83sLXOijDeG10V9FjP&^;7lCpP<=za-rlH%eUR6VF8Vy7X zHF%dQq1&um$CUlIqUY@LF#Zux35jTh=3(_cHqUc)6^DE>MZ{%20jH|fU5cF77A3Z` z=im%2Ki^7-QFR&2q+Fxn1J&~ht7=4#`w3p_MXQb+<`!wx6u}R3dmeqAOI@!2rRPr3 zOI=Ri)HjOKPp`fZJUU4pITJ2_S2 zX0ypEM@hF5PWS`GL!FhS0yp#_?rL0e0pKPt0d7)VSY2Gz?4$Kod^Ld4yyg~g*P$)B zMV|0MJLykj!iG-_4T=WUp7;#>p3R32Y9g&(yFMJg6~u7mcAXU#bqU94*5-5e8d8Vc z#cbWUC1S`TI8lt-)rGS7c&XL*Tax01x`&P(o0~F}d<4dPeo*U>NT}zHFLr97TQ_ae zH&hN&hT`Y!-$;3pt?FbnJR7b|JbV$yu{*+5iDTkaDWOP`a&>t$1`JY(?dDDP|~?66%WbXokaRMDc&^@fBc? zW{2uR4oGYMNtpgRJIuK0WTAV=FKyw@pfFhL%&J~xf-*Up=1+Bb=pv1lo1WWyIikWv z6X6@;i-E)O!p8RW=o)Lv5G;C!ecAYNW$DWgy}vVzTC-J_TVw6hTq7wlv6AOq{g9bs z++2uuP0m}(`TF3HLCzIQK0VZkWJV<$njE4Wv!#R`y^BRDHtYChj(M%F5r$amn?dJu z!N<^gIV9)6*jppvlj+#IoIHDP% zmDRDy*1zJv(Wa~uC`sF%7aK)jb2&QSRipus_npCAsU334*{l)A9WXbIqBj$Fb4)gSKK;8kOdChA5?hBI6%Q4S&dwLdFwH=!O{+w4 zTVmP|nvfjz3T(R~OPE7TGcEI*U9G85Ds-&kE1uU=*nj9|jv0)&C$)D7jn(DsLS*7?~|*!c`d(>f&cH zm=m?dQ4zi=31!!Lr?r?Ek;tc&)&D49!GVO2ipgPR4FS1jy(z^rI5UT_4(FDeIMntv zZNkrDlb6#CkCeD|u;QC7_578Wpw`xx7X=O^2R>LcY<& zwV}(lI3&wK^Fyg)&G^;z#Y0n#Yx*@It5^+X)(kDHXRx=yo4TC?+1YvPW*s~L;8VrJ zir(JgR+9L4cijO|10l4fHl}P$7#y}Z`?Fxfgs5hzdlU_3-EeJ1%j*Q^#Z^sxhKBbw z{~(pxN{?zwRTZb>E=cdRUa)hnsM8BGc)~Fb6Z2{Q6qN9)2ABog1C4R8AZhDFo8ko~r->z$r6h;$rlGdRJME*2%MddAi4Hhblt9d)SX^hLDTg z{%eMo2h>$f8x4pfv!AARzLWQ>*Pbit%aIvt4PqaQ@dov+jHnyk{$e;u%qnxNRb)NI zz}@fWziGtBtK>&7+#IIG5)B>>Q{JSg;L+dY#9?14LzmXp4=lBsAjxDs==xx!IW3@& zm3Q$VNo3y%Kh`Z*9eMePNSXTIPE(CX0{GDO#aD7Qu6Pc)~j{9)Dv@di5w zqGepks!}>aC7)sPdj8$U*ssDivZa-ydF13(;;YuX9HfJbb`c6u`Z@a$`2mpQO z;t?Ssl2v@4TiFn*wYN)6Hh##ny41SXrcfLN;Ml_3qBrNh3}k-$2U8CHPfYpt?*9e* z{=clO#k^X={X(Yo|C0ZZ-CA{M5t2z8miXxAru4jR~9BtWDp(a*#T_MkyCO z9$79^zq+yl@PCDok!SRB_R46F@iW#tcl@~hCpP>3Ygs3_V^}&A?@NWp>nuD6NBn9# zs^&jY1=JSPG~MyX?R6E`n%tUqcv5=?wr7NkSWkL#+~p>kU(b0HXC ze~aKW#N#c5OST%-Y*QvXTH__^p9cXD0h0{VYD=ea)hJPn4{wD<~J z?!vc)%nnW8QHW=c>)VlMGyb*UdMjwA0jq#Cl#hq#JHe-LaBuu%2pbl}kXN*GGoWls zH8O)b@h>T7MFsE%7)$$)D zINFDVnAX8(!W`*2dN4Q~!y=^+GCn@qA z=ZXt~OH0;^R6Rp&?%aa zs^4&8KBX@|j#@q{;(4KhsEsk^BkevAz8U^=s zBKFO;OwV<6nQr{8sMZzJU5byC+=srz3dtUhHkGsx-WzErz^zl2Nq)B3y;gd# zQ45Wm4^**L_m%>=&8SAZ-9y3#l1lbvkL`85vS+ERGh99B?e8w(?@46C+mbav`t^Hm zZs$zLk^R-aKR#!3ZWQ z3_RV2mGTJTq78AWrCn72=I%2-7P_w%8WDdSSsdd+P8a*8e=)d~RBiE9&uex1+;a`v zo!i`mcx9c~EA#bF5P5MYL&gzN*r&Gz<3iEZ$uP~4@trO|Y&CuVK3gv?9=E}`Vl1f_ zHeu}KW`K`N(fmDEY}A`P`)9dF>#g`IR>->{WND78EctZe8N?SQQkg0<=uAzvSRvOJ z5W2v#f~tKtUlS@hal>*K16LQe1K)UmGMdvaxbjuseS+0LJV?tZRxr}WCRbUYko@V_$wqwlA1vuUn{BoY+7lOZ zJtQD(tRhWbyB-nUg60>LZ}w*#V(+f2$e%oypf`0bD0t}5G|8B_ND#9a*p%|Lk;szv zjC0BTQ}SZ>bpM4!jAU+zv>G=h_;fVgE<2RR&h49Cr1l99MdD}91oiixob~&1pRJKS zCIE0!{ZIwz9g9lNU|oeG`{@a}%lEmz1Rn>0&9qiX-c&hq=}5|IeM7FFhpP)YlAbJD?kU^CwVMz%j@uQb%+x8Fc;l8UL6Xo%#?8xS+eoi#ao z`lV2VxuM<}*u!Z!HOv-;B& zeTH4#+yg^7GAW5QLgY+&!}2FY)7e6Q7?YbHFp?OL~z7ko!r-S zpDj&NpX|_3xYIgt&O!`k%pDsCW;f1i7-1P9*ML>{3)S`~JqNJ>yko(K)QY-0ML#iD zJG-)0VhL9A$lT&=^%6co(wUQ_k2dVK*<-KyWjk~Mo<|pr5LP$VnZ1z{7j3@0kRo6U zx1bSL>RUx|C8T~~^Ms?87OzTjGWIJE3Oj|&pBDM^lvQGHNNi?@`h1=TLYKc7{oTzi zLGy)3zPHPg>V5P>7HJrz;#4UgP*qR2s|=0&XHwm&gWP#9Cy&@YHO{AQHr_!|%_}#F z&#YGnW4#91HyR62;GloeS5sp3w9Z=Xv;eg}`E}dA@}k;)v#g%n?)-Gh2$*4+v;RYD zj9yf86(Go?q7abMKE1zcrlZWwjQdX1Ip(_t(Xd(Mg)s4%mjE)-nE@tdF|e4PbFWa$ z)bRxEz_jWLIV>T_TNQ4@O+9ShSvoR(`%|COO#RZ4lJdxz;gVI>IcTQNN~8(0dF6hy zTskc?w--E*JTG6=-7`0@m#51S-hTA|A@R_;wqEYOOT#8TYIij-0MLQ-6Q+J!;oQp; zOrq`rV&)0SsZyl; z(x!3WMqV=}Gc!C8i_NifSuS;`SRwq^O}m?q1s9J7as?L-rMP+vQLAv{Z-y2B!j{(0 zCE!gp#}+fYe(IKwPPS+Ek-J8h-#Q?_N_eg)3%;J$Uo%ki9%~sxw91!XVoyhTc52dA zGrQk6mSe_wF$(xA;axlH8)i2J-TSCSO>-%|?vxU*-sE0zRJ}ODiM4L8)8*u=FY?AI ziJFkl;E#5}QA09hhGb>$9?!<;=3J!Z|V9c=5ol#x}Kf2m< z<~KL>w*J?cCRLj+cJ^+3Zd%aytzy2kejG;vzShgBB6!oJpr_Fu;6qN3G9q{ z?dC)MP%$He6lzga!=q8p6n=L|GEt-0#(C~G>0ucq_r8Vl^h9q;gPiCSQ7?c+=`b>N zg>CMW!PL5du7S7fOX_j9!nrZPfLyui>EYE>3lhTy&O$&Qns@C4fC}V1t zWSxw5STPiHXH~4bBb=hx`oAeN-e63_&*e~NdzIRF6-(nVHlqe*pfCwP1llhP1l0zL z*&6ln`

^&W-Ug8d|RjzhCn_`FMqu*O=yBy`47=KBE2c-ei+}Pg*I*GnNxuf30)XVaT37FbV1iXY`fpX=IRzEtyw}AMcv1>v%iQ)o3(|7 z05!iMSpMcO$&9pG8sv~Y<&V3Mvzk0{5Mq|_{FfF`6_ChxPggDGPYN6e8@Qon$ zc=M0FR41+4$vxDVsbWRvS$5K<5;Hwx(9Xqcx(g^IE$kZ>->qrNKJg&6^fD{a6cdFH zVzl+rVI-H8f~Szu5tKI{JyorIl_{*YI^+>5OP!+fzj7KjG7o_E@7rbo%|6>#1nxL{ z=gF<>ZMveZBE^%bZLZuZ2($UOvW;poqEH=M7y{HAJ|J(D^F!Bdc9?lE%6Zcx7YgM! z%j>to@XoDij`7ym&kg)|D#d;k2?4Nhq`2)%~lFsM%pdq#|Km0?@b9!<|Tvp^QB`^Zo8>oT%%u{9UvNO zMgn%=tBN{5x`)Ro_o6GDk5fr-T_0=CbKc7%`|7)GR zUH_`&PpEBjt7X!kBVIB#S))yK&++BIHAhfkzVt>;tpsRB%E$`b+5Gu1aX&%qwOem$ zwd`YTZl70bdwlc!MX5+}(AmtKvsjM}$=B6lqK{9jhRMjwWP%jRf0Qb^{+pp{=qNcVajo1!(jsZmhW>MuEr9B-z5s+~iZ8zql=#JV}@cMQWyq$lU(v6*C4 zXvDy#FWoJcxmL%NL-8b|mLxeNgBJ_km*`&c#)8Z2ukj(EYsOE3s`VbdIenRx!LI0u za;EUl=dO+26$kE4CH4R6S}g8A?hXomcOa*2#NDDjE8b9&68w6z9d3Q^biK zP3ZByJ@VKMg|JMn8~4S#ob3j-uE&YH+Eq@1h#B#QCla+aagB6=O6XSGQ)y7vgX_^6 zz}F2L;-_YA{k@Mc^XZy})c&K}mkqb(pRziE;|F}yZKK8q31mQ^+aw-DdE)s3x1@O! zsB+Z2ySrMr>#d~9zyftgdRVfIW}Bb?2c!S`Y!#V zp5QL6>;SrJn2>J$m$gta7y_Rt+xx{?l`$s@oAN<5& z{O{D48NUU;ETSB(!GnTLcgdq5j{^5@eg1ujG6|O=GeWr`4LWJI_PplX9*TZ|5ppO{ z7xvfQXO~v@1dE@5@s}jUV6MH6)-1FR-+g))=vv0Y9++a-<5%W$DitS!x-6K7`q{BS z?x`Vox^wkr2&VjnGGiZbn*>ZB-|pA{`IfiP<4;6I?1|K{_ncNoOeO`8OU>_ym)UPfqRF9vHJ-1v|M*QxL9@yW?5_O_CJhY3 z5;_`|I0f5Eyra3=#f|&wTcdhUh+%WzqE|SYHlB9PIZ%iZz zycbZvhewEc2&h0|>bvCl8)c(Os6V)b`iMZ__jqCL*P=IV-+6C!XiUuanL=Jr}+`~apR>NBnRkosx` zXX%T7_pVG7ysd|ZsdbiYNRWtx#olDQLYPa_t3srXg_Cg+oj!ISy;K_c zRv{AexQe@r2^3HQbK*Eq(MM^fqzi{cusj7m0Z*pDq&6Xt*n(Vot` z_bi>HE0{ByCpPnFa@~exJ)aaf@hVC0oDmY>Q3l2ztsPP$hSo;9CGPR^m%HrULtJfo zjby?kM{f0wdvKapiqe|-KfyZ2TfbD5vB`mn*XLz+fW8f~A;NkS4eRQrPa%!z5Hy4+hq4tJa&-D#Az6^+)|n4`*dt(2pV zIjKl3G;HN7Fd4b^*IfYBwnQ1)Bizj%2?29F;qT zJ%Z?=;8YvEeCB$-4=W!ft=<+l+tg=N?OIM@HO|ON<7LBtigTMR!S%c+ZdCG{0@_<8 zGk1$LCl@P!5r9)P1@3N$Yqn8@q(n0No}lL(H}kYkt$k5f54%Pf>dthB=yb zu@gtdU~y8a7k4#pjhpkyzqEscJ{wptYgx_fn_&dZ4qz@}LQi6@JM|J;W3<(gc=c)^hagfcJa=yy)Zn%PZIn4Wk`% zOJ4Nzide(aag^D2i6iW8hsf;FN1MvRWH%yE-cqAkI#8rZpNO1Zvl`mH{o*iViLW7o zh|WJNc04Y}v^YLGc1EextMn0Ilfy62Cp>BKjQgEW7KVV{P!~?@m`%*}LW&xU33}8i99dHK}wwgi?amB(eJ&L8`HBg>>#NGyYZixYfCnq5cXZ(SkyB~mMFBp`H6rEG;$YJMuackJ*qa=~| zk~kMzP*P^RhG0D|wg%rU?&#a&s9t!00yrUp@#iy?C-FwCu2>hXoc8MveRQ+u{2MvkdzrI`2i9|}eF zqA?60=Z;rF^*ZuD)`VN0uGn!6;vz@dmzl&nRC6=-8db+j{Ms?m=$hQmQww_&(v?6U zyVRJPnLVTW5HE|LOB+7VHGa(ejIFtmu<&sD4?sqj%NVFQI+5rFxJaHZFt)W7B&q6&a%2x$4E9_TfJ(ZM z?u;a0TS6uN2Kt(fQt=rz?)Rho&OvvpOO1d)0knevN(r{58mB`ne|Qf8XklwQ|CosI zV-7GXdH#28{lX1h7%(gEiC%|JIgvMVYbx07HFE$gLU1!ZS@5xDD?=mZ`jeb!eXs7Q zU?WHGmEJ?3$Jl!XnsSNy;^uX@l=fWyhr37i(@qWeTZO)3(ASHn_on$Hj*eak%#4E=j=N5`rL zx21pR=0X*_>Dy9(jtV;7)Q*LY9aIuR?9|74Ajt!*Xfa>V;|Ko?)4^nXk8a>6RWE>l zM70>z?k&Y-y#AiQatGX<1eSUAPUfYuP*Y1)bw_cZBLk*>DYdpNV3s#FUxDH*K` ziFK&1{<2p_eJEwP$HAM?Q<(0*ed+;5jalwmtB4cim<<;O5Z<0GE|`2KBB5rIQeMS` zjuxBpiu-@v=L*Y+Z&e&+I+#fB{0lHfA~30i-1*Ex-sht{w}>mP=%>j~2ezxSF9sMu zJ$b6b;`<*}a2<=<&RW(;DsQb?ZN53D-E6xu{L#rjNFEFaM2}5nC2lZsCeU0FNgpF> zseUMhkKMmz_omw^KTGTW?-|oVr(~EvEoQ2*gdD}23 z55CnJg3{Mb=`%rliaQ~rWrQsVGwh75;)U>^f|k=JGV%Ui4<;16xp|ZVhDBZ4F*4nA5DrV^(Zb` zn@h)Nx`&q4?z@a$hbZfLDeG4df}TcYfIts~50QcoT5!2plXDKz`Vb$L@FHDy8DXtV zvIG*67+H?lKZfv&#{k83Etpjsm(I#_DqUejV0&|FpBDSbZEEwIXx!raY z{_VvGU&u&E(0`&oqb3)KlLi2AE^}%@uPe-9!!4D(z(K8VjDfb0$`I#u>$gD+Xx%`x z23Z<7QXoFY#szF6k?uqnf0-X$^W<5~DBD>geMbHd1nHsrxpCaB+d_{yGuwL|o8E8; zF@C{&8bCEYhadY&km4gbB0>QZ-hdh(KmN3}(RsV|X0710Y-&kPg`KV`#S+U~47=ZbKUAkmJ zI(R}vd5xAee5k+!iM{kiQ9FR%Z4CSV$K`HCpo^hC`<~6pKAq>s8^2r&drE}#Z>z^# zvd{VZ(cGsCj{wI<{GtqPI_Ce#U*0qY4dvVnPAwj1ObH48ec4v8+1HJKkR)K8oWCEf z|CZ5pFhUMjbx4y^Naa1k*qK0KbzzO<*>@)#^P@DdMf>w0y=-YmpdL2~^oh$dCvJR{ zC2UJ`m0@DA&Oqvm7!CV&n&GxcgY)hrL(8-F)SN?HPnuA?JRG9TpU!{3(`nWDN2V$W zMB7i25j4p;0B0>UdXBFt=84RK7FL-W^h@2vKa zLWQpKT_=If(+}y(mEQZ`1L=kds@&0D;@Qp_nW$q)`dY%E^uZd4a@dXdZ6pT7%mbvy zImXpq-QBtXdK$a(6mV$)Kp@+o&X{cZW&1m zej4^<+wJOZWz@`E@ZPFZ0Nl>S{~iNp9fab|qk*FIy`Zp|_MRgkP>D&as z()uHf7O989OF&4Xi?$$THoh<^pvs`Xk!O;C5Hx8jYv zJNt2qNZR#ZjP7p^UTsf@-{r%7sFc`sREUH=b|dVWys)MK!06tCa7Z@~bCg89f2?{8 z0w-svb?dI_0wV7NYF@osT)OtXvWCqVumeD)m+FMIr=;3k(PIIP2R;jpf_EQ*W++`8 z9eJNk$-&WGkgL)cM)qPFE*||vM02ilf3ne;ZqjBMu3&4-a? z=Z2S`4bE&#rzyA!8ll?1CK@I?(f+layv@n?Oh_Cn2m3J~pOCYmgYy_4_H6$|?Q&6W z*2pn3jT=_l1%zhlk%4L8yp!!T{sHX}Y2NsX;6Xa)+Sr{vuzwD4$~&%uy5XCqDn+_e zL_ZL%=Rcr%q%9+Sj?myC3fpeY7)K;r1p-`@jylO+P01(45%!fYWv5wQ__-3b4>w-(uOJZ!r9 zYQg;K+=Er=WBy7n?p5a(p>pzQciuy{$dmh^szKv^5-d) z5yzWk9W+Q|Nc!pRp3%{a0-k@Pf9L{5n!3w~Ndpf2(L4#w97hTc>HIy@!D?E&PR-GP zE)R`e63&y*=Z}pEez;e1-mhoY_URS7C?AU4MRmj(RHML&K|S?-B&T{*PGmW2qNiT& z+Yql$#m%*u;tL;zxJL5e^^wZRjp5p^fk~wx_|4|}`}7sy(2g|xc%{cKeSthqU-gWL zVW#{#_ldDHSZ9$Pr6$q1T1%Kj0g+}5nyTmJhx)CCV--tw7Ya|0PQo~2UU--F2Y!v| z&0EQ%feXI~YuvK%FGDqB2dHJ+L0<=2skYrAk3 zRBiEZ$k@2v&2mt(0NNM(<*!~iZC9|lfCe}6pP679n6at3*|&w`O3J>ATux^2;9+Ah zm47_b|H%UMd(l)Js`h0nSOH zz+F(2HTXw^BqoO^Ea`%FYL7l6nfq!<-#zS3VcwKgm+s{iV!fOF4n6cdsGr)YHX%H!et}aVN)Ax{O=!;t+1(CEx~w0 z_>WAp;jtj!3rGP~;Kz844lCRE&5hFVkK$*-rGFiDd7vlY3Di4l>Bs;5!iObEN6ny` zl4Xc5V@+(B+Wl`um59k7{}1T7W_j6(tFE5{ET|B6kuZlN5qs9~Rj zL;Y$dMb98uJ7Z(`)mv3cW#5PIj#w4eP2%g_xf2w--a!CE1$AabE!~yAGlpc_Ler3f zl4;jVtcJA!Y^i#GuTzI!K7gZTwWbrFxlPg@Wc4mD=2%RY)&|(%(sY`cUO6Z8;e<65 z*o2$0-CVlQqa#P_4%TrHYz-`y1JWI$C`BU86Un|;83&~CK4jNKNs?VgXsrus?tR?6 z_M8WIv0eaS*s3;;JmR+s)J^c?K67DX9_^vxkC>}T#enSE#u@DN zVGqn{$_>8YvoU@i6Y7X{y|Nxe+A<2ujn))g1=tl)c`V^j_*LwQK@VLRFGfOBt;r$D z-izY3;?B&Sy$!AhS~&teMD5OnwT7uYtaDW~Lq~BoQiH0`OWpW1#9@Ywr`DPR=VPtU z8FLyDpR2{AWz9P59fAw1Kg>)kd&ms})X$GlmTo8UcU4IjUrS=-Gadm?EKOY1RdI0i zkJTJjD#*A*Mox9`D(q&Ep^fpBFmZ``=21Y6FvxY1(|Lemb25GV0wHkv-Db^L)ViRH z-^>3PbYaD{HQRA|NsE84r;)F*(n%e^lu8f!JjPzN4_#lGb6Kvn;vu&<7b^83~mkx&{Gq)S0*gaPTUFWp0T z4yBZIm$WEdB9cQhbVv(G!wd`zqkwcsH{2J0=XcLJ_uTur|G{VWytDUSd+qf+&suxG zfB9WOLZpD7*4~oEI0kcM`exLUOgJ9fsl&N#Yy23X z0wfEvLC1~`rdeCbhn=h=2A6l&=MhPWTSuAd76su&z@*W2_V#*$0`}rraL=B;h{x2} zp1@5;X|8k^bv`FK62H5V31;F}i&s};MYc1#KK-06iFxrq^ASU%b4GkLsKE zn)jTzjSkP%dmA<$Q(;%oi3y&XcI|}RcDD0fCoCyWT}SQitDI)%`W4yv`y;LR7S-lQ z*uvvDtV1Lj!od%uaer`0-d76lO#p|_p7xo0;XjSr@7TLsG~J(!-Py=p+|9luT2S}i z6%uq2OMVaiF34A28?N(Qb1Vv7`Xl-~_@5^viHm|vwHvM-9yuZHT@3akncIh=_-Vw_ zq$##F?{_YHI4!5m_Gk=xI=7<{GwPATb>y{cp9q2I+^+O){t zEs_iy!8&Qtj&SF0+Z-N9g0^dJu1ABac<{&|;LAt?(xx=@RHYb>r-62vU6X~~n8{`m zIP4RNw8t#&z-Bn=YsuRDUf{M8y=*N3wmdY1a>Q=>lfQ*mSP~tP9 zD85Pzil~UsZC5X*Z~PhP>~Ogfb^8TT@ks?;A%2^93*VZF)N?)k_1rGAN!M*}!z<;Uo03}4n{ z6>FJ@dngfAgzh6qAy26*szZTREU^DgBdz#uQ3!G9dN729Y3;aiaW4tnbF4kqRyZtU zFx!q>PBz)?{9?=Yp#KmY3#<3}+EB~b?%`fJ|1+8=IO5_Jz0AiGmU8BWc<>LRv{C~y z?3&1&^x33)33L(_sLXdR=R@R;oF}K&#+7bimNNHysgX#M8UZW4cr`qdX158;_QCsl zt;Kl(b1CA{)(YOUmIU8&9QLp)OuUr>{$$-&$jwm%U3DUVI}}Gjj=Ezdnoha>&pM~I zU9bD}a~UTlXyu_fW*4jh?U3Cp6EB|1$B@B7w4j1Hz61dtI65R!0yH$ z)sTKsga|I#XvV^jEMl(yYWJ=`9B?xW{pXWgvZJ`TPPZu#{z zPREq`)8C$kP_Vqx#_&uRw^FkgDhV=30!s_RFXr!qSdsX)5q&atSKK0H{B~Ds+*!%llr4}HICph1n9avvJdDw?6D&_Y1=c*Yg7pGjF24O^E(=3ebUubxNP43 zl>E#}Vf7m?#J{_&PIB=DrcFeJ=I}Eu=s5!FgOx=|w(?pEm$CQrd^8Au$zl&@hi#aO zvG6b%SURb&{4XL-cV_QZzndaVSeRLfCX{5dPSM5Siz0J)lFs*Na7q}gi!a}AAs^__ z(c?n!p%Fav0d)j5V|aKnNe%XjIf0X)y|7mpskAWtOZZh))&cVXjx;Xgn@KMC(YFQh z*Yja8XI=MvFmoX*DFx&Pp^EiTz>g#8&i9h=qd{Rb)fk+>qy3kt@vmIQB`Hc23N@=q zq@$n7SdMT{)3m{1P2lATTHwd8Tr^xePfq>pMlyr48N!M0zhZ)~NIEBlweZclhhZ>^ zJ$wlVIx~)q`3NM9s|46RQyv%Oe)FNeY=Yn)#WCp?sq>bX(zwmSlkD&Xj5!Gn>aNvB zsAoJ_M8l*kx11I@i9a%?oy8V;IY}a|)zbjYfE5q+W>PZ0K;BJ~Z=a#a+x&)P>5B4R zWaZ%V%_xwzuBT>42U_$^c+2Zz6*Ho3gxO5oi$7vy{h=+`p6lNq9}Sn*a0+(}Ld3ik0PvKNU}Olo4FmHLPno{q- z+=LOG0Gl9n}=i0Jnnp|I*A5^34&RIm;=_ zt2P7a=W5uI2UQ)oD_l$_S2CYxf^H=oY*hg3EU~s*j?gAuW5T*T(>#)Gc0NTuI#$yD zTE;;GO-+5`u!ZdHZk_3C{_SY06e28ArxN8{rs$Fk+_BO~F0jUJtapwjC+wtQ8ba=f zYc@Yw))U%`M4ihl*a>$BhxnA`yOngPJ%g)cz7RcJVBd^LL+j-EpTLCQY-V#Wny2MN zTRVgzZ#TF0N(S=$M=9t&eTl6sIVflvcPbODjL1?^Xi4aWz^?*wrQ`47q-hO2fu@AE zEfs|SN50y#2uwZQ+1bM57uS$&5s%CwAe&h}94JfMWG(qJB@g-R^^#>_7yq5!!G+OY zslKV7LM(-dsNMKa@WLhXYqXyc6vW}8SJl2>d2unn2g~m{)GiwF$cu<@*}Lh>^;JaZkzLxA6e`7NbFnJR%b-=pMtIUwaX51a`}oxFkpSQ=@)v!Em-!qxj^?eB ztwXi8CDb66z61enEtWC-c3iF~oISj`^nHuVPU~sRT_(310g8KQ= zGkIuy4v!KZEpGa8h0$~*d}Q-WV&`Rm{5_c58eSk5sjqRs`OMyQSI>YBWL}RG2tq}Rg2VcPm=BnSGWdPU45w-}%>+4(ek)lG$!BQ_ zK+RjW)6j9o0?4Q~i{|-S=S1J-3L+1>=%^AInvO3|%HgH82M;@ZS$d*jLnQHz!%&Lp z!ANx3!p-U5C7KMzBXzR1YjTAGi?l$g99)NEEo3ed))drkX!jnLy5HwGm(HXsQKnI4 zk|ok6*={PK8Ox9PfF_(;oIXcHkCUEyn=r==@$-!*OECm!|8+515k8dsJ~efVzx9O1ZA$WpVWftP$oBxot0!&TBZt@ZfITxN>NmkT zov8l;*~wg5rzUsjQZ(#^&6j{giSssUGxTg>-rRJ9D%z}qdO8}W#9O0hR%4c9a1~ZY z*SwIH;6bYe^+6L*F~ECgC4ntU*|0A&o~An9Ja+2blHfjpw6lpyeHYfF>WpvX5PGf( z9T7H#br1K*Cnb!{nEvPYqMt>_(YBrd)@!&|p83iomJ?iRuKOyrD8C+?8E)>}eSC%V zRTo_-lZR4nO#=w6FgM`l2}SrE(;#-%L*m=@<*~BJ=fnKfH;hj9Z(`Ky-X8k~nDGUD zUNdkia)-&_|U0oKet~axpUZP%&w(Pq1uJ@jrJ6~7?66ag~S^C=c_hWr%PM(-NDE_4ut6dztd*?XPO`isC)3w9e zgCRfU*_P^Zzqf8<1buXQ&x?btQ`1h^HEK_?6it z>M`W4pnZ~giq!)kaJ{A^gRJtYKs_UEh|MY7S_oIm);pU&0Gkgp603<$$25tBD zb_ck-E>sXh`ZF8%zL5xa07xs5db){yq#yuu(;jLqQ0)}(XJQRGBxqszNx84^CGyF| zRczIk#~1m;nRDrP8FuAGqvKmsaW=s8PxKmAWAQv9hOn29m5R5!TL$%DILgfkwR@=+ z0b^Pqa5RGdAQ7wG8|DoqmR8ILOu&Exc?oZ$#uwhp201-_=d=h0iE`^#aByoS{@~Pi zm&ex>VOO61ys5eQHG=HO2V!TN>R`c|*VskH*>uI==w^%~fKMalWoR_Ec>71&JEV=| zMZYSp;QE4(r(s)$$!8XInjQ>{2cH`|V5)m;AWMRMVjE&RV%cH+27BJ;f=@+{e<%x& z07nL=+puDfe0f($R%-halzSN~;==j}eAJ;(_$dWlpbFXwhfS6m8T~d69INH^s^;Vm zv%39$^xmWUn(&vD{{`lXJ{24RwmRn7ZUmIFM*a@)bXm(oH>Bo7z)`f(^K+IXJa4|6 zA9sEDr~Qy@$!TWRg00vrXy>9gvmv7otE|X6dQa$~mwI=%En!bPDH$_3(bi}zPO>YR zPW}%=MMFVfcp@j9t-PpI5Q#-~5dOW3DG+cTr7#{o2#Nuo8+h_O5!^#U?e<)ne?vIn z+K;nxg@pOuv6TOy;@S>@K;0VG_!#|b4;}QE8{*{I=zGdEvaG!s?bQ4RbA!E94;Fzt zoQT=DQY_%mu37<};&BS~mfR+r9qdzFx-kh$49obt5Y|0-ht=v;{WbQe1WPdNDt^5q zG?ox{g_coosBEXa$A&<%ROqpq2pR3In+o5(i^&9lY~4Dh&khsl^gB?4)q!tpVKO=7CIP$?D8AUMAX91T{J!jgYBY@9o&wk3x7$PWw)k&zpq$J)NHFt zhazD?{wJ*E3%=|_5}3FcFqdLb*o}{BNe9Mj_~+d58~#$@FjXHFqgafri$>jo)Jc#+{op>} z8MumdU_1$81p7423pnnE4}j9Q*9pCi#}SX6!-<)cT7l(xM2NrH&(;!j3L`~4TXuWA0_bR^Nq$CtPX6coulc3cnG9Owknf_M`HCnHZEiDT zw(o#(xu+)?;ikUlpm==N@WKR-F!?!xe{dP+9`HY|f6V_W4v*=14R#&vv^fG7Rt;Pg z)k%FcgIJvY{-=ba1fmg@kgcIEdfX=b?v_^<2r}HN-d)qjgT-b#Yoihcsr(g3UpH_? zyy-RkPEdPzdmR50PSgJv{_?CfI5o8B{Sv9F60~dV(P0k$9xh;`25SDCR;fmp`LD^p zG++B}*9WSvQs@VkoBA!A9RJquoI1TEq&fLI!Vh5fH(M7&<*wz463qpB^rGGqX#%?c ziJwKnGQJ>6wd2vH7Y{FW-G4vywB0{tlkhI&rrn?emPZikBfXF~Fz*8QRI!|&_dI_Y z1LBC=@*J+{_@gt9>3;bW$fGvsj(on1g>lZG*w?tpc8OB;$3rwK-menAdBumFe2g+BbMOrDEgq!eUA@}8Z2R1bmxp{ z3bR0&H7xeIlN`SQPyV(WxZma{#Fgg=CI4&k(+;lTg*>q!rL(aYnB0kf7dv$mrquTlWpslXbXXQc7&J8uTmd5Gr9(Xp!K zOp$j8E?!6)9jf@VdBUWc^(}qqMfUR@=WAtRr9QR-N{=7`e3h}2nJ9bOfx%F-ffN2= zM&d~vxSVF9q~V9JRF2bG#n4Vr)!k}3L**|9?(Ch80_^LK?jyuC>vaM4OH)GdR>K?n zauj9$r|*+%eYsYo^4%z!ECNkrN-l|?HZFhMq%0c?4nZDx{`xiGvCtgF1=+vfWdqgo zy81r(_IkTg>`e_=Ph2XNHu}ZF@*KdW7UQd2ohHYe9OqZdm1|q5{*h1J@Bo~_tLJ@G zn*G*nPoik)-f+L(I)C+M2LCcU%T^0z4f)rbQuAJ9VLG_HYPM>W<3>t1(Rf!`p#?_^ zI_EN;Pk{Z{F+9nM<$c3YG5a*Ntl;i5bLR`)!QyyR#;*&$&W=nyuo=h%ykhhv-5?Q_v+Uak_4- zb19UEc|1wbD>w6i0hffno|(*&P1A&+$awoHtjFS5#>9=_O$`4bWJ}DUy15$w;>YS( z8`!k}30R2=sXta_F@-0&71);_y$5+qo)AsTSD$l2P})!oa5h7?TRhAhK>IrE`c;Ti zwDENz;qCuAke%(0{OVgl0gn(n{t@efJa|I}n@y(1rb^||mWfq9@+`9?puVr6zhV(( z#YvSbgNl!x8a>^;Vl*tVAj8;=|Kx%WVRp%^<{n`8wo?}>e50_^7K1lR> zdsuiBdK(HN9Ai>3%m&^A^qe!e!3F(gl@nZ8;ihP3oKgB71Z560nw<8O*|H6gU$8Ut zFGtmZwO2o07I0BSEpR!TmeTnx37O~}9_YH565A+Z@;3O}#4gUT0i|5!L2OTJyaG4! zj(|cO*8}qf7nK7L;^c=Hwqce=@CECH#I9g8bQ{-14m!yYQ?waU1u2%9t{X zo*tz9laM+2eXP8y_M1vm1>53?CMlZm4-fseW9&RX)(HRmh2VATo1>||}~sP~=rm?&$>`Ms8$!Vy4JN!!eS_YnxVWl|8l zwz66(bVy#>y7%%3j37Qu$-DV@2a7`gP3V>ZLT8N!_(^{+Rpn0C1=?lztK(K$$;&C* zR%rtKAH(_**BcUm`Zg(()GFmXoGDZgb%)DnfMLW%!}>-5%=m&@M2XYUxX7h-htNB$ z>nuihv-noAMqW`}$`DS|Mc!dM5)FDU0GI_Bf{iQ`Bv7Gs00mt&UYCnwPIvd)g6f-e zi2ewkw&>1G3zT%;Mg`p|rcuO=P}EPlt`w&cst78^XGSpv6#(82g+nQAe;DN3{xHoq zVa1Y(EIs#(-yD#*;k^To;Y~IpKB$kzb@GBQ7{h!2^%c?`Y)&@1ubWhvGu(UI6)gH= zIx~E$Z-EwKxXrdBTX#ATGyG2HoKpl=G)9d8UvKRy)NaFb*tx8sLn)rCN&}CaQ!_%Y z=lGjCFt9PnN`n*y@9-D_A-y{gLkx^d5^6D$X$H-}pvfaEFLLY>sA}M4S#mFiYb`1LI(T+ivQs0GOZLxjF1Wcc*{_WHI?zY*rhXY4>Wurw=L&QUY?LevwMo zW&m5lh$Ux-fSe`v8%{Vjh5axRfHZfKG!JRB)W}<$xm(%^mH|9K+&uvXJjnrHijDp) zNpN!wpPw-axY(rqT(=7Ef3Oh$_dA7PWiO8?5w3N}olU$*09P@AVhDk$`*l_Hj$`#p z&haS91FzlZaW1Lv@V*p)*Y~gqNFnloGW~BG0`0>@tUNdJ_yCAIT(L`yZ*Ra{Xvf%> z>1=@K9-;*_t^EXae8+MGf9P$#K)Mv_rYaj)dbwdh)`bHyIxATNAP_ZR8U0PWKweH) zGXSa2FzSzMz!YdFS&nqtj92vHgxzX6 zK#>2t+5caad}0~ErX)2iIFK+q$EST)qeIPU8-WR!XyNWH=kUBsf6TO{C=5K|jts;Q^( zT4!h0Jt%E@FgR@v!EI2qOKVuLr|&uoWn(K3*m(9Xk8M-HNw$=mD_Um?hXm$=$|%34 zn>?re_%U^>Q3DaRm|!wD;v_4;FFy>%rz7sFuTo>%s{^BtE-qg6!>Qf5)j?y48gt%M~8?hqKU zWQYA1B}_c@MFTCbl%PzpRNyD=qOI2kjXKD5jy>!BqFv%?FQWEWZiy(etY%1DxzCeeFJ`~4M}SQeDvEccI5XavRn8H%w3;B8I+FSuyJ4#%%9;pZ|b zt=gjC#>@G`*w0gw@IHo=5{HM^iiy@_R|Q$U@;L3|#u~$H!w#}aj)uizrS9zx%k`3P zp$RFOVs-vc!x$km#7l2KuepnOFoa=_K@L zqkAsg!(K$)lUT8zn;W-M|DjI#ox`y>jk+=VTtRHTy144tAqchWbq|=wBIPh6R<*HW z-SoQ@NqXihU_gC<_uixJ+OOPXdK$Ag)5?GkqMi~1;*-~?2llvlz|-3;D`X>eaWSUJELhqfvuV!%HI#*OM1*p~)QUFwrqGrOG3E5c_M zW09XA$a}w_TTab#1G+lL#5LqQ1lXp6FMzoDto1G^vA};6V}!kHvZ;UwJT)&cMzpA^ zM`B0gUD9pEzI}_+I%Ynilm3)Pa{F7mBg^liC!8)c#s*+-Yq-1xsRGsZMQ^c#9a9Z< z8q=mj)y}K$Yr%7KY*WY$=i&I8E6Er69KAB{kdhuvTkvp=7-YY-4{IzD1Tu?WbL?om zVw8btgo}_u=FA_;m}>SK7TEO+ft+#6>T3MGf@)U1!?0suP4M2swlugtajR-AT6dLOZq?c%+U-139| zesyl{OtY(*>Z7K%)bR9$(-@{t#*tj#b4PppbDC%-xYH^9fz(r^#ZYZx{rACgh$!_`5E%u|V)BdQson{I89qe3{{)9hfrzvO{nd9o%GvDET4cD3vyWy~!^Y_7kCc zekq1U4Xm9z(7_Ey;@AjjV9sM@5)M->WTm$;eslW~S<@u>)O>)cn>WKMRPHrgv`d48sav%$9iG|)t{+n3u2PG?({iR=DvTFzQ)~!H18#2byP3F$oFL6>t zHecpp5-S3Nu$Li|t8q_+LjwGs_ZMh-FrQ6VJ7i$EMb6pkV;fmt6bz&S4M_-et?uA5 zXpk?)uuZFKz2W*P!UMQ6)VP&^c>FAf2?gx6mXIqTGpd!b zRw821ggrt4gC44<#b|vJ&qHuORW2Hjk}z~L^-;`00%;x_?e*fHIH6S858EP&M^2Cb zG$u<|i!k+N>X%?egLnn*P~;sF(}m7`$C$?z$nuDW>3kbiYpS(=&G<}>sfOIzAQle< zWGfX$Ijt02S+bhcvAaSEuJ$RSJsEq(Nq&or`83XfZ1~lrNSRl`F3UQZB z+`fDp2k2W)jDPt7Ajzg$65PfQ^cf*R4a{0nVH$epAh@Hp_7JMzp7kOJG@OuCQl zVK7l*L@V_jambZksP&S$q=dXp2{D6emi!0GW42Vi5p=d9+Vlt`oji;-f@dRC2SE_v zKOn6FbcorK;Cq+hBbY~aoo$%*@BLk+%lfPG&vl2)g~bd&tp+OpCeXE)3czvCsQ(ou zSMKfn6tf$F7;I4qz90;$DDhCsClXe=aa@O9Z3}w|wz#7dSCb&{ZSfq%`oTAMBM$+7 z=!@k@KmN}y%Zfuz$d*xKGwaZ@tRHjbkKq9$!M#atR09!XUr6!c7Wo$9779UqHru50 zTCaEvdH(fx3r!ma##mO$9>(O)kAZD+7_3LjpLC@>KiWJS7PK2TzkXCmP}2Z4>6B9~ z%PbObnX4e`l=Ck*`8f_QlndNF)kLoZ(ra$Y*OECH9LDkk3=WqUSW1*65J{p@3`{S7 z^F2es5}z*N@bBxqWX5kI5X8TZH9# ztf~xK+T=c|r$bR+-jVO&^-+^D)C2FTrH(E4Fq3;ASO)#V!K~RqZ1SCKZ(Y{!&-()B z)@+1Q2E}_;$Hdax)t%9;+8?JO6bm>3LA5T={Ptr(dzqlQHJ_(k{{kccSt#7%OQ7tX zvN6CUX4A@^UBcgsaIz0-pGNUwSQ`!9gKAZW5Bl$1CDBz?cDFLe%i+Oa;?lQ~V1(jr z{Ml9M?Lz7rYpr&y4pn}6rrB{e%)Fj;ZqOwS&*`Wzi;lGWRHu0 zh(nc0w|<&egudppBv^9T{Lx%F>u5MZ3RX}m@E_1sfxDFo&X&SFWxo4Egql7CCF4B;(aBUFlV&?n>si2V~y8lh&NGVfMAEz7kikQq457XJPh znI3mE8!mG+*I_n^&M6`FukyRe#fs?hPpxS_q-egI+wKmc_n;5>K-tf5rbh_UDujj^ zU@O#jY=s-%dyiA87#v32 zN$W)>SFO%~HeW}xZC~|z%Kg?s^;6cZZTUHoCfzx}5XG@cjc*vTdT>sX$9?qv7Ksn1;v{c3ISDCP3Bu=?(pi@L00o>obMRC>YqEFy$}pceGdPI z;*|f2_Zaq>H>jQxIMp(dK(cVpjwy>kU-<@6j4A z`4Da`RrimW4qyu->5`x>&n~MIcZ0!vp(7-Yfq(OMgSG&Db$JG-5d|v1?oC%h0K*}Y&jqj=Y#6UEgW?`xx|xVQuK0Wa(VH! zK%K1dT`T}NBhZ*Hy-P@?WG)q!cRh7`m+aF0jX!kAnZO$EK{PHnp)jF8VP|8O8el?? zkV*!+^Y%{si2;)3dkkHXw?A&r*KW&hwdr5;sPBocAVJbC6t?<2UQfPM6^zMyh6s0$ zNc>Ip+^HlwfYM-}^+n`|E#~s0zM#$9x7P%0-s9$CFUPy(6;JO4kOYth9GbZ_NuAD= zY|X5^`?t*a1}L|?w<|$&N?}-Xy*w6J#EN-!O6VOc9@9B?&k%{L#JBFg?&lkC2<0wN zFL2!yr`js7y}h`64WQfb;J-mpl@KX48zy$6eVevbm3WP~QAdsxAgX__Zsgd#hGW`^ zkVsErCag)KW$0ziWb-_rXhfsY^arCErc6U7@}q7vF>3l zHy70PTYY24T(=ih`|3Uffn4Gwu4eOjxM^ciVq}qVcIS&@`EB4p(RhFH?F=64HlNYn z&$q7;wdz9afyFVWa)(@Ib9OeJaOsNnz2W^}AO_yi5g_{0mHd{ZV+BJql)7ZTg)a@{ z`hPH)&yzx&Slf0xU!5aU^1PYwTT*?7A*o{rHt!UM8z15&E}tt5ZiXGGz<55};sI4r ze^(t~W@@2mRx&5~`g4ao(NLE3U><43*9|jNc{ew!441aXcU6a$$4t9+9q+tdvgA^H<`~AF>;ysA0F5I3jjF%@*0ySH?`s zJwqxVxhC52)4rcozasmp?7{u-rlB!aN_bvAzI~aUL?Qv%TQfjS5#UUl0swdk5yMre zKWD`~q!(u)33K*Q=32pJB%N|yg4ekvrz)0Y+>FrX17&~Df%bZqBV8g*moc3vh9}t% zaS*l#Zz`AH><FJPT$vj443iQO(iI(VpszDHTGJQDJG@Ma@f0rV=H_=9kN3+JUZaeWbqv@aWQ zcq`Pf&g)Cda)L;}n;bb{>PTw5es7^KyF-)00*U)~m@1qPZYEfBwbJN=CIvSNr30ms zGkGDoj;4zRyIJB2c|WSga?UmaVxjV;3sOs+{m{%0@D=vBguz;sVCh4tCW^z1ZM}o zzVhGxocu;~9!8x1!9SY_&+5&vce}=e*{a4G!W=7H?}wJ8bdQQc&|{f(l45RG*}`tO zO0XK|Vj#6u==-zRM_0@5J_Wf8qV4Z01ETSLcA2W9YikxSFSTC$bhO#S8R`lWTc z_|_I8oOm+qwa)=GlfT*U_ZmeB8S7}-U4^4+9jXglpWI%RBNEbc64L|-X##3-k{Iap?KOYj@ zur`e}>ka&#bo8-r```Q7ooUq|9w0ocwD+3A11K(Evkebud)4{rE=kv2E3?_L z;U4e{yESOCKpwZ59E@&}<8(0GSWJtfso*jRlx|fRNGSjFbr1J1qA}DFEQzN(!(@qO zU{DiqMiz))o$(!`PJ=Yi0jFUXza(x-s)EiEZ>RnLOca;FV5-HR#*_tIQ5A-QdvU5B zi}mSG8}~%>fg`7DU~~eFp9U)MlKc32$NNglsL2pI7~tQx0-hDxDeAo*Y4He5^%vkKbmA||S{V5`aDkPGDK(Y%%s)OqYRQ|}aSB^MW?1?0{>1GWSN2~@gy2zq?I zx2LgSR%wkl0s)uDrxBDF$3%%+xN4V{=&eBT6M8L?Q#!q67M=HJm9DqHzSz}M`(EzK z2#d3NS65XEoLzrIHMyT&U(i1QzuB)nnt>S3)TD!bf1VkjxUTf=8oyR{yoAGHHgz{9ee;tyMhkoOHuDJqxeb!gE*RI=LRCU(|Ne3&-fE)yHDUkb$ADB}UZ3^v4u30iH_tX=%bc7dItJEa z_=vU!Nm8D7r}ncb;qo2&TTQWXKCm6ltzuEhCMAn#wIsU=V-pmRV!nCjG%0%i^Z8CU z6bemH&A;?~%Hn1~G5y46q_p&~Pf3I8AfquKh!KcJ7kwfck@82e?@|2a48&@=+U{#J zr7@qhb>~x2o4p%RGuX;d4i{6D=`EJabz<)(y-p{NT5kM9+QX8xIT?) zB_&?~N@WK&-8AEAC@C~!vnJq&E=B^q$WU|=D)m#jz&gLXqob8WnN@dyx}Q;lAM(C_ z@u3k^%ejW1II1~}CM2fCVx-8#?5Oig@o4FsJ4OBJ)Vn-cgMl&8m8}6_7tvlvwN|mw zi*!*C-t4Rz>5N=OY>qTaXy6cv<-f)LeLg7*(C|&InvNT3L`h?34 zAua=<%ap2-*Tx_cEKEoz{*@}VavIUucF2`;`q6<8b+y}4UX}ZpcYlcvj^DJXa#fu^ z`)wtDPzV0Cy81N>;j$%foJim~J0}xlmHTavIe{!Ht)Ind;dB?p4X?yQ8rn}D?IO(Z zQsT3`*8QgGyFWXYHQp%lbv8`s@8;B?k%M(aeh=oin}Gv;%}-;uc9hYY;(Jn(T7CN_}&&RNLwghZDX61$=Bg3RGhyO48pNnhr6&{=D)@M$%< zT~W|zj()k?z|C?SyTJHhhcYg3EF#X1UBBW`v$aHX0PVZA<2ZMqyP0zuRAC5*UF50* zpnT@5C1+fMAzZXxom{~7{&^4mEmVS?y|~1+<2>hvKjm>{&|%izk)3D-ncYG4#*}w( z?inv5PM;O81jplk%lrCd3yO-ZetRU&Z|vtA^f}91CsX)XWb68!bsU;=WxaQr%~}y~ zn9B_L<3is11U}SV!J>?~`*H1F->2!x{*>1J@&vv9ZTgmzd(~%~CZbiXm~y9(QtR~( zwlx-*&m|qK21R9cAa#v^xoASHJQYMVI%gn80l)97Y!_RRiF8n??6QtYmmI%2OxtMR z%D>uwMetPUtqL?wLDaKR$?h`#8?TS#>zst^fc=FH_cRZ~!$$d=0d>H)-mI^TP&CK zvKb^!XY=A`x`~st`euQ9C@KHl1Pz=;hZjgMm^e zG8`j}ViOiieM`=ToUt(iDh1_60;LhLsAb}V6K>l z@*?e@f#X?JA}(4g90^mNVV?$-mJ6K?Yk*S;-5}$r66yy<&hh+^B^9cbb=)_*uk!*4>|E13*pj2gXu`F zQ8}lB*JVv5L8{1<9xmG){A*1JrgIl)GX||9N#viTKpK+s{QXgI%zoBdAc;M7hc%CAXx$Ct9kwpirt zc3hb=1tcN%@PmS^#m-U(ro!#6%#aQW%Zx()mdU^~oPF9OS%mPE8=HwSCu@NdXl@ z!uFyYI`S*Q^Ll)prfD^|KQ+Z(ZoNe~!%U zgLB3G4E6--w}>#pE_&WaAFFy@({^{>TJLK`M?@%%i-UP5#G$`?`U(AA!6NocQxkzJ zFZ&wu-uWeUUY|x%KfXVzR^^=z=k(gEnV+qnsmHoiiQ@oWAIozNsTlpC?VZ2>YvUtP zBWrjEirn9>h?5vnlGu3zAB*EFi@Ir9dA3+z5pY=8= zmsz52w=v<)U3(nCoy)5wPYeNl1P2@x`ZD%fTR}mN$xiNyUIsaa(SkB{KgO)5-tf!mnq%j zPQ!6=8Zit+ejMLE;-AZ$2K=LldGk|yb$dL-e-76A^%O5?^9 zRRGlwcYnT;ee5g;$S}a)9~c3i`?wF8K5MtO6AYZ80cf)6e^gQaW9bYOD7R~?M3Mnd z3Qw92E(&e<@ax0JUc*K6wIgpzwZBi(!pwYx{3lXbixl!MVzkEAD`ODaog z_nD17Rr|-1V1;l<9}rUkN$0L#wD+hU89vJVrync17Tm*5o+LEQ@WZ=ZS}l_G@8@s+ zU3^^7(ZFU?V;ek27^ue)*VCYV_he#HLVmJ(?Z=2>)u=l;1bE_x?|%%gh4=2TCD01> w^iHw{`t5@MuQrN;k$06oxQvC=hqoBfBBRtTs(e?#OBn7c$*IX!N}GrNFJ&PHBme*a literal 0 HcmV?d00001 diff --git a/docs/users/integration-jetbrains.md b/docs/users/integration-jetbrains.md index 9584cd097..f4d6cfa02 100644 --- a/docs/users/integration-jetbrains.md +++ b/docs/users/integration-jetbrains.md @@ -19,7 +19,7 @@ 1. Install Qwen Code CLI: ```bash - npm install -g qwen-code + npm install -g @qwen-code/qwen-code ``` 2. Open your JetBrains IDE and navigate to AI Chat tool window. @@ -31,7 +31,7 @@ "agent_servers": { "qwen": { "command": "/path/to/qwen", - "args": ["--experimental-acp"], + "args": ["--acp"], "env": {} } } @@ -40,6 +40,8 @@ 4. The Qwen Code agent should now be available in the AI Assistant panel +![Qwen Code in JetBrains AI Chat](./images/jetbrains-acp.png) + ## Troubleshooting ### Agent not appearing From a8eb858f99da91f5320be8d90fc6d50a8407637a Mon Sep 17 00:00:00 2001 From: xwj02155382 Date: Tue, 13 Jan 2026 10:14:55 +0800 Subject: [PATCH 136/142] refactor: rename defaultHeaders to customHeaders - Rename defaultHeaders field to customHeaders in ContentGeneratorConfig - Update MODEL_GENERATION_CONFIG_FIELDS constant - Update ModelGenerationConfig type definition - Align naming with documentation and usage across the codebase --- packages/core/src/core/contentGenerator.ts | 2 +- packages/core/src/models/constants.ts | 2 +- packages/core/src/models/types.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index d229b707f..476776cb6 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -92,7 +92,7 @@ export type ContentGeneratorConfig = { // Schema compliance mode for tool definitions schemaCompliance?: 'auto' | 'openapi_30'; // Custom HTTP headers to be sent with requests - defaultHeaders?: Record; + customHeaders?: Record; }; // Keep the public ContentGeneratorConfigSources API, but reuse the generic diff --git a/packages/core/src/models/constants.ts b/packages/core/src/models/constants.ts index 2c550a5d6..fcb1be985 100644 --- a/packages/core/src/models/constants.ts +++ b/packages/core/src/models/constants.ts @@ -25,7 +25,7 @@ export const MODEL_GENERATION_CONFIG_FIELDS = [ 'disableCacheControl', 'schemaCompliance', 'reasoning', - 'defaultHeaders', + 'customHeaders', ] as const satisfies ReadonlyArray; /** diff --git a/packages/core/src/models/types.ts b/packages/core/src/models/types.ts index d429bf563..c8360e158 100644 --- a/packages/core/src/models/types.ts +++ b/packages/core/src/models/types.ts @@ -31,7 +31,7 @@ export type ModelGenerationConfig = Pick< | 'disableCacheControl' | 'schemaCompliance' | 'reasoning' - | 'defaultHeaders' + | 'customHeaders' >; /** From 8111511a89031c4792527ce2f377d7e1b82e02b2 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 13 Jan 2026 10:19:43 +0800 Subject: [PATCH 137/142] chore(vscode-ide-companion): add comments under window --- packages/vscode-ide-companion/src/extension.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index 4668969d6..be0f669e6 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -330,6 +330,7 @@ export async function activate(context: vscode.ExtensionContext) { // Use system Node via cmd.exe; avoid PowerShell parsing issues const quoteCmd = (s: string) => `"${s.replace(/"/g, '""')}"`; const cliQuoted = quoteCmd(cliEntry); + // TODO: @yiliang114, temporarily run through node, and later hope to decouple from the local node qwenCmd = `node ${cliQuoted}`; terminalOptions.shellPath = process.env.ComSpec; } else { From c0c94bd4fcb38d75b2077c8060f6a6cb848c0f35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E4=BC=9F=E5=85=89?= Date: Tue, 13 Jan 2026 10:39:32 +0800 Subject: [PATCH 138/142] feat: Customizing the sandbox environment --- docs/developers/tools/_meta.ts | 1 + docs/developers/tools/sandbox.md | 13 +++++++++++++ docs/users/features/sandbox.md | 9 --------- docs/users/quickstart.md | 2 +- packages/cli/src/utils/sandbox.ts | 6 +++--- 5 files changed, 18 insertions(+), 13 deletions(-) create mode 100644 docs/developers/tools/sandbox.md diff --git a/docs/developers/tools/_meta.ts b/docs/developers/tools/_meta.ts index 9964b9976..7d4f494b8 100644 --- a/docs/developers/tools/_meta.ts +++ b/docs/developers/tools/_meta.ts @@ -10,4 +10,5 @@ export default { 'web-search': 'Web Search', memory: 'Memory', 'mcp-server': 'MCP Servers', + sandbox: 'Sandboxing', }; diff --git a/docs/developers/tools/sandbox.md b/docs/developers/tools/sandbox.md new file mode 100644 index 000000000..b55964bca --- /dev/null +++ b/docs/developers/tools/sandbox.md @@ -0,0 +1,13 @@ +## Customizing the sandbox environment (Docker/Podman) + +If you need extra tools inside the container (e.g., `git`, `python`, `rg`), create a custom Dockerfile: + +1. `cd packages/cli` +2. `npm link` +3. `which qwen` +4. `cd your-project` + +- Path: `.qwen/sandbox.Dockerfile` +- Then run with: `BUILD_SANDBOX=1 qwen -s ...` + +This builds a project-specific image based on the default sandbox image. diff --git a/docs/users/features/sandbox.md b/docs/users/features/sandbox.md index 66ea359cc..23ea89fe7 100644 --- a/docs/users/features/sandbox.md +++ b/docs/users/features/sandbox.md @@ -166,15 +166,6 @@ export SANDBOX_SET_UID_GID=true # Force host UID/GID export SANDBOX_SET_UID_GID=false # Disable UID/GID mapping ``` -## Customizing the sandbox environment (Docker/Podman) - -If you need extra tools inside the container (e.g., `git`, `python`, `rg`), create a custom Dockerfile: - -- Path: `.qwen/sandbox.Dockerfile` -- Then run with: `BUILD_SANDBOX=1 qwen -s ...` - -This builds a project-specific image based on the default sandbox image. - ## Troubleshooting ### Common issues diff --git a/docs/users/quickstart.md b/docs/users/quickstart.md index 1fa249abc..eac8f9474 100644 --- a/docs/users/quickstart.md +++ b/docs/users/quickstart.md @@ -159,7 +159,7 @@ Qwen Code will: ### Test out other common workflows -There are a number of ways to work with Claude: +There are a number of ways to work with Qwen Code: **Refactor code** diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index ee518f438..a35985b04 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -360,10 +360,10 @@ export async function start_sandbox( // // note this can only be done with binary linked from gemini-cli repo if (process.env['BUILD_SANDBOX']) { - if (!gcPath.includes('gemini-cli/packages/')) { + if (!gcPath.includes('qwen-code/packages/')) { throw new FatalSandboxError( - 'Cannot build sandbox using installed gemini binary; ' + - 'run `npm link ./packages/cli` under gemini-cli repo to switch to linked binary.', + 'Cannot build sandbox using installed qwencode binary; ' + + 'run `npm link ./packages/cli` under qwencode-cli repo to switch to linked binary.', ); } else { console.error('building sandbox ...'); From 85473210e5f3ea1618cfb5d8b198d7c8d698c64a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E4=BC=9F=E5=85=89?= Date: Tue, 13 Jan 2026 10:47:08 +0800 Subject: [PATCH 139/142] feat: Customizing the sandbox environment --- packages/cli/src/utils/sandbox.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index a35985b04..749d7193e 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -362,8 +362,8 @@ export async function start_sandbox( if (process.env['BUILD_SANDBOX']) { if (!gcPath.includes('qwen-code/packages/')) { throw new FatalSandboxError( - 'Cannot build sandbox using installed qwencode binary; ' + - 'run `npm link ./packages/cli` under qwencode-cli repo to switch to linked binary.', + 'Cannot build sandbox using installed Qwen Code binary; ' + + 'run `npm link ./packages/cli` under QwenCode-cli repo to switch to linked binary.', ); } else { console.error('building sandbox ...'); From 5cfc9f4686cdf3ac9c0a6893f14a1bf071dc33fa Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 13 Jan 2026 16:51:36 +0800 Subject: [PATCH 140/142] Update skill manager and package dependencies Co-authored-by: Qwen-Coder --- package-lock.json | 7 +---- packages/core/package.json | 3 ++- packages/core/src/skills/skill-manager.ts | 33 +++++++++++++++-------- 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0ed7071f6..16a56593b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6216,10 +6216,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -13882,10 +13879,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">= 14.18.0" }, @@ -17974,6 +17968,7 @@ "ajv-formats": "^3.0.0", "async-mutex": "^0.5.0", "chardet": "^2.1.0", + "chokidar": "^4.0.3", "diff": "^7.0.0", "dotenv": "^17.1.0", "fast-levenshtein": "^2.0.6", diff --git a/packages/core/package.json b/packages/core/package.json index e7baa13b2..0408c94dc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -27,7 +27,6 @@ "@google/genai": "1.30.0", "@modelcontextprotocol/sdk": "^1.25.1", "@opentelemetry/api": "^1.9.0", - "async-mutex": "^0.5.0", "@opentelemetry/exporter-logs-otlp-grpc": "^0.203.0", "@opentelemetry/exporter-logs-otlp-http": "^0.203.0", "@opentelemetry/exporter-metrics-otlp-grpc": "^0.203.0", @@ -40,7 +39,9 @@ "@xterm/headless": "5.5.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.0", + "async-mutex": "^0.5.0", "chardet": "^2.1.0", + "chokidar": "^4.0.3", "diff": "^7.0.0", "dotenv": "^17.1.0", "fast-levenshtein": "^2.0.6", diff --git a/packages/core/src/skills/skill-manager.ts b/packages/core/src/skills/skill-manager.ts index 6d4b3d15e..a72205150 100644 --- a/packages/core/src/skills/skill-manager.ts +++ b/packages/core/src/skills/skill-manager.ts @@ -8,6 +8,7 @@ import * as fs from 'fs/promises'; import * as fsSync from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { watch as watchFs, type FSWatcher } from 'chokidar'; import { parse as parseYaml } from '../utils/yaml-parser.js'; import type { SkillConfig, @@ -30,7 +31,7 @@ export class SkillManager { private skillsCache: Map | null = null; private readonly changeListeners: Set<() => void> = new Set(); private parseErrors: Map = new Map(); - private readonly watchers: Map = new Map(); + private readonly watchers: Map = new Map(); private watchStarted = false; private refreshTimer: NodeJS.Timeout | null = null; @@ -243,7 +244,9 @@ export class SkillManager { */ stopWatching(): void { for (const watcher of this.watchers.values()) { - watcher.close(); + void watcher.close().catch((error) => { + console.warn('Failed to close skills watcher:', error); + }); } this.watchers.clear(); this.watchStarted = false; @@ -484,8 +487,6 @@ export class SkillManager { private updateWatchersFromCache(): void { const desiredPaths = new Set(); - const recursiveSupported = - process.platform === 'darwin' || process.platform === 'win32'; for (const level of ['project', 'user'] as const) { const baseDir = this.getSkillsBaseDir(level); @@ -508,7 +509,15 @@ export class SkillManager { for (const existingPath of this.watchers.keys()) { if (!desiredPaths.has(existingPath)) { - this.watchers.get(existingPath)?.close(); + void this.watchers + .get(existingPath) + ?.close() + .catch((error) => { + console.warn( + `Failed to close skills watcher for ${existingPath}:`, + error, + ); + }); this.watchers.delete(existingPath); } } @@ -519,13 +528,15 @@ export class SkillManager { } try { - const watcher = fsSync.watch( - watchPath, - { recursive: recursiveSupported }, - () => { + const watcher = watchFs(watchPath, { + ignoreInitial: true, + }) + .on('all', () => { this.scheduleRefresh(); - }, - ); + }) + .on('error', (error) => { + console.warn(`Skills watcher error for ${watchPath}:`, error); + }); this.watchers.set(watchPath, watcher); } catch (error) { console.warn( From e4dee3a2b2f3652969fcc90b5d9feb697c689f2b Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 13 Jan 2026 17:30:54 +0800 Subject: [PATCH 141/142] Implement proper header merging: customHeaders now merge with default headers instead of replacing them in all content generators Co-authored-by: Qwen-Coder --- .../anthropicContentGenerator.test.ts | 27 ++++++++++++++ .../anthropicContentGenerator.ts | 7 +--- .../geminiContentGenerator.test.ts | 35 +++++++++++++++++++ .../geminiContentGenerator.ts | 22 ++++++++---- .../provider/dashscope.test.ts | 21 +++++++++++ .../provider/dashscope.ts | 12 +++---- .../provider/default.test.ts | 20 +++++++++++ .../provider/default.ts | 12 +++---- 8 files changed, 129 insertions(+), 27 deletions(-) diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts index 483edac1a..cef3d0242 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts @@ -10,6 +10,7 @@ import type { GenerateContentParameters, } from '@google/genai'; import { FinishReason, GenerateContentResponse } from '@google/genai'; +import type { ContentGeneratorConfig } from '../contentGenerator.js'; // Mock the request tokenizer module BEFORE importing the class that uses it. const mockTokenizer = { @@ -127,6 +128,32 @@ describe('AnthropicContentGenerator', () => { ); }); + it('merges customHeaders into defaultHeaders (does not replace defaults)', async () => { + const { AnthropicContentGenerator } = await importGenerator(); + void new AnthropicContentGenerator( + { + model: 'claude-test', + apiKey: 'test-key', + baseUrl: 'https://example.invalid', + timeout: 10_000, + maxRetries: 2, + samplingParams: {}, + schemaCompliance: 'auto', + reasoning: { effort: 'medium' }, + customHeaders: { + 'X-Custom': '1', + }, + } as unknown as Record as ContentGeneratorConfig, + mockConfig, + ); + + const headers = (anthropicState.constructorOptions?.['defaultHeaders'] || + {}) as Record; + expect(headers['User-Agent']).toContain('QwenCode/1.2.3'); + expect(headers['anthropic-beta']).toContain('effort-2025-11-24'); + expect(headers['X-Custom']).toBe('1'); + }); + it('adds the effort beta header when reasoning.effort is set', async () => { const { AnthropicContentGenerator } = await importGenerator(); void new AnthropicContentGenerator( diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts index a5d714f3a..281c5d9ae 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts @@ -143,11 +143,6 @@ export class AnthropicContentGenerator implements ContentGenerator { const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; const { customHeaders } = this.contentGeneratorConfig; - // If customHeaders is provided, use it directly; otherwise build default headers - if (customHeaders) { - return customHeaders as Record; - } - const betas: string[] = []; const reasoning = this.contentGeneratorConfig.reasoning; @@ -169,7 +164,7 @@ export class AnthropicContentGenerator implements ContentGenerator { headers['anthropic-beta'] = betas.join(','); } - return headers; + return customHeaders ? { ...headers, ...customHeaders } : headers; } private async buildRequest( diff --git a/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.test.ts b/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.test.ts index 82f3b186e..bdf9bfb99 100644 --- a/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.test.ts +++ b/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.test.ts @@ -39,6 +39,41 @@ describe('GeminiContentGenerator', () => { mockGoogleGenAI = vi.mocked(GoogleGenAI).mock.results[0].value; }); + it('should merge customHeaders into existing httpOptions.headers', async () => { + vi.mocked(GoogleGenAI).mockClear(); + + void new GeminiContentGenerator( + { + apiKey: 'test-api-key', + httpOptions: { + headers: { + 'X-Base': 'base', + 'X-Override': 'base', + }, + }, + }, + { + customHeaders: { + 'X-Custom': 'custom', + 'X-Override': 'custom', + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ); + + expect(vi.mocked(GoogleGenAI)).toHaveBeenCalledTimes(1); + expect(vi.mocked(GoogleGenAI)).toHaveBeenCalledWith({ + apiKey: 'test-api-key', + httpOptions: { + headers: { + 'X-Base': 'base', + 'X-Custom': 'custom', + 'X-Override': 'custom', + }, + }, + }); + }); + it('should call generateContent on the underlying model', async () => { const request = { model: 'gemini-1.5-flash', contents: [] }; const expectedResponse = { responseId: 'test-id' }; diff --git a/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts b/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts index 530c456a4..33819cd7f 100644 --- a/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts +++ b/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts @@ -35,15 +35,23 @@ export class GeminiContentGenerator implements ContentGenerator { }, contentGeneratorConfig?: ContentGeneratorConfig, ) { - // If customHeaders is provided, use it directly; otherwise use options.httpOptions.headers const customHeaders = contentGeneratorConfig?.customHeaders; const finalOptions = customHeaders - ? { - ...options, - httpOptions: { - headers: customHeaders as Record, - }, - } + ? (() => { + const baseHttpOptions = options.httpOptions; + const baseHeaders = baseHttpOptions?.headers ?? {}; + + return { + ...options, + httpOptions: { + ...(baseHttpOptions ?? {}), + headers: { + ...baseHeaders, + ...customHeaders, + }, + }, + }; + })() : options; this.googleGenAI = new GoogleGenAI(finalOptions); diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts index 1dabaf8ab..e7c951fd9 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts @@ -142,6 +142,27 @@ describe('DashScopeOpenAICompatibleProvider', () => { }); }); + it('should merge custom headers with DashScope defaults', () => { + const providerWithCustomHeaders = new DashScopeOpenAICompatibleProvider( + { + ...mockContentGeneratorConfig, + customHeaders: { + 'X-Custom': '1', + 'X-DashScope-CacheControl': 'disable', + }, + } as ContentGeneratorConfig, + mockCliConfig, + ); + + const headers = providerWithCustomHeaders.buildHeaders(); + + expect(headers['User-Agent']).toContain('QwenCode/1.0.0'); + expect(headers['X-DashScope-UserAgent']).toContain('QwenCode/1.0.0'); + expect(headers['X-DashScope-AuthType']).toBe(AuthType.QWEN_OAUTH); + expect(headers['X-Custom']).toBe('1'); + expect(headers['X-DashScope-CacheControl']).toBe('disable'); + }); + it('should handle unknown CLI version', () => { ( mockCliConfig.getCliVersion as MockedFunction< diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts index 176ce6b6d..45b0568a0 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts @@ -48,18 +48,16 @@ export class DashScopeOpenAICompatibleProvider const version = this.cliConfig.getCliVersion() || 'unknown'; const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; const { authType, customHeaders } = this.contentGeneratorConfig; - - // If customHeaders is provided, use it directly; otherwise use default headers - if (customHeaders) { - return customHeaders; - } - - return { + const defaultHeaders = { 'User-Agent': userAgent, 'X-DashScope-CacheControl': 'enable', 'X-DashScope-UserAgent': userAgent, 'X-DashScope-AuthType': authType, }; + + return customHeaders + ? { ...defaultHeaders, ...customHeaders } + : defaultHeaders; } buildClient(): OpenAI { diff --git a/packages/core/src/core/openaiContentGenerator/provider/default.test.ts b/packages/core/src/core/openaiContentGenerator/provider/default.test.ts index 3855d2ccb..23a6887dc 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/default.test.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/default.test.ts @@ -73,6 +73,26 @@ describe('DefaultOpenAICompatibleProvider', () => { }); }); + it('should merge customHeaders with defaults (and allow overrides)', () => { + const providerWithCustomHeaders = new DefaultOpenAICompatibleProvider( + { + ...mockContentGeneratorConfig, + customHeaders: { + 'X-Custom': '1', + 'User-Agent': 'custom-agent', + }, + } as ContentGeneratorConfig, + mockCliConfig, + ); + + const headers = providerWithCustomHeaders.buildHeaders(); + + expect(headers).toEqual({ + 'User-Agent': 'custom-agent', + 'X-Custom': '1', + }); + }); + it('should handle unknown CLI version', () => { ( mockCliConfig.getCliVersion as MockedFunction< diff --git a/packages/core/src/core/openaiContentGenerator/provider/default.ts b/packages/core/src/core/openaiContentGenerator/provider/default.ts index 4cda72feb..6f449badd 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/default.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/default.ts @@ -26,15 +26,13 @@ export class DefaultOpenAICompatibleProvider const version = this.cliConfig.getCliVersion() || 'unknown'; const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; const { customHeaders } = this.contentGeneratorConfig; - - // If customHeaders is provided, use it directly; otherwise use default headers - if (customHeaders) { - return customHeaders; - } - - return { + const defaultHeaders = { 'User-Agent': userAgent, }; + + return customHeaders + ? { ...defaultHeaders, ...customHeaders } + : defaultHeaders; } buildClient(): OpenAI { From f762a62a2e20ac2ec26f4bc795a1bcb0e90d8f71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E4=BC=9F=E5=85=89?= Date: Tue, 13 Jan 2026 18:54:26 +0800 Subject: [PATCH 142/142] feat: Improve the usage documentation --- docs/developers/tools/sandbox.md | 89 +++++++++++++++++++++++++++++--- 1 file changed, 83 insertions(+), 6 deletions(-) diff --git a/docs/developers/tools/sandbox.md b/docs/developers/tools/sandbox.md index b55964bca..92550f164 100644 --- a/docs/developers/tools/sandbox.md +++ b/docs/developers/tools/sandbox.md @@ -1,13 +1,90 @@ ## Customizing the sandbox environment (Docker/Podman) -If you need extra tools inside the container (e.g., `git`, `python`, `rg`), create a custom Dockerfile: +### Currently, the project does not support the use of the BUILD_SANDBOX function after installation through the npm package -1. `cd packages/cli` -2. `npm link` -3. `which qwen` -4. `cd your-project` +1. To build a custom sandbox, you need to access the build scripts (scripts/build_sandbox.js) in the source code repository. +2. These build scripts are not included in the packages released by npm. +3. The code contains hard-coded path checks that explicitly reject build requests from non-source code environments. + +If you need extra tools inside the container (e.g., `git`, `python`, `rg`), create a custom Dockerfile, The specific operation is as follows + +#### 1、Clone qwen code project first, https://github.com/QwenLM/qwen-code.git + +#### 2、Make sure you perform the following operation in the source code repository directory + +```bash +# 1. First, install the dependencies of the project +npm install + +# 2. Build the Qwen Code project +npm run build + +# 3. Verify that the dist directory has been generated +ls -la packages/cli/dist/ + +# 4. Create a global link in the CLI package directory +cd packages/cli +npm link + +# 5. Verification link (it should now point to the source code) +which qwen +# Expected output: /xxx/xxx/.nvm/versions/node/v24.11.1/bin/qwen +# Or similar paths, but it should be a symbolic link + +# 6. For details of the symbolic link, you can see the specific source code path +ls -la $(dirname $(which qwen))/../lib/node_modules/@qwen-code/qwen-code +# It should show that this is a symbolic link pointing to your source code directory + +# 7.Test the version of qwen +qwen -v +# npm link will overwrite the global qwen. To avoid being unable to distinguish the same version number, you can uninstall the global CLI first +``` + +#### 3、Create your sandbox Dockerfile under the root directory of your own project - Path: `.qwen/sandbox.Dockerfile` -- Then run with: `BUILD_SANDBOX=1 qwen -s ...` + +- Official mirror image address:https://github.com/QwenLM/qwen-code/pkgs/container/qwen-code + +```bash +# Based on the official Qwen sandbox image (It is recommended to explicitly specify the version) +FROM ghcr.io/qwenlm/qwen-code:sha-570ec43 +# Add your extra tools here +RUN apt-get update && apt-get install -y \ + git \ + python3 \ + ripgrep +``` + +#### 4、Create the first sandbox image under the root directory of your project + +```bash +GEMINI_SANDBOX=docker BUILD_SANDBOX=1 qwen -s +# Observe whether the sandbox version of the tool you launched is consistent with the version of your custom image. If they are consistent, the startup will be successful +``` This builds a project-specific image based on the default sandbox image. + +#### Remove npm link + +- If you want to restore the official CLI of qwen, please remove the npm link + +```bash +# Method 1: Unlink globally +npm unlink -g @qwen-code/qwen-code + +# Method 2: Remove it in the packages/cli directory +cd packages/cli +npm unlink + +# Verification has been lifted +which qwen +# It should display "qwen not found" + +# Reinstall the global version if necessary +npm install -g @qwen-code/qwen-code + +# Verification Recovery +which qwen +qwen --version +```