diff --git a/.vscode/launch.json b/.vscode/launch.json index d98757fb..0ae4f1b1 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 7b4f502f..8a35ef6f 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 3162638f..a35ef293 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 a9bc3d9e..81d34fe1 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 310ef6b7..8210d5d5 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 fa1b0e0f..b2165ee9 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 7193fb63..671a1853 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 d2e20545..90b7f56a 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 f93b4489..08c6d41f 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 c3fc651b..a33ea161 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 c83637b7..9137d95a 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 3968c5cc..551ea9ff 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 61643fb3..15f15954 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 614208b7..7cfa92c0 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 }; + private async ensureConfigInitialized(): Promise { + if (this.configInitialized) { + return; } - // 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 }; - } - - // Unknown message type if (this.debugMode) { - console.error( - '[SessionManager] Unknown message type:', - JSON.stringify(message, null, 2), - ); - } - 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; + console.error('[Session] Initializing config'); } - // 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'); } - } - } - - /** - * Handle messages in processing state - */ - private async handleProcessingState(routed: RoutedMessage): Promise { - 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, - ); - } - } - } - - /** - * 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); - } - } - - // 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; 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'); - } - } + await dispatcher.dispatch(request); + } + + private handleControlResponse(response: CLIControlResponse): void { + const dispatcher = this.getDispatcher(); + if (!dispatcher) { + return; + } + + dispatcher.handleControlResponse(response); + } + + private handleControlCancel(cancelRequest: ControlCancelRequest): void { + const dispatcher = this.getDispatcher(); + if (!dispatcher) { + return; + } + + 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); } } } - /** - * Send tool results as user message - */ + 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); + } + } + } + + 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 784ea916..2eec24c1 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 8e5a9c90..77e4f980 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 613ac87e..a186374d 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 59baba85..29757ff6 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 493758dc..93f3b6e1 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 eb318f54..3c93112d 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 8dcab0de..d83e3e7a 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 67b78a50..0f83e3f1 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 afffa103..15f461e9 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 17e40dbe..8ff3047e 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 848b14c6..7b3c893e 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 00000000..067d1d22 --- /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 00000000..c732c6ff --- /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 00000000..c160a9af --- /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 00000000..841440e1 --- /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 00000000..a71e12ff --- /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 00000000..667bf5e5 --- /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 00000000..55d767c5 --- /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 00000000..4b87478e --- /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 00000000..1c717f8c --- /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 00000000..cbfb1b7a --- /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 00000000..21f503a6 --- /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 00000000..399221e0 --- /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 00000000..d3a548af --- /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 00000000..d2b9a400 --- /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 00000000..8a58c0be --- /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 00000000..b6101ab3 --- /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 00000000..e534bf70 --- /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 00000000..a97d3db6 --- /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 00000000..ea7ecef7 --- /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 00000000..44e3e528 --- /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 00000000..6bb0f965 --- /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 00000000..8e79898e --- /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 00000000..afcef8b1 --- /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 00000000..047be4f2 --- /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 00000000..5e1a9d15 --- /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 00000000..5ceeee4b --- /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 00000000..6bfd61a0 --- /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 00000000..2113a202 --- /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 00000000..55a87b92 --- /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 00000000..e608ba7b --- /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 00000000..11fba047 --- /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 00000000..33018d83 --- /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 20ec6b90..88cded8b 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', ],