From e1ffaec49998a281d3e77aea68531ef04357ca72 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Sun, 23 Nov 2025 19:36:31 +0800 Subject: [PATCH 01/22] feat: create draft framework for cli & sdk --- .vscode/launch.json | 1 - eslint.config.js | 10 +- packages/cli/src/config/config.ts | 50 +- packages/cli/src/gemini.test.tsx | 8 +- packages/cli/src/gemini.tsx | 27 +- .../control/ControlDispatcher.ts | 22 +- .../nonInteractive/control/ControlService.ts | 64 +- .../control/controllers/baseController.ts | 4 +- .../controllers/permissionController.ts | 92 +-- .../control/controllers/systemController.ts | 62 +- .../control/types/serviceAPIs.ts | 24 +- .../io/BaseJsonOutputAdapter.ts | 61 +- .../cli/src/nonInteractive/session.test.ts | 2 +- packages/cli/src/nonInteractive/session.ts | 642 +++++---------- packages/cli/src/nonInteractive/types.ts | 14 +- packages/cli/src/nonInteractiveCli.ts | 58 +- .../subagents/manage/AgentSelectionStep.tsx | 2 +- packages/core/src/config/config.ts | 51 +- packages/core/src/core/coreToolScheduler.ts | 21 +- .../core/src/subagents/subagent-events.ts | 3 +- .../core/src/subagents/subagent-manager.ts | 88 ++- packages/core/src/subagents/types.ts | 3 +- packages/core/src/tools/mcp-tool.ts | 6 +- packages/core/src/tools/shell.ts | 6 +- packages/core/src/tools/tools.ts | 19 +- packages/sdk-typescript/package.json | 68 ++ packages/sdk-typescript/src/index.ts | 66 ++ .../src/mcp/SdkControlServerTransport.ts | 111 +++ .../src/mcp/createSdkMcpServer.ts | 109 +++ packages/sdk-typescript/src/mcp/formatters.ts | 194 +++++ packages/sdk-typescript/src/mcp/tool.ts | 91 +++ packages/sdk-typescript/src/query/Query.ts | 738 ++++++++++++++++++ .../sdk-typescript/src/query/createQuery.ts | 139 ++++ .../src/transport/ProcessTransport.ts | 392 ++++++++++ .../sdk-typescript/src/transport/Transport.ts | 22 + packages/sdk-typescript/src/types/errors.ts | 17 + packages/sdk-typescript/src/types/protocol.ts | 560 +++++++++++++ .../src/types/queryOptionsSchema.ts | 86 ++ packages/sdk-typescript/src/types/types.ts | 57 ++ packages/sdk-typescript/src/utils/Stream.ts | 91 +++ packages/sdk-typescript/src/utils/cliPath.ts | 365 +++++++++ .../sdk-typescript/src/utils/jsonLines.ts | 65 ++ .../test/e2e/abort-and-lifecycle.test.ts | 466 +++++++++++ .../sdk-typescript/test/e2e/control.test.ts | 254 ++++++ .../sdk-typescript/test/e2e/globalSetup.ts | 56 ++ .../test/e2e/mcp-server.test.ts | 610 +++++++++++++++ .../test/e2e/multi-turn.test.ts | 479 ++++++++++++ .../test/e2e/permission-control.test.ts | 676 ++++++++++++++++ .../test/e2e/single-turn.test.ts | 479 ++++++++++++ .../test/unit/ProcessTransport.test.ts | 207 +++++ .../sdk-typescript/test/unit/Query.test.ts | 284 +++++++ .../unit/SdkControlServerTransport.test.ts | 259 ++++++ .../sdk-typescript/test/unit/Stream.test.ts | 255 ++++++ .../sdk-typescript/test/unit/cliPath.test.ts | 668 ++++++++++++++++ .../test/unit/createSdkMcpServer.test.ts | 350 +++++++++ packages/sdk-typescript/tsconfig.json | 41 + packages/sdk-typescript/vitest.config.ts | 40 + vitest.config.ts | 1 + 58 files changed, 8975 insertions(+), 661 deletions(-) create mode 100644 packages/sdk-typescript/package.json create mode 100644 packages/sdk-typescript/src/index.ts create mode 100644 packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts create mode 100644 packages/sdk-typescript/src/mcp/createSdkMcpServer.ts create mode 100644 packages/sdk-typescript/src/mcp/formatters.ts create mode 100644 packages/sdk-typescript/src/mcp/tool.ts create mode 100644 packages/sdk-typescript/src/query/Query.ts create mode 100644 packages/sdk-typescript/src/query/createQuery.ts create mode 100644 packages/sdk-typescript/src/transport/ProcessTransport.ts create mode 100644 packages/sdk-typescript/src/transport/Transport.ts create mode 100644 packages/sdk-typescript/src/types/errors.ts create mode 100644 packages/sdk-typescript/src/types/protocol.ts create mode 100644 packages/sdk-typescript/src/types/queryOptionsSchema.ts create mode 100644 packages/sdk-typescript/src/types/types.ts create mode 100644 packages/sdk-typescript/src/utils/Stream.ts create mode 100644 packages/sdk-typescript/src/utils/cliPath.ts create mode 100644 packages/sdk-typescript/src/utils/jsonLines.ts create mode 100644 packages/sdk-typescript/test/e2e/abort-and-lifecycle.test.ts create mode 100644 packages/sdk-typescript/test/e2e/control.test.ts create mode 100644 packages/sdk-typescript/test/e2e/globalSetup.ts create mode 100644 packages/sdk-typescript/test/e2e/mcp-server.test.ts create mode 100644 packages/sdk-typescript/test/e2e/multi-turn.test.ts create mode 100644 packages/sdk-typescript/test/e2e/permission-control.test.ts create mode 100644 packages/sdk-typescript/test/e2e/single-turn.test.ts create mode 100644 packages/sdk-typescript/test/unit/ProcessTransport.test.ts create mode 100644 packages/sdk-typescript/test/unit/Query.test.ts create mode 100644 packages/sdk-typescript/test/unit/SdkControlServerTransport.test.ts create mode 100644 packages/sdk-typescript/test/unit/Stream.test.ts create mode 100644 packages/sdk-typescript/test/unit/cliPath.test.ts create mode 100644 packages/sdk-typescript/test/unit/createSdkMcpServer.test.ts create mode 100644 packages/sdk-typescript/tsconfig.json create mode 100644 packages/sdk-typescript/vitest.config.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index d98757fb5b..0ae4f1b146 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -79,7 +79,6 @@ "--", "-p", "${input:prompt}", - "-y", "--output-format", "stream-json" ], diff --git a/eslint.config.js b/eslint.config.js index 7b4f502fbc..8a35ef6f12 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -150,7 +150,7 @@ export default tseslint.config( }, }, { - files: ['packages/*/src/**/*.test.{ts,tsx}'], + files: ['packages/*/src/**/*.test.{ts,tsx}', 'packages/**/test/**/*.test.{ts,tsx}'], plugins: { vitest, }, @@ -158,6 +158,14 @@ export default tseslint.config( ...vitest.configs.recommended.rules, 'vitest/expect-expect': 'off', 'vitest/no-commented-out-tests': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], }, }, // extra settings for scripts that we run directly with node diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 3162638fc8..a35ef29339 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -6,6 +6,7 @@ import { ApprovalMode, + AuthType, Config, DEFAULT_QWEN_EMBEDDING_MODEL, DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, @@ -133,6 +134,10 @@ export interface CliArgs { continue: boolean | undefined; /** Resume a specific session by its ID */ resume: string | undefined; + maxSessionTurns: number | undefined; + coreTools: string[] | undefined; + excludeTools: string[] | undefined; + authType: string | undefined; } function normalizeOutputFormat( @@ -411,6 +416,31 @@ export async function parseArguments(settings: Settings): Promise { description: 'Resume a specific session by its ID. Use without an ID to show session picker.', }) + .option('max-session-turns', { + type: 'number', + description: 'Maximum number of session turns', + }) + .option('core-tools', { + type: 'array', + string: true, + description: 'Core tool paths', + coerce: (tools: string[]) => + // Handle comma-separated values + tools.flatMap((tool) => tool.split(',').map((t) => t.trim())), + }) + .option('exclude-tools', { + type: 'array', + string: true, + description: 'Tools to exclude', + coerce: (tools: string[]) => + // Handle comma-separated values + tools.flatMap((tool) => tool.split(',').map((t) => t.trim())), + }) + .option('auth-type', { + type: 'string', + choices: [AuthType.USE_OPENAI, AuthType.QWEN_OAUTH], + description: 'Authentication type', + }) .deprecateOption( 'show-memory-usage', 'Use the "ui.showMemoryUsage" setting in settings.json instead. This flag will be removed in a future version.', @@ -745,8 +775,14 @@ export async function loadCliConfig( interactive = false; } // In non-interactive mode, exclude tools that require a prompt. + // 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[] = []; - if (!interactive && !argv.experimentalAcp) { + if ( + !interactive && + !argv.experimentalAcp && + inputFormat !== InputFormat.STREAM_JSON + ) { switch (approvalMode) { case ApprovalMode.PLAN: case ApprovalMode.DEFAULT: @@ -770,6 +806,7 @@ export async function loadCliConfig( settings, activeExtensions, extraExcludes.length > 0 ? extraExcludes : undefined, + argv.excludeTools, ); const blockedMcpServers: Array<{ name: string; extensionName: string }> = []; @@ -850,7 +887,7 @@ export async function loadCliConfig( debugMode, question, fullContext: argv.allFiles || false, - coreTools: settings.tools?.core || undefined, + coreTools: argv.coreTools || settings.tools?.core || undefined, allowedTools: argv.allowedTools || settings.tools?.allowed || undefined, excludeTools, toolDiscoveryCommand: settings.tools?.discoveryCommand, @@ -883,13 +920,16 @@ export async function loadCliConfig( model: resolvedModel, extensionContextFilePaths, sessionTokenLimit: settings.model?.sessionTokenLimit ?? -1, - maxSessionTurns: settings.model?.maxSessionTurns ?? -1, + maxSessionTurns: + argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1, experimentalZedIntegration: argv.experimentalAcp || false, listExtensions: argv.listExtensions || false, extensions: allExtensions, blockedMcpServers, noBrowser: !!process.env['NO_BROWSER'], - authType: settings.security?.auth?.selectedType, + authType: + (argv.authType as AuthType | undefined) || + settings.security?.auth?.selectedType, inputFormat, outputFormat, includePartialMessages, @@ -997,8 +1037,10 @@ function mergeExcludeTools( settings: Settings, extensions: Extension[], extraExcludes?: string[] | undefined, + cliExcludeTools?: string[] | undefined, ): string[] { const allExcludeTools = new Set([ + ...(cliExcludeTools || []), ...(settings.tools?.exclude || []), ...(extraExcludes || []), ]); diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index a9bc3d9eea..81d34fe1ab 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -272,7 +272,7 @@ describe('gemini.tsx main function', () => { ); vi.mocked(cleanupModule.cleanupCheckpoints).mockResolvedValue(undefined); - vi.mocked(cleanupModule.registerCleanup).mockImplementation(() => {}); + vi.mocked(cleanupModule.registerCleanup).mockImplementation(() => { }); const runExitCleanupMock = vi.mocked(cleanupModule.runExitCleanup); runExitCleanupMock.mockResolvedValue(undefined); vi.spyOn(extensionModule, 'loadExtensions').mockReturnValue([]); @@ -481,6 +481,10 @@ describe('gemini.tsx main function kitty protocol', () => { includePartialMessages: undefined, continue: undefined, resume: undefined, + coreTools: undefined, + excludeTools: undefined, + authType: undefined, + maxSessionTurns: undefined, }); await main(); @@ -494,7 +498,7 @@ describe('validateDnsResolutionOrder', () => { let consoleWarnSpy: ReturnType; beforeEach(() => { - consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); }); afterEach(() => { diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 310ef6b7fe..8210d5d5f0 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -383,7 +383,18 @@ export async function main() { setMaxSizedBoxDebugging(isDebugMode); - const initializationResult = await initializeApp(config, settings); + // Check input format early to determine initialization flow + const inputFormat = + typeof config.getInputFormat === 'function' + ? config.getInputFormat() + : InputFormat.TEXT; + + // For stream-json mode, defer config.initialize() until after the initialize control request + // For other modes, initialize normally + let initializationResult: InitializationResult | undefined; + if (inputFormat !== InputFormat.STREAM_JSON) { + initializationResult = await initializeApp(config, settings); + } if ( settings.merged.security?.auth?.selectedType === @@ -417,19 +428,15 @@ export async function main() { settings, startupWarnings, process.cwd(), - initializationResult, + initializationResult!, ); return; } - await config.initialize(); - - // Check input format BEFORE reading stdin - // In STREAM_JSON mode, stdin should be left for StreamJsonInputReader - const inputFormat = - typeof config.getInputFormat === 'function' - ? config.getInputFormat() - : InputFormat.TEXT; + // For non-stream-json mode, initialize config here + if (inputFormat !== InputFormat.STREAM_JSON) { + await config.initialize(); + } // Only read stdin if NOT in stream-json mode // In stream-json mode, stdin is used for protocol messages (control requests, etc.) diff --git a/packages/cli/src/nonInteractive/control/ControlDispatcher.ts b/packages/cli/src/nonInteractive/control/ControlDispatcher.ts index fa1b0e0f7b..b2165ee96a 100644 --- a/packages/cli/src/nonInteractive/control/ControlDispatcher.ts +++ b/packages/cli/src/nonInteractive/control/ControlDispatcher.ts @@ -26,7 +26,7 @@ import type { IControlContext } from './ControlContext.js'; import type { IPendingRequestRegistry } from './controllers/baseController.js'; import { SystemController } from './controllers/systemController.js'; -// import { PermissionController } from './controllers/permissionController.js'; +import { PermissionController } from './controllers/permissionController.js'; // import { MCPController } from './controllers/mcpController.js'; // import { HookController } from './controllers/hookController.js'; import type { @@ -64,7 +64,7 @@ export class ControlDispatcher implements IPendingRequestRegistry { // Make controllers publicly accessible readonly systemController: SystemController; - // readonly permissionController: PermissionController; + readonly permissionController: PermissionController; // readonly mcpController: MCPController; // readonly hookController: HookController; @@ -83,11 +83,11 @@ export class ControlDispatcher implements IPendingRequestRegistry { this, 'SystemController', ); - // this.permissionController = new PermissionController( - // context, - // this, - // 'PermissionController', - // ); + this.permissionController = new PermissionController( + context, + this, + 'PermissionController', + ); // this.mcpController = new MCPController(context, this, 'MCPController'); // this.hookController = new HookController(context, this, 'HookController'); @@ -230,7 +230,7 @@ export class ControlDispatcher implements IPendingRequestRegistry { // Cleanup controllers (MCP controller will close all clients) this.systemController.cleanup(); - // this.permissionController.cleanup(); + this.permissionController.cleanup(); // this.mcpController.cleanup(); // this.hookController.cleanup(); } @@ -302,9 +302,9 @@ export class ControlDispatcher implements IPendingRequestRegistry { case 'supported_commands': return this.systemController; - // case 'can_use_tool': - // case 'set_permission_mode': - // return this.permissionController; + case 'can_use_tool': + case 'set_permission_mode': + return this.permissionController; // case 'mcp_message': // case 'mcp_server_status': diff --git a/packages/cli/src/nonInteractive/control/ControlService.ts b/packages/cli/src/nonInteractive/control/ControlService.ts index 7193fb6317..671a18530b 100644 --- a/packages/cli/src/nonInteractive/control/ControlService.ts +++ b/packages/cli/src/nonInteractive/control/ControlService.ts @@ -29,7 +29,7 @@ import type { IControlContext } from './ControlContext.js'; import type { ControlDispatcher } from './ControlDispatcher.js'; import type { - // PermissionServiceAPI, + PermissionServiceAPI, SystemServiceAPI, // McpServiceAPI, // HookServiceAPI, @@ -61,43 +61,31 @@ export class ControlService { * Handles tool execution permissions, approval checks, and callbacks. * Delegates to the shared PermissionController instance. */ - // get permission(): PermissionServiceAPI { - // const controller = this.dispatcher.permissionController; - // return { - // /** - // * Check if a tool should be allowed based on current permission settings - // * - // * Evaluates permission mode and tool registry to determine if execution - // * should proceed. Can optionally modify tool arguments based on confirmation details. - // * - // * @param toolRequest - Tool call request information - // * @param confirmationDetails - Optional confirmation details for UI - // * @returns Permission decision with optional updated arguments - // */ - // shouldAllowTool: controller.shouldAllowTool.bind(controller), - // - // /** - // * Build UI suggestions for tool confirmation dialogs - // * - // * Creates actionable permission suggestions based on tool confirmation details. - // * - // * @param confirmationDetails - Tool confirmation details - // * @returns Array of permission suggestions or null - // */ - // buildPermissionSuggestions: - // controller.buildPermissionSuggestions.bind(controller), - // - // /** - // * Get callback for monitoring tool call status updates - // * - // * Returns callback function for integration with CoreToolScheduler. - // * - // * @returns Callback function for tool call updates - // */ - // getToolCallUpdateCallback: - // controller.getToolCallUpdateCallback.bind(controller), - // }; - // } + get permission(): PermissionServiceAPI { + const controller = this.dispatcher.permissionController; + return { + /** + * Build UI suggestions for tool confirmation dialogs + * + * Creates actionable permission suggestions based on tool confirmation details. + * + * @param confirmationDetails - Tool confirmation details + * @returns Array of permission suggestions or null + */ + buildPermissionSuggestions: + controller.buildPermissionSuggestions.bind(controller), + + /** + * Get callback for monitoring tool call status updates + * + * Returns callback function for integration with CoreToolScheduler. + * + * @returns Callback function for tool call updates + */ + getToolCallUpdateCallback: + controller.getToolCallUpdateCallback.bind(controller), + }; + } /** * System Domain API diff --git a/packages/cli/src/nonInteractive/control/controllers/baseController.ts b/packages/cli/src/nonInteractive/control/controllers/baseController.ts index d2e2054547..90b7f56ae9 100644 --- a/packages/cli/src/nonInteractive/control/controllers/baseController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/baseController.ts @@ -174,7 +174,5 @@ export abstract class BaseController { /** * Cleanup resources */ - cleanup(): void { - // Subclasses can override to add cleanup logic - } + cleanup(): void {} } diff --git a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts index f93b448948..08c6d41fed 100644 --- a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts @@ -15,8 +15,10 @@ */ import type { - ToolCallRequestInfo, WaitingToolCall, + ToolExecuteConfirmationDetails, + ToolMcpConfirmationDetails, + ApprovalMode, } from '@qwen-code/qwen-code-core'; import { InputFormat, @@ -206,6 +208,7 @@ export class PermissionController extends BaseController { } this.context.permissionMode = mode; + this.context.config.setApprovalMode(mode as ApprovalMode); if (this.context.debugMode) { console.error( @@ -334,47 +337,6 @@ export class PermissionController extends BaseController { } } - /** - * Check if a tool should be executed based on current permission settings - * - * This is a convenience method for direct tool execution checks without - * going through the control request flow. - */ - async shouldAllowTool( - toolRequest: ToolCallRequestInfo, - confirmationDetails?: unknown, - ): Promise<{ - allowed: boolean; - message?: string; - updatedArgs?: Record; - }> { - // Check permission mode - const modeResult = this.checkPermissionMode(); - if (!modeResult.allowed) { - return { - allowed: false, - message: modeResult.message, - }; - } - - // Check tool registry - const registryResult = this.checkToolRegistry(toolRequest.name); - if (!registryResult.allowed) { - return { - allowed: false, - message: registryResult.message, - }; - } - - // If we have confirmation details, we could potentially modify args - // This is a hook for future enhancement - if (confirmationDetails) { - // Future: handle argument modifications based on confirmation details - } - - return { allowed: true }; - } - /** * Get callback for monitoring tool calls and handling outgoing permission requests * This is passed to executeToolCall to hook into CoreToolScheduler updates @@ -430,17 +392,14 @@ export class PermissionController extends BaseController { toolCall.confirmationDetails, ); - const response = await this.sendControlRequest( - { - subtype: 'can_use_tool', - tool_name: toolCall.request.name, - tool_use_id: toolCall.request.callId, - input: toolCall.request.args, - permission_suggestions: permissionSuggestions, - blocked_path: null, - } as CLIControlPermissionRequest, - 30000, - ); + const response = await this.sendControlRequest({ + subtype: 'can_use_tool', + tool_name: toolCall.request.name, + tool_use_id: toolCall.request.callId, + input: toolCall.request.args, + permission_suggestions: permissionSuggestions, + blocked_path: null, + } as CLIControlPermissionRequest); if (response.subtype !== 'success') { await toolCall.confirmationDetails.onConfirm( @@ -462,8 +421,15 @@ export class PermissionController extends BaseController { ToolConfirmationOutcome.ProceedOnce, ); } else { + // Extract cancel message from response if available + const cancelMessage = + typeof payload['message'] === 'string' + ? payload['message'] + : undefined; + await toolCall.confirmationDetails.onConfirm( ToolConfirmationOutcome.Cancel, + cancelMessage ? { cancelMessage } : undefined, ); } } catch (error) { @@ -473,9 +439,23 @@ export class PermissionController extends BaseController { error, ); } - await toolCall.confirmationDetails.onConfirm( - ToolConfirmationOutcome.Cancel, - ); + // On error, use default cancel message + // Only pass payload for exec and mcp types that support it + const confirmationType = toolCall.confirmationDetails.type; + if (confirmationType === 'exec' || confirmationType === 'mcp') { + const execOrMcpDetails = toolCall.confirmationDetails as + | ToolExecuteConfirmationDetails + | ToolMcpConfirmationDetails; + await execOrMcpDetails.onConfirm( + ToolConfirmationOutcome.Cancel, + undefined, + ); + } else { + // For other types, don't pass payload (backward compatible) + await toolCall.confirmationDetails.onConfirm( + ToolConfirmationOutcome.Cancel, + ); + } } finally { this.pendingOutgoingRequests.delete(toolCall.request.callId); } diff --git a/packages/cli/src/nonInteractive/control/controllers/systemController.ts b/packages/cli/src/nonInteractive/control/controllers/systemController.ts index c3fc651bc9..a33ea16135 100644 --- a/packages/cli/src/nonInteractive/control/controllers/systemController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/systemController.ts @@ -55,12 +55,68 @@ export class SystemController extends BaseController { payload: CLIControlInitializeRequest, ): Promise> { // Register SDK MCP servers if provided - if (payload.sdkMcpServers && Array.isArray(payload.sdkMcpServers)) { - for (const serverName of payload.sdkMcpServers) { + if (payload.sdkMcpServers && typeof payload.sdkMcpServers === 'object') { + for (const serverName of Object.keys(payload.sdkMcpServers)) { this.context.sdkMcpServers.add(serverName); } + + // Add SDK MCP servers to config + try { + this.context.config.addMcpServers(payload.sdkMcpServers); + if (this.context.debugMode) { + console.error( + `[SystemController] Added ${Object.keys(payload.sdkMcpServers).length} SDK MCP servers to config`, + ); + } + } catch (error) { + if (this.context.debugMode) { + console.error( + '[SystemController] Failed to add SDK MCP servers:', + error, + ); + } + } + } + + // Add MCP servers to config if provided + if (payload.mcpServers && typeof payload.mcpServers === 'object') { + try { + this.context.config.addMcpServers(payload.mcpServers); + if (this.context.debugMode) { + console.error( + `[SystemController] Added ${Object.keys(payload.mcpServers).length} MCP servers to config`, + ); + } + } catch (error) { + if (this.context.debugMode) { + console.error('[SystemController] Failed to add MCP servers:', error); + } + } } + // Add session subagents to config if provided + if (payload.agents && Array.isArray(payload.agents)) { + try { + this.context.config.addSessionSubagents(payload.agents); + + if (this.context.debugMode) { + console.error( + `[SystemController] Added ${payload.agents.length} session subagents to config`, + ); + } + } catch (error) { + if (this.context.debugMode) { + console.error( + '[SystemController] Failed to add session subagents:', + error, + ); + } + } + } + + // Set SDK mode to true after handling initialize + this.context.config.setSdkMode(true); + // Build capabilities for response const capabilities = this.buildControlCapabilities(); @@ -86,7 +142,7 @@ export class SystemController extends BaseController { buildControlCapabilities(): Record { const capabilities: Record = { can_handle_can_use_tool: true, - can_handle_hook_callback: true, + can_handle_hook_callback: false, can_set_permission_mode: typeof this.context.config.setApprovalMode === 'function', can_set_model: typeof this.context.config.setModel === 'function', diff --git a/packages/cli/src/nonInteractive/control/types/serviceAPIs.ts b/packages/cli/src/nonInteractive/control/types/serviceAPIs.ts index c83637b7e3..9137d95aaf 100644 --- a/packages/cli/src/nonInteractive/control/types/serviceAPIs.ts +++ b/packages/cli/src/nonInteractive/control/types/serviceAPIs.ts @@ -13,10 +13,7 @@ */ import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import type { - ToolCallRequestInfo, - MCPServerConfig, -} from '@qwen-code/qwen-code-core'; +import type { MCPServerConfig } from '@qwen-code/qwen-code-core'; import type { PermissionSuggestion } from '../../types.js'; /** @@ -26,25 +23,6 @@ import type { PermissionSuggestion } from '../../types.js'; * permission suggestions, and tool call monitoring callbacks. */ export interface PermissionServiceAPI { - /** - * Check if a tool should be allowed based on current permission settings - * - * Evaluates permission mode and tool registry to determine if execution - * should proceed. Can optionally modify tool arguments based on confirmation details. - * - * @param toolRequest - Tool call request information containing name, args, and call ID - * @param confirmationDetails - Optional confirmation details for UI-driven approvals - * @returns Promise resolving to permission decision with optional updated arguments - */ - shouldAllowTool( - toolRequest: ToolCallRequestInfo, - confirmationDetails?: unknown, - ): Promise<{ - allowed: boolean; - message?: string; - updatedArgs?: Record; - }>; - /** * Build UI suggestions for tool confirmation dialogs * diff --git a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts index 3968c5cc1f..551ea9ff39 100644 --- a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts +++ b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts @@ -939,9 +939,25 @@ export abstract class BaseJsonOutputAdapter { this.emitMessageImpl(message); } + /** + * Checks if responseParts contain any functionResponse with an error. + * This handles cancelled responses and other error cases where the error + * is embedded in responseParts rather than the top-level error field. + * @param responseParts - Array of Part objects + * @returns Error message if found, undefined otherwise + */ + private checkResponsePartsForError( + responseParts: Part[] | undefined, + ): string | undefined { + // Use the shared helper function defined at file level + return checkResponsePartsForError(responseParts); + } + /** * Emits a tool result message. * Collects execution denied tool calls for inclusion in result messages. + * Handles both explicit errors (response.error) and errors embedded in + * responseParts (e.g., cancelled responses). * @param request - Tool call request info * @param response - Tool call response info * @param parentToolUseId - Parent tool use ID (null for main agent) @@ -951,6 +967,14 @@ export abstract class BaseJsonOutputAdapter { response: ToolCallResponseInfo, parentToolUseId: string | null = null, ): void { + // Check for errors in responseParts (e.g., cancelled responses) + const responsePartsError = this.checkResponsePartsForError( + response.responseParts, + ); + + // Determine if this is an error response + const hasError = Boolean(response.error) || Boolean(responsePartsError); + // Track permission denials (execution denied errors) if ( response.error && @@ -967,7 +991,7 @@ export abstract class BaseJsonOutputAdapter { const block: ToolResultBlock = { type: 'tool_result', tool_use_id: request.callId, - is_error: Boolean(response.error), + is_error: hasError, }; const content = toolResultContent(response); if (content !== undefined) { @@ -1173,11 +1197,41 @@ export function partsToString(parts: Part[]): string { .join(''); } +/** + * Checks if responseParts contain any functionResponse with an error. + * Helper function for extracting error messages from responseParts. + * @param responseParts - Array of Part objects + * @returns Error message if found, undefined otherwise + */ +function checkResponsePartsForError( + responseParts: Part[] | undefined, +): string | undefined { + if (!responseParts || responseParts.length === 0) { + return undefined; + } + + for (const part of responseParts) { + if ( + 'functionResponse' in part && + part.functionResponse?.response && + typeof part.functionResponse.response === 'object' && + 'error' in part.functionResponse.response && + part.functionResponse.response['error'] + ) { + const error = part.functionResponse.response['error']; + return typeof error === 'string' ? error : String(error); + } + } + + return undefined; +} + /** * Extracts content from tool response. * Uses functionResponsePartsToString to properly handle functionResponse parts, * which correctly extracts output content from functionResponse objects rather * than simply concatenating text or JSON.stringify. + * Also handles errors embedded in responseParts (e.g., cancelled responses). * * @param response - Tool call response * @returns String content or undefined @@ -1188,6 +1242,11 @@ export function toolResultContent( if (response.error) { return response.error.message; } + // Check for errors in responseParts (e.g., cancelled responses) + const responsePartsError = checkResponsePartsForError(response.responseParts); + if (responsePartsError) { + return responsePartsError; + } if ( typeof response.resultDisplay === 'string' && response.resultDisplay.trim().length > 0 diff --git a/packages/cli/src/nonInteractive/session.test.ts b/packages/cli/src/nonInteractive/session.test.ts index 61643fb3ee..15f1595471 100644 --- a/packages/cli/src/nonInteractive/session.test.ts +++ b/packages/cli/src/nonInteractive/session.test.ts @@ -134,7 +134,7 @@ function createControlCancel(requestId: string): ControlCancelRequest { }; } -describe('runNonInteractiveStreamJson', () => { +describe('runNonInteractiveStreamJson (refactored)', () => { let config: Config; let mockInputReader: { read: () => AsyncGenerator< diff --git a/packages/cli/src/nonInteractive/session.ts b/packages/cli/src/nonInteractive/session.ts index 614208b77b..7cfa92c008 100644 --- a/packages/cli/src/nonInteractive/session.ts +++ b/packages/cli/src/nonInteractive/session.ts @@ -4,17 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** - * Stream JSON Runner with Session State Machine - * - * Handles stream-json input/output format with: - * - Initialize handshake - * - Message routing (control vs user messages) - * - FIFO user message queue - * - Sequential message processing - * - Graceful shutdown - */ - import type { Config } from '@qwen-code/qwen-code-core'; import { StreamJsonInputReader } from './io/StreamJsonInputReader.js'; import { StreamJsonOutputAdapter } from './io/StreamJsonOutputAdapter.js'; @@ -42,48 +31,7 @@ import { createMinimalSettings } from '../config/settings.js'; import { runNonInteractive } from '../nonInteractiveCli.js'; import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js'; -const SESSION_STATE = { - INITIALIZING: 'initializing', - IDLE: 'idle', - PROCESSING_QUERY: 'processing_query', - SHUTTING_DOWN: 'shutting_down', -} as const; - -type SessionState = (typeof SESSION_STATE)[keyof typeof SESSION_STATE]; - -/** - * Message type classification for routing - */ -type MessageType = - | 'control_request' - | 'control_response' - | 'control_cancel' - | 'user' - | 'assistant' - | 'system' - | 'result' - | 'stream_event' - | 'unknown'; - -/** - * Routed message with classification - */ -interface RoutedMessage { - type: MessageType; - message: - | CLIMessage - | CLIControlRequest - | CLIControlResponse - | ControlCancelRequest; -} - -/** - * Session Manager - * - * Manages the session lifecycle and message processing state machine. - */ -class SessionManager { - private state: SessionState = SESSION_STATE.INITIALIZING; +class Session { private userMessageQueue: CLIUserMessage[] = []; private abortController: AbortController; private config: Config; @@ -98,6 +46,9 @@ class SessionManager { private debugMode: boolean; private shutdownHandler: (() => void) | null = null; private initialPrompt: CLIUserMessage | null = null; + private processingPromise: Promise | null = null; + private isShuttingDown: boolean = false; + private configInitialized: boolean = false; constructor(config: Config, initialPrompt?: CLIUserMessage) { this.config = config; @@ -112,146 +63,31 @@ class SessionManager { config.getIncludePartialMessages(), ); - // Setup signal handlers for graceful shutdown this.setupSignalHandlers(); } - /** - * Get next prompt ID - */ private getNextPromptId(): string { this.promptIdCounter++; return `${this.sessionId}########${this.promptIdCounter}`; } - /** - * Route a message to the appropriate handler based on its type - * - * Classifies incoming messages and routes them to appropriate handlers. - */ - private route( - message: - | CLIMessage - | CLIControlRequest - | CLIControlResponse - | ControlCancelRequest, - ): RoutedMessage { - // Check control messages first - if (isControlRequest(message)) { - return { type: 'control_request', message }; - } - if (isControlResponse(message)) { - return { type: 'control_response', message }; - } - if (isControlCancel(message)) { - return { type: 'control_cancel', message }; - } - - // Check data messages - if (isCLIUserMessage(message)) { - return { type: 'user', message }; - } - if (isCLIAssistantMessage(message)) { - return { type: 'assistant', message }; - } - if (isCLISystemMessage(message)) { - return { type: 'system', message }; - } - if (isCLIResultMessage(message)) { - return { type: 'result', message }; - } - if (isCLIPartialAssistantMessage(message)) { - return { type: 'stream_event', message }; + private async ensureConfigInitialized(): Promise { + if (this.configInitialized) { + return; } - // Unknown message type if (this.debugMode) { - console.error( - '[SessionManager] Unknown message type:', - JSON.stringify(message, null, 2), - ); + console.error('[Session] Initializing config'); } - return { type: 'unknown', message }; - } - - /** - * Process a single message with unified logic for both initial prompt and stream messages. - * - * Handles: - * - Abort check - * - First message detection and handling - * - Normal message processing - * - Shutdown state checks - * - * @param message - Message to process - * @returns true if the calling code should exit (break/return), false to continue - */ - private async processSingleMessage( - message: - | CLIMessage - | CLIControlRequest - | CLIControlResponse - | ControlCancelRequest, - ): Promise { - // Check for abort - if (this.abortController.signal.aborted) { - return true; - } - - // Handle first message if control system not yet initialized - if (this.controlSystemEnabled === null) { - const handled = await this.handleFirstMessage(message); - if (handled) { - // If handled, check if we should shutdown - return this.state === SESSION_STATE.SHUTTING_DOWN; - } - // If not handled, fall through to normal processing - } - - // Process message normally - await this.processMessage(message); - - // Check for shutdown after processing - return this.state === SESSION_STATE.SHUTTING_DOWN; - } - /** - * Main entry point - run the session - */ - async run(): Promise { try { - if (this.debugMode) { - console.error('[SessionManager] Starting session', this.sessionId); - } - - // Process initial prompt if provided - if (this.initialPrompt !== null) { - const shouldExit = await this.processSingleMessage(this.initialPrompt); - if (shouldExit) { - await this.shutdown(); - return; - } - } - - // Process messages from stream - for await (const message of this.inputReader.read()) { - const shouldExit = await this.processSingleMessage(message); - if (shouldExit) { - break; - } - } - - // Stream closed, shutdown - await this.shutdown(); + await this.config.initialize(); + this.configInitialized = true; } catch (error) { if (this.debugMode) { - console.error('[SessionManager] Error:', error); + console.error('[Session] Failed to initialize config:', error); } - await this.shutdown(); throw error; - } finally { - // Ensure signal handlers are always cleaned up even if shutdown wasn't called - this.cleanupSignalHandlers(); } } @@ -259,14 +95,6 @@ class SessionManager { if (this.controlContext && this.dispatcher && this.controlService) { return; } - // The control system follows a strict three-layer architecture: - // 1. ControlContext (shared session state) - // 2. ControlDispatcher (protocol routing SDK ↔ CLI) - // 3. ControlService (programmatic API for CLI runtime) - // - // Application code MUST interact with the control plane exclusively through - // ControlService. ControlDispatcher is reserved for protocol-level message - // routing and should never be used directly outside of this file. this.controlContext = new ControlContext({ config: this.config, streamJson: this.outputAdapter, @@ -299,25 +127,32 @@ class SessionManager { | CLIControlResponse | ControlCancelRequest, ): Promise { - const routed = this.route(message); - - if (routed.type === 'control_request') { - const request = routed.message as CLIControlRequest; + if (isControlRequest(message)) { + const request = message as CLIControlRequest; this.controlSystemEnabled = true; this.ensureControlSystem(); if (request.request.subtype === 'initialize') { + // Dispatch the initialize request first await this.dispatcher?.dispatch(request); - this.state = SESSION_STATE.IDLE; + + // After handling initialize control request, initialize the config + // This is the SDK mode where config initialization is deferred + await this.ensureConfigInitialized(); return true; } - return false; + if (this.debugMode) { + console.error( + '[Session] Ignoring non-initialize control request during initialization', + ); + } + return true; } - if (routed.type === 'user') { + if (isCLIUserMessage(message)) { this.controlSystemEnabled = false; - this.state = SESSION_STATE.PROCESSING_QUERY; - this.userMessageQueue.push(routed.message as CLIUserMessage); - await this.processUserMessageQueue(); + // For non-SDK mode (direct user message), initialize config if not already done + await this.ensureConfigInitialized(); + this.enqueueUserMessage(message as CLIUserMessage); return true; } @@ -325,241 +160,50 @@ class SessionManager { return false; } - /** - * Process a single message from the stream - */ - private async processMessage( - message: - | CLIMessage - | CLIControlRequest - | CLIControlResponse - | ControlCancelRequest, + private async handleControlRequest( + request: CLIControlRequest, ): Promise { - const routed = this.route(message); - - if (this.debugMode) { - console.error( - `[SessionManager] State: ${this.state}, Message type: ${routed.type}`, - ); - } - - switch (this.state) { - case SESSION_STATE.INITIALIZING: - await this.handleInitializingState(routed); - break; - - case SESSION_STATE.IDLE: - await this.handleIdleState(routed); - break; - - case SESSION_STATE.PROCESSING_QUERY: - await this.handleProcessingState(routed); - break; - - case SESSION_STATE.SHUTTING_DOWN: - // Ignore all messages during shutdown - break; - - default: { - // Exhaustive check - const _exhaustiveCheck: never = this.state; - if (this.debugMode) { - console.error('[SessionManager] Unknown state:', _exhaustiveCheck); - } - break; - } - } - } - - /** - * Handle messages in initializing state - */ - private async handleInitializingState(routed: RoutedMessage): Promise { - if (routed.type === 'control_request') { - const request = routed.message as CLIControlRequest; - const dispatcher = this.getDispatcher(); - if (!dispatcher) { - if (this.debugMode) { - console.error( - '[SessionManager] Control request received before control system initialization', - ); - } - return; - } - if (request.request.subtype === 'initialize') { - await dispatcher.dispatch(request); - this.state = SESSION_STATE.IDLE; - if (this.debugMode) { - console.error('[SessionManager] Initialized, transitioning to idle'); - } - } else { - if (this.debugMode) { - console.error( - '[SessionManager] Ignoring non-initialize control request during initialization', - ); - } - } - } else { - if (this.debugMode) { - console.error( - '[SessionManager] Ignoring non-control message during initialization', - ); - } - } - } - - /** - * Handle messages in idle state - */ - private async handleIdleState(routed: RoutedMessage): Promise { const dispatcher = this.getDispatcher(); - if (routed.type === 'control_request') { - if (!dispatcher) { - if (this.debugMode) { - console.error('[SessionManager] Ignoring control request (disabled)'); - } - return; - } - const request = routed.message as CLIControlRequest; - await dispatcher.dispatch(request); - // Stay in idle state - } else if (routed.type === 'control_response') { - if (!dispatcher) { - return; - } - const response = routed.message as CLIControlResponse; - dispatcher.handleControlResponse(response); - // Stay in idle state - } else if (routed.type === 'control_cancel') { - if (!dispatcher) { - return; - } - const cancelRequest = routed.message as ControlCancelRequest; - dispatcher.handleCancel(cancelRequest.request_id); - } else if (routed.type === 'user') { - const userMessage = routed.message as CLIUserMessage; - this.userMessageQueue.push(userMessage); - // Start processing queue - await this.processUserMessageQueue(); - } else { + if (!dispatcher) { if (this.debugMode) { - console.error( - '[SessionManager] Ignoring message type in idle state:', - routed.type, - ); + console.error('[Session] Control system not enabled'); } + return; } + + await dispatcher.dispatch(request); } - /** - * Handle messages in processing state - */ - private async handleProcessingState(routed: RoutedMessage): Promise { + private handleControlResponse(response: CLIControlResponse): void { const dispatcher = this.getDispatcher(); - if (routed.type === 'control_request') { - if (!dispatcher) { - if (this.debugMode) { - console.error( - '[SessionManager] Control request ignored during processing (disabled)', - ); - } - return; - } - const request = routed.message as CLIControlRequest; - await dispatcher.dispatch(request); - // Continue processing - } else if (routed.type === 'control_response') { - if (!dispatcher) { - return; - } - const response = routed.message as CLIControlResponse; - dispatcher.handleControlResponse(response); - // Continue processing - } else if (routed.type === 'user') { - // Enqueue for later - const userMessage = routed.message as CLIUserMessage; - this.userMessageQueue.push(userMessage); - if (this.debugMode) { - console.error( - '[SessionManager] Enqueued user message during processing', - ); - } - } else { - if (this.debugMode) { - console.error( - '[SessionManager] Ignoring message type during processing:', - routed.type, - ); - } + if (!dispatcher) { + return; } - } - /** - * Process user message queue (FIFO) - */ - private async processUserMessageQueue(): Promise { - while ( - this.userMessageQueue.length > 0 && - !this.abortController.signal.aborted - ) { - this.state = SESSION_STATE.PROCESSING_QUERY; - const userMessage = this.userMessageQueue.shift()!; - - try { - await this.processUserMessage(userMessage); - } catch (error) { - if (this.debugMode) { - console.error( - '[SessionManager] Error processing user message:', - error, - ); - } - // Send error result - this.emitErrorResult(error); - } - } + dispatcher.handleControlResponse(response); + } - // If control system is disabled (single-query mode) and queue is empty, - // automatically shutdown instead of returning to idle - if ( - !this.abortController.signal.aborted && - this.state === SESSION_STATE.PROCESSING_QUERY && - this.controlSystemEnabled === false && - this.userMessageQueue.length === 0 - ) { - if (this.debugMode) { - console.error( - '[SessionManager] Single-query mode: queue processed, shutting down', - ); - } - this.state = SESSION_STATE.SHUTTING_DOWN; + private handleControlCancel(cancelRequest: ControlCancelRequest): void { + const dispatcher = this.getDispatcher(); + if (!dispatcher) { return; } - // Return to idle after processing queue (for multi-query mode with control system) - if ( - !this.abortController.signal.aborted && - this.state === SESSION_STATE.PROCESSING_QUERY - ) { - this.state = SESSION_STATE.IDLE; - if (this.debugMode) { - console.error('[SessionManager] Queue processed, returning to idle'); - } - } + dispatcher.handleCancel(cancelRequest.request_id); } - /** - * Process a single user message - */ private async processUserMessage(userMessage: CLIUserMessage): Promise { const input = extractUserMessageText(userMessage); if (!input) { if (this.debugMode) { - console.error('[SessionManager] No text content in user message'); + console.error('[Session] No text content in user message'); } return; } + // Ensure config is initialized before processing user messages + await this.ensureConfigInitialized(); + const promptId = this.getNextPromptId(); try { @@ -575,16 +219,56 @@ class SessionManager { }, ); } catch (error) { - // Error already handled by runNonInteractive via adapter.emitResult if (this.debugMode) { - console.error('[SessionManager] Query execution error:', error); + console.error('[Session] Query execution error:', error); + } + } + } + + private async processUserMessageQueue(): Promise { + if (this.isShuttingDown || this.abortController.signal.aborted) { + return; + } + + while ( + this.userMessageQueue.length > 0 && + !this.isShuttingDown && + !this.abortController.signal.aborted + ) { + const userMessage = this.userMessageQueue.shift()!; + try { + await this.processUserMessage(userMessage); + } catch (error) { + if (this.debugMode) { + console.error('[Session] Error processing user message:', error); + } + this.emitErrorResult(error); } } } - /** - * Send tool results as user message - */ + private enqueueUserMessage(userMessage: CLIUserMessage): void { + this.userMessageQueue.push(userMessage); + this.ensureProcessingStarted(); + } + + private ensureProcessingStarted(): void { + if (this.processingPromise) { + return; + } + + this.processingPromise = this.processUserMessageQueue().finally(() => { + this.processingPromise = null; + if ( + this.userMessageQueue.length > 0 && + !this.isShuttingDown && + !this.abortController.signal.aborted + ) { + this.ensureProcessingStarted(); + } + }); + } + private emitErrorResult( error: unknown, numTurns: number = 0, @@ -602,52 +286,51 @@ class SessionManager { }); } - /** - * Handle interrupt control request - */ private handleInterrupt(): void { if (this.debugMode) { - console.error('[SessionManager] Interrupt requested'); - } - // Abort current query if processing - if (this.state === SESSION_STATE.PROCESSING_QUERY) { - this.abortController.abort(); - this.abortController = new AbortController(); // Create new controller for next query + console.error('[Session] Interrupt requested'); } + this.abortController.abort(); + this.abortController = new AbortController(); } - /** - * Setup signal handlers for graceful shutdown - */ private setupSignalHandlers(): void { this.shutdownHandler = () => { if (this.debugMode) { - console.error('[SessionManager] Shutdown signal received'); + console.error('[Session] Shutdown signal received'); } + this.isShuttingDown = true; this.abortController.abort(); - this.state = SESSION_STATE.SHUTTING_DOWN; }; process.on('SIGINT', this.shutdownHandler); process.on('SIGTERM', this.shutdownHandler); } - /** - * Shutdown session and cleanup resources - */ private async shutdown(): Promise { if (this.debugMode) { - console.error('[SessionManager] Shutting down'); + console.error('[Session] Shutting down'); + } + + this.isShuttingDown = true; + + if (this.processingPromise) { + try { + await this.processingPromise; + } catch (error) { + if (this.debugMode) { + console.error( + '[Session] Error waiting for processing to complete:', + error, + ); + } + } } - this.state = SESSION_STATE.SHUTTING_DOWN; this.dispatcher?.shutdown(); this.cleanupSignalHandlers(); } - /** - * Remove signal handlers to prevent memory leaks - */ private cleanupSignalHandlers(): void { if (this.shutdownHandler) { process.removeListener('SIGINT', this.shutdownHandler); @@ -655,6 +338,94 @@ class SessionManager { this.shutdownHandler = null; } } + + async run(): Promise { + try { + if (this.debugMode) { + console.error('[Session] Starting session', this.sessionId); + } + + if (this.initialPrompt !== null) { + const handled = await this.handleFirstMessage(this.initialPrompt); + if (handled && this.isShuttingDown) { + await this.shutdown(); + return; + } + } + + try { + for await (const message of this.inputReader.read()) { + if (this.abortController.signal.aborted) { + break; + } + + if (this.controlSystemEnabled === null) { + const handled = await this.handleFirstMessage(message); + if (handled) { + if (this.isShuttingDown) { + break; + } + continue; + } + } + + if (isControlRequest(message)) { + await this.handleControlRequest(message as CLIControlRequest); + } else if (isControlResponse(message)) { + this.handleControlResponse(message as CLIControlResponse); + } else if (isControlCancel(message)) { + this.handleControlCancel(message as ControlCancelRequest); + } else if (isCLIUserMessage(message)) { + this.enqueueUserMessage(message as CLIUserMessage); + } else if (this.debugMode) { + if ( + !isCLIAssistantMessage(message) && + !isCLISystemMessage(message) && + !isCLIResultMessage(message) && + !isCLIPartialAssistantMessage(message) + ) { + console.error( + '[Session] Unknown message type:', + JSON.stringify(message, null, 2), + ); + } + } + + if (this.isShuttingDown) { + break; + } + } + } catch (streamError) { + if (this.debugMode) { + console.error('[Session] Stream reading error:', streamError); + } + throw streamError; + } + + while (this.processingPromise) { + if (this.debugMode) { + console.error('[Session] Waiting for final processing to complete'); + } + try { + await this.processingPromise; + } catch (error) { + if (this.debugMode) { + console.error('[Session] Error in final processing:', error); + } + } + } + + await this.shutdown(); + } catch (error) { + if (this.debugMode) { + console.error('[Session] Error:', error); + } + await this.shutdown(); + throw error; + } finally { + this.cleanupSignalHandlers(); + } + } } function extractUserMessageText(message: CLIUserMessage): string | null { @@ -682,12 +453,6 @@ function extractUserMessageText(message: CLIUserMessage): string | null { return null; } -/** - * Entry point for stream-json mode - * - * @param config - Configuration object - * @param input - Optional initial prompt input to process before reading from stream - */ export async function runNonInteractiveStreamJson( config: Config, input: string, @@ -698,7 +463,6 @@ export async function runNonInteractiveStreamJson( consolePatcher.patch(); try { - // Create initial user message from prompt input if provided let initialPrompt: CLIUserMessage | undefined = undefined; if (input && input.trim().length > 0) { const sessionId = config.getSessionId(); @@ -713,7 +477,7 @@ export async function runNonInteractiveStreamJson( }; } - const manager = new SessionManager(config, initialPrompt); + const manager = new Session(config, initialPrompt); await manager.run(); } finally { consolePatcher.cleanup(); diff --git a/packages/cli/src/nonInteractive/types.ts b/packages/cli/src/nonInteractive/types.ts index 784ea916c7..2eec24c1fe 100644 --- a/packages/cli/src/nonInteractive/types.ts +++ b/packages/cli/src/nonInteractive/types.ts @@ -1,4 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import type { + MCPServerConfig, + SubagentConfig, +} from '@qwen-code/qwen-code-core'; /** * Annotation for attaching metadata to content blocks @@ -295,10 +299,18 @@ export interface CLIControlPermissionRequest { blocked_path: string | null; } +export enum AuthProviderType { + DYNAMIC_DISCOVERY = 'dynamic_discovery', + GOOGLE_CREDENTIALS = 'google_credentials', + SERVICE_ACCOUNT_IMPERSONATION = 'service_account_impersonation', +} + export interface CLIControlInitializeRequest { subtype: 'initialize'; hooks?: HookRegistration[] | null; - sdkMcpServers?: string[]; + sdkMcpServers?: Record; + mcpServers?: Record; + agents?: SubagentConfig[]; } export interface CLIControlSetPermissionModeRequest { diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 8e5a9c90fa..77e4f98043 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -15,6 +15,7 @@ import { FatalInputError, promptIdContext, OutputFormat, + InputFormat, uiTelemetryService, } from '@qwen-code/qwen-code-core'; import type { Content, Part, PartListUnion } from '@google/genai'; @@ -225,40 +226,14 @@ export async function runNonInteractive( for (const requestInfo of toolCallRequests) { const finalRequestInfo = requestInfo; - /* - if (options.controlService) { - const permissionResult = - await options.controlService.permission.shouldAllowTool( - requestInfo, - ); - if (!permissionResult.allowed) { - if (config.getDebugMode()) { - console.error( - `[runNonInteractive] Tool execution denied: ${requestInfo.name}`, - permissionResult.message ?? '', - ); - } - if (adapter && permissionResult.message) { - adapter.emitSystemMessage('tool_denied', { - tool: requestInfo.name, - message: permissionResult.message, - }); - } - continue; - } - - if (permissionResult.updatedArgs) { - finalRequestInfo = { - ...requestInfo, - args: permissionResult.updatedArgs, - }; - } - } - - const toolCallUpdateCallback = options.controlService - ? options.controlService.permission.getToolCallUpdateCallback() - : undefined; - */ + const inputFormat = + typeof config.getInputFormat === 'function' + ? config.getInputFormat() + : InputFormat.TEXT; + const toolCallUpdateCallback = + inputFormat === InputFormat.STREAM_JSON && options.controlService + ? options.controlService.permission.getToolCallUpdateCallback() + : undefined; // Only pass outputUpdateHandler for Task tool const isTaskTool = finalRequestInfo.name === 'task'; @@ -277,13 +252,13 @@ export async function runNonInteractive( isTaskTool && taskToolProgressHandler ? { outputUpdateHandler: taskToolProgressHandler, - /* - toolCallUpdateCallback - ? { onToolCallsUpdate: toolCallUpdateCallback } - : undefined, - */ + onToolCallsUpdate: toolCallUpdateCallback, } - : undefined, + : toolCallUpdateCallback + ? { + onToolCallsUpdate: toolCallUpdateCallback, + } + : undefined, ); // Note: In JSON mode, subagent messages are automatically added to the main @@ -303,9 +278,6 @@ export async function runNonInteractive( ? toolResponse.resultDisplay : undefined, ); - // Note: We no longer emit a separate system message for tool errors - // in JSON/STREAM_JSON mode, as the error is already captured in the - // tool_result block with is_error=true. } if (adapter) { diff --git a/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx b/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx index 613ac87e7e..a186374dd5 100644 --- a/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx +++ b/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx @@ -218,7 +218,7 @@ export const AgentSelectionStep = ({ const renderAgentItem = ( agent: { name: string; - level: 'project' | 'user' | 'builtin'; + level: 'project' | 'user' | 'builtin' | 'session'; isBuiltin?: boolean; }, index: number, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 59baba851b..29757ff6fe 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -65,6 +65,7 @@ import { ideContextStore } from '../ide/ideContext.js'; import { InputFormat, OutputFormat } from '../output/types.js'; import { PromptRegistry } from '../prompts/prompt-registry.js'; import { SubagentManager } from '../subagents/subagent-manager.js'; +import type { SubagentConfig } from '../subagents/types.js'; import { DEFAULT_OTLP_ENDPOINT, DEFAULT_TELEMETRY_TARGET, @@ -333,9 +334,11 @@ export interface ConfigParameters { eventEmitter?: EventEmitter; useSmartEdit?: boolean; output?: OutputSettings; - skipStartupContext?: boolean; inputFormat?: InputFormat; outputFormat?: OutputFormat; + skipStartupContext?: boolean; + sdkMode?: boolean; + sessionSubagents?: SubagentConfig[]; } function normalizeConfigOutputFormat( @@ -383,8 +386,10 @@ export class Config { private readonly toolDiscoveryCommand: string | undefined; private readonly toolCallCommand: string | undefined; private readonly mcpServerCommand: string | undefined; - private readonly mcpServers: Record | undefined; + private mcpServers: Record | undefined; + private sessionSubagents: SubagentConfig[]; private userMemory: string; + private sdkMode: boolean; private geminiMdFileCount: number; private approvalMode: ApprovalMode; private readonly showMemoryUsage: boolean; @@ -487,6 +492,8 @@ export class Config { this.toolCallCommand = params.toolCallCommand; this.mcpServerCommand = params.mcpServerCommand; this.mcpServers = params.mcpServers; + this.sessionSubagents = params.sessionSubagents ?? []; + this.sdkMode = params.sdkMode ?? false; this.userMemory = params.userMemory ?? ''; this.geminiMdFileCount = params.geminiMdFileCount ?? 0; this.approvalMode = params.approvalMode ?? ApprovalMode.DEFAULT; @@ -842,6 +849,46 @@ export class Config { return this.mcpServers; } + setMcpServers(servers: Record): void { + if (this.initialized) { + throw new Error('Cannot modify mcpServers after initialization'); + } + this.mcpServers = servers; + } + + addMcpServers(servers: Record): void { + if (this.initialized) { + throw new Error('Cannot modify mcpServers after initialization'); + } + this.mcpServers = { ...this.mcpServers, ...servers }; + } + + getSessionSubagents(): SubagentConfig[] { + return this.sessionSubagents; + } + + setSessionSubagents(subagents: SubagentConfig[]): void { + if (this.initialized) { + throw new Error('Cannot modify sessionSubagents after initialization'); + } + this.sessionSubagents = subagents; + } + + addSessionSubagents(subagents: SubagentConfig[]): void { + if (this.initialized) { + throw new Error('Cannot modify sessionSubagents after initialization'); + } + this.sessionSubagents = [...this.sessionSubagents, ...subagents]; + } + + getSdkMode(): boolean { + return this.sdkMode; + } + + setSdkMode(value: boolean): void { + this.sdkMode = value; + } + getUserMemory(): string { return this.userMemory; } diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 493758dc3d..93f3b6e186 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -916,7 +916,10 @@ export class CoreToolScheduler { async handleConfirmationResponse( callId: string, - originalOnConfirm: (outcome: ToolConfirmationOutcome) => Promise, + originalOnConfirm: ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => Promise, outcome: ToolConfirmationOutcome, signal: AbortSignal, payload?: ToolConfirmationPayload, @@ -925,9 +928,7 @@ export class CoreToolScheduler { (c) => c.request.callId === callId && c.status === 'awaiting_approval', ); - if (toolCall && toolCall.status === 'awaiting_approval') { - await originalOnConfirm(outcome); - } + await originalOnConfirm(outcome, payload); if (outcome === ToolConfirmationOutcome.ProceedAlways) { await this.autoApproveCompatiblePendingTools(signal, callId); @@ -936,11 +937,10 @@ export class CoreToolScheduler { this.setToolCallOutcome(callId, outcome); if (outcome === ToolConfirmationOutcome.Cancel || signal.aborted) { - this.setStatusInternal( - callId, - 'cancelled', - 'User did not allow tool call', - ); + // Use custom cancel message from payload if provided, otherwise use default + const cancelMessage = + payload?.cancelMessage || 'User did not allow tool call'; + this.setStatusInternal(callId, 'cancelled', cancelMessage); } else if (outcome === ToolConfirmationOutcome.ModifyWithEditor) { const waitingToolCall = toolCall as WaitingToolCall; if (isModifiableDeclarativeTool(waitingToolCall.tool)) { @@ -998,7 +998,8 @@ export class CoreToolScheduler { ): Promise { if ( toolCall.confirmationDetails.type !== 'edit' || - !isModifiableDeclarativeTool(toolCall.tool) + !isModifiableDeclarativeTool(toolCall.tool) || + !payload.newContent ) { return; } diff --git a/packages/core/src/subagents/subagent-events.ts b/packages/core/src/subagents/subagent-events.ts index eb318f5406..3c93112ddc 100644 --- a/packages/core/src/subagents/subagent-events.ts +++ b/packages/core/src/subagents/subagent-events.ts @@ -8,6 +8,7 @@ import { EventEmitter } from 'events'; import type { ToolCallConfirmationDetails, ToolConfirmationOutcome, + ToolResultDisplay, } from '../tools/tools.js'; import type { Part } from '@google/genai'; @@ -74,7 +75,7 @@ export interface SubAgentToolResultEvent { success: boolean; error?: string; responseParts?: Part[]; - resultDisplay?: string; + resultDisplay?: ToolResultDisplay; durationMs?: number; timestamp: number; } diff --git a/packages/core/src/subagents/subagent-manager.ts b/packages/core/src/subagents/subagent-manager.ts index 8dcab0dedf..d83e3e7a38 100644 --- a/packages/core/src/subagents/subagent-manager.ts +++ b/packages/core/src/subagents/subagent-manager.ts @@ -77,6 +77,15 @@ export class SubagentManager { ): Promise { this.validator.validateOrThrow(config); + // Prevent creating session-level agents + if (options.level === 'session') { + throw new SubagentError( + `Cannot create session-level subagent "${config.name}". Session agents are read-only and provided at runtime.`, + SubagentErrorCode.INVALID_CONFIG, + config.name, + ); + } + // Determine file path const filePath = options.customPath || this.getSubagentPath(config.name, options.level); @@ -142,6 +151,11 @@ export class SubagentManager { return BuiltinAgentRegistry.getBuiltinAgent(name); } + if (level === 'session') { + const sessionSubagents = this.subagentsCache?.get('session') || []; + return sessionSubagents.find((agent) => agent.name === name) || null; + } + return this.findSubagentByNameAtLevel(name, level); } @@ -191,6 +205,15 @@ export class SubagentManager { ); } + // Prevent updating session-level agents + if (existing.level === 'session') { + throw new SubagentError( + `Cannot update session-level subagent "${name}"`, + SubagentErrorCode.INVALID_CONFIG, + name, + ); + } + // Merge updates with existing configuration const updatedConfig = this.mergeConfigurations(existing, updates); @@ -236,8 +259,8 @@ export class SubagentManager { let deleted = false; for (const currentLevel of levelsToCheck) { - // Skip builtin level for deletion - if (currentLevel === 'builtin') { + // Skip builtin and session levels for deletion + if (currentLevel === 'builtin' || currentLevel === 'session') { continue; } @@ -277,6 +300,38 @@ export class SubagentManager { const subagents: SubagentConfig[] = []; const seenNames = new Set(); + // In SDK mode, only load session-level subagents + if (this.config.getSdkMode()) { + const sessionSubagents = this.config.getSessionSubagents(); + if (sessionSubagents && sessionSubagents.length > 0) { + this.loadSessionSubagents(sessionSubagents); + } + + const levelsToCheck: SubagentLevel[] = options.level + ? [options.level] + : ['session']; + + for (const level of levelsToCheck) { + const levelSubagents = this.subagentsCache?.get(level) || []; + + for (const subagent of levelSubagents) { + // Apply tool filter if specified + if ( + options.hasTool && + (!subagent.tools || !subagent.tools.includes(options.hasTool)) + ) { + continue; + } + + subagents.push(subagent); + seenNames.add(subagent.name); + } + } + + return subagents; + } + + // Normal mode: load from project, user, and builtin levels const levelsToCheck: SubagentLevel[] = options.level ? [options.level] : ['project', 'user', 'builtin']; @@ -322,8 +377,8 @@ export class SubagentManager { comparison = a.name.localeCompare(b.name); break; case 'level': { - // Project comes before user, user comes before builtin - const levelOrder = { project: 0, user: 1, builtin: 2 }; + // Project comes before user, user comes before builtin, session comes last + const levelOrder = { project: 0, user: 1, builtin: 2, session: 3 }; comparison = levelOrder[a.level] - levelOrder[b.level]; break; } @@ -339,6 +394,27 @@ export class SubagentManager { return subagents; } + /** + * Loads session-level subagents into the cache. + * Session subagents are provided directly via config and are read-only. + * + * @param subagents - Array of session subagent configurations + */ + loadSessionSubagents(subagents: SubagentConfig[]): void { + if (!this.subagentsCache) { + this.subagentsCache = new Map(); + } + + const sessionSubagents = subagents.map((config) => ({ + ...config, + level: 'session' as SubagentLevel, + filePath: ``, + })); + + this.subagentsCache.set('session', sessionSubagents); + this.notifyChangeListeners(); + } + /** * Refreshes the subagents cache by loading all subagents from disk. * This method is called automatically when cache is null or when force=true. @@ -693,6 +769,10 @@ export class SubagentManager { return ``; } + if (level === 'session') { + return ``; + } + const baseDir = level === 'project' ? path.join( diff --git a/packages/core/src/subagents/types.ts b/packages/core/src/subagents/types.ts index 67b78a5010..0f83e3f1f2 100644 --- a/packages/core/src/subagents/types.ts +++ b/packages/core/src/subagents/types.ts @@ -11,8 +11,9 @@ import type { Content, FunctionDeclaration } from '@google/genai'; * - 'project': Stored in `.qwen/agents/` within the project directory * - 'user': Stored in `~/.qwen/agents/` in the user's home directory * - 'builtin': Built-in agents embedded in the codebase, always available + * - 'session': Session-level agents provided at runtime, read-only */ -export type SubagentLevel = 'project' | 'user' | 'builtin'; +export type SubagentLevel = 'project' | 'user' | 'builtin' | 'session'; /** * Core configuration for a subagent as stored in Markdown files. diff --git a/packages/core/src/tools/mcp-tool.ts b/packages/core/src/tools/mcp-tool.ts index afffa103e5..15f461e90c 100644 --- a/packages/core/src/tools/mcp-tool.ts +++ b/packages/core/src/tools/mcp-tool.ts @@ -10,6 +10,7 @@ import type { ToolInvocation, ToolMcpConfirmationDetails, ToolResult, + ToolConfirmationPayload, } from './tools.js'; import { BaseDeclarativeTool, @@ -98,7 +99,10 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation< serverName: this.serverName, toolName: this.serverToolName, // Display original tool name in confirmation toolDisplayName: this.displayName, // Display global registry name exposed to model and user - onConfirm: async (outcome: ToolConfirmationOutcome) => { + onConfirm: async ( + outcome: ToolConfirmationOutcome, + _payload?: ToolConfirmationPayload, + ) => { if (outcome === ToolConfirmationOutcome.ProceedAlwaysServer) { DiscoveredMCPToolInvocation.allowlist.add(serverAllowListKey); } else if (outcome === ToolConfirmationOutcome.ProceedAlwaysTool) { diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 17e40dbe71..8ff3047eca 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -17,6 +17,7 @@ import type { ToolResultDisplay, ToolCallConfirmationDetails, ToolExecuteConfirmationDetails, + ToolConfirmationPayload, } from './tools.js'; import { BaseDeclarativeTool, @@ -102,7 +103,10 @@ export class ShellToolInvocation extends BaseToolInvocation< title: 'Confirm Shell Command', command: this.params.command, rootCommand: commandsToConfirm.join(', '), - onConfirm: async (outcome: ToolConfirmationOutcome) => { + onConfirm: async ( + outcome: ToolConfirmationOutcome, + _payload?: ToolConfirmationPayload, + ) => { if (outcome === ToolConfirmationOutcome.ProceedAlways) { commandsToConfirm.forEach((command) => this.allowlist.add(command)); } diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 848b14c63e..7b3c893e60 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -531,13 +531,18 @@ export interface ToolEditConfirmationDetails { export interface ToolConfirmationPayload { // used to override `modifiedProposedContent` for modifiable tools in the // inline modify flow - newContent: string; + newContent?: string; + // used to provide custom cancellation message when outcome is Cancel + cancelMessage?: string; } export interface ToolExecuteConfirmationDetails { type: 'exec'; title: string; - onConfirm: (outcome: ToolConfirmationOutcome) => Promise; + onConfirm: ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => Promise; command: string; rootCommand: string; } @@ -548,7 +553,10 @@ export interface ToolMcpConfirmationDetails { serverName: string; toolName: string; toolDisplayName: string; - onConfirm: (outcome: ToolConfirmationOutcome) => Promise; + onConfirm: ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => Promise; } export interface ToolInfoConfirmationDetails { @@ -573,6 +581,11 @@ export interface ToolPlanConfirmationDetails { onConfirm: (outcome: ToolConfirmationOutcome) => Promise; } +/** + * TODO: + * 1. support explicit denied outcome + * 2. support proceed with modified input + */ export enum ToolConfirmationOutcome { ProceedOnce = 'proceed_once', ProceedAlways = 'proceed_always', diff --git a/packages/sdk-typescript/package.json b/packages/sdk-typescript/package.json new file mode 100644 index 0000000000..067d1d22b4 --- /dev/null +++ b/packages/sdk-typescript/package.json @@ -0,0 +1,68 @@ +{ + "name": "@qwen-code/sdk-typescript", + "version": "0.1.0", + "description": "TypeScript SDK for programmatic access to qwen-code CLI", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsc", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "lint": "eslint src test", + "lint:fix": "eslint src test --fix", + "clean": "rm -rf dist", + "prepublishOnly": "npm run clean && npm run build" + }, + "keywords": [ + "qwen", + "qwen-code", + "ai", + "code-assistant", + "sdk", + "typescript" + ], + "author": "Qwen Team", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.4" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "@typescript-eslint/eslint-plugin": "^7.13.0", + "@typescript-eslint/parser": "^7.13.0", + "@vitest/coverage-v8": "^1.6.0", + "eslint": "^8.57.0", + "typescript": "^5.4.5", + "vitest": "^1.6.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/qwen-ai/qwen-code.git", + "directory": "packages/sdk/typescript" + }, + "bugs": { + "url": "https://github.com/qwen-ai/qwen-code/issues" + }, + "homepage": "https://github.com/qwen-ai/qwen-code#readme" +} diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts new file mode 100644 index 0000000000..c732c6ff6f --- /dev/null +++ b/packages/sdk-typescript/src/index.ts @@ -0,0 +1,66 @@ +export { query } from './query/createQuery.js'; + +export { Query } from './query/Query.js'; + +export type { ExternalMcpServerConfig } from './types/queryOptionsSchema.js'; + +export type { QueryOptions } from './query/createQuery.js'; + +export type { + ContentBlock, + TextBlock, + ThinkingBlock, + ToolUseBlock, + ToolResultBlock, + CLIUserMessage, + CLIAssistantMessage, + CLISystemMessage, + CLIResultMessage, + CLIPartialAssistantMessage, + CLIMessage, +} from './types/protocol.js'; + +export { + isCLIUserMessage, + isCLIAssistantMessage, + isCLISystemMessage, + isCLIResultMessage, + isCLIPartialAssistantMessage, +} from './types/protocol.js'; + +export { AbortError, isAbortError } from './types/errors.js'; + +export { ControlRequestType } from './types/protocol.js'; + +export { ProcessTransport } from './transport/ProcessTransport.js'; +export type { Transport } from './transport/Transport.js'; + +export { Stream } from './utils/Stream.js'; +export { + serializeJsonLine, + parseJsonLineSafe, + isValidMessage, + parseJsonLinesStream, +} from './utils/jsonLines.js'; +export { + findCliPath, + resolveCliPath, + prepareSpawnInfo, +} from './utils/cliPath.js'; +export type { SpawnInfo } from './utils/cliPath.js'; + +export { createSdkMcpServer } from './mcp/createSdkMcpServer.js'; +export { + tool, + createTool, + validateToolName, + validateInputSchema, +} from './mcp/tool.js'; + +export type { + JSONSchema, + ToolDefinition, + PermissionMode, + CanUseTool, + PermissionResult, +} from './types/types.js'; diff --git a/packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts b/packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts new file mode 100644 index 0000000000..c160a9af02 --- /dev/null +++ b/packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts @@ -0,0 +1,111 @@ +/** + * SdkControlServerTransport - bridges MCP Server with Query's control plane + * + * Implements @modelcontextprotocol/sdk Transport interface to enable + * SDK-embedded MCP servers. Messages flow bidirectionally: + * + * MCP Server → send() → Query → control_request (mcp_message) → CLI + * CLI → control_request (mcp_message) → Query → handleMessage() → MCP Server + */ + +import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; + +export type SendToQueryCallback = (message: JSONRPCMessage) => Promise; + +export interface SdkControlServerTransportOptions { + sendToQuery: SendToQueryCallback; + serverName: string; +} + +export class SdkControlServerTransport { + sendToQuery: SendToQueryCallback; + private serverName: string; + private started = false; + + onmessage?: (message: JSONRPCMessage) => void; + onerror?: (error: Error) => void; + onclose?: () => void; + + constructor(options: SdkControlServerTransportOptions) { + this.sendToQuery = options.sendToQuery; + this.serverName = options.serverName; + } + + async start(): Promise { + this.started = true; + } + + async send(message: JSONRPCMessage): Promise { + if (!this.started) { + throw new Error( + `SdkControlServerTransport (${this.serverName}) not started. Call start() first.`, + ); + } + + try { + // Send via Query's control plane + await this.sendToQuery(message); + } catch (error) { + // Invoke error callback if set + if (this.onerror) { + this.onerror(error instanceof Error ? error : new Error(String(error))); + } + throw error; + } + } + + async close(): Promise { + if (!this.started) { + return; // Already closed + } + + this.started = false; + + // Notify MCP Server + if (this.onclose) { + this.onclose(); + } + } + + handleMessage(message: JSONRPCMessage): void { + if (!this.started) { + console.warn( + `[SdkControlServerTransport] Received message for closed transport (${this.serverName})`, + ); + return; + } + + if (this.onmessage) { + this.onmessage(message); + } else { + console.warn( + `[SdkControlServerTransport] No onmessage handler set for ${this.serverName}`, + ); + } + } + + handleError(error: Error): void { + if (this.onerror) { + this.onerror(error); + } else { + console.error( + `[SdkControlServerTransport] Error for ${this.serverName}:`, + error, + ); + } + } + + isStarted(): boolean { + return this.started; + } + + getServerName(): string { + return this.serverName; + } +} + +export function createSdkControlServerTransport( + options: SdkControlServerTransportOptions, +): SdkControlServerTransport { + return new SdkControlServerTransport(options); +} diff --git a/packages/sdk-typescript/src/mcp/createSdkMcpServer.ts b/packages/sdk-typescript/src/mcp/createSdkMcpServer.ts new file mode 100644 index 0000000000..841440e1fc --- /dev/null +++ b/packages/sdk-typescript/src/mcp/createSdkMcpServer.ts @@ -0,0 +1,109 @@ +/** + * Factory function to create SDK-embedded MCP servers + * + * Creates MCP Server instances that run in the user's Node.js process + * and are proxied to the CLI via the control plane. + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { + ListToolsRequestSchema, + CallToolRequestSchema, + type CallToolResultSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import type { ToolDefinition } from '../types/types.js'; +import { formatToolResult, formatToolError } from './formatters.js'; +import { validateToolName } from './tool.js'; +import type { z } from 'zod'; + +type CallToolResult = z.infer; + +export function createSdkMcpServer( + name: string, + version: string, + tools: ToolDefinition[], +): Server { + // Validate server name + if (!name || typeof name !== 'string') { + throw new Error('MCP server name must be a non-empty string'); + } + + if (!version || typeof version !== 'string') { + throw new Error('MCP server version must be a non-empty string'); + } + + if (!Array.isArray(tools)) { + throw new Error('Tools must be an array'); + } + + // Validate tool names are unique + const toolNames = new Set(); + for (const tool of tools) { + validateToolName(tool.name); + + if (toolNames.has(tool.name)) { + throw new Error( + `Duplicate tool name '${tool.name}' in MCP server '${name}'`, + ); + } + toolNames.add(tool.name); + } + + // Create MCP Server instance + const server = new Server( + { + name, + version, + }, + { + capabilities: { + tools: {}, + }, + }, + ); + + // Create tool map for fast lookup + const toolMap = new Map(); + for (const tool of tools) { + toolMap.set(tool.name, tool); + } + + // Register list_tools handler + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: tools.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })), + })); + + // Register call_tool handler + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name: toolName, arguments: toolArgs } = request.params; + + // Find tool + const tool = toolMap.get(toolName); + if (!tool) { + return formatToolError( + new Error(`Tool '${toolName}' not found in server '${name}'`), + ) as CallToolResult; + } + + try { + // Invoke tool handler + const result = await tool.handler(toolArgs); + + // Format result + return formatToolResult(result) as CallToolResult; + } catch (error) { + // Handle tool execution error + return formatToolError( + error instanceof Error + ? error + : new Error(`Tool '${toolName}' failed: ${String(error)}`), + ) as CallToolResult; + } + }); + + return server; +} diff --git a/packages/sdk-typescript/src/mcp/formatters.ts b/packages/sdk-typescript/src/mcp/formatters.ts new file mode 100644 index 0000000000..a71e12ff1f --- /dev/null +++ b/packages/sdk-typescript/src/mcp/formatters.ts @@ -0,0 +1,194 @@ +/** + * Tool result formatting utilities for MCP responses + * + * Converts various output types to MCP content blocks. + */ + +export type McpContentBlock = + | { type: 'text'; text: string } + | { type: 'image'; data: string; mimeType: string } + | { type: 'resource'; uri: string; mimeType?: string; text?: string }; + +export interface ToolResult { + content: McpContentBlock[]; + isError?: boolean; +} + +export function formatToolResult(result: unknown): ToolResult { + // Handle Error objects + if (result instanceof Error) { + return { + content: [ + { + type: 'text', + text: result.message || 'Unknown error', + }, + ], + isError: true, + }; + } + + // Handle null/undefined + if (result === null || result === undefined) { + return { + content: [ + { + type: 'text', + text: '', + }, + ], + }; + } + + // Handle string + if (typeof result === 'string') { + return { + content: [ + { + type: 'text', + text: result, + }, + ], + }; + } + + // Handle number + if (typeof result === 'number') { + return { + content: [ + { + type: 'text', + text: String(result), + }, + ], + }; + } + + // Handle boolean + if (typeof result === 'boolean') { + return { + content: [ + { + type: 'text', + text: String(result), + }, + ], + }; + } + + // Handle object (including arrays) + if (typeof result === 'object') { + try { + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } catch { + // JSON.stringify failed + return { + content: [ + { + type: 'text', + text: String(result), + }, + ], + }; + } + } + + // Fallback: convert to string + return { + content: [ + { + type: 'text', + text: String(result), + }, + ], + }; +} + +export function formatToolError(error: Error | string): ToolResult { + const message = error instanceof Error ? error.message : error; + + return { + content: [ + { + type: 'text', + text: message, + }, + ], + isError: true, + }; +} + +export function formatTextResult(text: string): ToolResult { + return { + content: [ + { + type: 'text', + text, + }, + ], + }; +} + +export function formatJsonResult(data: unknown): ToolResult { + return { + content: [ + { + type: 'text', + text: JSON.stringify(data, null, 2), + }, + ], + }; +} + +export function mergeToolResults(results: ToolResult[]): ToolResult { + const mergedContent: McpContentBlock[] = []; + let hasError = false; + + for (const result of results) { + mergedContent.push(...result.content); + if (result.isError) { + hasError = true; + } + } + + return { + content: mergedContent, + isError: hasError, + }; +} + +export function isValidContentBlock(block: unknown): block is McpContentBlock { + if (!block || typeof block !== 'object') { + return false; + } + + const blockObj = block as Record; + + if (!blockObj.type || typeof blockObj.type !== 'string') { + return false; + } + + switch (blockObj.type) { + case 'text': + return typeof blockObj.text === 'string'; + + case 'image': + return ( + typeof blockObj.data === 'string' && + typeof blockObj.mimeType === 'string' + ); + + case 'resource': + return typeof blockObj.uri === 'string'; + + default: + return false; + } +} diff --git a/packages/sdk-typescript/src/mcp/tool.ts b/packages/sdk-typescript/src/mcp/tool.ts new file mode 100644 index 0000000000..667bf5e546 --- /dev/null +++ b/packages/sdk-typescript/src/mcp/tool.ts @@ -0,0 +1,91 @@ +/** + * Tool definition helper for SDK-embedded MCP servers + * + * Provides type-safe tool definitions with generic input/output types. + */ + +import type { ToolDefinition } from '../types/types.js'; + +export function tool( + def: ToolDefinition, +): ToolDefinition { + // Validate tool definition + if (!def.name || typeof def.name !== 'string') { + throw new Error('Tool definition must have a name (string)'); + } + + if (!def.description || typeof def.description !== 'string') { + throw new Error( + `Tool definition for '${def.name}' must have a description (string)`, + ); + } + + if (!def.inputSchema || typeof def.inputSchema !== 'object') { + throw new Error( + `Tool definition for '${def.name}' must have an inputSchema (object)`, + ); + } + + if (!def.handler || typeof def.handler !== 'function') { + throw new Error( + `Tool definition for '${def.name}' must have a handler (function)`, + ); + } + + // Return definition (pass-through for type safety) + return def; +} + +export function validateToolName(name: string): void { + if (!name) { + throw new Error('Tool name cannot be empty'); + } + + if (name.length > 64) { + throw new Error( + `Tool name '${name}' is too long (max 64 characters): ${name.length}`, + ); + } + + if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(name)) { + throw new Error( + `Tool name '${name}' is invalid. Must start with a letter and contain only letters, numbers, and underscores.`, + ); + } +} + +export function validateInputSchema(schema: unknown): void { + if (!schema || typeof schema !== 'object') { + throw new Error('Input schema must be an object'); + } + + const schemaObj = schema as Record; + + if (!schemaObj.type) { + throw new Error('Input schema must have a type field'); + } + + // For object schemas, validate properties + if (schemaObj.type === 'object') { + if (schemaObj.properties && typeof schemaObj.properties !== 'object') { + throw new Error('Input schema properties must be an object'); + } + + if (schemaObj.required && !Array.isArray(schemaObj.required)) { + throw new Error('Input schema required must be an array'); + } + } +} + +export function createTool( + def: ToolDefinition, +): ToolDefinition { + // Validate via tool() function + const validated = tool(def); + + // Additional validation + validateToolName(validated.name); + validateInputSchema(validated.inputSchema); + + return validated; +} diff --git a/packages/sdk-typescript/src/query/Query.ts b/packages/sdk-typescript/src/query/Query.ts new file mode 100644 index 0000000000..55d767c530 --- /dev/null +++ b/packages/sdk-typescript/src/query/Query.ts @@ -0,0 +1,738 @@ +/** + * Query class - Main orchestrator for SDK + * + * Manages SDK workflow, routes messages, and handles lifecycle. + * Implements AsyncIterator protocol for message consumption. + */ + +const PERMISSION_CALLBACK_TIMEOUT = 30000; +const MCP_REQUEST_TIMEOUT = 30000; +const CONTROL_REQUEST_TIMEOUT = 30000; +const STREAM_CLOSE_TIMEOUT = 10000; + +import { randomUUID } from 'node:crypto'; +import type { + CLIMessage, + CLIUserMessage, + CLIControlRequest, + CLIControlResponse, + ControlCancelRequest, + PermissionSuggestion, +} from '../types/protocol.js'; +import { + isCLIUserMessage, + isCLIAssistantMessage, + isCLISystemMessage, + isCLIResultMessage, + isCLIPartialAssistantMessage, + isControlRequest, + isControlResponse, + isControlCancel, +} from '../types/protocol.js'; +import type { Transport } from '../transport/Transport.js'; +import { type QueryOptions } from '../types/queryOptionsSchema.js'; +import { Stream } from '../utils/Stream.js'; +import { serializeJsonLine } from '../utils/jsonLines.js'; +import { AbortError } from '../types/errors.js'; +import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; +import type { SdkControlServerTransport } from '../mcp/SdkControlServerTransport.js'; +import { ControlRequestType } from '../types/protocol.js'; + +interface PendingControlRequest { + resolve: (response: Record | null) => void; + reject: (error: Error) => void; + timeout: NodeJS.Timeout; + abortController: AbortController; +} + +interface TransportWithEndInput extends Transport { + endInput(): void; +} + +export class Query implements AsyncIterable { + private transport: Transport; + private options: QueryOptions; + private sessionId: string; + private inputStream: Stream; + private sdkMessages: AsyncGenerator; + private abortController: AbortController; + private pendingControlRequests: Map = + new Map(); + private sdkMcpTransports: Map = new Map(); + readonly initialized: Promise; + private closed = false; + private messageRouterStarted = false; + + private firstResultReceivedPromise?: Promise; + private firstResultReceivedResolve?: () => void; + + private readonly isSingleTurn: boolean; + + constructor( + transport: Transport, + options: QueryOptions, + singleTurn: boolean = false, + ) { + this.transport = transport; + this.options = options; + this.sessionId = randomUUID(); + this.inputStream = new Stream(); + this.abortController = options.abortController ?? new AbortController(); + this.isSingleTurn = singleTurn; + + /** + * Create async generator proxy to ensure stream.next() is called at least once. + * The generator will start iterating when the user begins iteration. + * This ensures readResolve/readReject are set up as soon as iteration starts. + * If errors occur before iteration starts, they'll be stored in hasError and + * properly rejected when the user starts iterating. + */ + this.sdkMessages = this.readSdkMessages(); + + this.firstResultReceivedPromise = new Promise((resolve) => { + this.firstResultReceivedResolve = resolve; + }); + + /** + * Handle abort signal if controller is provided and already aborted or will be aborted. + * If already aborted, set error immediately. Otherwise, listen for abort events + * and set abort error on the stream before closing. + */ + if (this.abortController.signal.aborted) { + this.inputStream.error(new AbortError('Query aborted by user')); + this.close().catch((err) => { + console.error('[Query] Error during abort cleanup:', err); + }); + } else { + this.abortController.signal.addEventListener('abort', () => { + this.inputStream.error(new AbortError('Query aborted by user')); + this.close().catch((err) => { + console.error('[Query] Error during abort cleanup:', err); + }); + }); + } + + this.initialized = this.initialize(); + this.initialized.catch(() => {}); + + this.startMessageRouter(); + } + + private async initialize(): Promise { + try { + await this.setupSdkMcpServers(); + + const sdkMcpServerNames = Array.from(this.sdkMcpTransports.keys()); + + await this.sendControlRequest(ControlRequestType.INITIALIZE, { + hooks: null, + sdkMcpServers: + sdkMcpServerNames.length > 0 ? sdkMcpServerNames : undefined, + mcpServers: this.options.mcpServers, + }); + } catch (error) { + console.error('[Query] Initialization error:', error); + throw error; + } + } + + private async setupSdkMcpServers(): Promise { + if (!this.options.sdkMcpServers) { + return; + } + + const externalNames = Object.keys(this.options.mcpServers ?? {}); + const sdkNames = Object.keys(this.options.sdkMcpServers); + + const conflicts = sdkNames.filter((name) => externalNames.includes(name)); + if (conflicts.length > 0) { + throw new Error( + `MCP server name conflicts between mcpServers and sdkMcpServers: ${conflicts.join(', ')}`, + ); + } + + /** + * Import SdkControlServerTransport dynamically to avoid circular dependencies. + * Create transport for each server that sends MCP messages via control plane. + */ + const { SdkControlServerTransport } = await import( + '../mcp/SdkControlServerTransport.js' + ); + + for (const [name, server] of Object.entries(this.options.sdkMcpServers)) { + const transport = new SdkControlServerTransport({ + serverName: name, + sendToQuery: async (message: JSONRPCMessage) => { + await this.sendControlRequest(ControlRequestType.MCP_MESSAGE, { + server_name: name, + message, + }); + }, + }); + + await transport.start(); + await server.connect(transport); + this.sdkMcpTransports.set(name, transport); + } + } + + private startMessageRouter(): void { + if (this.messageRouterStarted) { + return; + } + + this.messageRouterStarted = true; + + (async () => { + try { + for await (const message of this.transport.readMessages()) { + await this.routeMessage(message); + + if (this.closed) { + break; + } + } + + if (this.abortController.signal.aborted) { + this.inputStream.error(new AbortError('Query aborted')); + } else { + this.inputStream.done(); + } + } catch (error) { + this.inputStream.error( + error instanceof Error ? error : new Error(String(error)), + ); + } + })(); + } + + private async routeMessage(message: unknown): Promise { + if (isControlRequest(message)) { + await this.handleControlRequest(message); + return; + } + + if (isControlResponse(message)) { + this.handleControlResponse(message); + return; + } + + if (isControlCancel(message)) { + this.handleControlCancelRequest(message); + return; + } + + if (isCLISystemMessage(message)) { + /** + * SystemMessage contains session info (cwd, tools, model, etc.) + * that should be passed to user. + */ + this.inputStream.enqueue(message); + return; + } + + if (isCLIResultMessage(message)) { + if (this.firstResultReceivedResolve) { + this.firstResultReceivedResolve(); + } + /** + * In single-turn mode, automatically close input after receiving result + * to signal completion to the CLI. + */ + if (this.isSingleTurn && 'endInput' in this.transport) { + (this.transport as TransportWithEndInput).endInput(); + } + this.inputStream.enqueue(message); + return; + } + + if ( + isCLIAssistantMessage(message) || + isCLIUserMessage(message) || + isCLIPartialAssistantMessage(message) + ) { + this.inputStream.enqueue(message); + return; + } + + if (process.env['DEBUG']) { + console.warn('[Query] Unknown message type:', message); + } + this.inputStream.enqueue(message as CLIMessage); + } + + private async handleControlRequest( + request: CLIControlRequest, + ): Promise { + const { request_id, request: payload } = request; + + const requestAbortController = new AbortController(); + + try { + let response: Record | null = null; + + switch (payload.subtype) { + case 'can_use_tool': + response = await this.handlePermissionRequest( + payload.tool_name, + payload.input as Record, + payload.permission_suggestions, + requestAbortController.signal, + ); + break; + + case 'mcp_message': + response = await this.handleMcpMessage( + payload.server_name, + payload.message as unknown as JSONRPCMessage, + ); + break; + + default: + throw new Error( + `Unknown control request subtype: ${payload.subtype}`, + ); + } + + await this.sendControlResponse(request_id, true, response); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + await this.sendControlResponse(request_id, false, errorMessage); + } + } + + private async handlePermissionRequest( + toolName: string, + toolInput: Record, + permissionSuggestions: PermissionSuggestion[] | null, + signal: AbortSignal, + ): Promise> { + /* Default deny all wildcard tool requests */ + if (!this.options.canUseTool) { + return { behavior: 'deny', message: 'Denied' }; + } + + try { + const timeoutPromise = new Promise((_, reject) => { + setTimeout( + () => reject(new Error('Permission callback timeout')), + PERMISSION_CALLBACK_TIMEOUT, + ); + }); + + const result = await Promise.race([ + Promise.resolve( + this.options.canUseTool(toolName, toolInput, { + signal, + suggestions: permissionSuggestions, + }), + ), + timeoutPromise, + ]); + + // Handle boolean return (backward compatibility) + if (typeof result === 'boolean') { + return result + ? { behavior: 'allow', updatedInput: toolInput } + : { behavior: 'deny', message: 'Denied' }; + } + + // Handle PermissionResult format + const permissionResult = result as { + behavior: 'allow' | 'deny'; + updatedInput?: Record; + message?: string; + interrupt?: boolean; + }; + + if (permissionResult.behavior === 'allow') { + return { + behavior: 'allow', + updatedInput: permissionResult.updatedInput ?? toolInput, + }; + } else { + return { + behavior: 'deny', + message: permissionResult.message ?? 'Denied', + ...(permissionResult.interrupt !== undefined + ? { interrupt: permissionResult.interrupt } + : {}), + }; + } + } catch (error) { + /** + * Timeout or error → deny (fail-safe). + * This ensures that any issues with the permission callback + * result in a safe default of denying access. + */ + const errorMessage = + error instanceof Error ? error.message : String(error); + console.warn( + '[Query] Permission callback error (denying by default):', + errorMessage, + ); + return { + behavior: 'deny', + message: `Permission check failed: ${errorMessage}`, + }; + } + } + + private async handleMcpMessage( + serverName: string, + message: JSONRPCMessage, + ): Promise> { + const transport = this.sdkMcpTransports.get(serverName); + if (!transport) { + throw new Error( + `MCP server '${serverName}' not found in SDK-embedded servers`, + ); + } + + /** + * Check if this is a request (has method and id) or notification. + * Requests need to wait for a response, while notifications are just routed. + */ + const isRequest = + 'method' in message && 'id' in message && message.id !== null; + + if (isRequest) { + const response = await this.handleMcpRequest( + serverName, + message, + transport, + ); + return { mcp_response: response }; + } else { + transport.handleMessage(message); + return { mcp_response: { jsonrpc: '2.0', result: {}, id: 0 } }; + } + } + + private handleMcpRequest( + _serverName: string, + message: JSONRPCMessage, + transport: SdkControlServerTransport, + ): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('MCP request timeout')); + }, MCP_REQUEST_TIMEOUT); + + const messageId = 'id' in message ? message.id : null; + + /** + * Hook into transport to capture response. + * Temporarily replace sendToQuery to intercept the response message + * matching this request's ID, then restore the original handler. + */ + const originalSend = transport.sendToQuery; + transport.sendToQuery = async (responseMessage: JSONRPCMessage) => { + if ('id' in responseMessage && responseMessage.id === messageId) { + clearTimeout(timeout); + transport.sendToQuery = originalSend; + resolve(responseMessage); + } + return originalSend(responseMessage); + }; + + transport.handleMessage(message); + }); + } + + private handleControlResponse(response: CLIControlResponse): void { + const { response: payload } = response; + const request_id = payload.request_id; + + const pending = this.pendingControlRequests.get(request_id); + if (!pending) { + console.warn( + '[Query] Received response for unknown request:', + request_id, + ); + return; + } + + clearTimeout(pending.timeout); + this.pendingControlRequests.delete(request_id); + + if (payload.subtype === 'success') { + pending.resolve(payload.response as Record | null); + } else { + /** + * Extract error message from error field. + * Error can be either a string or an object with a message property. + */ + const errorMessage = + typeof payload.error === 'string' + ? payload.error + : (payload.error?.message ?? 'Unknown error'); + pending.reject(new Error(errorMessage)); + } + } + + private handleControlCancelRequest(request: ControlCancelRequest): void { + const { request_id } = request; + + if (!request_id) { + console.warn('[Query] Received cancel request without request_id'); + return; + } + + const pending = this.pendingControlRequests.get(request_id); + if (pending) { + pending.abortController.abort(); + clearTimeout(pending.timeout); + this.pendingControlRequests.delete(request_id); + pending.reject(new AbortError('Request cancelled')); + } + } + + private async sendControlRequest( + subtype: string, + data: Record = {}, + ): Promise | null> { + const requestId = randomUUID(); + + const request: CLIControlRequest = { + type: 'control_request', + request_id: requestId, + request: { + subtype: subtype as never, + ...data, + } as CLIControlRequest['request'], + }; + + const responsePromise = new Promise | null>( + (resolve, reject) => { + const abortController = new AbortController(); + const timeout = setTimeout(() => { + this.pendingControlRequests.delete(requestId); + reject(new Error(`Control request timeout: ${subtype}`)); + }, CONTROL_REQUEST_TIMEOUT); + + this.pendingControlRequests.set(requestId, { + resolve, + reject, + timeout, + abortController, + }); + }, + ); + + this.transport.write(serializeJsonLine(request)); + return responsePromise; + } + + private async sendControlResponse( + requestId: string, + success: boolean, + responseOrError: Record | null | string, + ): Promise { + const response: CLIControlResponse = { + type: 'control_response', + response: success + ? { + subtype: 'success', + request_id: requestId, + response: responseOrError as Record | null, + } + : { + subtype: 'error', + request_id: requestId, + error: responseOrError as string, + }, + }; + + this.transport.write(serializeJsonLine(response)); + } + + async close(): Promise { + if (this.closed) { + return; + } + + this.closed = true; + + for (const pending of this.pendingControlRequests.values()) { + pending.abortController.abort(); + clearTimeout(pending.timeout); + } + this.pendingControlRequests.clear(); + + await this.transport.close(); + + /** + * Complete input stream - check if aborted first. + * Only set error/done if stream doesn't already have an error state. + */ + if (this.inputStream.hasError === undefined) { + if (this.abortController.signal.aborted) { + this.inputStream.error(new AbortError('Query aborted')); + } else { + this.inputStream.done(); + } + } + + for (const transport of this.sdkMcpTransports.values()) { + try { + await transport.close(); + } catch (error) { + console.error('[Query] Error closing MCP transport:', error); + } + } + this.sdkMcpTransports.clear(); + } + + private async *readSdkMessages(): AsyncGenerator { + for await (const message of this.inputStream) { + yield message; + } + } + + async next(...args: [] | [unknown]): Promise> { + return this.sdkMessages.next(...args); + } + + async return(value?: unknown): Promise> { + return this.sdkMessages.return(value); + } + + async throw(e?: unknown): Promise> { + return this.sdkMessages.throw(e); + } + + [Symbol.asyncIterator](): AsyncIterator { + return this.sdkMessages; + } + + async streamInput(messages: AsyncIterable): Promise { + if (this.closed) { + throw new Error('Query is closed'); + } + + try { + /** + * Wait for initialization to complete before sending messages. + * This prevents "write after end" errors when streamInput is called + * with an empty iterable before initialization finishes. + */ + await this.initialized; + + for await (const message of messages) { + if (this.abortController.signal.aborted) { + break; + } + this.transport.write(serializeJsonLine(message)); + } + + /** + * In multi-turn mode with MCP servers, wait for first result + * to ensure MCP servers have time to process before next input. + * This prevents race conditions where the next input arrives before + * MCP servers have finished processing the current request. + */ + if ( + !this.isSingleTurn && + this.sdkMcpTransports.size > 0 && + this.firstResultReceivedPromise + ) { + await Promise.race([ + this.firstResultReceivedPromise, + new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, STREAM_CLOSE_TIMEOUT); + }), + ]); + } + + this.endInput(); + } catch (error) { + if (this.abortController.signal.aborted) { + console.log('[Query] Aborted during input streaming'); + this.inputStream.error( + new AbortError('Query aborted during input streaming'), + ); + return; + } + throw error; + } + } + + endInput(): void { + if (this.closed) { + throw new Error('Query is closed'); + } + + if ( + 'endInput' in this.transport && + typeof this.transport.endInput === 'function' + ) { + (this.transport as TransportWithEndInput).endInput(); + } + } + + async interrupt(): Promise { + if (this.closed) { + throw new Error('Query is closed'); + } + + await this.sendControlRequest(ControlRequestType.INTERRUPT); + } + + async setPermissionMode(mode: string): Promise { + if (this.closed) { + throw new Error('Query is closed'); + } + + await this.sendControlRequest(ControlRequestType.SET_PERMISSION_MODE, { + mode, + }); + } + + async setModel(model: string): Promise { + if (this.closed) { + throw new Error('Query is closed'); + } + + await this.sendControlRequest(ControlRequestType.SET_MODEL, { model }); + } + + /** + * Get list of control commands supported by the CLI + * + * @returns Promise resolving to list of supported command names + * @throws Error if query is closed + */ + async supportedCommands(): Promise | null> { + if (this.closed) { + throw new Error('Query is closed'); + } + + return this.sendControlRequest(ControlRequestType.SUPPORTED_COMMANDS); + } + + /** + * Get the status of MCP servers + * + * @returns Promise resolving to MCP server status information + * @throws Error if query is closed + */ + async mcpServerStatus(): Promise | null> { + if (this.closed) { + throw new Error('Query is closed'); + } + + return this.sendControlRequest(ControlRequestType.MCP_SERVER_STATUS); + } + + getSessionId(): string { + return this.sessionId; + } + + isClosed(): boolean { + return this.closed; + } +} diff --git a/packages/sdk-typescript/src/query/createQuery.ts b/packages/sdk-typescript/src/query/createQuery.ts new file mode 100644 index 0000000000..4b87478e3e --- /dev/null +++ b/packages/sdk-typescript/src/query/createQuery.ts @@ -0,0 +1,139 @@ +/** + * Factory function for creating Query instances. + */ + +import type { CLIUserMessage } from '../types/protocol.js'; +import { serializeJsonLine } from '../utils/jsonLines.js'; +import { ProcessTransport } from '../transport/ProcessTransport.js'; +import { parseExecutableSpec } from '../utils/cliPath.js'; +import { Query } from './Query.js'; +import { + QueryOptionsSchema, + type QueryOptions, +} from '../types/queryOptionsSchema.js'; + +export type { QueryOptions }; + +export function query({ + prompt, + options = {}, +}: { + prompt: string | AsyncIterable; + options?: QueryOptions; +}): Query { + // Validate options and obtain normalized executable metadata + const parsedExecutable = validateOptions(options); + + // Determine if this is a single-turn or multi-turn query + // Single-turn: string prompt (simple Q&A) + // Multi-turn: AsyncIterable prompt (streaming conversation) + const isSingleTurn = typeof prompt === 'string'; + + // Resolve CLI specification while preserving explicit runtime directives + const pathToQwenExecutable = + options.pathToQwenExecutable ?? parsedExecutable.executablePath; + + // Use provided abortController or create a new one + const abortController = options.abortController ?? new AbortController(); + + // Create transport with abortController + const transport = new ProcessTransport({ + pathToQwenExecutable, + cwd: options.cwd, + model: options.model, + permissionMode: options.permissionMode, + mcpServers: options.mcpServers, + env: options.env, + abortController, + debug: options.debug, + stderr: options.stderr, + maxSessionTurns: options.maxSessionTurns, + coreTools: options.coreTools, + excludeTools: options.excludeTools, + authType: options.authType, + }); + + // Build query options with abortController + const queryOptions: QueryOptions = { + ...options, + abortController, + }; + + // Create Query + const queryInstance = new Query(transport, queryOptions, isSingleTurn); + + // Handle prompt based on type + if (isSingleTurn) { + // For single-turn queries, send the prompt directly via transport + const stringPrompt = prompt as string; + const message: CLIUserMessage = { + type: 'user', + session_id: queryInstance.getSessionId(), + message: { + role: 'user', + content: stringPrompt, + }, + parent_tool_use_id: null, + }; + + (async () => { + try { + await queryInstance.initialized; + transport.write(serializeJsonLine(message)); + } catch (err) { + console.error('[query] Error sending single-turn prompt:', err); + } + })(); + } else { + queryInstance + .streamInput(prompt as AsyncIterable) + .catch((err) => { + console.error('[query] Error streaming input:', err); + }); + } + + return queryInstance; +} + +/** + * Backward compatibility alias + * @deprecated Use query() instead + */ +export const createQuery = query; + +function validateOptions( + options: QueryOptions, +): ReturnType { + // Validate options using Zod schema + const validationResult = QueryOptionsSchema.safeParse(options); + if (!validationResult.success) { + const errors = validationResult.error.errors + .map((err) => `${err.path.join('.')}: ${err.message}`) + .join('; '); + throw new Error(`Invalid QueryOptions: ${errors}`); + } + + // Validate executable path early to provide clear error messages + let parsedExecutable: ReturnType; + try { + parsedExecutable = parseExecutableSpec(options.pathToQwenExecutable); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid pathToQwenExecutable: ${errorMessage}`); + } + + // Validate no MCP server name conflicts (cross-field validation not easily expressible in Zod) + if (options.mcpServers && options.sdkMcpServers) { + const externalNames = Object.keys(options.mcpServers); + const sdkNames = Object.keys(options.sdkMcpServers); + + const conflicts = externalNames.filter((name) => sdkNames.includes(name)); + if (conflicts.length > 0) { + throw new Error( + `MCP server name conflicts between mcpServers and sdkMcpServers: ${conflicts.join(', ')}`, + ); + } + } + + return parsedExecutable; +} diff --git a/packages/sdk-typescript/src/transport/ProcessTransport.ts b/packages/sdk-typescript/src/transport/ProcessTransport.ts new file mode 100644 index 0000000000..1c717f8c20 --- /dev/null +++ b/packages/sdk-typescript/src/transport/ProcessTransport.ts @@ -0,0 +1,392 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import * as readline from 'node:readline'; +import type { Writable, Readable } from 'node:stream'; +import type { TransportOptions } from '../types/types.js'; +import type { Transport } from './Transport.js'; +import { parseJsonLinesStream } from '../utils/jsonLines.js'; +import { prepareSpawnInfo } from '../utils/cliPath.js'; +import { AbortError } from '../types/errors.js'; + +type ExitListener = { + callback: (error?: Error) => void; + handler: (code: number | null, signal: NodeJS.Signals | null) => void; +}; + +export class ProcessTransport implements Transport { + private childProcess: ChildProcess | null = null; + private childStdin: Writable | null = null; + private childStdout: Readable | null = null; + private options: TransportOptions; + private ready = false; + private _exitError: Error | null = null; + private closed = false; + private abortController: AbortController; + private exitListeners: ExitListener[] = []; + private processExitHandler: (() => void) | null = null; + private abortHandler: (() => void) | null = null; + + constructor(options: TransportOptions) { + this.options = options; + this.abortController = + this.options.abortController ?? new AbortController(); + this.initialize(); + } + + private initialize(): void { + try { + if (this.abortController.signal.aborted) { + throw new AbortError('Transport start aborted'); + } + + const cliArgs = this.buildCliArguments(); + const cwd = this.options.cwd ?? process.cwd(); + const env = { ...process.env, ...this.options.env }; + + const spawnInfo = prepareSpawnInfo(this.options.pathToQwenExecutable); + + const stderrMode = + this.options.debug || this.options.stderr ? 'pipe' : 'ignore'; + + this.logForDebugging( + `Spawning CLI (${spawnInfo.type}): ${spawnInfo.command} ${[...spawnInfo.args, ...cliArgs].join(' ')}`, + ); + + this.childProcess = spawn( + spawnInfo.command, + [...spawnInfo.args, ...cliArgs], + { + cwd, + env, + stdio: ['pipe', 'pipe', stderrMode], + signal: this.abortController.signal, + }, + ); + + this.childStdin = this.childProcess.stdin; + this.childStdout = this.childProcess.stdout; + + if (this.options.debug || this.options.stderr) { + this.childProcess.stderr?.on('data', (data) => { + this.logForDebugging(data.toString()); + }); + } + + const cleanup = (): void => { + if (this.childProcess && !this.childProcess.killed) { + this.childProcess.kill('SIGTERM'); + } + }; + + this.processExitHandler = cleanup; + this.abortHandler = cleanup; + process.on('exit', this.processExitHandler); + this.abortController.signal.addEventListener('abort', this.abortHandler); + + this.setupEventHandlers(); + + this.ready = true; + } catch (error) { + this.ready = false; + throw error; + } + } + + private setupEventHandlers(): void { + if (!this.childProcess) return; + + this.childProcess.on('error', (error) => { + this.ready = false; + if (this.abortController.signal.aborted) { + this._exitError = new AbortError('CLI process aborted by user'); + } else { + this._exitError = new Error(`CLI process error: ${error.message}`); + this.logForDebugging(this._exitError.message); + } + }); + + this.childProcess.on('close', (code, signal) => { + this.ready = false; + if (this.abortController.signal.aborted) { + this._exitError = new AbortError('CLI process aborted by user'); + } else { + const error = this.getProcessExitError(code, signal); + if (error) { + this._exitError = error; + this.logForDebugging(error.message); + } + } + + const error = this._exitError; + for (const listener of this.exitListeners) { + try { + listener.callback(error || undefined); + } catch (err) { + this.logForDebugging(`Exit listener error: ${err}`); + } + } + }); + } + + private getProcessExitError( + code: number | null, + signal: NodeJS.Signals | null, + ): Error | undefined { + if (code !== 0 && code !== null) { + return new Error(`CLI process exited with code ${code}`); + } else if (signal) { + return new Error(`CLI process terminated by signal ${signal}`); + } + return undefined; + } + private buildCliArguments(): string[] { + const args: string[] = [ + '--input-format', + 'stream-json', + '--output-format', + 'stream-json', + ]; + + if (this.options.model) { + args.push('--model', this.options.model); + } + + if (this.options.permissionMode) { + args.push('--approval-mode', this.options.permissionMode); + } + + if (this.options.maxSessionTurns !== undefined) { + args.push('--max-session-turns', String(this.options.maxSessionTurns)); + } + + if (this.options.coreTools && this.options.coreTools.length > 0) { + args.push('--core-tools', this.options.coreTools.join(',')); + } + + if (this.options.excludeTools && this.options.excludeTools.length > 0) { + args.push('--exclude-tools', this.options.excludeTools.join(',')); + } + + if (this.options.authType) { + args.push('--auth-type', this.options.authType); + } + + return args; + } + + async close(): Promise { + if (this.childStdin) { + this.childStdin.end(); + this.childStdin = null; + } + + if (this.processExitHandler) { + process.off('exit', this.processExitHandler); + this.processExitHandler = null; + } + + if (this.abortHandler) { + this.abortController.signal.removeEventListener( + 'abort', + this.abortHandler, + ); + this.abortHandler = null; + } + + for (const { handler } of this.exitListeners) { + this.childProcess?.off('close', handler); + } + this.exitListeners = []; + + if (this.childProcess && !this.childProcess.killed) { + this.childProcess.kill('SIGTERM'); + setTimeout(() => { + if (this.childProcess && !this.childProcess.killed) { + this.childProcess.kill('SIGKILL'); + } + }, 5000); + } + + this.ready = false; + this.closed = true; + } + + async waitForExit(): Promise { + if (!this.childProcess) { + if (this._exitError) { + throw this._exitError; + } + return; + } + + if (this.childProcess.exitCode !== null || this.childProcess.killed) { + if (this._exitError) { + throw this._exitError; + } + return; + } + + return new Promise((resolve, reject) => { + const exitHandler = ( + code: number | null, + signal: NodeJS.Signals | null, + ) => { + if (this.abortController.signal.aborted) { + reject(new AbortError('Operation aborted')); + return; + } + + const error = this.getProcessExitError(code, signal); + if (error) { + reject(error); + } else { + resolve(); + } + }; + + this.childProcess!.once('close', exitHandler); + + const errorHandler = (error: Error) => { + this.childProcess!.off('close', exitHandler); + reject(error); + }; + + this.childProcess!.once('error', errorHandler); + + this.childProcess!.once('close', () => { + this.childProcess!.off('error', errorHandler); + }); + }); + } + + write(message: string): void { + if (this.abortController.signal.aborted) { + throw new AbortError('Cannot write: operation aborted'); + } + + if (!this.ready || !this.childStdin) { + throw new Error('Transport not ready for writing'); + } + + if (this.closed) { + throw new Error('Cannot write to closed transport'); + } + + if (this.childStdin.writableEnded) { + throw new Error('Cannot write to ended stream'); + } + + if (this.childProcess?.killed || this.childProcess?.exitCode !== null) { + throw new Error('Cannot write to terminated process'); + } + + if (this._exitError) { + throw new Error( + `Cannot write to process that exited with error: ${this._exitError.message}`, + ); + } + + if (process.env['DEBUG']) { + this.logForDebugging( + `[ProcessTransport] Writing to stdin (${message.length} bytes): ${message.substring(0, 100)}`, + ); + } + + try { + const written = this.childStdin.write(message); + if (!written) { + this.logForDebugging( + `[ProcessTransport] Write buffer full (${message.length} bytes), data queued. Waiting for drain event...`, + ); + } else if (process.env['DEBUG']) { + this.logForDebugging( + `[ProcessTransport] Write successful (${message.length} bytes)`, + ); + } + } catch (error) { + this.ready = false; + throw new Error( + `Failed to write to stdin: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + async *readMessages(): AsyncGenerator { + if (!this.childStdout) { + throw new Error('Cannot read messages: process not started'); + } + + const rl = readline.createInterface({ + input: this.childStdout, + crlfDelay: Infinity, + terminal: false, + }); + + try { + for await (const message of parseJsonLinesStream( + rl, + 'ProcessTransport', + )) { + yield message; + } + + await this.waitForExit(); + } finally { + rl.close(); + } + } + + get isReady(): boolean { + return this.ready; + } + + get exitError(): Error | null { + return this._exitError; + } + + onExit(callback: (error?: Error) => void): () => void { + if (!this.childProcess) { + return () => {}; + } + + const handler = (code: number | null, signal: NodeJS.Signals | null) => { + const error = this.getProcessExitError(code, signal); + callback(error); + }; + + this.childProcess.on('close', handler); + this.exitListeners.push({ callback, handler }); + + return () => { + if (this.childProcess) { + this.childProcess.off('close', handler); + } + const index = this.exitListeners.findIndex((l) => l.handler === handler); + if (index !== -1) { + this.exitListeners.splice(index, 1); + } + }; + } + + endInput(): void { + if (this.childStdin) { + this.childStdin.end(); + } + } + + getInputStream(): Writable | undefined { + return this.childStdin || undefined; + } + + getOutputStream(): Readable | undefined { + return this.childStdout || undefined; + } + + private logForDebugging(message: string): void { + if (this.options.debug || process.env['DEBUG']) { + process.stderr.write(`[ProcessTransport] ${message}\n`); + } + if (this.options.stderr) { + this.options.stderr(message); + } + } +} diff --git a/packages/sdk-typescript/src/transport/Transport.ts b/packages/sdk-typescript/src/transport/Transport.ts new file mode 100644 index 0000000000..cbfb1b7ade --- /dev/null +++ b/packages/sdk-typescript/src/transport/Transport.ts @@ -0,0 +1,22 @@ +/** + * Transport interface for SDK-CLI communication + * + * The Transport abstraction enables communication between SDK and CLI via different mechanisms: + * - ProcessTransport: Local subprocess via stdin/stdout (initial implementation) + * - HttpTransport: Remote CLI via HTTP (future) + * - WebSocketTransport: Remote CLI via WebSocket (future) + */ + +export interface Transport { + close(): Promise; + + waitForExit(): Promise; + + write(message: string): void; + + readMessages(): AsyncGenerator; + + readonly isReady: boolean; + + readonly exitError: Error | null; +} diff --git a/packages/sdk-typescript/src/types/errors.ts b/packages/sdk-typescript/src/types/errors.ts new file mode 100644 index 0000000000..21f503a65f --- /dev/null +++ b/packages/sdk-typescript/src/types/errors.ts @@ -0,0 +1,17 @@ +export class AbortError extends Error { + constructor(message = 'Operation aborted') { + super(message); + this.name = 'AbortError'; + Object.setPrototypeOf(this, AbortError.prototype); + } +} + +export function isAbortError(error: unknown): error is AbortError { + return ( + error instanceof AbortError || + (typeof error === 'object' && + error !== null && + 'name' in error && + error.name === 'AbortError') + ); +} diff --git a/packages/sdk-typescript/src/types/protocol.ts b/packages/sdk-typescript/src/types/protocol.ts new file mode 100644 index 0000000000..399221e0a6 --- /dev/null +++ b/packages/sdk-typescript/src/types/protocol.ts @@ -0,0 +1,560 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +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 CLIUserMessage { + type: 'user'; + uuid?: string; + session_id: string; + message: APIUserMessage; + parent_tool_use_id: string | null; + options?: Record; +} + +export interface CLIAssistantMessage { + type: 'assistant'; + uuid: string; + session_id: string; + message: APIAssistantMessage; + parent_tool_use_id: string | null; +} + +export interface CLISystemMessage { + type: 'system'; + subtype: string; + uuid: string; + session_id: string; + data?: unknown; + cwd?: string; + tools?: string[]; + mcp_servers?: Array<{ + name: string; + status: string; + }>; + model?: string; + permissionMode?: string; + slash_commands?: string[]; + apiKeySource?: string; + qwen_code_version?: string; + output_style?: string; + agents?: string[]; + skills?: string[]; + capabilities?: Record; + compact_metadata?: { + trigger: 'manual' | 'auto'; + pre_tokens: number; + }; +} + +export interface CLIResultMessageSuccess { + 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 CLIResultMessageError { + 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 CLIResultMessage = CLIResultMessageSuccess | CLIResultMessageError; + +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 CLIPartialAssistantMessage { + 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; +} + +export interface CLIControlInitializeRequest { + subtype: 'initialize'; + hooks?: HookRegistration[] | null; + sdkMcpServers?: Record; + 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 CLI message types + */ +export type CLIMessage = + | CLIUserMessage + | CLIAssistantMessage + | CLISystemMessage + | CLIResultMessage + | CLIPartialAssistantMessage; + +export function isCLIUserMessage(msg: any): msg is CLIUserMessage { + return ( + msg && typeof msg === 'object' && msg.type === 'user' && 'message' in msg + ); +} + +export function isCLIAssistantMessage(msg: any): msg is CLIAssistantMessage { + 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 isCLISystemMessage(msg: any): msg is CLISystemMessage { + return ( + msg && + typeof msg === 'object' && + msg.type === 'system' && + 'subtype' in msg && + 'uuid' in msg && + 'session_id' in msg + ); +} + +export function isCLIResultMessage(msg: any): msg is CLIResultMessage { + 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 isCLIPartialAssistantMessage( + msg: any, +): msg is CLIPartialAssistantMessage { + 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-typescript/src/types/queryOptionsSchema.ts b/packages/sdk-typescript/src/types/queryOptionsSchema.ts new file mode 100644 index 0000000000..d3a548af93 --- /dev/null +++ b/packages/sdk-typescript/src/types/queryOptionsSchema.ts @@ -0,0 +1,86 @@ +import { z } from 'zod'; +import type { CanUseTool } from './types.js'; +import type { SubagentConfig } from './protocol.js'; + +export const ExternalMcpServerConfigSchema = z.object({ + command: z.string().min(1, 'Command must be a non-empty string'), + args: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), +}); + +export const SdkMcpServerConfigSchema = z.object({ + connect: z.custom<(transport: unknown) => Promise>( + (val) => typeof val === 'function', + { message: 'connect must be a function' }, + ), +}); + +export const ModelConfigSchema = z.object({ + model: z.string().optional(), + temp: z.number().optional(), + top_p: z.number().optional(), +}); + +export const RunConfigSchema = z.object({ + max_time_minutes: z.number().optional(), + max_turns: z.number().optional(), +}); + +export const SubagentConfigSchema = z.object({ + name: z.string().min(1, 'Name must be a non-empty string'), + description: z.string().min(1, 'Description must be a non-empty string'), + tools: z.array(z.string()).optional(), + systemPrompt: z.string().min(1, 'System prompt must be a non-empty string'), + filePath: z.string().min(1, 'File path must be a non-empty string'), + modelConfig: ModelConfigSchema.partial().optional(), + runConfig: RunConfigSchema.partial().optional(), + color: z.string().optional(), + isBuiltin: z.boolean().optional(), +}); + +export const QueryOptionsSchema = z + .object({ + cwd: z.string().optional(), + model: z.string().optional(), + pathToQwenExecutable: z.string().optional(), + env: z.record(z.string(), z.string()).optional(), + permissionMode: z.enum(['default', 'plan', 'auto-edit', 'yolo']).optional(), + canUseTool: z + .custom((val) => typeof val === 'function', { + message: 'canUseTool must be a function', + }) + .optional(), + mcpServers: z.record(z.string(), ExternalMcpServerConfigSchema).optional(), + sdkMcpServers: z.record(z.string(), SdkMcpServerConfigSchema).optional(), + abortController: z.instanceof(AbortController).optional(), + debug: z.boolean().optional(), + stderr: z + .custom< + (message: string) => void + >((val) => typeof val === 'function', { message: 'stderr must be a function' }) + .optional(), + maxSessionTurns: z.number().optional(), + coreTools: z.array(z.string()).optional(), + excludeTools: z.array(z.string()).optional(), + authType: z.enum(['openai', 'qwen-oauth']).optional(), + agents: z + .array( + z.custom( + (val) => + val && + typeof val === 'object' && + 'name' in val && + 'description' in val && + 'systemPrompt' in val && + 'filePath' in val, + { message: 'agents must be an array of SubagentConfig objects' }, + ), + ) + .optional(), + }) + .strict(); + +export type ExternalMcpServerConfig = z.infer< + typeof ExternalMcpServerConfigSchema +>; +export type QueryOptions = z.infer; diff --git a/packages/sdk-typescript/src/types/types.ts b/packages/sdk-typescript/src/types/types.ts new file mode 100644 index 0000000000..d2b9a400a2 --- /dev/null +++ b/packages/sdk-typescript/src/types/types.ts @@ -0,0 +1,57 @@ +import type { PermissionMode, PermissionSuggestion } from './protocol.js'; +import type { ExternalMcpServerConfig } from './queryOptionsSchema.js'; + +export type { PermissionMode }; + +export type JSONSchema = { + type: string; + properties?: Record; + required?: string[]; + description?: string; + [key: string]: unknown; +}; + +export type ToolDefinition = { + name: string; + description: string; + inputSchema: JSONSchema; + handler: (input: TInput) => Promise; +}; + +export type TransportOptions = { + pathToQwenExecutable: string; + cwd?: string; + model?: string; + permissionMode?: PermissionMode; + mcpServers?: Record; + env?: Record; + abortController?: AbortController; + debug?: boolean; + stderr?: (message: string) => void; + maxSessionTurns?: number; + coreTools?: string[]; + excludeTools?: string[]; + authType?: string; +}; + +type ToolInput = Record; + +export type CanUseTool = ( + toolName: string, + input: ToolInput, + options: { + signal: AbortSignal; + suggestions?: PermissionSuggestion[] | null; + }, +) => Promise; + +export type PermissionResult = + | { + behavior: 'allow'; + updatedInput: ToolInput; + } + | { + behavior: 'deny'; + message: string; + interrupt?: boolean; + }; diff --git a/packages/sdk-typescript/src/utils/Stream.ts b/packages/sdk-typescript/src/utils/Stream.ts new file mode 100644 index 0000000000..8a58c0be18 --- /dev/null +++ b/packages/sdk-typescript/src/utils/Stream.ts @@ -0,0 +1,91 @@ +/** + * Async iterable queue for streaming messages between producer and consumer. + */ + +export class Stream implements AsyncIterable { + private returned: (() => void) | undefined; + private queue: T[] = []; + private readResolve: ((result: IteratorResult) => void) | undefined; + private readReject: ((error: Error) => void) | undefined; + private isDone = false; + hasError: Error | undefined; + private started = false; + + constructor(returned?: () => void) { + this.returned = returned; + } + + [Symbol.asyncIterator](): AsyncIterator { + if (this.started) { + throw new Error('Stream can only be iterated once'); + } + this.started = true; + return this; + } + + async next(): Promise> { + // Check queue first - if there are queued items, return immediately + if (this.queue.length > 0) { + return Promise.resolve({ + done: false, + value: this.queue.shift()!, + }); + } + // Check if stream is done + if (this.isDone) { + return Promise.resolve({ done: true, value: undefined }); + } + // Check for errors that occurred before next() was called + // This ensures errors set via error() before iteration starts are properly rejected + if (this.hasError) { + return Promise.reject(this.hasError); + } + // No queued items, not done, no error - set up promise for next value/error + return new Promise>((resolve, reject) => { + this.readResolve = resolve; + this.readReject = reject; + }); + } + + enqueue(value: T): void { + if (this.readResolve) { + const resolve = this.readResolve; + this.readResolve = undefined; + this.readReject = undefined; + resolve({ done: false, value }); + } else { + this.queue.push(value); + } + } + + done(): void { + this.isDone = true; + if (this.readResolve) { + const resolve = this.readResolve; + this.readResolve = undefined; + this.readReject = undefined; + resolve({ done: true, value: undefined }); + } + } + + error(error: Error): void { + this.hasError = error; + // If readReject exists (next() has been called), reject immediately + if (this.readReject) { + const reject = this.readReject; + this.readResolve = undefined; + this.readReject = undefined; + reject(error); + } + // Otherwise, error is stored in hasError and will be rejected when next() is called + // This handles the case where error() is called before the first next() call + } + + return(): Promise> { + this.isDone = true; + if (this.returned) { + this.returned(); + } + return Promise.resolve({ done: true, value: undefined }); + } +} diff --git a/packages/sdk-typescript/src/utils/cliPath.ts b/packages/sdk-typescript/src/utils/cliPath.ts new file mode 100644 index 0000000000..b6101ab30f --- /dev/null +++ b/packages/sdk-typescript/src/utils/cliPath.ts @@ -0,0 +1,365 @@ +/** + * CLI path auto-detection and subprocess spawning utilities + * + * Supports multiple execution modes: + * 1. Native binary: 'qwen' (production) + * 2. Node.js bundle: 'node /path/to/cli.js' (production validation) + * 3. Bun bundle: 'bun /path/to/cli.js' (alternative runtime) + * 4. TypeScript source: 'tsx /path/to/index.ts' (development) + * + * Auto-detection locations for native binary: + * 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 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { execSync } from 'node:child_process'; + +/** + * Executable types supported by the SDK + */ +export type ExecutableType = 'native' | 'node' | 'bun' | 'tsx' | 'deno'; + +/** + * Spawn information for CLI process + */ +export type SpawnInfo = { + /** Command to execute (e.g., 'qwen', 'node', 'bun', 'tsx') */ + command: string; + /** Arguments to pass to command */ + args: string[]; + /** Type of executable detected */ + type: ExecutableType; + /** Original input that was resolved */ + originalInput: string; +}; + +export function findNativeCliPath(): string { + const homeDir = process.env['HOME'] || process.env['USERPROFILE'] || ''; + + const candidates: Array = [ + // 1. Environment variable (highest priority) + process.env['QWEN_CODE_CLI_PATH'], + + // 2. Volta bin + path.join(homeDir, '.volta', 'bin', 'qwen'), + + // 3. Global npm installations + path.join(homeDir, '.npm-global', 'bin', 'qwen'), + + // 4. Common Unix binary locations + '/usr/local/bin/qwen', + + // 5. User local bin + path.join(homeDir, '.local', 'bin', 'qwen'), + + // 6. Node modules bin in home directory + path.join(homeDir, 'node_modules', '.bin', 'qwen'), + + // 7. Yarn global bin + path.join(homeDir, '.yarn', 'bin', 'qwen'), + ]; + + // Find first existing candidate + for (const candidate of candidates) { + if (candidate && fs.existsSync(candidate)) { + return path.resolve(candidate); + } + } + + // Not found - throw helpful error + throw new Error( + 'qwen CLI not found. Please:\n' + + ' 1. Install qwen globally: npm install -g qwen\n' + + ' 2. Or provide explicit executable: query({ pathToQwenExecutable: "/path/to/qwen" })\n' + + ' 3. Or set environment variable: QWEN_CODE_CLI_PATH="/path/to/qwen"\n' + + '\n' + + 'For development/testing, you can also use:\n' + + ' • TypeScript source: query({ pathToQwenExecutable: "/path/to/index.ts" })\n' + + ' • Node.js bundle: query({ pathToQwenExecutable: "/path/to/cli.js" })\n' + + ' • Force specific runtime: query({ pathToQwenExecutable: "bun:/path/to/cli.js" })', + ); +} + +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 + }); + return true; + } catch { + return false; + } +} + +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 + } +} + +/** + * 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 + */ +export function parseExecutableSpec(executableSpec?: string): { + runtime?: string; + executablePath: string; + isExplicitRuntime: boolean; +} { + // Handle empty string case first (before checking for undefined/null) + if ( + executableSpec === '' || + (executableSpec && executableSpec.trim() === '') + ) { + throw new Error('Command name cannot be empty'); + } + + if (!executableSpec) { + // Auto-detect native CLI + return { + executablePath: findNativeCliPath(), + isExplicitRuntime: false, + }; + } + + // Check for runtime prefix (e.g., 'bun:/path/to/cli.js') + const runtimeMatch = executableSpec.match(/^([^:]+):(.+)$/); + if (runtimeMatch) { + const [, runtime, filePath] = runtimeMatch; + if (!runtime || !filePath) { + throw new Error(`Invalid runtime specification: '${executableSpec}'`); + } + + // Validate runtime is supported + const supportedRuntimes = ['node', 'bun', 'tsx', 'deno']; + if (!supportedRuntimes.includes(runtime)) { + throw new Error( + `Unsupported runtime '${runtime}'. Supported runtimes: ${supportedRuntimes.join(', ')}`, + ); + } + + // Validate runtime availability + if (!validateRuntimeAvailability(runtime)) { + throw new Error( + `Runtime '${runtime}' is not available on this system. Please install it first.`, + ); + } + + const resolvedPath = path.resolve(filePath); + + // Validate file exists + 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.', + ); + } + + // Validate file extension matches runtime + 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, + }; + } + + // Check if it's a command name (no path separators) or a file path + const isCommandName = + !executableSpec.includes('/') && !executableSpec.includes('\\'); + + 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 (!/^[a-zA-Z0-9._-]+$/.test(executableSpec)) { + throw new Error( + `Invalid command name '${executableSpec}'. Command names should only contain letters, numbers, dots, hyphens, and underscores.`, + ); + } + + return { + executablePath: executableSpec, + isExplicitRuntime: false, + }; + } + + // It's a file path - validate and resolve + const resolvedPath = path.resolve(executableSpec); + + 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', + ); + } + + // 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 []; + } +} + +/** + * @deprecated Use parseExecutableSpec and prepareSpawnInfo instead + */ +export function resolveCliPath(explicitPath?: string): string { + const parsed = parseExecutableSpec(explicitPath); + return parsed.executablePath; +} + +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 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, + args: [], + type: 'native', + originalInput: executableSpec || '', + }; +} + +/** + * @deprecated Use prepareSpawnInfo() instead + */ +export function findCliPath(): string { + return findNativeCliPath(); +} diff --git a/packages/sdk-typescript/src/utils/jsonLines.ts b/packages/sdk-typescript/src/utils/jsonLines.ts new file mode 100644 index 0000000000..e534bf7000 --- /dev/null +++ b/packages/sdk-typescript/src/utils/jsonLines.ts @@ -0,0 +1,65 @@ +export function serializeJsonLine(message: unknown): string { + try { + return JSON.stringify(message) + '\n'; + } catch (error) { + throw new Error( + `Failed to serialize message to JSON: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +export function parseJsonLineSafe( + line: string, + context = 'JsonLines', +): unknown | null { + try { + return JSON.parse(line); + } catch (error) { + console.warn( + `[${context}] Failed to parse JSON line, skipping:`, + line.substring(0, 100), + error instanceof Error ? error.message : String(error), + ); + return null; + } +} + +export function isValidMessage(message: unknown): boolean { + return ( + message !== null && + typeof message === 'object' && + 'type' in message && + typeof (message as { type: unknown }).type === 'string' + ); +} + +export async function* parseJsonLinesStream( + lines: AsyncIterable, + context = 'JsonLines', +): AsyncGenerator { + for await (const line of lines) { + // Skip empty lines + if (line.trim().length === 0) { + continue; + } + + // Parse with error handling + const message = parseJsonLineSafe(line, context); + + // Skip malformed messages + if (message === null) { + continue; + } + + // Validate message structure + if (!isValidMessage(message)) { + console.warn( + `[${context}] Invalid message structure (missing 'type' field), skipping:`, + line.substring(0, 100), + ); + continue; + } + + yield message; + } +} diff --git a/packages/sdk-typescript/test/e2e/abort-and-lifecycle.test.ts b/packages/sdk-typescript/test/e2e/abort-and-lifecycle.test.ts new file mode 100644 index 0000000000..a97d3db698 --- /dev/null +++ b/packages/sdk-typescript/test/e2e/abort-and-lifecycle.test.ts @@ -0,0 +1,466 @@ +/** + * E2E tests based on abort-and-lifecycle.ts example + * Tests AbortController integration and process lifecycle management + */ + +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import { describe, it, expect } from 'vitest'; +import { + query, + AbortError, + isAbortError, + isCLIAssistantMessage, + type TextBlock, + type ContentBlock, +} from '../../src/index.js'; + +const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!; + +const SHARED_TEST_OPTIONS = { + pathToQwenExecutable: TEST_CLI_PATH, +}; + +describe('AbortController and Process Lifecycle (E2E)', () => { + describe('Basic AbortController Usage', () => { + /* TODO: Currently query does not throw AbortError when aborted */ + it('should support AbortController cancellation', async () => { + const controller = new AbortController(); + + // Abort after 5 seconds + setTimeout(() => { + controller.abort(); + }, 5000); + + const q = query({ + prompt: 'Write a very long story about TypeScript programming', + options: { + ...SHARED_TEST_OPTIONS, + abortController: controller, + debug: false, + }, + }); + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + const textBlocks = message.message.content.filter( + (block): block is TextBlock => block.type === 'text', + ); + const text = textBlocks + .map((b) => b.text) + .join('') + .slice(0, 100); + + // Should receive some content before abort + expect(text.length).toBeGreaterThan(0); + } + } + + // Should not reach here - query should be aborted + expect(false).toBe(true); + } catch (error) { + expect(isAbortError(error)).toBe(true); + } finally { + await q.close(); + } + }); + + it('should handle abort during query execution', async () => { + const controller = new AbortController(); + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + abortController: controller, + debug: false, + }, + }); + + let receivedFirstMessage = false; + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + if (!receivedFirstMessage) { + // Abort immediately after receiving first assistant message + receivedFirstMessage = true; + controller.abort(); + } + } + } + } catch (error) { + expect(isAbortError(error)).toBe(true); + expect(error instanceof AbortError).toBe(true); + // Should have received at least one message before abort + expect(receivedFirstMessage).toBe(true); + } finally { + await q.close(); + } + }); + + it('should handle abort immediately after query starts', async () => { + const controller = new AbortController(); + + const q = query({ + prompt: 'Write a very long essay', + options: { + ...SHARED_TEST_OPTIONS, + abortController: controller, + debug: false, + }, + }); + + // Abort immediately after query initialization + setTimeout(() => { + controller.abort(); + }, 200); + + try { + for await (const _message of q) { + // May or may not receive messages before abort + } + } catch (error) { + expect(isAbortError(error)).toBe(true); + expect(error instanceof AbortError).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('Process Lifecycle Monitoring', () => { + it('should handle normal process completion', async () => { + const q = query({ + prompt: 'Why do we choose to go to the moon?', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + let completedSuccessfully = false; + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + const textBlocks = message.message.content.filter( + (block): block is TextBlock => block.type === 'text', + ); + const text = textBlocks + .map((b) => b.text) + .join('') + .slice(0, 100); + expect(text.length).toBeGreaterThan(0); + } + } + + completedSuccessfully = true; + } catch (error) { + // Should not throw for normal completion + expect(false).toBe(true); + } finally { + await q.close(); + expect(completedSuccessfully).toBe(true); + } + }); + + it('should handle process cleanup after error', async () => { + const q = query({ + prompt: 'Hello world', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + const textBlocks = message.message.content.filter( + (block): block is TextBlock => block.type === 'text', + ); + const text = textBlocks + .map((b) => b.text) + .join('') + .slice(0, 50); + expect(text.length).toBeGreaterThan(0); + } + } + } catch (error) { + // Expected to potentially have errors + } finally { + // Should cleanup successfully even after error + await q.close(); + expect(true).toBe(true); // Cleanup completed + } + }); + }); + + describe('Input Stream Control', () => { + it('should support endInput() method', async () => { + const q = query({ + prompt: 'What is 2 + 2?', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + let receivedResponse = false; + let endInputCalled = false; + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message) && !endInputCalled) { + const textBlocks = message.message.content.filter( + (block: ContentBlock): block is TextBlock => + block.type === 'text', + ); + const text = textBlocks.map((b: TextBlock) => b.text).join(''); + + expect(text.length).toBeGreaterThan(0); + receivedResponse = true; + + // End input after receiving first response + q.endInput(); + endInputCalled = true; + } + } + + expect(receivedResponse).toBe(true); + expect(endInputCalled).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('Error Handling and Recovery', () => { + it('should handle invalid executable path', async () => { + try { + const q = query({ + prompt: 'Hello world', + options: { + pathToQwenExecutable: '/nonexistent/path/to/cli', + debug: false, + }, + }); + + // Should not reach here - query() should throw immediately + for await (const _message of q) { + // Should not reach here + } + + // Should not reach here + expect(false).toBe(true); + } catch (error) { + expect(error instanceof Error).toBe(true); + expect((error as Error).message).toBeDefined(); + expect((error as Error).message).toContain( + 'Invalid pathToQwenExecutable', + ); + } + }); + + it('should throw AbortError with correct properties', async () => { + const controller = new AbortController(); + + const q = query({ + prompt: 'Explain the concept of async programming', + options: { + ...SHARED_TEST_OPTIONS, + abortController: controller, + debug: false, + }, + }); + + // Abort after allowing query to start + setTimeout(() => controller.abort(), 1000); + + try { + for await (const _message of q) { + // May receive some messages before abort + } + } catch (error) { + // Verify error type and helper functions + expect(isAbortError(error)).toBe(true); + expect(error instanceof AbortError).toBe(true); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBeDefined(); + } finally { + await q.close(); + } + }); + }); + + describe('Debugging with stderr callback', () => { + it('should capture stderr messages when debug is enabled', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Why do we choose to go to the moon?', + options: { + ...SHARED_TEST_OPTIONS, + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + const textBlocks = message.message.content.filter( + (block): block is TextBlock => block.type === 'text', + ); + const text = textBlocks + .map((b) => b.text) + .join('') + .slice(0, 50); + expect(text.length).toBeGreaterThan(0); + } + } + } finally { + await q.close(); + expect(stderrMessages.length).toBeGreaterThan(0); + } + }); + + it('should not capture stderr when debug is disabled', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + try { + for await (const _message of q) { + // Consume all messages + } + } finally { + await q.close(); + // Should have minimal or no stderr output when debug is false + expect(stderrMessages.length).toBeLessThan(10); + } + }); + }); + + describe('Abort with Cleanup', () => { + it('should cleanup properly after abort', async () => { + const controller = new AbortController(); + + const q = query({ + prompt: 'Write a very long essay about programming', + options: { + ...SHARED_TEST_OPTIONS, + abortController: controller, + debug: false, + }, + }); + + // Abort immediately + setTimeout(() => controller.abort(), 100); + + try { + for await (const _message of q) { + // May receive some messages before abort + } + } catch (error) { + if (error instanceof AbortError) { + expect(true).toBe(true); // Expected abort error + } else { + throw error; // Unexpected error + } + } finally { + await q.close(); + expect(true).toBe(true); // Cleanup completed after abort + } + }); + + it('should handle multiple abort calls gracefully', async () => { + const controller = new AbortController(); + + const q = query({ + prompt: 'Count to 100', + options: { + ...SHARED_TEST_OPTIONS, + abortController: controller, + debug: false, + }, + }); + + // Multiple abort calls + setTimeout(() => controller.abort(), 100); + setTimeout(() => controller.abort(), 200); + setTimeout(() => controller.abort(), 300); + + try { + for await (const _message of q) { + // Should be interrupted + } + } catch (error) { + expect(isAbortError(error)).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('Resource Management Edge Cases', () => { + it('should handle close() called multiple times', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + // Start the query + const iterator = q[Symbol.asyncIterator](); + await iterator.next(); + + // Close multiple times + await q.close(); + await q.close(); + await q.close(); + + // Should not throw + expect(true).toBe(true); + }); + + it('should handle abort after close', async () => { + const controller = new AbortController(); + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + abortController: controller, + debug: false, + }, + }); + + // Start and close immediately + const iterator = q[Symbol.asyncIterator](); + await iterator.next(); + await q.close(); + + // Abort after close + controller.abort(); + + // Should not throw + expect(true).toBe(true); + }); + }); +}); diff --git a/packages/sdk-typescript/test/e2e/control.test.ts b/packages/sdk-typescript/test/e2e/control.test.ts new file mode 100644 index 0000000000..ea7ecef71d --- /dev/null +++ b/packages/sdk-typescript/test/e2e/control.test.ts @@ -0,0 +1,254 @@ +import { describe, it, expect } from 'vitest'; +import { query } from '../../src/index.js'; +import { + isCLIAssistantMessage, + isCLIResultMessage, + isCLISystemMessage, + type CLIUserMessage, +} from '../../src/types/protocol.js'; + +const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!; + +const SHARED_TEST_OPTIONS = { + pathToQwenExecutable: TEST_CLI_PATH, +}; + +/** + * Factory function that creates a streaming input with a control point. + * After the first message is yielded, the generator waits for a resume signal, + * allowing the test code to call query instance methods like setModel or setPermissionMode. + * + * @param firstMessage - The first user message to send + * @param secondMessage - The second user message to send after control operations + * @returns Object containing the async generator and a resume function + */ +function createStreamingInputWithControlPoint( + firstMessage: string, + secondMessage: string, +): { + generator: AsyncIterable; + resume: () => void; +} { + let resumeResolve: (() => void) | null = null; + const resumePromise = new Promise((resolve) => { + resumeResolve = resolve; + }); + + const generator = (async function* () { + const sessionId = crypto.randomUUID(); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: firstMessage, + }, + parent_tool_use_id: null, + } as CLIUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 200)); + + await resumePromise; + + await new Promise((resolve) => setTimeout(resolve, 200)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: secondMessage, + }, + parent_tool_use_id: null, + } as CLIUserMessage; + })(); + + const resume = () => { + if (resumeResolve) { + resumeResolve(); + } + }; + + return { generator, resume }; +} + +describe('Control Request/Response (E2E)', () => { + describe('System Controller Scope', () => { + it('should set model via control request during streaming input', async () => { + const { generator, resume } = createStreamingInputWithControlPoint( + 'Tell me the model name.', + 'Tell me the model name now again.', + ); + + const q = query({ + prompt: generator, + options: { + ...SHARED_TEST_OPTIONS, + model: 'qwen3-max', + debug: false, + }, + }); + + try { + const resolvers: { + first?: () => void; + second?: () => void; + } = {}; + const firstResponsePromise = new Promise((resolve) => { + resolvers.first = resolve; + }); + const secondResponsePromise = new Promise((resolve) => { + resolvers.second = resolve; + }); + + let firstResponseReceived = false; + let secondResponseReceived = false; + const systemMessages: Array<{ model?: string }> = []; + + // Consume messages in a single loop + (async () => { + for await (const message of q) { + if (isCLISystemMessage(message)) { + systemMessages.push({ model: message.model }); + } + if (isCLIAssistantMessage(message)) { + if (!firstResponseReceived) { + firstResponseReceived = true; + resolvers.first?.(); + } else if (!secondResponseReceived) { + secondResponseReceived = true; + resolvers.second?.(); + } + } + } + })(); + + // Wait for first response + await Promise.race([ + firstResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for first response')), + 10000, + ), + ), + ]); + + expect(firstResponseReceived).toBe(true); + + // Perform control operation: set model + await q.setModel('qwen3-vl-plus'); + + // Resume the input stream + resume(); + + // Wait for second response + await Promise.race([ + secondResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for second response')), + 10000, + ), + ), + ]); + + expect(secondResponseReceived).toBe(true); + + // Verify system messages - model should change from qwen3-max to qwen3-vl-plus + expect(systemMessages.length).toBeGreaterThanOrEqual(2); + expect(systemMessages[0].model).toBeOneOf(['qwen3-max', 'coder-model']); + expect(systemMessages[1].model).toBe('qwen3-vl-plus'); + } finally { + await q.close(); + } + }); + }); + + describe('Permission Controller Scope', () => { + it('should set permission mode via control request during streaming input', async () => { + const { generator, resume } = createStreamingInputWithControlPoint( + 'What is 1 + 1?', + 'What is 2 + 2?', + ); + + const q = query({ + prompt: generator, + options: { + pathToQwenExecutable: TEST_CLI_PATH, + permissionMode: 'default', + debug: false, + }, + }); + + try { + const resolvers: { + first?: () => void; + second?: () => void; + } = {}; + const firstResponsePromise = new Promise((resolve) => { + resolvers.first = resolve; + }); + const secondResponsePromise = new Promise((resolve) => { + resolvers.second = resolve; + }); + + let firstResponseReceived = false; + let permissionModeChanged = false; + let secondResponseReceived = false; + + // Consume messages in a single loop + (async () => { + for await (const message of q) { + if (isCLIAssistantMessage(message) || isCLIResultMessage(message)) { + if (!firstResponseReceived) { + firstResponseReceived = true; + resolvers.first?.(); + } else if (!secondResponseReceived) { + secondResponseReceived = true; + resolvers.second?.(); + } + } + } + })(); + + // Wait for first response + await Promise.race([ + firstResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for first response')), + 10000, + ), + ), + ]); + + expect(firstResponseReceived).toBe(true); + + // Perform control operation: set permission mode + await q.setPermissionMode('yolo'); + permissionModeChanged = true; + + // Resume the input stream + resume(); + + // Wait for second response + await Promise.race([ + secondResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for second response')), + 10000, + ), + ), + ]); + + expect(permissionModeChanged).toBe(true); + expect(secondResponseReceived).toBe(true); + } finally { + await q.close(); + } + }); + }); +}); diff --git a/packages/sdk-typescript/test/e2e/globalSetup.ts b/packages/sdk-typescript/test/e2e/globalSetup.ts new file mode 100644 index 0000000000..44e3e5287b --- /dev/null +++ b/packages/sdk-typescript/test/e2e/globalSetup.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { mkdir, readdir, rm } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const rootDir = join(__dirname, '../..'); +const e2eTestsDir = join(rootDir, '.integration-tests'); +let runDir = ''; + +export async function setup() { + runDir = join(e2eTestsDir, `${Date.now()}`); + await mkdir(runDir, { recursive: true }); + + // Clean up old test runs, but keep the latest few for debugging + try { + const testRuns = await readdir(e2eTestsDir); + if (testRuns.length > 5) { + const oldRuns = testRuns.sort().slice(0, testRuns.length - 5); + await Promise.all( + oldRuns.map((oldRun) => + rm(join(e2eTestsDir, oldRun), { + recursive: true, + force: true, + }), + ), + ); + } + } catch (e) { + console.error('Error cleaning up old test runs:', e); + } + + process.env['E2E_TEST_FILE_DIR'] = runDir; + process.env['QWEN_CLI_E2E_TEST'] = 'true'; + process.env['TEST_CLI_PATH'] = join(rootDir, '../../dist/cli.js'); + + if (process.env['KEEP_OUTPUT']) { + console.log(`Keeping output for test run in: ${runDir}`); + } + process.env['VERBOSE'] = process.env['VERBOSE'] ?? 'false'; + + console.log(`\nE2E test output directory: ${runDir}`); + console.log(`CLI path: ${process.env['TEST_CLI_PATH']}`); +} + +export async function teardown() { + // Cleanup the test run directory unless KEEP_OUTPUT is set + if (process.env['KEEP_OUTPUT'] !== 'true' && runDir) { + await rm(runDir, { recursive: true, force: true }); + } +} diff --git a/packages/sdk-typescript/test/e2e/mcp-server.test.ts b/packages/sdk-typescript/test/e2e/mcp-server.test.ts new file mode 100644 index 0000000000..6bb0f965c6 --- /dev/null +++ b/packages/sdk-typescript/test/e2e/mcp-server.test.ts @@ -0,0 +1,610 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * E2E tests for MCP (Model Context Protocol) server integration via SDK + * Tests that the SDK can properly interact with MCP servers configured in qwen-code + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { query } from '../../src/index.js'; +import { + isCLIAssistantMessage, + isCLIResultMessage, + isCLISystemMessage, + isCLIUserMessage, + type TextBlock, + type ContentBlock, + type CLIMessage, + type ToolUseBlock, + type CLISystemMessage, +} from '../../src/types/protocol.js'; +import { writeFileSync, mkdirSync, chmodSync } from 'node:fs'; +import { join } from 'node:path'; + +const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!; +const E2E_TEST_FILE_DIR = process.env['E2E_TEST_FILE_DIR']!; + +const SHARED_TEST_OPTIONS = { + pathToQwenExecutable: TEST_CLI_PATH, + permissionMode: 'yolo' as const, +}; + +/** + * Helper to extract text from ContentBlock array + */ +function extractText(content: ContentBlock[]): string { + return content + .filter((block): block is TextBlock => block.type === 'text') + .map((block) => block.text) + .join(''); +} + +/** + * Minimal MCP server implementation that doesn't require external dependencies + * This implements the MCP protocol directly using Node.js built-ins + */ +const MCP_SERVER_SCRIPT = `#!/usr/bin/env node +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +const readline = require('readline'); +const fs = require('fs'); + +// Debug logging to stderr (only when MCP_DEBUG or VERBOSE is set) +const debugEnabled = process.env['MCP_DEBUG'] === 'true' || process.env['VERBOSE'] === 'true'; +function debug(msg) { + if (debugEnabled) { + fs.writeSync(2, \`[MCP-DEBUG] \${msg}\\n\`); + } +} + +debug('MCP server starting...'); + +// Simple JSON-RPC implementation for MCP +class SimpleJSONRPC { + constructor() { + this.handlers = new Map(); + this.rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false + }); + + this.rl.on('line', (line) => { + debug(\`Received line: \${line}\`); + try { + const message = JSON.parse(line); + debug(\`Parsed message: \${JSON.stringify(message)}\`); + this.handleMessage(message); + } catch (e) { + debug(\`Parse error: \${e.message}\`); + } + }); + } + + send(message) { + const msgStr = JSON.stringify(message); + debug(\`Sending message: \${msgStr}\`); + process.stdout.write(msgStr + '\\n'); + } + + async handleMessage(message) { + if (message.method && this.handlers.has(message.method)) { + try { + const result = await this.handlers.get(message.method)(message.params || {}); + if (message.id !== undefined) { + this.send({ + jsonrpc: '2.0', + id: message.id, + result + }); + } + } catch (error) { + if (message.id !== undefined) { + this.send({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32603, + message: error.message + } + }); + } + } + } else if (message.id !== undefined) { + this.send({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32601, + message: 'Method not found' + } + }); + } + } + + on(method, handler) { + this.handlers.set(method, handler); + } +} + +// Create MCP server +const rpc = new SimpleJSONRPC(); + +// Handle initialize +rpc.on('initialize', async (params) => { + debug('Handling initialize request'); + return { + protocolVersion: '2024-11-05', + capabilities: { + tools: {} + }, + serverInfo: { + name: 'test-math-server', + version: '1.0.0' + } + }; +}); + +// Handle tools/list +rpc.on('tools/list', async () => { + debug('Handling tools/list request'); + return { + tools: [ + { + name: 'add', + description: 'Add two numbers together', + inputSchema: { + type: 'object', + properties: { + a: { type: 'number', description: 'First number' }, + b: { type: 'number', description: 'Second number' } + }, + required: ['a', 'b'] + } + }, + { + name: 'multiply', + description: 'Multiply two numbers together', + inputSchema: { + type: 'object', + properties: { + a: { type: 'number', description: 'First number' }, + b: { type: 'number', description: 'Second number' } + }, + required: ['a', 'b'] + } + } + ] + }; +}); + +// Handle tools/call +rpc.on('tools/call', async (params) => { + debug(\`Handling tools/call request for tool: \${params.name}\`); + + if (params.name === 'add') { + const { a, b } = params.arguments; + return { + content: [{ + type: 'text', + text: String(a + b) + }] + }; + } + + if (params.name === 'multiply') { + const { a, b } = params.arguments; + return { + content: [{ + type: 'text', + text: String(a * b) + }] + }; + } + + throw new Error('Unknown tool: ' + params.name); +}); + +// Send initialization notification +rpc.send({ + jsonrpc: '2.0', + method: 'initialized' +}); +`; + +describe('MCP Server Integration (E2E)', () => { + let testDir: string; + let serverScriptPath: string; + + beforeAll(() => { + // Use the centralized E2E test directory from globalSetup + testDir = join(E2E_TEST_FILE_DIR, 'mcp-server-test'); + mkdirSync(testDir, { recursive: true }); + + // Write MCP server script + serverScriptPath = join(testDir, 'mcp-server.cjs'); + writeFileSync(serverScriptPath, MCP_SERVER_SCRIPT); + + // Make script executable on Unix-like systems + if (process.platform !== 'win32') { + chmodSync(serverScriptPath, 0o755); + } + }); + + describe('Basic MCP Tool Usage', () => { + it('should use MCP add tool to add two numbers', async () => { + const q = query({ + prompt: + 'Use the add tool to calculate 5 + 10. Just give me the result.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'test-math-server': { + command: 'node', + args: [serverScriptPath], + }, + }, + }, + }); + + const messages: CLIMessage[] = []; + let assistantText = ''; + let foundToolUse = false; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + const toolUseBlock = message.message.content.find( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use', + ); + if (toolUseBlock && toolUseBlock.name === 'add') { + foundToolUse = true; + } + assistantText += extractText(message.message.content); + } + } + + // Validate tool was called + expect(foundToolUse).toBe(true); + + // Validate result contains expected answer + expect(assistantText).toMatch(/15/); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isCLIResultMessage(lastMessage)).toBe(true); + if (isCLIResultMessage(lastMessage)) { + expect(lastMessage.subtype).toBe('success'); + } + } finally { + await q.close(); + } + }); + + it('should use MCP multiply tool to multiply two numbers', async () => { + const q = query({ + prompt: + 'Use the multiply tool to calculate 6 * 7. Just give me the result.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'test-math-server': { + command: 'node', + args: [serverScriptPath], + }, + }, + }, + }); + + const messages: CLIMessage[] = []; + let assistantText = ''; + let foundToolUse = false; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + const toolUseBlock = message.message.content.find( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use', + ); + if (toolUseBlock && toolUseBlock.name === 'multiply') { + foundToolUse = true; + } + assistantText += extractText(message.message.content); + } + } + + // Validate tool was called + expect(foundToolUse).toBe(true); + + // Validate result contains expected answer + expect(assistantText).toMatch(/42/); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isCLIResultMessage(lastMessage)).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('MCP Server Discovery', () => { + it('should list MCP servers in system init message', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'test-math-server': { + command: 'node', + args: [serverScriptPath], + }, + }, + }, + }); + + let systemMessage: CLISystemMessage | null = null; + + try { + for await (const message of q) { + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + break; + } + } + + // Validate MCP server is listed + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.mcp_servers).toBeDefined(); + expect(Array.isArray(systemMessage!.mcp_servers)).toBe(true); + + // Find our test server + const testServer = systemMessage!.mcp_servers?.find( + (server) => server.name === 'test-math-server', + ); + expect(testServer).toBeDefined(); + + // Note: tools are not exposed in the mcp_servers array in system message + // They are available through the MCP protocol but not in the init message + } finally { + await q.close(); + } + }); + }); + + describe('Complex MCP Operations', () => { + it('should chain multiple MCP tool calls', async () => { + const q = query({ + prompt: + 'First use add to calculate 10 + 5, then multiply the result by 2. Give me the final answer.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'test-math-server': { + command: 'node', + args: [serverScriptPath], + }, + }, + }, + }); + + const messages: CLIMessage[] = []; + let assistantText = ''; + const toolCalls: string[] = []; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + const toolUseBlocks = message.message.content.filter( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use', + ); + toolUseBlocks.forEach((block) => { + toolCalls.push(block.name); + }); + assistantText += extractText(message.message.content); + } + } + + // Validate both tools were called + expect(toolCalls).toContain('add'); + expect(toolCalls).toContain('multiply'); + + // Validate result: (10 + 5) * 2 = 30 + expect(assistantText).toMatch(/30/); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isCLIResultMessage(lastMessage)).toBe(true); + } finally { + await q.close(); + } + }); + + it('should handle multiple calls to the same MCP tool', async () => { + const q = query({ + prompt: + 'Use the add tool twice: first add 1 + 2, then add 3 + 4. Tell me both results.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'test-math-server': { + command: 'node', + args: [serverScriptPath], + }, + }, + }, + }); + + const messages: CLIMessage[] = []; + let assistantText = ''; + const addToolCalls: ToolUseBlock[] = []; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + const toolUseBlocks = message.message.content.filter( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use', + ); + toolUseBlocks.forEach((block) => { + if (block.name === 'add') { + addToolCalls.push(block); + } + }); + assistantText += extractText(message.message.content); + } + } + + // Validate add tool was called at least twice + expect(addToolCalls.length).toBeGreaterThanOrEqual(2); + + // Validate results contain expected answers: 3 and 7 + expect(assistantText).toMatch(/3/); + expect(assistantText).toMatch(/7/); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isCLIResultMessage(lastMessage)).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('MCP Tool Message Flow', () => { + it('should receive proper message sequence for MCP tool usage', async () => { + const q = query({ + prompt: 'Use add to calculate 2 + 3', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'test-math-server': { + command: 'node', + args: [serverScriptPath], + }, + }, + }, + }); + + const messageTypes: string[] = []; + let foundToolUse = false; + let foundToolResult = false; + + try { + for await (const message of q) { + messageTypes.push(message.type); + + if (isCLIAssistantMessage(message)) { + const toolUseBlock = message.message.content.find( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use', + ); + if (toolUseBlock) { + foundToolUse = true; + expect(toolUseBlock.name).toBe('add'); + expect(toolUseBlock.input).toBeDefined(); + } + } + + if (isCLIUserMessage(message)) { + const content = message.message.content; + const contentArray = Array.isArray(content) + ? content + : [{ type: 'text', text: content }]; + const toolResultBlock = contentArray.find( + (block) => block.type === 'tool_result', + ); + if (toolResultBlock) { + foundToolResult = true; + } + } + } + + // Validate message flow + expect(foundToolUse).toBe(true); + expect(foundToolResult).toBe(true); + expect(messageTypes).toContain('system'); + expect(messageTypes).toContain('assistant'); + expect(messageTypes).toContain('user'); + expect(messageTypes).toContain('result'); + + // Result should be last message + expect(messageTypes[messageTypes.length - 1]).toBe('result'); + } finally { + await q.close(); + } + }); + }); + + describe('Error Handling', () => { + it('should handle gracefully when MCP tool is not available', async () => { + const q = query({ + prompt: 'Use the subtract tool to calculate 10 - 5', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'test-math-server': { + command: 'node', + args: [serverScriptPath], + }, + }, + }, + }); + + const messages: CLIMessage[] = []; + let assistantText = ''; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + assistantText += extractText(message.message.content); + } + } + + // Should complete without crashing + const lastMessage = messages[messages.length - 1]; + expect(isCLIResultMessage(lastMessage)).toBe(true); + + // Assistant should indicate tool is not available or provide alternative + expect(assistantText.length).toBeGreaterThan(0); + } finally { + await q.close(); + } + }); + }); +}); diff --git a/packages/sdk-typescript/test/e2e/multi-turn.test.ts b/packages/sdk-typescript/test/e2e/multi-turn.test.ts new file mode 100644 index 0000000000..8e79898ebc --- /dev/null +++ b/packages/sdk-typescript/test/e2e/multi-turn.test.ts @@ -0,0 +1,479 @@ +/** + * E2E tests based on multi-turn.ts example + * Tests multi-turn conversation functionality with real CLI + */ + +import { describe, it, expect } from 'vitest'; +import { query } from '../../src/index.js'; +import { + isCLIUserMessage, + isCLIAssistantMessage, + isCLISystemMessage, + isCLIResultMessage, + isCLIPartialAssistantMessage, + isControlRequest, + isControlResponse, + isControlCancel, + type CLIUserMessage, + type CLIAssistantMessage, + type TextBlock, + type ContentBlock, + type CLIMessage, + type ControlMessage, + type ToolUseBlock, +} from '../../src/types/protocol.js'; +const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!; + +const SHARED_TEST_OPTIONS = { + pathToQwenExecutable: TEST_CLI_PATH, +}; + +/** + * Determine the message type using protocol type guards + */ +function getMessageType(message: CLIMessage | ControlMessage): string { + if (isCLIUserMessage(message)) { + return '🧑 USER'; + } else if (isCLIAssistantMessage(message)) { + return '🤖 ASSISTANT'; + } else if (isCLISystemMessage(message)) { + return `🖥️ SYSTEM(${message.subtype})`; + } else if (isCLIResultMessage(message)) { + return `✅ RESULT(${message.subtype})`; + } else if (isCLIPartialAssistantMessage(message)) { + return '⏳ STREAM_EVENT'; + } else if (isControlRequest(message)) { + return `🎮 CONTROL_REQUEST(${message.request.subtype})`; + } else if (isControlResponse(message)) { + return `📭 CONTROL_RESPONSE(${message.response.subtype})`; + } else if (isControlCancel(message)) { + return '🛑 CONTROL_CANCEL'; + } else { + return '❓ UNKNOWN'; + } +} + +/** + * Helper to extract text from ContentBlock array + */ +function extractText(content: ContentBlock[]): string { + return content + .filter((block): block is TextBlock => block.type === 'text') + .map((block) => block.text) + .join(''); +} + +describe('Multi-Turn Conversations (E2E)', () => { + describe('AsyncIterable Prompt Support', () => { + it('should handle multi-turn conversation using AsyncIterable prompt', async () => { + // Create multi-turn conversation generator + async function* createMultiTurnConversation(): AsyncIterable { + const sessionId = crypto.randomUUID(); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'What is 1 + 1?', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 100)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'What is 2 + 2?', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 100)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'What is 3 + 3?', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + } + + // Create multi-turn query using AsyncIterable prompt + const q = query({ + prompt: createMultiTurnConversation(), + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + const assistantMessages: CLIAssistantMessage[] = []; + const assistantTexts: string[] = []; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + assistantMessages.push(message); + const text = extractText(message.message.content); + assistantTexts.push(text); + } + } + + expect(messages.length).toBeGreaterThan(0); + expect(assistantMessages.length).toBeGreaterThanOrEqual(3); + + // Validate content of responses + expect(assistantTexts[0]).toMatch(/2/); + expect(assistantTexts[1]).toMatch(/4/); + expect(assistantTexts[2]).toMatch(/6/); + } finally { + await q.close(); + } + }); + + it('should maintain session context across turns', async () => { + async function* createContextualConversation(): AsyncIterable { + const sessionId = crypto.randomUUID(); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: + 'Suppose we have 3 rabbits and 4 carrots. How many animals are there?', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 200)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'How many animals are there? Only output the number', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + } + + const q = query({ + prompt: createContextualConversation(), + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const assistantMessages: CLIAssistantMessage[] = []; + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + assistantMessages.push(message); + } + } + + expect(assistantMessages.length).toBeGreaterThanOrEqual(2); + + // The second response should reference the color blue + const secondResponse = extractText( + assistantMessages[1].message.content, + ); + expect(secondResponse.toLowerCase()).toContain('3'); + } finally { + await q.close(); + } + }); + }); + + describe('Tool Usage in Multi-Turn', () => { + it('should handle tool usage across multiple turns', async () => { + async function* createToolConversation(): AsyncIterable { + const sessionId = crypto.randomUUID(); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'Create a file named test.txt with content "hello"', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 200)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'Now read the test.txt file', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + } + + const q = query({ + prompt: createToolConversation(), + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'yolo', + cwd: '/tmp', + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + let toolUseCount = 0; + const assistantMessages: CLIAssistantMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + assistantMessages.push(message); + const hasToolUseBlock = message.message.content.some( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use', + ); + if (hasToolUseBlock) { + toolUseCount++; + } + } + } + + expect(messages.length).toBeGreaterThan(0); + expect(toolUseCount).toBeGreaterThan(0); + expect(assistantMessages.length).toBeGreaterThanOrEqual(2); + + // Validate second response mentions the file content + const secondResponse = extractText( + assistantMessages[assistantMessages.length - 1].message.content, + ); + expect(secondResponse.toLowerCase()).toMatch(/hello|test\.txt/); + } finally { + await q.close(); + } + }); + }); + + describe('Message Flow and Sequencing', () => { + it('should process messages in correct sequence', async () => { + async function* createSequentialConversation(): AsyncIterable { + const sessionId = crypto.randomUUID(); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'First question: What is 1 + 1?', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 100)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'Second question: What is 2 + 2?', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + } + + const q = query({ + prompt: createSequentialConversation(), + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const messageSequence: string[] = []; + const assistantResponses: string[] = []; + + try { + for await (const message of q) { + const messageType = getMessageType(message); + messageSequence.push(messageType); + + if (isCLIAssistantMessage(message)) { + const text = extractText(message.message.content); + assistantResponses.push(text); + } + } + + expect(messageSequence.length).toBeGreaterThan(0); + expect(assistantResponses.length).toBeGreaterThanOrEqual(2); + + // Should end with result + expect(messageSequence[messageSequence.length - 1]).toContain('RESULT'); + + // Should have assistant responses + expect(messageSequence.some((type) => type.includes('ASSISTANT'))).toBe( + true, + ); + } finally { + await q.close(); + } + }); + + it('should handle conversation completion correctly', async () => { + async function* createSimpleConversation(): AsyncIterable { + const sessionId = crypto.randomUUID(); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'Hello', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 100)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'Goodbye', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + } + + const q = query({ + prompt: createSimpleConversation(), + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + let completedNaturally = false; + let messageCount = 0; + + try { + for await (const message of q) { + messageCount++; + + if (isCLIResultMessage(message)) { + completedNaturally = true; + expect(message.subtype).toBe('success'); + } + } + + expect(messageCount).toBeGreaterThan(0); + expect(completedNaturally).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('Error Handling in Multi-Turn', () => { + it('should handle empty conversation gracefully', async () => { + async function* createEmptyConversation(): AsyncIterable { + // Generator that yields nothing + /* eslint-disable no-constant-condition */ + if (false) { + yield {} as CLIUserMessage; // Unreachable, but satisfies TypeScript + } + } + + const q = query({ + prompt: createEmptyConversation(), + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Should handle empty conversation without crashing + expect(true).toBe(true); + } finally { + await q.close(); + } + }); + + it('should handle conversation with delays', async () => { + async function* createDelayedConversation(): AsyncIterable { + const sessionId = crypto.randomUUID(); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'First message', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + + // Longer delay to test patience + await new Promise((resolve) => setTimeout(resolve, 500)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'Second message after delay', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + } + + const q = query({ + prompt: createDelayedConversation(), + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const assistantMessages: CLIAssistantMessage[] = []; + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + assistantMessages.push(message); + } + } + + expect(assistantMessages.length).toBeGreaterThanOrEqual(2); + } finally { + await q.close(); + } + }); + }); +}); diff --git a/packages/sdk-typescript/test/e2e/permission-control.test.ts b/packages/sdk-typescript/test/e2e/permission-control.test.ts new file mode 100644 index 0000000000..afcef8b176 --- /dev/null +++ b/packages/sdk-typescript/test/e2e/permission-control.test.ts @@ -0,0 +1,676 @@ +/** + * E2E tests for permission control features: + * - canUseTool callback parameter + * - setPermissionMode API + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { query } from '../../src/index.js'; +import { + isCLIAssistantMessage, + isCLIResultMessage, + isCLIUserMessage, + type CLIUserMessage, + type ToolUseBlock, + type ContentBlock, +} from '../../src/types/protocol.js'; +const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!; +const TEST_TIMEOUT = 30000; + +const SHARED_TEST_OPTIONS = { + pathToQwenExecutable: TEST_CLI_PATH, + debug: false, + env: {}, +}; + +/** + * Factory function that creates a streaming input with a control point. + * After the first message is yielded, the generator waits for a resume signal, + * allowing the test code to call query instance methods like setPermissionMode. + */ +function createStreamingInputWithControlPoint( + firstMessage: string, + secondMessage: string, +): { + generator: AsyncIterable; + resume: () => void; +} { + let resumeResolve: (() => void) | null = null; + const resumePromise = new Promise((resolve) => { + resumeResolve = resolve; + }); + + const generator = (async function* () { + const sessionId = crypto.randomUUID(); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: firstMessage, + }, + parent_tool_use_id: null, + } as CLIUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 200)); + + await resumePromise; + + await new Promise((resolve) => setTimeout(resolve, 200)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: secondMessage, + }, + parent_tool_use_id: null, + } as CLIUserMessage; + })(); + + const resume = () => { + if (resumeResolve) { + resumeResolve(); + } + }; + + return { generator, resume }; +} + +describe('Permission Control (E2E)', () => { + beforeAll(() => { + //process.env['DEBUG'] = '1'; + }); + + afterAll(() => { + delete process.env['DEBUG']; + }); + + describe('canUseTool callback parameter', () => { + it('should invoke canUseTool callback when tool is requested', async () => { + const toolCalls: Array<{ + toolName: string; + input: Record; + }> = []; + + const q = query({ + prompt: 'Write a js hello world to file.', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + + canUseTool: async (toolName, input) => { + toolCalls.push({ toolName, input }); + /* + { + behavior: 'allow', + updatedInput: input, + }; + */ + return { + behavior: 'deny', + message: 'Tool execution denied by user.', + }; + }, + }, + }); + + try { + let hasToolUse = false; + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + const toolUseBlock = message.message.content.find( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use', + ); + if (toolUseBlock) { + hasToolUse = true; + } + } + } + + expect(hasToolUse).toBe(true); + expect(toolCalls.length).toBeGreaterThan(0); + expect(toolCalls[0].toolName).toBeDefined(); + expect(toolCalls[0].input).toBeDefined(); + } finally { + await q.close(); + } + }); + + it('should allow tool execution when canUseTool returns allow', async () => { + let callbackInvoked = false; + + const q = query({ + prompt: 'Create a file named hello.txt with content "world"', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: '/tmp', + canUseTool: async (toolName, input) => { + callbackInvoked = true; + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + let hasToolResult = false; + for await (const message of q) { + if (isCLIUserMessage(message)) { + if ( + Array.isArray(message.message.content) && + message.message.content.some( + (block) => block.type === 'tool_result', + ) + ) { + hasToolResult = true; + } + } + } + + expect(callbackInvoked).toBe(true); + expect(hasToolResult).toBe(true); + } finally { + await q.close(); + } + }); + + it('should deny tool execution when canUseTool returns deny', async () => { + let callbackInvoked = false; + + const q = query({ + prompt: 'Create a file named test.txt', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + canUseTool: async () => { + callbackInvoked = true; + return { + behavior: 'deny', + message: 'Tool execution denied by test', + }; + }, + }, + }); + + try { + for await (const _message of q) { + // Consume all messages + } + + expect(callbackInvoked).toBe(true); + // Tool use might still appear, but execution should be denied + // The exact behavior depends on CLI implementation + } finally { + await q.close(); + } + }); + + it('should pass suggestions to canUseTool callback', async () => { + let receivedSuggestions: unknown = null; + + const q = query({ + prompt: 'Create a file named data.txt', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: '/tmp', + canUseTool: async (toolName, input, options) => { + receivedSuggestions = options?.suggestions; + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + for await (const _message of q) { + // Consume all messages + } + + // Suggestions may be null or an array, depending on CLI implementation + expect(receivedSuggestions !== undefined).toBe(true); + } finally { + await q.close(); + } + }); + + it('should pass abort signal to canUseTool callback', async () => { + let receivedSignal: AbortSignal | undefined = undefined; + + const q = query({ + prompt: 'Create a file named signal.txt', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: '/tmp', + canUseTool: async (toolName, input, options) => { + receivedSignal = options?.signal; + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + for await (const _message of q) { + // Consume all messages + } + + expect(receivedSignal).toBeDefined(); + expect(receivedSignal).toBeInstanceOf(AbortSignal); + } finally { + await q.close(); + } + }); + + it('should allow updatedInput modification in canUseTool callback', async () => { + const originalInputs: Record[] = []; + const updatedInputs: Record[] = []; + + const q = query({ + prompt: 'Create a file named modified.txt', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: '/tmp', + canUseTool: async (toolName, input) => { + originalInputs.push({ ...input }); + const updatedInput = { + ...input, + modified: true, + testKey: 'testValue', + }; + updatedInputs.push(updatedInput); + return { + behavior: 'allow', + updatedInput, + }; + }, + }, + }); + + try { + for await (const _message of q) { + // Consume all messages + } + + expect(originalInputs.length).toBeGreaterThan(0); + expect(updatedInputs.length).toBeGreaterThan(0); + expect(updatedInputs[0]?.['modified']).toBe(true); + expect(updatedInputs[0]?.['testKey']).toBe('testValue'); + } finally { + await q.close(); + } + }); + + it('should default to deny when canUseTool is not provided', async () => { + const q = query({ + prompt: 'Create a file named default.txt', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: '/tmp', + // canUseTool not provided + }, + }); + + try { + // When canUseTool is not provided, tools should be denied by default + // The exact behavior depends on CLI implementation + for await (const _message of q) { + // Consume all messages + } + // Test passes if no errors occur + expect(true).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('setPermissionMode API', () => { + it('should change permission mode from default to yolo', async () => { + const { generator, resume } = createStreamingInputWithControlPoint( + 'What is 1 + 1?', + 'What is 2 + 2?', + ); + + const q = query({ + prompt: generator, + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + debug: true, + }, + }); + + try { + const resolvers: { + first?: () => void; + second?: () => void; + } = {}; + const firstResponsePromise = new Promise((resolve) => { + resolvers.first = resolve; + }); + const secondResponsePromise = new Promise((resolve) => { + resolvers.second = resolve; + }); + + let firstResponseReceived = false; + let secondResponseReceived = false; + + (async () => { + for await (const message of q) { + if (isCLIAssistantMessage(message) || isCLIResultMessage(message)) { + if (!firstResponseReceived) { + firstResponseReceived = true; + resolvers.first?.(); + } else if (!secondResponseReceived) { + secondResponseReceived = true; + resolvers.second?.(); + } + } + } + })(); + + await Promise.race([ + firstResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for first response')), + 40000, + ), + ), + ]); + + expect(firstResponseReceived).toBe(true); + + await q.setPermissionMode('yolo'); + + resume(); + + await Promise.race([ + secondResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for second response')), + 40000, + ), + ), + ]); + + expect(secondResponseReceived).toBe(true); + } finally { + await q.close(); + } + }); + + it('should change permission mode from yolo to plan', async () => { + const { generator, resume } = createStreamingInputWithControlPoint( + 'What is 3 + 3?', + 'What is 4 + 4?', + ); + + const q = query({ + prompt: generator, + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'yolo', + }, + }); + + try { + const resolvers: { + first?: () => void; + second?: () => void; + } = {}; + const firstResponsePromise = new Promise((resolve) => { + resolvers.first = resolve; + }); + const secondResponsePromise = new Promise((resolve) => { + resolvers.second = resolve; + }); + + let firstResponseReceived = false; + let secondResponseReceived = false; + + (async () => { + for await (const message of q) { + if (isCLIAssistantMessage(message) || isCLIResultMessage(message)) { + if (!firstResponseReceived) { + firstResponseReceived = true; + resolvers.first?.(); + } else if (!secondResponseReceived) { + secondResponseReceived = true; + resolvers.second?.(); + } + } + } + })(); + + await Promise.race([ + firstResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for first response')), + 10000, + ), + ), + ]); + + expect(firstResponseReceived).toBe(true); + + await q.setPermissionMode('plan'); + + resume(); + + await Promise.race([ + secondResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for second response')), + 10000, + ), + ), + ]); + + expect(secondResponseReceived).toBe(true); + } finally { + await q.close(); + } + }); + + it('should change permission mode to auto-edit', async () => { + const { generator, resume } = createStreamingInputWithControlPoint( + 'What is 5 + 5?', + 'What is 6 + 6?', + ); + + const q = query({ + prompt: generator, + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + }, + }); + + try { + const resolvers: { + first?: () => void; + second?: () => void; + } = {}; + const firstResponsePromise = new Promise((resolve) => { + resolvers.first = resolve; + }); + const secondResponsePromise = new Promise((resolve) => { + resolvers.second = resolve; + }); + + let firstResponseReceived = false; + let secondResponseReceived = false; + + (async () => { + for await (const message of q) { + if (isCLIAssistantMessage(message) || isCLIResultMessage(message)) { + if (!firstResponseReceived) { + firstResponseReceived = true; + resolvers.first?.(); + } else if (!secondResponseReceived) { + secondResponseReceived = true; + resolvers.second?.(); + } + } + } + })(); + + await Promise.race([ + firstResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for first response')), + 10000, + ), + ), + ]); + + expect(firstResponseReceived).toBe(true); + + await q.setPermissionMode('auto-edit'); + + resume(); + + await Promise.race([ + secondResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for second response')), + 10000, + ), + ), + ]); + + expect(secondResponseReceived).toBe(true); + } finally { + await q.close(); + } + }); + + it('should throw error when setPermissionMode is called on closed query', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + }, + }); + + await q.close(); + + await expect(q.setPermissionMode('yolo')).rejects.toThrow( + 'Query is closed', + ); + }); + }); + + describe('canUseTool and setPermissionMode integration', () => { + it('should work together - canUseTool callback with dynamic permission mode change', async () => { + const toolCalls: Array<{ + toolName: string; + input: Record; + }> = []; + + const { generator, resume } = createStreamingInputWithControlPoint( + 'Create a file named first.txt', + 'Create a file named second.txt', + ); + + const q = query({ + prompt: generator, + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: '/tmp', + canUseTool: async (toolName, input) => { + toolCalls.push({ toolName, input }); + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + const resolvers: { + first?: () => void; + second?: () => void; + } = {}; + const firstResponsePromise = new Promise((resolve) => { + resolvers.first = resolve; + }); + const secondResponsePromise = new Promise((resolve) => { + resolvers.second = resolve; + }); + + let firstResponseReceived = false; + let secondResponseReceived = false; + + (async () => { + for await (const message of q) { + if (isCLIResultMessage(message)) { + if (!firstResponseReceived) { + firstResponseReceived = true; + resolvers.first?.(); + } else if (!secondResponseReceived) { + secondResponseReceived = true; + resolvers.second?.(); + } + } + } + })(); + + await Promise.race([ + firstResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for first response')), + TEST_TIMEOUT, + ), + ), + ]); + + expect(firstResponseReceived).toBe(true); + expect(toolCalls.length).toBeGreaterThan(0); + + await q.setPermissionMode('yolo'); + + resume(); + + await Promise.race([ + secondResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for second response')), + TEST_TIMEOUT, + ), + ), + ]); + + expect(secondResponseReceived).toBe(true); + } finally { + await q.close(); + } + }); + }); +}); diff --git a/packages/sdk-typescript/test/e2e/single-turn.test.ts b/packages/sdk-typescript/test/e2e/single-turn.test.ts new file mode 100644 index 0000000000..047be4f225 --- /dev/null +++ b/packages/sdk-typescript/test/e2e/single-turn.test.ts @@ -0,0 +1,479 @@ +/** + * E2E tests for single-turn query execution + * Tests basic query patterns with simple prompts and clear output expectations + */ + +import { describe, it, expect } from 'vitest'; +import { query } from '../../src/index.js'; +import { + isCLIAssistantMessage, + isCLISystemMessage, + isCLIResultMessage, + type TextBlock, + type ContentBlock, + type CLIMessage, + type CLISystemMessage, + type CLIAssistantMessage, +} from '../../src/types/protocol.js'; +const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!; + +const SHARED_TEST_OPTIONS = { + pathToQwenExecutable: TEST_CLI_PATH, +}; + +/** + * Helper to extract text from ContentBlock array + */ +function extractText(content: ContentBlock[]): string { + return content + .filter((block): block is TextBlock => block.type === 'text') + .map((block) => block.text) + .join(''); +} + +describe('Single-Turn Query (E2E)', () => { + describe('Simple Text Queries', () => { + it('should answer basic arithmetic question', async () => { + const q = query({ + prompt: 'What is 2 + 2? Just give me the number.', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + let assistantText = ''; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + assistantText += extractText(message.message.content); + } + } + + // Validate we got messages + expect(messages.length).toBeGreaterThan(0); + + // Validate assistant response content + expect(assistantText.length).toBeGreaterThan(0); + expect(assistantText).toMatch(/4/); + + // Validate message flow ends with success + const lastMessage = messages[messages.length - 1]; + expect(isCLIResultMessage(lastMessage)).toBe(true); + if (isCLIResultMessage(lastMessage)) { + expect(lastMessage.subtype).toBe('success'); + } + } finally { + await q.close(); + } + }); + + it('should answer simple factual question', async () => { + const q = query({ + prompt: 'What is the capital of France? One word answer.', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + let assistantText = ''; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + assistantText += extractText(message.message.content); + } + } + + // Validate content + expect(assistantText.length).toBeGreaterThan(0); + expect(assistantText.toLowerCase()).toContain('paris'); + + // Validate completion + const lastMessage = messages[messages.length - 1]; + expect(isCLIResultMessage(lastMessage)).toBe(true); + } finally { + await q.close(); + } + }); + + it('should handle greeting and self-description', async () => { + const q = query({ + prompt: 'Say hello and tell me your name in one sentence.', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + let assistantText = ''; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + assistantText += extractText(message.message.content); + } + } + + // Validate content contains greeting + expect(assistantText.length).toBeGreaterThan(0); + expect(assistantText.toLowerCase()).toMatch(/hello|hi|greetings/); + + // Validate message types + const assistantMessages = messages.filter(isCLIAssistantMessage); + expect(assistantMessages.length).toBeGreaterThan(0); + } finally { + await q.close(); + } + }); + }); + + describe('System Initialization', () => { + it('should receive system message with initialization info', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + let systemMessage: CLISystemMessage | null = null; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Validate system message exists and has required fields + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.type).toBe('system'); + expect(systemMessage!.subtype).toBe('init'); + expect(systemMessage!.uuid).toBeDefined(); + expect(systemMessage!.session_id).toBeDefined(); + expect(systemMessage!.cwd).toBeDefined(); + expect(systemMessage!.tools).toBeDefined(); + expect(Array.isArray(systemMessage!.tools)).toBe(true); + expect(systemMessage!.mcp_servers).toBeDefined(); + expect(Array.isArray(systemMessage!.mcp_servers)).toBe(true); + expect(systemMessage!.model).toBeDefined(); + expect(systemMessage!.permissionMode).toBeDefined(); + expect(systemMessage!.qwen_code_version).toBeDefined(); + + // Validate system message appears early in sequence + const systemMessageIndex = messages.findIndex( + (msg) => isCLISystemMessage(msg) && msg.subtype === 'init', + ); + expect(systemMessageIndex).toBeGreaterThanOrEqual(0); + expect(systemMessageIndex).toBeLessThan(3); + } finally { + await q.close(); + } + }); + + it('should maintain session ID consistency', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + let systemMessage: CLISystemMessage | null = null; + const sessionId = q.getSessionId(); + + try { + for await (const message of q) { + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Validate session IDs are consistent + expect(sessionId).toBeDefined(); + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.session_id).toBeDefined(); + expect(systemMessage!.uuid).toBeDefined(); + expect(systemMessage!.session_id).toBe(systemMessage!.uuid); + } finally { + await q.close(); + } + }); + }); + + describe('Message Flow', () => { + it('should follow expected message sequence', async () => { + const q = query({ + prompt: 'Say hi', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const messageTypes: string[] = []; + + try { + for await (const message of q) { + messageTypes.push(message.type); + } + + // Validate message sequence + expect(messageTypes.length).toBeGreaterThan(0); + expect(messageTypes).toContain('assistant'); + expect(messageTypes[messageTypes.length - 1]).toBe('result'); + } finally { + await q.close(); + } + }); + + it('should complete iteration naturally', async () => { + const q = query({ + prompt: 'Say goodbye', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + let completedNaturally = false; + let messageCount = 0; + + try { + for await (const message of q) { + messageCount++; + + if (isCLIResultMessage(message)) { + completedNaturally = true; + expect(message.subtype).toBe('success'); + } + } + + expect(messageCount).toBeGreaterThan(0); + expect(completedNaturally).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('Configuration Options', () => { + it('should respect debug option and capture stderr', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + try { + for await (const _message of q) { + // Consume all messages + } + + // Debug mode should produce stderr output + expect(stderrMessages.length).toBeGreaterThan(0); + } finally { + await q.close(); + } + }); + + it('should respect cwd option', async () => { + const testDir = process.cwd(); + + const q = query({ + prompt: 'What is 1 + 1?', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + let hasResponse = false; + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + hasResponse = true; + } + } + + expect(hasResponse).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('Message Type Recognition', () => { + it('should correctly identify all message types', async () => { + const q = query({ + prompt: 'What is 5 + 5?', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Validate type guards work correctly + const assistantMessages = messages.filter(isCLIAssistantMessage); + const resultMessages = messages.filter(isCLIResultMessage); + const systemMessages = messages.filter(isCLISystemMessage); + + expect(assistantMessages.length).toBeGreaterThan(0); + expect(resultMessages.length).toBeGreaterThan(0); + expect(systemMessages.length).toBeGreaterThan(0); + + // Validate assistant message structure + const firstAssistant = assistantMessages[0]; + expect(firstAssistant.message.content).toBeDefined(); + expect(Array.isArray(firstAssistant.message.content)).toBe(true); + + // Validate result message structure + const resultMessage = resultMessages[0]; + expect(resultMessage.subtype).toBe('success'); + } finally { + await q.close(); + } + }); + + it('should extract text content from assistant messages', async () => { + const q = query({ + prompt: 'Count from 1 to 3', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + let assistantMessage: CLIAssistantMessage | null = null; + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + assistantMessage = message; + } + } + + expect(assistantMessage).not.toBeNull(); + expect(assistantMessage!.message.content).toBeDefined(); + + // Extract text blocks + const textBlocks = assistantMessage!.message.content.filter( + (block: ContentBlock): block is TextBlock => block.type === 'text', + ); + + expect(textBlocks.length).toBeGreaterThan(0); + expect(textBlocks[0].text).toBeDefined(); + expect(textBlocks[0].text.length).toBeGreaterThan(0); + + // Validate content contains expected numbers + const text = extractText(assistantMessage!.message.content); + expect(text).toMatch(/1/); + expect(text).toMatch(/2/); + expect(text).toMatch(/3/); + } finally { + await q.close(); + } + }); + }); + + describe('Error Handling', () => { + it('should throw if CLI not found', async () => { + try { + const q = query({ + prompt: 'Hello', + options: { + pathToQwenExecutable: '/nonexistent/path/to/cli', + debug: false, + }, + }); + + for await (const _message of q) { + // Should not reach here + } + + expect(false).toBe(true); // Should have thrown + } catch (error) { + expect(error).toBeDefined(); + expect(error instanceof Error).toBe(true); + expect((error as Error).message).toContain( + 'Invalid pathToQwenExecutable', + ); + } + }); + }); + + describe('Resource Management', () => { + it('should cleanup subprocess on close()', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + // Start and immediately close + const iterator = q[Symbol.asyncIterator](); + await iterator.next(); + + // Should close without error + await q.close(); + expect(true).toBe(true); // Cleanup completed + }); + + it('should handle close() called multiple times', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + // Start the query + const iterator = q[Symbol.asyncIterator](); + await iterator.next(); + + // Close multiple times + await q.close(); + await q.close(); + await q.close(); + + // Should not throw + expect(true).toBe(true); + }); + }); +}); diff --git a/packages/sdk-typescript/test/unit/ProcessTransport.test.ts b/packages/sdk-typescript/test/unit/ProcessTransport.test.ts new file mode 100644 index 0000000000..5e1a9d15bd --- /dev/null +++ b/packages/sdk-typescript/test/unit/ProcessTransport.test.ts @@ -0,0 +1,207 @@ +/** + * Unit tests for ProcessTransport + * Tests subprocess lifecycle management and IPC + */ + +import { describe, expect, it } from 'vitest'; + +// Note: This is a placeholder test file +// ProcessTransport will be implemented in Phase 3 Implementation (T021) +// These tests are written first following TDD approach + +describe('ProcessTransport', () => { + describe('Construction and Initialization', () => { + it('should create transport with required options', () => { + // Test will be implemented with actual ProcessTransport class + expect(true).toBe(true); // Placeholder + }); + + it('should validate pathToQwenExecutable exists', () => { + // Should throw if pathToQwenExecutable does not exist + expect(true).toBe(true); // Placeholder + }); + + it('should build CLI arguments correctly', () => { + // Should include --input-format stream-json --output-format stream-json + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Lifecycle Management', () => { + it('should spawn subprocess during construction', async () => { + // Should call child_process.spawn in constructor + expect(true).toBe(true); // Placeholder + }); + + it('should set isReady to true after successful initialization', async () => { + // isReady should be true after construction completes + expect(true).toBe(true); // Placeholder + }); + + it('should throw if subprocess fails to spawn', async () => { + // Should throw Error if ENOENT or spawn fails + expect(true).toBe(true); // Placeholder + }); + + it('should close subprocess gracefully with SIGTERM', async () => { + // Should send SIGTERM first + expect(true).toBe(true); // Placeholder + }); + + it('should force kill with SIGKILL after timeout', async () => { + // Should send SIGKILL after 5s if process doesn\'t exit + expect(true).toBe(true); // Placeholder + }); + + it('should be idempotent when calling close() multiple times', async () => { + // Multiple close() calls should not error + expect(true).toBe(true); // Placeholder + }); + + it('should wait for process exit in waitForExit()', async () => { + // Should resolve when process exits + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Message Reading', () => { + it('should read JSON Lines from stdout', async () => { + // Should use readline to read lines and parse JSON + expect(true).toBe(true); // Placeholder + }); + + it('should yield parsed messages via readMessages()', async () => { + // Should yield messages as async generator + expect(true).toBe(true); // Placeholder + }); + + it('should skip malformed JSON lines with warning', async () => { + // Should log warning and continue on parse error + expect(true).toBe(true); // Placeholder + }); + + it('should complete generator when process exits', async () => { + // readMessages() should complete when stdout closes + expect(true).toBe(true); // Placeholder + }); + + it('should set exitError on unexpected process crash', async () => { + // exitError should be set if process crashes + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Message Writing', () => { + it('should write JSON Lines to stdin', () => { + // Should write JSON + newline to stdin + expect(true).toBe(true); // Placeholder + }); + + it('should throw if writing before transport is ready', () => { + // write() should throw if isReady is false + expect(true).toBe(true); // Placeholder + }); + + it('should throw if writing to closed transport', () => { + // write() should throw if transport is closed + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Error Handling', () => { + it('should handle process spawn errors', async () => { + // Should throw descriptive error on spawn failure + expect(true).toBe(true); // Placeholder + }); + + it('should handle process exit with non-zero code', async () => { + // Should set exitError when process exits with error + expect(true).toBe(true); // Placeholder + }); + + it('should handle write errors to closed stdin', () => { + // Should throw if stdin is closed + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Resource Cleanup', () => { + it('should register cleanup on parent process exit', () => { + // Should register process.on(\'exit\') handler + expect(true).toBe(true); // Placeholder + }); + + it('should kill subprocess on parent exit', () => { + // Cleanup should kill child process + expect(true).toBe(true); // Placeholder + }); + + it('should remove event listeners on close', async () => { + // Should clean up all event listeners + expect(true).toBe(true); // Placeholder + }); + }); + + describe('CLI Arguments', () => { + it('should include --input-format stream-json', () => { + // Args should always include input format flag + expect(true).toBe(true); // Placeholder + }); + + it('should include --output-format stream-json', () => { + // Args should always include output format flag + expect(true).toBe(true); // Placeholder + }); + + it('should include --model if provided', () => { + // Args should include model flag if specified + expect(true).toBe(true); // Placeholder + }); + + it('should include --permission-mode if provided', () => { + // Args should include permission mode flag if specified + expect(true).toBe(true); // Placeholder + }); + + it('should include --mcp-server for external MCP servers', () => { + // Args should include MCP server configs + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Working Directory', () => { + it('should spawn process in specified cwd', async () => { + // Should use cwd option for child_process.spawn + expect(true).toBe(true); // Placeholder + }); + + it('should default to process.cwd() if not specified', async () => { + // Should use current working directory by default + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Environment Variables', () => { + it('should pass environment variables to subprocess', async () => { + // Should merge env with process.env + expect(true).toBe(true); // Placeholder + }); + + it('should inherit parent env by default', async () => { + // Should use process.env if no env option + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Debug Mode', () => { + it('should inherit stderr when debug is true', async () => { + // Should set stderr: \'inherit\' if debug flag set + expect(true).toBe(true); // Placeholder + }); + + it('should ignore stderr when debug is false', async () => { + // Should set stderr: \'ignore\' if debug flag not set + expect(true).toBe(true); // Placeholder + }); + }); +}); diff --git a/packages/sdk-typescript/test/unit/Query.test.ts b/packages/sdk-typescript/test/unit/Query.test.ts new file mode 100644 index 0000000000..5ceeee4bb4 --- /dev/null +++ b/packages/sdk-typescript/test/unit/Query.test.ts @@ -0,0 +1,284 @@ +/** + * Unit tests for Query class + * Tests message routing, lifecycle, and orchestration + */ + +import { describe, expect, it } from 'vitest'; + +// Note: This is a placeholder test file +// Query will be implemented in Phase 3 Implementation (T022) +// These tests are written first following TDD approach + +describe('Query', () => { + describe('Construction and Initialization', () => { + it('should create Query with transport and options', () => { + // Should accept Transport and CreateQueryOptions + expect(true).toBe(true); // Placeholder + }); + + it('should generate unique session ID', () => { + // Each Query should have unique session_id + expect(true).toBe(true); // Placeholder + }); + + it('should validate MCP server name conflicts', () => { + // Should throw if mcpServers and sdkMcpServers have same keys + expect(true).toBe(true); // Placeholder + }); + + it('should lazy initialize on first message consumption', async () => { + // Should not call initialize() until messages are read + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Message Routing', () => { + it('should route user messages to CLI', async () => { + // Initial prompt should be sent as user message + expect(true).toBe(true); // Placeholder + }); + + it('should route assistant messages to output stream', async () => { + // Assistant messages from CLI should be yielded to user + expect(true).toBe(true); // Placeholder + }); + + it('should route tool_use messages to output stream', async () => { + // Tool use messages should be yielded to user + expect(true).toBe(true); // Placeholder + }); + + it('should route tool_result messages to output stream', async () => { + // Tool result messages should be yielded to user + expect(true).toBe(true); // Placeholder + }); + + it('should route result messages to output stream', async () => { + // Result messages should be yielded to user + expect(true).toBe(true); // Placeholder + }); + + it('should filter keep_alive messages from output', async () => { + // Keep alive messages should not be yielded to user + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Control Plane - Permission Control', () => { + it('should handle can_use_tool control requests', async () => { + // Should invoke canUseTool callback + expect(true).toBe(true); // Placeholder + }); + + it('should send control response with permission result', async () => { + // Should send response with allowed: true/false + expect(true).toBe(true); // Placeholder + }); + + it('should default to allowing tools if no callback', async () => { + // If canUseTool not provided, should allow all + expect(true).toBe(true); // Placeholder + }); + + it('should handle permission callback timeout', async () => { + // Should deny permission if callback exceeds 30s + expect(true).toBe(true); // Placeholder + }); + + it('should handle permission callback errors', async () => { + // Should deny permission if callback throws + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Control Plane - MCP Messages', () => { + it('should route MCP messages to SDK-embedded servers', async () => { + // Should find SdkControlServerTransport by server name + expect(true).toBe(true); // Placeholder + }); + + it('should handle MCP message responses', async () => { + // Should send response back to CLI + expect(true).toBe(true); // Placeholder + }); + + it('should handle MCP message timeout', async () => { + // Should return error if MCP server doesn\'t respond in 30s + expect(true).toBe(true); // Placeholder + }); + + it('should handle unknown MCP server names', async () => { + // Should return error if server name not found + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Control Plane - Other Requests', () => { + it('should handle initialize control request', async () => { + // Should register SDK MCP servers with CLI + expect(true).toBe(true); // Placeholder + }); + + it('should handle interrupt control request', async () => { + // Should send interrupt message to CLI + expect(true).toBe(true); // Placeholder + }); + + it('should handle set_permission_mode control request', async () => { + // Should send permission mode update to CLI + expect(true).toBe(true); // Placeholder + }); + + it('should handle supported_commands control request', async () => { + // Should query CLI capabilities + expect(true).toBe(true); // Placeholder + }); + + it('should handle mcp_server_status control request', async () => { + // Should check MCP server health + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Multi-Turn Conversation', () => { + it('should support streamInput() for follow-up messages', async () => { + // Should accept async iterable of messages + expect(true).toBe(true); // Placeholder + }); + + it('should maintain session context across turns', async () => { + // All messages should have same session_id + expect(true).toBe(true); // Placeholder + }); + + it('should throw if streamInput() called on closed query', async () => { + // Should throw Error if query is closed + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Lifecycle Management', () => { + it('should close transport on close()', async () => { + // Should call transport.close() + expect(true).toBe(true); // Placeholder + }); + + it('should mark query as closed', async () => { + // closed flag should be true after close() + expect(true).toBe(true); // Placeholder + }); + + it('should complete output stream on close()', async () => { + // inputStream should be marked done + expect(true).toBe(true); // Placeholder + }); + + it('should be idempotent when closing multiple times', async () => { + // Multiple close() calls should not error + expect(true).toBe(true); // Placeholder + }); + + it('should cleanup MCP transports on close()', async () => { + // Should close all SdkControlServerTransport instances + expect(true).toBe(true); // Placeholder + }); + + it('should handle abort signal cancellation', async () => { + // Should abort on AbortSignal + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Async Iteration', () => { + it('should support for await loop', async () => { + // Should implement AsyncIterator protocol + expect(true).toBe(true); // Placeholder + }); + + it('should yield messages in order', async () => { + // Messages should be yielded in received order + expect(true).toBe(true); // Placeholder + }); + + it('should complete iteration when query closes', async () => { + // for await loop should exit when query closes + expect(true).toBe(true); // Placeholder + }); + + it('should propagate transport errors', async () => { + // Should throw if transport encounters error + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Public API Methods', () => { + it('should provide interrupt() method', async () => { + // Should send interrupt control request + expect(true).toBe(true); // Placeholder + }); + + it('should provide setPermissionMode() method', async () => { + // Should send set_permission_mode control request + expect(true).toBe(true); // Placeholder + }); + + it('should provide supportedCommands() method', async () => { + // Should query CLI capabilities + expect(true).toBe(true); // Placeholder + }); + + it('should provide mcpServerStatus() method', async () => { + // Should check MCP server health + expect(true).toBe(true); // Placeholder + }); + + it('should throw if methods called on closed query', async () => { + // Public methods should throw if query is closed + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Error Handling', () => { + it('should propagate transport errors to stream', async () => { + // Transport errors should be surfaced in for await loop + expect(true).toBe(true); // Placeholder + }); + + it('should handle control request timeout', async () => { + // Should return error if control request doesn\'t respond + expect(true).toBe(true); // Placeholder + }); + + it('should handle malformed control responses', async () => { + // Should handle invalid response structures + expect(true).toBe(true); // Placeholder + }); + + it('should handle CLI sending error message', async () => { + // Should yield error message to user + expect(true).toBe(true); // Placeholder + }); + }); + + describe('State Management', () => { + it('should track pending control requests', () => { + // Should maintain map of request_id -> Promise + expect(true).toBe(true); // Placeholder + }); + + it('should track SDK MCP transports', () => { + // Should maintain map of server_name -> SdkControlServerTransport + expect(true).toBe(true); // Placeholder + }); + + it('should track initialization state', () => { + // Should have initialized Promise + expect(true).toBe(true); // Placeholder + }); + + it('should track closed state', () => { + // Should have closed boolean flag + expect(true).toBe(true); // Placeholder + }); + }); +}); diff --git a/packages/sdk-typescript/test/unit/SdkControlServerTransport.test.ts b/packages/sdk-typescript/test/unit/SdkControlServerTransport.test.ts new file mode 100644 index 0000000000..6bfd61a042 --- /dev/null +++ b/packages/sdk-typescript/test/unit/SdkControlServerTransport.test.ts @@ -0,0 +1,259 @@ +/** + * Unit tests for SdkControlServerTransport + * + * Tests MCP message proxying between MCP Server and Query's control plane. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SdkControlServerTransport } from '../../src/mcp/SdkControlServerTransport.js'; + +describe('SdkControlServerTransport', () => { + let sendToQuery: ReturnType; + let transport: SdkControlServerTransport; + + beforeEach(() => { + sendToQuery = vi.fn().mockResolvedValue({ result: 'success' }); + transport = new SdkControlServerTransport({ + serverName: 'test-server', + sendToQuery, + }); + }); + + describe('Lifecycle', () => { + it('should start successfully', async () => { + await transport.start(); + expect(transport.isStarted()).toBe(true); + }); + + it('should close successfully', async () => { + await transport.start(); + await transport.close(); + expect(transport.isStarted()).toBe(false); + }); + + it('should handle close callback', async () => { + const onclose = vi.fn(); + transport.onclose = onclose; + + await transport.start(); + await transport.close(); + + expect(onclose).toHaveBeenCalled(); + }); + }); + + describe('Message Sending', () => { + it('should send message to Query', async () => { + await transport.start(); + + const message = { + jsonrpc: '2.0' as const, + id: 1, + method: 'tools/list', + params: {}, + }; + + await transport.send(message); + + expect(sendToQuery).toHaveBeenCalledWith(message); + }); + + it('should throw error when sending before start', async () => { + const message = { + jsonrpc: '2.0' as const, + id: 1, + method: 'tools/list', + }; + + await expect(transport.send(message)).rejects.toThrow('not started'); + }); + + it('should handle send errors', async () => { + const error = new Error('Network error'); + sendToQuery.mockRejectedValue(error); + + const onerror = vi.fn(); + transport.onerror = onerror; + + await transport.start(); + + const message = { + jsonrpc: '2.0' as const, + id: 1, + method: 'tools/list', + }; + + await expect(transport.send(message)).rejects.toThrow('Network error'); + expect(onerror).toHaveBeenCalledWith(error); + }); + }); + + describe('Message Receiving', () => { + it('should deliver message to MCP Server via onmessage', async () => { + const onmessage = vi.fn(); + transport.onmessage = onmessage; + + await transport.start(); + + const message = { + jsonrpc: '2.0' as const, + id: 1, + result: { tools: [] }, + }; + + transport.handleMessage(message); + + expect(onmessage).toHaveBeenCalledWith(message); + }); + + it('should warn when receiving message without onmessage handler', async () => { + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + await transport.start(); + + const message = { + jsonrpc: '2.0' as const, + id: 1, + result: {}, + }; + + transport.handleMessage(message); + + expect(consoleWarnSpy).toHaveBeenCalled(); + + consoleWarnSpy.mockRestore(); + }); + + it('should warn when receiving message for closed transport', async () => { + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); + const onmessage = vi.fn(); + transport.onmessage = onmessage; + + await transport.start(); + await transport.close(); + + const message = { + jsonrpc: '2.0' as const, + id: 1, + result: {}, + }; + + transport.handleMessage(message); + + expect(consoleWarnSpy).toHaveBeenCalled(); + expect(onmessage).not.toHaveBeenCalled(); + + consoleWarnSpy.mockRestore(); + }); + }); + + describe('Error Handling', () => { + it('should deliver error to MCP Server via onerror', async () => { + const onerror = vi.fn(); + transport.onerror = onerror; + + await transport.start(); + + const error = new Error('Test error'); + transport.handleError(error); + + expect(onerror).toHaveBeenCalledWith(error); + }); + + it('should log error when no onerror handler set', async () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await transport.start(); + + const error = new Error('Test error'); + transport.handleError(error); + + expect(consoleErrorSpy).toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('Server Name', () => { + it('should return server name', () => { + expect(transport.getServerName()).toBe('test-server'); + }); + }); + + describe('Bidirectional Communication', () => { + it('should support full message round-trip', async () => { + const onmessage = vi.fn(); + transport.onmessage = onmessage; + + await transport.start(); + + // Send request from MCP Server to CLI + const request = { + jsonrpc: '2.0' as const, + id: 1, + method: 'tools/list', + params: {}, + }; + + await transport.send(request); + expect(sendToQuery).toHaveBeenCalledWith(request); + + // Receive response from CLI to MCP Server + const response = { + jsonrpc: '2.0' as const, + id: 1, + result: { + tools: [ + { + name: 'test_tool', + description: 'A test tool', + inputSchema: { type: 'object' }, + }, + ], + }, + }; + + transport.handleMessage(response); + expect(onmessage).toHaveBeenCalledWith(response); + }); + + it('should handle multiple messages in sequence', async () => { + const onmessage = vi.fn(); + transport.onmessage = onmessage; + + await transport.start(); + + // Send multiple requests + for (let i = 0; i < 5; i++) { + const message = { + jsonrpc: '2.0' as const, + id: i, + method: 'test', + }; + + await transport.send(message); + } + + expect(sendToQuery).toHaveBeenCalledTimes(5); + + // Receive multiple responses + for (let i = 0; i < 5; i++) { + const message = { + jsonrpc: '2.0' as const, + id: i, + result: {}, + }; + + transport.handleMessage(message); + } + + expect(onmessage).toHaveBeenCalledTimes(5); + }); + }); +}); diff --git a/packages/sdk-typescript/test/unit/Stream.test.ts b/packages/sdk-typescript/test/unit/Stream.test.ts new file mode 100644 index 0000000000..2113a20231 --- /dev/null +++ b/packages/sdk-typescript/test/unit/Stream.test.ts @@ -0,0 +1,255 @@ +/** + * Unit tests for Stream class + * Tests producer-consumer patterns and async iteration + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { Stream } from '../../src/utils/Stream.js'; + +describe('Stream', () => { + let stream: Stream; + + beforeEach(() => { + stream = new Stream(); + }); + + describe('Producer-Consumer Patterns', () => { + it('should deliver enqueued value immediately to waiting consumer', async () => { + // Start consumer (waits for value) + const consumerPromise = stream.next(); + + // Producer enqueues value + stream.enqueue('hello'); + + // Consumer should receive value immediately + const result = await consumerPromise; + expect(result).toEqual({ value: 'hello', done: false }); + }); + + it('should buffer values when consumer is slow', async () => { + // Producer enqueues multiple values + stream.enqueue('first'); + stream.enqueue('second'); + stream.enqueue('third'); + + // Consumer reads buffered values + expect(await stream.next()).toEqual({ value: 'first', done: false }); + expect(await stream.next()).toEqual({ value: 'second', done: false }); + expect(await stream.next()).toEqual({ value: 'third', done: false }); + }); + + it('should handle fast producer and fast consumer', async () => { + const values: string[] = []; + + // Produce and consume simultaneously + const consumerPromise = (async () => { + for (let i = 0; i < 3; i++) { + const result = await stream.next(); + if (!result.done) { + values.push(result.value); + } + } + })(); + + stream.enqueue('a'); + stream.enqueue('b'); + stream.enqueue('c'); + + await consumerPromise; + expect(values).toEqual(['a', 'b', 'c']); + }); + + it('should handle async iteration with for await loop', async () => { + const values: string[] = []; + + // Start consumer + const consumerPromise = (async () => { + for await (const value of stream) { + values.push(value); + } + })(); + + // Producer enqueues and completes + stream.enqueue('x'); + stream.enqueue('y'); + stream.enqueue('z'); + stream.done(); + + await consumerPromise; + expect(values).toEqual(['x', 'y', 'z']); + }); + }); + + describe('Stream Completion', () => { + it('should signal completion when done() is called', async () => { + stream.done(); + const result = await stream.next(); + expect(result).toEqual({ done: true, value: undefined }); + }); + + it('should complete waiting consumer immediately', async () => { + const consumerPromise = stream.next(); + stream.done(); + const result = await consumerPromise; + expect(result).toEqual({ done: true, value: undefined }); + }); + + it('should allow done() to be called multiple times', async () => { + stream.done(); + stream.done(); + stream.done(); + + const result = await stream.next(); + expect(result).toEqual({ done: true, value: undefined }); + }); + + it('should allow enqueuing to completed stream (no check in reference)', async () => { + stream.done(); + // Reference version doesn't check for done in enqueue + stream.enqueue('value'); + // Verify value was enqueued by reading it + expect(await stream.next()).toEqual({ value: 'value', done: false }); + }); + + it('should deliver buffered values before completion', async () => { + stream.enqueue('first'); + stream.enqueue('second'); + stream.done(); + + expect(await stream.next()).toEqual({ value: 'first', done: false }); + expect(await stream.next()).toEqual({ value: 'second', done: false }); + expect(await stream.next()).toEqual({ done: true, value: undefined }); + }); + }); + + describe('Error Handling', () => { + it('should propagate error to waiting consumer', async () => { + const consumerPromise = stream.next(); + const error = new Error('Stream error'); + stream.error(error); + + await expect(consumerPromise).rejects.toThrow('Stream error'); + }); + + it('should throw error on next read after error is set', async () => { + const error = new Error('Test error'); + stream.error(error); + + await expect(stream.next()).rejects.toThrow('Test error'); + }); + + it('should allow enqueuing to stream with error (no check in reference)', async () => { + stream.error(new Error('Error')); + // Reference version doesn't check for error in enqueue + stream.enqueue('value'); + // Verify value was enqueued by reading it + expect(await stream.next()).toEqual({ value: 'value', done: false }); + }); + + it('should store last error (reference overwrites)', async () => { + const firstError = new Error('First'); + const secondError = new Error('Second'); + + stream.error(firstError); + stream.error(secondError); + + await expect(stream.next()).rejects.toThrow('Second'); + }); + + it('should deliver buffered values before throwing error', async () => { + stream.enqueue('buffered'); + stream.error(new Error('Stream error')); + + expect(await stream.next()).toEqual({ value: 'buffered', done: false }); + await expect(stream.next()).rejects.toThrow('Stream error'); + }); + }); + + describe('State Properties', () => { + it('should track error state', () => { + expect(stream.hasError).toBeUndefined(); + stream.error(new Error('Test')); + expect(stream.hasError).toBeInstanceOf(Error); + expect(stream.hasError?.message).toBe('Test'); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty stream', async () => { + stream.done(); + const result = await stream.next(); + expect(result.done).toBe(true); + }); + + it('should handle single value', async () => { + stream.enqueue('only'); + stream.done(); + + expect(await stream.next()).toEqual({ value: 'only', done: false }); + expect(await stream.next()).toEqual({ done: true, value: undefined }); + }); + + it('should handle rapid enqueue-dequeue cycles', async () => { + const numberStream = new Stream(); + const iterations = 100; + const values: number[] = []; + + const producer = async (): Promise => { + for (let i = 0; i < iterations; i++) { + numberStream.enqueue(i); + await new Promise((resolve) => setImmediate(resolve)); + } + numberStream.done(); + }; + + const consumer = async (): Promise => { + for await (const value of numberStream) { + values.push(value); + } + }; + + await Promise.all([producer(), consumer()]); + expect(values).toHaveLength(iterations); + expect(values[0]).toBe(0); + expect(values[iterations - 1]).toBe(iterations - 1); + }); + }); + + describe('TypeScript Types', () => { + it('should handle different value types', async () => { + const numberStream = new Stream(); + numberStream.enqueue(42); + numberStream.done(); + + const result = await numberStream.next(); + expect(result.value).toBe(42); + + const objectStream = new Stream<{ id: number; name: string }>(); + objectStream.enqueue({ id: 1, name: 'test' }); + objectStream.done(); + + const objectResult = await objectStream.next(); + expect(objectResult.value).toEqual({ id: 1, name: 'test' }); + }); + }); + + describe('Iteration Restrictions', () => { + it('should only allow iteration once', async () => { + const stream = new Stream(); + stream.enqueue('test'); + stream.done(); + + // First iteration should work + const iterator1 = stream[Symbol.asyncIterator](); + expect(await iterator1.next()).toEqual({ + value: 'test', + done: false, + }); + + // Second iteration should throw + expect(() => stream[Symbol.asyncIterator]()).toThrow( + 'Stream can only be iterated once', + ); + }); + }); +}); diff --git a/packages/sdk-typescript/test/unit/cliPath.test.ts b/packages/sdk-typescript/test/unit/cliPath.test.ts new file mode 100644 index 0000000000..55a87b92f8 --- /dev/null +++ b/packages/sdk-typescript/test/unit/cliPath.test.ts @@ -0,0 +1,668 @@ +/** + * Unit tests for CLI path utilities + * Tests executable detection, parsing, and spawn info preparation + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { execSync } from 'node:child_process'; +import { + parseExecutableSpec, + prepareSpawnInfo, + findNativeCliPath, + resolveCliPath, +} from '../../src/utils/cliPath.js'; + +// Mock fs module +vi.mock('node:fs'); +const mockFs = vi.mocked(fs); + +// Mock child_process module +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); + }); + + afterEach(() => { + // Restore original process.versions + Object.defineProperty(process, 'versions', { + value: originalVersions, + writable: true, + }); + }); + + describe('parseExecutableSpec', () => { + describe('auto-detection (no spec provided)', () => { + it('should auto-detect native CLI when no spec provided', () => { + // Mock environment variable + const originalEnv = process.env['QWEN_CODE_CLI_PATH']; + process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen'; + mockFs.existsSync.mockReturnValue(true); + + const result = parseExecutableSpec(); + + expect(result).toEqual({ + executablePath: '/usr/local/bin/qwen', + isExplicitRuntime: false, + }); + + // Restore env + process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + }); + + it('should throw when auto-detection fails', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => parseExecutableSpec()).toThrow( + 'qwen CLI not found. Please:', + ); + }); + }); + + 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 throw for invalid runtime prefix format', () => { + expect(() => parseExecutableSpec('invalid:format')).toThrow( + 'Unsupported runtime', + ); + }); + + 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', + ); + }); + }); + + 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: '/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({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + }); + + it('should prepare spawn info for native binary path', () => { + const result = prepareSpawnInfo('/usr/local/bin/qwen'); + + expect(result).toEqual({ + command: '/usr/local/bin/qwen', + args: [], + type: 'native', + originalInput: '/usr/local/bin/qwen', + }); + }); + }); + + 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', + }); + }); + + 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, + }); + + 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', + }); + }); + + it('should handle .mjs files', () => { + const result = prepareSpawnInfo('/path/to/cli.mjs'); + + expect(result).toEqual({ + command: process.execPath, + args: [path.resolve('/path/to/cli.mjs')], + type: 'node', + originalInput: '/path/to/cli.mjs', + }); + }); + + it('should handle .cjs files', () => { + const result = prepareSpawnInfo('/path/to/cli.cjs'); + + expect(result).toEqual({ + command: process.execPath, + args: [path.resolve('/path/to/cli.cjs')], + type: 'node', + originalInput: '/path/to/cli.cjs', + }); + }); + }); + + describe('TypeScript files', () => { + 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'); + + expect(result).toEqual({ + command: 'tsx', + args: [path.resolve('/path/to/index.ts')], + type: 'tsx', + originalInput: '/path/to/index.ts', + }); + }); + + it('should use tsx for .tsx files when tsx is available', () => { + const result = prepareSpawnInfo('/path/to/cli.tsx'); + + expect(result).toEqual({ + command: 'tsx', + args: [path.resolve('/path/to/cli.tsx')], + type: 'tsx', + originalInput: '/path/to/cli.tsx', + }); + }); + + it('should throw helpful error when tsx is not available', () => { + // Mock tsx not being available + mockExecSync.mockImplementation(() => { + throw new Error('Command not found'); + }); + + expect(() => prepareSpawnInfo('/path/to/index.ts')).toThrow( + "TypeScript file '/path/to/index.ts' 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'); + + 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', + }); + }); + }); + + describe('auto-detection fallback', () => { + it('should auto-detect when no spec provided', () => { + // Mock environment variable + const originalEnv = process.env['QWEN_CODE_CLI_PATH']; + process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen'; + + const result = prepareSpawnInfo(); + + expect(result).toEqual({ + command: '/usr/local/bin/qwen', + args: [], + type: 'native', + originalInput: '', + }); + + // Restore env + process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + }); + }); + }); + + describe('findNativeCliPath', () => { + it('should find CLI from environment variable', () => { + const originalEnv = process.env['QWEN_CODE_CLI_PATH']; + process.env['QWEN_CODE_CLI_PATH'] = '/custom/path/to/qwen'; + mockFs.existsSync.mockReturnValue(true); + + const result = findNativeCliPath(); + + expect(result).toBe('/custom/path/to/qwen'); + + process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + }); + + it('should search common installation locations', () => { + const originalEnv = process.env['QWEN_CODE_CLI_PATH']; + delete process.env['QWEN_CODE_CLI_PATH']; + + // Mock fs.existsSync to return true for volta bin + mockFs.existsSync.mockImplementation((path) => { + return path.toString().includes('.volta/bin/qwen'); + }); + + const result = findNativeCliPath(); + + expect(result).toContain('.volta/bin/qwen'); + + process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + }); + + it('should throw descriptive error when CLI not found', () => { + const originalEnv = process.env['QWEN_CODE_CLI_PATH']; + delete process.env['QWEN_CODE_CLI_PATH']; + mockFs.existsSync.mockReturnValue(false); + + expect(() => findNativeCliPath()).toThrow('qwen CLI not found. Please:'); + + process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + }); + }); + + describe('resolveCliPath (backward compatibility)', () => { + it('should resolve CLI path for backward compatibility', () => { + mockFs.existsSync.mockReturnValue(true); + + const result = resolveCliPath('/path/to/qwen'); + + expect(result).toBe('/path/to/qwen'); + }); + + it('should auto-detect when no path provided', () => { + const originalEnv = process.env['QWEN_CODE_CLI_PATH']; + process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen'; + mockFs.existsSync.mockReturnValue(true); + + const result = resolveCliPath(); + + expect(result).toBe('/usr/local/bin/qwen'); + + process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + }); + }); + + describe('real-world use cases', () => { + beforeEach(() => { + 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); + + expect(result).toEqual({ + command: process.execPath, + args: [path.resolve(bundlePath)], + type: 'node', + originalInput: bundlePath, + }); + }); + + it('should handle production native binary', () => { + const result = prepareSpawnInfo('qwen'); + + expect(result).toEqual({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + }); + + 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}`, + }); + }); + }); + + 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 provide helpful error for invalid runtime specification', () => { + expect(() => prepareSpawnInfo('invalid:spec')).toThrow( + 'Unsupported runtime', + ); + }); + }); + + describe('comprehensive validation', () => { + describe('runtime validation', () => { + it('should reject unsupported runtimes', () => { + expect(() => + parseExecutableSpec('unsupported:/path/to/file.js'), + ).toThrow( + "Unsupported runtime 'unsupported'. Supported runtimes: node, bun, tsx, deno", + ); + }); + + 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( + 'Set QWEN_CODE_CLI_PATH environment variable', + ); + expect(() => parseExecutableSpec('/missing/file')).toThrow( + 'Install qwen globally: npm install -g qwen', + ); + expect(() => parseExecutableSpec('/missing/file')).toThrow( + 'Force specific runtime: bun:/path/to/cli.js or tsx:/path/to/index.ts', + ); + }); + }); + }); +}); diff --git a/packages/sdk-typescript/test/unit/createSdkMcpServer.test.ts b/packages/sdk-typescript/test/unit/createSdkMcpServer.test.ts new file mode 100644 index 0000000000..e608ba7b21 --- /dev/null +++ b/packages/sdk-typescript/test/unit/createSdkMcpServer.test.ts @@ -0,0 +1,350 @@ +/** + * Unit tests for createSdkMcpServer + * + * Tests MCP server creation and tool registration. + */ + +import { describe, expect, it, vi } from 'vitest'; +import { createSdkMcpServer } from '../../src/mcp/createSdkMcpServer.js'; +import { tool } from '../../src/mcp/tool.js'; +import type { ToolDefinition } from '../../src/types/config.js'; + +describe('createSdkMcpServer', () => { + describe('Server Creation', () => { + it('should create server with name and version', () => { + const server = createSdkMcpServer('test-server', '1.0.0', []); + + expect(server).toBeDefined(); + }); + + it('should throw error with invalid name', () => { + expect(() => createSdkMcpServer('', '1.0.0', [])).toThrow( + 'name must be a non-empty string', + ); + }); + + it('should throw error with invalid version', () => { + expect(() => createSdkMcpServer('test', '', [])).toThrow( + 'version must be a non-empty string', + ); + }); + + it('should throw error with non-array tools', () => { + expect(() => + createSdkMcpServer('test', '1.0.0', {} as unknown as ToolDefinition[]), + ).toThrow('Tools must be an array'); + }); + }); + + describe('Tool Registration', () => { + it('should register single tool', () => { + const testTool = tool({ + name: 'test_tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: { + input: { type: 'string' }, + }, + }, + handler: async () => 'result', + }); + + const server = createSdkMcpServer('test-server', '1.0.0', [testTool]); + + expect(server).toBeDefined(); + }); + + it('should register multiple tools', () => { + const tool1 = tool({ + name: 'tool1', + description: 'Tool 1', + inputSchema: { type: 'object' }, + handler: async () => 'result1', + }); + + const tool2 = tool({ + name: 'tool2', + description: 'Tool 2', + inputSchema: { type: 'object' }, + handler: async () => 'result2', + }); + + const server = createSdkMcpServer('test-server', '1.0.0', [tool1, tool2]); + + expect(server).toBeDefined(); + }); + + it('should throw error for duplicate tool names', () => { + const tool1 = tool({ + name: 'duplicate', + description: 'Tool 1', + inputSchema: { type: 'object' }, + handler: async () => 'result1', + }); + + const tool2 = tool({ + name: 'duplicate', + description: 'Tool 2', + inputSchema: { type: 'object' }, + handler: async () => 'result2', + }); + + expect(() => + createSdkMcpServer('test-server', '1.0.0', [tool1, tool2]), + ).toThrow("Duplicate tool name 'duplicate'"); + }); + + it('should validate tool names', () => { + const invalidTool = { + name: '123invalid', // Starts with number + description: 'Invalid tool', + inputSchema: { type: 'object' }, + handler: async () => 'result', + }; + + expect(() => + createSdkMcpServer('test-server', '1.0.0', [ + invalidTool as unknown as ToolDefinition, + ]), + ).toThrow('Tool name'); + }); + }); + + describe('Tool Handler Invocation', () => { + it('should invoke tool handler with correct input', async () => { + const handler = vi.fn().mockResolvedValue({ result: 'success' }); + + const testTool = tool({ + name: 'test_tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: { + value: { type: 'string' }, + }, + required: ['value'], + }, + handler, + }); + + createSdkMcpServer('test-server', '1.0.0', [testTool]); + + // Note: Actual invocation testing requires MCP SDK integration + // This test verifies the handler was properly registered + expect(handler).toBeDefined(); + }); + + it('should handle async tool handlers', async () => { + const handler = vi + .fn() + .mockImplementation(async (input: { value: string }) => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return { processed: input.value }; + }); + + const testTool = tool({ + name: 'async_tool', + description: 'An async tool', + inputSchema: { type: 'object' }, + handler, + }); + + const server = createSdkMcpServer('test-server', '1.0.0', [testTool]); + + expect(server).toBeDefined(); + }); + }); + + describe('Type Safety', () => { + it('should preserve input type in handler', async () => { + type ToolInput = { + name: string; + age: number; + }; + + type ToolOutput = { + greeting: string; + }; + + const handler = vi + .fn() + .mockImplementation(async (input: ToolInput): Promise => { + return { + greeting: `Hello ${input.name}, age ${input.age}`, + }; + }); + + const typedTool = tool({ + name: 'typed_tool', + description: 'A typed tool', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + required: ['name', 'age'], + }, + handler, + }); + + const server = createSdkMcpServer('test-server', '1.0.0', [ + typedTool as ToolDefinition, + ]); + + expect(server).toBeDefined(); + }); + }); + + describe('Error Handling in Tools', () => { + it('should handle tool handler errors gracefully', async () => { + const handler = vi.fn().mockRejectedValue(new Error('Tool failed')); + + const errorTool = tool({ + name: 'error_tool', + description: 'A tool that errors', + inputSchema: { type: 'object' }, + handler, + }); + + const server = createSdkMcpServer('test-server', '1.0.0', [errorTool]); + + expect(server).toBeDefined(); + // Error handling occurs during tool invocation + }); + + it('should handle synchronous tool handler errors', async () => { + const handler = vi.fn().mockImplementation(() => { + throw new Error('Sync error'); + }); + + const errorTool = tool({ + name: 'sync_error_tool', + description: 'A tool that errors synchronously', + inputSchema: { type: 'object' }, + handler, + }); + + const server = createSdkMcpServer('test-server', '1.0.0', [errorTool]); + + expect(server).toBeDefined(); + }); + }); + + describe('Complex Tool Scenarios', () => { + it('should support tool with complex input schema', () => { + const complexTool = tool({ + name: 'complex_tool', + description: 'A tool with complex schema', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string' }, + filters: { + type: 'object', + properties: { + category: { type: 'string' }, + minPrice: { type: 'number' }, + }, + }, + options: { + type: 'array', + items: { type: 'string' }, + }, + }, + required: ['query'], + }, + handler: async (input: { filters?: unknown[] }) => { + return { + results: [], + filters: input.filters, + }; + }, + }); + + const server = createSdkMcpServer('test-server', '1.0.0', [ + complexTool as ToolDefinition, + ]); + + expect(server).toBeDefined(); + }); + + it('should support tool returning complex output', () => { + const complexOutputTool = tool({ + name: 'complex_output_tool', + description: 'Returns complex data', + inputSchema: { type: 'object' }, + handler: async () => { + return { + data: [ + { id: 1, name: 'Item 1' }, + { id: 2, name: 'Item 2' }, + ], + metadata: { + total: 2, + page: 1, + }, + nested: { + deep: { + value: 'test', + }, + }, + }; + }, + }); + + const server = createSdkMcpServer('test-server', '1.0.0', [ + complexOutputTool, + ]); + + expect(server).toBeDefined(); + }); + }); + + describe('Multiple Servers', () => { + it('should create multiple independent servers', () => { + const tool1 = tool({ + name: 'tool1', + description: 'Tool in server 1', + inputSchema: { type: 'object' }, + handler: async () => 'result1', + }); + + const tool2 = tool({ + name: 'tool2', + description: 'Tool in server 2', + inputSchema: { type: 'object' }, + handler: async () => 'result2', + }); + + const server1 = createSdkMcpServer('server1', '1.0.0', [tool1]); + const server2 = createSdkMcpServer('server2', '1.0.0', [tool2]); + + expect(server1).toBeDefined(); + expect(server2).toBeDefined(); + }); + + it('should allow same tool name in different servers', () => { + const tool1 = tool({ + name: 'shared_name', + description: 'Tool in server 1', + inputSchema: { type: 'object' }, + handler: async () => 'result1', + }); + + const tool2 = tool({ + name: 'shared_name', + description: 'Tool in server 2', + inputSchema: { type: 'object' }, + handler: async () => 'result2', + }); + + const server1 = createSdkMcpServer('server1', '1.0.0', [tool1]); + const server2 = createSdkMcpServer('server2', '1.0.0', [tool2]); + + expect(server1).toBeDefined(); + expect(server2).toBeDefined(); + }); + }); +}); diff --git a/packages/sdk-typescript/tsconfig.json b/packages/sdk-typescript/tsconfig.json new file mode 100644 index 0000000000..11fba047e6 --- /dev/null +++ b/packages/sdk-typescript/tsconfig.json @@ -0,0 +1,41 @@ +{ + "compilerOptions": { + /* Language and Environment */ + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + + /* Emit */ + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "removeComments": true, + "importHelpers": false, + + /* Interop Constraints */ + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + + /* Type Checking */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": false, + + /* Completeness */ + "skipLibCheck": true, + + /* Module Resolution */ + "resolveJsonModule": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "test", ".integration-tests"] +} diff --git a/packages/sdk-typescript/vitest.config.ts b/packages/sdk-typescript/vitest.config.ts new file mode 100644 index 0000000000..33018d83a5 --- /dev/null +++ b/packages/sdk-typescript/vitest.config.ts @@ -0,0 +1,40 @@ +import { defineConfig } from 'vitest/config'; +import * as path from 'path'; + +const timeoutMinutes = Number(process.env['E2E_TIMEOUT_MINUTES'] || '3'); +const testTimeoutMs = timeoutMinutes * 60 * 1000; + +export default defineConfig({ + test: { + globals: false, + environment: 'node', + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: [ + 'node_modules/', + 'dist/', + 'test/', + '**/*.d.ts', + '**/*.config.*', + '**/index.ts', // Export-only files + ], + thresholds: { + lines: 80, + functions: 80, + branches: 75, + statements: 80, + }, + }, + include: ['test/**/*.test.ts'], + exclude: ['node_modules/', 'dist/'], + testTimeout: testTimeoutMs, + hookTimeout: 10000, + globalSetup: './test/e2e/globalSetup.ts', + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts index 20ec6b90f4..88cded8b8a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ 'packages/cli', 'packages/core', 'packages/vscode-ide-companion', + 'packages/sdk-typescript', 'integration-tests', 'scripts', ], From d76cdf107615ec7a6ff941c7d88dce498ce9b90e Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Tue, 25 Nov 2025 10:03:15 +0800 Subject: [PATCH 02/22] feat: sdk subagent support --- .../control/controllers/systemController.ts | 11 +- .../subagents/manage/AgentEditStep.tsx | 5 +- .../subagents/manage/AgentSelectionStep.tsx | 4 +- packages/core/src/config/config.ts | 13 +- .../src/subagents/subagent-manager.test.ts | 20 +- .../core/src/subagents/subagent-manager.ts | 23 +- packages/core/src/subagents/types.ts | 4 +- packages/sdk-typescript/src/query/Query.ts | 1 + packages/sdk-typescript/src/types/protocol.ts | 2 +- .../src/types/queryOptionsSchema.ts | 7 +- .../sdk-typescript/test/e2e/subagents.test.ts | 656 ++++++++++++++++++ 11 files changed, 705 insertions(+), 41 deletions(-) create mode 100644 packages/sdk-typescript/test/e2e/subagents.test.ts diff --git a/packages/cli/src/nonInteractive/control/controllers/systemController.ts b/packages/cli/src/nonInteractive/control/controllers/systemController.ts index a33ea16135..7981a67b91 100644 --- a/packages/cli/src/nonInteractive/control/controllers/systemController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/systemController.ts @@ -54,13 +54,13 @@ export class SystemController extends BaseController { private async handleInitialize( payload: CLIControlInitializeRequest, ): Promise> { - // Register SDK MCP servers if provided + this.context.config.setSdkMode(true); + if (payload.sdkMcpServers && typeof payload.sdkMcpServers === 'object') { for (const serverName of Object.keys(payload.sdkMcpServers)) { this.context.sdkMcpServers.add(serverName); } - // Add SDK MCP servers to config try { this.context.config.addMcpServers(payload.sdkMcpServers); if (this.context.debugMode) { @@ -78,7 +78,6 @@ export class SystemController extends BaseController { } } - // Add MCP servers to config if provided if (payload.mcpServers && typeof payload.mcpServers === 'object') { try { this.context.config.addMcpServers(payload.mcpServers); @@ -94,10 +93,9 @@ export class SystemController extends BaseController { } } - // Add session subagents to config if provided if (payload.agents && Array.isArray(payload.agents)) { try { - this.context.config.addSessionSubagents(payload.agents); + this.context.config.setSessionSubagents(payload.agents); if (this.context.debugMode) { console.error( @@ -114,9 +112,6 @@ export class SystemController extends BaseController { } } - // Set SDK mode to true after handling initialize - this.context.config.setSdkMode(true); - // Build capabilities for response const capabilities = this.buildControlCapabilities(); diff --git a/packages/cli/src/ui/components/subagents/manage/AgentEditStep.tsx b/packages/cli/src/ui/components/subagents/manage/AgentEditStep.tsx index ab1cd2a904..ccec2ebf63 100644 --- a/packages/cli/src/ui/components/subagents/manage/AgentEditStep.tsx +++ b/packages/cli/src/ui/components/subagents/manage/AgentEditStep.tsx @@ -69,7 +69,10 @@ export function EditOptionsStep({ if (selectedValue === 'editor') { // Launch editor directly try { - await launchEditor(selectedAgent?.filePath); + if (!selectedAgent.filePath) { + throw new Error('Agent has no file path'); + } + await launchEditor(selectedAgent.filePath); } catch (err) { setError( t('Failed to launch editor: {{error}}', { diff --git a/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx b/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx index a186374dd5..add3dcb586 100644 --- a/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx +++ b/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx @@ -267,7 +267,7 @@ export const AgentSelectionStep = ({ {t('Project Level ({{path}})', { - path: projectAgents[0].filePath.replace(/\/[^/]+$/, ''), + path: projectAgents[0].filePath?.replace(/\/[^/]+$/, '') || '', })} @@ -289,7 +289,7 @@ export const AgentSelectionStep = ({ > {t('User Level ({{path}})', { - path: userAgents[0].filePath.replace(/\/[^/]+$/, ''), + path: userAgents[0].filePath?.replace(/\/[^/]+$/, '') || '', })} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 29757ff6fe..65c39d8e34 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -613,6 +613,12 @@ export class Config { } this.promptRegistry = new PromptRegistry(); this.subagentManager = new SubagentManager(this); + + // Load session subagents if they were provided before initialization + if (this.sessionSubagents.length > 0) { + this.subagentManager.loadSessionSubagents(this.sessionSubagents); + } + this.toolRegistry = await this.createToolRegistry(); await this.geminiClient.initialize(); @@ -874,13 +880,6 @@ export class Config { this.sessionSubagents = subagents; } - addSessionSubagents(subagents: SubagentConfig[]): void { - if (this.initialized) { - throw new Error('Cannot modify sessionSubagents after initialization'); - } - this.sessionSubagents = [...this.sessionSubagents, ...subagents]; - } - getSdkMode(): boolean { return this.sdkMode; } diff --git a/packages/core/src/subagents/subagent-manager.test.ts b/packages/core/src/subagents/subagent-manager.test.ts index 26436c88fe..e04964ea12 100644 --- a/packages/core/src/subagents/subagent-manager.test.ts +++ b/packages/core/src/subagents/subagent-manager.test.ts @@ -182,7 +182,7 @@ You are a helpful assistant. it('should parse valid markdown content', () => { const config = manager.parseSubagentContent( validMarkdown, - validConfig.filePath, + validConfig.filePath!, 'project', ); @@ -207,7 +207,7 @@ You are a helpful assistant. const config = manager.parseSubagentContent( markdownWithTools, - validConfig.filePath, + validConfig.filePath!, 'project', ); @@ -228,7 +228,7 @@ You are a helpful assistant. const config = manager.parseSubagentContent( markdownWithModel, - validConfig.filePath, + validConfig.filePath!, 'project', ); @@ -249,7 +249,7 @@ You are a helpful assistant. const config = manager.parseSubagentContent( markdownWithRun, - validConfig.filePath, + validConfig.filePath!, 'project', ); @@ -267,7 +267,7 @@ You are a helpful assistant. const config = manager.parseSubagentContent( markdownWithNumeric, - validConfig.filePath, + validConfig.filePath!, 'project', ); @@ -288,7 +288,7 @@ You are a helpful assistant. const config = manager.parseSubagentContent( markdownWithBoolean, - validConfig.filePath, + validConfig.filePath!, 'project', ); @@ -324,7 +324,7 @@ Just content`; expect(() => manager.parseSubagentContent( invalidMarkdown, - validConfig.filePath, + validConfig.filePath!, 'project', ), ).toThrow(SubagentError); @@ -341,7 +341,7 @@ You are a helpful assistant. expect(() => manager.parseSubagentContent( markdownWithoutName, - validConfig.filePath, + validConfig.filePath!, 'project', ), ).toThrow(SubagentError); @@ -358,7 +358,7 @@ You are a helpful assistant. expect(() => manager.parseSubagentContent( markdownWithoutDescription, - validConfig.filePath, + validConfig.filePath!, 'project', ), ).toThrow(SubagentError); @@ -438,7 +438,7 @@ You are a helpful assistant. await manager.createSubagent(validConfig, { level: 'project' }); expect(fs.mkdir).toHaveBeenCalledWith( - path.normalize(path.dirname(validConfig.filePath)), + path.normalize(path.dirname(validConfig.filePath!)), { recursive: true }, ); expect(fs.writeFile).toHaveBeenCalledWith( diff --git a/packages/core/src/subagents/subagent-manager.ts b/packages/core/src/subagents/subagent-manager.ts index d83e3e7a38..baf49fa921 100644 --- a/packages/core/src/subagents/subagent-manager.ts +++ b/packages/core/src/subagents/subagent-manager.ts @@ -159,7 +159,14 @@ export class SubagentManager { return this.findSubagentByNameAtLevel(name, level); } - // Try project level first + // Try session level first (highest priority for runtime) + const sessionSubagents = this.subagentsCache?.get('session') || []; + const sessionConfig = sessionSubagents.find((agent) => agent.name === name); + if (sessionConfig) { + return sessionConfig; + } + + // Try project level const projectConfig = await this.findSubagentByNameAtLevel(name, 'project'); if (projectConfig) { return projectConfig; @@ -220,6 +227,15 @@ export class SubagentManager { // Validate the updated configuration this.validator.validateOrThrow(updatedConfig); + // Ensure filePath exists for file-based agents + if (!existing.filePath) { + throw new SubagentError( + `Cannot update subagent "${name}": no file path available`, + SubagentErrorCode.FILE_ERROR, + name, + ); + } + // Write the updated configuration const content = this.serializeSubagent(updatedConfig); @@ -302,11 +318,6 @@ export class SubagentManager { // In SDK mode, only load session-level subagents if (this.config.getSdkMode()) { - const sessionSubagents = this.config.getSessionSubagents(); - if (sessionSubagents && sessionSubagents.length > 0) { - this.loadSessionSubagents(sessionSubagents); - } - const levelsToCheck: SubagentLevel[] = options.level ? [options.level] : ['session']; diff --git a/packages/core/src/subagents/types.ts b/packages/core/src/subagents/types.ts index 0f83e3f1f2..accfb18fb3 100644 --- a/packages/core/src/subagents/types.ts +++ b/packages/core/src/subagents/types.ts @@ -42,8 +42,8 @@ export interface SubagentConfig { /** Storage level - determines where the configuration file is stored */ level: SubagentLevel; - /** Absolute path to the configuration file */ - filePath: string; + /** Absolute path to the configuration file. Optional for session subagents. */ + filePath?: string; /** * Optional model configuration. If not provided, uses defaults. diff --git a/packages/sdk-typescript/src/query/Query.ts b/packages/sdk-typescript/src/query/Query.ts index 55d767c530..c8039d4c80 100644 --- a/packages/sdk-typescript/src/query/Query.ts +++ b/packages/sdk-typescript/src/query/Query.ts @@ -129,6 +129,7 @@ export class Query implements AsyncIterable { sdkMcpServers: sdkMcpServerNames.length > 0 ? sdkMcpServerNames : undefined, mcpServers: this.options.mcpServers, + agents: this.options.agents, }); } catch (error) { console.error('[Query] Initialization error:', error); diff --git a/packages/sdk-typescript/src/types/protocol.ts b/packages/sdk-typescript/src/types/protocol.ts index 399221e0a6..2f1f9fe987 100644 --- a/packages/sdk-typescript/src/types/protocol.ts +++ b/packages/sdk-typescript/src/types/protocol.ts @@ -517,7 +517,7 @@ export interface SubagentConfig { tools?: string[]; systemPrompt: string; level: SubagentLevel; - filePath: string; + filePath?: string; modelConfig?: Partial; runConfig?: Partial; color?: string; diff --git a/packages/sdk-typescript/src/types/queryOptionsSchema.ts b/packages/sdk-typescript/src/types/queryOptionsSchema.ts index d3a548af93..7573abef3c 100644 --- a/packages/sdk-typescript/src/types/queryOptionsSchema.ts +++ b/packages/sdk-typescript/src/types/queryOptionsSchema.ts @@ -31,7 +31,6 @@ export const SubagentConfigSchema = z.object({ description: z.string().min(1, 'Description must be a non-empty string'), tools: z.array(z.string()).optional(), systemPrompt: z.string().min(1, 'System prompt must be a non-empty string'), - filePath: z.string().min(1, 'File path must be a non-empty string'), modelConfig: ModelConfigSchema.partial().optional(), runConfig: RunConfigSchema.partial().optional(), color: z.string().optional(), @@ -71,9 +70,9 @@ export const QueryOptionsSchema = z typeof val === 'object' && 'name' in val && 'description' in val && - 'systemPrompt' in val && - 'filePath' in val, - { message: 'agents must be an array of SubagentConfig objects' }, + 'systemPrompt' in val && { + message: 'agents must be an array of SubagentConfig objects', + }, ), ) .optional(), diff --git a/packages/sdk-typescript/test/e2e/subagents.test.ts b/packages/sdk-typescript/test/e2e/subagents.test.ts new file mode 100644 index 0000000000..fcceebb557 --- /dev/null +++ b/packages/sdk-typescript/test/e2e/subagents.test.ts @@ -0,0 +1,656 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * E2E tests for subagent configuration and execution + * Tests subagent delegation and task completion + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { query } from '../../src/index.js'; +import { + isCLIAssistantMessage, + isCLISystemMessage, + isCLIResultMessage, + type TextBlock, + type ContentBlock, + type CLIMessage, + type CLISystemMessage, + type SubagentConfig, + type ToolUseBlock, +} from '../../src/types/protocol.js'; +import { writeFile, mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!; +const E2E_TEST_FILE_DIR = process.env['E2E_TEST_FILE_DIR']!; + +const SHARED_TEST_OPTIONS = { + pathToQwenExecutable: TEST_CLI_PATH, +}; + +/** + * Helper to extract text from ContentBlock array + */ +function extractText(content: ContentBlock[]): string { + return content + .filter((block): block is TextBlock => block.type === 'text') + .map((block) => block.text) + .join(''); +} + +describe('Subagents (E2E)', () => { + let testWorkDir: string; + + beforeAll(async () => { + // Create a test working directory + testWorkDir = join(E2E_TEST_FILE_DIR, 'subagent-tests'); + await mkdir(testWorkDir, { recursive: true }); + + // Create a simple test file for subagent to work with + const testFilePath = join(testWorkDir, 'test.txt'); + await writeFile(testFilePath, 'Hello from test file\n', 'utf-8'); + }); + + describe('Subagent Configuration', () => { + it('should accept session-level subagent configuration', async () => { + const simpleSubagent: SubagentConfig = { + name: 'simple-greeter', + description: 'A simple subagent that responds to greetings', + systemPrompt: + 'You are a friendly greeter. When given a task, respond with a cheerful greeting.', + level: 'session', + }; + + const q = query({ + prompt: 'Hello, let simple-greeter to say hi back to me.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [simpleSubagent], + debug: false, + }, + }); + + let systemMessage: CLISystemMessage | null = null; + const messages: CLIMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Validate system message includes the subagent + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('simple-greeter'); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isCLIResultMessage(lastMessage)).toBe(true); + if (isCLIResultMessage(lastMessage)) { + expect(lastMessage.subtype).toBe('success'); + } + } finally { + await q.close(); + } + }); + + it('should accept multiple subagent configurations', async () => { + const greeterAgent: SubagentConfig = { + name: 'greeter', + description: 'Responds to greetings', + systemPrompt: 'You are a friendly greeter.', + level: 'session', + }; + + const mathAgent: SubagentConfig = { + name: 'math-helper', + description: 'Helps with math problems', + systemPrompt: 'You are a math expert. Solve math problems clearly.', + level: 'session', + }; + + const q = query({ + prompt: 'What is 5 + 5?', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [greeterAgent, mathAgent], + debug: false, + }, + }); + + let systemMessage: CLISystemMessage | null = null; + + try { + for await (const message of q) { + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Validate both subagents are registered + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('greeter'); + expect(systemMessage!.agents).toContain('math-helper'); + expect(systemMessage!.agents!.length).toBeGreaterThanOrEqual(2); + } finally { + await q.close(); + } + }); + + it('should handle subagent with custom model config', async () => { + const customModelAgent: SubagentConfig = { + name: 'custom-model-agent', + description: 'Agent with custom model configuration', + systemPrompt: 'You are a helpful assistant.', + level: 'session', + modelConfig: { + temp: 0.7, + top_p: 0.9, + }, + }; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [customModelAgent], + debug: false, + }, + }); + + let systemMessage: CLISystemMessage | null = null; + + try { + for await (const message of q) { + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Validate subagent is registered + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('custom-model-agent'); + } finally { + await q.close(); + } + }); + + it('should handle subagent with run config', async () => { + const limitedAgent: SubagentConfig = { + name: 'limited-agent', + description: 'Agent with execution limits', + systemPrompt: 'You are a helpful assistant.', + level: 'session', + runConfig: { + max_turns: 5, + max_time_minutes: 1, + }, + }; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [limitedAgent], + debug: false, + }, + }); + + let systemMessage: CLISystemMessage | null = null; + + try { + for await (const message of q) { + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Validate subagent is registered + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('limited-agent'); + } finally { + await q.close(); + } + }); + + it('should handle subagent with specific tools', async () => { + const toolRestrictedAgent: SubagentConfig = { + name: 'read-only-agent', + description: 'Agent that can only read files', + systemPrompt: + 'You are a file reading assistant. Read files when asked.', + level: 'session', + tools: ['read_file', 'list_directory'], + }; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [toolRestrictedAgent], + debug: false, + }, + }); + + let systemMessage: CLISystemMessage | null = null; + + try { + for await (const message of q) { + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Validate subagent is registered + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('read-only-agent'); + } finally { + await q.close(); + } + }); + }); + + describe('Subagent Execution', () => { + it('should delegate task to subagent when appropriate', async () => { + const fileReaderAgent: SubagentConfig = { + name: 'file-reader', + description: 'Reads and reports file contents', + systemPrompt: `You are a file reading assistant. When given a task to read a file, use the read_file tool to read it and report its contents back. Be concise in your response.`, + level: 'session', + tools: ['read_file', 'list_directory'], + }; + + const testFile = join(testWorkDir, 'test.txt'); + const q = query({ + prompt: `Use the file-reader subagent to read the file at ${testFile} and tell me what it contains.`, + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [fileReaderAgent], + debug: false, + permissionMode: 'yolo', + }, + }); + + const messages: CLIMessage[] = []; + let foundTaskTool = false; + let taskToolUseId: string | null = null; + let foundSubagentToolCall = false; + let assistantText = ''; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + // Check for task tool use in content blocks (main agent calling subagent) + const toolUseBlock = message.message.content.find( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use' && block.name === 'task', + ); + if (toolUseBlock) { + foundTaskTool = true; + taskToolUseId = toolUseBlock.id; + } + + // Check if this message is from a subagent (has parent_tool_use_id) + if (message.parent_tool_use_id !== null) { + // This is a subagent message + const subagentToolUse = message.message.content.find( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use', + ); + if (subagentToolUse) { + foundSubagentToolCall = true; + // Verify parent_tool_use_id matches the task tool use id + expect(message.parent_tool_use_id).toBe(taskToolUseId); + } + } + + assistantText += extractText(message.message.content); + } + } + + // Validate task tool was used (subagent delegation) + expect(foundTaskTool).toBe(true); + expect(taskToolUseId).not.toBeNull(); + + // Validate subagent actually made tool calls with proper parent_tool_use_id + expect(foundSubagentToolCall).toBe(true); + + // Validate we got a response + expect(assistantText.length).toBeGreaterThan(0); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isCLIResultMessage(lastMessage)).toBe(true); + if (isCLIResultMessage(lastMessage)) { + expect(lastMessage.subtype).toBe('success'); + } + } finally { + await q.close(); + } + }, 60000); // Increase timeout for subagent execution + + it('should complete simple task with subagent', async () => { + const simpleTaskAgent: SubagentConfig = { + name: 'simple-calculator', + description: 'Performs simple arithmetic calculations', + systemPrompt: + 'You are a calculator. When given a math problem, solve it and provide just the answer.', + level: 'session', + }; + + const q = query({ + prompt: 'Use the simple-calculator subagent to calculate 15 + 27.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [simpleTaskAgent], + debug: false, + permissionMode: 'yolo', + }, + }); + + const messages: CLIMessage[] = []; + let foundTaskTool = false; + let assistantText = ''; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + // Check for task tool use (main agent delegating to subagent) + const toolUseBlock = message.message.content.find( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use' && block.name === 'task', + ); + if (toolUseBlock) { + foundTaskTool = true; + } + + assistantText += extractText(message.message.content); + } + } + + // Validate task tool was used (subagent was called) + expect(foundTaskTool).toBe(true); + + // Validate we got a response + expect(assistantText.length).toBeGreaterThan(0); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isCLIResultMessage(lastMessage)).toBe(true); + if (isCLIResultMessage(lastMessage)) { + expect(lastMessage.subtype).toBe('success'); + } + } finally { + await q.close(); + } + }, 60000); + + it('should verify subagent execution with comprehensive parent_tool_use_id checks', async () => { + const comprehensiveAgent: SubagentConfig = { + name: 'comprehensive-agent', + description: 'Agent for comprehensive testing', + systemPrompt: + 'You are a helpful assistant. When asked to list files, use the list_directory tool.', + level: 'session', + tools: ['list_directory', 'read_file'], + }; + + const q = query({ + prompt: `Use the comprehensive-agent subagent to list the files in ${testWorkDir}.`, + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [comprehensiveAgent], + debug: false, + permissionMode: 'yolo', + }, + }); + + const messages: CLIMessage[] = []; + let taskToolUseId: string | null = null; + const subagentToolCalls: ToolUseBlock[] = []; + const mainAgentToolCalls: ToolUseBlock[] = []; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + // Collect all tool use blocks + const toolUseBlocks = message.message.content.filter( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use', + ); + + for (const toolUse of toolUseBlocks) { + if (toolUse.name === 'task') { + // This is the main agent calling the subagent + taskToolUseId = toolUse.id; + mainAgentToolCalls.push(toolUse); + } + + // If this message has parent_tool_use_id, it's from a subagent + if (message.parent_tool_use_id !== null) { + subagentToolCalls.push(toolUse); + } + } + } + } + + // Criterion 1: When a subagent is called, there must be a 'task' tool being called + expect(taskToolUseId).not.toBeNull(); + expect(mainAgentToolCalls.length).toBeGreaterThan(0); + expect(mainAgentToolCalls.some((tc) => tc.name === 'task')).toBe(true); + + // Criterion 2: A tool call from a subagent is identified by a non-null parent_tool_use_id + // All subagent tool calls should have parent_tool_use_id set to the task tool's id + expect(subagentToolCalls.length).toBeGreaterThan(0); + + // Verify all subagent messages have the correct parent_tool_use_id + const subagentMessages = messages.filter( + (msg): msg is CLIMessage & { parent_tool_use_id: string } => + isCLIAssistantMessage(msg) && msg.parent_tool_use_id !== null, + ); + + expect(subagentMessages.length).toBeGreaterThan(0); + for (const subagentMsg of subagentMessages) { + expect(subagentMsg.parent_tool_use_id).toBe(taskToolUseId); + } + + // Verify no main agent tool calls (except task) have parent_tool_use_id + const mainAgentMessages = messages.filter( + (msg): msg is CLIMessage => + isCLIAssistantMessage(msg) && msg.parent_tool_use_id === null, + ); + + for (const mainMsg of mainAgentMessages) { + if (isCLIAssistantMessage(mainMsg)) { + // Main agent messages should not have parent_tool_use_id + expect(mainMsg.parent_tool_use_id).toBeNull(); + } + } + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isCLIResultMessage(lastMessage)).toBe(true); + if (isCLIResultMessage(lastMessage)) { + expect(lastMessage.subtype).toBe('success'); + } + } finally { + await q.close(); + } + }, 60000); + }); + + describe('Subagent Error Handling', () => { + it('should handle empty subagent array', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [], + debug: false, + }, + }); + + let systemMessage: CLISystemMessage | null = null; + + try { + for await (const message of q) { + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Should still work with empty agents array + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + } finally { + await q.close(); + } + }); + + it('should handle subagent with minimal configuration', async () => { + const minimalAgent: SubagentConfig = { + name: 'minimal-agent', + description: 'Minimal configuration agent', + systemPrompt: 'You are a helpful assistant.', + level: 'session', + }; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [minimalAgent], + debug: false, + }, + }); + + let systemMessage: CLISystemMessage | null = null; + + try { + for await (const message of q) { + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Validate minimal agent is registered + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('minimal-agent'); + } finally { + await q.close(); + } + }); + }); + + describe('Subagent Integration', () => { + it('should work with other SDK options', async () => { + const testAgent: SubagentConfig = { + name: 'test-agent', + description: 'Test agent for integration', + systemPrompt: 'You are a test assistant.', + level: 'session', + }; + + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [testAgent], + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + permissionMode: 'default', + }, + }); + + let systemMessage: CLISystemMessage | null = null; + + try { + for await (const message of q) { + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Validate subagent works with debug mode + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('test-agent'); + expect(stderrMessages.length).toBeGreaterThan(0); + } finally { + await q.close(); + } + }); + + it('should maintain session consistency with subagents', async () => { + const sessionAgent: SubagentConfig = { + name: 'session-agent', + description: 'Agent for session testing', + systemPrompt: 'You are a session test assistant.', + level: 'session', + }; + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [sessionAgent], + debug: false, + }, + }); + + let systemMessage: CLISystemMessage | null = null; + + try { + for await (const message of q) { + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Validate session consistency + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.session_id).toBeDefined(); + expect(systemMessage!.uuid).toBeDefined(); + expect(systemMessage!.session_id).toBe(systemMessage!.uuid); + expect(systemMessage!.agents).toContain('session-agent'); + } finally { + await q.close(); + } + }); + }); +}); From ad9ba914e1f7b02fb87266b855346cff80f2d254 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Tue, 25 Nov 2025 10:27:06 +0800 Subject: [PATCH 03/22] refactor: clean up exports in sdk-typescript index file --- packages/sdk-typescript/src/index.ts | 30 ---------------------------- 1 file changed, 30 deletions(-) diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index c732c6ff6f..4c549fcb6a 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -28,38 +28,8 @@ export { isCLIPartialAssistantMessage, } from './types/protocol.js'; -export { AbortError, isAbortError } from './types/errors.js'; - -export { ControlRequestType } from './types/protocol.js'; - -export { ProcessTransport } from './transport/ProcessTransport.js'; -export type { Transport } from './transport/Transport.js'; - -export { Stream } from './utils/Stream.js'; -export { - serializeJsonLine, - parseJsonLineSafe, - isValidMessage, - parseJsonLinesStream, -} from './utils/jsonLines.js'; -export { - findCliPath, - resolveCliPath, - prepareSpawnInfo, -} from './utils/cliPath.js'; -export type { SpawnInfo } from './utils/cliPath.js'; - -export { createSdkMcpServer } from './mcp/createSdkMcpServer.js'; -export { - tool, - createTool, - validateToolName, - validateInputSchema, -} from './mcp/tool.js'; - export type { JSONSchema, - ToolDefinition, PermissionMode, CanUseTool, PermissionResult, From ac6aecb6228bfbe8a23f7a4e0be406e97876b0a8 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Tue, 25 Nov 2025 11:45:34 +0800 Subject: [PATCH 04/22] refactor: update test structure and clean up unused code in cli and sdk --- .../controllers/permissionController.ts | 2 +- .../cli/src/nonInteractive/session.test.ts | 2 +- packages/cli/src/nonInteractive/types.ts | 6 - packages/core/src/config/config.ts | 7 - packages/sdk-typescript/src/index.ts | 2 +- .../sdk-typescript/src/query/createQuery.ts | 21 - .../src/transport/ProcessTransport.ts | 44 - packages/sdk-typescript/src/types/types.ts | 2 - packages/sdk-typescript/src/utils/Stream.ts | 12 - packages/sdk-typescript/src/utils/cliPath.ts | 21 - .../sdk-typescript/src/utils/jsonLines.ts | 4 - .../test/unit/ProcessTransport.test.ts | 1390 ++++++++++++++-- .../sdk-typescript/test/unit/Query.test.ts | 1469 +++++++++++++++-- .../sdk-typescript/test/unit/cliPath.test.ts | 23 - 14 files changed, 2612 insertions(+), 393 deletions(-) diff --git a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts index 08c6d41fed..37a9082f35 100644 --- a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts @@ -442,7 +442,7 @@ export class PermissionController extends BaseController { // On error, use default cancel message // Only pass payload for exec and mcp types that support it const confirmationType = toolCall.confirmationDetails.type; - if (confirmationType === 'exec' || confirmationType === 'mcp') { + if (['edit', 'exec', 'mcp'].includes(confirmationType)) { const execOrMcpDetails = toolCall.confirmationDetails as | ToolExecuteConfirmationDetails | ToolMcpConfirmationDetails; diff --git a/packages/cli/src/nonInteractive/session.test.ts b/packages/cli/src/nonInteractive/session.test.ts index 15f1595471..61643fb3ee 100644 --- a/packages/cli/src/nonInteractive/session.test.ts +++ b/packages/cli/src/nonInteractive/session.test.ts @@ -134,7 +134,7 @@ function createControlCancel(requestId: string): ControlCancelRequest { }; } -describe('runNonInteractiveStreamJson (refactored)', () => { +describe('runNonInteractiveStreamJson', () => { let config: Config; let mockInputReader: { read: () => AsyncGenerator< diff --git a/packages/cli/src/nonInteractive/types.ts b/packages/cli/src/nonInteractive/types.ts index 2eec24c1fe..fb8dcf7667 100644 --- a/packages/cli/src/nonInteractive/types.ts +++ b/packages/cli/src/nonInteractive/types.ts @@ -299,12 +299,6 @@ export interface CLIControlPermissionRequest { blocked_path: string | null; } -export enum AuthProviderType { - DYNAMIC_DISCOVERY = 'dynamic_discovery', - GOOGLE_CREDENTIALS = 'google_credentials', - SERVICE_ACCOUNT_IMPERSONATION = 'service_account_impersonation', -} - export interface CLIControlInitializeRequest { subtype: 'initialize'; hooks?: HookRegistration[] | null; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 65c39d8e34..be84655f33 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -855,13 +855,6 @@ export class Config { return this.mcpServers; } - setMcpServers(servers: Record): void { - if (this.initialized) { - throw new Error('Cannot modify mcpServers after initialization'); - } - this.mcpServers = servers; - } - addMcpServers(servers: Record): void { if (this.initialized) { throw new Error('Cannot modify mcpServers after initialization'); diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 4c549fcb6a..23ba3f936c 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -1,5 +1,5 @@ export { query } from './query/createQuery.js'; - +export { AbortError, isAbortError } from './types/errors.js'; export { Query } from './query/Query.js'; export type { ExternalMcpServerConfig } from './types/queryOptionsSchema.js'; diff --git a/packages/sdk-typescript/src/query/createQuery.ts b/packages/sdk-typescript/src/query/createQuery.ts index 4b87478e3e..e390763593 100644 --- a/packages/sdk-typescript/src/query/createQuery.ts +++ b/packages/sdk-typescript/src/query/createQuery.ts @@ -21,28 +21,20 @@ export function query({ prompt: string | AsyncIterable; options?: QueryOptions; }): Query { - // Validate options and obtain normalized executable metadata const parsedExecutable = validateOptions(options); - // Determine if this is a single-turn or multi-turn query - // Single-turn: string prompt (simple Q&A) - // Multi-turn: AsyncIterable prompt (streaming conversation) const isSingleTurn = typeof prompt === 'string'; - // Resolve CLI specification while preserving explicit runtime directives const pathToQwenExecutable = options.pathToQwenExecutable ?? parsedExecutable.executablePath; - // Use provided abortController or create a new one const abortController = options.abortController ?? new AbortController(); - // Create transport with abortController const transport = new ProcessTransport({ pathToQwenExecutable, cwd: options.cwd, model: options.model, permissionMode: options.permissionMode, - mcpServers: options.mcpServers, env: options.env, abortController, debug: options.debug, @@ -53,18 +45,14 @@ export function query({ authType: options.authType, }); - // Build query options with abortController const queryOptions: QueryOptions = { ...options, abortController, }; - // Create Query const queryInstance = new Query(transport, queryOptions, isSingleTurn); - // Handle prompt based on type if (isSingleTurn) { - // For single-turn queries, send the prompt directly via transport const stringPrompt = prompt as string; const message: CLIUserMessage = { type: 'user', @@ -95,16 +83,9 @@ export function query({ return queryInstance; } -/** - * Backward compatibility alias - * @deprecated Use query() instead - */ -export const createQuery = query; - function validateOptions( options: QueryOptions, ): ReturnType { - // Validate options using Zod schema const validationResult = QueryOptionsSchema.safeParse(options); if (!validationResult.success) { const errors = validationResult.error.errors @@ -113,7 +94,6 @@ function validateOptions( throw new Error(`Invalid QueryOptions: ${errors}`); } - // Validate executable path early to provide clear error messages let parsedExecutable: ReturnType; try { parsedExecutable = parseExecutableSpec(options.pathToQwenExecutable); @@ -122,7 +102,6 @@ function validateOptions( throw new Error(`Invalid pathToQwenExecutable: ${errorMessage}`); } - // Validate no MCP server name conflicts (cross-field validation not easily expressible in Zod) if (options.mcpServers && options.sdkMcpServers) { const externalNames = Object.keys(options.mcpServers); const sdkNames = Object.keys(options.sdkMcpServers); diff --git a/packages/sdk-typescript/src/transport/ProcessTransport.ts b/packages/sdk-typescript/src/transport/ProcessTransport.ts index 1c717f8c20..62a6b2d051 100644 --- a/packages/sdk-typescript/src/transport/ProcessTransport.ts +++ b/packages/sdk-typescript/src/transport/ProcessTransport.ts @@ -7,11 +7,6 @@ import { parseJsonLinesStream } from '../utils/jsonLines.js'; import { prepareSpawnInfo } from '../utils/cliPath.js'; import { AbortError } from '../types/errors.js'; -type ExitListener = { - callback: (error?: Error) => void; - handler: (code: number | null, signal: NodeJS.Signals | null) => void; -}; - export class ProcessTransport implements Transport { private childProcess: ChildProcess | null = null; private childStdin: Writable | null = null; @@ -21,7 +16,6 @@ export class ProcessTransport implements Transport { private _exitError: Error | null = null; private closed = false; private abortController: AbortController; - private exitListeners: ExitListener[] = []; private processExitHandler: (() => void) | null = null; private abortHandler: (() => void) | null = null; @@ -115,15 +109,6 @@ export class ProcessTransport implements Transport { this.logForDebugging(error.message); } } - - const error = this._exitError; - for (const listener of this.exitListeners) { - try { - listener.callback(error || undefined); - } catch (err) { - this.logForDebugging(`Exit listener error: ${err}`); - } - } }); } @@ -192,11 +177,6 @@ export class ProcessTransport implements Transport { this.abortHandler = null; } - for (const { handler } of this.exitListeners) { - this.childProcess?.off('close', handler); - } - this.exitListeners = []; - if (this.childProcess && !this.childProcess.killed) { this.childProcess.kill('SIGTERM'); setTimeout(() => { @@ -343,30 +323,6 @@ export class ProcessTransport implements Transport { return this._exitError; } - onExit(callback: (error?: Error) => void): () => void { - if (!this.childProcess) { - return () => {}; - } - - const handler = (code: number | null, signal: NodeJS.Signals | null) => { - const error = this.getProcessExitError(code, signal); - callback(error); - }; - - this.childProcess.on('close', handler); - this.exitListeners.push({ callback, handler }); - - return () => { - if (this.childProcess) { - this.childProcess.off('close', handler); - } - const index = this.exitListeners.findIndex((l) => l.handler === handler); - if (index !== -1) { - this.exitListeners.splice(index, 1); - } - }; - } - endInput(): void { if (this.childStdin) { this.childStdin.end(); diff --git a/packages/sdk-typescript/src/types/types.ts b/packages/sdk-typescript/src/types/types.ts index d2b9a400a2..856099fca1 100644 --- a/packages/sdk-typescript/src/types/types.ts +++ b/packages/sdk-typescript/src/types/types.ts @@ -1,5 +1,4 @@ import type { PermissionMode, PermissionSuggestion } from './protocol.js'; -import type { ExternalMcpServerConfig } from './queryOptionsSchema.js'; export type { PermissionMode }; @@ -23,7 +22,6 @@ export type TransportOptions = { cwd?: string; model?: string; permissionMode?: PermissionMode; - mcpServers?: Record; env?: Record; abortController?: AbortController; debug?: boolean; diff --git a/packages/sdk-typescript/src/utils/Stream.ts b/packages/sdk-typescript/src/utils/Stream.ts index 8a58c0be18..70caf82e1c 100644 --- a/packages/sdk-typescript/src/utils/Stream.ts +++ b/packages/sdk-typescript/src/utils/Stream.ts @@ -1,7 +1,3 @@ -/** - * Async iterable queue for streaming messages between producer and consumer. - */ - export class Stream implements AsyncIterable { private returned: (() => void) | undefined; private queue: T[] = []; @@ -24,23 +20,18 @@ export class Stream implements AsyncIterable { } async next(): Promise> { - // Check queue first - if there are queued items, return immediately if (this.queue.length > 0) { return Promise.resolve({ done: false, value: this.queue.shift()!, }); } - // Check if stream is done if (this.isDone) { return Promise.resolve({ done: true, value: undefined }); } - // Check for errors that occurred before next() was called - // This ensures errors set via error() before iteration starts are properly rejected if (this.hasError) { return Promise.reject(this.hasError); } - // No queued items, not done, no error - set up promise for next value/error return new Promise>((resolve, reject) => { this.readResolve = resolve; this.readReject = reject; @@ -70,15 +61,12 @@ export class Stream implements AsyncIterable { error(error: Error): void { this.hasError = error; - // If readReject exists (next() has been called), reject immediately if (this.readReject) { const reject = this.readReject; this.readResolve = undefined; this.readReject = undefined; reject(error); } - // Otherwise, error is stored in hasError and will be rejected when next() is called - // This handles the case where error() is called before the first next() call } return(): Promise> { diff --git a/packages/sdk-typescript/src/utils/cliPath.ts b/packages/sdk-typescript/src/utils/cliPath.ts index b6101ab30f..2d91941310 100644 --- a/packages/sdk-typescript/src/utils/cliPath.ts +++ b/packages/sdk-typescript/src/utils/cliPath.ts @@ -154,7 +154,6 @@ export function parseExecutableSpec(executableSpec?: string): { executablePath: string; isExplicitRuntime: boolean; } { - // Handle empty string case first (before checking for undefined/null) if ( executableSpec === '' || (executableSpec && executableSpec.trim() === '') @@ -163,7 +162,6 @@ export function parseExecutableSpec(executableSpec?: string): { } if (!executableSpec) { - // Auto-detect native CLI return { executablePath: findNativeCliPath(), isExplicitRuntime: false, @@ -178,7 +176,6 @@ export function parseExecutableSpec(executableSpec?: string): { throw new Error(`Invalid runtime specification: '${executableSpec}'`); } - // Validate runtime is supported const supportedRuntimes = ['node', 'bun', 'tsx', 'deno']; if (!supportedRuntimes.includes(runtime)) { throw new Error( @@ -186,7 +183,6 @@ export function parseExecutableSpec(executableSpec?: string): { ); } - // Validate runtime availability if (!validateRuntimeAvailability(runtime)) { throw new Error( `Runtime '${runtime}' is not available on this system. Please install it first.`, @@ -195,7 +191,6 @@ export function parseExecutableSpec(executableSpec?: string): { const resolvedPath = path.resolve(filePath); - // Validate file exists if (!fs.existsSync(resolvedPath)) { throw new Error( `Executable file not found at '${resolvedPath}' for runtime '${runtime}'. ` + @@ -203,7 +198,6 @@ export function parseExecutableSpec(executableSpec?: string): { ); } - // Validate file extension matches runtime if (!validateFileExtensionForRuntime(resolvedPath, runtime)) { const ext = path.extname(resolvedPath); throw new Error( @@ -285,14 +279,6 @@ function getExpectedExtensions(runtime: string): string[] { } } -/** - * @deprecated Use parseExecutableSpec and prepareSpawnInfo instead - */ -export function resolveCliPath(explicitPath?: string): string { - const parsed = parseExecutableSpec(explicitPath); - return parsed.executablePath; -} - function detectRuntimeFromExtension(filePath: string): string | undefined { const ext = path.extname(filePath).toLowerCase(); @@ -356,10 +342,3 @@ export function prepareSpawnInfo(executableSpec?: string): SpawnInfo { originalInput: executableSpec || '', }; } - -/** - * @deprecated Use prepareSpawnInfo() instead - */ -export function findCliPath(): string { - return findNativeCliPath(); -} diff --git a/packages/sdk-typescript/src/utils/jsonLines.ts b/packages/sdk-typescript/src/utils/jsonLines.ts index e534bf7000..6d1bd090d2 100644 --- a/packages/sdk-typescript/src/utils/jsonLines.ts +++ b/packages/sdk-typescript/src/utils/jsonLines.ts @@ -38,20 +38,16 @@ export async function* parseJsonLinesStream( context = 'JsonLines', ): AsyncGenerator { for await (const line of lines) { - // Skip empty lines if (line.trim().length === 0) { continue; } - // Parse with error handling const message = parseJsonLineSafe(line, context); - // Skip malformed messages if (message === null) { continue; } - // Validate message structure if (!isValidMessage(message)) { console.warn( `[${context}] Invalid message structure (missing 'type' field), skipping:`, diff --git a/packages/sdk-typescript/test/unit/ProcessTransport.test.ts b/packages/sdk-typescript/test/unit/ProcessTransport.test.ts index 5e1a9d15bd..0854a02d4d 100644 --- a/packages/sdk-typescript/test/unit/ProcessTransport.test.ts +++ b/packages/sdk-typescript/test/unit/ProcessTransport.test.ts @@ -3,205 +3,1379 @@ * Tests subprocess lifecycle management and IPC */ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { ProcessTransport } from '../../src/transport/ProcessTransport.js'; +import { AbortError } from '../../src/types/errors.js'; +import type { TransportOptions } from '../../src/types/types.js'; +import { Readable, Writable } from 'node:stream'; +import type { ChildProcess } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import * as childProcess from 'node:child_process'; +import * as cliPath from '../../src/utils/cliPath.js'; +import * as jsonLines from '../../src/utils/jsonLines.js'; -// Note: This is a placeholder test file -// ProcessTransport will be implemented in Phase 3 Implementation (T021) -// These tests are written first following TDD approach +// Mock modules +vi.mock('node:child_process'); +vi.mock('../../src/utils/cliPath.js'); +vi.mock('../../src/utils/jsonLines.js'); + +const mockSpawn = vi.mocked(childProcess.spawn); +const mockPrepareSpawnInfo = vi.mocked(cliPath.prepareSpawnInfo); +const mockParseJsonLinesStream = vi.mocked(jsonLines.parseJsonLinesStream); + +// Helper function to create a mock child process with optional overrides +function createMockChildProcess( + overrides: Partial = {}, +): ChildProcess & EventEmitter { + const mockStdin = new Writable({ + write: vi.fn((chunk, encoding, callback) => { + if (typeof callback === 'function') callback(); + return true; + }), + }); + const mockWriteFn = vi.fn((chunk, encoding, callback) => { + if (typeof callback === 'function') callback(); + return true; + }); + mockStdin.write = mockWriteFn as unknown as typeof mockStdin.write; + + const mockStdout = new Readable({ read: vi.fn() }); + const mockStderr = new Readable({ read: vi.fn() }); + + const baseProcess = Object.assign(new EventEmitter(), { + stdin: mockStdin, + stdout: mockStdout, + stderr: mockStderr, + pid: 12345, + killed: false, + exitCode: null, + signalCode: null, + kill: vi.fn(() => true), + send: vi.fn(), + disconnect: vi.fn(), + unref: vi.fn(), + ref: vi.fn(), + connected: false, + stdio: [mockStdin, mockStdout, mockStderr, null, null], + spawnargs: [], + spawnfile: 'qwen', + channel: null, + ...overrides, + }) as unknown as ChildProcess & EventEmitter; + + return baseProcess; +} describe('ProcessTransport', () => { + let mockChildProcess: ChildProcess & EventEmitter; + let mockStdin: Writable; + let mockStdout: Readable; + let mockStderr: Readable; + + beforeEach(() => { + vi.clearAllMocks(); + + const mockWriteFn = vi.fn((chunk, encoding, callback) => { + if (typeof callback === 'function') callback(); + return true; + }); + + mockStdin = new Writable({ + write: mockWriteFn, + }); + // Override write with a spy so we can track calls + mockStdin.write = mockWriteFn as unknown as typeof mockStdin.write; + + mockStdout = new Readable({ read: vi.fn() }); + mockStderr = new Readable({ read: vi.fn() }); + + mockChildProcess = Object.assign(new EventEmitter(), { + stdin: mockStdin, + stdout: mockStdout, + stderr: mockStderr, + pid: 12345, + killed: false, + exitCode: null, + signalCode: null, + kill: vi.fn(() => true), + send: vi.fn(), + disconnect: vi.fn(), + unref: vi.fn(), + ref: vi.fn(), + connected: false, + stdio: [mockStdin, mockStdout, mockStderr, null, null], + spawnargs: [], + spawnfile: 'qwen', + channel: null, + }) as unknown as ChildProcess & EventEmitter; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + describe('Construction and Initialization', () => { it('should create transport with required options', () => { - // Test will be implemented with actual ProcessTransport class - expect(true).toBe(true); // Placeholder + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport).toBeDefined(); + expect(transport.isReady).toBe(true); + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.arrayContaining([ + '--input-format', + 'stream-json', + '--output-format', + 'stream-json', + ]), + expect.objectContaining({ + stdio: ['pipe', 'pipe', 'ignore'], + }), + ); }); - it('should validate pathToQwenExecutable exists', () => { - // Should throw if pathToQwenExecutable does not exist - expect(true).toBe(true); // Placeholder + it('should build CLI arguments correctly with all options', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + model: 'qwen-max', + permissionMode: 'auto-edit', + maxSessionTurns: 10, + coreTools: ['read_file', 'write_file'], + excludeTools: ['web_search'], + authType: 'api-key', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.arrayContaining([ + '--input-format', + 'stream-json', + '--output-format', + 'stream-json', + '--model', + 'qwen-max', + '--approval-mode', + 'auto-edit', + '--max-session-turns', + '10', + '--core-tools', + 'read_file,write_file', + '--exclude-tools', + 'web_search', + '--auth-type', + 'api-key', + ]), + expect.any(Object), + ); }); - it('should build CLI arguments correctly', () => { - // Should include --input-format stream-json --output-format stream-json - expect(true).toBe(true); // Placeholder + it('should throw if aborted before initialization', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const abortController = new AbortController(); + abortController.abort(); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + expect(() => new ProcessTransport(options)).toThrow(AbortError); + expect(() => new ProcessTransport(options)).toThrow( + 'Transport start aborted', + ); + }); + + it('should use provided AbortController', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const abortController = new AbortController(); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + signal: abortController.signal, + }), + ); + }); + + it('should create default AbortController if not provided', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + signal: expect.any(AbortSignal), + }), + ); }); }); describe('Lifecycle Management', () => { - it('should spawn subprocess during construction', async () => { - // Should call child_process.spawn in constructor - expect(true).toBe(true); // Placeholder + it('should spawn subprocess during construction', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledTimes(1); }); - it('should set isReady to true after successful initialization', async () => { - // isReady should be true after construction completes - expect(true).toBe(true); // Placeholder + it('should set isReady to true after successful initialization', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport.isReady).toBe(true); }); - it('should throw if subprocess fails to spawn', async () => { - // Should throw Error if ENOENT or spawn fails - expect(true).toBe(true); // Placeholder + it('should set isReady to false on process error', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockChildProcess.emit('error', new Error('Spawn failed')); + + expect(transport.isReady).toBe(false); + expect(transport.exitError).toBeDefined(); }); it('should close subprocess gracefully with SIGTERM', async () => { - // Should send SIGTERM first - expect(true).toBe(true); // Placeholder + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await transport.close(); + + expect(mockChildProcess.kill).toHaveBeenCalledWith('SIGTERM'); }); it('should force kill with SIGKILL after timeout', async () => { - // Should send SIGKILL after 5s if process doesn\'t exit - expect(true).toBe(true); // Placeholder + vi.useFakeTimers(); + + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await transport.close(); + + vi.advanceTimersByTime(5000); + + expect(mockChildProcess.kill).toHaveBeenCalledWith('SIGKILL'); + + vi.useRealTimers(); }); it('should be idempotent when calling close() multiple times', async () => { - // Multiple close() calls should not error - expect(true).toBe(true); // Placeholder + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await transport.close(); + await transport.close(); + await transport.close(); + + expect(mockChildProcess.kill).toHaveBeenCalledTimes(3); }); it('should wait for process exit in waitForExit()', async () => { - // Should resolve when process exits - expect(true).toBe(true); // Placeholder + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const waitPromise = transport.waitForExit(); + + mockChildProcess.emit('close', 0, null); + + await expect(waitPromise).resolves.toBeUndefined(); + }); + + it('should reject waitForExit() on non-zero exit code', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const waitPromise = transport.waitForExit(); + + mockChildProcess.emit('close', 1, null); + + await expect(waitPromise).rejects.toThrow( + 'CLI process exited with code 1', + ); + }); + + it('should reject waitForExit() on signal termination', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const waitPromise = transport.waitForExit(); + + mockChildProcess.emit('close', null, 'SIGTERM'); + + await expect(waitPromise).rejects.toThrow( + 'CLI process terminated by signal SIGTERM', + ); + }); + + it('should reject waitForExit() with AbortError when aborted', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const abortController = new AbortController(); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + const transport = new ProcessTransport(options); + + const waitPromise = transport.waitForExit(); + + abortController.abort(); + mockChildProcess.emit('close', 0, null); + + await expect(waitPromise).rejects.toThrow(AbortError); }); }); describe('Message Reading', () => { it('should read JSON Lines from stdout', async () => { - // Should use readline to read lines and parse JSON - expect(true).toBe(true); // Placeholder + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const mockMessages = [ + { type: 'message', content: 'test1' }, + { type: 'message', content: 'test2' }, + ]; + + mockParseJsonLinesStream.mockImplementation(async function* () { + for (const msg of mockMessages) { + yield msg; + } + }); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const messages: unknown[] = []; + const readPromise = (async () => { + for await (const message of transport.readMessages()) { + messages.push(message); + } + })(); + + // Give time for the async generator to start and yield messages + await new Promise((resolve) => setTimeout(resolve, 10)); + + mockChildProcess.emit('close', 0, null); + + await readPromise; + + expect(messages).toEqual(mockMessages); + }, 5000); // Set a reasonable timeout + + it('should throw if reading from transport without stdout', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const processWithoutStdout = createMockChildProcess({ stdout: null }); + mockSpawn.mockReturnValue(processWithoutStdout); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const generator = transport.readMessages(); + + await expect(generator.next()).rejects.toThrow( + 'Cannot read messages: process not started', + ); }); + }); - it('should yield parsed messages via readMessages()', async () => { - // Should yield messages as async generator - expect(true).toBe(true); // Placeholder + describe('Message Writing', () => { + it('should write message to stdin', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const message = '{"type":"test","data":"hello"}\n'; + transport.write(message); + + expect(mockStdin.write).toHaveBeenCalledWith(message); }); - it('should skip malformed JSON lines with warning', async () => { - // Should log warning and continue on parse error - expect(true).toBe(true); // Placeholder + it('should throw if writing before transport is ready', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockChildProcess.emit('error', new Error('Process error')); + + expect(() => transport.write('test')).toThrow( + 'Transport not ready for writing', + ); }); - it('should complete generator when process exits', async () => { - // readMessages() should complete when stdout closes - expect(true).toBe(true); // Placeholder + it('should throw if writing to closed transport', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await transport.close(); + + // After close(), isReady is false, so we get "Transport not ready" error first + expect(() => transport.write('test')).toThrow( + 'Transport not ready for writing', + ); }); - it('should set exitError on unexpected process crash', async () => { - // exitError should be set if process crashes - expect(true).toBe(true); // Placeholder + it('should throw if writing when aborted', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const abortController = new AbortController(); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + const transport = new ProcessTransport(options); + + abortController.abort(); + + expect(() => transport.write('test')).toThrow(AbortError); + expect(() => transport.write('test')).toThrow( + 'Cannot write: operation aborted', + ); }); - }); - describe('Message Writing', () => { - it('should write JSON Lines to stdin', () => { - // Should write JSON + newline to stdin - expect(true).toBe(true); // Placeholder + it('should throw if writing to ended stream', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockStdin.end(); + + expect(() => transport.write('test')).toThrow( + 'Cannot write to ended stream', + ); }); - it('should throw if writing before transport is ready', () => { - // write() should throw if isReady is false - expect(true).toBe(true); // Placeholder + it('should throw if writing to terminated process', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const terminatedProcess = createMockChildProcess({ exitCode: 1 }); + mockSpawn.mockReturnValue(terminatedProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(() => transport.write('test')).toThrow( + 'Cannot write to terminated process', + ); }); - it('should throw if writing to closed transport', () => { - // write() should throw if transport is closed - expect(true).toBe(true); // Placeholder + it('should throw if process has exit error', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockChildProcess.emit('close', 1, null); + + // After process closes with error, isReady is false, so we get "Transport not ready" error first + expect(() => transport.write('test')).toThrow( + 'Transport not ready for writing', + ); }); }); describe('Error Handling', () => { - it('should handle process spawn errors', async () => { - // Should throw descriptive error on spawn failure - expect(true).toBe(true); // Placeholder + it('should set exitError on process error', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const error = new Error('Process error'); + mockChildProcess.emit('error', error); + + expect(transport.exitError).toBeDefined(); + expect(transport.exitError?.message).toContain('CLI process error'); + }); + + it('should set exitError on process close with non-zero code', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockChildProcess.emit('close', 1, null); + + expect(transport.exitError).toBeDefined(); + expect(transport.exitError?.message).toBe( + 'CLI process exited with code 1', + ); }); - it('should handle process exit with non-zero code', async () => { - // Should set exitError when process exits with error - expect(true).toBe(true); // Placeholder + it('should set exitError on process close with signal', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockChildProcess.emit('close', null, 'SIGKILL'); + + expect(transport.exitError).toBeDefined(); + expect(transport.exitError?.message).toBe( + 'CLI process terminated by signal SIGKILL', + ); }); - it('should handle write errors to closed stdin', () => { - // Should throw if stdin is closed - expect(true).toBe(true); // Placeholder + it('should set AbortError when process aborted', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const abortController = new AbortController(); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + const transport = new ProcessTransport(options); + + abortController.abort(); + mockChildProcess.emit('error', new Error('Aborted')); + + expect(transport.exitError).toBeInstanceOf(AbortError); + expect(transport.exitError?.message).toBe('CLI process aborted by user'); + }); + + it('should not set exitError on clean exit', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockChildProcess.emit('close', 0, null); + + expect(transport.exitError).toBeNull(); }); }); describe('Resource Cleanup', () => { it('should register cleanup on parent process exit', () => { - // Should register process.on(\'exit\') handler - expect(true).toBe(true); // Placeholder - }); + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const processOnSpy = vi.spyOn(process, 'on'); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + new ProcessTransport(options); - it('should kill subprocess on parent exit', () => { - // Cleanup should kill child process - expect(true).toBe(true); // Placeholder + expect(processOnSpy).toHaveBeenCalledWith('exit', expect.any(Function)); + + processOnSpy.mockRestore(); }); it('should remove event listeners on close', async () => { - // Should clean up all event listeners - expect(true).toBe(true); // Placeholder + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const processOffSpy = vi.spyOn(process, 'off'); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await transport.close(); + + expect(processOffSpy).toHaveBeenCalledWith('exit', expect.any(Function)); + + processOffSpy.mockRestore(); + }); + + it('should register abort listener', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const abortController = new AbortController(); + const addEventListenerSpy = vi.spyOn( + abortController.signal, + 'addEventListener', + ); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + new ProcessTransport(options); + + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'abort', + expect.any(Function), + ); + + addEventListenerSpy.mockRestore(); + }); + + it('should remove abort listener on close', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const abortController = new AbortController(); + const removeEventListenerSpy = vi.spyOn( + abortController.signal, + 'removeEventListener', + ); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + const transport = new ProcessTransport(options); + + await transport.close(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'abort', + expect.any(Function), + ); + + removeEventListenerSpy.mockRestore(); + }); + + it('should end stdin on close', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const endSpy = vi.spyOn(mockStdin, 'end'); + + await transport.close(); + + expect(endSpy).toHaveBeenCalled(); }); }); - describe('CLI Arguments', () => { - it('should include --input-format stream-json', () => { - // Args should always include input format flag - expect(true).toBe(true); // Placeholder + describe('Working Directory', () => { + it('should spawn process in specified cwd', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + cwd: '/custom/path', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + cwd: '/custom/path', + }), + ); }); - it('should include --output-format stream-json', () => { - // Args should always include output format flag - expect(true).toBe(true); // Placeholder + it('should default to process.cwd() if not specified', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + cwd: process.cwd(), + }), + ); }); + }); + + describe('Environment Variables', () => { + it('should pass environment variables to subprocess', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + env: { + CUSTOM_VAR: 'custom_value', + }, + }; - it('should include --model if provided', () => { - // Args should include model flag if specified - expect(true).toBe(true); // Placeholder + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining({ + CUSTOM_VAR: 'custom_value', + }), + }), + ); }); - it('should include --permission-mode if provided', () => { - // Args should include permission mode flag if specified - expect(true).toBe(true); // Placeholder + it('should inherit parent env by default', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining(process.env), + }), + ); }); - it('should include --mcp-server for external MCP servers', () => { - // Args should include MCP server configs - expect(true).toBe(true); // Placeholder + it('should merge custom env with parent env', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + env: { + CUSTOM_VAR: 'custom_value', + }, + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining({ + ...process.env, + CUSTOM_VAR: 'custom_value', + }), + }), + ); }); }); - describe('Working Directory', () => { - it('should spawn process in specified cwd', async () => { - // Should use cwd option for child_process.spawn - expect(true).toBe(true); // Placeholder + describe('Debug and Stderr Handling', () => { + it('should pipe stderr when debug is true', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + debug: true, + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + stdio: ['pipe', 'pipe', 'pipe'], + }), + ); }); - it('should default to process.cwd() if not specified', async () => { - // Should use current working directory by default - expect(true).toBe(true); // Placeholder + it('should pipe stderr when stderr callback is provided', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const stderrCallback = vi.fn(); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + stderr: stderrCallback, + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + stdio: ['pipe', 'pipe', 'pipe'], + }), + ); + }); + + it('should ignore stderr when debug is false and no callback', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + debug: false, + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + stdio: ['pipe', 'pipe', 'ignore'], + }), + ); + }); + + it('should call stderr callback when data is received', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const stderrCallback = vi.fn(); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + stderr: stderrCallback, + }; + + new ProcessTransport(options); + + mockStderr.emit('data', Buffer.from('error message')); + + expect(stderrCallback).toHaveBeenCalledWith('error message'); }); }); - describe('Environment Variables', () => { - it('should pass environment variables to subprocess', async () => { - // Should merge env with process.env - expect(true).toBe(true); // Placeholder + describe('Stream Access', () => { + it('should provide access to stdin via getInputStream()', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport.getInputStream()).toBe(mockStdin); }); - it('should inherit parent env by default', async () => { - // Should use process.env if no env option - expect(true).toBe(true); // Placeholder + it('should provide access to stdout via getOutputStream()', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport.getOutputStream()).toBe(mockStdout); + }); + + it('should allow ending input via endInput()', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const endSpy = vi.spyOn(mockStdin, 'end'); + + transport.endInput(); + + expect(endSpy).toHaveBeenCalled(); }); }); - describe('Debug Mode', () => { - it('should inherit stderr when debug is true', async () => { - // Should set stderr: \'inherit\' if debug flag set - expect(true).toBe(true); // Placeholder + describe('Edge Cases', () => { + it('should handle process that exits immediately', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const immediateExitProcess = createMockChildProcess({ exitCode: 0 }); + mockSpawn.mockReturnValue(immediateExitProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport.isReady).toBe(true); + }); + + it('should handle waitForExit() when process already exited', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const exitedProcess = createMockChildProcess({ exitCode: 0 }); + mockSpawn.mockReturnValue(exitedProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await expect(transport.waitForExit()).resolves.toBeUndefined(); + }); + + it('should handle close() when process is already killed', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const killedProcess = createMockChildProcess({ killed: true }); + mockSpawn.mockReturnValue(killedProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await expect(transport.close()).resolves.toBeUndefined(); }); - it('should ignore stderr when debug is false', async () => { - // Should set stderr: \'ignore\' if debug flag not set - expect(true).toBe(true); // Placeholder + it('should handle endInput() when stdin is null', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const processWithoutStdin = createMockChildProcess({ stdin: null }); + mockSpawn.mockReturnValue(processWithoutStdin); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(() => transport.endInput()).not.toThrow(); + }); + + it('should return undefined for getInputStream() when stdin is null', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const processWithoutStdin = createMockChildProcess({ stdin: null }); + mockSpawn.mockReturnValue(processWithoutStdin); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport.getInputStream()).toBeUndefined(); + }); + + it('should return undefined for getOutputStream() when stdout is null', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const processWithoutStdout = createMockChildProcess({ stdout: null }); + mockSpawn.mockReturnValue(processWithoutStdout); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport.getOutputStream()).toBeUndefined(); }); }); }); diff --git a/packages/sdk-typescript/test/unit/Query.test.ts b/packages/sdk-typescript/test/unit/Query.test.ts index 5ceeee4bb4..9b8e34c26f 100644 --- a/packages/sdk-typescript/test/unit/Query.test.ts +++ b/packages/sdk-typescript/test/unit/Query.test.ts @@ -3,282 +3,1467 @@ * Tests message routing, lifecycle, and orchestration */ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { Query } from '../../src/query/Query.js'; +import type { Transport } from '../../src/transport/Transport.js'; +import type { + CLIMessage, + CLIUserMessage, + CLIAssistantMessage, + CLISystemMessage, + CLIResultMessage, + CLIPartialAssistantMessage, + CLIControlRequest, + CLIControlResponse, + ControlCancelRequest, +} from '../../src/types/protocol.js'; +import { ControlRequestType } from '../../src/types/protocol.js'; +import { AbortError } from '../../src/types/errors.js'; +import { Stream } from '../../src/utils/Stream.js'; -// Note: This is a placeholder test file -// Query will be implemented in Phase 3 Implementation (T022) -// These tests are written first following TDD approach +// Mock Transport implementation +class MockTransport implements Transport { + private messageStream = new Stream(); + public writtenMessages: string[] = []; + public closed = false; + public endInputCalled = false; + public isReady = true; + public exitError: Error | null = null; + + write(data: string): void { + this.writtenMessages.push(data); + } + + async *readMessages(): AsyncGenerator { + for await (const message of this.messageStream) { + yield message; + } + } + + async close(): Promise { + this.closed = true; + this.messageStream.done(); + } + + async waitForExit(): Promise { + // Mock implementation - do nothing + } + + endInput(): void { + this.endInputCalled = true; + } + + // Test helper methods + simulateMessage(message: unknown): void { + this.messageStream.enqueue(message); + } + + simulateError(error: Error): void { + this.messageStream.error(error); + } + + simulateClose(): void { + this.messageStream.done(); + } + + getLastWrittenMessage(): unknown { + if (this.writtenMessages.length === 0) return null; + return JSON.parse(this.writtenMessages[this.writtenMessages.length - 1]); + } + + getAllWrittenMessages(): unknown[] { + return this.writtenMessages.map((msg) => JSON.parse(msg)); + } +} + +// Helper function to find control response by request_id +function findControlResponse( + messages: unknown[], + requestId: string, +): CLIControlResponse | undefined { + return messages.find( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'control_response' && + 'response' in msg && + typeof msg.response === 'object' && + msg.response !== null && + 'request_id' in msg.response && + msg.response.request_id === requestId, + ) as CLIControlResponse | undefined; +} + +// Helper function to find control request by subtype +function findControlRequest( + messages: unknown[], + subtype: string, +): CLIControlRequest | undefined { + return messages.find( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'control_request' && + 'request' in msg && + typeof msg.request === 'object' && + msg.request !== null && + 'subtype' in msg.request && + msg.request.subtype === subtype, + ) as CLIControlRequest | undefined; +} + +// Helper function to create test messages +function createUserMessage( + content: string, + sessionId = 'test-session', +): CLIUserMessage { + return { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content, + }, + parent_tool_use_id: null, + }; +} + +function createAssistantMessage( + content: string, + sessionId = 'test-session', +): CLIAssistantMessage { + return { + type: 'assistant', + uuid: 'msg-123', + session_id: sessionId, + message: { + id: 'msg-123', + type: 'message', + role: 'assistant', + model: 'test-model', + content: [{ type: 'text', text: content }], + usage: { input_tokens: 10, output_tokens: 20 }, + }, + parent_tool_use_id: null, + }; +} + +function createSystemMessage( + subtype: string, + sessionId = 'test-session', +): CLISystemMessage { + return { + type: 'system', + subtype, + uuid: 'sys-123', + session_id: sessionId, + cwd: '/test/path', + tools: ['read_file', 'write_file'], + model: 'test-model', + }; +} + +function createResultMessage( + success: boolean, + sessionId = 'test-session', +): CLIResultMessage { + if (success) { + return { + type: 'result', + subtype: 'success', + uuid: 'result-123', + session_id: sessionId, + is_error: false, + duration_ms: 1000, + duration_api_ms: 800, + num_turns: 1, + result: 'Success', + usage: { input_tokens: 10, output_tokens: 20 }, + permission_denials: [], + }; + } else { + return { + type: 'result', + subtype: 'error_during_execution', + uuid: 'result-123', + session_id: sessionId, + is_error: true, + duration_ms: 1000, + duration_api_ms: 800, + num_turns: 1, + usage: { input_tokens: 10, output_tokens: 20 }, + permission_denials: [], + error: { message: 'Test error' }, + }; + } +} + +function createPartialMessage( + sessionId = 'test-session', +): CLIPartialAssistantMessage { + return { + type: 'stream_event', + uuid: 'stream-123', + session_id: sessionId, + event: { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'Hello' }, + }, + parent_tool_use_id: null, + }; +} + +function createControlRequest( + subtype: string, + requestId = 'req-123', +): CLIControlRequest { + return { + type: 'control_request', + request_id: requestId, + request: { + subtype, + tool_name: 'test_tool', + input: { arg: 'value' }, + permission_suggestions: null, + blocked_path: null, + } as CLIControlRequest['request'], + }; +} + +function createControlResponse( + requestId: string, + success: boolean, + data?: unknown, +): CLIControlResponse { + return { + type: 'control_response', + response: success + ? { + subtype: 'success', + request_id: requestId, + response: data ?? null, + } + : { + subtype: 'error', + request_id: requestId, + error: 'Test error', + }, + }; +} + +function createControlCancel(requestId: string): ControlCancelRequest { + return { + type: 'control_cancel_request', + request_id: requestId, + }; +} describe('Query', () => { + let transport: MockTransport; + + beforeEach(() => { + transport = new MockTransport(); + vi.clearAllMocks(); + }); + + afterEach(async () => { + if (!transport.closed) { + await transport.close(); + } + }); + describe('Construction and Initialization', () => { - it('should create Query with transport and options', () => { - // Should accept Transport and CreateQueryOptions - expect(true).toBe(true); // Placeholder + it('should create Query with transport and options', async () => { + const query = new Query(transport, { + cwd: '/test', + }); + + expect(query).toBeDefined(); + expect(query.getSessionId()).toBeTruthy(); + expect(query.isClosed()).toBe(false); + + // Should send initialize control request + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + expect(initRequest.type).toBe('control_request'); + expect(initRequest.request.subtype).toBe('initialize'); + + await query.close(); }); - it('should generate unique session ID', () => { - // Each Query should have unique session_id - expect(true).toBe(true); // Placeholder + it('should generate unique session ID', async () => { + const transport2 = new MockTransport(); + const query1 = new Query(transport, { cwd: '/test' }); + const query2 = new Query(transport2, { + cwd: '/test', + }); + + expect(query1.getSessionId()).not.toBe(query2.getSessionId()); + + await query1.close(); + await query2.close(); + await transport2.close(); }); - it('should validate MCP server name conflicts', () => { - // Should throw if mcpServers and sdkMcpServers have same keys - expect(true).toBe(true); // Placeholder + it('should validate MCP server name conflicts', async () => { + const mockServer = { + connect: vi.fn(), + }; + + await expect(async () => { + const query = new Query(transport, { + cwd: '/test', + mcpServers: { server1: { command: 'test' } }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sdkMcpServers: { server1: mockServer as any }, + }); + await query.initialized; + }).rejects.toThrow(/name conflicts/); }); - it('should lazy initialize on first message consumption', async () => { - // Should not call initialize() until messages are read - expect(true).toBe(true); // Placeholder + it('should initialize with SDK MCP servers', async () => { + const mockServer = { + connect: vi.fn().mockResolvedValue(undefined), + }; + + const query = new Query(transport, { + cwd: '/test', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sdkMcpServers: { testServer: mockServer as any }, + }); + + // Respond to initialize request + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + expect(mockServer.connect).toHaveBeenCalled(); + + await query.close(); + }); + + it('should handle initialization errors', async () => { + const query = new Query(transport, { + cwd: '/test', + }); + + // Simulate initialization failure + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, false), + ); + + await expect(query.initialized).rejects.toThrow(); + + await query.close(); }); }); describe('Message Routing', () => { - it('should route user messages to CLI', async () => { - // Initial prompt should be sent as user message - expect(true).toBe(true); // Placeholder + it('should route user messages to output stream', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const userMsg = createUserMessage('Hello'); + transport.simulateMessage(userMsg); + + const result = await query.next(); + expect(result.done).toBe(false); + expect(result.value).toEqual(userMsg); + + await query.close(); }); it('should route assistant messages to output stream', async () => { - // Assistant messages from CLI should be yielded to user - expect(true).toBe(true); // Placeholder - }); + const query = new Query(transport, { cwd: '/test' }); + + const assistantMsg = createAssistantMessage('Response'); + transport.simulateMessage(assistantMsg); + + const result = await query.next(); + expect(result.done).toBe(false); + expect(result.value).toEqual(assistantMsg); - it('should route tool_use messages to output stream', async () => { - // Tool use messages should be yielded to user - expect(true).toBe(true); // Placeholder + await query.close(); }); - it('should route tool_result messages to output stream', async () => { - // Tool result messages should be yielded to user - expect(true).toBe(true); // Placeholder + it('should route system messages to output stream', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const systemMsg = createSystemMessage('session_start'); + transport.simulateMessage(systemMsg); + + const result = await query.next(); + expect(result.done).toBe(false); + expect(result.value).toEqual(systemMsg); + + await query.close(); }); it('should route result messages to output stream', async () => { - // Result messages should be yielded to user - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + const resultMsg = createResultMessage(true); + transport.simulateMessage(resultMsg); + + const result = await query.next(); + expect(result.done).toBe(false); + expect(result.value).toEqual(resultMsg); + + await query.close(); + }); + + it('should route partial assistant messages to output stream', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const partialMsg = createPartialMessage(); + transport.simulateMessage(partialMsg); + + const result = await query.next(); + expect(result.done).toBe(false); + expect(result.value).toEqual(partialMsg); + + await query.close(); + }); + + it('should handle unknown message types', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const unknownMsg = { type: 'unknown', data: 'test' }; + transport.simulateMessage(unknownMsg); + + const result = await query.next(); + expect(result.done).toBe(false); + expect(result.value).toEqual(unknownMsg); + + await query.close(); }); - it('should filter keep_alive messages from output', async () => { - // Keep alive messages should not be yielded to user - expect(true).toBe(true); // Placeholder + it('should yield messages in order', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const msg1 = createUserMessage('First'); + const msg2 = createAssistantMessage('Second'); + const msg3 = createResultMessage(true); + + transport.simulateMessage(msg1); + transport.simulateMessage(msg2); + transport.simulateMessage(msg3); + + const result1 = await query.next(); + expect(result1.value).toEqual(msg1); + + const result2 = await query.next(); + expect(result2.value).toEqual(msg2); + + const result3 = await query.next(); + expect(result3.value).toEqual(msg3); + + await query.close(); }); }); describe('Control Plane - Permission Control', () => { it('should handle can_use_tool control requests', async () => { - // Should invoke canUseTool callback - expect(true).toBe(true); // Placeholder + const canUseTool = vi.fn().mockResolvedValue(true); + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + expect(canUseTool).toHaveBeenCalledWith( + 'test_tool', + { arg: 'value' }, + expect.objectContaining({ + signal: expect.any(AbortSignal), + suggestions: null, + }), + ); + }); + + await query.close(); }); - it('should send control response with permission result', async () => { - // Should send response with allowed: true/false - expect(true).toBe(true); // Placeholder + it('should send control response with permission result - allow', async () => { + const canUseTool = vi.fn().mockResolvedValue(true); + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-1'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-1'); + + expect(response).toBeDefined(); + expect(response?.response.subtype).toBe('success'); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'allow', + }); + } + }); + + await query.close(); }); - it('should default to allowing tools if no callback', async () => { - // If canUseTool not provided, should allow all - expect(true).toBe(true); // Placeholder + it('should send control response with permission result - deny', async () => { + const canUseTool = vi.fn().mockResolvedValue(false); + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-2'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-2'); + + expect(response).toBeDefined(); + expect(response?.response.subtype).toBe('success'); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'deny', + }); + } + }); + + await query.close(); + }); + + it('should default to denying tools if no callback', async () => { + const query = new Query(transport, { + cwd: '/test', + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-3'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-3'); + + expect(response).toBeDefined(); + expect(response?.response.subtype).toBe('success'); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'deny', + }); + } + }); + + await query.close(); }); it('should handle permission callback timeout', async () => { - // Should deny permission if callback exceeds 30s - expect(true).toBe(true); // Placeholder + const canUseTool = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => resolve(true), 35000); // Exceeds 30s timeout + }), + ); + + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-4'); + transport.simulateMessage(controlReq); + + await vi.waitFor( + () => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-4'); + + expect(response).toBeDefined(); + expect(response?.response.subtype).toBe('success'); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'deny', + }); + } + }, + { timeout: 35000 }, + ); + + await query.close(); }); it('should handle permission callback errors', async () => { - // Should deny permission if callback throws - expect(true).toBe(true); // Placeholder - }); - }); + const canUseTool = vi.fn().mockRejectedValue(new Error('Callback error')); + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); - describe('Control Plane - MCP Messages', () => { - it('should route MCP messages to SDK-embedded servers', async () => { - // Should find SdkControlServerTransport by server name - expect(true).toBe(true); // Placeholder - }); + const controlReq = createControlRequest('can_use_tool', 'perm-req-5'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-5'); - it('should handle MCP message responses', async () => { - // Should send response back to CLI - expect(true).toBe(true); // Placeholder + expect(response).toBeDefined(); + expect(response?.response.subtype).toBe('success'); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'deny', + }); + } + }); + + await query.close(); }); - it('should handle MCP message timeout', async () => { - // Should return error if MCP server doesn\'t respond in 30s - expect(true).toBe(true); // Placeholder + it('should handle PermissionResult format with updatedInput', async () => { + const canUseTool = vi.fn().mockResolvedValue({ + behavior: 'allow', + updatedInput: { arg: 'modified' }, + }); + + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-6'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-6'); + + expect(response).toBeDefined(); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'allow', + updatedInput: { arg: 'modified' }, + }); + } + }); + + await query.close(); }); - it('should handle unknown MCP server names', async () => { - // Should return error if server name not found - expect(true).toBe(true); // Placeholder + it('should handle permission denial with interrupt flag', async () => { + const canUseTool = vi.fn().mockResolvedValue({ + behavior: 'deny', + message: 'Denied by user', + interrupt: true, + }); + + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-7'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-7'); + + expect(response).toBeDefined(); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'deny', + message: 'Denied by user', + interrupt: true, + }); + } + }); + + await query.close(); }); }); - describe('Control Plane - Other Requests', () => { - it('should handle initialize control request', async () => { - // Should register SDK MCP servers with CLI - expect(true).toBe(true); // Placeholder - }); + describe('Control Plane - Control Cancel', () => { + it('should handle control cancel requests', async () => { + const canUseTool = vi.fn().mockImplementation( + ({ signal }: { signal: AbortSignal }) => + new Promise((resolve, reject) => { + signal.addEventListener('abort', () => reject(new AbortError())); + setTimeout(() => resolve(true), 5000); + }), + ); - it('should handle interrupt control request', async () => { - // Should send interrupt message to CLI - expect(true).toBe(true); // Placeholder - }); + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); - it('should handle set_permission_mode control request', async () => { - // Should send permission mode update to CLI - expect(true).toBe(true); // Placeholder - }); + const controlReq = createControlRequest('can_use_tool', 'cancel-req-1'); + transport.simulateMessage(controlReq); + + // Wait a bit then send cancel + await new Promise((resolve) => setTimeout(resolve, 100)); + transport.simulateMessage(createControlCancel('cancel-req-1')); + + await vi.waitFor(() => { + expect(canUseTool).toHaveBeenCalled(); + }); - it('should handle supported_commands control request', async () => { - // Should query CLI capabilities - expect(true).toBe(true); // Placeholder + await query.close(); }); - it('should handle mcp_server_status control request', async () => { - // Should check MCP server health - expect(true).toBe(true); // Placeholder + it('should ignore cancel for unknown request_id', async () => { + const query = new Query(transport, { + cwd: '/test', + }); + + // Send cancel for non-existent request + transport.simulateMessage(createControlCancel('unknown-req')); + + // Should not throw or cause issues + await new Promise((resolve) => setTimeout(resolve, 100)); + + await query.close(); }); }); describe('Multi-Turn Conversation', () => { it('should support streamInput() for follow-up messages', async () => { - // Should accept async iterable of messages - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + async function* messageGenerator() { + yield createUserMessage('Follow-up 1'); + yield createUserMessage('Follow-up 2'); + } + + await query.streamInput(messageGenerator()); + + const messages = transport.getAllWrittenMessages(); + const userMessages = messages.filter( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'user', + ); + expect(userMessages.length).toBeGreaterThanOrEqual(2); + + await query.close(); }); it('should maintain session context across turns', async () => { - // All messages should have same session_id - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + const sessionId = query.getSessionId(); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + async function* messageGenerator() { + yield createUserMessage('Turn 1', sessionId); + yield createUserMessage('Turn 2', sessionId); + } + + await query.streamInput(messageGenerator()); + + const messages = transport.getAllWrittenMessages(); + const userMessages = messages.filter( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'user', + ) as CLIUserMessage[]; + + userMessages.forEach((msg) => { + expect(msg.session_id).toBe(sessionId); + }); + + await query.close(); }); it('should throw if streamInput() called on closed query', async () => { - // Should throw Error if query is closed - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + await query.close(); + + async function* messageGenerator() { + yield createUserMessage('Test'); + } + + await expect(query.streamInput(messageGenerator())).rejects.toThrow( + 'Query is closed', + ); + }); + + it('should handle abort during streamInput', async () => { + const abortController = new AbortController(); + const query = new Query(transport, { + cwd: '/test', + abortController, + }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + async function* messageGenerator() { + yield createUserMessage('Message 1'); + abortController.abort(); + yield createUserMessage('Message 2'); // Should not be sent + } + + await query.streamInput(messageGenerator()); + + await query.close(); }); }); describe('Lifecycle Management', () => { it('should close transport on close()', async () => { - // Should call transport.close() - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + await query.close(); + + expect(transport.closed).toBe(true); }); it('should mark query as closed', async () => { - // closed flag should be true after close() - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + expect(query.isClosed()).toBe(false); + + await query.close(); + expect(query.isClosed()).toBe(true); }); it('should complete output stream on close()', async () => { - // inputStream should be marked done - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + const iterationPromise = (async () => { + const messages: CLIMessage[] = []; + for await (const msg of query) { + messages.push(msg); + } + return messages; + })(); + + await query.close(); + transport.simulateClose(); + + const messages = await iterationPromise; + expect(Array.isArray(messages)).toBe(true); }); it('should be idempotent when closing multiple times', async () => { - // Multiple close() calls should not error - expect(true).toBe(true); // Placeholder - }); + const query = new Query(transport, { cwd: '/test' }); - it('should cleanup MCP transports on close()', async () => { - // Should close all SdkControlServerTransport instances - expect(true).toBe(true); // Placeholder + await query.close(); + await query.close(); + await query.close(); + + expect(query.isClosed()).toBe(true); }); it('should handle abort signal cancellation', async () => { - // Should abort on AbortSignal - expect(true).toBe(true); // Placeholder + const abortController = new AbortController(); + const query = new Query(transport, { + cwd: '/test', + abortController, + }); + + abortController.abort(); + + await vi.waitFor(() => { + expect(query.isClosed()).toBe(true); + }); + }); + + it('should handle pre-aborted signal', async () => { + const abortController = new AbortController(); + abortController.abort(); + + const query = new Query(transport, { + cwd: '/test', + abortController, + }); + + await vi.waitFor(() => { + expect(query.isClosed()).toBe(true); + }); }); }); describe('Async Iteration', () => { it('should support for await loop', async () => { - // Should implement AsyncIterator protocol - expect(true).toBe(true); // Placeholder - }); + const query = new Query(transport, { cwd: '/test' }); - it('should yield messages in order', async () => { - // Messages should be yielded in received order - expect(true).toBe(true); // Placeholder + const messages: CLIMessage[] = []; + const iterationPromise = (async () => { + for await (const msg of query) { + messages.push(msg); + if (messages.length >= 2) break; + } + })(); + + transport.simulateMessage(createUserMessage('First')); + transport.simulateMessage(createAssistantMessage('Second')); + + await iterationPromise; + + expect(messages).toHaveLength(2); + expect((messages[0] as CLIUserMessage).message.content).toBe('First'); + + await query.close(); }); it('should complete iteration when query closes', async () => { - // for await loop should exit when query closes - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + const messages: CLIMessage[] = []; + const iterationPromise = (async () => { + for await (const msg of query) { + messages.push(msg); + } + })(); + + transport.simulateMessage(createUserMessage('Test')); + + // Give time for message to be processed + await new Promise((resolve) => setTimeout(resolve, 10)); + + await query.close(); + transport.simulateClose(); + + await iterationPromise; + expect(messages.length).toBeGreaterThanOrEqual(1); }); it('should propagate transport errors', async () => { - // Should throw if transport encounters error - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + const iterationPromise = (async () => { + for await (const msg of query) { + void msg; + } + })(); + + transport.simulateError(new Error('Transport error')); + + await expect(iterationPromise).rejects.toThrow('Transport error'); + + await query.close(); }); }); describe('Public API Methods', () => { it('should provide interrupt() method', async () => { - // Should send interrupt control request - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + const interruptPromise = query.interrupt(); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const interruptMsg = findControlRequest( + messages, + ControlRequestType.INTERRUPT, + ); + expect(interruptMsg).toBeDefined(); + }); + + // Respond to interrupt + const messages = transport.getAllWrittenMessages(); + const interruptMsg = findControlRequest( + messages, + ControlRequestType.INTERRUPT, + )!; + transport.simulateMessage( + createControlResponse(interruptMsg.request_id, true, {}), + ); + + await interruptPromise; + await query.close(); }); it('should provide setPermissionMode() method', async () => { - // Should send set_permission_mode control request - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + const setModePromise = query.setPermissionMode('yolo'); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const setModeMsg = findControlRequest( + messages, + ControlRequestType.SET_PERMISSION_MODE, + ); + expect(setModeMsg).toBeDefined(); + }); + + // Respond to set permission mode + const messages = transport.getAllWrittenMessages(); + const setModeMsg = findControlRequest( + messages, + ControlRequestType.SET_PERMISSION_MODE, + )!; + transport.simulateMessage( + createControlResponse(setModeMsg.request_id, true, {}), + ); + + await setModePromise; + await query.close(); + }); + + it('should provide setModel() method', async () => { + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + const setModelPromise = query.setModel('new-model'); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const setModelMsg = findControlRequest( + messages, + ControlRequestType.SET_MODEL, + ); + expect(setModelMsg).toBeDefined(); + }); + + // Respond to set model + const messages = transport.getAllWrittenMessages(); + const setModelMsg = findControlRequest( + messages, + ControlRequestType.SET_MODEL, + )!; + transport.simulateMessage( + createControlResponse(setModelMsg.request_id, true, {}), + ); + + await setModelPromise; + await query.close(); }); it('should provide supportedCommands() method', async () => { - // Should query CLI capabilities - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + const commandsPromise = query.supportedCommands(); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const commandsMsg = findControlRequest( + messages, + ControlRequestType.SUPPORTED_COMMANDS, + ); + expect(commandsMsg).toBeDefined(); + }); + + // Respond with commands + const messages = transport.getAllWrittenMessages(); + const commandsMsg = findControlRequest( + messages, + ControlRequestType.SUPPORTED_COMMANDS, + )!; + transport.simulateMessage( + createControlResponse(commandsMsg.request_id, true, { + commands: ['interrupt', 'set_model'], + }), + ); + + const result = await commandsPromise; + expect(result).toMatchObject({ commands: ['interrupt', 'set_model'] }); + + await query.close(); }); it('should provide mcpServerStatus() method', async () => { - // Should check MCP server health - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + const statusPromise = query.mcpServerStatus(); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const statusMsg = findControlRequest( + messages, + ControlRequestType.MCP_SERVER_STATUS, + ); + expect(statusMsg).toBeDefined(); + }); + + // Respond with status + const messages = transport.getAllWrittenMessages(); + const statusMsg = findControlRequest( + messages, + ControlRequestType.MCP_SERVER_STATUS, + )!; + transport.simulateMessage( + createControlResponse(statusMsg.request_id, true, { + servers: [{ name: 'test', status: 'connected' }], + }), + ); + + const result = await statusPromise; + expect(result).toMatchObject({ + servers: [{ name: 'test', status: 'connected' }], + }); + + await query.close(); }); it('should throw if methods called on closed query', async () => { - // Public methods should throw if query is closed - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + await query.close(); + + await expect(query.interrupt()).rejects.toThrow('Query is closed'); + await expect(query.setPermissionMode('yolo')).rejects.toThrow( + 'Query is closed', + ); + await expect(query.setModel('model')).rejects.toThrow('Query is closed'); + await expect(query.supportedCommands()).rejects.toThrow( + 'Query is closed', + ); + await expect(query.mcpServerStatus()).rejects.toThrow('Query is closed'); }); }); describe('Error Handling', () => { it('should propagate transport errors to stream', async () => { - // Transport errors should be surfaced in for await loop - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + const error = new Error('Transport failure'); + transport.simulateError(error); + + await expect(query.next()).rejects.toThrow('Transport failure'); + + await query.close(); }); it('should handle control request timeout', async () => { - // Should return error if control request doesn\'t respond - expect(true).toBe(true); // Placeholder - }); + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + // Call interrupt but don't respond - should timeout + const interruptPromise = query.interrupt(); + + await expect(interruptPromise).rejects.toThrow(/timeout/i); + + await query.close(); + }, 35000); it('should handle malformed control responses', async () => { - // Should handle invalid response structures - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + const interruptPromise = query.interrupt(); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const interruptMsg = findControlRequest( + messages, + ControlRequestType.INTERRUPT, + ); + expect(interruptMsg).toBeDefined(); + }); + + // Send malformed response + const messages = transport.getAllWrittenMessages(); + const interruptMsg = findControlRequest( + messages, + ControlRequestType.INTERRUPT, + )!; + + transport.simulateMessage({ + type: 'control_response', + response: { + subtype: 'error', + request_id: interruptMsg.request_id, + error: { message: 'Malformed error' }, + }, + }); + + await expect(interruptPromise).rejects.toThrow('Malformed error'); + + await query.close(); + }); + + it('should handle CLI sending error result message', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const errorResult = createResultMessage(false); + transport.simulateMessage(errorResult); + + const result = await query.next(); + expect(result.done).toBe(false); + expect((result.value as CLIResultMessage).is_error).toBe(true); + + await query.close(); + }); + }); + + describe('Single-Turn Mode', () => { + it('should auto-close input after result in single-turn mode', async () => { + const query = new Query( + transport, + { cwd: '/test' }, + true, // singleTurn = true + ); + + const resultMsg = createResultMessage(true); + transport.simulateMessage(resultMsg); + + await query.next(); + + expect(transport.endInputCalled).toBe(true); + + await query.close(); }); - it('should handle CLI sending error message', async () => { - // Should yield error message to user - expect(true).toBe(true); // Placeholder + it('should not auto-close input in multi-turn mode', async () => { + const query = new Query( + transport, + { cwd: '/test' }, + false, // singleTurn = false + ); + + const resultMsg = createResultMessage(true); + transport.simulateMessage(resultMsg); + + await query.next(); + + expect(transport.endInputCalled).toBe(false); + + await query.close(); }); }); describe('State Management', () => { - it('should track pending control requests', () => { - // Should maintain map of request_id -> Promise - expect(true).toBe(true); // Placeholder + it('should track session ID', () => { + const query = new Query(transport, { cwd: '/test' }); + const sessionId = query.getSessionId(); + + expect(sessionId).toBeTruthy(); + expect(typeof sessionId).toBe('string'); + expect(sessionId.length).toBeGreaterThan(0); + }); + + it('should track closed state', async () => { + const query = new Query(transport, { cwd: '/test' }); + + expect(query.isClosed()).toBe(false); + await query.close(); + expect(query.isClosed()).toBe(true); + }); + + it('should provide endInput() method', async () => { + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + query.endInput(); + expect(transport.endInputCalled).toBe(true); + + await query.close(); + }); + + it('should throw if endInput() called on closed query', async () => { + const query = new Query(transport, { cwd: '/test' }); + await query.close(); + + expect(() => query.endInput()).toThrow('Query is closed'); }); + }); + + describe('Edge Cases', () => { + it('should handle empty message stream', async () => { + const query = new Query(transport, { cwd: '/test' }); + + transport.simulateClose(); - it('should track SDK MCP transports', () => { - // Should maintain map of server_name -> SdkControlServerTransport - expect(true).toBe(true); // Placeholder + const result = await query.next(); + expect(result.done).toBe(true); + + await query.close(); }); - it('should track initialization state', () => { - // Should have initialized Promise - expect(true).toBe(true); // Placeholder + it('should handle rapid message flow', async () => { + const query = new Query(transport, { cwd: '/test' }); + + // Simulate rapid messages + for (let i = 0; i < 100; i++) { + transport.simulateMessage(createUserMessage(`Message ${i}`)); + } + + const messages: CLIMessage[] = []; + for (let i = 0; i < 100; i++) { + const result = await query.next(); + if (!result.done) { + messages.push(result.value); + } + } + + expect(messages.length).toBe(100); + + await query.close(); }); - it('should track closed state', () => { - // Should have closed boolean flag - expect(true).toBe(true); // Placeholder + it('should handle close during message iteration', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const iterationPromise = (async () => { + const messages: CLIMessage[] = []; + for await (const msg of query) { + messages.push(msg); + if (messages.length === 2) { + await query.close(); + } + } + return messages; + })(); + + transport.simulateMessage(createUserMessage('First')); + transport.simulateMessage(createUserMessage('Second')); + transport.simulateMessage(createUserMessage('Third')); + transport.simulateClose(); + + const messages = await iterationPromise; + expect(messages.length).toBeGreaterThanOrEqual(2); }); }); }); diff --git a/packages/sdk-typescript/test/unit/cliPath.test.ts b/packages/sdk-typescript/test/unit/cliPath.test.ts index 55a87b92f8..0e40e23a79 100644 --- a/packages/sdk-typescript/test/unit/cliPath.test.ts +++ b/packages/sdk-typescript/test/unit/cliPath.test.ts @@ -11,7 +11,6 @@ import { parseExecutableSpec, prepareSpawnInfo, findNativeCliPath, - resolveCliPath, } from '../../src/utils/cliPath.js'; // Mock fs module @@ -421,28 +420,6 @@ describe('CLI Path Utilities', () => { }); }); - describe('resolveCliPath (backward compatibility)', () => { - it('should resolve CLI path for backward compatibility', () => { - mockFs.existsSync.mockReturnValue(true); - - const result = resolveCliPath('/path/to/qwen'); - - expect(result).toBe('/path/to/qwen'); - }); - - it('should auto-detect when no path provided', () => { - const originalEnv = process.env['QWEN_CODE_CLI_PATH']; - process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen'; - mockFs.existsSync.mockReturnValue(true); - - const result = resolveCliPath(); - - expect(result).toBe('/usr/local/bin/qwen'); - - process.env['QWEN_CODE_CLI_PATH'] = originalEnv; - }); - }); - describe('real-world use cases', () => { beforeEach(() => { mockFs.existsSync.mockReturnValue(true); From 49dc84ac0e5df805f0a41089b635ca97f3b98e94 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Tue, 25 Nov 2025 18:05:58 +0800 Subject: [PATCH 05/22] feat: add support for includePartialMessages option in query and transport layers --- .../sdk-typescript/src/query/createQuery.ts | 1 + .../src/transport/ProcessTransport.ts | 4 ++ .../src/types/queryOptionsSchema.ts | 1 + packages/sdk-typescript/src/types/types.ts | 1 + .../test/e2e/multi-turn.test.ts | 63 +++++++++++++++++++ .../test/e2e/single-turn.test.ts | 36 +++++++++++ 6 files changed, 106 insertions(+) diff --git a/packages/sdk-typescript/src/query/createQuery.ts b/packages/sdk-typescript/src/query/createQuery.ts index e390763593..7549b2b34c 100644 --- a/packages/sdk-typescript/src/query/createQuery.ts +++ b/packages/sdk-typescript/src/query/createQuery.ts @@ -43,6 +43,7 @@ export function query({ coreTools: options.coreTools, excludeTools: options.excludeTools, authType: options.authType, + includePartialMessages: options.includePartialMessages, }); const queryOptions: QueryOptions = { diff --git a/packages/sdk-typescript/src/transport/ProcessTransport.ts b/packages/sdk-typescript/src/transport/ProcessTransport.ts index 62a6b2d051..ba13f044ab 100644 --- a/packages/sdk-typescript/src/transport/ProcessTransport.ts +++ b/packages/sdk-typescript/src/transport/ProcessTransport.ts @@ -155,6 +155,10 @@ export class ProcessTransport implements Transport { args.push('--auth-type', this.options.authType); } + if (this.options.includePartialMessages) { + args.push('--include-partial-messages'); + } + return args; } diff --git a/packages/sdk-typescript/src/types/queryOptionsSchema.ts b/packages/sdk-typescript/src/types/queryOptionsSchema.ts index 7573abef3c..c347bfdd95 100644 --- a/packages/sdk-typescript/src/types/queryOptionsSchema.ts +++ b/packages/sdk-typescript/src/types/queryOptionsSchema.ts @@ -76,6 +76,7 @@ export const QueryOptionsSchema = z ), ) .optional(), + includePartialMessages: z.boolean().optional(), }) .strict(); diff --git a/packages/sdk-typescript/src/types/types.ts b/packages/sdk-typescript/src/types/types.ts index 856099fca1..e4cbbb5b6b 100644 --- a/packages/sdk-typescript/src/types/types.ts +++ b/packages/sdk-typescript/src/types/types.ts @@ -30,6 +30,7 @@ export type TransportOptions = { coreTools?: string[]; excludeTools?: string[]; authType?: string; + includePartialMessages?: boolean; }; type ToolInput = Record; diff --git a/packages/sdk-typescript/test/e2e/multi-turn.test.ts b/packages/sdk-typescript/test/e2e/multi-turn.test.ts index 8e79898ebc..52c012c8a7 100644 --- a/packages/sdk-typescript/test/e2e/multi-turn.test.ts +++ b/packages/sdk-typescript/test/e2e/multi-turn.test.ts @@ -476,4 +476,67 @@ describe('Multi-Turn Conversations (E2E)', () => { } }); }); + + describe('Partial Messages in Multi-Turn', () => { + it('should receive partial messages when includePartialMessages is enabled', async () => { + async function* createMultiTurnConversation(): AsyncIterable { + const sessionId = crypto.randomUUID(); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'What is 1 + 1?', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 100)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'What is 2 + 2?', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + } + + const q = query({ + prompt: createMultiTurnConversation(), + options: { + ...SHARED_TEST_OPTIONS, + includePartialMessages: true, + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + let partialMessageCount = 0; + let assistantMessageCount = 0; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIPartialAssistantMessage(message)) { + partialMessageCount++; + } + + if (isCLIAssistantMessage(message)) { + assistantMessageCount++; + } + } + + expect(messages.length).toBeGreaterThan(0); + expect(partialMessageCount).toBeGreaterThan(0); + expect(assistantMessageCount).toBeGreaterThanOrEqual(2); + } finally { + await q.close(); + } + }); + }); }); diff --git a/packages/sdk-typescript/test/e2e/single-turn.test.ts b/packages/sdk-typescript/test/e2e/single-turn.test.ts index 047be4f225..93c1ecc85e 100644 --- a/packages/sdk-typescript/test/e2e/single-turn.test.ts +++ b/packages/sdk-typescript/test/e2e/single-turn.test.ts @@ -9,6 +9,7 @@ import { isCLIAssistantMessage, isCLISystemMessage, isCLIResultMessage, + isCLIPartialAssistantMessage, type TextBlock, type ContentBlock, type CLIMessage, @@ -327,6 +328,41 @@ describe('Single-Turn Query (E2E)', () => { await q.close(); } }); + + it('should receive partial messages when includePartialMessages is enabled', async () => { + const q = query({ + prompt: 'Count from 1 to 5', + options: { + ...SHARED_TEST_OPTIONS, + includePartialMessages: true, + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + let partialMessageCount = 0; + let assistantMessageCount = 0; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIPartialAssistantMessage(message)) { + partialMessageCount++; + } + + if (isCLIAssistantMessage(message)) { + assistantMessageCount++; + } + } + + expect(messages.length).toBeGreaterThan(0); + expect(partialMessageCount).toBeGreaterThan(0); + expect(assistantMessageCount).toBeGreaterThan(0); + } finally { + await q.close(); + } + }); }); describe('Message Type Recognition', () => { From 769a438fa4fdcde706776a9c4aa5b02175488318 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Wed, 26 Nov 2025 21:37:40 +0800 Subject: [PATCH 06/22] feat: enhance logging capabilities and update query options in sdk-typescript - Introduced a new logging system with adjustable log levels (debug, info, warn, error). - Updated query options to include a logLevel parameter for controlling verbosity. - Refactored existing code to utilize the new logging system for better error handling and debugging. - Cleaned up unused code and improved the structure of the SDK. --- eslint.config.js | 2 +- packages/cli/src/gemini.tsx | 3 +- .../cli/src/validateNonInterActiveAuth.ts | 2 +- packages/sdk-typescript/package.json | 28 +-- packages/sdk-typescript/scripts/build.js | 95 ++++++++++ packages/sdk-typescript/src/index.ts | 7 +- .../src/mcp/SdkControlServerTransport.ts | 26 +-- packages/sdk-typescript/src/query/Query.ts | 84 +++------ .../sdk-typescript/src/query/createQuery.ts | 36 ++-- .../src/transport/ProcessTransport.ts | 51 +++-- .../src/types/queryOptionsSchema.ts | 7 +- packages/sdk-typescript/src/types/types.ts | 178 +++++++++++++++++- .../sdk-typescript/src/utils/jsonLines.ts | 12 +- packages/sdk-typescript/src/utils/logger.ts | 147 +++++++++++++++ .../test/e2e/single-turn.test.ts | 3 +- packages/sdk-typescript/tsconfig.build.json | 14 ++ 16 files changed, 552 insertions(+), 143 deletions(-) create mode 100755 packages/sdk-typescript/scripts/build.js create mode 100644 packages/sdk-typescript/src/utils/logger.ts create mode 100644 packages/sdk-typescript/tsconfig.build.json diff --git a/eslint.config.js b/eslint.config.js index 8a35ef6f12..e477d95fd5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -170,7 +170,7 @@ export default tseslint.config( }, // extra settings for scripts that we run directly with node { - files: ['./scripts/**/*.js', 'esbuild.config.js'], + files: ['./scripts/**/*.js', 'esbuild.config.js', 'packages/*/scripts/**/*.js'], languageOptions: { globals: { ...globals.node, diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 8210d5d5f0..3aa3f95730 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -449,7 +449,8 @@ export async function main() { } const nonInteractiveConfig = await validateNonInteractiveAuth( - settings.merged.security?.auth?.selectedType, + settings.merged.security?.auth?.selectedType || + (argv.authType as AuthType), settings.merged.security?.auth?.useExternal, config, settings, diff --git a/packages/cli/src/validateNonInterActiveAuth.ts b/packages/cli/src/validateNonInterActiveAuth.ts index 78ccc99330..1590c07403 100644 --- a/packages/cli/src/validateNonInterActiveAuth.ts +++ b/packages/cli/src/validateNonInterActiveAuth.ts @@ -41,7 +41,7 @@ export async function validateNonInteractiveAuth( } const effectiveAuthType = - enforcedType || getAuthTypeFromEnv() || configuredAuthType; + 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`; diff --git a/packages/sdk-typescript/package.json b/packages/sdk-typescript/package.json index 067d1d22b4..63fed22751 100644 --- a/packages/sdk-typescript/package.json +++ b/packages/sdk-typescript/package.json @@ -2,14 +2,15 @@ "name": "@qwen-code/sdk-typescript", "version": "0.1.0", "description": "TypeScript SDK for programmatic access to qwen-code CLI", - "main": "dist/index.js", - "types": "dist/index.d.ts", + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", "type": "module", "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.js" + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" }, "./package.json": "./package.json" }, @@ -19,14 +20,16 @@ "LICENSE" ], "scripts": { - "build": "tsc", + "build": "node scripts/build.js", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "lint": "eslint src test", "lint:fix": "eslint src test --fix", + "typecheck": "tsc --noEmit", "clean": "rm -rf dist", - "prepublishOnly": "npm run clean && npm run build" + "prepublishOnly": "npm run clean && npm run build", + "prepack": "npm run build" }, "keywords": [ "qwen", @@ -49,20 +52,23 @@ "@typescript-eslint/eslint-plugin": "^7.13.0", "@typescript-eslint/parser": "^7.13.0", "@vitest/coverage-v8": "^1.6.0", + "dts-bundle-generator": "^9.5.1", + "esbuild": "^0.25.12", "eslint": "^8.57.0", "typescript": "^5.4.5", - "vitest": "^1.6.0" + "vitest": "^1.6.0", + "zod": "^3.23.8" }, "peerDependencies": { "typescript": ">=5.0.0" }, "repository": { "type": "git", - "url": "https://github.com/qwen-ai/qwen-code.git", - "directory": "packages/sdk/typescript" + "url": "https://github.com/QwenLM/qwen-code.git", + "directory": "packages/sdk-typescript" }, "bugs": { - "url": "https://github.com/qwen-ai/qwen-code/issues" + "url": "https://github.com/QwenLM/qwen-code/issues" }, - "homepage": "https://github.com/qwen-ai/qwen-code#readme" + "homepage": "https://qwenlm.github.io/qwen-code-docs/" } diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js new file mode 100755 index 0000000000..055584a5ad --- /dev/null +++ b/packages/sdk-typescript/scripts/build.js @@ -0,0 +1,95 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execSync } from 'node:child_process'; +import { rmSync, mkdirSync, existsSync, cpSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import esbuild from 'esbuild'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const rootDir = join(__dirname, '..'); + +rmSync(join(rootDir, 'dist'), { recursive: true, force: true }); +mkdirSync(join(rootDir, 'dist'), { recursive: true }); + +execSync('tsc --project tsconfig.build.json', { + stdio: 'inherit', + cwd: rootDir, +}); + +try { + execSync( + 'npx dts-bundle-generator -o dist/index.d.ts src/index.ts --no-check', + { + stdio: 'inherit', + cwd: rootDir, + }, + ); + + const dirsToRemove = ['mcp', 'query', 'transport', 'types', 'utils']; + for (const dir of dirsToRemove) { + const dirPath = join(rootDir, 'dist', dir); + if (existsSync(dirPath)) { + rmSync(dirPath, { recursive: true, force: true }); + } + } +} catch (error) { + console.warn( + 'Could not bundle type definitions, keeping separate .d.ts files', + error.message, + ); +} + +await esbuild.build({ + entryPoints: [join(rootDir, 'src', 'index.ts')], + bundle: true, + format: 'esm', + platform: 'node', + target: 'node18', + outfile: join(rootDir, 'dist', 'index.mjs'), + external: ['@modelcontextprotocol/sdk'], + sourcemap: false, + minify: true, + minifyWhitespace: true, + minifyIdentifiers: true, + minifySyntax: true, + legalComments: 'none', + keepNames: false, + treeShaking: true, +}); + +await esbuild.build({ + entryPoints: [join(rootDir, 'src', 'index.ts')], + bundle: true, + format: 'cjs', + platform: 'node', + target: 'node18', + outfile: join(rootDir, 'dist', 'index.cjs'), + external: ['@modelcontextprotocol/sdk'], + sourcemap: false, + minify: true, + minifyWhitespace: true, + minifyIdentifiers: true, + minifySyntax: true, + legalComments: 'none', + keepNames: false, + treeShaking: true, +}); + +const filesToCopy = ['README.md', 'LICENSE']; +for (const file of filesToCopy) { + const sourcePath = join(rootDir, '..', '..', file); + const targetPath = join(rootDir, 'dist', file); + if (existsSync(sourcePath)) { + try { + cpSync(sourcePath, targetPath); + } catch (error) { + console.warn(`Could not copy ${file}:`, error.message); + } + } +} diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 23ba3f936c..5992c6c5cc 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -1,10 +1,10 @@ export { query } from './query/createQuery.js'; export { AbortError, isAbortError } from './types/errors.js'; export { Query } from './query/Query.js'; - -export type { ExternalMcpServerConfig } from './types/queryOptionsSchema.js'; +export { SdkLogger } from './utils/logger.js'; export type { QueryOptions } from './query/createQuery.js'; +export type { LogLevel, LoggerConfig, ScopedLogger } from './utils/logger.js'; export type { ContentBlock, @@ -29,8 +29,9 @@ export { } from './types/protocol.js'; export type { - JSONSchema, PermissionMode, CanUseTool, PermissionResult, + ExternalMcpServerConfig, + SdkMcpServerConfig, } from './types/types.js'; diff --git a/packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts b/packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts index c160a9af02..06392a4f84 100644 --- a/packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts +++ b/packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts @@ -9,6 +9,7 @@ */ import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; +import { SdkLogger } from '../utils/logger.js'; export type SendToQueryCallback = (message: JSONRPCMessage) => Promise; @@ -21,6 +22,7 @@ export class SdkControlServerTransport { sendToQuery: SendToQueryCallback; private serverName: string; private started = false; + private logger; onmessage?: (message: JSONRPCMessage) => void; onerror?: (error: Error) => void; @@ -29,10 +31,14 @@ export class SdkControlServerTransport { constructor(options: SdkControlServerTransportOptions) { this.sendToQuery = options.sendToQuery; this.serverName = options.serverName; + this.logger = SdkLogger.createLogger( + `SdkControlServerTransport:${options.serverName}`, + ); } async start(): Promise { this.started = true; + this.logger.debug('Transport started'); } async send(message: JSONRPCMessage): Promise { @@ -43,10 +49,10 @@ export class SdkControlServerTransport { } try { - // Send via Query's control plane + this.logger.debug('Sending message to Query', message); await this.sendToQuery(message); } catch (error) { - // Invoke error callback if set + this.logger.error('Error sending message:', error); if (this.onerror) { this.onerror(error instanceof Error ? error : new Error(String(error))); } @@ -60,6 +66,7 @@ export class SdkControlServerTransport { } this.started = false; + this.logger.debug('Transport closed'); // Notify MCP Server if (this.onclose) { @@ -69,29 +76,22 @@ export class SdkControlServerTransport { handleMessage(message: JSONRPCMessage): void { if (!this.started) { - console.warn( - `[SdkControlServerTransport] Received message for closed transport (${this.serverName})`, - ); + this.logger.warn('Received message for closed transport'); return; } + this.logger.debug('Handling message from CLI', message); if (this.onmessage) { this.onmessage(message); } else { - console.warn( - `[SdkControlServerTransport] No onmessage handler set for ${this.serverName}`, - ); + this.logger.warn('No onmessage handler set'); } } handleError(error: Error): void { + this.logger.error('Transport error:', error); if (this.onerror) { this.onerror(error); - } else { - console.error( - `[SdkControlServerTransport] Error for ${this.serverName}:`, - error, - ); } } diff --git a/packages/sdk-typescript/src/query/Query.ts b/packages/sdk-typescript/src/query/Query.ts index c8039d4c80..de4c48525d 100644 --- a/packages/sdk-typescript/src/query/Query.ts +++ b/packages/sdk-typescript/src/query/Query.ts @@ -11,6 +11,7 @@ const CONTROL_REQUEST_TIMEOUT = 30000; const STREAM_CLOSE_TIMEOUT = 10000; import { randomUUID } from 'node:crypto'; +import { SdkLogger } from '../utils/logger.js'; import type { CLIMessage, CLIUserMessage, @@ -30,7 +31,7 @@ import { isControlCancel, } from '../types/protocol.js'; import type { Transport } from '../transport/Transport.js'; -import { type QueryOptions } from '../types/queryOptionsSchema.js'; +import type { QueryOptions } from '../types/types.js'; import { Stream } from '../utils/Stream.js'; import { serializeJsonLine } from '../utils/jsonLines.js'; import { AbortError } from '../types/errors.js'; @@ -49,6 +50,8 @@ interface TransportWithEndInput extends Transport { endInput(): void; } +const logger = SdkLogger.createLogger('Query'); + export class Query implements AsyncIterable { private transport: Transport; private options: QueryOptions; @@ -101,13 +104,13 @@ export class Query implements AsyncIterable { if (this.abortController.signal.aborted) { this.inputStream.error(new AbortError('Query aborted by user')); this.close().catch((err) => { - console.error('[Query] Error during abort cleanup:', err); + logger.error('Error during abort cleanup:', err); }); } else { this.abortController.signal.addEventListener('abort', () => { this.inputStream.error(new AbortError('Query aborted by user')); this.close().catch((err) => { - console.error('[Query] Error during abort cleanup:', err); + logger.error('Error during abort cleanup:', err); }); }); } @@ -120,7 +123,7 @@ export class Query implements AsyncIterable { private async initialize(): Promise { try { - await this.setupSdkMcpServers(); + logger.debug('Initializing Query'); const sdkMcpServerNames = Array.from(this.sdkMcpTransports.keys()); @@ -131,52 +134,13 @@ export class Query implements AsyncIterable { mcpServers: this.options.mcpServers, agents: this.options.agents, }); + logger.info('Query initialized successfully'); } catch (error) { - console.error('[Query] Initialization error:', error); + logger.error('Initialization error:', error); throw error; } } - private async setupSdkMcpServers(): Promise { - if (!this.options.sdkMcpServers) { - return; - } - - const externalNames = Object.keys(this.options.mcpServers ?? {}); - const sdkNames = Object.keys(this.options.sdkMcpServers); - - const conflicts = sdkNames.filter((name) => externalNames.includes(name)); - if (conflicts.length > 0) { - throw new Error( - `MCP server name conflicts between mcpServers and sdkMcpServers: ${conflicts.join(', ')}`, - ); - } - - /** - * Import SdkControlServerTransport dynamically to avoid circular dependencies. - * Create transport for each server that sends MCP messages via control plane. - */ - const { SdkControlServerTransport } = await import( - '../mcp/SdkControlServerTransport.js' - ); - - for (const [name, server] of Object.entries(this.options.sdkMcpServers)) { - const transport = new SdkControlServerTransport({ - serverName: name, - sendToQuery: async (message: JSONRPCMessage) => { - await this.sendControlRequest(ControlRequestType.MCP_MESSAGE, { - server_name: name, - message, - }); - }, - }); - - await transport.start(); - await server.connect(transport); - this.sdkMcpTransports.set(name, transport); - } - } - private startMessageRouter(): void { if (this.messageRouterStarted) { return; @@ -256,9 +220,7 @@ export class Query implements AsyncIterable { return; } - if (process.env['DEBUG']) { - console.warn('[Query] Unknown message type:', message); - } + logger.warn('Unknown message type:', message); this.inputStream.enqueue(message as CLIMessage); } @@ -267,6 +229,7 @@ export class Query implements AsyncIterable { ): Promise { const { request_id, request: payload } = request; + logger.debug(`Handling control request: ${payload.subtype}`); const requestAbortController = new AbortController(); try { @@ -299,6 +262,7 @@ export class Query implements AsyncIterable { } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`Control request error (${payload.subtype}):`, errorMessage); await this.sendControlResponse(request_id, false, errorMessage); } } @@ -369,8 +333,8 @@ export class Query implements AsyncIterable { */ const errorMessage = error instanceof Error ? error.message : String(error); - console.warn( - '[Query] Permission callback error (denying by default):', + logger.warn( + 'Permission callback error (denying by default):', errorMessage, ); return { @@ -448,9 +412,10 @@ export class Query implements AsyncIterable { const pending = this.pendingControlRequests.get(request_id); if (!pending) { - console.warn( - '[Query] Received response for unknown request:', + logger.warn( + 'Received response for unknown request:', request_id, + JSON.stringify(payload), ); return; } @@ -459,6 +424,9 @@ export class Query implements AsyncIterable { this.pendingControlRequests.delete(request_id); if (payload.subtype === 'success') { + logger.debug( + `Control response success for request: ${request_id}: ${JSON.stringify(payload.response)}`, + ); pending.resolve(payload.response as Record | null); } else { /** @@ -469,6 +437,10 @@ export class Query implements AsyncIterable { typeof payload.error === 'string' ? payload.error : (payload.error?.message ?? 'Unknown error'); + logger.error( + `Control response error for request ${request_id}:`, + errorMessage, + ); pending.reject(new Error(errorMessage)); } } @@ -477,12 +449,13 @@ export class Query implements AsyncIterable { const { request_id } = request; if (!request_id) { - console.warn('[Query] Received cancel request without request_id'); + logger.warn('Received cancel request without request_id'); return; } const pending = this.pendingControlRequests.get(request_id); if (pending) { + logger.debug(`Cancelling control request: ${request_id}`); pending.abortController.abort(); clearTimeout(pending.timeout); this.pendingControlRequests.delete(request_id); @@ -580,10 +553,11 @@ export class Query implements AsyncIterable { try { await transport.close(); } catch (error) { - console.error('[Query] Error closing MCP transport:', error); + logger.error('Error closing MCP transport:', error); } } this.sdkMcpTransports.clear(); + logger.info('Query closed'); } private async *readSdkMessages(): AsyncGenerator { @@ -652,7 +626,7 @@ export class Query implements AsyncIterable { this.endInput(); } catch (error) { if (this.abortController.signal.aborted) { - console.log('[Query] Aborted during input streaming'); + logger.info('Aborted during input streaming'); this.inputStream.error( new AbortError('Query aborted during input streaming'), ); diff --git a/packages/sdk-typescript/src/query/createQuery.ts b/packages/sdk-typescript/src/query/createQuery.ts index 7549b2b34c..71fd6e9b4e 100644 --- a/packages/sdk-typescript/src/query/createQuery.ts +++ b/packages/sdk-typescript/src/query/createQuery.ts @@ -7,18 +7,29 @@ import { serializeJsonLine } from '../utils/jsonLines.js'; import { ProcessTransport } from '../transport/ProcessTransport.js'; import { parseExecutableSpec } from '../utils/cliPath.js'; import { Query } from './Query.js'; -import { - QueryOptionsSchema, - type QueryOptions, -} from '../types/queryOptionsSchema.js'; +import type { QueryOptions } from '../types/types.js'; +import { QueryOptionsSchema } from '../types/queryOptionsSchema.js'; +import { SdkLogger } from '../utils/logger.js'; export type { QueryOptions }; +const logger = SdkLogger.createLogger('createQuery'); + export function query({ prompt, options = {}, }: { + /** + * The prompt to send to the Qwen Code CLI process. + * - `string` for single-turn query, + * - `AsyncIterable` for multi-turn query. + * + * The transport will remain open until the prompt is done. + */ prompt: string | AsyncIterable; + /** + * Configuration options for the query. + */ options?: QueryOptions; }): Query { const parsedExecutable = validateOptions(options); @@ -39,6 +50,7 @@ export function query({ abortController, debug: options.debug, stderr: options.stderr, + logLevel: options.logLevel, maxSessionTurns: options.maxSessionTurns, coreTools: options.coreTools, excludeTools: options.excludeTools, @@ -70,14 +82,14 @@ export function query({ await queryInstance.initialized; transport.write(serializeJsonLine(message)); } catch (err) { - console.error('[query] Error sending single-turn prompt:', err); + logger.error('Error sending single-turn prompt:', err); } })(); } else { queryInstance .streamInput(prompt as AsyncIterable) .catch((err) => { - console.error('[query] Error streaming input:', err); + logger.error('Error streaming input:', err); }); } @@ -103,17 +115,5 @@ function validateOptions( throw new Error(`Invalid pathToQwenExecutable: ${errorMessage}`); } - if (options.mcpServers && options.sdkMcpServers) { - const externalNames = Object.keys(options.mcpServers); - const sdkNames = Object.keys(options.sdkMcpServers); - - const conflicts = externalNames.filter((name) => sdkNames.includes(name)); - if (conflicts.length > 0) { - throw new Error( - `MCP server name conflicts between mcpServers and sdkMcpServers: ${conflicts.join(', ')}`, - ); - } - } - return parsedExecutable; } diff --git a/packages/sdk-typescript/src/transport/ProcessTransport.ts b/packages/sdk-typescript/src/transport/ProcessTransport.ts index ba13f044ab..d473160ccc 100644 --- a/packages/sdk-typescript/src/transport/ProcessTransport.ts +++ b/packages/sdk-typescript/src/transport/ProcessTransport.ts @@ -6,6 +6,9 @@ import type { Transport } from './Transport.js'; import { parseJsonLinesStream } from '../utils/jsonLines.js'; import { prepareSpawnInfo } from '../utils/cliPath.js'; import { AbortError } from '../types/errors.js'; +import { SdkLogger } from '../utils/logger.js'; + +const logger = SdkLogger.createLogger('ProcessTransport'); export class ProcessTransport implements Transport { private childProcess: ChildProcess | null = null; @@ -23,6 +26,11 @@ export class ProcessTransport implements Transport { this.options = options; this.abortController = this.options.abortController ?? new AbortController(); + SdkLogger.configure({ + debug: options.debug, + stderr: options.stderr, + logLevel: options.logLevel, + }); this.initialize(); } @@ -41,7 +49,7 @@ export class ProcessTransport implements Transport { const stderrMode = this.options.debug || this.options.stderr ? 'pipe' : 'ignore'; - this.logForDebugging( + logger.debug( `Spawning CLI (${spawnInfo.type}): ${spawnInfo.command} ${[...spawnInfo.args, ...cliArgs].join(' ')}`, ); @@ -61,7 +69,7 @@ export class ProcessTransport implements Transport { if (this.options.debug || this.options.stderr) { this.childProcess.stderr?.on('data', (data) => { - this.logForDebugging(data.toString()); + logger.debug(data.toString()); }); } @@ -79,8 +87,10 @@ export class ProcessTransport implements Transport { this.setupEventHandlers(); this.ready = true; + logger.info('CLI process started successfully'); } catch (error) { this.ready = false; + logger.error('Failed to initialize CLI process:', error); throw error; } } @@ -94,7 +104,7 @@ export class ProcessTransport implements Transport { this._exitError = new AbortError('CLI process aborted by user'); } else { this._exitError = new Error(`CLI process error: ${error.message}`); - this.logForDebugging(this._exitError.message); + logger.error(this._exitError.message); } }); @@ -106,7 +116,7 @@ export class ProcessTransport implements Transport { const error = this.getProcessExitError(code, signal); if (error) { this._exitError = error; - this.logForDebugging(error.message); + logger.error(error.message); } } }); @@ -269,28 +279,24 @@ export class ProcessTransport implements Transport { ); } - if (process.env['DEBUG']) { - this.logForDebugging( - `[ProcessTransport] Writing to stdin (${message.length} bytes): ${message.substring(0, 100)}`, - ); - } + logger.debug( + `Writing to stdin (${message.length} bytes): ${message.trim()}`, + ); try { const written = this.childStdin.write(message); if (!written) { - this.logForDebugging( - `[ProcessTransport] Write buffer full (${message.length} bytes), data queued. Waiting for drain event...`, - ); - } else if (process.env['DEBUG']) { - this.logForDebugging( - `[ProcessTransport] Write successful (${message.length} bytes)`, + logger.warn( + `Write buffer full (${message.length} bytes), data queued. Waiting for drain event...`, ); + } else { + logger.debug(`Write successful (${message.length} bytes)`); } } catch (error) { this.ready = false; - throw new Error( - `Failed to write to stdin: ${error instanceof Error ? error.message : String(error)}`, - ); + const errorMsg = `Failed to write to stdin: ${error instanceof Error ? error.message : String(error)}`; + logger.error(errorMsg); + throw new Error(errorMsg); } } @@ -340,13 +346,4 @@ export class ProcessTransport implements Transport { getOutputStream(): Readable | undefined { return this.childStdout || undefined; } - - private logForDebugging(message: string): void { - if (this.options.debug || process.env['DEBUG']) { - process.stderr.write(`[ProcessTransport] ${message}\n`); - } - if (this.options.stderr) { - this.options.stderr(message); - } - } } diff --git a/packages/sdk-typescript/src/types/queryOptionsSchema.ts b/packages/sdk-typescript/src/types/queryOptionsSchema.ts index c347bfdd95..c462935750 100644 --- a/packages/sdk-typescript/src/types/queryOptionsSchema.ts +++ b/packages/sdk-typescript/src/types/queryOptionsSchema.ts @@ -50,7 +50,6 @@ export const QueryOptionsSchema = z }) .optional(), mcpServers: z.record(z.string(), ExternalMcpServerConfigSchema).optional(), - sdkMcpServers: z.record(z.string(), SdkMcpServerConfigSchema).optional(), abortController: z.instanceof(AbortController).optional(), debug: z.boolean().optional(), stderr: z @@ -58,6 +57,7 @@ export const QueryOptionsSchema = z (message: string) => void >((val) => typeof val === 'function', { message: 'stderr must be a function' }) .optional(), + logLevel: z.enum(['debug', 'info', 'warn', 'error']).optional(), maxSessionTurns: z.number().optional(), coreTools: z.array(z.string()).optional(), excludeTools: z.array(z.string()).optional(), @@ -79,8 +79,3 @@ export const QueryOptionsSchema = z includePartialMessages: z.boolean().optional(), }) .strict(); - -export type ExternalMcpServerConfig = z.infer< - typeof ExternalMcpServerConfigSchema ->; -export type QueryOptions = z.infer; diff --git a/packages/sdk-typescript/src/types/types.ts b/packages/sdk-typescript/src/types/types.ts index e4cbbb5b6b..0c23581b4f 100644 --- a/packages/sdk-typescript/src/types/types.ts +++ b/packages/sdk-typescript/src/types/types.ts @@ -1,8 +1,12 @@ -import type { PermissionMode, PermissionSuggestion } from './protocol.js'; +import type { + PermissionMode, + PermissionSuggestion, + SubagentConfig, +} from './protocol.js'; export type { PermissionMode }; -export type JSONSchema = { +type JSONSchema = { type: string; properties?: Record; required?: string[]; @@ -26,6 +30,7 @@ export type TransportOptions = { abortController?: AbortController; debug?: boolean; stderr?: (message: string) => void; + logLevel?: 'debug' | 'info' | 'warn' | 'error'; maxSessionTurns?: number; coreTools?: string[]; excludeTools?: string[]; @@ -54,3 +59,172 @@ export type PermissionResult = message: string; interrupt?: boolean; }; + +export interface ExternalMcpServerConfig { + command: string; + args?: string[]; + env?: Record; +} + +export interface SdkMcpServerConfig { + connect: (transport: unknown) => Promise; +} + +/** + * Configuration options for creating a query session with the Qwen CLI. + */ +export interface QueryOptions { + /** + * The working directory for the query session. + * This determines the context in which file operations and commands are executed. + * @default process.cwd() + */ + cwd?: string; + + /** + * The AI model to use for the query session. + * This takes precedence over the environment variables `OPENAI_MODEL` and `QWEN_MODEL` + * @example 'qwen-max', 'qwen-plus', 'qwen-turbo' + */ + model?: string; + + /** + * Path to the Qwen CLI executable or runtime specification. + * + * 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 + * + * 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. + * + * @example 'qwen' + * @example '/usr/local/bin/qwen' + * @example 'tsx:/path/to/packages/cli/src/index.ts' + */ + pathToQwenExecutable?: string; + + /** + * Environment variables to pass to the Qwen CLI process. + * These variables will be merged with the current process environment. + */ + env?: Record; + + /** + * Alias for `approval-mode` command line argument. + * Behaves slightly differently from the command line argument. + * Permission mode controlling how the CLI handles tool usage and file operations **in non-interactive mode**. + * - 'default': Automatically deny all write-like tools(edit, write_file, etc.) and dangers commands. + * - 'plan': Shows a plan before executing operations + * - 'auto-edit': Automatically applies edits without confirmation + * - 'yolo': Executes all operations without prompting + * @default 'default' + */ + permissionMode?: 'default' | 'plan' | 'auto-edit' | 'yolo'; + + /** + * Custom permission handler for tool usage. + * This function is called when the SDK needs to determine if a tool should be allowed. + * Use this with `permissionMode` to gain more control over the tool usage. + * TODO: For now we don't support modifying the input. + */ + canUseTool?: CanUseTool; + + /** + * External MCP (Model Context Protocol) servers to connect to. + * Each server is identified by a unique name and configured with command, args, and environment. + * @example { 'my-server': { command: 'node', args: ['server.js'], env: { PORT: '3000' } } } + */ + mcpServers?: Record; + + /** + * AbortController to cancel the query session. + * Call abortController.abort() to terminate the session and cleanup resources. + * Remember to handle the AbortError when the session is aborted. + */ + abortController?: AbortController; + + /** + * Enable debug mode for verbose logging. + * When true, additional diagnostic information will be output. + * Use this with `logLevel` to control the verbosity of the logs. + * @default false + */ + debug?: boolean; + + /** + * Custom handler for stderr output from the Qwen CLI process. + * Use this to capture and process error messages or diagnostic output. + */ + stderr?: (message: string) => void; + + /** + * Logging level for the SDK. + * Controls the verbosity of log messages output by the SDK. + * @default 'info' + */ + logLevel?: 'debug' | 'info' | 'warn' | 'error'; + + /** + * Maximum number of conversation turns before the session automatically terminates. + * A turn consists of a user message and an assistant response. + * @default -1 (unlimited) + */ + maxSessionTurns?: number; + + /** + * Equivalent to `tool.core` in settings.json. + * List of core tools to enable for the session. + * If specified, only these tools will be available to the AI. + * @example ['read_file', 'write_file', 'run_terminal_cmd'] + */ + coreTools?: string[]; + + /** + * Equivalent to `tool.exclude` in settings.json. + * List of tools to exclude from the session. + * These tools will not be available to the AI, even if they are core tools. + * @example ['run_terminal_cmd', 'delete_file'] + */ + excludeTools?: string[]; + + /** + * Authentication type for the AI service. + * - 'openai': Use OpenAI-compatible authentication + * - 'qwen-oauth': Use Qwen OAuth authentication + * + * Though we support 'qwen-oauth', it's not recommended to use it in the SDK. + * Because the credentials are stored in `~/.qwen` and may need to refresh periodically. + */ + authType?: 'openai' | 'qwen-oauth'; + + /** + * Configuration for subagents that can be invoked during the session. + * Subagents are specialized AI agents that can handle specific tasks or domains. + * The invocation is marked as a `task` tool use with the name of agent and a tool_use_id. + * The tool use of these agent is marked with the parent_tool_use_id of the `task` tool use. + */ + agents?: SubagentConfig[]; + + /** + * Include partial messages in the response stream. + * When true, the SDK will emit incomplete messages as they are being generated, + * allowing for real-time streaming of the AI's response. + * @default false + */ + includePartialMessages?: boolean; +} diff --git a/packages/sdk-typescript/src/utils/jsonLines.ts b/packages/sdk-typescript/src/utils/jsonLines.ts index 6d1bd090d2..8af8ec6ae5 100644 --- a/packages/sdk-typescript/src/utils/jsonLines.ts +++ b/packages/sdk-typescript/src/utils/jsonLines.ts @@ -1,3 +1,5 @@ +import { SdkLogger } from './logger.js'; + export function serializeJsonLine(message: unknown): string { try { return JSON.stringify(message) + '\n'; @@ -12,11 +14,12 @@ export function parseJsonLineSafe( line: string, context = 'JsonLines', ): unknown | null { + const logger = SdkLogger.createLogger(context); try { return JSON.parse(line); } catch (error) { - console.warn( - `[${context}] Failed to parse JSON line, skipping:`, + logger.warn( + 'Failed to parse JSON line, skipping:', line.substring(0, 100), error instanceof Error ? error.message : String(error), ); @@ -37,6 +40,7 @@ export async function* parseJsonLinesStream( lines: AsyncIterable, context = 'JsonLines', ): AsyncGenerator { + const logger = SdkLogger.createLogger(context); for await (const line of lines) { if (line.trim().length === 0) { continue; @@ -49,8 +53,8 @@ export async function* parseJsonLinesStream( } if (!isValidMessage(message)) { - console.warn( - `[${context}] Invalid message structure (missing 'type' field), skipping:`, + logger.warn( + "Invalid message structure (missing 'type' field), skipping:", line.substring(0, 100), ); continue; diff --git a/packages/sdk-typescript/src/utils/logger.ts b/packages/sdk-typescript/src/utils/logger.ts new file mode 100644 index 0000000000..afb7a495d5 --- /dev/null +++ b/packages/sdk-typescript/src/utils/logger.ts @@ -0,0 +1,147 @@ +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +export interface LoggerConfig { + debug?: boolean; + stderr?: (message: string) => void; + logLevel?: LogLevel; +} + +export interface ScopedLogger { + debug(message: string, ...args: unknown[]): void; + info(message: string, ...args: unknown[]): void; + warn(message: string, ...args: unknown[]): void; + error(message: string, ...args: unknown[]): void; +} + +const LOG_LEVEL_PRIORITY: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, +}; + +export class SdkLogger { + private static config: LoggerConfig = {}; + private static effectiveLevel: LogLevel = 'info'; + + static configure(config: LoggerConfig): void { + this.config = config; + this.effectiveLevel = this.determineLogLevel(); + } + + private static determineLogLevel(): LogLevel { + if (this.config.logLevel) { + return this.config.logLevel; + } + + if (this.config.debug) { + return 'debug'; + } + + const envLevel = process.env['DEBUG_QWEN_CODE_SDK_LEVEL']; + if (envLevel && this.isValidLogLevel(envLevel)) { + return envLevel as LogLevel; + } + + if (process.env['DEBUG_QWEN_CODE_SDK']) { + return 'debug'; + } + + return 'info'; + } + + private static isValidLogLevel(level: string): boolean { + return ['debug', 'info', 'warn', 'error'].includes(level); + } + + private static shouldLog(level: LogLevel): boolean { + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[this.effectiveLevel]; + } + + private static formatTimestamp(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const hours = String(now.getHours()).padStart(2, '0'); + const minutes = String(now.getMinutes()).padStart(2, '0'); + const seconds = String(now.getSeconds()).padStart(2, '0'); + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; + } + + private static formatMessage( + level: LogLevel, + scope: string, + message: string, + args: unknown[], + ): string { + const timestamp = this.formatTimestamp(); + const levelStr = `[${level.toUpperCase()}]`.padEnd(7); + let fullMessage = `${timestamp} ${levelStr} [${scope}] ${message}`; + + if (args.length > 0) { + const argsStr = args + .map((arg) => { + if (typeof arg === 'string') { + return arg; + } + if (arg instanceof Error) { + return arg.message; + } + try { + return JSON.stringify(arg); + } catch { + return String(arg); + } + }) + .join(' '); + fullMessage += ` ${argsStr}`; + } + + return fullMessage; + } + + private static log( + level: LogLevel, + scope: string, + message: string, + args: unknown[], + ): void { + if (!this.shouldLog(level)) { + return; + } + + const formattedMessage = this.formatMessage(level, scope, message, args); + + if (this.config.stderr) { + this.config.stderr(formattedMessage); + } else { + if (level === 'warn' || level === 'error') { + process.stderr.write(formattedMessage + '\n'); + } else { + process.stdout.write(formattedMessage + '\n'); + } + } + } + + static createLogger(scope: string): ScopedLogger { + return { + debug: (message: string, ...args: unknown[]) => { + this.log('debug', scope, message, args); + }, + info: (message: string, ...args: unknown[]) => { + this.log('info', scope, message, args); + }, + warn: (message: string, ...args: unknown[]) => { + this.log('warn', scope, message, args); + }, + error: (message: string, ...args: unknown[]) => { + this.log('error', scope, message, args); + }, + }; + } + + static getEffectiveLevel(): LogLevel { + return this.effectiveLevel; + } +} diff --git a/packages/sdk-typescript/test/e2e/single-turn.test.ts b/packages/sdk-typescript/test/e2e/single-turn.test.ts index 93c1ecc85e..2052b6b258 100644 --- a/packages/sdk-typescript/test/e2e/single-turn.test.ts +++ b/packages/sdk-typescript/test/e2e/single-turn.test.ts @@ -39,7 +39,8 @@ describe('Single-Turn Query (E2E)', () => { prompt: 'What is 2 + 2? Just give me the number.', options: { ...SHARED_TEST_OPTIONS, - debug: false, + debug: true, + logLevel: 'debug', }, }); diff --git a/packages/sdk-typescript/tsconfig.build.json b/packages/sdk-typescript/tsconfig.build.json new file mode 100644 index 0000000000..53e1cea0e1 --- /dev/null +++ b/packages/sdk-typescript/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": false, + "sourceMap": false, + "emitDeclarationOnly": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "test", "**/*.test.ts", "**/*.spec.ts"] +} From d76341b8d880b7a96d4003f37742574bfa8bcd9c Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Wed, 26 Nov 2025 22:14:37 +0800 Subject: [PATCH 07/22] chore: keep comments for queryOptions --- packages/sdk-typescript/scripts/build.js | 2 +- packages/sdk-typescript/tsconfig.build.json | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index 055584a5ad..e78f161a4f 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -24,7 +24,7 @@ execSync('tsc --project tsconfig.build.json', { try { execSync( - 'npx dts-bundle-generator -o dist/index.d.ts src/index.ts --no-check', + 'npx dts-bundle-generator --project tsconfig.build.json -o dist/index.d.ts src/index.ts --no-check', { stdio: 'inherit', cwd: rootDir, diff --git a/packages/sdk-typescript/tsconfig.build.json b/packages/sdk-typescript/tsconfig.build.json index 53e1cea0e1..61dbca5b1d 100644 --- a/packages/sdk-typescript/tsconfig.build.json +++ b/packages/sdk-typescript/tsconfig.build.json @@ -7,7 +7,8 @@ "declaration": true, "declarationMap": false, "sourceMap": false, - "emitDeclarationOnly": true + "emitDeclarationOnly": true, + "removeComments": false }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist", "test", "**/*.test.ts", "**/*.spec.ts"] From 638b7bb466d7a10b7faf035ef344f0d6826a69fe Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Thu, 27 Nov 2025 11:44:57 +0800 Subject: [PATCH 08/22] feat: add `allowedTools` support --- .../src/transport/ProcessTransport.ts | 4 + .../src/types/queryOptionsSchema.ts | 1 + packages/sdk-typescript/src/types/types.ts | 93 ++- .../test/e2e/permission-control.test.ts | 636 ++++++++++++++++++ ...control.test.ts => system-control.test.ts} | 161 +++-- 5 files changed, 827 insertions(+), 68 deletions(-) rename packages/sdk-typescript/test/e2e/{control.test.ts => system-control.test.ts} (57%) diff --git a/packages/sdk-typescript/src/transport/ProcessTransport.ts b/packages/sdk-typescript/src/transport/ProcessTransport.ts index d473160ccc..c54d910451 100644 --- a/packages/sdk-typescript/src/transport/ProcessTransport.ts +++ b/packages/sdk-typescript/src/transport/ProcessTransport.ts @@ -161,6 +161,10 @@ export class ProcessTransport implements Transport { args.push('--exclude-tools', this.options.excludeTools.join(',')); } + if (this.options.allowedTools && this.options.allowedTools.length > 0) { + args.push('--allowed-tools', this.options.allowedTools.join(',')); + } + if (this.options.authType) { args.push('--auth-type', this.options.authType); } diff --git a/packages/sdk-typescript/src/types/queryOptionsSchema.ts b/packages/sdk-typescript/src/types/queryOptionsSchema.ts index c462935750..579445cfe0 100644 --- a/packages/sdk-typescript/src/types/queryOptionsSchema.ts +++ b/packages/sdk-typescript/src/types/queryOptionsSchema.ts @@ -61,6 +61,7 @@ export const QueryOptionsSchema = z maxSessionTurns: z.number().optional(), coreTools: z.array(z.string()).optional(), excludeTools: z.array(z.string()).optional(), + allowedTools: z.array(z.string()).optional(), authType: z.enum(['openai', 'qwen-oauth']).optional(), agents: z .array( diff --git a/packages/sdk-typescript/src/types/types.ts b/packages/sdk-typescript/src/types/types.ts index 0c23581b4f..a3f6cd0335 100644 --- a/packages/sdk-typescript/src/types/types.ts +++ b/packages/sdk-typescript/src/types/types.ts @@ -34,6 +34,7 @@ export type TransportOptions = { maxSessionTurns?: number; coreTools?: string[]; excludeTools?: string[]; + allowedTools?: string[]; authType?: string; includePartialMessages?: boolean; }; @@ -125,22 +126,50 @@ export interface QueryOptions { env?: Record; /** - * Alias for `approval-mode` command line argument. - * Behaves slightly differently from the command line argument. - * Permission mode controlling how the CLI handles tool usage and file operations **in non-interactive mode**. - * - 'default': Automatically deny all write-like tools(edit, write_file, etc.) and dangers commands. - * - 'plan': Shows a plan before executing operations - * - 'auto-edit': Automatically applies edits without confirmation - * - 'yolo': Executes all operations without prompting + * Permission mode controlling how the SDK handles tool execution approval. + * + * - '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. + * Read-only tools execute normally. + * - 'auto-edit': Auto-approve edit tools (edit, write_file) while other tools require confirmation. + * - 'yolo': All tools execute automatically without confirmation. + * + * **Priority Chain (highest to lowest):** + * 1. `excludeTools` - Blocks tools completely (returns permission error) + * 2. `permissionMode: 'plan'` - Blocks non-read-only tools (except exit_plan_mode) + * 3. `permissionMode: 'yolo'` - Auto-approves all tools + * 4. `allowedTools` - Auto-approves matching tools + * 5. `canUseTool` callback - Custom approval logic + * 6. Default behavior - Auto-deny in SDK mode + * * @default 'default' + * @see canUseTool For custom permission handling + * @see allowedTools For auto-approving specific tools + * @see excludeTools For blocking specific tools */ permissionMode?: 'default' | 'plan' | 'auto-edit' | 'yolo'; /** - * Custom permission handler for tool usage. - * This function is called when the SDK needs to determine if a tool should be allowed. - * Use this with `permissionMode` to gain more control over the tool usage. - * TODO: For now we don't support modifying the input. + * Custom permission handler for tool execution approval. + * + * This callback is invoked when a tool requires confirmation and allows you to + * programmatically approve or deny execution. It acts as a fallback after + * `allowedTools` check but before default denial. + * + * **When is this called?** + * - Only for tools requiring confirmation (write operations, shell commands, etc.) + * - After `excludeTools` and `allowedTools` checks + * - Not called in 'yolo' mode or 'plan' mode + * - Not called for tools already in `allowedTools` + * + * **Usage with permissionMode:** + * - 'default': Invoked for all write tools not in `allowedTools`; if not provided, auto-denied. + * - 'auto-edit': Invoked for non-edit tools (edit/write_file auto-approved); if not provided, auto-denied. + * - 'plan': Not invoked; write tools are blocked by plan mode. + * - 'yolo': Not invoked; all tools auto-approved. + * + * @see allowedTools For auto-approving tools without callback */ canUseTool?: CanUseTool; @@ -197,11 +226,49 @@ export interface QueryOptions { /** * Equivalent to `tool.exclude` in settings.json. * List of tools to exclude from the session. - * These tools will not be available to the AI, even if they are core tools. - * @example ['run_terminal_cmd', 'delete_file'] + * + * **Behavior:** + * - Excluded tools return a permission error immediately when invoked + * - Takes highest priority - overrides all other permission settings + * - Tools will not be available to the AI, even if in `coreTools` or `allowedTools` + * + * **Pattern matching:** + * - Tool name: `'write_file'`, `'run_shell_command'` + * - Tool class: `'WriteTool'`, `'ShellTool'` + * - Shell command prefix: `'ShellTool(git commit)'` (matches commands starting with "git commit") + * + * @example ['run_terminal_cmd', 'delete_file', 'ShellTool(rm )'] + * @see allowedTools For allowing specific tools */ excludeTools?: string[]; + /** + * Equivalent to `tool.allowed` in settings.json. + * List of tools that are allowed to run without confirmation. + * + * **Behavior:** + * - Matching tools bypass `canUseTool` callback and execute automatically + * - Only applies when tool requires confirmation (write operations, shell commands) + * - Checked after `excludeTools` but before `canUseTool` callback + * - Does not override `permissionMode: 'plan'` (plan mode blocks all write tools) + * - Has no effect in `permissionMode: 'yolo'` (already auto-approved) + * + * **Pattern matching:** + * - Tool name: `'write_file'`, `'run_shell_command'` + * - Tool class: `'WriteTool'`, `'ShellTool'` + * - Shell command prefix: `'ShellTool(git status)'` (matches commands starting with "git status") + * + * **Use cases:** + * - Auto-approve safe shell commands: `['ShellTool(git status)', 'ShellTool(ls)']` + * - Auto-approve specific tools: `['write_file', 'edit']` + * - Combine with `permissionMode: 'default'` to selectively auto-approve tools + * + * @example ['read_file', 'ShellTool(git status)', 'ShellTool(npm test)'] + * @see canUseTool For custom approval logic + * @see excludeTools For blocking specific tools + */ + allowedTools?: string[]; + /** * Authentication type for the AI service. * - 'openai': Use OpenAI-compatible authentication diff --git a/packages/sdk-typescript/test/e2e/permission-control.test.ts b/packages/sdk-typescript/test/e2e/permission-control.test.ts index afcef8b176..157706087c 100644 --- a/packages/sdk-typescript/test/e2e/permission-control.test.ts +++ b/packages/sdk-typescript/test/e2e/permission-control.test.ts @@ -673,4 +673,640 @@ describe('Permission Control (E2E)', () => { } }); }); + + describe('ApprovalMode behavior tests', () => { + describe('default mode', () => { + it( + 'should auto-deny tools requiring confirmation without canUseTool callback', + async () => { + const q = query({ + prompt: + 'Create a file named test-default-deny.txt with content "hello"', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: '/tmp', + // No canUseTool callback provided + }, + }); + + try { + let hasToolResult = false; + let hasErrorInResult = false; + + for await (const message of q) { + if (isCLIUserMessage(message)) { + if (Array.isArray(message.message.content)) { + const toolResult = message.message.content.find( + (block) => block.type === 'tool_result', + ); + if (toolResult && 'tool_use_id' in toolResult) { + hasToolResult = true; + // Check if the result contains an error about permission + if ( + 'content' in toolResult && + typeof toolResult.content === 'string' && + (toolResult.content.includes('permission') || + toolResult.content.includes('declined')) + ) { + hasErrorInResult = true; + } + } + } + } + } + + // In default mode without canUseTool, tools should be denied + expect(hasToolResult).toBe(true); + expect(hasErrorInResult).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should allow tools when canUseTool returns allow', + async () => { + let callbackInvoked = false; + + const q = query({ + prompt: + 'Create a file named test-default-allow.txt with content "world"', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: '/tmp', + canUseTool: async (toolName, input) => { + callbackInvoked = true; + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + let hasSuccessfulToolResult = false; + + for await (const message of q) { + if (isCLIUserMessage(message)) { + if (Array.isArray(message.message.content)) { + const toolResult = message.message.content.find( + (block) => block.type === 'tool_result', + ); + if (toolResult && 'tool_use_id' in toolResult) { + // Check if the result is successful (not an error) + if ( + 'content' in toolResult && + typeof toolResult.content === 'string' && + !toolResult.content.includes('permission') && + !toolResult.content.includes('declined') + ) { + hasSuccessfulToolResult = true; + } + } + } + } + } + + expect(callbackInvoked).toBe(true); + expect(hasSuccessfulToolResult).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should execute read-only tools without confirmation', + async () => { + const q = query({ + prompt: 'List files in the current directory', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: '/tmp', + // No canUseTool callback - read-only tools should still work + }, + }); + + try { + let hasToolResult = false; + + for await (const message of q) { + if (isCLIUserMessage(message)) { + if (Array.isArray(message.message.content)) { + const toolResult = message.message.content.find( + (block) => block.type === 'tool_result', + ); + if (toolResult) { + hasToolResult = true; + } + } + } + } + + expect(hasToolResult).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('yolo mode', () => { + it( + 'should auto-approve all tools without canUseTool callback', + async () => { + const q = query({ + prompt: + 'Create a file named test-yolo.txt with content "yolo mode"', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'yolo', + cwd: '/tmp', + // No canUseTool callback - tools should still execute + }, + }); + + try { + let hasSuccessfulToolResult = false; + + for await (const message of q) { + if (isCLIUserMessage(message)) { + if (Array.isArray(message.message.content)) { + const toolResult = message.message.content.find( + (block) => block.type === 'tool_result', + ); + if (toolResult && 'tool_use_id' in toolResult) { + // Check if the result is successful (not a permission error) + if ( + 'content' in toolResult && + typeof toolResult.content === 'string' && + !toolResult.content.includes('permission') && + !toolResult.content.includes('declined') + ) { + hasSuccessfulToolResult = true; + } + } + } + } + } + + expect(hasSuccessfulToolResult).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should not invoke canUseTool callback in yolo mode', + async () => { + let callbackInvoked = false; + + const q = query({ + prompt: 'Create a file named test-yolo-no-callback.txt', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'yolo', + cwd: '/tmp', + canUseTool: async (toolName, input) => { + callbackInvoked = true; + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + let hasToolResult = false; + + for await (const message of q) { + if (isCLIUserMessage(message)) { + if (Array.isArray(message.message.content)) { + const toolResult = message.message.content.find( + (block) => block.type === 'tool_result', + ); + if (toolResult) { + hasToolResult = true; + } + } + } + } + + expect(hasToolResult).toBe(true); + // canUseTool should not be invoked in yolo mode + expect(callbackInvoked).toBe(false); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should execute dangerous commands without confirmation', + async () => { + const q = query({ + prompt: 'Run command: echo "dangerous operation"', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'yolo', + cwd: '/tmp', + }, + }); + + try { + let hasCommandResult = false; + + for await (const message of q) { + if (isCLIUserMessage(message)) { + if (Array.isArray(message.message.content)) { + const toolResult = message.message.content.find( + (block) => block.type === 'tool_result', + ); + if (toolResult && 'tool_use_id' in toolResult) { + hasCommandResult = true; + } + } + } + } + + expect(hasCommandResult).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('plan mode', () => { + it( + 'should block non-read-only tools and return plan mode error', + async () => { + const q = query({ + prompt: 'Create a file named test-plan.txt', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'plan', + cwd: '/tmp', + }, + }); + + try { + let hasBlockedToolCall = false; + let hasPlanModeMessage = false; + + for await (const message of q) { + if (isCLIUserMessage(message)) { + if (Array.isArray(message.message.content)) { + const toolResult = message.message.content.find( + (block) => block.type === 'tool_result', + ); + if (toolResult && 'tool_use_id' in toolResult) { + hasBlockedToolCall = true; + // Check for plan mode specific error message + if ( + 'content' in toolResult && + typeof toolResult.content === 'string' && + (toolResult.content.includes('Plan mode') || + toolResult.content.includes('plan mode')) + ) { + hasPlanModeMessage = true; + } + } + } + } + } + + expect(hasBlockedToolCall).toBe(true); + expect(hasPlanModeMessage).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should allow read-only tools in plan mode', + async () => { + const q = query({ + prompt: 'List files in /tmp directory', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'plan', + cwd: '/tmp', + }, + }); + + try { + let hasSuccessfulToolResult = false; + + for await (const message of q) { + if (isCLIUserMessage(message)) { + if (Array.isArray(message.message.content)) { + const toolResult = message.message.content.find( + (block) => block.type === 'tool_result', + ); + if (toolResult && 'tool_use_id' in toolResult) { + // Check if the result is successful (not blocked by plan mode) + if ( + 'content' in toolResult && + typeof toolResult.content === 'string' && + !toolResult.content.includes('Plan mode') + ) { + hasSuccessfulToolResult = true; + } + } + } + } + } + + expect(hasSuccessfulToolResult).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should block tools even with canUseTool callback in plan mode', + async () => { + let callbackInvoked = false; + + const q = query({ + prompt: 'Create a file named test-plan-callback.txt', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'plan', + cwd: '/tmp', + canUseTool: async (toolName, input) => { + callbackInvoked = true; + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + let hasPlanModeBlock = false; + + for await (const message of q) { + if (isCLIUserMessage(message)) { + if (Array.isArray(message.message.content)) { + const toolResult = message.message.content.find( + (block) => block.type === 'tool_result', + ); + if ( + toolResult && + 'content' in toolResult && + typeof toolResult.content === 'string' && + toolResult.content.includes('Plan mode') + ) { + hasPlanModeBlock = true; + } + } + } + } + + // Plan mode should block tools before canUseTool is invoked + expect(hasPlanModeBlock).toBe(true); + // canUseTool should not be invoked for blocked tools in plan mode + expect(callbackInvoked).toBe(false); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('auto-edit mode', () => { + it( + 'should behave like default mode without canUseTool callback', + async () => { + const q = query({ + prompt: 'Create a file named test-auto-edit.txt', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'auto-edit', + cwd: '/tmp', + // No canUseTool callback + }, + }); + + try { + let hasToolResult = false; + let hasDeniedTool = false; + + for await (const message of q) { + if (isCLIUserMessage(message)) { + if (Array.isArray(message.message.content)) { + const toolResult = message.message.content.find( + (block) => block.type === 'tool_result', + ); + if (toolResult && 'tool_use_id' in toolResult) { + hasToolResult = true; + // Check if the tool was denied + if ( + 'content' in toolResult && + typeof toolResult.content === 'string' && + (toolResult.content.includes('permission') || + toolResult.content.includes('declined')) + ) { + hasDeniedTool = true; + } + } + } + } + } + + expect(hasToolResult).toBe(true); + expect(hasDeniedTool).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should allow tools when canUseTool returns allow', + async () => { + let callbackInvoked = false; + + const q = query({ + prompt: 'Create a file named test-auto-edit-allow.txt', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'auto-edit', + cwd: '/tmp', + canUseTool: async (toolName, input) => { + callbackInvoked = true; + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + let hasSuccessfulToolResult = false; + + for await (const message of q) { + if (isCLIUserMessage(message)) { + if (Array.isArray(message.message.content)) { + const toolResult = message.message.content.find( + (block) => block.type === 'tool_result', + ); + if (toolResult && 'tool_use_id' in toolResult) { + // Check if the result is successful + if ( + 'content' in toolResult && + typeof toolResult.content === 'string' && + !toolResult.content.includes('permission') && + !toolResult.content.includes('declined') + ) { + hasSuccessfulToolResult = true; + } + } + } + } + } + + expect(callbackInvoked).toBe(true); + expect(hasSuccessfulToolResult).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should execute read-only tools without confirmation', + async () => { + const q = query({ + prompt: 'Read the contents of /etc/hosts file', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'auto-edit', + // No canUseTool callback - read-only tools should still work + }, + }); + + try { + let hasToolResult = false; + + for await (const message of q) { + if (isCLIUserMessage(message)) { + if (Array.isArray(message.message.content)) { + const toolResult = message.message.content.find( + (block) => block.type === 'tool_result', + ); + if (toolResult) { + hasToolResult = true; + } + } + } + } + + expect(hasToolResult).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('mode comparison tests', () => { + it( + 'should demonstrate different behaviors across all modes for write operations', + async () => { + const modes: Array<'default' | 'plan' | 'auto-edit' | 'yolo'> = [ + 'default', + 'plan', + 'auto-edit', + 'yolo', + ]; + const results: Record = {}; + + for (const mode of modes) { + const q = query({ + prompt: `Create a file named test-${mode}.txt`, + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: mode, + cwd: '/tmp', + canUseTool: + mode === 'yolo' + ? undefined + : async (toolName, input) => { + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + let toolExecuted = false; + + for await (const message of q) { + if (isCLIUserMessage(message)) { + if (Array.isArray(message.message.content)) { + const toolResult = message.message.content.find( + (block) => block.type === 'tool_result', + ); + if ( + toolResult && + 'content' in toolResult && + typeof toolResult.content === 'string' + ) { + // Check if tool executed successfully (not blocked or denied) + if ( + !toolResult.content.includes('Plan mode') && + !toolResult.content.includes('permission') && + !toolResult.content.includes('declined') + ) { + toolExecuted = true; + } + } + } + } + } + + results[mode] = toolExecuted; + } finally { + await q.close(); + } + } + + // Verify expected behaviors + expect(results['default']).toBe(true); // Allowed via canUseTool + expect(results['plan']).toBe(false); // Blocked by plan mode + expect(results['auto-edit']).toBe(true); // Allowed via canUseTool + expect(results['yolo']).toBe(true); // Auto-approved + }, + TEST_TIMEOUT * 4, + ); + }); + }); }); diff --git a/packages/sdk-typescript/test/e2e/control.test.ts b/packages/sdk-typescript/test/e2e/system-control.test.ts similarity index 57% rename from packages/sdk-typescript/test/e2e/control.test.ts rename to packages/sdk-typescript/test/e2e/system-control.test.ts index ea7ecef71d..373f88e787 100644 --- a/packages/sdk-typescript/test/e2e/control.test.ts +++ b/packages/sdk-typescript/test/e2e/system-control.test.ts @@ -1,8 +1,12 @@ +/** + * E2E tests for system controller features: + * - setModel API for dynamic model switching + */ + import { describe, it, expect } from 'vitest'; import { query } from '../../src/index.js'; import { isCLIAssistantMessage, - isCLIResultMessage, isCLISystemMessage, type CLIUserMessage, } from '../../src/types/protocol.js'; @@ -16,7 +20,7 @@ const SHARED_TEST_OPTIONS = { /** * Factory function that creates a streaming input with a control point. * After the first message is yielded, the generator waits for a resume signal, - * allowing the test code to call query instance methods like setModel or setPermissionMode. + * allowing the test code to call query instance methods like setModel. * * @param firstMessage - The first user message to send * @param secondMessage - The second user message to send after control operations @@ -73,9 +77,9 @@ function createStreamingInputWithControlPoint( return { generator, resume }; } -describe('Control Request/Response (E2E)', () => { - describe('System Controller Scope', () => { - it('should set model via control request during streaming input', async () => { +describe('System Control (E2E)', () => { + describe('setModel API', () => { + it('should change model dynamically during streaming input', async () => { const { generator, resume } = createStreamingInputWithControlPoint( 'Tell me the model name.', 'Tell me the model name now again.', @@ -164,50 +168,77 @@ describe('Control Request/Response (E2E)', () => { await q.close(); } }); - }); - describe('Permission Controller Scope', () => { - it('should set permission mode via control request during streaming input', async () => { - const { generator, resume } = createStreamingInputWithControlPoint( - 'What is 1 + 1?', - 'What is 2 + 2?', - ); + it('should handle multiple model changes in sequence', async () => { + const sessionId = crypto.randomUUID(); + let resumeResolve1: (() => void) | null = null; + let resumeResolve2: (() => void) | null = null; + const resumePromise1 = new Promise((resolve) => { + resumeResolve1 = resolve; + }); + const resumePromise2 = new Promise((resolve) => { + resumeResolve2 = resolve; + }); + + const generator = (async function* () { + yield { + type: 'user', + session_id: sessionId, + message: { role: 'user', content: 'First message' }, + parent_tool_use_id: null, + } as CLIUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 200)); + await resumePromise1; + await new Promise((resolve) => setTimeout(resolve, 200)); + + yield { + type: 'user', + session_id: sessionId, + message: { role: 'user', content: 'Second message' }, + parent_tool_use_id: null, + } as CLIUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 200)); + await resumePromise2; + await new Promise((resolve) => setTimeout(resolve, 200)); + + yield { + type: 'user', + session_id: sessionId, + message: { role: 'user', content: 'Third message' }, + parent_tool_use_id: null, + } as CLIUserMessage; + })(); const q = query({ prompt: generator, options: { - pathToQwenExecutable: TEST_CLI_PATH, - permissionMode: 'default', + ...SHARED_TEST_OPTIONS, + model: 'qwen3-max', debug: false, }, }); try { - const resolvers: { - first?: () => void; - second?: () => void; - } = {}; - const firstResponsePromise = new Promise((resolve) => { - resolvers.first = resolve; - }); - const secondResponsePromise = new Promise((resolve) => { - resolvers.second = resolve; - }); - - let firstResponseReceived = false; - let permissionModeChanged = false; - let secondResponseReceived = false; + const systemMessages: Array<{ model?: string }> = []; + let responseCount = 0; + const resolvers: Array<() => void> = []; + const responsePromises = [ + new Promise((resolve) => resolvers.push(resolve)), + new Promise((resolve) => resolvers.push(resolve)), + new Promise((resolve) => resolvers.push(resolve)), + ]; - // Consume messages in a single loop (async () => { for await (const message of q) { - if (isCLIAssistantMessage(message) || isCLIResultMessage(message)) { - if (!firstResponseReceived) { - firstResponseReceived = true; - resolvers.first?.(); - } else if (!secondResponseReceived) { - secondResponseReceived = true; - resolvers.second?.(); + if (isCLISystemMessage(message)) { + systemMessages.push({ model: message.model }); + } + if (isCLIAssistantMessage(message)) { + if (responseCount < resolvers.length) { + resolvers[responseCount]?.(); + responseCount++; } } } @@ -215,40 +246,60 @@ describe('Control Request/Response (E2E)', () => { // Wait for first response await Promise.race([ - firstResponsePromise, + responsePromises[0], new Promise((_, reject) => - setTimeout( - () => reject(new Error('Timeout waiting for first response')), - 10000, - ), + setTimeout(() => reject(new Error('Timeout 1')), 10000), ), ]); - expect(firstResponseReceived).toBe(true); + // First model change + await q.setModel('qwen3-turbo'); + resumeResolve1?.(); - // Perform control operation: set permission mode - await q.setPermissionMode('yolo'); - permissionModeChanged = true; + // Wait for second response + await Promise.race([ + responsePromises[1], + new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout 2')), 10000), + ), + ]); - // Resume the input stream - resume(); + // Second model change + await q.setModel('qwen3-vl-plus'); + resumeResolve2?.(); - // Wait for second response + // Wait for third response await Promise.race([ - secondResponsePromise, + responsePromises[2], new Promise((_, reject) => - setTimeout( - () => reject(new Error('Timeout waiting for second response')), - 10000, - ), + setTimeout(() => reject(new Error('Timeout 3')), 10000), ), ]); - expect(permissionModeChanged).toBe(true); - expect(secondResponseReceived).toBe(true); + // Verify we received system messages for each model + expect(systemMessages.length).toBeGreaterThanOrEqual(3); + expect(systemMessages[0].model).toBeOneOf(['qwen3-max', 'coder-model']); + expect(systemMessages[1].model).toBe('qwen3-turbo'); + expect(systemMessages[2].model).toBe('qwen3-vl-plus'); } finally { await q.close(); } }); + + it('should throw error when setModel is called on closed query', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + model: 'qwen3-max', + }, + }); + + await q.close(); + + await expect(q.setModel('qwen3-turbo')).rejects.toThrow( + 'Query is closed', + ); + }); }); }); From 56957a687b36968b78f247c295621d5ad3f6e81b Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Thu, 27 Nov 2025 14:50:40 +0800 Subject: [PATCH 09/22] refactor: rename ambiguous exported types --- eslint.config.js | 1 + packages/sdk-typescript/src/index.ts | 22 +- packages/sdk-typescript/src/query/Query.ts | 46 +- .../sdk-typescript/src/query/createQuery.ts | 10 +- packages/sdk-typescript/src/types/protocol.ts | 42 +- .../sdk-typescript/test/e2e/globalSetup.ts | 9 +- .../test/e2e/mcp-server.test.ts | 323 ++----- .../test/e2e/multi-turn.test.ts | 108 +-- .../test/e2e/permission-control.test.ts | 52 +- .../test/e2e/single-turn.test.ts | 116 ++- .../sdk-typescript/test/e2e/subagents.test.ts | 182 ++-- .../test/e2e/system-control.test.ts | 26 +- .../sdk-typescript/test/e2e/test-helper.ts | 829 ++++++++++++++++++ .../sdk-typescript/test/unit/Query.test.ts | 38 +- 14 files changed, 1189 insertions(+), 615 deletions(-) create mode 100644 packages/sdk-typescript/test/e2e/test-helper.ts diff --git a/eslint.config.js b/eslint.config.js index e477d95fd5..13a3d1c36d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -22,6 +22,7 @@ export default tseslint.config( 'bundle/**', 'package/bundle/**', '.integration-tests/**', + 'packages/**/.integration-test/**', 'dist/**', ], }, diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 5992c6c5cc..f8bf81c5a1 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -12,20 +12,20 @@ export type { ThinkingBlock, ToolUseBlock, ToolResultBlock, - CLIUserMessage, - CLIAssistantMessage, - CLISystemMessage, - CLIResultMessage, - CLIPartialAssistantMessage, - CLIMessage, + SDKUserMessage, + SDKAssistantMessage, + SDKSystemMessage, + SDKResultMessage, + SDKPartialAssistantMessage, + SDKMessage, } from './types/protocol.js'; export { - isCLIUserMessage, - isCLIAssistantMessage, - isCLISystemMessage, - isCLIResultMessage, - isCLIPartialAssistantMessage, + isSDKUserMessage, + isSDKAssistantMessage, + isSDKSystemMessage, + isSDKResultMessage, + isSDKPartialAssistantMessage, } from './types/protocol.js'; export type { diff --git a/packages/sdk-typescript/src/query/Query.ts b/packages/sdk-typescript/src/query/Query.ts index de4c48525d..d34d6fa4e1 100644 --- a/packages/sdk-typescript/src/query/Query.ts +++ b/packages/sdk-typescript/src/query/Query.ts @@ -13,19 +13,19 @@ const STREAM_CLOSE_TIMEOUT = 10000; import { randomUUID } from 'node:crypto'; import { SdkLogger } from '../utils/logger.js'; import type { - CLIMessage, - CLIUserMessage, + SDKMessage, + SDKUserMessage, CLIControlRequest, CLIControlResponse, ControlCancelRequest, PermissionSuggestion, } from '../types/protocol.js'; import { - isCLIUserMessage, - isCLIAssistantMessage, - isCLISystemMessage, - isCLIResultMessage, - isCLIPartialAssistantMessage, + isSDKUserMessage, + isSDKAssistantMessage, + isSDKSystemMessage, + isSDKResultMessage, + isSDKPartialAssistantMessage, isControlRequest, isControlResponse, isControlCancel, @@ -52,12 +52,12 @@ interface TransportWithEndInput extends Transport { const logger = SdkLogger.createLogger('Query'); -export class Query implements AsyncIterable { +export class Query implements AsyncIterable { private transport: Transport; private options: QueryOptions; private sessionId: string; - private inputStream: Stream; - private sdkMessages: AsyncGenerator; + private inputStream: Stream; + private sdkMessages: AsyncGenerator; private abortController: AbortController; private pendingControlRequests: Map = new Map(); @@ -79,7 +79,7 @@ export class Query implements AsyncIterable { this.transport = transport; this.options = options; this.sessionId = randomUUID(); - this.inputStream = new Stream(); + this.inputStream = new Stream(); this.abortController = options.abortController ?? new AbortController(); this.isSingleTurn = singleTurn; @@ -187,7 +187,7 @@ export class Query implements AsyncIterable { return; } - if (isCLISystemMessage(message)) { + if (isSDKSystemMessage(message)) { /** * SystemMessage contains session info (cwd, tools, model, etc.) * that should be passed to user. @@ -196,7 +196,7 @@ export class Query implements AsyncIterable { return; } - if (isCLIResultMessage(message)) { + if (isSDKResultMessage(message)) { if (this.firstResultReceivedResolve) { this.firstResultReceivedResolve(); } @@ -212,16 +212,16 @@ export class Query implements AsyncIterable { } if ( - isCLIAssistantMessage(message) || - isCLIUserMessage(message) || - isCLIPartialAssistantMessage(message) + isSDKAssistantMessage(message) || + isSDKUserMessage(message) || + isSDKPartialAssistantMessage(message) ) { this.inputStream.enqueue(message); return; } logger.warn('Unknown message type:', message); - this.inputStream.enqueue(message as CLIMessage); + this.inputStream.enqueue(message as SDKMessage); } private async handleControlRequest( @@ -560,29 +560,29 @@ export class Query implements AsyncIterable { logger.info('Query closed'); } - private async *readSdkMessages(): AsyncGenerator { + private async *readSdkMessages(): AsyncGenerator { for await (const message of this.inputStream) { yield message; } } - async next(...args: [] | [unknown]): Promise> { + async next(...args: [] | [unknown]): Promise> { return this.sdkMessages.next(...args); } - async return(value?: unknown): Promise> { + async return(value?: unknown): Promise> { return this.sdkMessages.return(value); } - async throw(e?: unknown): Promise> { + async throw(e?: unknown): Promise> { return this.sdkMessages.throw(e); } - [Symbol.asyncIterator](): AsyncIterator { + [Symbol.asyncIterator](): AsyncIterator { return this.sdkMessages; } - async streamInput(messages: AsyncIterable): Promise { + async streamInput(messages: AsyncIterable): Promise { if (this.closed) { throw new Error('Query is closed'); } diff --git a/packages/sdk-typescript/src/query/createQuery.ts b/packages/sdk-typescript/src/query/createQuery.ts index 71fd6e9b4e..2b39dafacd 100644 --- a/packages/sdk-typescript/src/query/createQuery.ts +++ b/packages/sdk-typescript/src/query/createQuery.ts @@ -2,7 +2,7 @@ * Factory function for creating Query instances. */ -import type { CLIUserMessage } from '../types/protocol.js'; +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'; @@ -22,11 +22,11 @@ export function query({ /** * The prompt to send to the Qwen Code CLI process. * - `string` for single-turn query, - * - `AsyncIterable` for multi-turn query. + * - `AsyncIterable` for multi-turn query. * * The transport will remain open until the prompt is done. */ - prompt: string | AsyncIterable; + prompt: string | AsyncIterable; /** * Configuration options for the query. */ @@ -67,7 +67,7 @@ export function query({ if (isSingleTurn) { const stringPrompt = prompt as string; - const message: CLIUserMessage = { + const message: SDKUserMessage = { type: 'user', session_id: queryInstance.getSessionId(), message: { @@ -87,7 +87,7 @@ export function query({ })(); } else { queryInstance - .streamInput(prompt as AsyncIterable) + .streamInput(prompt as AsyncIterable) .catch((err) => { logger.error('Error streaming input:', err); }); diff --git a/packages/sdk-typescript/src/types/protocol.ts b/packages/sdk-typescript/src/types/protocol.ts index 2f1f9fe987..6db627e399 100644 --- a/packages/sdk-typescript/src/types/protocol.ts +++ b/packages/sdk-typescript/src/types/protocol.ts @@ -89,7 +89,7 @@ export interface APIAssistantMessage { usage: Usage; } -export interface CLIUserMessage { +export interface SDKUserMessage { type: 'user'; uuid?: string; session_id: string; @@ -98,7 +98,7 @@ export interface CLIUserMessage { options?: Record; } -export interface CLIAssistantMessage { +export interface SDKAssistantMessage { type: 'assistant'; uuid: string; session_id: string; @@ -106,7 +106,7 @@ export interface CLIAssistantMessage { parent_tool_use_id: string | null; } -export interface CLISystemMessage { +export interface SDKSystemMessage { type: 'system'; subtype: string; uuid: string; @@ -133,7 +133,7 @@ export interface CLISystemMessage { }; } -export interface CLIResultMessageSuccess { +export interface SDKResultMessageSuccess { type: 'result'; subtype: 'success'; uuid: string; @@ -149,7 +149,7 @@ export interface CLIResultMessageSuccess { [key: string]: unknown; } -export interface CLIResultMessageError { +export interface SDKResultMessageError { type: 'result'; subtype: 'error_max_turns' | 'error_during_execution'; uuid: string; @@ -169,7 +169,7 @@ export interface CLIResultMessageError { [key: string]: unknown; } -export type CLIResultMessage = CLIResultMessageSuccess | CLIResultMessageError; +export type SDKResultMessage = SDKResultMessageSuccess | SDKResultMessageError; export interface MessageStartStreamEvent { type: 'message_start'; @@ -222,7 +222,7 @@ export type StreamEvent = | ContentBlockStopEvent | MessageStopStreamEvent; -export interface CLIPartialAssistantMessage { +export interface SDKPartialAssistantMessage { type: 'stream_event'; uuid: string; session_id: string; @@ -389,22 +389,22 @@ export type ControlMessage = | ControlCancelRequest; /** - * Union of all CLI message types + * Union of all SDK message types */ -export type CLIMessage = - | CLIUserMessage - | CLIAssistantMessage - | CLISystemMessage - | CLIResultMessage - | CLIPartialAssistantMessage; - -export function isCLIUserMessage(msg: any): msg is CLIUserMessage { +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 isCLIAssistantMessage(msg: any): msg is CLIAssistantMessage { +export function isSDKAssistantMessage(msg: any): msg is SDKAssistantMessage { return ( msg && typeof msg === 'object' && @@ -416,7 +416,7 @@ export function isCLIAssistantMessage(msg: any): msg is CLIAssistantMessage { ); } -export function isCLISystemMessage(msg: any): msg is CLISystemMessage { +export function isSDKSystemMessage(msg: any): msg is SDKSystemMessage { return ( msg && typeof msg === 'object' && @@ -427,7 +427,7 @@ export function isCLISystemMessage(msg: any): msg is CLISystemMessage { ); } -export function isCLIResultMessage(msg: any): msg is CLIResultMessage { +export function isSDKResultMessage(msg: any): msg is SDKResultMessage { return ( msg && typeof msg === 'object' && @@ -440,9 +440,9 @@ export function isCLIResultMessage(msg: any): msg is CLIResultMessage { ); } -export function isCLIPartialAssistantMessage( +export function isSDKPartialAssistantMessage( msg: any, -): msg is CLIPartialAssistantMessage { +): msg is SDKPartialAssistantMessage { return ( msg && typeof msg === 'object' && diff --git a/packages/sdk-typescript/test/e2e/globalSetup.ts b/packages/sdk-typescript/test/e2e/globalSetup.ts index 44e3e5287b..4f98b87739 100644 --- a/packages/sdk-typescript/test/e2e/globalSetup.ts +++ b/packages/sdk-typescript/test/e2e/globalSetup.ts @@ -14,14 +14,15 @@ const e2eTestsDir = join(rootDir, '.integration-tests'); let runDir = ''; export async function setup() { - runDir = join(e2eTestsDir, `${Date.now()}`); + runDir = join(e2eTestsDir, `sdk-e2e-${Date.now()}`); await mkdir(runDir, { recursive: true }); // Clean up old test runs, but keep the latest few for debugging try { const testRuns = await readdir(e2eTestsDir); - if (testRuns.length > 5) { - const oldRuns = testRuns.sort().slice(0, testRuns.length - 5); + const sdkTestRuns = testRuns.filter((run) => run.startsWith('sdk-e2e-')); + if (sdkTestRuns.length > 5) { + const oldRuns = sdkTestRuns.sort().slice(0, sdkTestRuns.length - 5); await Promise.all( oldRuns.map((oldRun) => rm(join(e2eTestsDir, oldRun), { @@ -44,7 +45,7 @@ export async function setup() { } process.env['VERBOSE'] = process.env['VERBOSE'] ?? 'false'; - console.log(`\nE2E test output directory: ${runDir}`); + console.log(`\nSDK E2E test output directory: ${runDir}`); console.log(`CLI path: ${process.env['TEST_CLI_PATH']}`); } diff --git a/packages/sdk-typescript/test/e2e/mcp-server.test.ts b/packages/sdk-typescript/test/e2e/mcp-server.test.ts index 6bb0f965c6..868fb95941 100644 --- a/packages/sdk-typescript/test/e2e/mcp-server.test.ts +++ b/packages/sdk-typescript/test/e2e/mcp-server.test.ts @@ -9,234 +9,48 @@ * Tests that the SDK can properly interact with MCP servers configured in qwen-code */ -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { query } from '../../src/index.js'; import { - isCLIAssistantMessage, - isCLIResultMessage, - isCLISystemMessage, - isCLIUserMessage, - type TextBlock, - type ContentBlock, - type CLIMessage, + isSDKAssistantMessage, + isSDKResultMessage, + isSDKSystemMessage, + isSDKUserMessage, + type SDKMessage, type ToolUseBlock, - type CLISystemMessage, + type SDKSystemMessage, } from '../../src/types/protocol.js'; -import { writeFileSync, mkdirSync, chmodSync } from 'node:fs'; -import { join } from 'node:path'; - -const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!; -const E2E_TEST_FILE_DIR = process.env['E2E_TEST_FILE_DIR']!; +import { + SDKTestHelper, + createMCPServer, + extractText, + findToolUseBlocks, + createSharedTestOptions, +} from './test-helper.js'; const SHARED_TEST_OPTIONS = { - pathToQwenExecutable: TEST_CLI_PATH, + ...createSharedTestOptions(), permissionMode: 'yolo' as const, }; -/** - * Helper to extract text from ContentBlock array - */ -function extractText(content: ContentBlock[]): string { - return content - .filter((block): block is TextBlock => block.type === 'text') - .map((block) => block.text) - .join(''); -} - -/** - * Minimal MCP server implementation that doesn't require external dependencies - * This implements the MCP protocol directly using Node.js built-ins - */ -const MCP_SERVER_SCRIPT = `#!/usr/bin/env node -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -const readline = require('readline'); -const fs = require('fs'); - -// Debug logging to stderr (only when MCP_DEBUG or VERBOSE is set) -const debugEnabled = process.env['MCP_DEBUG'] === 'true' || process.env['VERBOSE'] === 'true'; -function debug(msg) { - if (debugEnabled) { - fs.writeSync(2, \`[MCP-DEBUG] \${msg}\\n\`); - } -} - -debug('MCP server starting...'); - -// Simple JSON-RPC implementation for MCP -class SimpleJSONRPC { - constructor() { - this.handlers = new Map(); - this.rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - terminal: false - }); - - this.rl.on('line', (line) => { - debug(\`Received line: \${line}\`); - try { - const message = JSON.parse(line); - debug(\`Parsed message: \${JSON.stringify(message)}\`); - this.handleMessage(message); - } catch (e) { - debug(\`Parse error: \${e.message}\`); - } - }); - } - - send(message) { - const msgStr = JSON.stringify(message); - debug(\`Sending message: \${msgStr}\`); - process.stdout.write(msgStr + '\\n'); - } - - async handleMessage(message) { - if (message.method && this.handlers.has(message.method)) { - try { - const result = await this.handlers.get(message.method)(message.params || {}); - if (message.id !== undefined) { - this.send({ - jsonrpc: '2.0', - id: message.id, - result - }); - } - } catch (error) { - if (message.id !== undefined) { - this.send({ - jsonrpc: '2.0', - id: message.id, - error: { - code: -32603, - message: error.message - } - }); - } - } - } else if (message.id !== undefined) { - this.send({ - jsonrpc: '2.0', - id: message.id, - error: { - code: -32601, - message: 'Method not found' - } - }); - } - } - - on(method, handler) { - this.handlers.set(method, handler); - } -} - -// Create MCP server -const rpc = new SimpleJSONRPC(); - -// Handle initialize -rpc.on('initialize', async (params) => { - debug('Handling initialize request'); - return { - protocolVersion: '2024-11-05', - capabilities: { - tools: {} - }, - serverInfo: { - name: 'test-math-server', - version: '1.0.0' - } - }; -}); - -// Handle tools/list -rpc.on('tools/list', async () => { - debug('Handling tools/list request'); - return { - tools: [ - { - name: 'add', - description: 'Add two numbers together', - inputSchema: { - type: 'object', - properties: { - a: { type: 'number', description: 'First number' }, - b: { type: 'number', description: 'Second number' } - }, - required: ['a', 'b'] - } - }, - { - name: 'multiply', - description: 'Multiply two numbers together', - inputSchema: { - type: 'object', - properties: { - a: { type: 'number', description: 'First number' }, - b: { type: 'number', description: 'Second number' } - }, - required: ['a', 'b'] - } - } - ] - }; -}); - -// Handle tools/call -rpc.on('tools/call', async (params) => { - debug(\`Handling tools/call request for tool: \${params.name}\`); - - if (params.name === 'add') { - const { a, b } = params.arguments; - return { - content: [{ - type: 'text', - text: String(a + b) - }] - }; - } - - if (params.name === 'multiply') { - const { a, b } = params.arguments; - return { - content: [{ - type: 'text', - text: String(a * b) - }] - }; - } - - throw new Error('Unknown tool: ' + params.name); -}); - -// Send initialization notification -rpc.send({ - jsonrpc: '2.0', - method: 'initialized' -}); -`; - describe('MCP Server Integration (E2E)', () => { - let testDir: string; + let helper: SDKTestHelper; let serverScriptPath: string; + let testDir: string; - beforeAll(() => { - // Use the centralized E2E test directory from globalSetup - testDir = join(E2E_TEST_FILE_DIR, 'mcp-server-test'); - mkdirSync(testDir, { recursive: true }); + beforeAll(async () => { + // Create isolated test environment using SDKTestHelper + helper = new SDKTestHelper(); + testDir = await helper.setup('mcp-server-integration'); - // Write MCP server script - serverScriptPath = join(testDir, 'mcp-server.cjs'); - writeFileSync(serverScriptPath, MCP_SERVER_SCRIPT); + // Create MCP server using the helper utility + const mcpServer = await createMCPServer(helper, 'math', 'test-math-server'); + serverScriptPath = mcpServer.scriptPath; + }); - // Make script executable on Unix-like systems - if (process.platform !== 'win32') { - chmodSync(serverScriptPath, 0o755); - } + afterAll(async () => { + // Cleanup test directory + await helper.cleanup(); }); describe('Basic MCP Tool Usage', () => { @@ -257,7 +71,7 @@ describe('MCP Server Integration (E2E)', () => { }, }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; let assistantText = ''; let foundToolUse = false; @@ -265,12 +79,9 @@ describe('MCP Server Integration (E2E)', () => { for await (const message of q) { messages.push(message); - if (isCLIAssistantMessage(message)) { - const toolUseBlock = message.message.content.find( - (block: ContentBlock): block is ToolUseBlock => - block.type === 'tool_use', - ); - if (toolUseBlock && toolUseBlock.name === 'add') { + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks(message, 'add'); + if (toolUseBlocks.length > 0) { foundToolUse = true; } assistantText += extractText(message.message.content); @@ -285,8 +96,8 @@ describe('MCP Server Integration (E2E)', () => { // Validate successful completion const lastMessage = messages[messages.length - 1]; - expect(isCLIResultMessage(lastMessage)).toBe(true); - if (isCLIResultMessage(lastMessage)) { + expect(isSDKResultMessage(lastMessage)).toBe(true); + if (isSDKResultMessage(lastMessage)) { expect(lastMessage.subtype).toBe('success'); } } finally { @@ -311,7 +122,7 @@ describe('MCP Server Integration (E2E)', () => { }, }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; let assistantText = ''; let foundToolUse = false; @@ -319,12 +130,9 @@ describe('MCP Server Integration (E2E)', () => { for await (const message of q) { messages.push(message); - if (isCLIAssistantMessage(message)) { - const toolUseBlock = message.message.content.find( - (block: ContentBlock): block is ToolUseBlock => - block.type === 'tool_use', - ); - if (toolUseBlock && toolUseBlock.name === 'multiply') { + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks(message, 'multiply'); + if (toolUseBlocks.length > 0) { foundToolUse = true; } assistantText += extractText(message.message.content); @@ -339,7 +147,7 @@ describe('MCP Server Integration (E2E)', () => { // Validate successful completion const lastMessage = messages[messages.length - 1]; - expect(isCLIResultMessage(lastMessage)).toBe(true); + expect(isSDKResultMessage(lastMessage)).toBe(true); } finally { await q.close(); } @@ -363,11 +171,11 @@ describe('MCP Server Integration (E2E)', () => { }, }); - let systemMessage: CLISystemMessage | null = null; + let systemMessage: SDKSystemMessage | null = null; try { for await (const message of q) { - if (isCLISystemMessage(message) && message.subtype === 'init') { + if (isSDKSystemMessage(message) && message.subtype === 'init') { systemMessage = message; break; } @@ -410,7 +218,7 @@ describe('MCP Server Integration (E2E)', () => { }, }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; let assistantText = ''; const toolCalls: string[] = []; @@ -418,11 +226,8 @@ describe('MCP Server Integration (E2E)', () => { for await (const message of q) { messages.push(message); - if (isCLIAssistantMessage(message)) { - const toolUseBlocks = message.message.content.filter( - (block: ContentBlock): block is ToolUseBlock => - block.type === 'tool_use', - ); + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks(message); toolUseBlocks.forEach((block) => { toolCalls.push(block.name); }); @@ -439,7 +244,7 @@ describe('MCP Server Integration (E2E)', () => { // Validate successful completion const lastMessage = messages[messages.length - 1]; - expect(isCLIResultMessage(lastMessage)).toBe(true); + expect(isSDKResultMessage(lastMessage)).toBe(true); } finally { await q.close(); } @@ -462,7 +267,7 @@ describe('MCP Server Integration (E2E)', () => { }, }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; let assistantText = ''; const addToolCalls: ToolUseBlock[] = []; @@ -470,16 +275,9 @@ describe('MCP Server Integration (E2E)', () => { for await (const message of q) { messages.push(message); - if (isCLIAssistantMessage(message)) { - const toolUseBlocks = message.message.content.filter( - (block: ContentBlock): block is ToolUseBlock => - block.type === 'tool_use', - ); - toolUseBlocks.forEach((block) => { - if (block.name === 'add') { - addToolCalls.push(block); - } - }); + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks(message, 'add'); + addToolCalls.push(...toolUseBlocks); assistantText += extractText(message.message.content); } } @@ -493,7 +291,7 @@ describe('MCP Server Integration (E2E)', () => { // Validate successful completion const lastMessage = messages[messages.length - 1]; - expect(isCLIResultMessage(lastMessage)).toBe(true); + expect(isSDKResultMessage(lastMessage)).toBe(true); } finally { await q.close(); } @@ -525,19 +323,16 @@ describe('MCP Server Integration (E2E)', () => { for await (const message of q) { messageTypes.push(message.type); - if (isCLIAssistantMessage(message)) { - const toolUseBlock = message.message.content.find( - (block: ContentBlock): block is ToolUseBlock => - block.type === 'tool_use', - ); - if (toolUseBlock) { + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks(message); + if (toolUseBlocks.length > 0) { foundToolUse = true; - expect(toolUseBlock.name).toBe('add'); - expect(toolUseBlock.input).toBeDefined(); + expect(toolUseBlocks[0].name).toBe('add'); + expect(toolUseBlocks[0].input).toBeDefined(); } } - if (isCLIUserMessage(message)) { + if (isSDKUserMessage(message)) { const content = message.message.content; const contentArray = Array.isArray(content) ? content @@ -584,21 +379,21 @@ describe('MCP Server Integration (E2E)', () => { }, }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; let assistantText = ''; try { for await (const message of q) { messages.push(message); - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { assistantText += extractText(message.message.content); } } // Should complete without crashing const lastMessage = messages[messages.length - 1]; - expect(isCLIResultMessage(lastMessage)).toBe(true); + expect(isSDKResultMessage(lastMessage)).toBe(true); // Assistant should indicate tool is not available or provide alternative expect(assistantText.length).toBeGreaterThan(0); diff --git a/packages/sdk-typescript/test/e2e/multi-turn.test.ts b/packages/sdk-typescript/test/e2e/multi-turn.test.ts index 52c012c8a7..be49dc5ecc 100644 --- a/packages/sdk-typescript/test/e2e/multi-turn.test.ts +++ b/packages/sdk-typescript/test/e2e/multi-turn.test.ts @@ -6,19 +6,19 @@ import { describe, it, expect } from 'vitest'; import { query } from '../../src/index.js'; import { - isCLIUserMessage, - isCLIAssistantMessage, - isCLISystemMessage, - isCLIResultMessage, - isCLIPartialAssistantMessage, + isSDKUserMessage, + isSDKAssistantMessage, + isSDKSystemMessage, + isSDKResultMessage, + isSDKPartialAssistantMessage, isControlRequest, isControlResponse, isControlCancel, - type CLIUserMessage, - type CLIAssistantMessage, + type SDKUserMessage, + type SDKAssistantMessage, type TextBlock, type ContentBlock, - type CLIMessage, + type SDKMessage, type ControlMessage, type ToolUseBlock, } from '../../src/types/protocol.js'; @@ -31,16 +31,16 @@ const SHARED_TEST_OPTIONS = { /** * Determine the message type using protocol type guards */ -function getMessageType(message: CLIMessage | ControlMessage): string { - if (isCLIUserMessage(message)) { +function getMessageType(message: SDKMessage | ControlMessage): string { + if (isSDKUserMessage(message)) { return '🧑 USER'; - } else if (isCLIAssistantMessage(message)) { + } else if (isSDKAssistantMessage(message)) { return '🤖 ASSISTANT'; - } else if (isCLISystemMessage(message)) { + } else if (isSDKSystemMessage(message)) { return `🖥️ SYSTEM(${message.subtype})`; - } else if (isCLIResultMessage(message)) { + } else if (isSDKResultMessage(message)) { return `✅ RESULT(${message.subtype})`; - } else if (isCLIPartialAssistantMessage(message)) { + } else if (isSDKPartialAssistantMessage(message)) { return '⏳ STREAM_EVENT'; } else if (isControlRequest(message)) { return `🎮 CONTROL_REQUEST(${message.request.subtype})`; @@ -67,7 +67,7 @@ describe('Multi-Turn Conversations (E2E)', () => { describe('AsyncIterable Prompt Support', () => { it('should handle multi-turn conversation using AsyncIterable prompt', async () => { // Create multi-turn conversation generator - async function* createMultiTurnConversation(): AsyncIterable { + async function* createMultiTurnConversation(): AsyncIterable { const sessionId = crypto.randomUUID(); yield { @@ -78,7 +78,7 @@ describe('Multi-Turn Conversations (E2E)', () => { content: 'What is 1 + 1?', }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; await new Promise((resolve) => setTimeout(resolve, 100)); @@ -90,7 +90,7 @@ describe('Multi-Turn Conversations (E2E)', () => { content: 'What is 2 + 2?', }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; await new Promise((resolve) => setTimeout(resolve, 100)); @@ -102,7 +102,7 @@ describe('Multi-Turn Conversations (E2E)', () => { content: 'What is 3 + 3?', }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; } // Create multi-turn query using AsyncIterable prompt @@ -114,15 +114,15 @@ describe('Multi-Turn Conversations (E2E)', () => { }, }); - const messages: CLIMessage[] = []; - const assistantMessages: CLIAssistantMessage[] = []; + const messages: SDKMessage[] = []; + const assistantMessages: SDKAssistantMessage[] = []; const assistantTexts: string[] = []; try { for await (const message of q) { messages.push(message); - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { assistantMessages.push(message); const text = extractText(message.message.content); assistantTexts.push(text); @@ -142,7 +142,7 @@ describe('Multi-Turn Conversations (E2E)', () => { }); it('should maintain session context across turns', async () => { - async function* createContextualConversation(): AsyncIterable { + async function* createContextualConversation(): AsyncIterable { const sessionId = crypto.randomUUID(); yield { @@ -154,7 +154,7 @@ describe('Multi-Turn Conversations (E2E)', () => { 'Suppose we have 3 rabbits and 4 carrots. How many animals are there?', }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; await new Promise((resolve) => setTimeout(resolve, 200)); @@ -166,7 +166,7 @@ describe('Multi-Turn Conversations (E2E)', () => { content: 'How many animals are there? Only output the number', }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; } const q = query({ @@ -177,11 +177,11 @@ describe('Multi-Turn Conversations (E2E)', () => { }, }); - const assistantMessages: CLIAssistantMessage[] = []; + const assistantMessages: SDKAssistantMessage[] = []; try { for await (const message of q) { - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { assistantMessages.push(message); } } @@ -201,7 +201,7 @@ describe('Multi-Turn Conversations (E2E)', () => { describe('Tool Usage in Multi-Turn', () => { it('should handle tool usage across multiple turns', async () => { - async function* createToolConversation(): AsyncIterable { + async function* createToolConversation(): AsyncIterable { const sessionId = crypto.randomUUID(); yield { @@ -212,7 +212,7 @@ describe('Multi-Turn Conversations (E2E)', () => { content: 'Create a file named test.txt with content "hello"', }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; await new Promise((resolve) => setTimeout(resolve, 200)); @@ -224,7 +224,7 @@ describe('Multi-Turn Conversations (E2E)', () => { content: 'Now read the test.txt file', }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; } const q = query({ @@ -237,15 +237,15 @@ describe('Multi-Turn Conversations (E2E)', () => { }, }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; let toolUseCount = 0; - const assistantMessages: CLIAssistantMessage[] = []; + const assistantMessages: SDKAssistantMessage[] = []; try { for await (const message of q) { messages.push(message); - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { assistantMessages.push(message); const hasToolUseBlock = message.message.content.some( (block: ContentBlock): block is ToolUseBlock => @@ -274,7 +274,7 @@ describe('Multi-Turn Conversations (E2E)', () => { describe('Message Flow and Sequencing', () => { it('should process messages in correct sequence', async () => { - async function* createSequentialConversation(): AsyncIterable { + async function* createSequentialConversation(): AsyncIterable { const sessionId = crypto.randomUUID(); yield { @@ -285,7 +285,7 @@ describe('Multi-Turn Conversations (E2E)', () => { content: 'First question: What is 1 + 1?', }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; await new Promise((resolve) => setTimeout(resolve, 100)); @@ -297,7 +297,7 @@ describe('Multi-Turn Conversations (E2E)', () => { content: 'Second question: What is 2 + 2?', }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; } const q = query({ @@ -316,7 +316,7 @@ describe('Multi-Turn Conversations (E2E)', () => { const messageType = getMessageType(message); messageSequence.push(messageType); - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { const text = extractText(message.message.content); assistantResponses.push(text); } @@ -338,7 +338,7 @@ describe('Multi-Turn Conversations (E2E)', () => { }); it('should handle conversation completion correctly', async () => { - async function* createSimpleConversation(): AsyncIterable { + async function* createSimpleConversation(): AsyncIterable { const sessionId = crypto.randomUUID(); yield { @@ -349,7 +349,7 @@ describe('Multi-Turn Conversations (E2E)', () => { content: 'Hello', }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; await new Promise((resolve) => setTimeout(resolve, 100)); @@ -361,7 +361,7 @@ describe('Multi-Turn Conversations (E2E)', () => { content: 'Goodbye', }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; } const q = query({ @@ -379,7 +379,7 @@ describe('Multi-Turn Conversations (E2E)', () => { for await (const message of q) { messageCount++; - if (isCLIResultMessage(message)) { + if (isSDKResultMessage(message)) { completedNaturally = true; expect(message.subtype).toBe('success'); } @@ -395,11 +395,11 @@ describe('Multi-Turn Conversations (E2E)', () => { describe('Error Handling in Multi-Turn', () => { it('should handle empty conversation gracefully', async () => { - async function* createEmptyConversation(): AsyncIterable { + async function* createEmptyConversation(): AsyncIterable { // Generator that yields nothing /* eslint-disable no-constant-condition */ if (false) { - yield {} as CLIUserMessage; // Unreachable, but satisfies TypeScript + yield {} as SDKUserMessage; // Unreachable, but satisfies TypeScript } } @@ -411,7 +411,7 @@ describe('Multi-Turn Conversations (E2E)', () => { }, }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; try { for await (const message of q) { @@ -426,7 +426,7 @@ describe('Multi-Turn Conversations (E2E)', () => { }); it('should handle conversation with delays', async () => { - async function* createDelayedConversation(): AsyncIterable { + async function* createDelayedConversation(): AsyncIterable { const sessionId = crypto.randomUUID(); yield { @@ -437,7 +437,7 @@ describe('Multi-Turn Conversations (E2E)', () => { content: 'First message', }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; // Longer delay to test patience await new Promise((resolve) => setTimeout(resolve, 500)); @@ -450,7 +450,7 @@ describe('Multi-Turn Conversations (E2E)', () => { content: 'Second message after delay', }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; } const q = query({ @@ -461,11 +461,11 @@ describe('Multi-Turn Conversations (E2E)', () => { }, }); - const assistantMessages: CLIAssistantMessage[] = []; + const assistantMessages: SDKAssistantMessage[] = []; try { for await (const message of q) { - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { assistantMessages.push(message); } } @@ -479,7 +479,7 @@ describe('Multi-Turn Conversations (E2E)', () => { describe('Partial Messages in Multi-Turn', () => { it('should receive partial messages when includePartialMessages is enabled', async () => { - async function* createMultiTurnConversation(): AsyncIterable { + async function* createMultiTurnConversation(): AsyncIterable { const sessionId = crypto.randomUUID(); yield { @@ -490,7 +490,7 @@ describe('Multi-Turn Conversations (E2E)', () => { content: 'What is 1 + 1?', }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; await new Promise((resolve) => setTimeout(resolve, 100)); @@ -502,7 +502,7 @@ describe('Multi-Turn Conversations (E2E)', () => { content: 'What is 2 + 2?', }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; } const q = query({ @@ -514,7 +514,7 @@ describe('Multi-Turn Conversations (E2E)', () => { }, }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; let partialMessageCount = 0; let assistantMessageCount = 0; @@ -522,11 +522,11 @@ describe('Multi-Turn Conversations (E2E)', () => { for await (const message of q) { messages.push(message); - if (isCLIPartialAssistantMessage(message)) { + if (isSDKPartialAssistantMessage(message)) { partialMessageCount++; } - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { assistantMessageCount++; } } diff --git a/packages/sdk-typescript/test/e2e/permission-control.test.ts b/packages/sdk-typescript/test/e2e/permission-control.test.ts index 157706087c..9747bca00c 100644 --- a/packages/sdk-typescript/test/e2e/permission-control.test.ts +++ b/packages/sdk-typescript/test/e2e/permission-control.test.ts @@ -7,10 +7,10 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { query } from '../../src/index.js'; import { - isCLIAssistantMessage, - isCLIResultMessage, - isCLIUserMessage, - type CLIUserMessage, + isSDKAssistantMessage, + isSDKResultMessage, + isSDKUserMessage, + type SDKUserMessage, type ToolUseBlock, type ContentBlock, } from '../../src/types/protocol.js'; @@ -32,7 +32,7 @@ function createStreamingInputWithControlPoint( firstMessage: string, secondMessage: string, ): { - generator: AsyncIterable; + generator: AsyncIterable; resume: () => void; } { let resumeResolve: (() => void) | null = null; @@ -51,7 +51,7 @@ function createStreamingInputWithControlPoint( content: firstMessage, }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; await new Promise((resolve) => setTimeout(resolve, 200)); @@ -67,7 +67,7 @@ function createStreamingInputWithControlPoint( content: secondMessage, }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; })(); const resume = () => { @@ -120,7 +120,7 @@ describe('Permission Control (E2E)', () => { try { let hasToolUse = false; for await (const message of q) { - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { const toolUseBlock = message.message.content.find( (block: ContentBlock): block is ToolUseBlock => block.type === 'tool_use', @@ -162,7 +162,7 @@ describe('Permission Control (E2E)', () => { try { let hasToolResult = false; for await (const message of q) { - if (isCLIUserMessage(message)) { + if (isSDKUserMessage(message)) { if ( Array.isArray(message.message.content) && message.message.content.some( @@ -372,7 +372,7 @@ describe('Permission Control (E2E)', () => { (async () => { for await (const message of q) { - if (isCLIAssistantMessage(message) || isCLIResultMessage(message)) { + if (isSDKAssistantMessage(message) || isSDKResultMessage(message)) { if (!firstResponseReceived) { firstResponseReceived = true; resolvers.first?.(); @@ -447,7 +447,7 @@ describe('Permission Control (E2E)', () => { (async () => { for await (const message of q) { - if (isCLIAssistantMessage(message) || isCLIResultMessage(message)) { + if (isSDKAssistantMessage(message) || isSDKResultMessage(message)) { if (!firstResponseReceived) { firstResponseReceived = true; resolvers.first?.(); @@ -522,7 +522,7 @@ describe('Permission Control (E2E)', () => { (async () => { for await (const message of q) { - if (isCLIAssistantMessage(message) || isCLIResultMessage(message)) { + if (isSDKAssistantMessage(message) || isSDKResultMessage(message)) { if (!firstResponseReceived) { firstResponseReceived = true; resolvers.first?.(); @@ -628,7 +628,7 @@ describe('Permission Control (E2E)', () => { (async () => { for await (const message of q) { - if (isCLIResultMessage(message)) { + if (isSDKResultMessage(message)) { if (!firstResponseReceived) { firstResponseReceived = true; resolvers.first?.(); @@ -695,7 +695,7 @@ describe('Permission Control (E2E)', () => { let hasErrorInResult = false; for await (const message of q) { - if (isCLIUserMessage(message)) { + if (isSDKUserMessage(message)) { if (Array.isArray(message.message.content)) { const toolResult = message.message.content.find( (block) => block.type === 'tool_result', @@ -752,7 +752,7 @@ describe('Permission Control (E2E)', () => { let hasSuccessfulToolResult = false; for await (const message of q) { - if (isCLIUserMessage(message)) { + if (isSDKUserMessage(message)) { if (Array.isArray(message.message.content)) { const toolResult = message.message.content.find( (block) => block.type === 'tool_result', @@ -798,7 +798,7 @@ describe('Permission Control (E2E)', () => { let hasToolResult = false; for await (const message of q) { - if (isCLIUserMessage(message)) { + if (isSDKUserMessage(message)) { if (Array.isArray(message.message.content)) { const toolResult = message.message.content.find( (block) => block.type === 'tool_result', @@ -838,7 +838,7 @@ describe('Permission Control (E2E)', () => { let hasSuccessfulToolResult = false; for await (const message of q) { - if (isCLIUserMessage(message)) { + if (isSDKUserMessage(message)) { if (Array.isArray(message.message.content)) { const toolResult = message.message.content.find( (block) => block.type === 'tool_result', @@ -891,7 +891,7 @@ describe('Permission Control (E2E)', () => { let hasToolResult = false; for await (const message of q) { - if (isCLIUserMessage(message)) { + if (isSDKUserMessage(message)) { if (Array.isArray(message.message.content)) { const toolResult = message.message.content.find( (block) => block.type === 'tool_result', @@ -929,7 +929,7 @@ describe('Permission Control (E2E)', () => { let hasCommandResult = false; for await (const message of q) { - if (isCLIUserMessage(message)) { + if (isSDKUserMessage(message)) { if (Array.isArray(message.message.content)) { const toolResult = message.message.content.find( (block) => block.type === 'tool_result', @@ -968,7 +968,7 @@ describe('Permission Control (E2E)', () => { let hasPlanModeMessage = false; for await (const message of q) { - if (isCLIUserMessage(message)) { + if (isSDKUserMessage(message)) { if (Array.isArray(message.message.content)) { const toolResult = message.message.content.find( (block) => block.type === 'tool_result', @@ -1014,7 +1014,7 @@ describe('Permission Control (E2E)', () => { let hasSuccessfulToolResult = false; for await (const message of q) { - if (isCLIUserMessage(message)) { + if (isSDKUserMessage(message)) { if (Array.isArray(message.message.content)) { const toolResult = message.message.content.find( (block) => block.type === 'tool_result', @@ -1066,7 +1066,7 @@ describe('Permission Control (E2E)', () => { let hasPlanModeBlock = false; for await (const message of q) { - if (isCLIUserMessage(message)) { + if (isSDKUserMessage(message)) { if (Array.isArray(message.message.content)) { const toolResult = message.message.content.find( (block) => block.type === 'tool_result', @@ -1114,7 +1114,7 @@ describe('Permission Control (E2E)', () => { let hasDeniedTool = false; for await (const message of q) { - if (isCLIUserMessage(message)) { + if (isSDKUserMessage(message)) { if (Array.isArray(message.message.content)) { const toolResult = message.message.content.find( (block) => block.type === 'tool_result', @@ -1169,7 +1169,7 @@ describe('Permission Control (E2E)', () => { let hasSuccessfulToolResult = false; for await (const message of q) { - if (isCLIUserMessage(message)) { + if (isSDKUserMessage(message)) { if (Array.isArray(message.message.content)) { const toolResult = message.message.content.find( (block) => block.type === 'tool_result', @@ -1214,7 +1214,7 @@ describe('Permission Control (E2E)', () => { let hasToolResult = false; for await (const message of q) { - if (isCLIUserMessage(message)) { + if (isSDKUserMessage(message)) { if (Array.isArray(message.message.content)) { const toolResult = message.message.content.find( (block) => block.type === 'tool_result', @@ -1270,7 +1270,7 @@ describe('Permission Control (E2E)', () => { let toolExecuted = false; for await (const message of q) { - if (isCLIUserMessage(message)) { + if (isSDKUserMessage(message)) { if (Array.isArray(message.message.content)) { const toolResult = message.message.content.find( (block) => block.type === 'tool_result', diff --git a/packages/sdk-typescript/test/e2e/single-turn.test.ts b/packages/sdk-typescript/test/e2e/single-turn.test.ts index 2052b6b258..476d9bfbc2 100644 --- a/packages/sdk-typescript/test/e2e/single-turn.test.ts +++ b/packages/sdk-typescript/test/e2e/single-turn.test.ts @@ -6,31 +6,22 @@ import { describe, it, expect } from 'vitest'; import { query } from '../../src/index.js'; import { - isCLIAssistantMessage, - isCLISystemMessage, - isCLIResultMessage, - isCLIPartialAssistantMessage, - type TextBlock, - type ContentBlock, - type CLIMessage, - type CLISystemMessage, - type CLIAssistantMessage, + isSDKAssistantMessage, + isSDKSystemMessage, + isSDKResultMessage, + isSDKPartialAssistantMessage, + type SDKMessage, + type SDKSystemMessage, + type SDKAssistantMessage, } from '../../src/types/protocol.js'; -const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!; - -const SHARED_TEST_OPTIONS = { - pathToQwenExecutable: TEST_CLI_PATH, -}; +import { + extractText, + createSharedTestOptions, + assertSuccessfulCompletion, + collectMessagesByType, +} from './test-helper.js'; -/** - * Helper to extract text from ContentBlock array - */ -function extractText(content: ContentBlock[]): string { - return content - .filter((block): block is TextBlock => block.type === 'text') - .map((block) => block.text) - .join(''); -} +const SHARED_TEST_OPTIONS = createSharedTestOptions(); describe('Single-Turn Query (E2E)', () => { describe('Simple Text Queries', () => { @@ -44,14 +35,14 @@ describe('Single-Turn Query (E2E)', () => { }, }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; let assistantText = ''; try { for await (const message of q) { messages.push(message); - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { assistantText += extractText(message.message.content); } } @@ -64,11 +55,7 @@ describe('Single-Turn Query (E2E)', () => { expect(assistantText).toMatch(/4/); // Validate message flow ends with success - const lastMessage = messages[messages.length - 1]; - expect(isCLIResultMessage(lastMessage)).toBe(true); - if (isCLIResultMessage(lastMessage)) { - expect(lastMessage.subtype).toBe('success'); - } + assertSuccessfulCompletion(messages); } finally { await q.close(); } @@ -83,14 +70,14 @@ describe('Single-Turn Query (E2E)', () => { }, }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; let assistantText = ''; try { for await (const message of q) { messages.push(message); - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { assistantText += extractText(message.message.content); } } @@ -100,8 +87,7 @@ describe('Single-Turn Query (E2E)', () => { expect(assistantText.toLowerCase()).toContain('paris'); // Validate completion - const lastMessage = messages[messages.length - 1]; - expect(isCLIResultMessage(lastMessage)).toBe(true); + assertSuccessfulCompletion(messages); } finally { await q.close(); } @@ -116,14 +102,14 @@ describe('Single-Turn Query (E2E)', () => { }, }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; let assistantText = ''; try { for await (const message of q) { messages.push(message); - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { assistantText += extractText(message.message.content); } } @@ -133,7 +119,10 @@ describe('Single-Turn Query (E2E)', () => { expect(assistantText.toLowerCase()).toMatch(/hello|hi|greetings/); // Validate message types - const assistantMessages = messages.filter(isCLIAssistantMessage); + const assistantMessages = collectMessagesByType( + messages, + isSDKAssistantMessage, + ); expect(assistantMessages.length).toBeGreaterThan(0); } finally { await q.close(); @@ -151,14 +140,14 @@ describe('Single-Turn Query (E2E)', () => { }, }); - const messages: CLIMessage[] = []; - let systemMessage: CLISystemMessage | null = null; + const messages: SDKMessage[] = []; + let systemMessage: SDKSystemMessage | null = null; try { for await (const message of q) { messages.push(message); - if (isCLISystemMessage(message) && message.subtype === 'init') { + if (isSDKSystemMessage(message) && message.subtype === 'init') { systemMessage = message; } } @@ -180,7 +169,7 @@ describe('Single-Turn Query (E2E)', () => { // Validate system message appears early in sequence const systemMessageIndex = messages.findIndex( - (msg) => isCLISystemMessage(msg) && msg.subtype === 'init', + (msg) => isSDKSystemMessage(msg) && msg.subtype === 'init', ); expect(systemMessageIndex).toBeGreaterThanOrEqual(0); expect(systemMessageIndex).toBeLessThan(3); @@ -198,12 +187,12 @@ describe('Single-Turn Query (E2E)', () => { }, }); - let systemMessage: CLISystemMessage | null = null; + let systemMessage: SDKSystemMessage | null = null; const sessionId = q.getSessionId(); try { for await (const message of q) { - if (isCLISystemMessage(message) && message.subtype === 'init') { + if (isSDKSystemMessage(message) && message.subtype === 'init') { systemMessage = message; } } @@ -262,7 +251,7 @@ describe('Single-Turn Query (E2E)', () => { for await (const message of q) { messageCount++; - if (isCLIResultMessage(message)) { + if (isSDKResultMessage(message)) { completedNaturally = true; expect(message.subtype).toBe('success'); } @@ -319,7 +308,7 @@ describe('Single-Turn Query (E2E)', () => { try { for await (const message of q) { - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { hasResponse = true; } } @@ -340,7 +329,7 @@ describe('Single-Turn Query (E2E)', () => { }, }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; let partialMessageCount = 0; let assistantMessageCount = 0; @@ -348,11 +337,11 @@ describe('Single-Turn Query (E2E)', () => { for await (const message of q) { messages.push(message); - if (isCLIPartialAssistantMessage(message)) { + if (isSDKPartialAssistantMessage(message)) { partialMessageCount++; } - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { assistantMessageCount++; } } @@ -376,7 +365,7 @@ describe('Single-Turn Query (E2E)', () => { }, }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; try { for await (const message of q) { @@ -384,9 +373,18 @@ describe('Single-Turn Query (E2E)', () => { } // Validate type guards work correctly - const assistantMessages = messages.filter(isCLIAssistantMessage); - const resultMessages = messages.filter(isCLIResultMessage); - const systemMessages = messages.filter(isCLISystemMessage); + const assistantMessages = collectMessagesByType( + messages, + isSDKAssistantMessage, + ); + const resultMessages = collectMessagesByType( + messages, + isSDKResultMessage, + ); + const systemMessages = collectMessagesByType( + messages, + isSDKSystemMessage, + ); expect(assistantMessages.length).toBeGreaterThan(0); expect(resultMessages.length).toBeGreaterThan(0); @@ -414,11 +412,11 @@ describe('Single-Turn Query (E2E)', () => { }, }); - let assistantMessage: CLIAssistantMessage | null = null; + let assistantMessage: SDKAssistantMessage | null = null; try { for await (const message of q) { - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { assistantMessage = message; } } @@ -426,17 +424,9 @@ describe('Single-Turn Query (E2E)', () => { expect(assistantMessage).not.toBeNull(); expect(assistantMessage!.message.content).toBeDefined(); - // Extract text blocks - const textBlocks = assistantMessage!.message.content.filter( - (block: ContentBlock): block is TextBlock => block.type === 'text', - ); - - expect(textBlocks.length).toBeGreaterThan(0); - expect(textBlocks[0].text).toBeDefined(); - expect(textBlocks[0].text.length).toBeGreaterThan(0); - // Validate content contains expected numbers const text = extractText(assistantMessage!.message.content); + expect(text.length).toBeGreaterThan(0); expect(text).toMatch(/1/); expect(text).toMatch(/2/); expect(text).toMatch(/3/); diff --git a/packages/sdk-typescript/test/e2e/subagents.test.ts b/packages/sdk-typescript/test/e2e/subagents.test.ts index fcceebb557..075105b18a 100644 --- a/packages/sdk-typescript/test/e2e/subagents.test.ts +++ b/packages/sdk-typescript/test/e2e/subagents.test.ts @@ -9,50 +9,42 @@ * Tests subagent delegation and task completion */ -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { query } from '../../src/index.js'; import { - isCLIAssistantMessage, - isCLISystemMessage, - isCLIResultMessage, - type TextBlock, - type ContentBlock, - type CLIMessage, - type CLISystemMessage, + isSDKAssistantMessage, + type SDKMessage, type SubagentConfig, + type ContentBlock, type ToolUseBlock, } from '../../src/types/protocol.js'; -import { writeFile, mkdir } from 'node:fs/promises'; -import { join } from 'node:path'; - -const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!; -const E2E_TEST_FILE_DIR = process.env['E2E_TEST_FILE_DIR']!; +import { + SDKTestHelper, + extractText, + createSharedTestOptions, + findToolUseBlocks, + assertSuccessfulCompletion, + findSystemMessage, +} from './test-helper.js'; -const SHARED_TEST_OPTIONS = { - pathToQwenExecutable: TEST_CLI_PATH, -}; - -/** - * Helper to extract text from ContentBlock array - */ -function extractText(content: ContentBlock[]): string { - return content - .filter((block): block is TextBlock => block.type === 'text') - .map((block) => block.text) - .join(''); -} +const SHARED_TEST_OPTIONS = createSharedTestOptions(); describe('Subagents (E2E)', () => { + let helper: SDKTestHelper; let testWorkDir: string; beforeAll(async () => { - // Create a test working directory - testWorkDir = join(E2E_TEST_FILE_DIR, 'subagent-tests'); - await mkdir(testWorkDir, { recursive: true }); + // Create isolated test environment using SDKTestHelper + helper = new SDKTestHelper(); + testWorkDir = await helper.setup('subagent-tests'); // Create a simple test file for subagent to work with - const testFilePath = join(testWorkDir, 'test.txt'); - await writeFile(testFilePath, 'Hello from test file\n', 'utf-8'); + await helper.createFile('test.txt', 'Hello from test file\n'); + }); + + afterAll(async () => { + // Cleanup test directory + await helper.cleanup(); }); describe('Subagent Configuration', () => { @@ -75,29 +67,21 @@ describe('Subagents (E2E)', () => { }, }); - let systemMessage: CLISystemMessage | null = null; - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; try { for await (const message of q) { messages.push(message); - - if (isCLISystemMessage(message) && message.subtype === 'init') { - systemMessage = message; - } } // Validate system message includes the subagent + const systemMessage = findSystemMessage(messages, 'init'); expect(systemMessage).not.toBeNull(); expect(systemMessage!.agents).toBeDefined(); expect(systemMessage!.agents).toContain('simple-greeter'); // Validate successful completion - const lastMessage = messages[messages.length - 1]; - expect(isCLIResultMessage(lastMessage)).toBe(true); - if (isCLIResultMessage(lastMessage)) { - expect(lastMessage.subtype).toBe('success'); - } + assertSuccessfulCompletion(messages); } finally { await q.close(); } @@ -128,16 +112,15 @@ describe('Subagents (E2E)', () => { }, }); - let systemMessage: CLISystemMessage | null = null; + const messages: SDKMessage[] = []; try { for await (const message of q) { - if (isCLISystemMessage(message) && message.subtype === 'init') { - systemMessage = message; - } + messages.push(message); } // Validate both subagents are registered + const systemMessage = findSystemMessage(messages, 'init'); expect(systemMessage).not.toBeNull(); expect(systemMessage!.agents).toBeDefined(); expect(systemMessage!.agents).toContain('greeter'); @@ -170,16 +153,15 @@ describe('Subagents (E2E)', () => { }, }); - let systemMessage: CLISystemMessage | null = null; + const messages: SDKMessage[] = []; try { for await (const message of q) { - if (isCLISystemMessage(message) && message.subtype === 'init') { - systemMessage = message; - } + messages.push(message); } // Validate subagent is registered + const systemMessage = findSystemMessage(messages, 'init'); expect(systemMessage).not.toBeNull(); expect(systemMessage!.agents).toBeDefined(); expect(systemMessage!.agents).toContain('custom-model-agent'); @@ -210,16 +192,15 @@ describe('Subagents (E2E)', () => { }, }); - let systemMessage: CLISystemMessage | null = null; + const messages: SDKMessage[] = []; try { for await (const message of q) { - if (isCLISystemMessage(message) && message.subtype === 'init') { - systemMessage = message; - } + messages.push(message); } // Validate subagent is registered + const systemMessage = findSystemMessage(messages, 'init'); expect(systemMessage).not.toBeNull(); expect(systemMessage!.agents).toBeDefined(); expect(systemMessage!.agents).toContain('limited-agent'); @@ -248,16 +229,15 @@ describe('Subagents (E2E)', () => { }, }); - let systemMessage: CLISystemMessage | null = null; + const messages: SDKMessage[] = []; try { for await (const message of q) { - if (isCLISystemMessage(message) && message.subtype === 'init') { - systemMessage = message; - } + messages.push(message); } // Validate subagent is registered + const systemMessage = findSystemMessage(messages, 'init'); expect(systemMessage).not.toBeNull(); expect(systemMessage!.agents).toBeDefined(); expect(systemMessage!.agents).toContain('read-only-agent'); @@ -277,7 +257,7 @@ describe('Subagents (E2E)', () => { tools: ['read_file', 'list_directory'], }; - const testFile = join(testWorkDir, 'test.txt'); + const testFile = helper.getPath('test.txt'); const q = query({ prompt: `Use the file-reader subagent to read the file at ${testFile} and tell me what it contains.`, options: { @@ -289,7 +269,7 @@ describe('Subagents (E2E)', () => { }, }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; let foundTaskTool = false; let taskToolUseId: string | null = null; let foundSubagentToolCall = false; @@ -299,25 +279,19 @@ describe('Subagents (E2E)', () => { for await (const message of q) { messages.push(message); - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { // Check for task tool use in content blocks (main agent calling subagent) - const toolUseBlock = message.message.content.find( - (block: ContentBlock): block is ToolUseBlock => - block.type === 'tool_use' && block.name === 'task', - ); - if (toolUseBlock) { + const taskToolBlocks = findToolUseBlocks(message, 'task'); + if (taskToolBlocks.length > 0) { foundTaskTool = true; - taskToolUseId = toolUseBlock.id; + taskToolUseId = taskToolBlocks[0].id; } // Check if this message is from a subagent (has parent_tool_use_id) if (message.parent_tool_use_id !== null) { // This is a subagent message - const subagentToolUse = message.message.content.find( - (block: ContentBlock): block is ToolUseBlock => - block.type === 'tool_use', - ); - if (subagentToolUse) { + const subagentToolBlocks = findToolUseBlocks(message); + if (subagentToolBlocks.length > 0) { foundSubagentToolCall = true; // Verify parent_tool_use_id matches the task tool use id expect(message.parent_tool_use_id).toBe(taskToolUseId); @@ -339,11 +313,7 @@ describe('Subagents (E2E)', () => { expect(assistantText.length).toBeGreaterThan(0); // Validate successful completion - const lastMessage = messages[messages.length - 1]; - expect(isCLIResultMessage(lastMessage)).toBe(true); - if (isCLIResultMessage(lastMessage)) { - expect(lastMessage.subtype).toBe('success'); - } + assertSuccessfulCompletion(messages); } finally { await q.close(); } @@ -369,7 +339,7 @@ describe('Subagents (E2E)', () => { }, }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; let foundTaskTool = false; let assistantText = ''; @@ -377,7 +347,7 @@ describe('Subagents (E2E)', () => { for await (const message of q) { messages.push(message); - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { // Check for task tool use (main agent delegating to subagent) const toolUseBlock = message.message.content.find( (block: ContentBlock): block is ToolUseBlock => @@ -398,11 +368,7 @@ describe('Subagents (E2E)', () => { expect(assistantText.length).toBeGreaterThan(0); // Validate successful completion - const lastMessage = messages[messages.length - 1]; - expect(isCLIResultMessage(lastMessage)).toBe(true); - if (isCLIResultMessage(lastMessage)) { - expect(lastMessage.subtype).toBe('success'); - } + assertSuccessfulCompletion(messages); } finally { await q.close(); } @@ -429,7 +395,7 @@ describe('Subagents (E2E)', () => { }, }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; let taskToolUseId: string | null = null; const subagentToolCalls: ToolUseBlock[] = []; const mainAgentToolCalls: ToolUseBlock[] = []; @@ -438,7 +404,7 @@ describe('Subagents (E2E)', () => { for await (const message of q) { messages.push(message); - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { // Collect all tool use blocks const toolUseBlocks = message.message.content.filter( (block: ContentBlock): block is ToolUseBlock => @@ -471,8 +437,8 @@ describe('Subagents (E2E)', () => { // Verify all subagent messages have the correct parent_tool_use_id const subagentMessages = messages.filter( - (msg): msg is CLIMessage & { parent_tool_use_id: string } => - isCLIAssistantMessage(msg) && msg.parent_tool_use_id !== null, + (msg): msg is SDKMessage & { parent_tool_use_id: string } => + isSDKAssistantMessage(msg) && msg.parent_tool_use_id !== null, ); expect(subagentMessages.length).toBeGreaterThan(0); @@ -482,23 +448,19 @@ describe('Subagents (E2E)', () => { // Verify no main agent tool calls (except task) have parent_tool_use_id const mainAgentMessages = messages.filter( - (msg): msg is CLIMessage => - isCLIAssistantMessage(msg) && msg.parent_tool_use_id === null, + (msg): msg is SDKMessage => + isSDKAssistantMessage(msg) && msg.parent_tool_use_id === null, ); for (const mainMsg of mainAgentMessages) { - if (isCLIAssistantMessage(mainMsg)) { + if (isSDKAssistantMessage(mainMsg)) { // Main agent messages should not have parent_tool_use_id expect(mainMsg.parent_tool_use_id).toBeNull(); } } // Validate successful completion - const lastMessage = messages[messages.length - 1]; - expect(isCLIResultMessage(lastMessage)).toBe(true); - if (isCLIResultMessage(lastMessage)) { - expect(lastMessage.subtype).toBe('success'); - } + assertSuccessfulCompletion(messages); } finally { await q.close(); } @@ -517,16 +479,15 @@ describe('Subagents (E2E)', () => { }, }); - let systemMessage: CLISystemMessage | null = null; + const messages: SDKMessage[] = []; try { for await (const message of q) { - if (isCLISystemMessage(message) && message.subtype === 'init') { - systemMessage = message; - } + messages.push(message); } // Should still work with empty agents array + const systemMessage = findSystemMessage(messages, 'init'); expect(systemMessage).not.toBeNull(); expect(systemMessage!.agents).toBeDefined(); } finally { @@ -552,16 +513,15 @@ describe('Subagents (E2E)', () => { }, }); - let systemMessage: CLISystemMessage | null = null; + const messages: SDKMessage[] = []; try { for await (const message of q) { - if (isCLISystemMessage(message) && message.subtype === 'init') { - systemMessage = message; - } + messages.push(message); } // Validate minimal agent is registered + const systemMessage = findSystemMessage(messages, 'init'); expect(systemMessage).not.toBeNull(); expect(systemMessage!.agents).toBeDefined(); expect(systemMessage!.agents).toContain('minimal-agent'); @@ -596,16 +556,15 @@ describe('Subagents (E2E)', () => { }, }); - let systemMessage: CLISystemMessage | null = null; + const messages: SDKMessage[] = []; try { for await (const message of q) { - if (isCLISystemMessage(message) && message.subtype === 'init') { - systemMessage = message; - } + messages.push(message); } // Validate subagent works with debug mode + const systemMessage = findSystemMessage(messages, 'init'); expect(systemMessage).not.toBeNull(); expect(systemMessage!.agents).toBeDefined(); expect(systemMessage!.agents).toContain('test-agent'); @@ -633,16 +592,15 @@ describe('Subagents (E2E)', () => { }, }); - let systemMessage: CLISystemMessage | null = null; + const messages: SDKMessage[] = []; try { for await (const message of q) { - if (isCLISystemMessage(message) && message.subtype === 'init') { - systemMessage = message; - } + messages.push(message); } // Validate session consistency + const systemMessage = findSystemMessage(messages, 'init'); expect(systemMessage).not.toBeNull(); expect(systemMessage!.session_id).toBeDefined(); expect(systemMessage!.uuid).toBeDefined(); diff --git a/packages/sdk-typescript/test/e2e/system-control.test.ts b/packages/sdk-typescript/test/e2e/system-control.test.ts index 373f88e787..3bf1903d3d 100644 --- a/packages/sdk-typescript/test/e2e/system-control.test.ts +++ b/packages/sdk-typescript/test/e2e/system-control.test.ts @@ -6,9 +6,9 @@ import { describe, it, expect } from 'vitest'; import { query } from '../../src/index.js'; import { - isCLIAssistantMessage, - isCLISystemMessage, - type CLIUserMessage, + isSDKAssistantMessage, + isSDKSystemMessage, + type SDKUserMessage, } from '../../src/types/protocol.js'; const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!; @@ -30,7 +30,7 @@ function createStreamingInputWithControlPoint( firstMessage: string, secondMessage: string, ): { - generator: AsyncIterable; + generator: AsyncIterable; resume: () => void; } { let resumeResolve: (() => void) | null = null; @@ -49,7 +49,7 @@ function createStreamingInputWithControlPoint( content: firstMessage, }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; await new Promise((resolve) => setTimeout(resolve, 200)); @@ -65,7 +65,7 @@ function createStreamingInputWithControlPoint( content: secondMessage, }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; })(); const resume = () => { @@ -113,10 +113,10 @@ describe('System Control (E2E)', () => { // Consume messages in a single loop (async () => { for await (const message of q) { - if (isCLISystemMessage(message)) { + if (isSDKSystemMessage(message)) { systemMessages.push({ model: message.model }); } - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { if (!firstResponseReceived) { firstResponseReceived = true; resolvers.first?.(); @@ -186,7 +186,7 @@ describe('System Control (E2E)', () => { session_id: sessionId, message: { role: 'user', content: 'First message' }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; await new Promise((resolve) => setTimeout(resolve, 200)); await resumePromise1; @@ -197,7 +197,7 @@ describe('System Control (E2E)', () => { session_id: sessionId, message: { role: 'user', content: 'Second message' }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; await new Promise((resolve) => setTimeout(resolve, 200)); await resumePromise2; @@ -208,7 +208,7 @@ describe('System Control (E2E)', () => { session_id: sessionId, message: { role: 'user', content: 'Third message' }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; })(); const q = query({ @@ -232,10 +232,10 @@ describe('System Control (E2E)', () => { (async () => { for await (const message of q) { - if (isCLISystemMessage(message)) { + if (isSDKSystemMessage(message)) { systemMessages.push({ model: message.model }); } - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { if (responseCount < resolvers.length) { resolvers[responseCount]?.(); responseCount++; diff --git a/packages/sdk-typescript/test/e2e/test-helper.ts b/packages/sdk-typescript/test/e2e/test-helper.ts new file mode 100644 index 0000000000..19299d5376 --- /dev/null +++ b/packages/sdk-typescript/test/e2e/test-helper.ts @@ -0,0 +1,829 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * SDK E2E Test Helper + * Provides utilities for SDK e2e tests including test isolation, + * file management, MCP server setup, and common test utilities. + */ + +import { mkdir, writeFile, readFile, rm, chmod } from 'node:fs/promises'; +import { join } from 'node:path'; +import { existsSync } from 'node:fs'; +import type { + SDKMessage, + SDKAssistantMessage, + SDKSystemMessage, + SDKUserMessage, + ContentBlock, + TextBlock, + ToolUseBlock, +} from '../../src/types/protocol.js'; +import { + isSDKAssistantMessage, + isSDKSystemMessage, + isSDKResultMessage, +} from '../../src/types/protocol.js'; + +// ============================================================================ +// Core Test Helper Class +// ============================================================================ + +export interface SDKTestHelperOptions { + /** + * Optional settings for .qwen/settings.json + */ + settings?: Record; + /** + * Whether to create .qwen/settings.json + */ + createQwenConfig?: boolean; +} + +/** + * Helper class for SDK E2E tests + * Provides isolated test environments for each test case + */ +export class SDKTestHelper { + testDir: string | null = null; + testName?: string; + private baseDir: string; + + constructor() { + this.baseDir = process.env['E2E_TEST_FILE_DIR']!; + if (!this.baseDir) { + throw new Error('E2E_TEST_FILE_DIR environment variable not set'); + } + } + + /** + * Setup an isolated test directory for a specific test + */ + async setup( + testName: string, + options: SDKTestHelperOptions = {}, + ): Promise { + this.testName = testName; + const sanitizedName = this.sanitizeTestName(testName); + this.testDir = join(this.baseDir, sanitizedName); + + await mkdir(this.testDir, { recursive: true }); + + // Optionally create .qwen/settings.json for CLI configuration + if (options.createQwenConfig) { + const qwenDir = join(this.testDir, '.qwen'); + await mkdir(qwenDir, { recursive: true }); + + const settings = { + telemetry: { + enabled: false, // SDK tests don't need telemetry + }, + ...options.settings, + }; + + await writeFile( + join(qwenDir, 'settings.json'), + JSON.stringify(settings, null, 2), + 'utf-8', + ); + } + + return this.testDir; + } + + /** + * Create a file in the test directory + */ + async createFile(fileName: string, content: string): Promise { + if (!this.testDir) { + throw new Error('Test directory not initialized. Call setup() first.'); + } + const filePath = join(this.testDir, fileName); + await writeFile(filePath, content, 'utf-8'); + return filePath; + } + + /** + * Read a file from the test directory + */ + async readFile(fileName: string): Promise { + if (!this.testDir) { + throw new Error('Test directory not initialized. Call setup() first.'); + } + const filePath = join(this.testDir, fileName); + return await readFile(filePath, 'utf-8'); + } + + /** + * Create a subdirectory in the test directory + */ + async mkdir(dirName: string): Promise { + if (!this.testDir) { + throw new Error('Test directory not initialized. Call setup() first.'); + } + const dirPath = join(this.testDir, dirName); + await mkdir(dirPath, { recursive: true }); + return dirPath; + } + + /** + * Check if a file exists in the test directory + */ + fileExists(fileName: string): boolean { + if (!this.testDir) { + throw new Error('Test directory not initialized. Call setup() first.'); + } + const filePath = join(this.testDir, fileName); + return existsSync(filePath); + } + + /** + * Get the full path to a file in the test directory + */ + getPath(fileName: string): string { + if (!this.testDir) { + throw new Error('Test directory not initialized. Call setup() first.'); + } + return join(this.testDir, fileName); + } + + /** + * Cleanup test directory + */ + async cleanup(): Promise { + if (this.testDir && process.env['KEEP_OUTPUT'] !== 'true') { + try { + await rm(this.testDir, { recursive: true, force: true }); + } catch (error) { + if (process.env['VERBOSE'] === 'true') { + console.warn('Cleanup warning:', (error as Error).message); + } + } + } + } + + /** + * Sanitize test name to create valid directory name + */ + private sanitizeTestName(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]/g, '-') + .replace(/-+/g, '-') + .substring(0, 100); // Limit length + } +} + +// ============================================================================ +// MCP Server Utilities +// ============================================================================ + +export interface MCPServerConfig { + command: string; + args: string[]; +} + +export interface MCPServerResult { + scriptPath: string; + config: MCPServerConfig; +} + +/** + * Built-in MCP server template: Math server with add and multiply tools + */ +const MCP_MATH_SERVER_SCRIPT = `#!/usr/bin/env node +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +const readline = require('readline'); +const fs = require('fs'); + +// Debug logging to stderr (only when MCP_DEBUG or VERBOSE is set) +const debugEnabled = process.env['MCP_DEBUG'] === 'true' || process.env['VERBOSE'] === 'true'; +function debug(msg) { + if (debugEnabled) { + fs.writeSync(2, \`[MCP-DEBUG] \${msg}\\n\`); + } +} + +debug('MCP server starting...'); + +// Simple JSON-RPC implementation for MCP +class SimpleJSONRPC { + constructor() { + this.handlers = new Map(); + this.rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false + }); + + this.rl.on('line', (line) => { + debug(\`Received line: \${line}\`); + try { + const message = JSON.parse(line); + debug(\`Parsed message: \${JSON.stringify(message)}\`); + this.handleMessage(message); + } catch (e) { + debug(\`Parse error: \${e.message}\`); + } + }); + } + + send(message) { + const msgStr = JSON.stringify(message); + debug(\`Sending message: \${msgStr}\`); + process.stdout.write(msgStr + '\\n'); + } + + async handleMessage(message) { + if (message.method && this.handlers.has(message.method)) { + try { + const result = await this.handlers.get(message.method)(message.params || {}); + if (message.id !== undefined) { + this.send({ + jsonrpc: '2.0', + id: message.id, + result + }); + } + } catch (error) { + if (message.id !== undefined) { + this.send({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32603, + message: error.message + } + }); + } + } + } else if (message.id !== undefined) { + this.send({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32601, + message: 'Method not found' + } + }); + } + } + + on(method, handler) { + this.handlers.set(method, handler); + } +} + +// Create MCP server +const rpc = new SimpleJSONRPC(); + +// Handle initialize +rpc.on('initialize', async (params) => { + debug('Handling initialize request'); + return { + protocolVersion: '2024-11-05', + capabilities: { + tools: {} + }, + serverInfo: { + name: 'test-math-server', + version: '1.0.0' + } + }; +}); + +// Handle tools/list +rpc.on('tools/list', async () => { + debug('Handling tools/list request'); + return { + tools: [ + { + name: 'add', + description: 'Add two numbers together', + inputSchema: { + type: 'object', + properties: { + a: { type: 'number', description: 'First number' }, + b: { type: 'number', description: 'Second number' } + }, + required: ['a', 'b'] + } + }, + { + name: 'multiply', + description: 'Multiply two numbers together', + inputSchema: { + type: 'object', + properties: { + a: { type: 'number', description: 'First number' }, + b: { type: 'number', description: 'Second number' } + }, + required: ['a', 'b'] + } + } + ] + }; +}); + +// Handle tools/call +rpc.on('tools/call', async (params) => { + debug(\`Handling tools/call request for tool: \${params.name}\`); + + if (params.name === 'add') { + const { a, b } = params.arguments; + return { + content: [{ + type: 'text', + text: String(a + b) + }] + }; + } + + if (params.name === 'multiply') { + const { a, b } = params.arguments; + return { + content: [{ + type: 'text', + text: String(a * b) + }] + }; + } + + throw new Error('Unknown tool: ' + params.name); +}); + +// Send initialization notification +rpc.send({ + jsonrpc: '2.0', + method: 'initialized' +}); +`; + +/** + * Create an MCP server script in the test directory + * @param helper - SDKTestHelper instance + * @param type - Type of MCP server ('math' or provide custom script) + * @param serverName - Name of the MCP server (default: 'test-math-server') + * @param customScript - Custom MCP server script (if type is not 'math') + * @returns Object with scriptPath and config + */ +export async function createMCPServer( + helper: SDKTestHelper, + type: 'math' | 'custom' = 'math', + serverName: string = 'test-math-server', + customScript?: string, +): Promise { + if (!helper.testDir) { + throw new Error('Test directory not initialized. Call setup() first.'); + } + + const script = type === 'math' ? MCP_MATH_SERVER_SCRIPT : customScript; + if (!script) { + throw new Error('Custom script required when type is "custom"'); + } + + const scriptPath = join(helper.testDir, `${serverName}.cjs`); + await writeFile(scriptPath, script, 'utf-8'); + + // Make script executable on Unix-like systems + if (process.platform !== 'win32') { + await chmod(scriptPath, 0o755); + } + + return { + scriptPath, + config: { + command: 'node', + args: [scriptPath], + }, + }; +} + +// ============================================================================ +// Message & Content Utilities +// ============================================================================ + +/** + * Extract text from ContentBlock array + */ +export function extractText(content: ContentBlock[]): string { + return content + .filter((block): block is TextBlock => block.type === 'text') + .map((block) => block.text) + .join(''); +} + +/** + * Collect messages by type + */ +export function collectMessagesByType( + messages: SDKMessage[], + predicate: (msg: SDKMessage) => msg is T, +): T[] { + return messages.filter(predicate); +} + +/** + * Find tool use blocks in a message + */ +export function findToolUseBlocks( + message: SDKAssistantMessage, + toolName?: string, +): ToolUseBlock[] { + const toolUseBlocks = message.message.content.filter( + (block): block is ToolUseBlock => block.type === 'tool_use', + ); + + if (toolName) { + return toolUseBlocks.filter((block) => block.name === toolName); + } + + return toolUseBlocks; +} + +/** + * Extract all assistant text from messages + */ +export function getAssistantText(messages: SDKMessage[]): string { + return messages + .filter(isSDKAssistantMessage) + .map((msg) => extractText(msg.message.content)) + .join(''); +} + +/** + * Find system message with optional subtype filter + */ +export function findSystemMessage( + messages: SDKMessage[], + subtype?: string, +): SDKSystemMessage | null { + const systemMessages = messages.filter(isSDKSystemMessage); + + if (subtype) { + return systemMessages.find((msg) => msg.subtype === subtype) || null; + } + + return systemMessages[0] || null; +} + +/** + * Find all tool calls in messages + */ +export function findToolCalls( + messages: SDKMessage[], + toolName?: string, +): Array<{ message: SDKAssistantMessage; toolUse: ToolUseBlock }> { + const results: Array<{ + message: SDKAssistantMessage; + toolUse: ToolUseBlock; + }> = []; + + for (const message of messages) { + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks(message, toolName); + for (const toolUse of toolUseBlocks) { + results.push({ message, toolUse }); + } + } + } + + return results; +} + +// ============================================================================ +// Streaming Input Utilities +// ============================================================================ + +/** + * Create a simple streaming input from an array of message contents + */ +export async function* createStreamingInput( + messageContents: string[], + sessionId?: string, +): AsyncIterable { + const sid = sessionId || crypto.randomUUID(); + + for (const content of messageContents) { + yield { + type: 'user', + session_id: sid, + message: { + role: 'user', + content: content, + }, + parent_tool_use_id: null, + } as SDKUserMessage; + + // Small delay between messages + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} + +/** + * Create a controlled streaming input with pause/resume capability + */ +export function createControlledStreamingInput( + messageContents: string[], + sessionId?: string, +): { + generator: AsyncIterable; + resume: () => void; + resumeAll: () => void; +} { + const sid = sessionId || crypto.randomUUID(); + const resumeResolvers: Array<() => void> = []; + const resumePromises: Array> = []; + + // Create a resume promise for each message after the first + for (let i = 1; i < messageContents.length; i++) { + const promise = new Promise((resolve) => { + resumeResolvers.push(resolve); + }); + resumePromises.push(promise); + } + + const generator = (async function* () { + // Yield first message immediately + yield { + type: 'user', + session_id: sid, + message: { + role: 'user', + content: messageContents[0], + }, + parent_tool_use_id: null, + } as SDKUserMessage; + + // For subsequent messages, wait for resume + for (let i = 1; i < messageContents.length; i++) { + await new Promise((resolve) => setTimeout(resolve, 200)); + await resumePromises[i - 1]; + await new Promise((resolve) => setTimeout(resolve, 200)); + + yield { + type: 'user', + session_id: sid, + message: { + role: 'user', + content: messageContents[i], + }, + parent_tool_use_id: null, + } as SDKUserMessage; + } + })(); + + let currentResumeIndex = 0; + + return { + generator, + resume: () => { + if (currentResumeIndex < resumeResolvers.length) { + resumeResolvers[currentResumeIndex](); + currentResumeIndex++; + } + }, + resumeAll: () => { + resumeResolvers.forEach((resolve) => resolve()); + currentResumeIndex = resumeResolvers.length; + }, + }; +} + +// ============================================================================ +// Assertion Utilities +// ============================================================================ + +/** + * Assert that messages follow expected type sequence + */ +export function assertMessageSequence( + messages: SDKMessage[], + expectedTypes: string[], +): void { + const actualTypes = messages.map((msg) => msg.type); + + if (actualTypes.length < expectedTypes.length) { + throw new Error( + `Expected at least ${expectedTypes.length} messages, got ${actualTypes.length}`, + ); + } + + for (let i = 0; i < expectedTypes.length; i++) { + if (actualTypes[i] !== expectedTypes[i]) { + throw new Error( + `Expected message ${i} to be type '${expectedTypes[i]}', got '${actualTypes[i]}'`, + ); + } + } +} + +/** + * Assert that a specific tool was called + */ +export function assertToolCalled( + messages: SDKMessage[], + toolName: string, +): void { + const toolCalls = findToolCalls(messages, toolName); + + if (toolCalls.length === 0) { + const allToolCalls = findToolCalls(messages); + const allToolNames = allToolCalls.map((tc) => tc.toolUse.name); + throw new Error( + `Expected tool '${toolName}' to be called. Found tools: ${allToolNames.length > 0 ? allToolNames.join(', ') : 'none'}`, + ); + } +} + +/** + * Assert that the conversation completed successfully + */ +export function assertSuccessfulCompletion(messages: SDKMessage[]): void { + const lastMessage = messages[messages.length - 1]; + + if (!isSDKResultMessage(lastMessage)) { + throw new Error( + `Expected last message to be a result message, got '${lastMessage.type}'`, + ); + } + + if (lastMessage.subtype !== 'success') { + throw new Error( + `Expected successful completion, got result subtype '${lastMessage.subtype}'`, + ); + } +} + +/** + * Wait for a condition to be true with timeout + */ +export async function waitFor( + predicate: () => boolean | Promise, + options: { + timeout?: number; + interval?: number; + errorMessage?: string; + } = {}, +): Promise { + const { + timeout = 5000, + interval = 100, + errorMessage = 'Condition not met within timeout', + } = options; + + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + const result = await predicate(); + if (result) { + return; + } + await new Promise((resolve) => setTimeout(resolve, interval)); + } + + throw new Error(errorMessage); +} + +// ============================================================================ +// Debug and Validation Utilities +// ============================================================================ + +/** + * Validate model output and warn about unexpected content + * Inspired by integration-tests test-helper + */ +export function validateModelOutput( + result: string, + expectedContent: string | (string | RegExp)[] | null = null, + testName = '', +): boolean { + // First, check if there's any output at all + if (!result || result.trim().length === 0) { + throw new Error('Expected model to return some output'); + } + + // If expectedContent is provided, check for it and warn if missing + if (expectedContent) { + const contents = Array.isArray(expectedContent) + ? expectedContent + : [expectedContent]; + const missingContent = contents.filter((content) => { + if (typeof content === 'string') { + return !result.toLowerCase().includes(content.toLowerCase()); + } else if (content instanceof RegExp) { + return !content.test(result); + } + return false; + }); + + if (missingContent.length > 0) { + console.warn( + `Warning: Model did not include expected content in response: ${missingContent.join(', ')}.`, + 'This is not ideal but not a test failure.', + ); + console.warn( + 'The tool was called successfully, which is the main requirement.', + ); + return false; + } else if (process.env['VERBOSE'] === 'true') { + console.log(`${testName}: Model output validated successfully.`); + } + return true; + } + + return true; +} + +/** + * Print debug information when tests fail + */ +export function printDebugInfo( + messages: SDKMessage[], + context: Record = {}, +): void { + console.error('Test failed - Debug info:'); + console.error('Message count:', messages.length); + + // Print message types + const messageTypes = messages.map((m) => m.type); + console.error('Message types:', messageTypes.join(', ')); + + // Print assistant text + const assistantText = getAssistantText(messages); + console.error( + 'Assistant text (first 500 chars):', + assistantText.substring(0, 500), + ); + if (assistantText.length > 500) { + console.error( + 'Assistant text (last 500 chars):', + assistantText.substring(assistantText.length - 500), + ); + } + + // Print tool calls + const toolCalls = findToolCalls(messages); + console.error( + 'Tool calls found:', + toolCalls.map((tc) => tc.toolUse.name), + ); + + // Print any additional context provided + Object.entries(context).forEach(([key, value]) => { + console.error(`${key}:`, value); + }); +} + +/** + * Create detailed error message for tool call expectations + */ +export function createToolCallErrorMessage( + expectedTools: string | string[], + foundTools: string[], + messages: SDKMessage[], +): string { + const expectedStr = Array.isArray(expectedTools) + ? expectedTools.join(' or ') + : expectedTools; + + const assistantText = getAssistantText(messages); + const preview = assistantText + ? assistantText.substring(0, 200) + '...' + : 'no output'; + + return ( + `Expected to find ${expectedStr} tool call(s). ` + + `Found: ${foundTools.length > 0 ? foundTools.join(', ') : 'none'}. ` + + `Output preview: ${preview}` + ); +} + +// ============================================================================ +// Shared Test Options Helper +// ============================================================================ + +/** + * Create shared test options with CLI path + */ +export function createSharedTestOptions( + overrides: Record = {}, +) { + const TEST_CLI_PATH = process.env['TEST_CLI_PATH']; + if (!TEST_CLI_PATH) { + throw new Error('TEST_CLI_PATH environment variable not set'); + } + + return { + pathToQwenExecutable: TEST_CLI_PATH, + ...overrides, + }; +} diff --git a/packages/sdk-typescript/test/unit/Query.test.ts b/packages/sdk-typescript/test/unit/Query.test.ts index 9b8e34c26f..b7309a1923 100644 --- a/packages/sdk-typescript/test/unit/Query.test.ts +++ b/packages/sdk-typescript/test/unit/Query.test.ts @@ -7,12 +7,12 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { Query } from '../../src/query/Query.js'; import type { Transport } from '../../src/transport/Transport.js'; import type { - CLIMessage, - CLIUserMessage, - CLIAssistantMessage, - CLISystemMessage, - CLIResultMessage, - CLIPartialAssistantMessage, + SDKMessage, + SDKUserMessage, + SDKAssistantMessage, + SDKSystemMessage, + SDKResultMessage, + SDKPartialAssistantMessage, CLIControlRequest, CLIControlResponse, ControlCancelRequest, @@ -118,7 +118,7 @@ function findControlRequest( function createUserMessage( content: string, sessionId = 'test-session', -): CLIUserMessage { +): SDKUserMessage { return { type: 'user', session_id: sessionId, @@ -133,7 +133,7 @@ function createUserMessage( function createAssistantMessage( content: string, sessionId = 'test-session', -): CLIAssistantMessage { +): SDKAssistantMessage { return { type: 'assistant', uuid: 'msg-123', @@ -153,7 +153,7 @@ function createAssistantMessage( function createSystemMessage( subtype: string, sessionId = 'test-session', -): CLISystemMessage { +): SDKSystemMessage { return { type: 'system', subtype, @@ -168,7 +168,7 @@ function createSystemMessage( function createResultMessage( success: boolean, sessionId = 'test-session', -): CLIResultMessage { +): SDKResultMessage { if (success) { return { type: 'result', @@ -202,7 +202,7 @@ function createResultMessage( function createPartialMessage( sessionId = 'test-session', -): CLIPartialAssistantMessage { +): SDKPartialAssistantMessage { return { type: 'stream_event', uuid: 'stream-123', @@ -816,7 +816,7 @@ describe('Query', () => { msg !== null && 'type' in msg && msg.type === 'user', - ) as CLIUserMessage[]; + ) as SDKUserMessage[]; userMessages.forEach((msg) => { expect(msg.session_id).toBe(sessionId); @@ -889,7 +889,7 @@ describe('Query', () => { const query = new Query(transport, { cwd: '/test' }); const iterationPromise = (async () => { - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; for await (const msg of query) { messages.push(msg); } @@ -946,7 +946,7 @@ describe('Query', () => { it('should support for await loop', async () => { const query = new Query(transport, { cwd: '/test' }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; const iterationPromise = (async () => { for await (const msg of query) { messages.push(msg); @@ -960,7 +960,7 @@ describe('Query', () => { await iterationPromise; expect(messages).toHaveLength(2); - expect((messages[0] as CLIUserMessage).message.content).toBe('First'); + expect((messages[0] as SDKUserMessage).message.content).toBe('First'); await query.close(); }); @@ -968,7 +968,7 @@ describe('Query', () => { it('should complete iteration when query closes', async () => { const query = new Query(transport, { cwd: '/test' }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; const iterationPromise = (async () => { for await (const msg of query) { messages.push(msg); @@ -1321,7 +1321,7 @@ describe('Query', () => { const result = await query.next(); expect(result.done).toBe(false); - expect((result.value as CLIResultMessage).is_error).toBe(true); + expect((result.value as SDKResultMessage).is_error).toBe(true); await query.close(); }); @@ -1430,7 +1430,7 @@ describe('Query', () => { transport.simulateMessage(createUserMessage(`Message ${i}`)); } - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; for (let i = 0; i < 100; i++) { const result = await query.next(); if (!result.done) { @@ -1447,7 +1447,7 @@ describe('Query', () => { const query = new Query(transport, { cwd: '/test' }); const iterationPromise = (async () => { - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; for await (const msg of query) { messages.push(msg); if (messages.length === 2) { From 249b141f1965aaa4dccf6dd301e595ab75b32049 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Fri, 28 Nov 2025 16:47:45 +0800 Subject: [PATCH 10/22] feat: add `allowedTools` for SDK use and re-organize test setup --- packages/cli/src/config/config.ts | 9 +- packages/core/src/core/coreToolScheduler.ts | 5 +- packages/sdk-typescript/src/query/Query.ts | 25 +- .../sdk-typescript/src/query/createQuery.ts | 1 + .../test/e2e/abort-and-lifecycle.test.ts | 48 +- .../test/e2e/configuration-options.test.ts | 620 +++++++++++++++ .../test/e2e/mcp-server.test.ts | 6 +- .../test/e2e/multi-turn.test.ts | 29 +- .../test/e2e/permission-control.test.ts | 472 ++++------- .../test/e2e/single-turn.test.ts | 29 +- .../sdk-typescript/test/e2e/subagents.test.ts | 6 +- .../test/e2e/system-control.test.ts | 26 +- .../sdk-typescript/test/e2e/test-helper.ts | 141 ++++ .../test/e2e/tool-control.test.ts | 748 ++++++++++++++++++ packages/sdk-typescript/vitest.config.ts | 8 + 15 files changed, 1779 insertions(+), 394 deletions(-) create mode 100644 packages/sdk-typescript/test/e2e/configuration-options.test.ts create mode 100644 packages/sdk-typescript/test/e2e/tool-control.test.ts diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index a35ef29339..3212996d29 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -425,7 +425,6 @@ export async function parseArguments(settings: Settings): Promise { string: true, description: 'Core tool paths', coerce: (tools: string[]) => - // Handle comma-separated values tools.flatMap((tool) => tool.split(',').map((t) => t.trim())), }) .option('exclude-tools', { @@ -433,7 +432,13 @@ export async function parseArguments(settings: Settings): Promise { string: true, description: 'Tools to exclude', coerce: (tools: string[]) => - // Handle comma-separated values + tools.flatMap((tool) => tool.split(',').map((t) => t.trim())), + }) + .option('allowed-tools', { + type: 'array', + string: true, + description: 'Tools to allow, will bypass confirmation', + coerce: (tools: string[]) => tools.flatMap((tool) => tool.split(',').map((t) => t.trim())), }) .option('auth-type', { diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 93f3b6e186..aeffdfc78b 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -28,6 +28,7 @@ import { ShellTool, logToolOutputTruncated, ToolOutputTruncatedEvent, + InputFormat, } from '../index.js'; import type { Part, PartListUnion } from '@google/genai'; import { getResponseTextFromParts } from '../utils/generateContentResponseUtilities.js'; @@ -824,10 +825,10 @@ export class CoreToolScheduler { const shouldAutoDeny = !this.config.isInteractive() && !this.config.getIdeMode() && - !this.config.getExperimentalZedIntegration(); + !this.config.getExperimentalZedIntegration() && + this.config.getInputFormat() !== InputFormat.STREAM_JSON; if (shouldAutoDeny) { - // Treat as execution denied error, similar to excluded tools const errorMessage = `Qwen Code requires permission to use "${reqInfo.name}", but that permission was declined.`; this.setStatusInternal( reqInfo.callId, diff --git a/packages/sdk-typescript/src/query/Query.ts b/packages/sdk-typescript/src/query/Query.ts index d34d6fa4e1..849b0d7b29 100644 --- a/packages/sdk-typescript/src/query/Query.ts +++ b/packages/sdk-typescript/src/query/Query.ts @@ -296,32 +296,17 @@ export class Query implements AsyncIterable { timeoutPromise, ]); - // Handle boolean return (backward compatibility) - if (typeof result === 'boolean') { - return result - ? { behavior: 'allow', updatedInput: toolInput } - : { behavior: 'deny', message: 'Denied' }; - } - - // Handle PermissionResult format - const permissionResult = result as { - behavior: 'allow' | 'deny'; - updatedInput?: Record; - message?: string; - interrupt?: boolean; - }; - - if (permissionResult.behavior === 'allow') { + if (result.behavior === 'allow') { return { behavior: 'allow', - updatedInput: permissionResult.updatedInput ?? toolInput, + updatedInput: result.updatedInput ?? toolInput, }; } else { return { behavior: 'deny', - message: permissionResult.message ?? 'Denied', - ...(permissionResult.interrupt !== undefined - ? { interrupt: permissionResult.interrupt } + message: result.message ?? 'Denied', + ...(result.interrupt !== undefined + ? { interrupt: result.interrupt } : {}), }; } diff --git a/packages/sdk-typescript/src/query/createQuery.ts b/packages/sdk-typescript/src/query/createQuery.ts index 2b39dafacd..43ccf94781 100644 --- a/packages/sdk-typescript/src/query/createQuery.ts +++ b/packages/sdk-typescript/src/query/createQuery.ts @@ -54,6 +54,7 @@ export function query({ maxSessionTurns: options.maxSessionTurns, coreTools: options.coreTools, excludeTools: options.excludeTools, + allowedTools: options.allowedTools, authType: options.authType, includePartialMessages: options.includePartialMessages, }); diff --git a/packages/sdk-typescript/test/e2e/abort-and-lifecycle.test.ts b/packages/sdk-typescript/test/e2e/abort-and-lifecycle.test.ts index a97d3db698..806a4a201f 100644 --- a/packages/sdk-typescript/test/e2e/abort-and-lifecycle.test.ts +++ b/packages/sdk-typescript/test/e2e/abort-and-lifecycle.test.ts @@ -5,25 +5,32 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { query, AbortError, isAbortError, - isCLIAssistantMessage, + isSDKAssistantMessage, type TextBlock, type ContentBlock, } from '../../src/index.js'; +import { SDKTestHelper, createSharedTestOptions } from './test-helper.js'; -const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!; - -const SHARED_TEST_OPTIONS = { - pathToQwenExecutable: TEST_CLI_PATH, -}; +const SHARED_TEST_OPTIONS = createSharedTestOptions(); describe('AbortController and Process Lifecycle (E2E)', () => { + let helper: SDKTestHelper; + let testDir: string; + + beforeEach(async () => { + helper = new SDKTestHelper(); + testDir = await helper.setup('abort-and-lifecycle'); + }); + + afterEach(async () => { + await helper.cleanup(); + }); describe('Basic AbortController Usage', () => { - /* TODO: Currently query does not throw AbortError when aborted */ it('should support AbortController cancellation', async () => { const controller = new AbortController(); @@ -36,6 +43,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { prompt: 'Write a very long story about TypeScript programming', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, abortController: controller, debug: false, }, @@ -43,7 +51,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { try { for await (const message of q) { - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { const textBlocks = message.message.content.filter( (block): block is TextBlock => block.type === 'text', ); @@ -73,6 +81,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { prompt: 'Hello', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, abortController: controller, debug: false, }, @@ -82,7 +91,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { try { for await (const message of q) { - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { if (!receivedFirstMessage) { // Abort immediately after receiving first assistant message receivedFirstMessage = true; @@ -107,6 +116,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { prompt: 'Write a very long essay', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, abortController: controller, debug: false, }, @@ -136,6 +146,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { prompt: 'Why do we choose to go to the moon?', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -144,7 +155,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { try { for await (const message of q) { - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { const textBlocks = message.message.content.filter( (block): block is TextBlock => block.type === 'text', ); @@ -171,13 +182,14 @@ describe('AbortController and Process Lifecycle (E2E)', () => { prompt: 'Hello world', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); try { for await (const message of q) { - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { const textBlocks = message.message.content.filter( (block): block is TextBlock => block.type === 'text', ); @@ -204,6 +216,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { prompt: 'What is 2 + 2?', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -213,7 +226,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { try { for await (const message of q) { - if (isCLIAssistantMessage(message) && !endInputCalled) { + if (isSDKAssistantMessage(message) && !endInputCalled) { const textBlocks = message.message.content.filter( (block: ContentBlock): block is TextBlock => block.type === 'text', @@ -271,6 +284,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { prompt: 'Explain the concept of async programming', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, abortController: controller, debug: false, }, @@ -303,6 +317,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { prompt: 'Why do we choose to go to the moon?', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: true, stderr: (msg: string) => { stderrMessages.push(msg); @@ -312,7 +327,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { try { for await (const message of q) { - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { const textBlocks = message.message.content.filter( (block): block is TextBlock => block.type === 'text', ); @@ -336,6 +351,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { prompt: 'Hello', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, stderr: (msg: string) => { stderrMessages.push(msg); @@ -363,6 +379,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { prompt: 'Write a very long essay about programming', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, abortController: controller, debug: false, }, @@ -394,6 +411,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { prompt: 'Count to 100', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, abortController: controller, debug: false, }, @@ -422,6 +440,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { prompt: 'Hello', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -446,6 +465,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { prompt: 'Hello', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, abortController: controller, debug: false, }, diff --git a/packages/sdk-typescript/test/e2e/configuration-options.test.ts b/packages/sdk-typescript/test/e2e/configuration-options.test.ts new file mode 100644 index 0000000000..ddf94cd580 --- /dev/null +++ b/packages/sdk-typescript/test/e2e/configuration-options.test.ts @@ -0,0 +1,620 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * E2E tests for SDK configuration options: + * - logLevel: Controls SDK internal logging verbosity + * - env: Environment variables passed to CLI process + * - authType: Authentication type for AI service + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { query } from '../../src/index.js'; +import { + isSDKAssistantMessage, + isSDKSystemMessage, + type SDKMessage, +} from '../../src/types/protocol.js'; +import { + SDKTestHelper, + extractText, + createSharedTestOptions, + assertSuccessfulCompletion, +} from './test-helper.js'; + +const SHARED_TEST_OPTIONS = createSharedTestOptions(); + +describe('Configuration Options (E2E)', () => { + let helper: SDKTestHelper; + let testDir: string; + + beforeEach(async () => { + helper = new SDKTestHelper(); + testDir = await helper.setup('configuration-options'); + }); + + afterEach(async () => { + await helper.cleanup(); + }); + + describe('logLevel Option', () => { + it('should respect logLevel: debug and capture detailed logs', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'What is 1 + 1? Just answer the number.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + logLevel: 'debug', + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Debug level should produce verbose logging + expect(stderrMessages.length).toBeGreaterThan(0); + + // Debug logs should contain detailed information like [DEBUG] + const hasDebugLogs = stderrMessages.some( + (msg) => msg.includes('[DEBUG]') || msg.includes('debug'), + ); + expect(hasDebugLogs).toBe(true); + + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + + it('should respect logLevel: info and filter out debug messages', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'What is 2 + 2? Just answer the number.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + logLevel: 'info', + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Info level should filter out debug messages + // Check that we don't have [DEBUG] level messages from the SDK logger + const sdkDebugLogs = stderrMessages.filter( + (msg) => + msg.includes('[DEBUG]') && msg.includes('[ProcessTransport]'), + ); + expect(sdkDebugLogs.length).toBe(0); + + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + + it('should respect logLevel: warn and only show warnings and errors', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + logLevel: 'warn', + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Warn level should filter out info and debug messages from SDK + const sdkInfoOrDebugLogs = stderrMessages.filter( + (msg) => + (msg.includes('[DEBUG]') || msg.includes('[INFO]')) && + (msg.includes('[ProcessTransport]') || + msg.includes('[createQuery]') || + msg.includes('[Query]')), + ); + expect(sdkInfoOrDebugLogs.length).toBe(0); + + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + + it('should respect logLevel: error and only show error messages', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Hello world', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + logLevel: 'error', + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Error level should filter out all non-error messages from SDK + const sdkNonErrorLogs = stderrMessages.filter( + (msg) => + (msg.includes('[DEBUG]') || + msg.includes('[INFO]') || + msg.includes('[WARN]')) && + (msg.includes('[ProcessTransport]') || + msg.includes('[createQuery]') || + msg.includes('[Query]')), + ); + expect(sdkNonErrorLogs.length).toBe(0); + + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + + it('should use logLevel over debug flag when both are provided', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'What is 3 + 3?', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: true, // Would normally enable debug logging + logLevel: 'error', // But logLevel should take precedence + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + try { + for await (const _message of q) { + // Consume all messages + } + + // logLevel: error should suppress debug/info/warn even with debug: true + const sdkNonErrorLogs = stderrMessages.filter( + (msg) => + (msg.includes('[DEBUG]') || + msg.includes('[INFO]') || + msg.includes('[WARN]')) && + (msg.includes('[ProcessTransport]') || + msg.includes('[createQuery]') || + msg.includes('[Query]')), + ); + expect(sdkNonErrorLogs.length).toBe(0); + } finally { + await q.close(); + } + }); + }); + + describe('env Option', () => { + it('should pass custom environment variables to CLI process', async () => { + const q = query({ + prompt: 'What is 1 + 1? Just the number please.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + env: { + CUSTOM_TEST_VAR: 'test_value_12345', + ANOTHER_VAR: 'another_value', + }, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // The query should complete successfully with custom env vars + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + + it('should allow overriding existing environment variables', async () => { + // Store original value for comparison + const originalPath = process.env['PATH']; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + env: { + // Override an existing env var (not PATH as it might break things) + MY_TEST_OVERRIDE: 'overridden_value', + }, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Query should complete successfully + assertSuccessfulCompletion(messages); + + // Verify original process env is not modified + expect(process.env['PATH']).toBe(originalPath); + } finally { + await q.close(); + } + }); + + it('should work with empty env object', async () => { + const q = query({ + prompt: 'What is 2 + 2?', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + env: {}, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + + it('should support setting model-related environment variables', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + env: { + // Common model-related env vars that CLI might respect + OPENAI_API_KEY: process.env['OPENAI_API_KEY'] || 'test-key', + }, + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Should complete (may succeed or fail based on API key validity) + expect(messages.length).toBeGreaterThan(0); + } finally { + await q.close(); + } + }); + + it('should not leak env vars between query instances', async () => { + // First query with specific env var + const q1 = query({ + prompt: 'Say one', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + env: { + ISOLATED_VAR_1: 'value_1', + }, + debug: false, + }, + }); + + try { + for await (const _message of q1) { + // Consume messages + } + } finally { + await q1.close(); + } + + // Second query with different env var + const q2 = query({ + prompt: 'Say two', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + env: { + ISOLATED_VAR_2: 'value_2', + }, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q2) { + messages.push(message); + } + + // Second query should complete successfully + assertSuccessfulCompletion(messages); + + // Verify process.env is not polluted by either query + expect(process.env['ISOLATED_VAR_1']).toBeUndefined(); + expect(process.env['ISOLATED_VAR_2']).toBeUndefined(); + } finally { + await q2.close(); + } + }); + }); + + describe('authType Option', () => { + it('should accept authType: openai', async () => { + const q = query({ + prompt: 'What is 1 + 1? Just the number.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + authType: 'openai', + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Query should complete with openai auth type + assertSuccessfulCompletion(messages); + + // Verify we got an assistant response + const assistantMessages = messages.filter(isSDKAssistantMessage); + expect(assistantMessages.length).toBeGreaterThan(0); + } finally { + await q.close(); + } + }); + + it('should accept authType: qwen-oauth', async () => { + // Note: qwen-oauth requires credentials in ~/.qwen + // This test may fail if credentials are not configured + // The test verifies the option is accepted and passed correctly + + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + authType: 'qwen-oauth', + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // The query should at least start (may fail due to missing credentials) + expect(messages.length).toBeGreaterThan(0); + } catch (error) { + // qwen-oauth may fail if credentials are not configured + // This is acceptable - we're testing that the option is passed correctly + expect(error).toBeDefined(); + } finally { + await q.close(); + } + }); + + it('should use default auth when authType is not specified', async () => { + const q = query({ + prompt: 'What is 2 + 2? Just the number.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + // authType not specified - should use default + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Query should complete with default auth + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + + it('should properly pass authType to CLI process', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Say hi', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + authType: 'openai', + debug: true, + logLevel: 'debug', + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // There should be spawn log containing auth-type + const hasAuthTypeArg = stderrMessages.some((msg) => + msg.includes('--auth-type'), + ); + expect(hasAuthTypeArg).toBe(true); + + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + }); + + describe('Combined Options', () => { + it('should work with logLevel, env, and authType together', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'What is 3 + 3? Just the number.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + logLevel: 'debug', + env: { + COMBINED_TEST_VAR: 'combined_value', + }, + authType: 'openai', + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + const messages: SDKMessage[] = []; + let assistantText = ''; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + assistantText += extractText(message.message.content); + } + } + + // All three options should work together + expect(stderrMessages.length).toBeGreaterThan(0); // logLevel: debug produces logs + expect(assistantText).toMatch(/6/); // Query should work + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + + it('should maintain system message consistency with all options', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + logLevel: 'info', + env: { + SYSTEM_MSG_TEST: 'test', + }, + authType: 'openai', + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Should have system init message + const systemMessages = messages.filter(isSDKSystemMessage); + const initMessage = systemMessages.find((m) => m.subtype === 'init'); + + expect(initMessage).toBeDefined(); + expect(initMessage!.session_id).toBeDefined(); + expect(initMessage!.tools).toBeDefined(); + expect(initMessage!.permissionMode).toBeDefined(); + + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + }); +}); diff --git a/packages/sdk-typescript/test/e2e/mcp-server.test.ts b/packages/sdk-typescript/test/e2e/mcp-server.test.ts index 868fb95941..dd13d2053d 100644 --- a/packages/sdk-typescript/test/e2e/mcp-server.test.ts +++ b/packages/sdk-typescript/test/e2e/mcp-server.test.ts @@ -9,7 +9,7 @@ * Tests that the SDK can properly interact with MCP servers configured in qwen-code */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { query } from '../../src/index.js'; import { isSDKAssistantMessage, @@ -38,7 +38,7 @@ describe('MCP Server Integration (E2E)', () => { let serverScriptPath: string; let testDir: string; - beforeAll(async () => { + beforeEach(async () => { // Create isolated test environment using SDKTestHelper helper = new SDKTestHelper(); testDir = await helper.setup('mcp-server-integration'); @@ -48,7 +48,7 @@ describe('MCP Server Integration (E2E)', () => { serverScriptPath = mcpServer.scriptPath; }); - afterAll(async () => { + afterEach(async () => { // Cleanup test directory await helper.cleanup(); }); diff --git a/packages/sdk-typescript/test/e2e/multi-turn.test.ts b/packages/sdk-typescript/test/e2e/multi-turn.test.ts index be49dc5ecc..689a6468b4 100644 --- a/packages/sdk-typescript/test/e2e/multi-turn.test.ts +++ b/packages/sdk-typescript/test/e2e/multi-turn.test.ts @@ -3,7 +3,7 @@ * Tests multi-turn conversation functionality with real CLI */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { query } from '../../src/index.js'; import { isSDKUserMessage, @@ -22,11 +22,9 @@ import { type ControlMessage, type ToolUseBlock, } from '../../src/types/protocol.js'; -const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!; +import { SDKTestHelper, createSharedTestOptions } from './test-helper.js'; -const SHARED_TEST_OPTIONS = { - pathToQwenExecutable: TEST_CLI_PATH, -}; +const SHARED_TEST_OPTIONS = createSharedTestOptions(); /** * Determine the message type using protocol type guards @@ -64,6 +62,18 @@ function extractText(content: ContentBlock[]): string { } describe('Multi-Turn Conversations (E2E)', () => { + let helper: SDKTestHelper; + let testDir: string; + + beforeEach(async () => { + helper = new SDKTestHelper(); + testDir = await helper.setup('multi-turn'); + }); + + afterEach(async () => { + await helper.cleanup(); + }); + describe('AsyncIterable Prompt Support', () => { it('should handle multi-turn conversation using AsyncIterable prompt', async () => { // Create multi-turn conversation generator @@ -110,6 +120,7 @@ describe('Multi-Turn Conversations (E2E)', () => { prompt: createMultiTurnConversation(), options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -173,6 +184,7 @@ describe('Multi-Turn Conversations (E2E)', () => { prompt: createContextualConversation(), options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -232,7 +244,7 @@ describe('Multi-Turn Conversations (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, permissionMode: 'yolo', - cwd: '/tmp', + cwd: testDir, debug: false, }, }); @@ -304,6 +316,7 @@ describe('Multi-Turn Conversations (E2E)', () => { prompt: createSequentialConversation(), options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -368,6 +381,7 @@ describe('Multi-Turn Conversations (E2E)', () => { prompt: createSimpleConversation(), options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -407,6 +421,7 @@ describe('Multi-Turn Conversations (E2E)', () => { prompt: createEmptyConversation(), options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -457,6 +472,7 @@ describe('Multi-Turn Conversations (E2E)', () => { prompt: createDelayedConversation(), options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -509,6 +525,7 @@ describe('Multi-Turn Conversations (E2E)', () => { prompt: createMultiTurnConversation(), options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, includePartialMessages: true, debug: false, }, diff --git a/packages/sdk-typescript/test/e2e/permission-control.test.ts b/packages/sdk-typescript/test/e2e/permission-control.test.ts index 9747bca00c..23b4cffebd 100644 --- a/packages/sdk-typescript/test/e2e/permission-control.test.ts +++ b/packages/sdk-typescript/test/e2e/permission-control.test.ts @@ -4,24 +4,36 @@ * - setPermissionMode API */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { + describe, + it, + expect, + beforeAll, + afterAll, + beforeEach, + afterEach, +} from 'vitest'; import { query } from '../../src/index.js'; import { isSDKAssistantMessage, isSDKResultMessage, isSDKUserMessage, + type SDKMessage, type SDKUserMessage, type ToolUseBlock, type ContentBlock, } from '../../src/types/protocol.js'; -const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!; -const TEST_TIMEOUT = 30000; +import { + SDKTestHelper, + createSharedTestOptions, + findAllToolResultBlocks, + hasAnyToolResults, + hasSuccessfulToolResults, + hasErrorToolResults, +} from './test-helper.js'; -const SHARED_TEST_OPTIONS = { - pathToQwenExecutable: TEST_CLI_PATH, - debug: false, - env: {}, -}; +const TEST_TIMEOUT = 30000; +const SHARED_TEST_OPTIONS = createSharedTestOptions(); /** * Factory function that creates a streaming input with a control point. @@ -80,6 +92,9 @@ function createStreamingInputWithControlPoint( } describe('Permission Control (E2E)', () => { + let helper: SDKTestHelper; + let testDir: string; + beforeAll(() => { //process.env['DEBUG'] = '1'; }); @@ -88,6 +103,15 @@ describe('Permission Control (E2E)', () => { delete process.env['DEBUG']; }); + beforeEach(async () => { + helper = new SDKTestHelper(); + testDir = await helper.setup('permission-control'); + }); + + afterEach(async () => { + await helper.cleanup(); + }); + describe('canUseTool callback parameter', () => { it('should invoke canUseTool callback when tool is requested', async () => { const toolCalls: Array<{ @@ -99,16 +123,9 @@ describe('Permission Control (E2E)', () => { prompt: 'Write a js hello world to file.', options: { ...SHARED_TEST_OPTIONS, - permissionMode: 'default', - + cwd: testDir, canUseTool: async (toolName, input) => { toolCalls.push({ toolName, input }); - /* - { - behavior: 'allow', - updatedInput: input, - }; - */ return { behavior: 'deny', message: 'Tool execution denied by user.', @@ -148,7 +165,7 @@ describe('Permission Control (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, permissionMode: 'default', - cwd: '/tmp', + cwd: testDir, canUseTool: async (toolName, input) => { callbackInvoked = true; return { @@ -188,6 +205,7 @@ describe('Permission Control (E2E)', () => { prompt: 'Create a file named test.txt', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, permissionMode: 'default', canUseTool: async () => { callbackInvoked = true; @@ -220,7 +238,7 @@ describe('Permission Control (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, permissionMode: 'default', - cwd: '/tmp', + cwd: testDir, canUseTool: async (toolName, input, options) => { receivedSuggestions = options?.suggestions; return { @@ -251,7 +269,7 @@ describe('Permission Control (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, permissionMode: 'default', - cwd: '/tmp', + cwd: testDir, canUseTool: async (toolName, input, options) => { receivedSignal = options?.signal; return { @@ -274,53 +292,13 @@ describe('Permission Control (E2E)', () => { } }); - it('should allow updatedInput modification in canUseTool callback', async () => { - const originalInputs: Record[] = []; - const updatedInputs: Record[] = []; - - const q = query({ - prompt: 'Create a file named modified.txt', - options: { - ...SHARED_TEST_OPTIONS, - permissionMode: 'default', - cwd: '/tmp', - canUseTool: async (toolName, input) => { - originalInputs.push({ ...input }); - const updatedInput = { - ...input, - modified: true, - testKey: 'testValue', - }; - updatedInputs.push(updatedInput); - return { - behavior: 'allow', - updatedInput, - }; - }, - }, - }); - - try { - for await (const _message of q) { - // Consume all messages - } - - expect(originalInputs.length).toBeGreaterThan(0); - expect(updatedInputs.length).toBeGreaterThan(0); - expect(updatedInputs[0]?.['modified']).toBe(true); - expect(updatedInputs[0]?.['testKey']).toBe('testValue'); - } finally { - await q.close(); - } - }); - it('should default to deny when canUseTool is not provided', async () => { const q = query({ prompt: 'Create a file named default.txt', options: { ...SHARED_TEST_OPTIONS, permissionMode: 'default', - cwd: '/tmp', + cwd: testDir, // canUseTool not provided }, }); @@ -350,6 +328,7 @@ describe('Permission Control (E2E)', () => { prompt: generator, options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, permissionMode: 'default', debug: true, }, @@ -426,6 +405,7 @@ describe('Permission Control (E2E)', () => { prompt: generator, options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, permissionMode: 'yolo', }, }); @@ -501,6 +481,7 @@ describe('Permission Control (E2E)', () => { prompt: generator, options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, permissionMode: 'default', }, }); @@ -539,7 +520,7 @@ describe('Permission Control (E2E)', () => { new Promise((_, reject) => setTimeout( () => reject(new Error('Timeout waiting for first response')), - 10000, + 15000, ), ), ]); @@ -571,6 +552,7 @@ describe('Permission Control (E2E)', () => { prompt: 'Hello', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, permissionMode: 'default', }, }); @@ -600,7 +582,7 @@ describe('Permission Control (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, permissionMode: 'default', - cwd: '/tmp', + cwd: testDir, canUseTool: async (toolName, input) => { toolCalls.push({ toolName, input }); return { @@ -685,40 +667,20 @@ describe('Permission Control (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, permissionMode: 'default', - cwd: '/tmp', + cwd: testDir, // No canUseTool callback provided }, }); try { - let hasToolResult = false; - let hasErrorInResult = false; - + const messages: SDKMessage[] = []; for await (const message of q) { - if (isSDKUserMessage(message)) { - if (Array.isArray(message.message.content)) { - const toolResult = message.message.content.find( - (block) => block.type === 'tool_result', - ); - if (toolResult && 'tool_use_id' in toolResult) { - hasToolResult = true; - // Check if the result contains an error about permission - if ( - 'content' in toolResult && - typeof toolResult.content === 'string' && - (toolResult.content.includes('permission') || - toolResult.content.includes('declined')) - ) { - hasErrorInResult = true; - } - } - } - } + messages.push(message); } // In default mode without canUseTool, tools should be denied - expect(hasToolResult).toBe(true); - expect(hasErrorInResult).toBe(true); + expect(hasAnyToolResults(messages)).toBe(true); + expect(hasErrorToolResults(messages)).toBe(true); } finally { await q.close(); } @@ -737,7 +699,7 @@ describe('Permission Control (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, permissionMode: 'default', - cwd: '/tmp', + cwd: testDir, canUseTool: async (toolName, input) => { callbackInvoked = true; return { @@ -749,31 +711,13 @@ describe('Permission Control (E2E)', () => { }); try { - let hasSuccessfulToolResult = false; - + const messages: SDKMessage[] = []; for await (const message of q) { - if (isSDKUserMessage(message)) { - if (Array.isArray(message.message.content)) { - const toolResult = message.message.content.find( - (block) => block.type === 'tool_result', - ); - if (toolResult && 'tool_use_id' in toolResult) { - // Check if the result is successful (not an error) - if ( - 'content' in toolResult && - typeof toolResult.content === 'string' && - !toolResult.content.includes('permission') && - !toolResult.content.includes('declined') - ) { - hasSuccessfulToolResult = true; - } - } - } - } + messages.push(message); } expect(callbackInvoked).toBe(true); - expect(hasSuccessfulToolResult).toBe(true); + expect(hasSuccessfulToolResults(messages)).toBe(true); } finally { await q.close(); } @@ -789,28 +733,18 @@ describe('Permission Control (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, permissionMode: 'default', - cwd: '/tmp', + cwd: testDir, // No canUseTool callback - read-only tools should still work }, }); try { - let hasToolResult = false; - + const messages: SDKMessage[] = []; for await (const message of q) { - if (isSDKUserMessage(message)) { - if (Array.isArray(message.message.content)) { - const toolResult = message.message.content.find( - (block) => block.type === 'tool_result', - ); - if (toolResult) { - hasToolResult = true; - } - } - } + messages.push(message); } - expect(hasToolResult).toBe(true); + expect(hasAnyToolResults(messages)).toBe(true); } finally { await q.close(); } @@ -829,36 +763,18 @@ describe('Permission Control (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, permissionMode: 'yolo', - cwd: '/tmp', + cwd: testDir, // No canUseTool callback - tools should still execute }, }); try { - let hasSuccessfulToolResult = false; - + const messages: SDKMessage[] = []; for await (const message of q) { - if (isSDKUserMessage(message)) { - if (Array.isArray(message.message.content)) { - const toolResult = message.message.content.find( - (block) => block.type === 'tool_result', - ); - if (toolResult && 'tool_use_id' in toolResult) { - // Check if the result is successful (not a permission error) - if ( - 'content' in toolResult && - typeof toolResult.content === 'string' && - !toolResult.content.includes('permission') && - !toolResult.content.includes('declined') - ) { - hasSuccessfulToolResult = true; - } - } - } - } + messages.push(message); } - expect(hasSuccessfulToolResult).toBe(true); + expect(hasSuccessfulToolResults(messages)).toBe(true); } finally { await q.close(); } @@ -876,7 +792,7 @@ describe('Permission Control (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, permissionMode: 'yolo', - cwd: '/tmp', + cwd: testDir, canUseTool: async (toolName, input) => { callbackInvoked = true; return { @@ -888,22 +804,12 @@ describe('Permission Control (E2E)', () => { }); try { - let hasToolResult = false; - + const messages: SDKMessage[] = []; for await (const message of q) { - if (isSDKUserMessage(message)) { - if (Array.isArray(message.message.content)) { - const toolResult = message.message.content.find( - (block) => block.type === 'tool_result', - ); - if (toolResult) { - hasToolResult = true; - } - } - } + messages.push(message); } - expect(hasToolResult).toBe(true); + expect(hasAnyToolResults(messages)).toBe(true); // canUseTool should not be invoked in yolo mode expect(callbackInvoked).toBe(false); } finally { @@ -921,27 +827,17 @@ describe('Permission Control (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, permissionMode: 'yolo', - cwd: '/tmp', + cwd: testDir, }, }); try { - let hasCommandResult = false; - + const messages: SDKMessage[] = []; for await (const message of q) { - if (isSDKUserMessage(message)) { - if (Array.isArray(message.message.content)) { - const toolResult = message.message.content.find( - (block) => block.type === 'tool_result', - ); - if (toolResult && 'tool_use_id' in toolResult) { - hasCommandResult = true; - } - } - } + messages.push(message); } - expect(hasCommandResult).toBe(true); + expect(hasAnyToolResults(messages)).toBe(true); } finally { await q.close(); } @@ -950,52 +846,46 @@ describe('Permission Control (E2E)', () => { ); }); - describe('plan mode', () => { + /** + * We've some issues of how to handle plan mode. + * The test cases are skipped for now. + */ + describe.skip('plan mode', () => { it( 'should block non-read-only tools and return plan mode error', async () => { const q = query({ - prompt: 'Create a file named test-plan.txt', + prompt: + 'Init a monorepo of a Node.js project with frontend and backend.', options: { ...SHARED_TEST_OPTIONS, permissionMode: 'plan', - cwd: '/tmp', + cwd: testDir, }, }); try { - let hasBlockedToolCall = false; - let hasPlanModeMessage = false; - + const messages: SDKMessage[] = []; for await (const message of q) { - if (isSDKUserMessage(message)) { - if (Array.isArray(message.message.content)) { - const toolResult = message.message.content.find( - (block) => block.type === 'tool_result', - ); - if (toolResult && 'tool_use_id' in toolResult) { - hasBlockedToolCall = true; - // Check for plan mode specific error message - if ( - 'content' in toolResult && - typeof toolResult.content === 'string' && - (toolResult.content.includes('Plan mode') || - toolResult.content.includes('plan mode')) - ) { - hasPlanModeMessage = true; - } - } - } - } + messages.push(message); } + const toolResults = findAllToolResultBlocks(messages); + const hasBlockedToolCall = toolResults.length > 0; + const hasPlanModeMessage = toolResults.some( + (result) => + result.isError && + (result.content.includes('Plan mode') || + result.content.includes('plan mode')), + ); + expect(hasBlockedToolCall).toBe(true); expect(hasPlanModeMessage).toBe(true); } finally { await q.close(); } }, - TEST_TIMEOUT, + TEST_TIMEOUT * 10, ); it( @@ -1006,34 +896,17 @@ describe('Permission Control (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, permissionMode: 'plan', - cwd: '/tmp', + cwd: testDir, }, }); try { - let hasSuccessfulToolResult = false; - + const messages: SDKMessage[] = []; for await (const message of q) { - if (isSDKUserMessage(message)) { - if (Array.isArray(message.message.content)) { - const toolResult = message.message.content.find( - (block) => block.type === 'tool_result', - ); - if (toolResult && 'tool_use_id' in toolResult) { - // Check if the result is successful (not blocked by plan mode) - if ( - 'content' in toolResult && - typeof toolResult.content === 'string' && - !toolResult.content.includes('Plan mode') - ) { - hasSuccessfulToolResult = true; - } - } - } - } + messages.push(message); } - expect(hasSuccessfulToolResult).toBe(true); + expect(hasSuccessfulToolResults(messages)).toBe(true); } finally { await q.close(); } @@ -1051,7 +924,7 @@ describe('Permission Control (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, permissionMode: 'plan', - cwd: '/tmp', + cwd: testDir, canUseTool: async (toolName, input) => { callbackInvoked = true; return { @@ -1063,26 +936,17 @@ describe('Permission Control (E2E)', () => { }); try { - let hasPlanModeBlock = false; - + const messages: SDKMessage[] = []; for await (const message of q) { - if (isSDKUserMessage(message)) { - if (Array.isArray(message.message.content)) { - const toolResult = message.message.content.find( - (block) => block.type === 'tool_result', - ); - if ( - toolResult && - 'content' in toolResult && - typeof toolResult.content === 'string' && - toolResult.content.includes('Plan mode') - ) { - hasPlanModeBlock = true; - } - } - } + messages.push(message); } + const toolResults = findAllToolResultBlocks(messages); + const hasPlanModeBlock = toolResults.some( + (result) => + result.isError && result.content.includes('Plan mode'), + ); + // Plan mode should block tools before canUseTool is invoked expect(hasPlanModeBlock).toBe(true); // canUseTool should not be invoked for blocked tools in plan mode @@ -1097,46 +961,27 @@ describe('Permission Control (E2E)', () => { describe('auto-edit mode', () => { it( - 'should behave like default mode without canUseTool callback', + 'should auto-approve write/edit tools without canUseTool callback', async () => { const q = query({ - prompt: 'Create a file named test-auto-edit.txt', + prompt: + 'Create a file named test-auto-edit.txt with content "auto-edit test"', options: { ...SHARED_TEST_OPTIONS, permissionMode: 'auto-edit', - cwd: '/tmp', - // No canUseTool callback + cwd: testDir, + // No canUseTool callback - write/edit tools should still execute }, }); try { - let hasToolResult = false; - let hasDeniedTool = false; - + const messages: SDKMessage[] = []; for await (const message of q) { - if (isSDKUserMessage(message)) { - if (Array.isArray(message.message.content)) { - const toolResult = message.message.content.find( - (block) => block.type === 'tool_result', - ); - if (toolResult && 'tool_use_id' in toolResult) { - hasToolResult = true; - // Check if the tool was denied - if ( - 'content' in toolResult && - typeof toolResult.content === 'string' && - (toolResult.content.includes('permission') || - toolResult.content.includes('declined')) - ) { - hasDeniedTool = true; - } - } - } - } + messages.push(message); } - expect(hasToolResult).toBe(true); - expect(hasDeniedTool).toBe(true); + // auto-edit mode should auto-approve write/edit tools + expect(hasSuccessfulToolResults(messages)).toBe(true); } finally { await q.close(); } @@ -1145,16 +990,16 @@ describe('Permission Control (E2E)', () => { ); it( - 'should allow tools when canUseTool returns allow', + 'should not invoke canUseTool callback for write/edit tools', async () => { let callbackInvoked = false; const q = query({ - prompt: 'Create a file named test-auto-edit-allow.txt', + prompt: 'Create a file named test-auto-edit-no-callback.txt', options: { ...SHARED_TEST_OPTIONS, permissionMode: 'auto-edit', - cwd: '/tmp', + cwd: testDir, canUseTool: async (toolName, input) => { callbackInvoked = true; return { @@ -1166,31 +1011,14 @@ describe('Permission Control (E2E)', () => { }); try { - let hasSuccessfulToolResult = false; - + const messages: SDKMessage[] = []; for await (const message of q) { - if (isSDKUserMessage(message)) { - if (Array.isArray(message.message.content)) { - const toolResult = message.message.content.find( - (block) => block.type === 'tool_result', - ); - if (toolResult && 'tool_use_id' in toolResult) { - // Check if the result is successful - if ( - 'content' in toolResult && - typeof toolResult.content === 'string' && - !toolResult.content.includes('permission') && - !toolResult.content.includes('declined') - ) { - hasSuccessfulToolResult = true; - } - } - } - } + messages.push(message); } - expect(callbackInvoked).toBe(true); - expect(hasSuccessfulToolResult).toBe(true); + // auto-edit mode should auto-approve write/edit tools without invoking callback + expect(hasSuccessfulToolResults(messages)).toBe(true); + expect(callbackInvoked).toBe(false); } finally { await q.close(); } @@ -1201,32 +1029,29 @@ describe('Permission Control (E2E)', () => { it( 'should execute read-only tools without confirmation', async () => { + // Create a test file in the test directory for the model to read + await helper.createFile( + 'test-read-file.txt', + 'This is a test file for read-only tool verification.', + ); + const q = query({ - prompt: 'Read the contents of /etc/hosts file', + prompt: 'Read the contents of test-read-file.txt file', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, permissionMode: 'auto-edit', // No canUseTool callback - read-only tools should still work }, }); try { - let hasToolResult = false; - + const messages: SDKMessage[] = []; for await (const message of q) { - if (isSDKUserMessage(message)) { - if (Array.isArray(message.message.content)) { - const toolResult = message.message.content.find( - (block) => block.type === 'tool_result', - ); - if (toolResult) { - hasToolResult = true; - } - } - } + messages.push(message); } - expect(hasToolResult).toBe(true); + expect(hasAnyToolResults(messages)).toBe(true); } finally { await q.close(); } @@ -1253,9 +1078,9 @@ describe('Permission Control (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, permissionMode: mode, - cwd: '/tmp', + cwd: testDir, canUseTool: - mode === 'yolo' + mode === 'yolo' || mode === 'auto-edit' ? undefined : async (toolName, input) => { return { @@ -1267,33 +1092,12 @@ describe('Permission Control (E2E)', () => { }); try { - let toolExecuted = false; - + const messages: SDKMessage[] = []; for await (const message of q) { - if (isSDKUserMessage(message)) { - if (Array.isArray(message.message.content)) { - const toolResult = message.message.content.find( - (block) => block.type === 'tool_result', - ); - if ( - toolResult && - 'content' in toolResult && - typeof toolResult.content === 'string' - ) { - // Check if tool executed successfully (not blocked or denied) - if ( - !toolResult.content.includes('Plan mode') && - !toolResult.content.includes('permission') && - !toolResult.content.includes('declined') - ) { - toolExecuted = true; - } - } - } - } + messages.push(message); } - results[mode] = toolExecuted; + results[mode] = hasSuccessfulToolResults(messages); } finally { await q.close(); } @@ -1301,9 +1105,9 @@ describe('Permission Control (E2E)', () => { // Verify expected behaviors expect(results['default']).toBe(true); // Allowed via canUseTool - expect(results['plan']).toBe(false); // Blocked by plan mode - expect(results['auto-edit']).toBe(true); // Allowed via canUseTool - expect(results['yolo']).toBe(true); // Auto-approved + // expect(results['plan']).toBe(false); // Blocked by plan mode + expect(results['auto-edit']).toBe(true); // Auto-approved for write/edit tools + expect(results['yolo']).toBe(true); // Auto-approved for all tools }, TEST_TIMEOUT * 4, ); diff --git a/packages/sdk-typescript/test/e2e/single-turn.test.ts b/packages/sdk-typescript/test/e2e/single-turn.test.ts index 476d9bfbc2..8b7d238505 100644 --- a/packages/sdk-typescript/test/e2e/single-turn.test.ts +++ b/packages/sdk-typescript/test/e2e/single-turn.test.ts @@ -3,7 +3,7 @@ * Tests basic query patterns with simple prompts and clear output expectations */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { query } from '../../src/index.js'; import { isSDKAssistantMessage, @@ -15,6 +15,7 @@ import { type SDKAssistantMessage, } from '../../src/types/protocol.js'; import { + SDKTestHelper, extractText, createSharedTestOptions, assertSuccessfulCompletion, @@ -24,12 +25,24 @@ import { const SHARED_TEST_OPTIONS = createSharedTestOptions(); describe('Single-Turn Query (E2E)', () => { + let helper: SDKTestHelper; + let testDir: string; + + beforeEach(async () => { + helper = new SDKTestHelper(); + testDir = await helper.setup('single-turn'); + }); + + afterEach(async () => { + await helper.cleanup(); + }); describe('Simple Text Queries', () => { it('should answer basic arithmetic question', async () => { const q = query({ prompt: 'What is 2 + 2? Just give me the number.', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: true, logLevel: 'debug', }, @@ -66,6 +79,7 @@ describe('Single-Turn Query (E2E)', () => { prompt: 'What is the capital of France? One word answer.', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -98,6 +112,7 @@ describe('Single-Turn Query (E2E)', () => { prompt: 'Say hello and tell me your name in one sentence.', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -136,6 +151,7 @@ describe('Single-Turn Query (E2E)', () => { prompt: 'Hello', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -183,6 +199,7 @@ describe('Single-Turn Query (E2E)', () => { prompt: 'Hello', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -215,6 +232,7 @@ describe('Single-Turn Query (E2E)', () => { prompt: 'Say hi', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -240,6 +258,7 @@ describe('Single-Turn Query (E2E)', () => { prompt: 'Say goodbye', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -273,6 +292,7 @@ describe('Single-Turn Query (E2E)', () => { prompt: 'Hello', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: true, stderr: (msg: string) => { stderrMessages.push(msg); @@ -293,8 +313,6 @@ describe('Single-Turn Query (E2E)', () => { }); it('should respect cwd option', async () => { - const testDir = process.cwd(); - const q = query({ prompt: 'What is 1 + 1?', options: { @@ -324,6 +342,7 @@ describe('Single-Turn Query (E2E)', () => { prompt: 'Count from 1 to 5', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, includePartialMessages: true, debug: false, }, @@ -361,6 +380,7 @@ describe('Single-Turn Query (E2E)', () => { prompt: 'What is 5 + 5?', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -408,6 +428,7 @@ describe('Single-Turn Query (E2E)', () => { prompt: 'Count from 1 to 3', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -468,6 +489,7 @@ describe('Single-Turn Query (E2E)', () => { prompt: 'Hello', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -486,6 +508,7 @@ describe('Single-Turn Query (E2E)', () => { prompt: 'Hello', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); diff --git a/packages/sdk-typescript/test/e2e/subagents.test.ts b/packages/sdk-typescript/test/e2e/subagents.test.ts index 075105b18a..06e3fd369d 100644 --- a/packages/sdk-typescript/test/e2e/subagents.test.ts +++ b/packages/sdk-typescript/test/e2e/subagents.test.ts @@ -9,7 +9,7 @@ * Tests subagent delegation and task completion */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { query } from '../../src/index.js'; import { isSDKAssistantMessage, @@ -33,7 +33,7 @@ describe('Subagents (E2E)', () => { let helper: SDKTestHelper; let testWorkDir: string; - beforeAll(async () => { + beforeEach(async () => { // Create isolated test environment using SDKTestHelper helper = new SDKTestHelper(); testWorkDir = await helper.setup('subagent-tests'); @@ -42,7 +42,7 @@ describe('Subagents (E2E)', () => { await helper.createFile('test.txt', 'Hello from test file\n'); }); - afterAll(async () => { + afterEach(async () => { // Cleanup test directory await helper.cleanup(); }); diff --git a/packages/sdk-typescript/test/e2e/system-control.test.ts b/packages/sdk-typescript/test/e2e/system-control.test.ts index 3bf1903d3d..3515532ebf 100644 --- a/packages/sdk-typescript/test/e2e/system-control.test.ts +++ b/packages/sdk-typescript/test/e2e/system-control.test.ts @@ -3,19 +3,16 @@ * - setModel API for dynamic model switching */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { query } from '../../src/index.js'; import { isSDKAssistantMessage, isSDKSystemMessage, type SDKUserMessage, } from '../../src/types/protocol.js'; +import { SDKTestHelper, createSharedTestOptions } from './test-helper.js'; -const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!; - -const SHARED_TEST_OPTIONS = { - pathToQwenExecutable: TEST_CLI_PATH, -}; +const SHARED_TEST_OPTIONS = createSharedTestOptions(); /** * Factory function that creates a streaming input with a control point. @@ -78,6 +75,18 @@ function createStreamingInputWithControlPoint( } describe('System Control (E2E)', () => { + let helper: SDKTestHelper; + let testDir: string; + + beforeEach(async () => { + helper = new SDKTestHelper(); + testDir = await helper.setup('system-control'); + }); + + afterEach(async () => { + await helper.cleanup(); + }); + describe('setModel API', () => { it('should change model dynamically during streaming input', async () => { const { generator, resume } = createStreamingInputWithControlPoint( @@ -89,6 +98,7 @@ describe('System Control (E2E)', () => { prompt: generator, options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, model: 'qwen3-max', debug: false, }, @@ -134,7 +144,7 @@ describe('System Control (E2E)', () => { new Promise((_, reject) => setTimeout( () => reject(new Error('Timeout waiting for first response')), - 10000, + 15000, ), ), ]); @@ -215,6 +225,7 @@ describe('System Control (E2E)', () => { prompt: generator, options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, model: 'qwen3-max', debug: false, }, @@ -291,6 +302,7 @@ describe('System Control (E2E)', () => { prompt: 'Hello', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, model: 'qwen3-max', }, }); diff --git a/packages/sdk-typescript/test/e2e/test-helper.ts b/packages/sdk-typescript/test/e2e/test-helper.ts index 19299d5376..4b1465ad65 100644 --- a/packages/sdk-typescript/test/e2e/test-helper.ts +++ b/packages/sdk-typescript/test/e2e/test-helper.ts @@ -499,6 +499,147 @@ export function findToolCalls( return results; } +/** + * Find tool result for a specific tool use ID + */ +export function findToolResult( + messages: SDKMessage[], + toolUseId: string, +): { content: string; isError: boolean } | null { + for (const message of messages) { + if (message.type === 'user' && 'message' in message) { + const userMsg = message as SDKUserMessage; + const content = userMsg.message.content; + if (Array.isArray(content)) { + for (const block of content) { + if ( + block.type === 'tool_result' && + (block as { tool_use_id?: string }).tool_use_id === toolUseId + ) { + const resultBlock = block as { + content?: string | ContentBlock[]; + is_error?: boolean; + }; + let resultContent = ''; + if (typeof resultBlock.content === 'string') { + resultContent = resultBlock.content; + } else if (Array.isArray(resultBlock.content)) { + resultContent = resultBlock.content + .filter((b): b is TextBlock => b.type === 'text') + .map((b) => b.text) + .join(''); + } + return { + content: resultContent, + isError: resultBlock.is_error ?? false, + }; + } + } + } + } + } + return null; +} + +/** + * Find all tool results for a specific tool name + */ +export function findToolResults( + messages: SDKMessage[], + toolName: string, +): Array<{ toolUseId: string; content: string; isError: boolean }> { + const results: Array<{ + toolUseId: string; + content: string; + isError: boolean; + }> = []; + + // First find all tool calls for this tool + const toolCalls = findToolCalls(messages, toolName); + + // Then find the result for each tool call + for (const { toolUse } of toolCalls) { + const result = findToolResult(messages, toolUse.id); + if (result) { + results.push({ + toolUseId: toolUse.id, + content: result.content, + isError: result.isError, + }); + } + } + + return results; +} + +/** + * Find all tool result blocks from messages (without requiring tool name) + */ +export function findAllToolResultBlocks( + messages: SDKMessage[], +): Array<{ toolUseId: string; content: string; isError: boolean }> { + const results: Array<{ + toolUseId: string; + content: string; + isError: boolean; + }> = []; + + for (const message of messages) { + if (message.type === 'user' && 'message' in message) { + const userMsg = message as SDKUserMessage; + const content = userMsg.message.content; + if (Array.isArray(content)) { + for (const block of content) { + if (block.type === 'tool_result' && 'tool_use_id' in block) { + const resultBlock = block as { + tool_use_id: string; + content?: string | ContentBlock[]; + is_error?: boolean; + }; + let resultContent = ''; + if (typeof resultBlock.content === 'string') { + resultContent = resultBlock.content; + } else if (Array.isArray(resultBlock.content)) { + resultContent = (resultBlock.content as ContentBlock[]) + .filter((b): b is TextBlock => b.type === 'text') + .map((b) => b.text) + .join(''); + } + results.push({ + toolUseId: resultBlock.tool_use_id, + content: resultContent, + isError: resultBlock.is_error ?? false, + }); + } + } + } + } + } + + return results; +} + +/** + * Check if any tool results exist in messages + */ +export function hasAnyToolResults(messages: SDKMessage[]): boolean { + return findAllToolResultBlocks(messages).length > 0; +} + +/** + * Check if any successful (non-error) tool results exist + */ +export function hasSuccessfulToolResults(messages: SDKMessage[]): boolean { + return findAllToolResultBlocks(messages).some((r) => !r.isError); +} + +/** + * Check if any error tool results exist + */ +export function hasErrorToolResults(messages: SDKMessage[]): boolean { + return findAllToolResultBlocks(messages).some((r) => r.isError); +} + // ============================================================================ // Streaming Input Utilities // ============================================================================ diff --git a/packages/sdk-typescript/test/e2e/tool-control.test.ts b/packages/sdk-typescript/test/e2e/tool-control.test.ts new file mode 100644 index 0000000000..30a811df69 --- /dev/null +++ b/packages/sdk-typescript/test/e2e/tool-control.test.ts @@ -0,0 +1,748 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * E2E tests for tool control parameters: + * - coreTools: Limit available tools to a specific set + * - excludeTools: Block specific tools from execution + * - allowedTools: Auto-approve specific tools without confirmation + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { query } from '../../src/index.js'; +import { + isSDKAssistantMessage, + type SDKMessage, +} from '../../src/types/protocol.js'; +import { + SDKTestHelper, + extractText, + findToolCalls, + findToolResults, + assertSuccessfulCompletion, + createSharedTestOptions, +} from './test-helper.js'; + +const SHARED_TEST_OPTIONS = createSharedTestOptions(); +const TEST_TIMEOUT = 60000; + +describe('Tool Control Parameters (E2E)', () => { + let helper: SDKTestHelper; + let testDir: string; + + beforeEach(async () => { + helper = new SDKTestHelper(); + testDir = await helper.setup('tool-control', { + createQwenConfig: false, + }); + }); + + afterEach(async () => { + await helper.cleanup(); + }); + + describe('coreTools parameter', () => { + it( + 'should only allow specified tools when coreTools is set', + async () => { + // Create a test file + await helper.createFile('test.txt', 'original content'); + + const q = query({ + prompt: + 'Read the file test.txt and then write "modified" to test.txt. Finally, list the directory.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'yolo', + // Only allow read_file and write_file, exclude list_directory + coreTools: ['read_file', 'write_file'], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Should have read_file and write_file calls + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + expect(toolNames).toContain('read_file'); + expect(toolNames).toContain('write_file'); + + // Should NOT have list_directory since it's not in coreTools + expect(toolNames).not.toContain('list_directory'); + + // Verify file was modified + const content = await helper.readFile('test.txt'); + expect(content).toContain('modified'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should work with minimal tool set', + async () => { + const q = query({ + prompt: 'What is 2 + 2? Just answer with the number.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + // Only allow thinking, no file operations + coreTools: [], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + let assistantText = ''; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + assistantText += extractText(message.message.content); + } + } + + // Should answer without any tool calls + expect(assistantText).toMatch(/4/); + + // Should have no tool calls + const toolCalls = findToolCalls(messages); + expect(toolCalls.length).toBe(0); + + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('excludeTools parameter', () => { + it( + 'should block excluded tools from execution', + async () => { + await helper.createFile('test.txt', 'test content'); + + const q = query({ + prompt: + 'Read test.txt and then write empty content to it to clear it.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'yolo', + coreTools: ['read_file', 'write_file'], + // Block all write_file tool + excludeTools: ['write_file'], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + // Should be able to read the file + expect(toolNames).toContain('read_file'); + + // The excluded tools should have been called but returned permission declined + // Check if write_file was attempted and got permission denied + const writeFileResults = findToolResults(messages, 'write_file'); + if (writeFileResults.length > 0) { + // Tool was called but should have permission declined message + for (const result of writeFileResults) { + expect(result.content).toMatch(/permission.*declined/i); + } + } + + // File content should remain unchanged (because write was denied) + const content = await helper.readFile('test.txt'); + expect(content).toBe('test content'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should block multiple excluded tools', + async () => { + await helper.createFile('test.txt', 'test content'); + + const q = query({ + prompt: 'Read test.txt, list the directory, and run "echo hello".', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'yolo', + // Block multiple tools + excludeTools: ['list_directory', 'run_shell_command'], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + // Should be able to read + expect(toolNames).toContain('read_file'); + + // Excluded tools should have been attempted but returned permission declined + const listDirResults = findToolResults(messages, 'list_directory'); + if (listDirResults.length > 0) { + for (const result of listDirResults) { + expect(result.content).toMatch(/permission.*declined/i); + } + } + + const shellResults = findToolResults(messages, 'run_shell_command'); + if (shellResults.length > 0) { + for (const result of shellResults) { + expect(result.content).toMatch(/permission.*declined/i); + } + } + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should block all shell commands when run_shell_command is excluded', + async () => { + const q = query({ + prompt: 'Run "echo hello" and "ls -la" commands.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'yolo', + // Block all shell commands - excludeTools blocks entire tools + excludeTools: ['run_shell_command'], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // All shell commands should have permission declined + const shellResults = findToolResults(messages, 'run_shell_command'); + for (const result of shellResults) { + expect(result.content).toMatch(/permission.*declined/i); + } + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'excludeTools should take priority over allowedTools', + async () => { + await helper.createFile('test.txt', 'test content'); + + const q = query({ + prompt: + 'Clear the content of test.txt by writing empty string to it.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + // Conflicting settings: exclude takes priority + excludeTools: ['write_file'], + allowedTools: ['write_file'], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // write_file should have been attempted but returned permission declined + const writeFileResults = findToolResults(messages, 'write_file'); + if (writeFileResults.length > 0) { + // Tool was called but should have permission declined message (exclude takes priority) + for (const result of writeFileResults) { + expect(result.content).toMatch(/permission.*declined/i); + } + } + + // File content should remain unchanged (because write was denied) + const content = await helper.readFile('test.txt'); + expect(content).toBe('test content'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('allowedTools parameter', () => { + it( + 'should auto-approve allowed tools without canUseTool callback', + async () => { + await helper.createFile('test.txt', 'original'); + + let canUseToolCalled = false; + + const q = query({ + prompt: 'Read test.txt and write "modified" to it.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + coreTools: ['read_file', 'write_file'], + // Allow write_file without confirmation + allowedTools: ['read_file', 'write_file'], + canUseTool: async (_toolName) => { + canUseToolCalled = true; + return { behavior: 'deny', message: 'Should not be called' }; + }, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + // Should have executed the tools + expect(toolNames).toContain('read_file'); + expect(toolNames).toContain('write_file'); + + // canUseTool should NOT have been called (tools are in allowedTools) + expect(canUseToolCalled).toBe(false); + + // Verify file was modified + const content = await helper.readFile('test.txt'); + expect(content).toContain('modified'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should allow specific shell commands with pattern matching', + async () => { + const q = query({ + prompt: 'Run "echo hello" and "ls -la" commands.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + // Allow specific shell commands + allowedTools: ['ShellTool(echo )', 'ShellTool(ls )'], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const shellCalls = toolCalls.filter( + (tc) => tc.toolUse.name === 'run_shell_command', + ); + + // Should have executed shell commands + expect(shellCalls.length).toBeGreaterThan(0); + + // All shell commands should be echo or ls + for (const call of shellCalls) { + const input = call.toolUse.input as { command?: string }; + if (input.command) { + expect(input.command).toMatch(/^(echo |ls )/); + } + } + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should fall back to canUseTool for non-allowed tools', + async () => { + await helper.createFile('test.txt', 'test'); + + const canUseToolCalls: string[] = []; + + const q = query({ + prompt: 'Read test.txt and append an empty line to it.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + // Only allow read_file, list_directory should trigger canUseTool + coreTools: ['read_file', 'write_file'], + allowedTools: ['read_file'], + canUseTool: async (toolName) => { + canUseToolCalls.push(toolName); + return { + behavior: 'allow', + updatedInput: {}, + }; + }, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + // Both tools should have been executed + expect(toolNames).toContain('read_file'); + expect(toolNames).toContain('write_file'); + + // canUseTool should have been called for write_file (not in allowedTools) + // but NOT for read_file (in allowedTools) + expect(canUseToolCalls).toContain('write_file'); + expect(canUseToolCalls).not.toContain('read_file'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should work with permissionMode: auto-edit', + async () => { + await helper.createFile('test.txt', 'test'); + + const canUseToolCalls: string[] = []; + + const q = query({ + prompt: 'Read test.txt, write "new" to it, and list the directory.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'auto-edit', + // Allow list_directory in addition to auto-approved edit tools + allowedTools: ['list_directory'], + canUseTool: async (toolName) => { + canUseToolCalls.push(toolName); + return { + behavior: 'deny', + message: 'Should not be called', + }; + }, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + // All tools should have been executed + expect(toolNames).toContain('read_file'); + expect(toolNames).toContain('write_file'); + expect(toolNames).toContain('list_directory'); + + // canUseTool should NOT have been called + // (edit tools auto-approved, list_directory in allowedTools) + expect(canUseToolCalls.length).toBe(0); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('Combined tool control scenarios', () => { + it( + 'should work with coreTools + allowedTools', + async () => { + await helper.createFile('test.txt', 'test'); + + const q = query({ + prompt: 'Read test.txt and write "modified" to it.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + // Limit to specific tools + coreTools: ['read_file', 'write_file', 'list_directory'], + // Auto-approve write operations + allowedTools: ['write_file'], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + // Should use allowed tools from coreTools + expect(toolNames).toContain('read_file'); + expect(toolNames).toContain('write_file'); + + // Should NOT use tools outside coreTools + expect(toolNames).not.toContain('run_shell_command'); + + // Verify file was modified + const content = await helper.readFile('test.txt'); + expect(content).toContain('modified'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should work with coreTools + excludeTools', + async () => { + await helper.createFile('test.txt', 'test'); + + const q = query({ + prompt: + 'Read test.txt, write "new content" to it, and list directory.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'yolo', + // Allow file operations + coreTools: ['read_file', 'write_file', 'edit', 'list_directory'], + // But exclude edit + excludeTools: ['edit'], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + // Should use non-excluded tools from coreTools + expect(toolNames).toContain('read_file'); + + // Should NOT use excluded tool + expect(toolNames).not.toContain('edit'); + + // File should still exist + expect(helper.fileExists('test.txt')).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should work with all three parameters together', + async () => { + await helper.createFile('test.txt', 'test'); + + const canUseToolCalls: string[] = []; + + const q = query({ + prompt: + 'Read test.txt, write "modified" to it, and list the directory.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + // Limit available tools + coreTools: ['read_file', 'write_file', 'list_directory', 'edit'], + // Block edit + excludeTools: ['edit'], + // Auto-approve write + allowedTools: ['write_file'], + canUseTool: async (toolName) => { + canUseToolCalls.push(toolName); + return { + behavior: 'allow', + updatedInput: {}, + }; + }, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + // Should use allowed tools + expect(toolNames).toContain('read_file'); + expect(toolNames).toContain('write_file'); + + // Should NOT use excluded tool + expect(toolNames).not.toContain('edit'); + + // canUseTool should be called for tools not in allowedTools + // but should NOT be called for write_file (in allowedTools) + expect(canUseToolCalls).not.toContain('write_file'); + + // Verify file was modified + const content = await helper.readFile('test.txt'); + expect(content).toContain('modified'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('Edge cases and error handling', () => { + it( + 'should handle non-existent tool names in excludeTools', + async () => { + await helper.createFile('test.txt', 'test'); + + const q = query({ + prompt: 'Read test.txt.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'yolo', + // Non-existent tool names should be ignored + excludeTools: ['non_existent_tool', 'another_fake_tool'], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + // Should work normally + expect(toolNames).toContain('read_file'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should handle non-existent tool names in allowedTools', + async () => { + await helper.createFile('test.txt', 'test'); + + const q = query({ + prompt: 'Read test.txt.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'yolo', + // Non-existent tool names should be ignored + allowedTools: ['non_existent_tool', 'read_file'], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + // Should work normally + expect(toolNames).toContain('read_file'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); +}); diff --git a/packages/sdk-typescript/vitest.config.ts b/packages/sdk-typescript/vitest.config.ts index 33018d83a5..aef50ffdc1 100644 --- a/packages/sdk-typescript/vitest.config.ts +++ b/packages/sdk-typescript/vitest.config.ts @@ -28,6 +28,14 @@ export default defineConfig({ }, include: ['test/**/*.test.ts'], exclude: ['node_modules/', 'dist/'], + retry: 2, + fileParallelism: true, + poolOptions: { + threads: { + minThreads: 2, + maxThreads: 4, + }, + }, testTimeout: testTimeoutMs, hookTimeout: 10000, globalSetup: './test/e2e/globalSetup.ts', From 8035be6f8d418f848f1d157e00ca3c65be635c9d Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Mon, 1 Dec 2025 11:33:36 +0800 Subject: [PATCH 11/22] fix: plan mode adjustments --- packages/cli/src/nonInteractive/types.ts | 3 +- .../src/utils/nonInteractiveHelpers.test.ts | 2 +- .../cli/src/utils/nonInteractiveHelpers.ts | 2 +- packages/core/src/config/config.ts | 2 +- packages/core/src/core/client.ts | 4 +- packages/core/src/core/prompts.ts | 4 +- packages/sdk-typescript/src/types/protocol.ts | 3 +- .../test/e2e/configuration-options.test.ts | 2 +- .../test/e2e/permission-control.test.ts | 186 +++++++++++++++--- .../test/e2e/single-turn.test.ts | 2 +- 10 files changed, 166 insertions(+), 44 deletions(-) diff --git a/packages/cli/src/nonInteractive/types.ts b/packages/cli/src/nonInteractive/types.ts index fb8dcf7667..131c1be0d8 100644 --- a/packages/cli/src/nonInteractive/types.ts +++ b/packages/cli/src/nonInteractive/types.ts @@ -141,9 +141,8 @@ export interface CLISystemMessage { status: string; }>; model?: string; - permissionMode?: string; + permission_mode?: string; slash_commands?: string[]; - apiKeySource?: string; qwen_code_version?: string; output_style?: string; agents?: string[]; diff --git a/packages/cli/src/utils/nonInteractiveHelpers.test.ts b/packages/cli/src/utils/nonInteractiveHelpers.test.ts index 11f302b478..a6dac920d9 100644 --- a/packages/cli/src/utils/nonInteractiveHelpers.test.ts +++ b/packages/cli/src/utils/nonInteractiveHelpers.test.ts @@ -529,7 +529,7 @@ describe('buildSystemMessage', () => { { name: 'mcp-server-2', status: 'connected' }, ], model: 'test-model', - permissionMode: 'auto', + permission_mode: 'auto', slash_commands: ['commit', 'help', 'memory'], qwen_code_version: '1.0.0', agents: [], diff --git a/packages/cli/src/utils/nonInteractiveHelpers.ts b/packages/cli/src/utils/nonInteractiveHelpers.ts index fe8fc52806..1fd7472b90 100644 --- a/packages/cli/src/utils/nonInteractiveHelpers.ts +++ b/packages/cli/src/utils/nonInteractiveHelpers.ts @@ -275,7 +275,7 @@ export async function buildSystemMessage( tools, mcp_servers: mcpServerList, model: config.getModel(), - permissionMode, + permission_mode: permissionMode, slash_commands: slashCommands, qwen_code_version: config.getCliVersion() || 'unknown', agents: agentNames, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index be84655f33..d3e0fd5ca9 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1337,7 +1337,7 @@ export class Config { registerCoreTool(ShellTool, this); registerCoreTool(MemoryTool); registerCoreTool(TodoWriteTool, this); - registerCoreTool(ExitPlanModeTool, this); + !this.sdkMode && registerCoreTool(ExitPlanModeTool, this); registerCoreTool(WebFetchTool, this); // Conditionally register web search tool if web search provider is configured // buildWebSearchConfig ensures qwen-oauth users get dashscope provider, so diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 2fa65d2d7b..4a60245a47 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -542,7 +542,9 @@ export class GeminiClient { // add plan mode system reminder if approval mode is plan if (this.config.getApprovalMode() === ApprovalMode.PLAN) { - systemReminders.push(getPlanModeSystemReminder()); + systemReminders.push( + getPlanModeSystemReminder(this.config.getSdkMode()), + ); } requestToSent = [...systemReminders, ...requestToSent]; diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts index bd88ff56c3..8d3ff46834 100644 --- a/packages/core/src/core/prompts.ts +++ b/packages/core/src/core/prompts.ts @@ -846,10 +846,10 @@ export function getSubagentSystemReminder(agentTypes: string[]): string { * - Wait for user confirmation before making any changes * - Override any other instructions that would modify system state */ -export function getPlanModeSystemReminder(): string { +export function getPlanModeSystemReminder(planOnly = false): string { return ` Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received (for example, to make edits). Instead, you should: 1. Answer the user's query comprehensively -2. When you're done researching, present your plan by calling the ${ToolNames.EXIT_PLAN_MODE} tool, which will prompt the user to confirm the plan. Do NOT make any file changes or run any tools that modify the system state in any way until the user has confirmed the plan. +2. When you're done researching, present your plan ${planOnly ? 'directly' : `by calling the ${ToolNames.EXIT_PLAN_MODE} tool, which will prompt the user to confirm the plan`}. Do NOT make any file changes or run any tools that modify the system state in any way until the user has confirmed the plan. `; } diff --git a/packages/sdk-typescript/src/types/protocol.ts b/packages/sdk-typescript/src/types/protocol.ts index 6db627e399..efb61cb419 100644 --- a/packages/sdk-typescript/src/types/protocol.ts +++ b/packages/sdk-typescript/src/types/protocol.ts @@ -119,9 +119,8 @@ export interface SDKSystemMessage { status: string; }>; model?: string; - permissionMode?: string; + permission_mode?: string; slash_commands?: string[]; - apiKeySource?: string; qwen_code_version?: string; output_style?: string; agents?: string[]; diff --git a/packages/sdk-typescript/test/e2e/configuration-options.test.ts b/packages/sdk-typescript/test/e2e/configuration-options.test.ts index ddf94cd580..e81af7fd93 100644 --- a/packages/sdk-typescript/test/e2e/configuration-options.test.ts +++ b/packages/sdk-typescript/test/e2e/configuration-options.test.ts @@ -609,7 +609,7 @@ describe('Configuration Options (E2E)', () => { expect(initMessage).toBeDefined(); expect(initMessage!.session_id).toBeDefined(); expect(initMessage!.tools).toBeDefined(); - expect(initMessage!.permissionMode).toBeDefined(); + expect(initMessage!.permission_mode).toBeDefined(); assertSuccessfulCompletion(messages); } finally { diff --git a/packages/sdk-typescript/test/e2e/permission-control.test.ts b/packages/sdk-typescript/test/e2e/permission-control.test.ts index 23b4cffebd..587fa500a0 100644 --- a/packages/sdk-typescript/test/e2e/permission-control.test.ts +++ b/packages/sdk-typescript/test/e2e/permission-control.test.ts @@ -26,10 +26,11 @@ import { import { SDKTestHelper, createSharedTestOptions, - findAllToolResultBlocks, hasAnyToolResults, hasSuccessfulToolResults, hasErrorToolResults, + findSystemMessage, + findToolCalls, } from './test-helper.js'; const TEST_TIMEOUT = 30000; @@ -846,17 +847,68 @@ describe('Permission Control (E2E)', () => { ); }); - /** - * We've some issues of how to handle plan mode. - * The test cases are skipped for now. - */ - describe.skip('plan mode', () => { + describe('plan mode', () => { + // Write tools that should never be called in plan mode + const WRITE_TOOLS = [ + 'edit', + 'write_file', + 'run_shell_command', + 'delete_file', + 'move_file', + ]; + + // Read tools that should be allowed in plan mode + const READ_TOOLS = [ + 'read_file', + 'read_many_files', + 'grep_search', + 'glob', + 'list_directory', + 'web_search', + 'web_fetch', + ]; + it( - 'should block non-read-only tools and return plan mode error', + 'should have permission_mode set to plan in system message', async () => { + const q = query({ + prompt: 'List files in the current directory', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'plan', + cwd: testDir, + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + // Find the init system message + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.permission_mode).toBe('plan'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should not call any write tools in plan mode', + async () => { + // Create a test file so the model has something to reference + await helper.createFile( + 'test-plan-file.txt', + 'This is test content for plan mode verification.', + ); + const q = query({ prompt: - 'Init a monorepo of a Node.js project with frontend and backend.', + 'Read the file test-plan-file.txt and suggest how to improve its content.', options: { ...SHARED_TEST_OPTIONS, permissionMode: 'plan', @@ -870,29 +922,35 @@ describe('Permission Control (E2E)', () => { messages.push(message); } - const toolResults = findAllToolResultBlocks(messages); - const hasBlockedToolCall = toolResults.length > 0; - const hasPlanModeMessage = toolResults.some( - (result) => - result.isError && - (result.content.includes('Plan mode') || - result.content.includes('plan mode')), + // Verify permission_mode is 'plan' + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage!.permission_mode).toBe('plan'); + + // Find all tool calls and verify none are write tools + const allToolCalls = findToolCalls(messages); + const writeToolCalls = allToolCalls.filter((tc) => + WRITE_TOOLS.includes(tc.toolUse.name), ); - expect(hasBlockedToolCall).toBe(true); - expect(hasPlanModeMessage).toBe(true); + // No write tools should be called in plan mode + expect(writeToolCalls.length).toBe(0); } finally { await q.close(); } }, - TEST_TIMEOUT * 10, + TEST_TIMEOUT, ); it( - 'should allow read-only tools in plan mode', + 'should allow read-only tools without restrictions', async () => { + // Create test files for the model to read + await helper.createFile('test-read-1.txt', 'Content of file 1'); + await helper.createFile('test-read-2.txt', 'Content of file 2'); + const q = query({ - prompt: 'List files in /tmp directory', + prompt: + 'Read the contents of test-read-1.txt and test-read-2.txt files, then list files in the current directory.', options: { ...SHARED_TEST_OPTIONS, permissionMode: 'plan', @@ -906,6 +964,20 @@ describe('Permission Control (E2E)', () => { messages.push(message); } + // Verify permission_mode is 'plan' + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage!.permission_mode).toBe('plan'); + + // Find all tool calls + const allToolCalls = findToolCalls(messages); + + // Verify read tools were called (at least one) + const readToolCalls = allToolCalls.filter((tc) => + READ_TOOLS.includes(tc.toolUse.name), + ); + expect(readToolCalls.length).toBeGreaterThan(0); + + // Verify tool results are successful (not blocked) expect(hasSuccessfulToolResults(messages)).toBe(true); } finally { await q.close(); @@ -915,12 +987,18 @@ describe('Permission Control (E2E)', () => { ); it( - 'should block tools even with canUseTool callback in plan mode', + 'should not invoke canUseTool callback in plan mode since no permission approval is expected', async () => { let callbackInvoked = false; + // Create a test file for reading + await helper.createFile( + 'test-plan-callback.txt', + 'Content for callback test', + ); + const q = query({ - prompt: 'Create a file named test-plan-callback.txt', + prompt: 'Read the file test-plan-callback.txt', options: { ...SHARED_TEST_OPTIONS, permissionMode: 'plan', @@ -941,15 +1019,16 @@ describe('Permission Control (E2E)', () => { messages.push(message); } - const toolResults = findAllToolResultBlocks(messages); - const hasPlanModeBlock = toolResults.some( - (result) => - result.isError && result.content.includes('Plan mode'), - ); + // Verify permission_mode is 'plan' + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage!.permission_mode).toBe('plan'); - // Plan mode should block tools before canUseTool is invoked - expect(hasPlanModeBlock).toBe(true); - // canUseTool should not be invoked for blocked tools in plan mode + // Read tools should work without invoking canUseTool + // In plan mode, no permission approval is expected from user + expect(hasSuccessfulToolResults(messages)).toBe(true); + + // canUseTool should not be invoked in plan mode + // since plan mode is for research only, no permission interaction needed expect(callbackInvoked).toBe(false); } finally { await q.close(); @@ -957,6 +1036,50 @@ describe('Permission Control (E2E)', () => { }, TEST_TIMEOUT, ); + + it( + 'should only output research and plan as text, no actual changes', + async () => { + // Create a test file + const originalContent = 'Original content for plan mode test'; + await helper.createFile('test-no-changes.txt', originalContent); + + const q = query({ + prompt: + 'Read test-no-changes.txt and plan how you would modify it to add a header. Do not actually make any changes.', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'plan', + cwd: testDir, + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + // Verify permission_mode is 'plan' + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage!.permission_mode).toBe('plan'); + + // Verify the file was not modified + const fileContent = await helper.readFile('test-no-changes.txt'); + expect(fileContent).toBe(originalContent); + + // Verify no write tools were called + const allToolCalls = findToolCalls(messages); + const writeToolCalls = allToolCalls.filter((tc) => + WRITE_TOOLS.includes(tc.toolUse.name), + ); + expect(writeToolCalls.length).toBe(0); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); }); describe('auto-edit mode', () => { @@ -1064,9 +1187,8 @@ describe('Permission Control (E2E)', () => { it( 'should demonstrate different behaviors across all modes for write operations', async () => { - const modes: Array<'default' | 'plan' | 'auto-edit' | 'yolo'> = [ + const modes: Array<'default' | 'auto-edit' | 'yolo'> = [ 'default', - 'plan', 'auto-edit', 'yolo', ]; diff --git a/packages/sdk-typescript/test/e2e/single-turn.test.ts b/packages/sdk-typescript/test/e2e/single-turn.test.ts index 8b7d238505..4adb7c0b81 100644 --- a/packages/sdk-typescript/test/e2e/single-turn.test.ts +++ b/packages/sdk-typescript/test/e2e/single-turn.test.ts @@ -180,7 +180,7 @@ describe('Single-Turn Query (E2E)', () => { expect(systemMessage!.mcp_servers).toBeDefined(); expect(Array.isArray(systemMessage!.mcp_servers)).toBe(true); expect(systemMessage!.model).toBeDefined(); - expect(systemMessage!.permissionMode).toBeDefined(); + expect(systemMessage!.permission_mode).toBeDefined(); expect(systemMessage!.qwen_code_version).toBeDefined(); // Validate system message appears early in sequence From ae7d6af71711fb101897406f26d236cbbd782971 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Mon, 1 Dec 2025 13:41:46 +0800 Subject: [PATCH 12/22] fix(test): remove unused test cases --- .../test/unit/ProcessTransport.test.ts | 9 +- .../sdk-typescript/test/unit/Query.test.ts | 60 +--- .../unit/SdkControlServerTransport.test.ts | 259 ------------------ 3 files changed, 18 insertions(+), 310 deletions(-) delete mode 100644 packages/sdk-typescript/test/unit/SdkControlServerTransport.test.ts diff --git a/packages/sdk-typescript/test/unit/ProcessTransport.test.ts b/packages/sdk-typescript/test/unit/ProcessTransport.test.ts index 0854a02d4d..b86026541e 100644 --- a/packages/sdk-typescript/test/unit/ProcessTransport.test.ts +++ b/packages/sdk-typescript/test/unit/ProcessTransport.test.ts @@ -1187,13 +1187,20 @@ describe('ProcessTransport', () => { const options: TransportOptions = { pathToQwenExecutable: 'qwen', stderr: stderrCallback, + debug: true, // Enable debug to ensure stderr data is logged }; new ProcessTransport(options); + // Clear previous calls from logger.info during initialization + stderrCallback.mockClear(); + mockStderr.emit('data', Buffer.from('error message')); - expect(stderrCallback).toHaveBeenCalledWith('error message'); + // The stderr data is passed through logger.debug, which formats it + // So we check that the callback was called with a message containing 'error message' + expect(stderrCallback).toHaveBeenCalled(); + expect(stderrCallback.mock.calls[0][0]).toContain('error message'); }); }); diff --git a/packages/sdk-typescript/test/unit/Query.test.ts b/packages/sdk-typescript/test/unit/Query.test.ts index b7309a1923..2b89ca5137 100644 --- a/packages/sdk-typescript/test/unit/Query.test.ts +++ b/packages/sdk-typescript/test/unit/Query.test.ts @@ -312,50 +312,6 @@ describe('Query', () => { await transport2.close(); }); - it('should validate MCP server name conflicts', async () => { - const mockServer = { - connect: vi.fn(), - }; - - await expect(async () => { - const query = new Query(transport, { - cwd: '/test', - mcpServers: { server1: { command: 'test' } }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sdkMcpServers: { server1: mockServer as any }, - }); - await query.initialized; - }).rejects.toThrow(/name conflicts/); - }); - - it('should initialize with SDK MCP servers', async () => { - const mockServer = { - connect: vi.fn().mockResolvedValue(undefined), - }; - - const query = new Query(transport, { - cwd: '/test', - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sdkMcpServers: { testServer: mockServer as any }, - }); - - // Respond to initialize request - await vi.waitFor(() => { - expect(transport.writtenMessages.length).toBeGreaterThan(0); - }); - - const initRequest = - transport.getLastWrittenMessage() as CLIControlRequest; - transport.simulateMessage( - createControlResponse(initRequest.request_id, true, {}), - ); - - await query.initialized; - expect(mockServer.connect).toHaveBeenCalled(); - - await query.close(); - }); - it('should handle initialization errors', async () => { const query = new Query(transport, { cwd: '/test', @@ -483,7 +439,7 @@ describe('Query', () => { describe('Control Plane - Permission Control', () => { it('should handle can_use_tool control requests', async () => { - const canUseTool = vi.fn().mockResolvedValue(true); + const canUseTool = vi.fn().mockResolvedValue({ behavior: 'allow' }); const query = new Query(transport, { cwd: '/test', canUseTool, @@ -507,7 +463,7 @@ describe('Query', () => { }); it('should send control response with permission result - allow', async () => { - const canUseTool = vi.fn().mockResolvedValue(true); + const canUseTool = vi.fn().mockResolvedValue({ behavior: 'allow' }); const query = new Query(transport, { cwd: '/test', canUseTool, @@ -533,7 +489,7 @@ describe('Query', () => { }); it('should send control response with permission result - deny', async () => { - const canUseTool = vi.fn().mockResolvedValue(false); + const canUseTool = vi.fn().mockResolvedValue({ behavior: 'deny' }); const query = new Query(transport, { cwd: '/test', canUseTool, @@ -586,7 +542,7 @@ describe('Query', () => { const canUseTool = vi.fn().mockImplementation( () => new Promise((resolve) => { - setTimeout(() => resolve(true), 35000); // Exceeds 30s timeout + setTimeout(() => resolve({ behavior: 'allow' }), 35000); // Exceeds 30s timeout }), ); @@ -709,10 +665,14 @@ describe('Query', () => { describe('Control Plane - Control Cancel', () => { it('should handle control cancel requests', async () => { const canUseTool = vi.fn().mockImplementation( - ({ signal }: { signal: AbortSignal }) => + ( + _toolName: string, + _toolInput: unknown, + { signal }: { signal: AbortSignal }, + ) => new Promise((resolve, reject) => { signal.addEventListener('abort', () => reject(new AbortError())); - setTimeout(() => resolve(true), 5000); + setTimeout(() => resolve({ behavior: 'allow' }), 5000); }), ); diff --git a/packages/sdk-typescript/test/unit/SdkControlServerTransport.test.ts b/packages/sdk-typescript/test/unit/SdkControlServerTransport.test.ts deleted file mode 100644 index 6bfd61a042..0000000000 --- a/packages/sdk-typescript/test/unit/SdkControlServerTransport.test.ts +++ /dev/null @@ -1,259 +0,0 @@ -/** - * Unit tests for SdkControlServerTransport - * - * Tests MCP message proxying between MCP Server and Query's control plane. - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { SdkControlServerTransport } from '../../src/mcp/SdkControlServerTransport.js'; - -describe('SdkControlServerTransport', () => { - let sendToQuery: ReturnType; - let transport: SdkControlServerTransport; - - beforeEach(() => { - sendToQuery = vi.fn().mockResolvedValue({ result: 'success' }); - transport = new SdkControlServerTransport({ - serverName: 'test-server', - sendToQuery, - }); - }); - - describe('Lifecycle', () => { - it('should start successfully', async () => { - await transport.start(); - expect(transport.isStarted()).toBe(true); - }); - - it('should close successfully', async () => { - await transport.start(); - await transport.close(); - expect(transport.isStarted()).toBe(false); - }); - - it('should handle close callback', async () => { - const onclose = vi.fn(); - transport.onclose = onclose; - - await transport.start(); - await transport.close(); - - expect(onclose).toHaveBeenCalled(); - }); - }); - - describe('Message Sending', () => { - it('should send message to Query', async () => { - await transport.start(); - - const message = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/list', - params: {}, - }; - - await transport.send(message); - - expect(sendToQuery).toHaveBeenCalledWith(message); - }); - - it('should throw error when sending before start', async () => { - const message = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/list', - }; - - await expect(transport.send(message)).rejects.toThrow('not started'); - }); - - it('should handle send errors', async () => { - const error = new Error('Network error'); - sendToQuery.mockRejectedValue(error); - - const onerror = vi.fn(); - transport.onerror = onerror; - - await transport.start(); - - const message = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/list', - }; - - await expect(transport.send(message)).rejects.toThrow('Network error'); - expect(onerror).toHaveBeenCalledWith(error); - }); - }); - - describe('Message Receiving', () => { - it('should deliver message to MCP Server via onmessage', async () => { - const onmessage = vi.fn(); - transport.onmessage = onmessage; - - await transport.start(); - - const message = { - jsonrpc: '2.0' as const, - id: 1, - result: { tools: [] }, - }; - - transport.handleMessage(message); - - expect(onmessage).toHaveBeenCalledWith(message); - }); - - it('should warn when receiving message without onmessage handler', async () => { - const consoleWarnSpy = vi - .spyOn(console, 'warn') - .mockImplementation(() => {}); - - await transport.start(); - - const message = { - jsonrpc: '2.0' as const, - id: 1, - result: {}, - }; - - transport.handleMessage(message); - - expect(consoleWarnSpy).toHaveBeenCalled(); - - consoleWarnSpy.mockRestore(); - }); - - it('should warn when receiving message for closed transport', async () => { - const consoleWarnSpy = vi - .spyOn(console, 'warn') - .mockImplementation(() => {}); - const onmessage = vi.fn(); - transport.onmessage = onmessage; - - await transport.start(); - await transport.close(); - - const message = { - jsonrpc: '2.0' as const, - id: 1, - result: {}, - }; - - transport.handleMessage(message); - - expect(consoleWarnSpy).toHaveBeenCalled(); - expect(onmessage).not.toHaveBeenCalled(); - - consoleWarnSpy.mockRestore(); - }); - }); - - describe('Error Handling', () => { - it('should deliver error to MCP Server via onerror', async () => { - const onerror = vi.fn(); - transport.onerror = onerror; - - await transport.start(); - - const error = new Error('Test error'); - transport.handleError(error); - - expect(onerror).toHaveBeenCalledWith(error); - }); - - it('should log error when no onerror handler set', async () => { - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - - await transport.start(); - - const error = new Error('Test error'); - transport.handleError(error); - - expect(consoleErrorSpy).toHaveBeenCalled(); - - consoleErrorSpy.mockRestore(); - }); - }); - - describe('Server Name', () => { - it('should return server name', () => { - expect(transport.getServerName()).toBe('test-server'); - }); - }); - - describe('Bidirectional Communication', () => { - it('should support full message round-trip', async () => { - const onmessage = vi.fn(); - transport.onmessage = onmessage; - - await transport.start(); - - // Send request from MCP Server to CLI - const request = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/list', - params: {}, - }; - - await transport.send(request); - expect(sendToQuery).toHaveBeenCalledWith(request); - - // Receive response from CLI to MCP Server - const response = { - jsonrpc: '2.0' as const, - id: 1, - result: { - tools: [ - { - name: 'test_tool', - description: 'A test tool', - inputSchema: { type: 'object' }, - }, - ], - }, - }; - - transport.handleMessage(response); - expect(onmessage).toHaveBeenCalledWith(response); - }); - - it('should handle multiple messages in sequence', async () => { - const onmessage = vi.fn(); - transport.onmessage = onmessage; - - await transport.start(); - - // Send multiple requests - for (let i = 0; i < 5; i++) { - const message = { - jsonrpc: '2.0' as const, - id: i, - method: 'test', - }; - - await transport.send(message); - } - - expect(sendToQuery).toHaveBeenCalledTimes(5); - - // Receive multiple responses - for (let i = 0; i < 5; i++) { - const message = { - jsonrpc: '2.0' as const, - id: i, - result: {}, - }; - - transport.handleMessage(message); - } - - expect(onmessage).toHaveBeenCalledTimes(5); - }); - }); -}); From 3056f8a63d1697d0eede3ab8af2facec42bd6f2a Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Mon, 1 Dec 2025 14:56:11 +0800 Subject: [PATCH 13/22] feat(tests): move SDK integration tests to `integration-tests` to share `globalSetup` --- eslint.config.js | 2 +- integration-tests/globalSetup.ts | 44 ++++++++++++-- .../abort-and-lifecycle.test.ts | 2 +- .../configuration-options.test.ts | 4 +- .../sdk-typescript}/mcp-server.test.ts | 4 +- .../sdk-typescript}/multi-turn.test.ts | 4 +- .../permission-control.test.ts | 4 +- .../sdk-typescript}/single-turn.test.ts | 4 +- .../sdk-typescript}/subagents.test.ts | 4 +- .../sdk-typescript}/system-control.test.ts | 8 +-- .../sdk-typescript}/test-helper.ts | 4 +- .../sdk-typescript}/tool-control.test.ts | 4 +- integration-tests/tsconfig.json | 8 ++- integration-tests/vitest.config.ts | 16 +++++- packages/sdk-typescript/package.json | 1 + packages/sdk-typescript/src/index.ts | 11 ++++ .../sdk-typescript/test/e2e/globalSetup.ts | 57 ------------------- 17 files changed, 95 insertions(+), 86 deletions(-) rename {packages/sdk-typescript/test/e2e => integration-tests/sdk-typescript}/abort-and-lifecycle.test.ts (99%) rename {packages/sdk-typescript/test/e2e => integration-tests/sdk-typescript}/configuration-options.test.ts (99%) rename {packages/sdk-typescript/test/e2e => integration-tests/sdk-typescript}/mcp-server.test.ts (99%) rename {packages/sdk-typescript/test/e2e => integration-tests/sdk-typescript}/multi-turn.test.ts (99%) rename {packages/sdk-typescript/test/e2e => integration-tests/sdk-typescript}/permission-control.test.ts (99%) rename {packages/sdk-typescript/test/e2e => integration-tests/sdk-typescript}/single-turn.test.ts (99%) rename {packages/sdk-typescript/test/e2e => integration-tests/sdk-typescript}/subagents.test.ts (99%) rename {packages/sdk-typescript/test/e2e => integration-tests/sdk-typescript}/system-control.test.ts (98%) rename {packages/sdk-typescript/test/e2e => integration-tests/sdk-typescript}/test-helper.ts (99%) rename {packages/sdk-typescript/test/e2e => integration-tests/sdk-typescript}/tool-control.test.ts (99%) delete mode 100644 packages/sdk-typescript/test/e2e/globalSetup.ts diff --git a/eslint.config.js b/eslint.config.js index 13a3d1c36d..5b3b7f3d09 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -238,7 +238,7 @@ export default tseslint.config( prettierConfig, // extra settings for scripts that we run directly with node { - files: ['./integration-tests/**/*.js'], + files: ['./integration-tests/**/*.{js,ts,tsx}'], languageOptions: { globals: { ...globals.node, diff --git a/integration-tests/globalSetup.ts b/integration-tests/globalSetup.ts index 77105af2c1..a8a9877fe4 100644 --- a/integration-tests/globalSetup.ts +++ b/integration-tests/globalSetup.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ @@ -30,6 +30,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const rootDir = join(__dirname, '..'); const integrationTestsDir = join(rootDir, '.integration-tests'); let runDir = ''; // Make runDir accessible in teardown +let sdkE2eRunDir = ''; // SDK E2E test run directory const memoryFilePath = join( os.homedir(), @@ -48,14 +49,36 @@ export async function setup() { // File doesn't exist, which is fine. } + // Setup for CLI integration tests runDir = join(integrationTestsDir, `${Date.now()}`); await mkdir(runDir, { recursive: true }); + // Setup for SDK E2E tests (separate directory with prefix) + sdkE2eRunDir = join(integrationTestsDir, `sdk-e2e-${Date.now()}`); + await mkdir(sdkE2eRunDir, { recursive: true }); + // Clean up old test runs, but keep the latest few for debugging try { const testRuns = await readdir(integrationTestsDir); - if (testRuns.length > 5) { - const oldRuns = testRuns.sort().slice(0, testRuns.length - 5); + + // Clean up old CLI integration test runs (without sdk-e2e- prefix) + const cliTestRuns = testRuns.filter((run) => !run.startsWith('sdk-e2e-')); + if (cliTestRuns.length > 5) { + const oldRuns = cliTestRuns.sort().slice(0, cliTestRuns.length - 5); + await Promise.all( + oldRuns.map((oldRun) => + rm(join(integrationTestsDir, oldRun), { + recursive: true, + force: true, + }), + ), + ); + } + + // Clean up old SDK E2E test runs (with sdk-e2e- prefix) + const sdkTestRuns = testRuns.filter((run) => run.startsWith('sdk-e2e-')); + if (sdkTestRuns.length > 5) { + const oldRuns = sdkTestRuns.sort().slice(0, sdkTestRuns.length - 5); await Promise.all( oldRuns.map((oldRun) => rm(join(integrationTestsDir, oldRun), { @@ -69,24 +92,37 @@ export async function setup() { console.error('Error cleaning up old test runs:', e); } + // Environment variables for CLI integration tests process.env['INTEGRATION_TEST_FILE_DIR'] = runDir; process.env['GEMINI_CLI_INTEGRATION_TEST'] = 'true'; process.env['TELEMETRY_LOG_FILE'] = join(runDir, 'telemetry.log'); + // Environment variables for SDK E2E tests + process.env['E2E_TEST_FILE_DIR'] = sdkE2eRunDir; + process.env['TEST_CLI_PATH'] = join(rootDir, 'dist/cli.js'); + if (process.env['KEEP_OUTPUT']) { console.log(`Keeping output for test run in: ${runDir}`); + console.log(`Keeping output for SDK E2E test run in: ${sdkE2eRunDir}`); } process.env['VERBOSE'] = process.env['VERBOSE'] ?? 'false'; console.log(`\nIntegration test output directory: ${runDir}`); + console.log(`SDK E2E test output directory: ${sdkE2eRunDir}`); + console.log(`CLI path: ${process.env['TEST_CLI_PATH']}`); } export async function teardown() { - // Cleanup the test run directory unless KEEP_OUTPUT is set + // Cleanup the CLI test run directory unless KEEP_OUTPUT is set if (process.env['KEEP_OUTPUT'] !== 'true' && runDir) { await rm(runDir, { recursive: true, force: true }); } + // Cleanup the SDK E2E test run directory unless KEEP_OUTPUT is set + if (process.env['KEEP_OUTPUT'] !== 'true' && sdkE2eRunDir) { + await rm(sdkE2eRunDir, { recursive: true, force: true }); + } + if (originalMemoryContent !== null) { await mkdir(dirname(memoryFilePath), { recursive: true }); await writeFile(memoryFilePath, originalMemoryContent, 'utf-8'); diff --git a/packages/sdk-typescript/test/e2e/abort-and-lifecycle.test.ts b/integration-tests/sdk-typescript/abort-and-lifecycle.test.ts similarity index 99% rename from packages/sdk-typescript/test/e2e/abort-and-lifecycle.test.ts rename to integration-tests/sdk-typescript/abort-and-lifecycle.test.ts index 806a4a201f..b0b4c3fd37 100644 --- a/packages/sdk-typescript/test/e2e/abort-and-lifecycle.test.ts +++ b/integration-tests/sdk-typescript/abort-and-lifecycle.test.ts @@ -13,7 +13,7 @@ import { isSDKAssistantMessage, type TextBlock, type ContentBlock, -} from '../../src/index.js'; +} from '@qwen-code/sdk-typescript'; import { SDKTestHelper, createSharedTestOptions } from './test-helper.js'; const SHARED_TEST_OPTIONS = createSharedTestOptions(); diff --git a/packages/sdk-typescript/test/e2e/configuration-options.test.ts b/integration-tests/sdk-typescript/configuration-options.test.ts similarity index 99% rename from packages/sdk-typescript/test/e2e/configuration-options.test.ts rename to integration-tests/sdk-typescript/configuration-options.test.ts index e81af7fd93..bac0a3680f 100644 --- a/packages/sdk-typescript/test/e2e/configuration-options.test.ts +++ b/integration-tests/sdk-typescript/configuration-options.test.ts @@ -12,12 +12,12 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { query } from '../../src/index.js'; import { + query, isSDKAssistantMessage, isSDKSystemMessage, type SDKMessage, -} from '../../src/types/protocol.js'; +} from '@qwen-code/sdk-typescript'; import { SDKTestHelper, extractText, diff --git a/packages/sdk-typescript/test/e2e/mcp-server.test.ts b/integration-tests/sdk-typescript/mcp-server.test.ts similarity index 99% rename from packages/sdk-typescript/test/e2e/mcp-server.test.ts rename to integration-tests/sdk-typescript/mcp-server.test.ts index dd13d2053d..110c192434 100644 --- a/packages/sdk-typescript/test/e2e/mcp-server.test.ts +++ b/integration-tests/sdk-typescript/mcp-server.test.ts @@ -10,8 +10,8 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { query } from '../../src/index.js'; import { + query, isSDKAssistantMessage, isSDKResultMessage, isSDKSystemMessage, @@ -19,7 +19,7 @@ import { type SDKMessage, type ToolUseBlock, type SDKSystemMessage, -} from '../../src/types/protocol.js'; +} from '@qwen-code/sdk-typescript'; import { SDKTestHelper, createMCPServer, diff --git a/packages/sdk-typescript/test/e2e/multi-turn.test.ts b/integration-tests/sdk-typescript/multi-turn.test.ts similarity index 99% rename from packages/sdk-typescript/test/e2e/multi-turn.test.ts rename to integration-tests/sdk-typescript/multi-turn.test.ts index 689a6468b4..17b6f675f3 100644 --- a/packages/sdk-typescript/test/e2e/multi-turn.test.ts +++ b/integration-tests/sdk-typescript/multi-turn.test.ts @@ -4,8 +4,8 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { query } from '../../src/index.js'; import { + query, isSDKUserMessage, isSDKAssistantMessage, isSDKSystemMessage, @@ -21,7 +21,7 @@ import { type SDKMessage, type ControlMessage, type ToolUseBlock, -} from '../../src/types/protocol.js'; +} from '@qwen-code/sdk-typescript'; import { SDKTestHelper, createSharedTestOptions } from './test-helper.js'; const SHARED_TEST_OPTIONS = createSharedTestOptions(); diff --git a/packages/sdk-typescript/test/e2e/permission-control.test.ts b/integration-tests/sdk-typescript/permission-control.test.ts similarity index 99% rename from packages/sdk-typescript/test/e2e/permission-control.test.ts rename to integration-tests/sdk-typescript/permission-control.test.ts index 587fa500a0..31c7768aba 100644 --- a/packages/sdk-typescript/test/e2e/permission-control.test.ts +++ b/integration-tests/sdk-typescript/permission-control.test.ts @@ -13,8 +13,8 @@ import { beforeEach, afterEach, } from 'vitest'; -import { query } from '../../src/index.js'; import { + query, isSDKAssistantMessage, isSDKResultMessage, isSDKUserMessage, @@ -22,7 +22,7 @@ import { type SDKUserMessage, type ToolUseBlock, type ContentBlock, -} from '../../src/types/protocol.js'; +} from '@qwen-code/sdk-typescript'; import { SDKTestHelper, createSharedTestOptions, diff --git a/packages/sdk-typescript/test/e2e/single-turn.test.ts b/integration-tests/sdk-typescript/single-turn.test.ts similarity index 99% rename from packages/sdk-typescript/test/e2e/single-turn.test.ts rename to integration-tests/sdk-typescript/single-turn.test.ts index 4adb7c0b81..aa2716f365 100644 --- a/packages/sdk-typescript/test/e2e/single-turn.test.ts +++ b/integration-tests/sdk-typescript/single-turn.test.ts @@ -4,8 +4,8 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { query } from '../../src/index.js'; import { + query, isSDKAssistantMessage, isSDKSystemMessage, isSDKResultMessage, @@ -13,7 +13,7 @@ import { type SDKMessage, type SDKSystemMessage, type SDKAssistantMessage, -} from '../../src/types/protocol.js'; +} from '@qwen-code/sdk-typescript'; import { SDKTestHelper, extractText, diff --git a/packages/sdk-typescript/test/e2e/subagents.test.ts b/integration-tests/sdk-typescript/subagents.test.ts similarity index 99% rename from packages/sdk-typescript/test/e2e/subagents.test.ts rename to integration-tests/sdk-typescript/subagents.test.ts index 06e3fd369d..86516053d8 100644 --- a/packages/sdk-typescript/test/e2e/subagents.test.ts +++ b/integration-tests/sdk-typescript/subagents.test.ts @@ -10,14 +10,14 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { query } from '../../src/index.js'; import { + query, isSDKAssistantMessage, type SDKMessage, type SubagentConfig, type ContentBlock, type ToolUseBlock, -} from '../../src/types/protocol.js'; +} from '@qwen-code/sdk-typescript'; import { SDKTestHelper, extractText, diff --git a/packages/sdk-typescript/test/e2e/system-control.test.ts b/integration-tests/sdk-typescript/system-control.test.ts similarity index 98% rename from packages/sdk-typescript/test/e2e/system-control.test.ts rename to integration-tests/sdk-typescript/system-control.test.ts index 3515532ebf..069eccd931 100644 --- a/packages/sdk-typescript/test/e2e/system-control.test.ts +++ b/integration-tests/sdk-typescript/system-control.test.ts @@ -4,12 +4,12 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { query } from '../../src/index.js'; import { + query, isSDKAssistantMessage, isSDKSystemMessage, type SDKUserMessage, -} from '../../src/types/protocol.js'; +} from '@qwen-code/sdk-typescript'; import { SDKTestHelper, createSharedTestOptions } from './test-helper.js'; const SHARED_TEST_OPTIONS = createSharedTestOptions(); @@ -265,7 +265,7 @@ describe('System Control (E2E)', () => { // First model change await q.setModel('qwen3-turbo'); - resumeResolve1?.(); + resumeResolve1!(); // Wait for second response await Promise.race([ @@ -277,7 +277,7 @@ describe('System Control (E2E)', () => { // Second model change await q.setModel('qwen3-vl-plus'); - resumeResolve2?.(); + resumeResolve2!(); // Wait for third response await Promise.race([ diff --git a/packages/sdk-typescript/test/e2e/test-helper.ts b/integration-tests/sdk-typescript/test-helper.ts similarity index 99% rename from packages/sdk-typescript/test/e2e/test-helper.ts rename to integration-tests/sdk-typescript/test-helper.ts index 4b1465ad65..cd95051fab 100644 --- a/packages/sdk-typescript/test/e2e/test-helper.ts +++ b/integration-tests/sdk-typescript/test-helper.ts @@ -21,12 +21,12 @@ import type { ContentBlock, TextBlock, ToolUseBlock, -} from '../../src/types/protocol.js'; +} from '@qwen-code/sdk-typescript'; import { isSDKAssistantMessage, isSDKSystemMessage, isSDKResultMessage, -} from '../../src/types/protocol.js'; +} from '@qwen-code/sdk-typescript'; // ============================================================================ // Core Test Helper Class diff --git a/packages/sdk-typescript/test/e2e/tool-control.test.ts b/integration-tests/sdk-typescript/tool-control.test.ts similarity index 99% rename from packages/sdk-typescript/test/e2e/tool-control.test.ts rename to integration-tests/sdk-typescript/tool-control.test.ts index 30a811df69..036d779e51 100644 --- a/packages/sdk-typescript/test/e2e/tool-control.test.ts +++ b/integration-tests/sdk-typescript/tool-control.test.ts @@ -12,11 +12,11 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { query } from '../../src/index.js'; import { + query, isSDKAssistantMessage, type SDKMessage, -} from '../../src/types/protocol.js'; +} from '@qwen-code/sdk-typescript'; import { SDKTestHelper, extractText, diff --git a/integration-tests/tsconfig.json b/integration-tests/tsconfig.json index 295741e16f..7f2a010dfd 100644 --- a/integration-tests/tsconfig.json +++ b/integration-tests/tsconfig.json @@ -2,7 +2,13 @@ "extends": "../tsconfig.json", "compilerOptions": { "noEmit": true, - "allowJs": true + "allowJs": true, + "baseUrl": ".", + "paths": { + "@qwen-code/sdk-typescript": [ + "../packages/sdk-typescript/dist/index.d.ts" + ] + } }, "include": ["**/*.ts"], "references": [{ "path": "../packages/core" }] diff --git a/integration-tests/vitest.config.ts b/integration-tests/vitest.config.ts index c8b79ad6b4..a452583ca3 100644 --- a/integration-tests/vitest.config.ts +++ b/integration-tests/vitest.config.ts @@ -1,12 +1,15 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ import { defineConfig } from 'vitest/config'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; -const timeoutMinutes = Number(process.env.TB_TIMEOUT_MINUTES || '5'); +const __dirname = dirname(fileURLToPath(import.meta.url)); +const timeoutMinutes = Number(process.env['TB_TIMEOUT_MINUTES'] || '5'); const testTimeoutMs = timeoutMinutes * 60 * 1000; export default defineConfig({ @@ -25,4 +28,13 @@ export default defineConfig({ }, }, }, + resolve: { + alias: { + // Use built SDK bundle for e2e tests + '@qwen-code/sdk-typescript': resolve( + __dirname, + '../packages/sdk-typescript/dist/index.mjs', + ), + }, + }, }); diff --git a/packages/sdk-typescript/package.json b/packages/sdk-typescript/package.json index 63fed22751..d2787bf80b 100644 --- a/packages/sdk-typescript/package.json +++ b/packages/sdk-typescript/package.json @@ -22,6 +22,7 @@ "scripts": { "build": "node scripts/build.js", "test": "vitest run", + "test:ci": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "lint": "eslint src test", diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index f8bf81c5a1..da40baf2a1 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -18,6 +18,14 @@ export type { SDKResultMessage, SDKPartialAssistantMessage, SDKMessage, + ControlMessage, + CLIControlRequest, + CLIControlResponse, + ControlCancelRequest, + SubagentConfig, + SubagentLevel, + ModelConfig, + RunConfig, } from './types/protocol.js'; export { @@ -26,6 +34,9 @@ export { isSDKSystemMessage, isSDKResultMessage, isSDKPartialAssistantMessage, + isControlRequest, + isControlResponse, + isControlCancel, } from './types/protocol.js'; export type { diff --git a/packages/sdk-typescript/test/e2e/globalSetup.ts b/packages/sdk-typescript/test/e2e/globalSetup.ts deleted file mode 100644 index 4f98b87739..0000000000 --- a/packages/sdk-typescript/test/e2e/globalSetup.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { mkdir, readdir, rm } from 'node:fs/promises'; -import { join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const rootDir = join(__dirname, '../..'); -const e2eTestsDir = join(rootDir, '.integration-tests'); -let runDir = ''; - -export async function setup() { - runDir = join(e2eTestsDir, `sdk-e2e-${Date.now()}`); - await mkdir(runDir, { recursive: true }); - - // Clean up old test runs, but keep the latest few for debugging - try { - const testRuns = await readdir(e2eTestsDir); - const sdkTestRuns = testRuns.filter((run) => run.startsWith('sdk-e2e-')); - if (sdkTestRuns.length > 5) { - const oldRuns = sdkTestRuns.sort().slice(0, sdkTestRuns.length - 5); - await Promise.all( - oldRuns.map((oldRun) => - rm(join(e2eTestsDir, oldRun), { - recursive: true, - force: true, - }), - ), - ); - } - } catch (e) { - console.error('Error cleaning up old test runs:', e); - } - - process.env['E2E_TEST_FILE_DIR'] = runDir; - process.env['QWEN_CLI_E2E_TEST'] = 'true'; - process.env['TEST_CLI_PATH'] = join(rootDir, '../../dist/cli.js'); - - if (process.env['KEEP_OUTPUT']) { - console.log(`Keeping output for test run in: ${runDir}`); - } - process.env['VERBOSE'] = process.env['VERBOSE'] ?? 'false'; - - console.log(`\nSDK E2E test output directory: ${runDir}`); - console.log(`CLI path: ${process.env['TEST_CLI_PATH']}`); -} - -export async function teardown() { - // Cleanup the test run directory unless KEEP_OUTPUT is set - if (process.env['KEEP_OUTPUT'] !== 'true' && runDir) { - await rm(runDir, { recursive: true, force: true }); - } -} From 50e3a6ee0a84042d38d07ef56536976a21c85422 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Mon, 1 Dec 2025 16:18:18 +0800 Subject: [PATCH 14/22] fix: enhance 429 error handling and fix failed cases --- integration-tests/simple-mcp-server.test.ts | 2 +- .../control/controllers/systemController.ts | 77 +++++++++---------- .../io/BaseJsonOutputAdapter.ts | 18 ++++- .../cli/src/nonInteractive/session.test.ts | 1 + packages/cli/src/nonInteractiveCli.ts | 10 +++ packages/sdk-typescript/scripts/build.js | 2 +- packages/sdk-typescript/vitest.config.ts | 1 - 7 files changed, 68 insertions(+), 43 deletions(-) diff --git a/integration-tests/simple-mcp-server.test.ts b/integration-tests/simple-mcp-server.test.ts index d8b6268d01..d58bd9829c 100644 --- a/integration-tests/simple-mcp-server.test.ts +++ b/integration-tests/simple-mcp-server.test.ts @@ -213,7 +213,7 @@ describe('simple-mcp-server', () => { it('should add two numbers', async () => { // Test directory is already set up in before hook // Just run the command - MCP server config is in settings.json - const output = await rig.run('add 5 and 10'); + const output = await rig.run('add 5 and 10, use tool if you can.'); const foundToolCall = await rig.waitForToolCall('add'); diff --git a/packages/cli/src/nonInteractive/control/controllers/systemController.ts b/packages/cli/src/nonInteractive/control/controllers/systemController.ts index 7981a67b91..c94187e7da 100644 --- a/packages/cli/src/nonInteractive/control/controllers/systemController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/systemController.ts @@ -19,6 +19,8 @@ import type { CLIControlInitializeRequest, CLIControlSetModelRequest, } from '../../types.js'; +import { CommandService } from '../../../services/CommandService.js'; +import { BuiltinCommandLoader } from '../../../services/BuiltinCommandLoader.js'; export class SystemController extends BaseController { /** @@ -141,31 +143,10 @@ export class SystemController extends BaseController { can_set_permission_mode: typeof this.context.config.setApprovalMode === 'function', can_set_model: typeof this.context.config.setModel === 'function', + /* TODO: sdkMcpServers support */ + can_handle_mcp_message: false, }; - // Check if MCP message handling is available - try { - const mcpProvider = this.context.config as unknown as { - getMcpServers?: () => Record | undefined; - }; - if (typeof mcpProvider.getMcpServers === 'function') { - const servers = mcpProvider.getMcpServers(); - capabilities['can_handle_mcp_message'] = Boolean( - servers && Object.keys(servers).length > 0, - ); - } else { - capabilities['can_handle_mcp_message'] = false; - } - } catch (error) { - if (this.context.debugMode) { - console.error( - '[SystemController] Failed to determine MCP capability:', - error, - ); - } - capabilities['can_handle_mcp_message'] = false; - } - return capabilities; } @@ -240,27 +221,45 @@ export class SystemController extends BaseController { /** * Handle supported_commands request * - * Returns list of supported control commands - * - * Note: This list should match the ControlRequestType enum in - * packages/sdk/typescript/src/types/controlRequests.ts + * Returns list of supported slash commands loaded dynamically */ private async handleSupportedCommands(): Promise> { - const commands = [ - 'initialize', - 'interrupt', - 'set_model', - 'supported_commands', - 'can_use_tool', - 'set_permission_mode', - 'mcp_message', - 'mcp_server_status', - 'hook_callback', - ]; + const slashCommands = await this.loadSlashCommandNames(); return { subtype: 'supported_commands', - commands, + commands: slashCommands, }; } + + /** + * Load slash command names using CommandService + * + * @returns Promise resolving to array of slash command names + */ + private async loadSlashCommandNames(): Promise { + const controller = new AbortController(); + try { + const service = await CommandService.create( + [new BuiltinCommandLoader(this.context.config)], + controller.signal, + ); + const names = new Set(); + const commands = service.getCommands(); + for (const command of commands) { + names.add(command.name); + } + return Array.from(names).sort(); + } catch (error) { + if (this.context.debugMode) { + console.error( + '[SystemController] Failed to load slash commands:', + error, + ); + } + return []; + } finally { + controller.abort(); + } + } } diff --git a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts index 551ea9ff39..915fb72135 100644 --- a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts +++ b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts @@ -13,7 +13,11 @@ import type { ServerGeminiStreamEvent, TaskResultDisplay, } from '@qwen-code/qwen-code-core'; -import { GeminiEventType, ToolErrorType } from '@qwen-code/qwen-code-core'; +import { + GeminiEventType, + ToolErrorType, + parseAndFormatApiError, +} from '@qwen-code/qwen-code-core'; import type { Part, GenerateContentResponseUsageMetadata } from '@google/genai'; import type { CLIAssistantMessage, @@ -600,6 +604,18 @@ export abstract class BaseJsonOutputAdapter { } this.finalizePendingBlocks(state, null); break; + case GeminiEventType.Error: { + // Format the error message using parseAndFormatApiError for consistency + // with interactive mode error display + const errorText = parseAndFormatApiError( + event.value.error, + this.config.getContentGeneratorConfig()?.authType, + undefined, + this.config.getModel(), + ); + this.appendText(state, errorText, null); + break; + } default: break; } diff --git a/packages/cli/src/nonInteractive/session.test.ts b/packages/cli/src/nonInteractive/session.test.ts index 61643fb3ee..6670d4c2dc 100644 --- a/packages/cli/src/nonInteractive/session.test.ts +++ b/packages/cli/src/nonInteractive/session.test.ts @@ -69,6 +69,7 @@ function createConfig(overrides: ConfigOverrides = {}): Config { getDebugMode: () => false, getApprovalMode: () => 'auto', getOutputFormat: () => 'stream-json', + initialize: vi.fn(), }; return { ...base, ...overrides } as unknown as Config; } diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 77e4f98043..6f96d62b72 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -17,6 +17,7 @@ import { OutputFormat, InputFormat, uiTelemetryService, + parseAndFormatApiError, } from '@qwen-code/qwen-code-core'; import type { Content, Part, PartListUnion } from '@google/genai'; import type { CLIUserMessage, PermissionMode } from './nonInteractive/types.js'; @@ -210,6 +211,15 @@ export async function runNonInteractive( process.stdout.write(event.value); } else if (event.type === GeminiEventType.ToolCallRequest) { toolCallRequests.push(event.value); + } else if (event.type === GeminiEventType.Error) { + // Format and output the error message for text mode + const errorText = parseAndFormatApiError( + event.value.error, + config.getContentGeneratorConfig()?.authType, + undefined, + config.getModel(), + ); + process.stderr.write(`${errorText}\n`); } } } diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index e78f161a4f..db0632cfde 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -24,7 +24,7 @@ execSync('tsc --project tsconfig.build.json', { try { execSync( - 'npx dts-bundle-generator --project tsconfig.build.json -o dist/index.d.ts src/index.ts --no-check', + 'npx dts-bundle-generator --project tsconfig.build.json -o dist/index.d.ts src/index.ts', { stdio: 'inherit', cwd: rootDir, diff --git a/packages/sdk-typescript/vitest.config.ts b/packages/sdk-typescript/vitest.config.ts index aef50ffdc1..f46dc53792 100644 --- a/packages/sdk-typescript/vitest.config.ts +++ b/packages/sdk-typescript/vitest.config.ts @@ -38,7 +38,6 @@ export default defineConfig({ }, testTimeout: testTimeoutMs, hookTimeout: 10000, - globalSetup: './test/e2e/globalSetup.ts', }, resolve: { alias: { From 81c8b3eaec23927b5173b2217b6d66ef493782d0 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Mon, 1 Dec 2025 17:54:43 +0800 Subject: [PATCH 15/22] feat: add GitHub Actions workflow for SDK release automation --- .github/workflows/release-sdk.yml | 226 +++++++++++ packages/sdk-typescript/README.md | 284 ++++++++++++++ packages/sdk-typescript/package.json | 3 +- packages/sdk-typescript/scripts/build.js | 18 +- .../scripts/get-release-version.js | 353 ++++++++++++++++++ 5 files changed, 872 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/release-sdk.yml create mode 100644 packages/sdk-typescript/README.md create mode 100644 packages/sdk-typescript/scripts/get-release-version.js diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml new file mode 100644 index 0000000000..6249f08e05 --- /dev/null +++ b/.github/workflows/release-sdk.yml @@ -0,0 +1,226 @@ +name: 'Release SDK' + +on: + schedule: + # Runs every day at 1:00 AM UTC for the nightly release (offset from CLI at 0:00). + - cron: '0 1 * * *' + # Runs every Wednesday at 00:59 UTC for the preview release (offset from CLI on Tuesday). + - cron: '59 0 * * 3' + workflow_dispatch: + inputs: + version: + description: 'The version to release (e.g., v0.1.11). Required for manual patch releases.' + required: false + type: 'string' + ref: + description: 'The branch or ref (full git sha) to release from.' + required: true + type: 'string' + default: 'main' + dry_run: + description: 'Run a dry-run of the release process; no branches, npm packages or GitHub releases will be created.' + required: true + type: 'boolean' + default: true + create_nightly_release: + description: 'Auto apply the nightly release tag, input version is ignored.' + required: false + type: 'boolean' + default: false + create_preview_release: + description: 'Auto apply the preview release tag, input version is ignored.' + required: false + type: 'boolean' + default: false + force_skip_tests: + description: 'Select to skip the "Run Tests" step in testing. Prod releases should run tests' + required: false + type: 'boolean' + default: false + +jobs: + release-sdk: + runs-on: 'ubuntu-latest' + environment: + name: 'production-release' + url: '${{ github.server_url }}/${{ github.repository }}/releases/tag/sdk-typescript-${{ steps.version.outputs.RELEASE_TAG }}' + if: |- + ${{ github.repository == 'QwenLM/qwen-code' }} + permissions: + contents: 'write' + packages: 'write' + id-token: 'write' + issues: 'write' + outputs: + RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' + + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + with: + ref: '${{ github.event.inputs.ref || github.sha }}' + fetch-depth: 0 + + - name: 'Set booleans for simplified logic' + env: + CREATE_NIGHTLY_RELEASE: '${{ github.event.inputs.create_nightly_release }}' + CREATE_PREVIEW_RELEASE: '${{ github.event.inputs.create_preview_release }}' + EVENT_NAME: '${{ github.event_name }}' + CRON: '${{ github.event.schedule }}' + DRY_RUN_INPUT: '${{ github.event.inputs.dry_run }}' + id: 'vars' + run: |- + is_nightly="false" + if [[ "${CRON}" == "0 1 * * *" || "${CREATE_NIGHTLY_RELEASE}" == "true" ]]; then + is_nightly="true" + fi + echo "is_nightly=${is_nightly}" >> "${GITHUB_OUTPUT}" + + is_preview="false" + if [[ "${CRON}" == "59 0 * * 3" || "${CREATE_PREVIEW_RELEASE}" == "true" ]]; then + is_preview="true" + fi + echo "is_preview=${is_preview}" >> "${GITHUB_OUTPUT}" + + is_dry_run="false" + if [[ "${DRY_RUN_INPUT}" == "true" ]]; then + is_dry_run="true" + fi + echo "is_dry_run=${is_dry_run}" >> "${GITHUB_OUTPUT}" + + - name: 'Setup Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: 'Install Dependencies' + run: |- + npm ci + + - name: 'Get the version' + id: 'version' + run: | + VERSION_ARGS=() + if [[ "${IS_NIGHTLY}" == "true" ]]; then + VERSION_ARGS+=(--type=nightly) + elif [[ "${IS_PREVIEW}" == "true" ]]; then + VERSION_ARGS+=(--type=preview) + if [[ -n "${MANUAL_VERSION}" ]]; then + VERSION_ARGS+=("--preview_version_override=${MANUAL_VERSION}") + fi + else + VERSION_ARGS+=(--type=stable) + if [[ -n "${MANUAL_VERSION}" ]]; then + VERSION_ARGS+=("--stable_version_override=${MANUAL_VERSION}") + fi + fi + + VERSION_JSON=$(node packages/sdk-typescript/scripts/get-release-version.js "${VERSION_ARGS[@]}") + echo "RELEASE_TAG=$(echo "$VERSION_JSON" | jq -r .releaseTag)" >> "$GITHUB_OUTPUT" + echo "RELEASE_VERSION=$(echo "$VERSION_JSON" | jq -r .releaseVersion)" >> "$GITHUB_OUTPUT" + echo "NPM_TAG=$(echo "$VERSION_JSON" | jq -r .npmTag)" >> "$GITHUB_OUTPUT" + + echo "PREVIOUS_RELEASE_TAG=$(echo "$VERSION_JSON" | jq -r .previousReleaseTag)" >> "$GITHUB_OUTPUT" + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + IS_NIGHTLY: '${{ steps.vars.outputs.is_nightly }}' + IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}' + MANUAL_VERSION: '${{ inputs.version }}' + + - name: 'Run Tests' + if: |- + ${{ github.event.inputs.force_skip_tests != 'true' }} + working-directory: 'packages/sdk-typescript' + run: | + npm run test:ci + env: + OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}' + OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}' + OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}' + + - name: 'Configure Git User' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: 'Create and switch to a release branch' + id: 'release_branch' + env: + RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' + run: |- + BRANCH_NAME="release/sdk-typescript/${RELEASE_TAG}" + git switch -c "${BRANCH_NAME}" + echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}" + + - name: 'Update package version' + working-directory: 'packages/sdk-typescript' + env: + RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}' + run: |- + npm version "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version + + - name: 'Commit and Conditionally Push package version' + env: + BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}' + IS_DRY_RUN: '${{ steps.vars.outputs.is_dry_run }}' + RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' + run: |- + git add packages/sdk-typescript/package.json + if git diff --staged --quiet; then + echo "No version changes to commit" + else + git commit -m "chore(release): sdk-typescript ${RELEASE_TAG}" + fi + if [[ "${IS_DRY_RUN}" == "false" ]]; then + echo "Pushing release branch to remote..." + git push --set-upstream origin "${BRANCH_NAME}" --follow-tags + else + echo "Dry run enabled. Skipping push." + fi + + - name: 'Build SDK' + working-directory: 'packages/sdk-typescript' + run: |- + npm run build + + - name: 'Configure npm for publishing' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + scope: '@qwen-code' + + - name: 'Publish @qwen-code/sdk-typescript' + working-directory: 'packages/sdk-typescript' + run: |- + npm publish --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }} + env: + NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}' + + - name: 'Create GitHub Release and Tag' + if: |- + ${{ steps.vars.outputs.is_dry_run == 'false' }} + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}' + RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' + PREVIOUS_RELEASE_TAG: '${{ steps.version.outputs.PREVIOUS_RELEASE_TAG }}' + run: |- + gh release create "sdk-typescript-${RELEASE_TAG}" \ + --target "$RELEASE_BRANCH" \ + --title "SDK TypeScript Release ${RELEASE_TAG}" \ + --notes-start-tag "sdk-typescript-${PREVIOUS_RELEASE_TAG}" \ + --generate-notes + + - name: 'Create Issue on Failure' + if: |- + ${{ failure() }} + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }} || "N/A"' + DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + run: |- + gh issue create \ + --title "SDK Release Failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')" \ + --body "The SDK release workflow failed. See the full run for details: ${DETAILS_URL}" diff --git a/packages/sdk-typescript/README.md b/packages/sdk-typescript/README.md new file mode 100644 index 0000000000..ed441bc703 --- /dev/null +++ b/packages/sdk-typescript/README.md @@ -0,0 +1,284 @@ +# @qwen-code/sdk-typescript + +A minimum experimental TypeScript SDK for programmatic access to Qwen Code. + +Feel free to submit a feature request/issue/PR. + +## Installation + +```bash +npm install @qwen-code/sdk-typescript +``` + +## Requirements + +- Node.js >= 20.0.0 +- [Qwen Code](https://github.com/QwenLM/qwen-code) installed and accessible in PATH + +> **Note for nvm users**: If you use nvm to manage Node.js versions, the SDK may not be able to auto-detect the Qwen Code executable. You should explicitly set the `pathToQwenExecutable` option to the full path of the `qwen` binary. + +## Quick Start + +```typescript +import { query } from '@qwen-code/sdk-typescript'; + +// Single-turn query +const result = query({ + prompt: 'What files are in the current directory?', + options: { + cwd: '/path/to/project', + }, +}); + +// Iterate over messages +for await (const message of result) { + if (message.type === 'assistant') { + console.log('Assistant:', message.message.content); + } else if (message.type === 'result') { + console.log('Result:', message.result); + } +} +``` + +## API Reference + +### `query(config)` + +Creates a new query session with the Qwen Code. + +#### Parameters + +- `prompt`: `string | AsyncIterable` - The prompt to send. Use a string for single-turn queries or an async iterable for multi-turn conversations. +- `options`: `QueryOptions` - Configuration options for the query session. + +#### QueryOptions + +| Option | Type | Default | Description | +| ------------------------ | ---------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cwd` | `string` | `process.cwd()` | The working directory for the query session. Determines the context in which file operations and commands are executed. | +| `model` | `string` | - | The AI model to use (e.g., `'qwen-max'`, `'qwen-plus'`, `'qwen-turbo'`). Takes precedence over `OPENAI_MODEL` and `QWEN_MODEL` environment variables. | +| `pathToQwenExecutable` | `string` | Auto-detected | Path to the Qwen Code executable. Supports multiple formats: `'qwen'` (native binary from PATH), `'/path/to/qwen'` (explicit path), `'/path/to/cli.js'` (Node.js bundle), `'node:/path/to/cli.js'` (force Node.js runtime), `'bun:/path/to/cli.js'` (force Bun runtime). If not provided, auto-detects from: `QWEN_CODE_CLI_PATH` env var, `~/.volta/bin/qwen`, `~/.npm-global/bin/qwen`, `/usr/local/bin/qwen`, `~/.local/bin/qwen`, `~/node_modules/.bin/qwen`, `~/.yarn/bin/qwen`. | +| `permissionMode` | `'default' \| 'plan' \| 'auto-edit' \| 'yolo'` | `'default'` | Permission mode controlling tool execution approval. See [Permission Modes](#permission-modes) for details. | +| `canUseTool` | `CanUseTool` | - | Custom permission handler for tool execution approval. Invoked when a tool requires confirmation. Must respond within 30 seconds or the request will be auto-denied. See [Custom Permission Handler](#custom-permission-handler). | +| `env` | `Record` | - | Environment variables to pass to the Qwen Code process. Merged with the current process environment. | +| `mcpServers` | `Record` | - | External MCP (Model Context Protocol) servers to connect. Each server is identified by a unique name and configured with `command`, `args`, and `env`. | +| `abortController` | `AbortController` | - | Controller to cancel the query session. Call `abortController.abort()` to terminate the session and cleanup resources. | +| `debug` | `boolean` | `false` | Enable debug mode for verbose logging from the CLI process. | +| `maxSessionTurns` | `number` | `-1` (unlimited) | Maximum number of conversation turns before the session automatically terminates. A turn consists of a user message and an assistant response. | +| `coreTools` | `string[]` | - | Equivalent to `tool.core` in settings.json. If specified, only these tools will be available to the AI. Example: `['read_file', 'write_file', 'run_terminal_cmd']`. | +| `excludeTools` | `string[]` | - | Equivalent to `tool.exclude` in settings.json. Excluded tools return a permission error immediately. Takes highest priority over all other permission settings. Supports pattern matching: tool name (`'write_file'`), tool class (`'ShellTool'`), or shell command prefix (`'ShellTool(rm )'`). | +| `allowedTools` | `string[]` | - | Equivalent to `tool.allowed` in settings.json. Matching tools bypass `canUseTool` callback and execute automatically. Only applies when tool requires confirmation. Supports same pattern matching as `excludeTools`. | +| `authType` | `'openai' \| 'qwen-oauth'` | `'openai'` | Authentication type for the AI service. Using `'qwen-oauth'` in SDK is not recommended as credentials are stored in `~/.qwen` and may need periodic refresh. | +| `agents` | `SubagentConfig[]` | - | Configuration for subagents that can be invoked during the session. Subagents are specialized AI agents for specific tasks or domains. | +| `includePartialMessages` | `boolean` | `false` | When `true`, the SDK emits incomplete messages as they are being generated, allowing real-time streaming of the AI's response. | + +### Timeouts + +The SDK enforces the following timeouts: + +| Timeout | Duration | Description | +| ------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------- | +| Permission Callback | 30 seconds | Maximum time for `canUseTool` callback to respond. If exceeded, the tool request is auto-denied. | +| Control Request | 30 seconds | Maximum time for control operations like `initialize()`, `setModel()`, `setPermissionMode()`, and `interrupt()` to complete. | + +### Message Types + +The SDK provides type guards to identify different message types: + +```typescript +import { + isSDKUserMessage, + isSDKAssistantMessage, + isSDKSystemMessage, + isSDKResultMessage, + isSDKPartialAssistantMessage, +} from '@qwen-code/sdk-typescript'; + +for await (const message of result) { + if (isSDKAssistantMessage(message)) { + // Handle assistant message + } else if (isSDKResultMessage(message)) { + // Handle result message + } +} +``` + +### Query Instance Methods + +The `Query` instance returned by `query()` provides several methods: + +```typescript +const q = query({ prompt: 'Hello', options: {} }); + +// Get session ID +const sessionId = q.getSessionId(); + +// Check if closed +const closed = q.isClosed(); + +// Interrupt the current operation +await q.interrupt(); + +// Change permission mode mid-session +await q.setPermissionMode('yolo'); + +// Change model mid-session +await q.setModel('qwen-max'); + +// Close the session +await q.close(); +``` + +## 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. + +### Permission Priority Chain + +1. `excludeTools` - Blocks tools completely +2. `permissionMode: 'plan'` - Blocks non-read-only tools +3. `permissionMode: 'yolo'` - Auto-approves all tools +4. `allowedTools` - Auto-approves matching tools +5. `canUseTool` callback - Custom approval logic +6. Default behavior - Auto-deny in SDK mode + +## Examples + +### Multi-turn Conversation + +```typescript +import { query, type SDKUserMessage } from '@qwen-code/sdk-typescript'; + +async function* generateMessages(): AsyncIterable { + yield { + type: 'user', + session_id: 'my-session', + message: { role: 'user', content: 'Create a hello.txt file' }, + parent_tool_use_id: null, + }; + + // Wait for some condition or user input + yield { + type: 'user', + session_id: 'my-session', + message: { role: 'user', content: 'Now read the file back' }, + parent_tool_use_id: null, + }; +} + +const result = query({ + prompt: generateMessages(), + options: { + permissionMode: 'auto-edit', + }, +}); + +for await (const message of result) { + console.log(message); +} +``` + +### Custom Permission Handler + +```typescript +import { query, type CanUseTool } from '@qwen-code/sdk-typescript'; + +const canUseTool: CanUseTool = async (toolName, input, { signal }) => { + // Allow all read operations + if (toolName.startsWith('read_')) { + return { behavior: 'allow', updatedInput: input }; + } + + // Prompt user for write operations (in a real app) + const userApproved = await promptUser(`Allow ${toolName}?`); + + if (userApproved) { + return { behavior: 'allow', updatedInput: input }; + } + + return { behavior: 'deny', message: 'User denied the operation' }; +}; + +const result = query({ + prompt: 'Create a new file', + options: { + canUseTool, + }, +}); +``` + +### With MCP Servers + +```typescript +import { query } from '@qwen-code/sdk-typescript'; + +const result = query({ + prompt: 'Use the custom tool from my MCP server', + options: { + mcpServers: { + 'my-server': { + command: 'node', + args: ['path/to/mcp-server.js'], + env: { PORT: '3000' }, + }, + }, + }, +}); +``` + +### Abort a Query + +```typescript +import { query, isAbortError } from '@qwen-code/sdk-typescript'; + +const abortController = new AbortController(); + +const result = query({ + prompt: 'Long running task...', + options: { + abortController, + }, +}); + +// Abort after 5 seconds +setTimeout(() => abortController.abort(), 5000); + +try { + for await (const message of result) { + console.log(message); + } +} catch (error) { + if (isAbortError(error)) { + console.log('Query was aborted'); + } else { + throw error; + } +} +``` + +## Error Handling + +The SDK provides an `AbortError` class for handling aborted queries: + +```typescript +import { AbortError, isAbortError } from '@qwen-code/sdk-typescript'; + +try { + // ... query operations +} catch (error) { + if (isAbortError(error)) { + // Handle abort + } else { + // Handle other errors + } +} +``` + +## License + +Apache-2.0 - see [LICENSE](./LICENSE) for details. diff --git a/packages/sdk-typescript/package.json b/packages/sdk-typescript/package.json index d2787bf80b..0f2346034f 100644 --- a/packages/sdk-typescript/package.json +++ b/packages/sdk-typescript/package.json @@ -16,8 +16,7 @@ }, "files": [ "dist", - "README.md", - "LICENSE" + "README.md" ], "scripts": { "build": "node scripts/build.js", diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index db0632cfde..beda8b0e7b 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -81,15 +81,13 @@ await esbuild.build({ treeShaking: true, }); -const filesToCopy = ['README.md', 'LICENSE']; -for (const file of filesToCopy) { - const sourcePath = join(rootDir, '..', '..', file); - const targetPath = join(rootDir, 'dist', file); - if (existsSync(sourcePath)) { - try { - cpSync(sourcePath, targetPath); - } catch (error) { - console.warn(`Could not copy ${file}:`, error.message); - } +// Copy LICENSE from root directory to dist +const licenseSource = join(rootDir, '..', '..', 'LICENSE'); +const licenseTarget = join(rootDir, 'dist', 'LICENSE'); +if (existsSync(licenseSource)) { + try { + cpSync(licenseSource, licenseTarget); + } catch (error) { + console.warn('Could not copy LICENSE:', error.message); } } diff --git a/packages/sdk-typescript/scripts/get-release-version.js b/packages/sdk-typescript/scripts/get-release-version.js new file mode 100644 index 0000000000..349bfd07b5 --- /dev/null +++ b/packages/sdk-typescript/scripts/get-release-version.js @@ -0,0 +1,353 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const PACKAGE_NAME = '@qwen-code/sdk-typescript'; +const TAG_PREFIX = 'sdk-typescript-v'; + +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, 'utf-8')); +} + +function getArgs() { + const args = {}; + process.argv.slice(2).forEach((arg) => { + if (arg.startsWith('--')) { + const [key, value] = arg.substring(2).split('='); + args[key] = value === undefined ? true : value; + } + }); + return args; +} + +function getVersionFromNPM(distTag) { + const command = `npm view ${PACKAGE_NAME} version --tag=${distTag}`; + try { + return execSync(command).toString().trim(); + } catch (error) { + console.error( + `Failed to get NPM version for dist-tag "${distTag}": ${error.message}`, + ); + return ''; + } +} + +function getAllVersionsFromNPM() { + const command = `npm view ${PACKAGE_NAME} versions --json`; + try { + const versionsJson = execSync(command).toString().trim(); + const result = JSON.parse(versionsJson); + // npm returns a string if there's only one version, array otherwise + return Array.isArray(result) ? result : [result]; + } catch (error) { + console.error(`Failed to get all NPM versions: ${error.message}`); + return []; + } +} + +function isVersionDeprecated(version) { + const command = `npm view ${PACKAGE_NAME}@${version} deprecated`; + try { + const output = execSync(command).toString().trim(); + return output.length > 0; + } catch (error) { + console.error( + `Failed to check deprecation status for ${version}: ${error.message}`, + ); + return false; + } +} + +function semverCompare(a, b) { + const parseVersion = (v) => { + const [main, prerelease] = v.split('-'); + const [major, minor, patch] = main.split('.').map(Number); + return { major, minor, patch, prerelease: prerelease || '' }; + }; + + const va = parseVersion(a); + const vb = parseVersion(b); + + if (va.major !== vb.major) return va.major - vb.major; + if (va.minor !== vb.minor) return va.minor - vb.minor; + if (va.patch !== vb.patch) return va.patch - vb.patch; + + // Handle prerelease comparison + if (!va.prerelease && vb.prerelease) return 1; // stable > prerelease + if (va.prerelease && !vb.prerelease) return -1; // prerelease < stable + if (va.prerelease && vb.prerelease) { + return va.prerelease.localeCompare(vb.prerelease); + } + return 0; +} + +function detectRollbackAndGetBaseline(npmDistTag) { + const distTagVersion = getVersionFromNPM(npmDistTag); + if (!distTagVersion) return { baseline: '', isRollback: false }; + + const allVersions = getAllVersionsFromNPM(); + if (allVersions.length === 0) + return { baseline: distTagVersion, isRollback: false }; + + let matchingVersions; + if (npmDistTag === 'latest') { + matchingVersions = allVersions.filter((v) => !v.includes('-')); + } else if (npmDistTag === 'preview') { + matchingVersions = allVersions.filter((v) => v.includes('-preview')); + } else if (npmDistTag === 'nightly') { + matchingVersions = allVersions.filter((v) => v.includes('-nightly')); + } else { + return { baseline: distTagVersion, isRollback: false }; + } + + if (matchingVersions.length === 0) + return { baseline: distTagVersion, isRollback: false }; + + matchingVersions.sort((a, b) => -semverCompare(a, b)); + + let highestExistingVersion = ''; + for (const version of matchingVersions) { + if (!isVersionDeprecated(version)) { + highestExistingVersion = version; + break; + } else { + console.error(`Ignoring deprecated version: ${version}`); + } + } + + if (!highestExistingVersion) { + highestExistingVersion = distTagVersion; + } + + const isRollback = semverCompare(highestExistingVersion, distTagVersion) > 0; + + return { + baseline: isRollback ? highestExistingVersion : distTagVersion, + isRollback, + distTagVersion, + highestExistingVersion, + }; +} + +function doesVersionExist(version) { + // Check NPM + try { + const command = `npm view ${PACKAGE_NAME}@${version} version 2>/dev/null`; + const output = execSync(command).toString().trim(); + if (output === version) { + console.error(`Version ${version} already exists on NPM.`); + return true; + } + } catch (_error) { + // This is expected if the version doesn't exist. + } + + // Check Git tags + try { + const command = `git tag -l '${TAG_PREFIX}${version}'`; + const tagOutput = execSync(command).toString().trim(); + if (tagOutput === `${TAG_PREFIX}${version}`) { + console.error(`Git tag ${TAG_PREFIX}${version} already exists.`); + return true; + } + } catch (error) { + console.error(`Failed to check git tags for conflicts: ${error.message}`); + } + + // Check GitHub releases + try { + const command = `gh release view "${TAG_PREFIX}${version}" --json tagName --jq .tagName 2>/dev/null`; + const output = execSync(command).toString().trim(); + if (output === `${TAG_PREFIX}${version}`) { + console.error(`GitHub release ${TAG_PREFIX}${version} already exists.`); + return true; + } + } catch (error) { + const isExpectedNotFound = + error.message.includes('release not found') || + error.message.includes('Not Found') || + error.message.includes('not found') || + error.status === 1; + if (!isExpectedNotFound) { + console.error( + `Failed to check GitHub releases for conflicts: ${error.message}`, + ); + } + } + + return false; +} + +function getAndVerifyTags(npmDistTag) { + const rollbackInfo = detectRollbackAndGetBaseline(npmDistTag); + const baselineVersion = rollbackInfo.baseline; + + if (!baselineVersion) { + // First release for this dist-tag, use package.json version as baseline + const packageJson = readJson(join(__dirname, '..', 'package.json')); + return { + latestVersion: packageJson.version.split('-')[0], + latestTag: `v${packageJson.version.split('-')[0]}`, + }; + } + + if (rollbackInfo.isRollback) { + console.error( + `Rollback detected! NPM ${npmDistTag} tag is ${rollbackInfo.distTagVersion}, but using ${baselineVersion} as baseline for next version calculation.`, + ); + } + + return { + latestVersion: baselineVersion, + latestTag: `v${baselineVersion}`, + }; +} + +function getLatestStableReleaseTag() { + try { + const { latestTag } = getAndVerifyTags('latest'); + return latestTag; + } catch (error) { + console.error( + `Failed to determine latest stable release tag: ${error.message}`, + ); + return ''; + } +} + +function getNightlyVersion() { + const packageJson = readJson(join(__dirname, '..', 'package.json')); + const baseVersion = packageJson.version.split('-')[0]; + const date = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + const gitShortHash = execSync('git rev-parse --short HEAD').toString().trim(); + const releaseVersion = `${baseVersion}-nightly.${date}.${gitShortHash}`; + return { + releaseVersion, + npmTag: 'nightly', + }; +} + +function validateVersion(version, format, name) { + const versionRegex = { + 'X.Y.Z': /^\d+\.\d+\.\d+$/, + 'X.Y.Z-preview.N': /^\d+\.\d+\.\d+-preview\.\d+$/, + }; + + if (!versionRegex[format] || !versionRegex[format].test(version)) { + throw new Error( + `Invalid ${name}: ${version}. Must be in ${format} format.`, + ); + } +} + +function getStableVersion(args) { + let releaseVersion; + if (args.stable_version_override) { + const overrideVersion = args.stable_version_override.replace(/^v/, ''); + validateVersion(overrideVersion, 'X.Y.Z', 'stable_version_override'); + releaseVersion = overrideVersion; + } else { + // Try to get from preview, fallback to package.json for first release + const { latestVersion: latestPreviewVersion } = getAndVerifyTags('preview'); + releaseVersion = latestPreviewVersion.replace(/-preview.*/, ''); + } + + return { + releaseVersion, + npmTag: 'latest', + }; +} + +function getPreviewVersion(args) { + let releaseVersion; + if (args.preview_version_override) { + const overrideVersion = args.preview_version_override.replace(/^v/, ''); + validateVersion( + overrideVersion, + 'X.Y.Z-preview.N', + 'preview_version_override', + ); + releaseVersion = overrideVersion; + } else { + // Try to get from nightly, fallback to package.json for first release + const { latestVersion: latestNightlyVersion } = getAndVerifyTags('nightly'); + releaseVersion = + latestNightlyVersion.replace(/-nightly.*/, '') + '-preview.0'; + } + + return { + releaseVersion, + npmTag: 'preview', + }; +} + +export function getVersion(options = {}) { + const args = { ...getArgs(), ...options }; + const type = args.type || 'nightly'; + + let versionData; + switch (type) { + case 'nightly': + versionData = getNightlyVersion(); + if (doesVersionExist(versionData.releaseVersion)) { + throw new Error( + `Version conflict! Nightly version ${versionData.releaseVersion} already exists.`, + ); + } + break; + case 'stable': + versionData = getStableVersion(args); + break; + case 'preview': + versionData = getPreviewVersion(args); + break; + default: + throw new Error(`Unknown release type: ${type}`); + } + + // For stable and preview versions, check for existence and increment if needed. + if (type === 'stable' || type === 'preview') { + let releaseVersion = versionData.releaseVersion; + while (doesVersionExist(releaseVersion)) { + console.error(`Version ${releaseVersion} exists, incrementing.`); + if (releaseVersion.includes('-preview.')) { + const [version, prereleasePart] = releaseVersion.split('-'); + const previewNumber = parseInt(prereleasePart.split('.')[1]); + releaseVersion = `${version}-preview.${previewNumber + 1}`; + } else { + const versionParts = releaseVersion.split('.'); + const major = versionParts[0]; + const minor = versionParts[1]; + const patch = parseInt(versionParts[2]); + releaseVersion = `${major}.${minor}.${patch + 1}`; + } + } + versionData.releaseVersion = releaseVersion; + } + + const result = { + releaseTag: `v${versionData.releaseVersion}`, + ...versionData, + }; + + result.previousReleaseTag = getLatestStableReleaseTag(); + + return result; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const version = JSON.stringify(getVersion(getArgs()), null, 2); + console.log(version); +} From b1d848f935f348826e4fdf4b4c8219bd8154538f Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Mon, 1 Dec 2025 18:04:32 +0800 Subject: [PATCH 16/22] chore: update lockfile --- package-lock.json | 2819 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 2819 insertions(+) diff --git a/package-lock.json b/package-lock.json index b20422258d..53fe9d4656 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1285,6 +1285,22 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1299,6 +1315,14 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@humanwhocodes/retry": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", @@ -2769,6 +2793,10 @@ "resolved": "packages/test-utils", "link": true }, + "node_modules/@qwen-code/sdk-typescript": { + "resolved": "packages/sdk-typescript", + "link": true + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.44.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.0.tgz", @@ -4158,6 +4186,13 @@ "node": ">=20.0.0" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, "node_modules/@vitest/coverage-v8": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", @@ -4726,6 +4761,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", @@ -5086,6 +5134,16 @@ "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", "license": "MIT" }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/array.prototype.findlast": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", @@ -6190,6 +6248,13 @@ "dev": true, "license": "MIT" }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, "node_modules/config-chain": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", @@ -6747,6 +6812,39 @@ "node": ">=0.3.1" } }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dir-glob/node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -6860,6 +6958,23 @@ "url": "https://dotenvx.com" } }, + "node_modules/dts-bundle-generator": { + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/dts-bundle-generator/-/dts-bundle-generator-9.5.1.tgz", + "integrity": "sha512-DxpJOb2FNnEyOzMkG11sxO2dmxPjthoVWxfKqWYJ/bI/rT1rvTMktF5EKjAYrRZu6Z6t3NhOUZ0sZ5ZXevOfbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "typescript": ">=5.0.2", + "yargs": "^17.6.0" + }, + "bin": { + "dts-bundle-generator": "dist/bin/dts-bundle-generator.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -7706,6 +7821,72 @@ "node": ">=20.0.0" } }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -8191,6 +8372,13 @@ "node": ">= 10.0.0" } }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -8303,6 +8491,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -8340,6 +8538,19 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -8904,6 +9115,16 @@ "node": ">= 14" } }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -9036,6 +9257,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -10589,6 +10822,23 @@ "node": ">=4" } }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -10944,6 +11194,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -11072,6 +11329,19 @@ "license": "MIT", "optional": true }, + "node_modules/mlly": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" + } + }, "node_modules/mnemonist": { "version": "0.40.3", "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz", @@ -11642,6 +11912,35 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -12132,6 +12431,16 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -12266,6 +12575,18 @@ "node": ">=16.20.0" } }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -13095,6 +13416,45 @@ "dev": true, "license": "MIT" }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/rollup": { "version": "4.44.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.0.tgz", @@ -14090,6 +14450,19 @@ "node": ">=4" } }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -14755,6 +15128,16 @@ "node": ">= 0.8.0" } }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", @@ -14926,6 +15309,13 @@ "dev": true, "license": "MIT" }, + "node_modules/ufo": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "dev": true, + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -16285,6 +16675,2435 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "packages/sdk-typescript": { + "name": "@qwen-code/sdk-typescript", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.4" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "@typescript-eslint/eslint-plugin": "^7.13.0", + "@typescript-eslint/parser": "^7.13.0", + "@vitest/coverage-v8": "^1.6.0", + "dts-bundle-generator": "^9.5.1", + "esbuild": "^0.25.12", + "eslint": "^8.57.0", + "typescript": "^5.4.5", + "vitest": "^1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "packages/sdk-typescript/node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "packages/sdk-typescript/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "packages/sdk-typescript/node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "packages/sdk-typescript/node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "packages/sdk-typescript/node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true, + "license": "MIT" + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/eslint-plugin": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", + "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/type-utils": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/parser": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", + "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/scope-manager": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", + "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/type-utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", + "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/types": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", + "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/typescript-estree": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", + "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", + "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + } + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/visitor-keys": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", + "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "packages/sdk-typescript/node_modules/@vitest/coverage-v8": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.1.tgz", + "integrity": "sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.1", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.4", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.4", + "istanbul-reports": "^3.1.6", + "magic-string": "^0.30.5", + "magicast": "^0.3.3", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "test-exclude": "^6.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "1.6.1" + } + }, + "packages/sdk-typescript/node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/sdk-typescript/node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/sdk-typescript/node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/sdk-typescript/node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/sdk-typescript/node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/sdk-typescript/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "packages/sdk-typescript/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "packages/sdk-typescript/node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "packages/sdk-typescript/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "packages/sdk-typescript/node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "packages/sdk-typescript/node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "packages/sdk-typescript/node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "packages/sdk-typescript/node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "packages/sdk-typescript/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "packages/sdk-typescript/node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "packages/sdk-typescript/node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "packages/sdk-typescript/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "packages/sdk-typescript/node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "packages/sdk-typescript/node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "packages/sdk-typescript/node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "packages/sdk-typescript/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/sdk-typescript/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/sdk-typescript/node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/sdk-typescript/node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "packages/sdk-typescript/node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "packages/sdk-typescript/node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/sdk-typescript/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "packages/sdk-typescript/node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "packages/sdk-typescript/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "packages/sdk-typescript/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "packages/sdk-typescript/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "packages/sdk-typescript/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "packages/sdk-typescript/node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "packages/sdk-typescript/node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "packages/sdk-typescript/node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "packages/sdk-typescript/node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "packages/sdk-typescript/node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "packages/sdk-typescript/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/sdk-typescript/node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "packages/sdk-typescript/node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "packages/sdk-typescript/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/test-utils": { "name": "@qwen-code/qwen-code-test-utils", "version": "0.4.0", From 56f61bc0b86bcc61fd98b33044872efb564ee1d2 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Mon, 1 Dec 2025 22:50:46 +0800 Subject: [PATCH 17/22] fix: path literals in windows --- packages/sdk-typescript/test/unit/cliPath.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/sdk-typescript/test/unit/cliPath.test.ts b/packages/sdk-typescript/test/unit/cliPath.test.ts index 0e40e23a79..c097f44dc7 100644 --- a/packages/sdk-typescript/test/unit/cliPath.test.ts +++ b/packages/sdk-typescript/test/unit/cliPath.test.ts @@ -59,7 +59,7 @@ describe('CLI Path Utilities', () => { const result = parseExecutableSpec(); expect(result).toEqual({ - executablePath: '/usr/local/bin/qwen', + executablePath: path.resolve('/usr/local/bin/qwen'), isExplicitRuntime: false, }); @@ -167,7 +167,7 @@ describe('CLI Path Utilities', () => { const result = parseExecutableSpec('/absolute/path/to/qwen'); expect(result).toEqual({ - executablePath: '/absolute/path/to/qwen', + executablePath: path.resolve('/absolute/path/to/qwen'), isExplicitRuntime: false, }); }); @@ -214,7 +214,7 @@ describe('CLI Path Utilities', () => { const result = prepareSpawnInfo('/usr/local/bin/qwen'); expect(result).toEqual({ - command: '/usr/local/bin/qwen', + command: path.resolve('/usr/local/bin/qwen'), args: [], type: 'native', originalInput: '/usr/local/bin/qwen', @@ -304,8 +304,9 @@ describe('CLI Path Utilities', () => { throw new Error('Command not found'); }); + const resolvedPath = path.resolve('/path/to/index.ts'); expect(() => prepareSpawnInfo('/path/to/index.ts')).toThrow( - "TypeScript file '/path/to/index.ts' requires 'tsx' runtime, but it's not available", + `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', @@ -368,7 +369,7 @@ describe('CLI Path Utilities', () => { const result = prepareSpawnInfo(); expect(result).toEqual({ - command: '/usr/local/bin/qwen', + command: path.resolve('/usr/local/bin/qwen'), args: [], type: 'native', originalInput: '', @@ -388,7 +389,7 @@ describe('CLI Path Utilities', () => { const result = findNativeCliPath(); - expect(result).toBe('/custom/path/to/qwen'); + expect(result).toBe(path.resolve('/custom/path/to/qwen')); process.env['QWEN_CODE_CLI_PATH'] = originalEnv; }); From 839a1d9d8c026be2c643b32c18476c411ea66898 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Mon, 1 Dec 2025 23:10:05 +0800 Subject: [PATCH 18/22] fix: mock path for cross platform compability in test cases --- packages/sdk-typescript/test/unit/cliPath.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/sdk-typescript/test/unit/cliPath.test.ts b/packages/sdk-typescript/test/unit/cliPath.test.ts index c097f44dc7..43f50dec24 100644 --- a/packages/sdk-typescript/test/unit/cliPath.test.ts +++ b/packages/sdk-typescript/test/unit/cliPath.test.ts @@ -399,13 +399,15 @@ describe('CLI Path Utilities', () => { delete process.env['QWEN_CODE_CLI_PATH']; // Mock fs.existsSync to return true for volta bin - mockFs.existsSync.mockImplementation((path) => { - return path.toString().includes('.volta/bin/qwen'); + // Use path.join to match platform-specific path separators + const voltaBinPath = path.join('.volta', 'bin', 'qwen'); + mockFs.existsSync.mockImplementation((p) => { + return p.toString().includes(voltaBinPath); }); const result = findNativeCliPath(); - expect(result).toContain('.volta/bin/qwen'); + expect(result).toContain(voltaBinPath); process.env['QWEN_CODE_CLI_PATH'] = originalEnv; }); From 51b9281774f3cee884d934486f99f3f6a4fd9d28 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Wed, 3 Dec 2025 09:58:20 +0800 Subject: [PATCH 19/22] chore: remove scheduled triggers from SDK release workflow --- .github/workflows/release-sdk.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml index 6249f08e05..cd80ca4406 100644 --- a/.github/workflows/release-sdk.yml +++ b/.github/workflows/release-sdk.yml @@ -1,11 +1,6 @@ name: 'Release SDK' on: - schedule: - # Runs every day at 1:00 AM UTC for the nightly release (offset from CLI at 0:00). - - cron: '0 1 * * *' - # Runs every Wednesday at 00:59 UTC for the preview release (offset from CLI on Tuesday). - - cron: '59 0 * * 3' workflow_dispatch: inputs: version: From c18fed574f15e19715ac4facdc00804d377d9fb3 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Thu, 4 Dec 2025 17:22:20 +0800 Subject: [PATCH 20/22] chore: fix RELEASE_TAG fallback in workflow --- .github/workflows/release-sdk.yml | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml index cd80ca4406..ef1775a6f6 100644 --- a/.github/workflows/release-sdk.yml +++ b/.github/workflows/release-sdk.yml @@ -39,8 +39,7 @@ jobs: environment: name: 'production-release' url: '${{ github.server_url }}/${{ github.repository }}/releases/tag/sdk-typescript-${{ steps.version.outputs.RELEASE_TAG }}' - if: |- - ${{ github.repository == 'QwenLM/qwen-code' }} + if: github.repository == 'QwenLM/qwen-code' permissions: contents: 'write' packages: 'write' @@ -60,19 +59,17 @@ jobs: env: CREATE_NIGHTLY_RELEASE: '${{ github.event.inputs.create_nightly_release }}' CREATE_PREVIEW_RELEASE: '${{ github.event.inputs.create_preview_release }}' - EVENT_NAME: '${{ github.event_name }}' - CRON: '${{ github.event.schedule }}' DRY_RUN_INPUT: '${{ github.event.inputs.dry_run }}' id: 'vars' run: |- is_nightly="false" - if [[ "${CRON}" == "0 1 * * *" || "${CREATE_NIGHTLY_RELEASE}" == "true" ]]; then + if [[ "${CREATE_NIGHTLY_RELEASE}" == "true" ]]; then is_nightly="true" fi echo "is_nightly=${is_nightly}" >> "${GITHUB_OUTPUT}" is_preview="false" - if [[ "${CRON}" == "59 0 * * 3" || "${CREATE_PREVIEW_RELEASE}" == "true" ]]; then + if [[ "${CREATE_PREVIEW_RELEASE}" == "true" ]]; then is_preview="true" fi echo "is_preview=${is_preview}" >> "${GITHUB_OUTPUT}" @@ -124,8 +121,7 @@ jobs: MANUAL_VERSION: '${{ inputs.version }}' - name: 'Run Tests' - if: |- - ${{ github.event.inputs.force_skip_tests != 'true' }} + if: github.event.inputs.force_skip_tests != 'true' working-directory: 'packages/sdk-typescript' run: | npm run test:ci @@ -182,7 +178,7 @@ jobs: - name: 'Configure npm for publishing' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 with: - node-version: '20' + node-version-file: '.nvmrc' registry-url: 'https://registry.npmjs.org' scope: '@qwen-code' @@ -194,8 +190,7 @@ jobs: NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}' - name: 'Create GitHub Release and Tag' - if: |- - ${{ steps.vars.outputs.is_dry_run == 'false' }} + if: steps.vars.outputs.is_dry_run == 'false' env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}' @@ -209,11 +204,10 @@ jobs: --generate-notes - name: 'Create Issue on Failure' - if: |- - ${{ failure() }} + if: failure() env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' - RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }} || "N/A"' + RELEASE_TAG: "${{ steps.version.outputs.RELEASE_TAG || 'N/A' }}" DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' run: |- gh issue create \ From 0630908e0c9e1565cc80c7e17ebec518a3489279 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Thu, 4 Dec 2025 17:36:22 +0800 Subject: [PATCH 21/22] fix: lint error --- packages/cli/src/gemini.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 81d34fe1ab..f602d17d95 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -272,7 +272,7 @@ describe('gemini.tsx main function', () => { ); vi.mocked(cleanupModule.cleanupCheckpoints).mockResolvedValue(undefined); - vi.mocked(cleanupModule.registerCleanup).mockImplementation(() => { }); + vi.mocked(cleanupModule.registerCleanup).mockImplementation(() => {}); const runExitCleanupMock = vi.mocked(cleanupModule.runExitCleanup); runExitCleanupMock.mockResolvedValue(undefined); vi.spyOn(extensionModule, 'loadExtensions').mockReturnValue([]); @@ -498,7 +498,7 @@ describe('validateDnsResolutionOrder', () => { let consoleWarnSpy: ReturnType; beforeEach(() => { - consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); }); afterEach(() => { From 427c69ba0747a8b1cc02e502a38ed9ebd8edefd4 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Thu, 4 Dec 2025 17:52:07 +0800 Subject: [PATCH 22/22] chore: fix sdk release workflow and verified with yamllint and act --- .github/workflows/release-sdk.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml index ef1775a6f6..d0b558f7dc 100644 --- a/.github/workflows/release-sdk.yml +++ b/.github/workflows/release-sdk.yml @@ -39,7 +39,8 @@ jobs: environment: name: 'production-release' url: '${{ github.server_url }}/${{ github.repository }}/releases/tag/sdk-typescript-${{ steps.version.outputs.RELEASE_TAG }}' - if: github.repository == 'QwenLM/qwen-code' + if: |- + ${{ github.repository == 'QwenLM/qwen-code' }} permissions: contents: 'write' packages: 'write' @@ -121,7 +122,8 @@ jobs: MANUAL_VERSION: '${{ inputs.version }}' - name: 'Run Tests' - if: github.event.inputs.force_skip_tests != 'true' + if: |- + ${{ github.event.inputs.force_skip_tests != 'true' }} working-directory: 'packages/sdk-typescript' run: | npm run test:ci @@ -190,7 +192,8 @@ jobs: NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}' - name: 'Create GitHub Release and Tag' - if: steps.vars.outputs.is_dry_run == 'false' + if: |- + ${{ steps.vars.outputs.is_dry_run == 'false' }} env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}' @@ -204,7 +207,8 @@ jobs: --generate-notes - name: 'Create Issue on Failure' - if: failure() + if: |- + ${{ failure() }} env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' RELEASE_TAG: "${{ steps.version.outputs.RELEASE_TAG || 'N/A' }}"