diff --git a/packages/cli/package.json b/packages/cli/package.json index cdf19e03..f7773554 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -16,10 +16,6 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" - }, - "./protocol": { - "types": "./dist/src/types/protocol.d.ts", - "import": "./dist/src/types/protocol.js" } }, "scripts": { diff --git a/packages/sdk/typescript/package.json b/packages/sdk/typescript/package.json new file mode 100644 index 00000000..b0b7885f --- /dev/null +++ b/packages/sdk/typescript/package.json @@ -0,0 +1,69 @@ +{ + "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", + "@qwen-code/qwen-code": "file:../../cli" + }, + "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..a5b3b253 --- /dev/null +++ b/packages/sdk/typescript/src/index.ts @@ -0,0 +1,108 @@ +/** + * TypeScript SDK for programmatic access to qwen-code CLI + * + * @example + * ```typescript + * import { query } from '@qwen-code/sdk-typescript'; + * + * const q = query({ + * prompt: 'What files are in this directory?', + * options: { cwd: process.cwd() }, + * }); + * + * for await (const message of q) { + * if (message.type === 'assistant') { + * console.log(message.message.content); + * } + * } + * + * await q.close(); + * ``` + */ + +// Main API +export { query } from './query/createQuery.js'; + +/** @deprecated Use query() instead */ +export { createQuery } from './query/createQuery.js'; + +export { Query } from './query/Query.js'; + +// Configuration types +export type { + CreateQueryOptions, + PermissionMode, + PermissionCallback, + ExternalMcpServerConfig, + TransportOptions, +} from './types/config.js'; + +export type { QueryOptions } from './query/createQuery.js'; + +// Protocol types +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 type { JSONSchema } from './types/mcp.js'; + +export { AbortError, isAbortError } from './types/errors.js'; + +// Control Request Types +export { + ControlRequestType, + getAllControlRequestTypes, + isValidControlRequestType, +} from './types/controlRequests.js'; + +// Transport +export { ProcessTransport } from './transport/ProcessTransport.js'; +export type { Transport } from './transport/Transport.js'; + +// Utilities +export { Stream } from './utils/Stream.js'; +export { + serializeJsonLine, + parseJsonLine, + parseJsonLineSafe, + isValidMessage, + parseJsonLinesStream, +} from './utils/jsonLines.js'; +export { + findCliPath, + resolveCliPath, + prepareSpawnInfo, +} from './utils/cliPath.js'; +export type { SpawnInfo } from './utils/cliPath.js'; + +// MCP helpers +export { + createSdkMcpServer, + createSimpleMcpServer, +} from './mcp/createSdkMcpServer.js'; +export { + tool, + createTool, + validateToolName, + validateInputSchema, +} from './mcp/tool.js'; + +export type { ToolDefinition } from './types/config.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..d7540c17 --- /dev/null +++ b/packages/sdk/typescript/src/mcp/SdkControlServerTransport.ts @@ -0,0 +1,153 @@ +/** + * 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'; + +/** + * Callback type for sending messages to Query + */ +export type SendToQueryCallback = (message: JSONRPCMessage) => Promise; + +/** + * SdkControlServerTransport options + */ +export interface SdkControlServerTransportOptions { + sendToQuery: SendToQueryCallback; + serverName: string; +} + +/** + * Transport adapter that bridges MCP Server with Query's control plane + */ +export class SdkControlServerTransport { + public sendToQuery: SendToQueryCallback; + private serverName: string; + private started = false; + + /** + * Callbacks set by MCP Server + */ + onmessage?: (message: JSONRPCMessage) => void; + onerror?: (error: Error) => void; + onclose?: () => void; + + constructor(options: SdkControlServerTransportOptions) { + this.sendToQuery = options.sendToQuery; + this.serverName = options.serverName; + } + + /** + * Start the transport + */ + async start(): Promise { + this.started = true; + } + + /** + * Send message from MCP Server to CLI via Query's control plane + * + * @param message - JSON-RPC message from MCP Server + */ + 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; + } + } + + /** + * Close the transport + */ + async close(): Promise { + if (!this.started) { + return; // Already closed + } + + this.started = false; + + // Notify MCP Server + if (this.onclose) { + this.onclose(); + } + } + + /** + * Handle incoming message from CLI + * + * @param message - JSON-RPC message from CLI + */ + 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}`, + ); + } + } + + /** + * Handle incoming error from CLI + * + * @param error - Error from CLI + */ + handleError(error: Error): void { + if (this.onerror) { + this.onerror(error); + } else { + console.error( + `[SdkControlServerTransport] Error for ${this.serverName}:`, + error, + ); + } + } + + /** + * Check if transport is started + */ + isStarted(): boolean { + return this.started; + } + + /** + * Get server name + */ + getServerName(): string { + return this.serverName; + } +} + +/** + * Create SdkControlServerTransport instance + */ +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..df1bd256 --- /dev/null +++ b/packages/sdk/typescript/src/mcp/createSdkMcpServer.ts @@ -0,0 +1,177 @@ +/** + * 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, + CallToolResult, +} from '@modelcontextprotocol/sdk/types.js'; +import type { ToolDefinition } from '../types/config.js'; +import { formatToolResult, formatToolError } from './formatters.js'; +import { validateToolName } from './tool.js'; + +/** + * Create an SDK-embedded MCP server with custom tools + * + * The server runs in your Node.js process and is proxied to the CLI. + * + * @param name - Server name (must be unique) + * @param version - Server version + * @param tools - Array of tool definitions + * @returns MCP Server instance + * + * @example + * ```typescript + * const server = createSdkMcpServer('database', '1.0.0', [ + * tool({ + * name: 'query_db', + * description: 'Query the database', + * inputSchema: { + * type: 'object', + * properties: { query: { type: 'string' } }, + * required: ['query'] + * }, + * handler: async (input) => db.query(input.query) + * }) + * ]); + * ``` + */ +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 () => { + return { + 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; +} + +/** + * Create MCP server with inline tool definitions + * + * @param name - Server name + * @param version - Server version + * @param toolDefinitions - Object mapping tool names to definitions + * @returns MCP Server instance + * + * @example + * ```typescript + * const server = createSimpleMcpServer('utils', '1.0.0', { + * greeting: { + * description: 'Generate a greeting', + * inputSchema: { + * type: 'object', + * properties: { name: { type: 'string' } }, + * required: ['name'] + * }, + * handler: async ({ name }) => `Hello, ${name}!` + * } + * }); + * ``` + */ +export function createSimpleMcpServer( + name: string, + version: string, + toolDefinitions: Record< + string, + Omit & { name?: string } + >, +): Server { + const tools: ToolDefinition[] = Object.entries(toolDefinitions).map( + ([toolName, def]) => ({ + name: def.name || toolName, + description: def.description, + inputSchema: def.inputSchema, + handler: def.handler, + }), + ); + + return createSdkMcpServer(name, version, tools); +} diff --git a/packages/sdk/typescript/src/mcp/formatters.ts b/packages/sdk/typescript/src/mcp/formatters.ts new file mode 100644 index 00000000..4406db51 --- /dev/null +++ b/packages/sdk/typescript/src/mcp/formatters.ts @@ -0,0 +1,247 @@ +/** + * Tool result formatting utilities for MCP responses + * + * Converts various output types to MCP content blocks. + */ + +/** + * MCP content block types + */ +export type McpContentBlock = + | { type: 'text'; text: string } + | { type: 'image'; data: string; mimeType: string } + | { type: 'resource'; uri: string; mimeType?: string; text?: string }; + +/** + * Tool result structure + */ +export interface ToolResult { + content: McpContentBlock[]; + isError?: boolean; +} + +/** + * Format tool result for MCP response + * + * Converts any value to MCP content blocks (strings, objects, errors, etc.) + * + * @param result - Tool handler output or error + * @returns Formatted tool result + * + * @example + * ```typescript + * formatToolResult('Hello') + * // → { content: [{ type: 'text', text: 'Hello' }] } + * + * formatToolResult({ temperature: 72 }) + * // → { content: [{ type: 'text', text: '{"temperature":72}' }] } + * ``` + */ +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), + }, + ], + }; +} + +/** + * Format error for MCP response + * + * @param error - Error object or string + * @returns Tool result with error flag + */ +export function formatToolError(error: Error | string): ToolResult { + const message = error instanceof Error ? error.message : error; + + return { + content: [ + { + type: 'text', + text: message, + }, + ], + isError: true, + }; +} + +/** + * Format text content for MCP response + * + * @param text - Text content + * @returns Tool result with text content + */ +export function formatTextResult(text: string): ToolResult { + return { + content: [ + { + type: 'text', + text, + }, + ], + }; +} + +/** + * Format JSON content for MCP response + * + * @param data - Data to serialize as JSON + * @returns Tool result with JSON text content + */ +export function formatJsonResult(data: unknown): ToolResult { + return { + content: [ + { + type: 'text', + text: JSON.stringify(data, null, 2), + }, + ], + }; +} + +/** + * Merge multiple tool results into a single result + * + * @param results - Array of tool results + * @returns Merged tool result + */ +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, + }; +} + +/** + * Validate MCP content block + * + * @param block - Content block to validate + * @returns True if valid + */ +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..8e7eb7c2 --- /dev/null +++ b/packages/sdk/typescript/src/mcp/tool.ts @@ -0,0 +1,140 @@ +/** + * Tool definition helper for SDK-embedded MCP servers + * + * Provides type-safe tool definitions with generic input/output types. + */ + +import type { ToolDefinition } from '../types/config.js'; + +/** + * Create a type-safe tool definition + * + * Validates the tool definition and provides type inference for input/output types. + * + * @param def - Tool definition with handler + * @returns The same tool definition (for type safety) + * + * @example + * ```typescript + * const weatherTool = tool<{ location: string }, { temperature: number }>({ + * name: 'get_weather', + * description: 'Get weather for a location', + * inputSchema: { + * type: 'object', + * properties: { + * location: { type: 'string' } + * }, + * required: ['location'] + * }, + * handler: async (input) => { + * return { temperature: await fetchWeather(input.location) }; + * } + * }); + * ``` + */ +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; +} + +/** + * Validate tool name + * + * Tool names must: + * - Start with a letter + * - Contain only letters, numbers, and underscores + * - Be between 1 and 64 characters + * + * @param name - Tool name to validate + * @throws Error if name is invalid + */ +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.`, + ); + } +} + +/** + * Validate tool input schema (JSON Schema compliance) + * + * @param schema - Input schema to validate + * @throws Error if schema is invalid + */ +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'); + } + } +} + +/** + * Create tool definition with strict validation + * + * @param def - Tool definition + * @returns Validated tool definition + */ +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..c33d44e2 --- /dev/null +++ b/packages/sdk/typescript/src/query/Query.ts @@ -0,0 +1,874 @@ +/** + * Query class - Main orchestrator for SDK + * + * Manages SDK workflow, routes messages, and handles lifecycle. + * Implements AsyncIterator protocol for message consumption. + */ + +import { randomUUID } from 'node:crypto'; +import type { + CLIMessage, + CLIUserMessage, + CLIControlRequest, + CLIControlResponse, + ControlCancelRequest, + PermissionApproval, + 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 { CreateQueryOptions } from '../types/config.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/controlRequests.js'; + +interface PendingControlRequest { + resolve: (response: Record | null) => void; + reject: (error: Error) => void; + timeout: NodeJS.Timeout; + abortController: AbortController; +} + +interface HookRegistration { + matcher: Record; + hookCallbackIds: string[]; +} + +interface TransportWithEndInput extends Transport { + endInput(): void; +} + +/** + * Query class + * + * Main entry point for SDK users. Orchestrates communication with CLI, + * routes messages, handles control plane, and manages lifecycle. + */ +export class Query implements AsyncIterable { + private transport: Transport; + private options: CreateQueryOptions; + private sessionId: string; + private inputStream: Stream; + private sdkMessages: AsyncGenerator; + private abortController: AbortController; + private pendingControlRequests: Map = + new Map(); + private sdkMcpTransports: Map = new Map(); + private initialized: Promise; + private closed = false; + private messageRouterStarted = false; + + private firstResultReceivedPromise?: Promise; + private firstResultReceivedResolve?: () => void; + + private hookCallbacks = new Map< + string, + ( + input: unknown, + toolUseId: string | null, + options: { signal: AbortSignal }, + ) => Promise + >(); + private nextCallbackId = 0; + + private readonly isSingleTurn: boolean; + + constructor(transport: Transport, options: CreateQueryOptions) { + this.transport = transport; + this.options = options; + this.sessionId = randomUUID(); + this.inputStream = new Stream(); + this.abortController = options.abortController ?? new AbortController(); + this.isSingleTurn = options.singleTurn ?? false; + + /** + * 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(); + } + + /** + * Initialize the query + */ + private async initialize(): Promise { + try { + await this.setupSdkMcpServers(); + + let hooks: Record | undefined; + if (this.options.hooks) { + hooks = {}; + for (const [event, matchers] of Object.entries(this.options.hooks)) { + if (matchers.length > 0) { + hooks[event] = matchers.map((matcher) => { + const callbackIds: string[] = []; + for (const callback of matcher.hooks) { + const callbackId = `hook_${this.nextCallbackId++}`; + this.hookCallbacks.set(callbackId, callback); + callbackIds.push(callbackId); + } + return { + matcher: matcher.matcher, + hookCallbackIds: callbackIds, + }; + }); + } + } + } + + const sdkMcpServerNames = Array.from(this.sdkMcpTransports.keys()); + await this.sendControlRequest(ControlRequestType.INITIALIZE, { + hooks: hooks ? Object.values(hooks).flat() : null, + sdkMcpServers: + sdkMcpServerNames.length > 0 ? sdkMcpServerNames : undefined, + }); + } catch (error) { + console.error('[Query] Initialization error:', error); + throw error; + } + } + + /** + * Setup SDK-embedded MCP servers + */ + 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); + } + } + + /** + * Start message router (background task) + */ + 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)), + ); + } + })(); + } + + /** + * Route incoming message + */ + 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_SDK']) { + console.warn('[Query] Unknown message type:', message); + } + this.inputStream.enqueue(message as CLIMessage); + } + + /** + * Handle control request from CLI + */ + 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, + )) as unknown as Record; + break; + + case 'mcp_message': + response = await this.handleMcpMessage( + payload.server_name, + payload.message as unknown as JSONRPCMessage, + ); + break; + + case 'hook_callback': + response = await this.handleHookCallback( + payload.callback_id, + payload.input, + payload.tool_use_id, + requestAbortController.signal, + ); + 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); + } + } + + /** + * Handle permission request (can_use_tool) + */ + private async handlePermissionRequest( + toolName: string, + toolInput: Record, + permissionSuggestions: PermissionSuggestion[] | null, + signal: AbortSignal, + ): Promise { + if (!this.options.canUseTool) { + return { allowed: true }; + } + + try { + const timeoutMs = 30000; + const timeoutPromise = new Promise((_, reject) => { + setTimeout( + () => reject(new Error('Permission callback timeout')), + timeoutMs, + ); + }); + + const result = await Promise.race([ + Promise.resolve( + this.options.canUseTool(toolName, toolInput, { + signal, + suggestions: permissionSuggestions, + }), + ), + timeoutPromise, + ]); + + if (typeof result === 'boolean') { + return { allowed: result }; + } + return result as PermissionApproval; + } 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. + */ + console.warn( + '[Query] Permission callback error (denying by default):', + error instanceof Error ? error.message : String(error), + ); + return { allowed: false }; + } + } + + /** + * Handle MCP message routing + */ + 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 } }; + } + } + + /** + * Handle MCP request and wait for response + */ + private handleMcpRequest( + _serverName: string, + message: JSONRPCMessage, + transport: SdkControlServerTransport, + ): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('MCP request timeout')); + }, 30000); + + 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); + }); + } + + /** + * Handle control response from CLI + */ + 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)); + } + } + + /** + * Handle control cancel request from CLI + */ + 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')); + } + } + + /** + * Handle hook callback request + */ + private async handleHookCallback( + callbackId: string, + input: unknown, + toolUseId: string | null, + signal: AbortSignal, + ): Promise> { + const callback = this.hookCallbacks.get(callbackId); + if (!callback) { + throw new Error(`No hook callback found for ID: ${callbackId}`); + } + + const result = await callback(input, toolUseId, { signal }); + return result as Record; + } + + /** + * Send control request to CLI + */ + 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}`)); + }, 300000); + + this.pendingControlRequests.set(requestId, { + resolve, + reject, + timeout, + abortController, + }); + }, + ); + + this.transport.write(serializeJsonLine(request)); + return responsePromise; + } + + /** + * Send control response to CLI + */ + 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)); + } + + /** + * Close the query and cleanup resources + * + * Idempotent - safe to call multiple times. + */ + 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(); + + this.hookCallbacks.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(); + } + + /** + * Read SDK messages from input stream (async generator proxy). + * This ensures stream.next() is called at least once, making readReject non-null. + * + * We don't wait for initialization here to ensure inputStream.next() is called + * as early as possible, so that readResolve/readReject are set up before any errors + * (like abort) can occur. Messages will be queued in the stream if they arrive before + * iteration starts. + * + * Starting iteration immediately ensures errors (like abort) can be properly propagated + * even if they occur before initialization completes. + */ + private async *readSdkMessages(): AsyncGenerator { + for await (const message of this.inputStream) { + yield message; + } + } + + /** + * AsyncIterator protocol: next() + */ + async next(...args: [] | [unknown]): Promise> { + return this.sdkMessages.next(...args); + } + + /** + * AsyncIterator protocol: return() + */ + async return(value?: unknown): Promise> { + return this.sdkMessages.return(value); + } + + /** + * AsyncIterator protocol: throw() + */ + async throw(e?: unknown): Promise> { + return this.sdkMessages.throw(e); + } + + /** + * AsyncIterable protocol: Symbol.asyncIterator + */ + [Symbol.asyncIterator](): AsyncIterator { + return this.sdkMessages; + } + + /** + * Send follow-up messages for multi-turn conversations + * + * @param messages - Async iterable of user messages to send + * @throws Error if query is closed + */ + 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 + ) { + const STREAM_CLOSE_TIMEOUT = 10000; + + 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; + } + } + + /** + * End input stream (close stdin to CLI) + * + * @throws Error if query is closed + */ + 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(); + } + } + + /** + * Interrupt the current operation + * + * @throws Error if query is closed + */ + async interrupt(): Promise { + if (this.closed) { + throw new Error('Query is closed'); + } + + await this.sendControlRequest(ControlRequestType.INTERRUPT); + } + + /** + * Set the permission mode for tool execution + * + * @param mode - Permission mode ('default' | 'plan' | 'auto-edit' | 'yolo') + * @throws Error if query is closed + */ + async setPermissionMode(mode: string): Promise { + if (this.closed) { + throw new Error('Query is closed'); + } + + await this.sendControlRequest(ControlRequestType.SET_PERMISSION_MODE, { + mode, + }); + } + + /** + * Set the model for the current query + * + * @param model - Model name (e.g., 'qwen-2.5-coder-32b-instruct') + * @throws Error if query is closed + */ + 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); + } + + /** + * Get the session ID for this query + * + * @returns UUID session identifier + */ + getSessionId(): string { + return this.sessionId; + } + + /** + * Check if the query has been closed + * + * @returns true if query is closed, false otherwise + */ + 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..0a94ac51 --- /dev/null +++ b/packages/sdk/typescript/src/query/createQuery.ts @@ -0,0 +1,206 @@ +/** + * Factory function for creating Query instances. + */ + +import type { CLIUserMessage } from '../types/protocol.js'; +import { serializeJsonLine } from '../utils/jsonLines.js'; +import type { + CreateQueryOptions, + PermissionMode, + PermissionCallback, + ExternalMcpServerConfig, +} from '../types/config.js'; +import { ProcessTransport } from '../transport/ProcessTransport.js'; +import { parseExecutableSpec } from '../utils/cliPath.js'; +import { Query } from './Query.js'; + +/** + * Configuration options for creating a Query. + */ +export type QueryOptions = { + cwd?: string; + model?: string; + pathToQwenExecutable?: string; + env?: Record; + permissionMode?: PermissionMode; + canUseTool?: PermissionCallback; + mcpServers?: Record; + sdkMcpServers?: Record< + string, + { connect: (transport: unknown) => Promise } + >; + abortController?: AbortController; + debug?: boolean; + stderr?: (message: string) => void; +}; + +/** + * Create a Query instance for interacting with the Qwen CLI. + * + * Supports both single-turn (string) and multi-turn (AsyncIterable) prompts. + * + * @example + * ```typescript + * const q = query({ + * prompt: 'What files are in this directory?', + * options: { cwd: process.cwd() }, + * }); + * + * for await (const msg of q) { + * if (msg.type === 'assistant') { + * console.log(msg.message.content); + * } + * } + * ``` + */ +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'; + + // Build CreateQueryOptions + const queryOptions: CreateQueryOptions = { + ...options, + singleTurn: isSingleTurn, + }; + + // 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, + }); + + // Build query options with abortController + const finalQueryOptions: CreateQueryOptions = { + ...queryOptions, + abortController, + }; + + // Create Query + const queryInstance = new Query(transport, finalQueryOptions); + + // 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 new Promise((resolve) => setTimeout(resolve, 0)); + transport.write(serializeJsonLine(message)); + } catch (err) { + console.error('[query] Error sending single-turn prompt:', err); + } + })(); + } else { + // For multi-turn queries, stream the input + 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; + +/** + * Validate query configuration options and normalize CLI executable details. + * + * Performs strict validation for each supported option, including + * permission mode, callbacks, AbortController usage, and executable spec. + * Returns the parsed executable description so callers can retain + * explicit runtime directives (e.g., `bun:/path/to/cli.js`) while still + * benefiting from early validation and auto-detection fallbacks when the + * specification is omitted. + */ +function validateOptions( + options: QueryOptions, +): ReturnType { + let parsedExecutable: ReturnType; + + // Validate permission mode if provided + if (options.permissionMode) { + const validModes = ['default', 'plan', 'auto-edit', 'yolo']; + if (!validModes.includes(options.permissionMode)) { + throw new Error( + `Invalid permissionMode: ${options.permissionMode}. Valid values are: ${validModes.join(', ')}`, + ); + } + } + + // Validate canUseTool is a function if provided + if (options.canUseTool && typeof options.canUseTool !== 'function') { + throw new Error('canUseTool must be a function'); + } + + // Validate abortController is AbortController if provided + if ( + options.abortController && + !(options.abortController instanceof AbortController) + ) { + throw new Error('abortController must be an AbortController instance'); + } + + // Validate executable path early to provide clear error messages + 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 + 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..bd3ba7d0 --- /dev/null +++ b/packages/sdk/typescript/src/transport/ProcessTransport.ts @@ -0,0 +1,379 @@ +/** + * ProcessTransport - Subprocess-based transport for SDK-CLI communication + * + * Manages CLI subprocess lifecycle and provides IPC via stdin/stdout using JSON Lines protocol. + */ + +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/config.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.mcpServers) { + for (const [name, config] of Object.entries(this.options.mcpServers)) { + args.push('--mcp-server', JSON.stringify({ name, ...config })); + } + } + + 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.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_SDK']) { + this.logForDebugging( + `[ProcessTransport] Writing to stdin: ${message.substring(0, 100)}`, + ); + } + + try { + const written = this.childStdin.write(message); + if (!written && process.env['DEBUG_SDK']) { + this.logForDebugging( + '[ProcessTransport] Write buffer full, data queued', + ); + } + } 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, + }); + + 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..7ce3e978 --- /dev/null +++ b/packages/sdk/typescript/src/transport/Transport.ts @@ -0,0 +1,93 @@ +/** + * 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) + */ + +/** + * Abstract Transport interface + * + * Provides bidirectional communication with lifecycle management. + * Implements async generator pattern for reading messages with automatic backpressure. + * + * Transport initialization happens in the constructor. For ProcessTransport, + * the subprocess is spawned during construction. Future transports (HTTP/WebSocket) + * may use async factory methods if asynchronous initialization is needed. + */ +export interface Transport { + /** + * Close the transport gracefully. + * + * For ProcessTransport: sends SIGTERM, waits 5s, then SIGKILL + * For HttpTransport: sends close request, closes connection + * For WebSocketTransport: sends close frame + * + * Idempotent - safe to call multiple times. + */ + close(): Promise; + + /** + * Wait for transport to fully exit and cleanup. + * + * Resolves when all resources are cleaned up: + * - Process has exited (ProcessTransport) + * - Connection is closed (Http/WebSocketTransport) + * - All cleanup callbacks have run + * + * @returns Promise that resolves when exit is complete + */ + waitForExit(): Promise; + + /** + * Write a message to the transport. + * + * For ProcessTransport: writes to stdin + * For HttpTransport: sends HTTP request + * For WebSocketTransport: sends WebSocket message + * + * Message format: JSON Lines (one JSON object per line) + * + * @param message - Serialized JSON message (without trailing newline) + * @throws Error if transport is not ready or closed + */ + write(message: string): void; + + /** + * Read messages from transport as async generator. + * + * Yields messages as they arrive, supporting natural backpressure via async iteration. + * Generator completes when transport closes. + * + * For ProcessTransport: reads from stdout using readline + * For HttpTransport: reads from chunked HTTP response + * For WebSocketTransport: reads from WebSocket messages + * + * Message format: JSON Lines (one JSON object per line) + * Malformed JSON lines are logged and skipped. + * + * @yields Parsed JSON messages + * @throws Error if transport encounters fatal error + */ + readMessages(): AsyncGenerator; + + /** + * Whether transport is ready for I/O operations. + * + * true: write() and readMessages() can be called + * false: transport not started or has failed + */ + readonly isReady: boolean; + + /** + * Error that caused transport to exit unexpectedly (if any). + * + * null: transport exited normally or still running + * Error: transport failed with this error + * + * Useful for diagnostics when transport closes unexpectedly. + */ + readonly exitError: Error | null; +} diff --git a/packages/sdk/typescript/src/types/config.ts b/packages/sdk/typescript/src/types/config.ts new file mode 100644 index 00000000..7e270c31 --- /dev/null +++ b/packages/sdk/typescript/src/types/config.ts @@ -0,0 +1,145 @@ +/** + * Configuration types for SDK + */ + +import type { ToolDefinition as ToolDef } from './mcp.js'; +import type { PermissionMode } from './protocol.js'; + +export type { ToolDef as ToolDefinition }; +export type { PermissionMode }; + +/** + * Permission callback function + * Called before each tool execution to determine if it should be allowed + * + * @param toolName - Name of the tool being executed + * @param input - Input parameters for the tool + * @param options - Additional options (signal for cancellation, suggestions) + * @returns Promise or boolean|unknown - true to allow, false to deny, or custom response + */ +export type PermissionCallback = ( + toolName: string, + input: Record, + options?: { + signal?: AbortSignal; + suggestions?: unknown; + }, +) => Promise | boolean | unknown; + +/** + * Hook callback function + * Called at specific points in tool execution lifecycle + * + * @param input - Hook input data + * @param toolUseId - Tool execution ID (null if not associated with a tool) + * @param options - Options including abort signal + * @returns Promise with hook result + */ +export type HookCallback = ( + input: unknown, + toolUseId: string | null, + options: { signal: AbortSignal }, +) => Promise; + +/** + * Hook matcher configuration + */ +export interface HookMatcher { + matcher: Record; + hooks: HookCallback[]; +} + +/** + * Hook configuration by event type + */ +export type HookConfig = { + [event: string]: HookMatcher[]; +}; + +/** + * External MCP server configuration (spawned by CLI) + */ +export type ExternalMcpServerConfig = { + /** Command to execute (e.g., 'mcp-server-filesystem') */ + command: string; + /** Command-line arguments */ + args?: string[]; + /** Environment variables */ + env?: Record; +}; + +/** + * Options for creating a Query instance + */ +export type CreateQueryOptions = { + // Basic configuration + /** Working directory for CLI execution */ + cwd?: string; + /** Model name (e.g., 'qwen-2.5-coder-32b-instruct') */ + model?: string; + + // Transport configuration + /** Path to qwen executable (auto-detected if omitted) */ + pathToQwenExecutable?: string; + /** Environment variables for CLI process */ + env?: Record; + + // Permission control + /** Permission mode ('default' | 'plan' | 'auto-edit' | 'yolo') */ + permissionMode?: PermissionMode; + /** Callback invoked before each tool execution */ + canUseTool?: PermissionCallback; + + // Hook system + /** Hook configuration for tool execution lifecycle */ + hooks?: HookConfig; + + // MCP server configuration + /** External MCP servers (spawned by CLI) */ + mcpServers?: Record; + /** SDK-embedded MCP servers (run in Node.js process) */ + sdkMcpServers?: Record< + string, + { connect: (transport: unknown) => Promise } + >; // Server from @modelcontextprotocol/sdk + + // Conversation mode + /** + * Single-turn mode: automatically close input after receiving result + * Multi-turn mode: keep input open for follow-up messages + * @default false (multi-turn) + */ + singleTurn?: boolean; + + // Advanced options + /** AbortController for cancellation support */ + abortController?: AbortController; + /** Enable debug output (inherits stderr) */ + debug?: boolean; + /** Callback for stderr output */ + stderr?: (message: string) => void; +}; + +/** + * Transport options for ProcessTransport + */ +export type TransportOptions = { + /** Path to qwen executable */ + pathToQwenExecutable: string; + /** Working directory for CLI execution */ + cwd?: string; + /** Model name */ + model?: string; + /** Permission mode */ + permissionMode?: PermissionMode; + /** External MCP servers */ + mcpServers?: Record; + /** Environment variables */ + env?: Record; + /** AbortController for cancellation support */ + abortController?: AbortController; + /** Enable debug output */ + debug?: boolean; + /** Callback for stderr output */ + stderr?: (message: string) => void; +}; diff --git a/packages/sdk/typescript/src/types/controlRequests.ts b/packages/sdk/typescript/src/types/controlRequests.ts new file mode 100644 index 00000000..b2634d3c --- /dev/null +++ b/packages/sdk/typescript/src/types/controlRequests.ts @@ -0,0 +1,50 @@ +/** + * @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', +} + +/** + * Get all available control request types as a string array + */ +export function getAllControlRequestTypes(): string[] { + return Object.values(ControlRequestType); +} + +/** + * Check if a string is a valid control request type + */ +export function isValidControlRequestType( + type: string, +): type is ControlRequestType { + return getAllControlRequestTypes().includes(type); +} diff --git a/packages/sdk/typescript/src/types/errors.ts b/packages/sdk/typescript/src/types/errors.ts new file mode 100644 index 00000000..137893cd --- /dev/null +++ b/packages/sdk/typescript/src/types/errors.ts @@ -0,0 +1,27 @@ +/** + * Error types for SDK + */ + +/** + * Error thrown when an operation is aborted via AbortSignal + */ +export class AbortError extends Error { + constructor(message = 'Operation aborted') { + super(message); + this.name = 'AbortError'; + Object.setPrototypeOf(this, AbortError.prototype); + } +} + +/** + * Check if an error is an AbortError + */ +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/mcp.ts b/packages/sdk/typescript/src/types/mcp.ts new file mode 100644 index 00000000..53a8bfc9 --- /dev/null +++ b/packages/sdk/typescript/src/types/mcp.ts @@ -0,0 +1,32 @@ +/** + * MCP integration types for SDK + */ + +/** + * JSON Schema definition + * Used for tool input validation + */ +export type JSONSchema = { + type: string; + properties?: Record; + required?: string[]; + description?: string; + [key: string]: unknown; +}; + +/** + * Tool definition for SDK-embedded MCP servers + * + * @template TInput - Type of tool input (inferred from handler) + * @template TOutput - Type of tool output (inferred from handler return) + */ +export type ToolDefinition = { + /** Unique tool name */ + name: string; + /** Human-readable description (helps agent decide when to use it) */ + description: string; + /** JSON Schema for input validation */ + inputSchema: JSONSchema; + /** Async handler function that executes the tool */ + handler: (input: TInput) => Promise; +}; diff --git a/packages/sdk/typescript/src/types/protocol.ts b/packages/sdk/typescript/src/types/protocol.ts new file mode 100644 index 00000000..784ea916 --- /dev/null +++ b/packages/sdk/typescript/src/types/protocol.ts @@ -0,0 +1,509 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/** + * Annotation for attaching metadata to content blocks + */ +export interface Annotation { + type: string; + value: string; +} + +/** + * Usage information types + */ +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; +} + +/** + * Permission denial information + */ +export interface CLIPermissionDenial { + tool_name: string; + tool_use_id: string; + tool_input: unknown; +} + +/** + * Content block types from Anthropic SDK + */ +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; + +/** + * Anthropic SDK Message types + */ +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; +} + +/** + * CLI Message wrapper types + */ +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; + +/** + * Stream event types for real-time message updates + */ +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'; + +/** + * Permission suggestion for tool use requests + * TODO: Align with `ToolCallConfirmationDetails` + */ +export interface PermissionSuggestion { + type: 'allow' | 'deny' | 'modify'; + label: string; + description?: string; + modifiedInput?: unknown; +} + +/** + * Hook callback placeholder for future implementation + */ +export interface HookRegistration { + event: string; + callback_id: string; +} + +/** + * Hook callback result placeholder for future implementation + */ +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 interface CLIControlInitializeRequest { + subtype: 'initialize'; + hooks?: HookRegistration[] | null; + sdkMcpServers?: string[]; +} + +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; +} + +/** + * Permission approval result + */ +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; + +/** + * Type guard functions for message discrimination + */ + +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 + ); +} + +/** + * Content block type guards + */ + +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'; +} 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..ff368067 --- /dev/null +++ b/packages/sdk/typescript/src/utils/cliPath.ts @@ -0,0 +1,438 @@ +/** + * 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; +}; + +/** + * Find native CLI executable path + * + * Searches global installation locations in order of priority. + * Only looks for native 'qwen' binary, not JS/TS files. + * + * @returns Absolute path to CLI executable + * @throws Error if CLI not found + */ +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" })', + ); +} + +/** + * Check if a command is available in the system PATH + * + * @param command - Command to check (e.g., 'bun', 'tsx', 'deno') + * @returns true if command is available + */ +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; + } +} + +/** + * Validate that a runtime is available on the system + * + * @param runtime - Runtime to validate (node, bun, tsx, deno) + * @returns true if runtime is available + */ +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); +} + +/** + * Validate file extension matches expected runtime + * + * @param filePath - Path to the file + * @param runtime - Expected runtime + * @returns true if extension is compatible + */ +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, + }; +} + +/** + * Get expected file extensions for a runtime + */ +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 []; + } +} + +/** + * Resolve CLI path from options (backward compatibility) + * + * @param explicitPath - Optional explicit CLI path or command name + * @returns Resolved CLI path + * @throws Error if CLI not found + * @deprecated Use parseExecutableSpec and prepareSpawnInfo instead + */ +export function resolveCliPath(explicitPath?: string): string { + const parsed = parseExecutableSpec(explicitPath); + return parsed.executablePath; +} + +/** + * Detect runtime for file based on extension + * + * Uses sensible defaults: + * - JavaScript files (.js, .mjs, .cjs) -> Node.js (default choice) + * - TypeScript files (.ts, .tsx) -> tsx (if available) + * + * @param filePath - Path to the file + * @returns Suggested runtime or undefined for native executables + */ +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; +} + +/** + * Prepare spawn information for CLI process + * + * Handles all supported executable formats with clear separation of concerns: + * 1. Parse the executable specification + * 2. Determine the appropriate runtime + * 3. Build the spawn command and arguments + * + * @param executableSpec - Executable specification (path, command, or runtime:path) + * @returns SpawnInfo with command and args for spawning + * + * @example + * ```typescript + * // Native binary (production) + * prepareSpawnInfo('qwen') // -> { command: 'qwen', args: [], type: 'native' } + * + * // Node.js bundle (default for .js files) + * prepareSpawnInfo('/path/to/cli.js') // -> { command: 'node', args: ['/path/to/cli.js'], type: 'node' } + * + * // TypeScript source (development, requires tsx) + * prepareSpawnInfo('/path/to/index.ts') // -> { command: 'tsx', args: ['/path/to/index.ts'], type: 'tsx' } + * + * // Advanced: Force specific runtime + * prepareSpawnInfo('bun:/path/to/cli.js') // -> { command: 'bun', args: ['/path/to/cli.js'], type: 'bun' } + * ``` + */ +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 || '', + }; +} + +/** + * Legacy function for backward compatibility + * @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..65fd2ff6 --- /dev/null +++ b/packages/sdk/typescript/src/utils/jsonLines.ts @@ -0,0 +1,137 @@ +/** + * JSON Lines protocol utilities + * + * JSON Lines format: one JSON object per line, newline-delimited + * Example: + * {"type":"user","message":{...}} + * {"type":"assistant","message":{...}} + * + * Used for SDK-CLI communication over stdin/stdout streams. + */ + +/** + * Serialize a message to JSON Lines format + * + * Converts object to JSON and appends newline. + * + * @param message - Object to serialize + * @returns JSON string with trailing newline + * @throws Error if JSON serialization fails + */ +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)}`, + ); + } +} + +/** + * Parse a JSON Lines message + * + * Parses single line of JSON (without newline). + * + * @param line - JSON string (without trailing newline) + * @returns Parsed object + * @throws Error if JSON parsing fails + */ +export function parseJsonLine(line: string): unknown { + try { + return JSON.parse(line); + } catch (error) { + throw new Error( + `Failed to parse JSON line: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +/** + * Parse JSON Lines with error handling + * + * Attempts to parse JSON line, logs warning and returns null on failure. + * Useful for robust parsing where malformed messages should be skipped. + * + * @param line - JSON string (without trailing newline) + * @param context - Context string for error logging (e.g., 'Transport') + * @returns Parsed object or null if parsing fails + */ +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; + } +} + +/** + * Validate message has required type field + * + * Ensures message conforms to basic message protocol. + * + * @param message - Parsed message object + * @returns true if valid, false otherwise + */ +export function isValidMessage(message: unknown): boolean { + return ( + message !== null && + typeof message === 'object' && + 'type' in message && + typeof (message as { type: unknown }).type === 'string' + ); +} + +/** + * Async generator that yields parsed JSON Lines from async iterable of strings + * + * Usage: + * ```typescript + * const lines = readline.createInterface({ input: stream }); + * for await (const message of parseJsonLinesStream(lines)) { + * console.log(message); + * } + * ``` + * + * @param lines - AsyncIterable of line strings + * @param context - Context string for error logging + * @yields Parsed message objects (skips malformed lines) + */ +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..6bf31106 --- /dev/null +++ b/packages/sdk/typescript/test/e2e/abort-and-lifecycle.test.ts @@ -0,0 +1,530 @@ +/** + * 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'; + +// Test configuration +const TEST_CLI_PATH = + '/Users/mingholy/Work/Projects/qwen-code/packages/cli/index.ts'; +const TEST_TIMEOUT = 30000; + +// Shared test options with permissionMode to allow all tools +const SHARED_TEST_OPTIONS = { + pathToQwenExecutable: TEST_CLI_PATH, + permissionMode: 'yolo' as const, +}; + +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(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should handle abort during query execution', + async () => { + const controller = new AbortController(); + + const q = query({ + prompt: 'Write a detailed explanation about TypeScript', + 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(); + } + }, + TEST_TIMEOUT, + ); + + 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(); + } + }, + TEST_TIMEOUT, + ); + }); + + 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); + } + }, + TEST_TIMEOUT, + ); + + 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 + } + }, + TEST_TIMEOUT, + ); + }); + + 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; + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + const textBlocks = message.message.content.filter( + (block: ContentBlock): block is TextBlock => + block.type === 'text', + ); + const text = textBlocks + .map((b: TextBlock) => b.text) + .join('') + .slice(0, 100); + + expect(text.length).toBeGreaterThan(0); + receivedResponse = true; + + // End input after receiving first response + q.endInput(); + break; + } + } + + expect(receivedResponse).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + 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', + ); + } + }, + TEST_TIMEOUT, + ); + + 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(); + } + }, + TEST_TIMEOUT, + ); + }); + + 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: (message: string) => { + stderrMessages.push(message); + }, + }, + }); + + 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); + } + }, + TEST_TIMEOUT, + ); + + 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: (message: string) => { + stderrMessages.push(message); + }, + }, + }); + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + break; + } + } + } finally { + await q.close(); + // Should have minimal or no stderr output when debug is false + expect(stderrMessages.length).toBeLessThan(10); + } + }, + TEST_TIMEOUT, + ); + }); + + 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 + } + }, + TEST_TIMEOUT, + ); + + 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(); + } + }, + TEST_TIMEOUT, + ); + }); + + 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); + }, + TEST_TIMEOUT, + ); + + 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); + }, + TEST_TIMEOUT, + ); + }); +}); diff --git a/packages/sdk/typescript/test/e2e/basic-usage.test.ts b/packages/sdk/typescript/test/e2e/basic-usage.test.ts new file mode 100644 index 00000000..d53d1743 --- /dev/null +++ b/packages/sdk/typescript/test/e2e/basic-usage.test.ts @@ -0,0 +1,515 @@ +/** + * E2E tests based on basic-usage.ts example + * Tests message type recognition and basic query patterns + */ + +import { describe, it, expect } from 'vitest'; +import { query } from '../../src/index.js'; +import { + isCLIUserMessage, + isCLIAssistantMessage, + isCLISystemMessage, + isCLIResultMessage, + isCLIPartialAssistantMessage, + isControlRequest, + isControlResponse, + isControlCancel, + type TextBlock, + type ContentBlock, + type CLIMessage, + type ControlMessage, + type CLISystemMessage, + type CLIUserMessage, + type CLIAssistantMessage, + type ToolUseBlock, + type ToolResultBlock, +} from '../../src/types/protocol.js'; + +// Test configuration +const TEST_CLI_PATH = + '/Users/mingholy/Work/Projects/qwen-code/packages/cli/index.ts'; +const TEST_TIMEOUT = 30000; + +// Shared test options with permissionMode to allow all tools +const SHARED_TEST_OPTIONS = { + pathToQwenExecutable: TEST_CLI_PATH, + permissionMode: 'yolo' as const, +}; + +/** + * 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'; + } +} + +describe('Basic Usage (E2E)', () => { + describe('Message Type Recognition', () => { + it('should correctly identify message types using type guards', async () => { + const q = query({ + prompt: + 'What files are in the current directory? List only the top-level files and folders.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: process.cwd(), + debug: true, + }, + }); + + const messages: CLIMessage[] = []; + const messageTypes: string[] = []; + + try { + for await (const message of q) { + messages.push(message); + const messageType = getMessageType(message); + messageTypes.push(messageType); + + if (isCLIResultMessage(message)) { + break; + } + } + + expect(messages.length).toBeGreaterThan(0); + expect(messageTypes.length).toBe(messages.length); + + // Should have at least assistant and result messages + expect(messageTypes.some((type) => type.includes('ASSISTANT'))).toBe( + true, + ); + expect(messageTypes.some((type) => type.includes('RESULT'))).toBe(true); + + // Verify type guards work correctly + const assistantMessages = messages.filter(isCLIAssistantMessage); + const resultMessages = messages.filter(isCLIResultMessage); + + expect(assistantMessages.length).toBeGreaterThan(0); + expect(resultMessages.length).toBeGreaterThan(0); + } finally { + await q.close(); + } + }); + + it( + 'should handle message content extraction', + async () => { + const q = query({ + prompt: 'Say hello and explain what you are', + options: { + ...SHARED_TEST_OPTIONS, + debug: true, + }, + }); + + let assistantMessage: CLIAssistantMessage | null = null; + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + assistantMessage = message; + break; + } + } + + 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); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('Basic Query Patterns', () => { + it( + 'should handle simple question-answer pattern', + async () => { + const q = query({ + prompt: 'What is 2 + 2?', + options: { + ...SHARED_TEST_OPTIONS, + debug: true, + }, + }); + + const messages: CLIMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + expect(messages.length).toBeGreaterThan(0); + + // Should have assistant response + const assistantMessages = messages.filter(isCLIAssistantMessage); + expect(assistantMessages.length).toBeGreaterThan(0); + + // Should end with result + const lastMessage = messages[messages.length - 1]; + expect(isCLIResultMessage(lastMessage)).toBe(true); + if (isCLIResultMessage(lastMessage)) { + expect(lastMessage.subtype).toBe('success'); + } + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should handle file system query pattern', + async () => { + const q = query({ + prompt: + 'What files are in the current directory? List only the top-level files and folders.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: process.cwd(), + debug: true, + }, + }); + + const messages: CLIMessage[] = []; + let hasToolUse = false; + let hasToolResult = 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) { + hasToolUse = true; + expect(toolUseBlock.name).toBeDefined(); + expect(toolUseBlock.id).toBeDefined(); + } + } + + if (isCLIUserMessage(message)) { + // Tool results are sent as user messages with ToolResultBlock[] content + if (Array.isArray(message.message.content)) { + const toolResultBlock = message.message.content.find( + (block: ToolResultBlock): block is ToolResultBlock => + block.type === 'tool_result', + ); + if (toolResultBlock) { + hasToolResult = true; + expect(toolResultBlock.tool_use_id).toBeDefined(); + expect(toolResultBlock.content).toBeDefined(); + } + } + } + + if (isCLIResultMessage(message)) { + break; + } + } + + expect(messages.length).toBeGreaterThan(0); + expect(hasToolUse).toBe(true); + expect(hasToolResult).toBe(true); + + // Should have assistant response after tool execution + const assistantMessages = messages.filter(isCLIAssistantMessage); + expect(assistantMessages.length).toBeGreaterThan(0); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('Configuration and Options', () => { + it( + 'should respect debug option', + async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + debug: true, + stderr: (message: string) => { + stderrMessages.push(message); + }, + }, + }); + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + break; + } + } + + // Debug mode should produce stderr output + expect(stderrMessages.length).toBeGreaterThan(0); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should respect cwd option', + async () => { + const q = query({ + prompt: 'List files in current directory', + options: { + ...SHARED_TEST_OPTIONS, + cwd: process.cwd(), + debug: false, + }, + }); + + let hasResponse = false; + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + hasResponse = true; + break; + } + } + + expect(hasResponse).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('SDK-CLI Handshaking Process', () => { + it( + 'should receive system message after initialization', + 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); + + // Capture system message + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + break; // Exit early once we get the system message + } + + // Stop after getting assistant response to avoid long execution + if (isCLIAssistantMessage(message)) { + break; + } + } + + // Verify system message was received after initialization + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.type).toBe('system'); + expect(systemMessage!.subtype).toBe('init'); + + // Validate system message structure matches sendSystemMessage() + 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!.slash_commands).toBeDefined(); + expect(Array.isArray(systemMessage!.slash_commands)).toBe(true); + // expect(systemMessage!.apiKeySource).toBeDefined(); + expect(systemMessage!.qwen_code_version).toBeDefined(); + // expect(systemMessage!.output_style).toBeDefined(); + expect(systemMessage!.agents).toBeDefined(); + expect(Array.isArray(systemMessage!.agents)).toBe(true); + // expect(systemMessage!.skills).toBeDefined(); + // expect(Array.isArray(systemMessage!.skills)).toBe(true); + + // Verify system message appears early in the message sequence + const systemMessageIndex = messages.findIndex( + (msg) => isCLISystemMessage(msg) && msg.subtype === 'init', + ); + expect(systemMessageIndex).toBeGreaterThanOrEqual(0); + expect(systemMessageIndex).toBeLessThan(3); // Should be one of the first few messages + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should handle initialization with session ID consistency', + async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + let systemMessage: CLISystemMessage | null = null; + let userMessage: CLIUserMessage | null = null; + const sessionId = q.getSessionId(); + + try { + for await (const message of q) { + // Capture system message + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + + // Capture user message + if (isCLIUserMessage(message)) { + userMessage = message; + } + + // Stop after getting assistant response to avoid long execution + if (isCLIAssistantMessage(message)) { + break; + } + } + + // Verify session IDs are consistent within the system + expect(sessionId).toBeDefined(); + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.session_id).toBeDefined(); + expect(systemMessage!.uuid).toBeDefined(); + + // System message should have consistent session_id and uuid + expect(systemMessage!.session_id).toBe(systemMessage!.uuid); + + if (userMessage) { + expect(userMessage.session_id).toBeDefined(); + // User message should have the same session_id as system message + expect(userMessage.session_id).toBe(systemMessage!.session_id); + } + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('Message Flow Validation', () => { + it( + 'should follow expected message sequence', + async () => { + const q = query({ + prompt: 'What is the current time?', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const messageSequence: string[] = []; + + try { + for await (const message of q) { + messageSequence.push(message.type); + + if (isCLIResultMessage(message)) { + break; + } + } + + expect(messageSequence.length).toBeGreaterThan(0); + + // Should end with result + expect(messageSequence[messageSequence.length - 1]).toBe('result'); + + // Should have at least one assistant message + expect(messageSequence).toContain('assistant'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should handle graceful completion', + async () => { + const q = query({ + prompt: 'Say goodbye', + options: { + ...SHARED_TEST_OPTIONS, + debug: true, + }, + }); + + 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(); + } + }, + TEST_TIMEOUT, + ); + }); +}); 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..6d23fc16 --- /dev/null +++ b/packages/sdk/typescript/test/e2e/multi-turn.test.ts @@ -0,0 +1,517 @@ +/** + * 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'; + +// Test configuration +const TEST_CLI_PATH = + '/Users/mingholy/Work/Projects/qwen-code/packages/cli/index.ts'; +const TEST_TIMEOUT = 60000; // Longer timeout for multi-turn conversations + +// Shared test options with permissionMode to allow all tools +const SHARED_TEST_OPTIONS = { + pathToQwenExecutable: TEST_CLI_PATH, + permissionMode: 'yolo' as const, +}; + +/** + * 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 the name of this project? Check the package.json file.', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + + // Wait a bit to simulate user thinking time + await new Promise((resolve) => setTimeout(resolve, 100)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'What version is it currently on?', + }, + 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 are the main dependencies?', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + } + + // Create multi-turn query using AsyncIterable prompt + const q = query({ + prompt: createMultiTurnConversation(), + options: { + ...SHARED_TEST_OPTIONS, + cwd: process.cwd(), + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + const assistantMessages: CLIAssistantMessage[] = []; + let turnCount = 0; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + assistantMessages.push(message); + turnCount++; + } + } + + expect(messages.length).toBeGreaterThan(0); + expect(assistantMessages.length).toBeGreaterThanOrEqual(3); // Should have responses to all 3 questions + expect(turnCount).toBeGreaterThanOrEqual(3); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + 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: + 'My name is Alice. Remember this during our current conversation.', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 200)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'What is my name?', + }, + 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 name Alice + const secondResponse = extractText( + assistantMessages[1].message.content, + ); + expect(secondResponse.toLowerCase()).toContain('alice'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + 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: 'List the files in the current directory', + }, + 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 tell me about the package.json file specifically', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + } + + const q = query({ + prompt: createToolConversation(), + options: { + ...SHARED_TEST_OPTIONS, + cwd: process.cwd(), + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + let toolUseCount = 0; + let assistantCount = 0; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + const hasToolUseBlock = message.message.content.some( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use', + ); + if (hasToolUseBlock) { + toolUseCount++; + } + } + + if (isCLIAssistantMessage(message)) { + assistantCount++; + } + + if (isCLIResultMessage(message)) { + break; + } + } + + expect(messages.length).toBeGreaterThan(0); + expect(toolUseCount).toBeGreaterThan(0); // Should use tools + expect(assistantCount).toBeGreaterThanOrEqual(2); // Should have responses to both questions + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + 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(); + } + }, + TEST_TIMEOUT, + ); + + 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(); + } + }, + TEST_TIMEOUT, + ); + }); + + 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); + + if (isCLIResultMessage(message)) { + break; + } + } + + // Should handle empty conversation without crashing + expect(true).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + 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(); + } + }, + TEST_TIMEOUT, + ); + }); +}); diff --git a/packages/sdk/typescript/test/e2e/simple-query.test.ts b/packages/sdk/typescript/test/e2e/simple-query.test.ts new file mode 100644 index 00000000..030bcc02 --- /dev/null +++ b/packages/sdk/typescript/test/e2e/simple-query.test.ts @@ -0,0 +1,747 @@ +/** + * End-to-End tests for simple query execution with real CLI + * Tests the complete SDK workflow with actual CLI subprocess + */ + +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import { describe, it, expect } from 'vitest'; +import { + query, + AbortError, + isAbortError, + isCLIAssistantMessage, + isCLIUserMessage, + isCLIResultMessage, + type TextBlock, + type ToolUseBlock, + type ToolResultBlock, + type ContentBlock, + type CLIMessage, + type CLIAssistantMessage, +} from '../../src/index.js'; + +// Test configuration +const TEST_CLI_PATH = + '/Users/mingholy/Work/Projects/qwen-code/packages/cli/index.ts'; +const TEST_TIMEOUT = 30000; + +// Shared test options with permissionMode to allow all tools +const SHARED_TEST_OPTIONS = { + pathToQwenExecutable: TEST_CLI_PATH, + permissionMode: 'yolo' as const, +}; + +describe('Simple Query Execution (E2E)', () => { + describe('Basic Query Flow', () => { + it( + 'should execute simple text query', + async () => { + const q = query({ + prompt: 'What is 2 + 2?', + options: { + ...SHARED_TEST_OPTIONS, + debug: true, + env: { + DEBUG: '1', + }, + }, + }); + + const messages: CLIMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + if (isCLIResultMessage(message)) { + break; + } + } + + expect(messages.length).toBeGreaterThan(0); + + // Should have at least one assistant message + const assistantMessages = messages.filter(isCLIAssistantMessage); + expect(assistantMessages.length).toBeGreaterThan(0); + + // Should end with result message + const lastMessage = messages[messages.length - 1]; + expect(isCLIResultMessage(lastMessage)).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should receive assistant response', + async () => { + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + let hasAssistantMessage = false; + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + hasAssistantMessage = true; + const textBlocks = message.message.content.filter( + (block): block is TextBlock => block.type === 'text', + ); + expect(textBlocks.length).toBeGreaterThan(0); + expect(textBlocks[0].text.length).toBeGreaterThan(0); + break; + } + } + + expect(hasAssistantMessage).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should receive result message at end', + async () => { + const q = query({ + prompt: 'Simple test', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + expect(messages.length).toBeGreaterThan(0); + + const lastMessage = messages[messages.length - 1]; + expect(isCLIResultMessage(lastMessage)).toBe(true); + if (isCLIResultMessage(lastMessage)) { + expect(lastMessage.subtype).toBe('success'); + } + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should complete iteration after result', + async () => { + const q = query({ + prompt: 'Hello, who are you?', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + let messageCount = 0; + let completedNaturally = false; + + try { + for await (const message of q) { + messageCount++; + if (isCLIResultMessage(message)) { + // Should be the last message + completedNaturally = true; + } + } + + expect(messageCount).toBeGreaterThan(0); + expect(completedNaturally).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('Query with Tool Usage', () => { + it( + 'should handle query requiring tool execution', + async () => { + const q = query({ + prompt: + 'What files are in the current directory? List only the top-level files and folders.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: process.cwd(), + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + let hasToolUse = false; + let hasAssistantResponse = false; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + hasAssistantResponse = true; + const hasToolUseBlock = message.message.content.some( + (block: ContentBlock) => block.type === 'tool_use', + ); + if (hasToolUseBlock) { + hasToolUse = true; + } + } + + if (isCLIResultMessage(message)) { + break; + } + } + + expect(messages.length).toBeGreaterThan(0); + expect(hasToolUse).toBe(true); + expect(hasAssistantResponse).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should yield tool_use messages', + async () => { + const q = query({ + prompt: 'List files in current directory', + options: { + ...SHARED_TEST_OPTIONS, + cwd: process.cwd(), + debug: false, + }, + }); + + let toolUseMessage: ToolUseBlock | null = null; + + try { + 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) { + toolUseMessage = toolUseBlock; + expect(toolUseBlock.name).toBeDefined(); + expect(toolUseBlock.id).toBeDefined(); + expect(toolUseBlock.input).toBeDefined(); + break; + } + } + } + + expect(toolUseMessage).not.toBeNull(); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should yield tool_result messages', + async () => { + const q = query({ + prompt: 'List files in current directory', + options: { + ...SHARED_TEST_OPTIONS, + cwd: process.cwd(), + debug: false, + }, + }); + + let toolResultMessage: ToolResultBlock | null = null; + + try { + for await (const message of q) { + if (isCLIUserMessage(message)) { + // Tool results are sent as user messages with ToolResultBlock[] content + if (Array.isArray(message.message.content)) { + const toolResultBlock = message.message.content.find( + (block: ContentBlock): block is ToolResultBlock => + block.type === 'tool_result', + ); + if (toolResultBlock) { + toolResultMessage = toolResultBlock; + expect(toolResultBlock.tool_use_id).toBeDefined(); + expect(toolResultBlock.content).toBeDefined(); + // Content should not be a simple string but structured data + expect(typeof toolResultBlock.content).not.toBe('undefined'); + break; + } + } + } + } + + expect(toolResultMessage).not.toBeNull(); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should yield final assistant response', + async () => { + const q = query({ + prompt: 'List files in current directory and tell me what you found', + options: { + ...SHARED_TEST_OPTIONS, + cwd: process.cwd(), + debug: false, + }, + }); + + const assistantMessages: CLIAssistantMessage[] = []; + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + assistantMessages.push(message); + } + + if (isCLIResultMessage(message)) { + break; + } + } + + expect(assistantMessages.length).toBeGreaterThan(0); + + // Final assistant message should contain summary + const finalAssistant = + assistantMessages[assistantMessages.length - 1]; + const textBlocks = finalAssistant.message.content.filter( + (block: ContentBlock): block is TextBlock => block.type === 'text', + ); + expect(textBlocks.length).toBeGreaterThan(0); + expect(textBlocks[0].text.length).toBeGreaterThan(0); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('Configuration Options', () => { + it( + 'should respect cwd option', + async () => { + const testDir = '/tmp'; + + const q = query({ + prompt: 'What is the current working directory?', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + let hasResponse = false; + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + hasResponse = true; + // Should execute in specified directory + break; + } + } + + expect(hasResponse).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should use explicit CLI path when provided', + async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + let hasResponse = false; + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + hasResponse = true; + break; + } + } + + expect(hasResponse).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + 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 + }, + TEST_TIMEOUT, + ); + }); + + 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, + }, + }); + + // Should not reach here - query() should throw immediately + 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', + ); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('Timeout and Cancellation', () => { + it( + 'should support AbortSignal cancellation', + async () => { + const controller = new AbortController(); + + // Abort after 2 seconds + setTimeout(() => { + controller.abort(); + }, 2000); + + const q = query({ + prompt: 'Write a very long story about TypeScript', + options: { + ...SHARED_TEST_OPTIONS, + abortController: controller, + debug: false, + }, + }); + + try { + for await (const _message of q) { + // Should be interrupted by abort + } + + // Should not reach here + expect(false).toBe(true); + } catch (error) { + expect(isAbortError(error)).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should cleanup on cancellation', + async () => { + const controller = new AbortController(); + + const q = query({ + prompt: 'Write a very long essay', + options: { + ...SHARED_TEST_OPTIONS, + abortController: controller, + debug: false, + }, + }); + + // Abort immediately + setTimeout(() => controller.abort(), 100); + + try { + for await (const _message of q) { + // Should be interrupted + } + } catch (error) { + expect(error instanceof AbortError).toBe(true); + } finally { + // Should cleanup successfully even after abort + await q.close(); + expect(true).toBe(true); // Cleanup completed + } + }, + TEST_TIMEOUT, + ); + }); + + describe('Message Collection Patterns', () => { + it( + 'should collect all messages in array', + async () => { + const q = query({ + prompt: 'What is 2 + 2?', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + expect(messages.length).toBeGreaterThan(0); + + // Should have various message types + const messageTypes = messages.map((m) => m.type); + expect(messageTypes).toContain('assistant'); + expect(messageTypes).toContain('result'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should extract final answer', + async () => { + const q = query({ + prompt: 'What is the capital of France?', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Get last assistant message content + const assistantMessages = messages.filter(isCLIAssistantMessage); + expect(assistantMessages.length).toBeGreaterThan(0); + + const lastAssistant = assistantMessages[assistantMessages.length - 1]; + const textBlocks = lastAssistant.message.content.filter( + (block: ContentBlock): block is TextBlock => block.type === 'text', + ); + + expect(textBlocks.length).toBeGreaterThan(0); + expect(textBlocks[0].text).toContain('Paris'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should track tool usage', + async () => { + const q = query({ + prompt: 'List files in current directory', + options: { + ...SHARED_TEST_OPTIONS, + cwd: process.cwd(), + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Count tool_use blocks in assistant messages and tool_result blocks in user messages + let toolUseCount = 0; + let toolResultCount = 0; + + messages.forEach((message) => { + if (isCLIAssistantMessage(message)) { + message.message.content.forEach((block: ContentBlock) => { + if (block.type === 'tool_use') { + toolUseCount++; + } + }); + } else if (isCLIUserMessage(message)) { + // Tool results are in user messages + if (Array.isArray(message.message.content)) { + message.message.content.forEach((block: ContentBlock) => { + if (block.type === 'tool_result') { + toolResultCount++; + } + }); + } + } + }); + + expect(toolUseCount).toBeGreaterThan(0); + expect(toolResultCount).toBeGreaterThan(0); + + // Each tool_use should have a corresponding tool_result + expect(toolResultCount).toBeGreaterThanOrEqual(toolUseCount); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('Real-World Scenarios', () => { + it( + 'should handle code analysis query', + async () => { + const q = query({ + prompt: + 'What is the main export of the package.json file in this directory?', + options: { + ...SHARED_TEST_OPTIONS, + cwd: process.cwd(), + debug: false, + }, + }); + + let hasAnalysis = false; + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + const textBlocks = message.message.content.filter( + (block: ContentBlock): block is TextBlock => + block.type === 'text', + ); + if (textBlocks.length > 0 && textBlocks[0].text.length > 0) { + hasAnalysis = true; + break; + } + } + } + + expect(hasAnalysis).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should handle multi-step query', + async () => { + const q = query({ + prompt: + 'List the files in this directory and tell me what type of project this is', + options: { + ...SHARED_TEST_OPTIONS, + cwd: process.cwd(), + debug: false, + }, + }); + + let hasToolUse = false; + let hasAnalysis = false; + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + const hasToolUseBlock = message.message.content.some( + (block: ContentBlock) => block.type === 'tool_use', + ); + if (hasToolUseBlock) { + hasToolUse = true; + } + } + + if (isCLIAssistantMessage(message)) { + const textBlocks = message.message.content.filter( + (block: ContentBlock): block is TextBlock => + block.type === 'text', + ); + if (textBlocks.length > 0 && textBlocks[0].text.length > 0) { + hasAnalysis = true; + } + } + + if (isCLIResultMessage(message)) { + break; + } + } + + expect(hasToolUse).toBe(true); + expect(hasAnalysis).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); +}); 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..5fa97a43 --- /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"] +} diff --git a/packages/sdk/typescript/vitest.config.ts b/packages/sdk/typescript/vitest.config.ts new file mode 100644 index 00000000..f3909ea4 --- /dev/null +++ b/packages/sdk/typescript/vitest.config.ts @@ -0,0 +1,36 @@ +import { defineConfig } from 'vitest/config'; +import * as path from 'path'; + +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: 30000, + hookTimeout: 10000, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts index 20ec6b90..f9602bac 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', ],