diff --git a/.vscode/launch.json b/.vscode/launch.json index 143f314e..1966371c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -73,15 +73,7 @@ "request": "launch", "name": "Launch CLI Non-Interactive", "runtimeExecutable": "npm", - "runtimeArgs": [ - "run", - "start", - "--", - "-p", - "${input:prompt}", - "--output-format", - "json" - ], + "runtimeArgs": ["run", "start", "--", "-p", "${input:prompt}", "-y"], "skipFiles": ["/**"], "cwd": "${workspaceFolder}", "console": "integratedTerminal", diff --git a/package.json b/package.json index 7f4f0419..9a09c952 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,6 @@ "build:packages": "npm run build --workspaces", "build:sandbox": "node scripts/build_sandbox.js", "bundle": "npm run generate && node esbuild.config.js && node scripts/copy_bundle_assets.js", - "qwen": "tsx packages/cli/index.ts", - "stream-json-session": "tsx packages/cli/index.ts --input-format stream-json --output-format stream-json", "test": "npm run test --workspaces --if-present --parallel", "test:ci": "npm run test:ci --workspaces --if-present --parallel && npm run test:scripts", "test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts", diff --git a/packages/sdk/typescript/package.json b/packages/sdk/typescript/package.json deleted file mode 100644 index b0b7885f..00000000 --- a/packages/sdk/typescript/package.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "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 deleted file mode 100644 index a5b3b253..00000000 --- a/packages/sdk/typescript/src/index.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * 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 deleted file mode 100644 index d7540c17..00000000 --- a/packages/sdk/typescript/src/mcp/SdkControlServerTransport.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * 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 deleted file mode 100644 index df1bd256..00000000 --- a/packages/sdk/typescript/src/mcp/createSdkMcpServer.ts +++ /dev/null @@ -1,177 +0,0 @@ -/** - * 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 deleted file mode 100644 index 4406db51..00000000 --- a/packages/sdk/typescript/src/mcp/formatters.ts +++ /dev/null @@ -1,247 +0,0 @@ -/** - * 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 deleted file mode 100644 index 8e7eb7c2..00000000 --- a/packages/sdk/typescript/src/mcp/tool.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * 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 deleted file mode 100644 index 7d33f5d7..00000000 --- a/packages/sdk/typescript/src/query/Query.ts +++ /dev/null @@ -1,895 +0,0 @@ -/** - * 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'; - -/** - * Pending control request tracking - */ -interface PendingControlRequest { - resolve: (response: Record | null) => void; - reject: (error: Error) => void; - timeout: NodeJS.Timeout; - abortController: AbortController; -} - -/** - * Hook configuration for SDK initialization - */ -interface HookRegistration { - matcher: Record; - hookCallbackIds: string[]; -} - -/** - * Transport with input stream control (e.g., ProcessTransport) - */ -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 abortController: AbortController; - private pendingControlRequests: Map = - new Map(); - private sdkMcpTransports: Map = new Map(); - private initialized: Promise | null = null; - private closed = false; - private messageRouterStarted = false; - - // First result tracking for MCP servers - private firstResultReceivedPromise?: Promise; - private firstResultReceivedResolve?: () => void; - - // Hook callbacks tracking - private hookCallbacks = new Map< - string, - ( - input: unknown, - toolUseId: string | null, - options: { signal: AbortSignal }, - ) => Promise - >(); - private nextCallbackId = 0; - - // Single-turn mode flag - private readonly isSingleTurn: boolean; - - constructor(transport: Transport, options: CreateQueryOptions) { - this.transport = transport; - this.options = options; - this.sessionId = randomUUID(); - this.inputStream = new Stream(); - // Use provided abortController or create a new one - this.abortController = options.abortController ?? new AbortController(); - this.isSingleTurn = options.singleTurn ?? false; - - // Setup first result tracking - this.firstResultReceivedPromise = new Promise((resolve) => { - this.firstResultReceivedResolve = resolve; - }); - - // Handle abort signal if controller is provided and already aborted or will be aborted - if (this.abortController.signal.aborted) { - // Already aborted - set error immediately - this.inputStream.setError(new AbortError('Query aborted by user')); - this.close().catch((err) => { - console.error('[Query] Error during abort cleanup:', err); - }); - } else { - // Listen for abort events on the controller's signal - this.abortController.signal.addEventListener('abort', () => { - // Set abort error on the stream before closing - this.inputStream.setError(new AbortError('Query aborted by user')); - this.close().catch((err) => { - console.error('[Query] Error during abort cleanup:', err); - }); - }); - } - - // Initialize immediately (no lazy initialization) - this.initialize(); - } - - /** - * Initialize the query - */ - private initialize(): void { - // Initialize asynchronously but don't block constructor - // Capture the promise immediately so other code can wait for initialization - this.initialized = (async () => { - try { - // Start transport - await this.transport.start(); - - // Setup SDK-embedded MCP servers - await this.setupSdkMcpServers(); - - // Prepare hooks configuration - 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, - }; - }); - } - } - } - - // Start message router in background - this.startMessageRouter(); - - // Send initialize control request - 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, - }); - - // Note: Single-turn prompts are sent directly via transport in createQuery.ts - } catch (error) { - console.error('[Query] Initialization error:', error); - throw error; - } - })(); - } - - /** - * Setup SDK-embedded MCP servers - */ - private async setupSdkMcpServers(): Promise { - if (!this.options.sdkMcpServers) { - return; - } - - // Validate no name conflicts with external MCP servers - 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 (dynamic to avoid circular deps) - const { SdkControlServerTransport } = await import( - '../mcp/SdkControlServerTransport.js' - ); - - // Create SdkControlServerTransport for each server - for (const [name, server] of Object.entries(this.options.sdkMcpServers)) { - // Create transport that sends MCP messages via control plane - const transport = new SdkControlServerTransport({ - serverName: name, - sendToQuery: async (message: JSONRPCMessage) => { - // Send MCP message to CLI via control request - await this.sendControlRequest(ControlRequestType.MCP_MESSAGE, { - server_name: name, - message, - }); - }, - }); - - // Start transport - await transport.start(); - - // Connect server to transport - await server.connect(transport); - - // Store transport for cleanup - this.sdkMcpTransports.set(name, transport); - } - } - - /** - * Start message router (background task) - */ - private startMessageRouter(): void { - if (this.messageRouterStarted) { - return; - } - - this.messageRouterStarted = true; - - // Route messages from transport to input stream - (async () => { - try { - for await (const message of this.transport.readMessages()) { - await this.routeMessage(message); - - // Stop if closed - if (this.closed) { - break; - } - } - - // Transport completed - check if aborted first - if (this.abortController.signal.aborted) { - this.inputStream.setError(new AbortError('Query aborted')); - } else { - this.inputStream.done(); - } - } catch (error) { - // Transport error - propagate to stream - this.inputStream.setError( - error instanceof Error ? error : new Error(String(error)), - ); - } - })().catch((err) => { - console.error('[Query] Message router error:', err); - this.inputStream.setError( - err instanceof Error ? err : new Error(String(err)), - ); - }); - } - - /** - * Route incoming message - */ - private async routeMessage(message: unknown): Promise { - // Check control messages first - if (isControlRequest(message)) { - // CLI asking SDK for something (permission, MCP message, hook callback) - await this.handleControlRequest(message); - return; - } - - if (isControlResponse(message)) { - // Response to SDK's control request - this.handleControlResponse(message); - return; - } - - if (isControlCancel(message)) { - // Cancel pending control request - this.handleControlCancelRequest(message); - return; - } - - // Check data messages - 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)) { - // Result message - trigger first result received - if (this.firstResultReceivedResolve) { - this.firstResultReceivedResolve(); - } - // In single-turn mode, automatically close input after receiving result - if (this.isSingleTurn && 'endInput' in this.transport) { - (this.transport as TransportWithEndInput).endInput(); - } - // Pass to user - this.inputStream.enqueue(message); - return; - } - - if ( - isCLIAssistantMessage(message) || - isCLIUserMessage(message) || - isCLIPartialAssistantMessage(message) - ) { - // Pass to user - this.inputStream.enqueue(message); - return; - } - - // Unknown message - log and pass through - 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; - - // Create abort controller for this 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}`, - ); - } - - // Send success response - await this.sendControlResponse(request_id, true, response); - } catch (error) { - // Send error response - 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 { - // Default: allow if no callback provided - if (!this.options.canUseTool) { - return { allowed: true }; - } - - try { - // Invoke callback with timeout - const timeoutMs = 30000; // 30 seconds - const timeoutPromise = new Promise((_, reject) => { - setTimeout( - () => reject(new Error('Permission callback timeout')), - timeoutMs, - ); - }); - - // Call with signal and suggestions - const result = await Promise.race([ - Promise.resolve( - this.options.canUseTool(toolName, toolInput, { - signal, - suggestions: permissionSuggestions, - }), - ), - timeoutPromise, - ]); - - // Support both boolean and object return values - if (typeof result === 'boolean') { - return { allowed: result }; - } - // Ensure result is a valid PermissionApproval - return result as PermissionApproval; - } catch (error) { - // Timeout or error → deny (fail-safe) - 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> { - // Get transport for this server - 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 - const isRequest = - 'method' in message && 'id' in message && message.id !== null; - - if (isRequest) { - // Request message - wait for response from MCP server - const response = await this.handleMcpRequest( - serverName, - message, - transport, - ); - return { mcp_response: response }; - } else { - // Notification or response - just route it - transport.handleMessage(message); - // Return acknowledgment for notifications - 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); // 30 seconds - - // Store message ID for matching - const messageId = 'id' in message ? message.id : null; - - // Hook into transport to capture response - const originalSend = transport.sendToQuery; - transport.sendToQuery = async (responseMessage: JSONRPCMessage) => { - if ('id' in responseMessage && responseMessage.id === messageId) { - clearTimeout(timeout); - // Restore original send - transport.sendToQuery = originalSend; - resolve(responseMessage); - } - // Forward to original handler - return originalSend(responseMessage); - }; - - // Send message to MCP server - 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; - } - - // Clear timeout - clearTimeout(pending.timeout); - this.pendingControlRequests.delete(request_id); - - // Resolve or reject based on response type - if (payload.subtype === 'success') { - pending.resolve(payload.response as Record | null); - } else { - // Extract error message from error field (can be string or object) - 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) { - // Abort the request - pending.abortController.abort(); - - // Clean up - clearTimeout(pending.timeout); - this.pendingControlRequests.delete(request_id); - - // Reject with abort error - 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}`); - } - - // Invoke callback with signal - 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, // Type assertion needed for dynamic subtype - ...data, - } as CLIControlRequest['request'], - }; - - // Create promise for response - 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); // 30 seconds - - this.pendingControlRequests.set(requestId, { - resolve, - reject, - timeout, - abortController, - }); - }, - ); - - // Send request - this.transport.write(serializeJsonLine(request)); - - // Wait for response - 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; // Already closed - } - - this.closed = true; - - // Cancel pending control requests - for (const pending of this.pendingControlRequests.values()) { - pending.abortController.abort(); - clearTimeout(pending.timeout); - } - this.pendingControlRequests.clear(); - - // Clear hook callbacks - this.hookCallbacks.clear(); - - // Close transport - await this.transport.close(); - - // Complete input stream - check if aborted first - if (!this.inputStream.hasError) { - if (this.abortController.signal.aborted) { - this.inputStream.setError(new AbortError('Query aborted')); - } else { - this.inputStream.done(); - } - } - - // Cleanup MCP transports - for (const transport of this.sdkMcpTransports.values()) { - try { - await transport.close(); - } catch (error) { - console.error('[Query] Error closing MCP transport:', error); - } - } - this.sdkMcpTransports.clear(); - } - - /** - * AsyncIterator protocol: next() - */ - async next(): Promise> { - // Wait for initialization to complete if still in progress - if (this.initialized) { - await this.initialized; - } - - return this.inputStream.next(); - } - - /** - * AsyncIterable protocol: Symbol.asyncIterator - */ - [Symbol.asyncIterator](): AsyncIterator { - return this; - } - - /** - * 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 - if (this.initialized) { - await this.initialized; - } - - // Send all messages - for await (const message of messages) { - // Check if aborted - 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 - if ( - !this.isSingleTurn && - this.sdkMcpTransports.size > 0 && - this.firstResultReceivedPromise - ) { - const STREAM_CLOSE_TIMEOUT = 10000; // 10 seconds - - await Promise.race([ - this.firstResultReceivedPromise, - new Promise((resolve) => { - setTimeout(() => { - resolve(); - }, STREAM_CLOSE_TIMEOUT); - }), - ]); - } - - this.endInput(); - } catch (error) { - // Check if aborted - if so, set abort error on stream - if (this.abortController.signal.aborted) { - console.log('[Query] Aborted during input streaming'); - this.inputStream.setError( - 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 deleted file mode 100644 index 0a94ac51..00000000 --- a/packages/sdk/typescript/src/query/createQuery.ts +++ /dev/null @@ -1,206 +0,0 @@ -/** - * 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 deleted file mode 100644 index 30a0a63e..00000000 --- a/packages/sdk/typescript/src/transport/ProcessTransport.ts +++ /dev/null @@ -1,480 +0,0 @@ -/** - * 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'; - -/** - * Exit listener type - */ -type ExitListener = { - callback: (error?: Error) => void; - handler: (code: number | null, signal: NodeJS.Signals | null) => void; -}; - -/** - * ProcessTransport implementation - * - * Lifecycle: - * 1. Created with options - * 2. start() spawns subprocess - * 3. isReady becomes true - * 4. write() sends messages to stdin - * 5. readMessages() yields messages from stdout - * 6. close() gracefully shuts down (SIGTERM → SIGKILL) - * 7. waitForExit() resolves when cleanup complete - */ -export class ProcessTransport implements Transport { - private childProcess: ChildProcess | null = null; - private options: TransportOptions; - private _isReady = false; - private _exitError: Error | null = null; - private exitPromise: Promise | null = null; - private exitResolve: (() => void) | null = null; - private cleanupCallbacks: Array<() => void> = []; - private closed = false; - private abortController: AbortController | null = null; - private exitListeners: ExitListener[] = []; - - constructor(options: TransportOptions) { - this.options = options; - } - - /** - * Start the transport by spawning CLI subprocess - */ - async start(): Promise { - if (this.childProcess) { - return; // Already started - } - - // Use provided abortController or create a new one - this.abortController = - this.options.abortController ?? new AbortController(); - - // Check if already aborted - 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 }; - - // Setup abort handler - this.abortController.signal.addEventListener('abort', () => { - this.logForDebugging('Transport aborted by user'); - this._exitError = new AbortError('Operation aborted by user'); - this._isReady = false; - void this.close(); - }); - - // Create exit promise - this.exitPromise = new Promise((resolve) => { - this.exitResolve = resolve; - }); - - try { - // Detect executable type and prepare spawn info - 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(' ')}`, - ); - - // Spawn CLI subprocess with appropriate command and args - this.childProcess = spawn( - spawnInfo.command, - [...spawnInfo.args, ...cliArgs], - { - cwd, - env, - stdio: ['pipe', 'pipe', stderrMode], - // Use AbortController signal - signal: this.abortController.signal, - }, - ); - - // Handle stderr for debugging - if (this.options.debug || this.options.stderr) { - this.childProcess.stderr?.on('data', (data) => { - this.logForDebugging(data.toString()); - }); - } - - // Setup event handlers - this.setupEventHandlers(); - - // Mark as ready - this._isReady = true; - - // Register cleanup on parent process exit - this.registerParentExitHandler(); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - throw new Error(`Failed to spawn CLI process: ${errorMessage}`); - } - } - - /** - * Setup event handlers for child process - */ - private setupEventHandlers(): void { - if (!this.childProcess) return; - - // Handle process errors - this.childProcess.on('error', (error) => { - 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._isReady = false; - this.logForDebugging(`Process error: ${error.message}`); - }); - - // Handle process exit - this.childProcess.on('exit', (code, signal) => { - this._isReady = false; - - // Check if aborted - if (this.abortController?.signal.aborted) { - this._exitError = new AbortError('CLI process aborted by user'); - } else if (code !== null && code !== 0 && !this.closed) { - this._exitError = new Error(`CLI process exited with code ${code}`); - this.logForDebugging(`Process exited with code ${code}`); - } else if (signal && !this.closed) { - this._exitError = new Error(`CLI process killed by signal ${signal}`); - this.logForDebugging(`Process killed by signal ${signal}`); - } - - // Notify exit listeners - const error = this._exitError; - for (const listener of this.exitListeners) { - try { - listener.callback(error || undefined); - } catch (err) { - this.logForDebugging(`Exit listener error: ${err}`); - } - } - - // Resolve exit promise - if (this.exitResolve) { - this.exitResolve(); - } - }); - } - - /** - * Register cleanup handler on parent process exit - */ - private registerParentExitHandler(): void { - const cleanup = (): void => { - if (this.childProcess && !this.childProcess.killed) { - this.childProcess.kill('SIGKILL'); - } - }; - - process.on('exit', cleanup); - this.cleanupCallbacks.push(() => { - process.off('exit', cleanup); - }); - } - - /** - * Build CLI command-line arguments - */ - private buildCliArguments(): string[] { - const args: string[] = [ - '--input-format', - 'stream-json', - '--output-format', - 'stream-json', - ]; - - // Add model if specified - if (this.options.model) { - args.push('--model', this.options.model); - } - - // Add permission mode if specified - if (this.options.permissionMode) { - args.push('--approval-mode', this.options.permissionMode); - } - - // Add MCP servers if specified - if (this.options.mcpServers) { - for (const [name, config] of Object.entries(this.options.mcpServers)) { - args.push('--mcp-server', JSON.stringify({ name, ...config })); - } - } - - return args; - } - - /** - * Close the transport gracefully - */ - async close(): Promise { - if (this.closed || !this.childProcess) { - return; // Already closed or never started - } - - this.closed = true; - this._isReady = false; - - // Clean up exit listeners - for (const { handler } of this.exitListeners) { - this.childProcess?.off('exit', handler); - } - this.exitListeners = []; - - // Send SIGTERM for graceful shutdown - this.childProcess.kill('SIGTERM'); - - // Wait 5 seconds, then force kill if still alive - const forceKillTimeout = setTimeout(() => { - if (this.childProcess && !this.childProcess.killed) { - this.childProcess.kill('SIGKILL'); - } - }, 5000); - - // Wait for exit - await this.waitForExit(); - - // Clear timeout - clearTimeout(forceKillTimeout); - - // Run cleanup callbacks - for (const callback of this.cleanupCallbacks) { - callback(); - } - this.cleanupCallbacks = []; - } - - /** - * Wait for process to fully exit - */ - async waitForExit(): Promise { - if (this.exitPromise) { - await this.exitPromise; - } - } - - /** - * Write a message to stdin - */ - write(message: string): void { - // Check abort status - if (this.abortController?.signal.aborted) { - throw new AbortError('Cannot write: operation aborted'); - } - - if (!this._isReady || !this.childProcess?.stdin) { - 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.childProcess.stdin.write(message + '\n', (err) => { - if (err) { - throw new Error(`Failed to write to stdin: ${err.message}`); - } - }); - if (!written && process.env['DEBUG_SDK']) { - this.logForDebugging( - '[ProcessTransport] Write buffer full, data queued', - ); - } - } catch (error) { - this._isReady = false; - throw new Error( - `Failed to write to stdin: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - - /** - * Read messages from stdout as async generator - */ - async *readMessages(): AsyncGenerator { - if (!this.childProcess?.stdout) { - throw new Error('Cannot read messages: process not started'); - } - - const rl = readline.createInterface({ - input: this.childProcess.stdout, - crlfDelay: Infinity, - }); - - try { - // Use JSON Lines parser - for await (const message of parseJsonLinesStream( - rl, - 'ProcessTransport', - )) { - yield message; - } - - await this.waitForExit(); - } finally { - rl.close(); - } - } - - /** - * Check if transport is ready for I/O - */ - get isReady(): boolean { - return this._isReady; - } - - /** - * Get exit error (if any) - */ - get exitError(): Error | null { - return this._exitError; - } - - /** - * Get child process (for testing) - */ - get process(): ChildProcess | null { - return this.childProcess; - } - - /** - * Get path to qwen executable - */ - get pathToQwenExecutable(): string { - return this.options.pathToQwenExecutable; - } - - /** - * Get CLI arguments - */ - get cliArgs(): readonly string[] { - return this.buildCliArguments(); - } - - /** - * Get working directory - */ - get cwd(): string { - return this.options.cwd ?? process.cwd(); - } - - /** - * Register a callback to be invoked when the process exits - * - * @param callback - Function to call on exit, receives error if abnormal exit - * @returns Cleanup function to remove the listener - */ - onExit(callback: (error?: Error) => void): () => void { - if (!this.childProcess) { - return () => {}; // No-op if process not started - } - - const handler = (code: number | null, signal: NodeJS.Signals | null) => { - let error: Error | undefined; - - if (this.abortController?.signal.aborted) { - error = new AbortError('Process aborted by user'); - } else if (code !== null && code !== 0) { - error = new Error(`Process exited with code ${code}`); - } else if (signal) { - error = new Error(`Process killed by signal ${signal}`); - } - - callback(error); - }; - - this.childProcess.on('exit', handler); - this.exitListeners.push({ callback, handler }); - - // Return cleanup function - return () => { - if (this.childProcess) { - this.childProcess.off('exit', handler); - } - const index = this.exitListeners.findIndex((l) => l.handler === handler); - if (index !== -1) { - this.exitListeners.splice(index, 1); - } - }; - } - - /** - * End input stream (close stdin) - * Useful when you want to signal no more input will be sent - */ - endInput(): void { - if (this.childProcess?.stdin) { - this.childProcess.stdin.end(); - } - } - - /** - * Get direct access to stdin stream - * Use with caution - prefer write() method for normal use - * - * @returns Writable stream for stdin, or undefined if not available - */ - getInputStream(): Writable | undefined { - return this.childProcess?.stdin || undefined; - } - - /** - * Get direct access to stdout stream - * Use with caution - prefer readMessages() for normal use - * - * @returns Readable stream for stdout, or undefined if not available - */ - getOutputStream(): Readable | undefined { - return this.childProcess?.stdout || undefined; - } - - /** - * Log message for debugging (if debug enabled) - */ - 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 deleted file mode 100644 index caff806c..00000000 --- a/packages/sdk/typescript/src/transport/Transport.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * 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. - */ -export interface Transport { - /** - * Initialize and start the transport. - * - * For ProcessTransport: spawns CLI subprocess - * For HttpTransport: establishes HTTP connection - * For WebSocketTransport: opens WebSocket connection - * - * Must be called before write() or readMessages(). - * - * @throws Error if transport cannot be started - */ - start(): Promise; - - /** - * 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 deleted file mode 100644 index 7e270c31..00000000 --- a/packages/sdk/typescript/src/types/config.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * 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 deleted file mode 100644 index b2634d3c..00000000 --- a/packages/sdk/typescript/src/types/controlRequests.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * @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 deleted file mode 100644 index 137893cd..00000000 --- a/packages/sdk/typescript/src/types/errors.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * 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 deleted file mode 100644 index 53a8bfc9..00000000 --- a/packages/sdk/typescript/src/types/mcp.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * 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 deleted file mode 100644 index 723f69db..00000000 --- a/packages/sdk/typescript/src/types/protocol.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Protocol types for SDK-CLI communication - * - * Re-exports protocol types from CLI package to ensure SDK and CLI use identical types. - */ - -export type { - ContentBlock, - TextBlock, - ThinkingBlock, - ToolUseBlock, - ToolResultBlock, - CLIUserMessage, - CLIAssistantMessage, - CLISystemMessage, - CLIResultMessage, - CLIPartialAssistantMessage, - CLIMessage, - PermissionMode, - PermissionSuggestion, - PermissionApproval, - HookRegistration, - CLIControlInterruptRequest, - CLIControlPermissionRequest, - CLIControlInitializeRequest, - CLIControlSetPermissionModeRequest, - CLIHookCallbackRequest, - CLIControlMcpMessageRequest, - CLIControlSetModelRequest, - CLIControlMcpStatusRequest, - CLIControlSupportedCommandsRequest, - ControlRequestPayload, - CLIControlRequest, - ControlResponse, - ControlErrorResponse, - CLIControlResponse, - ControlCancelRequest, - ControlMessage, -} from '@qwen-code/qwen-code/protocol'; - -export { - isCLIUserMessage, - isCLIAssistantMessage, - isCLISystemMessage, - isCLIResultMessage, - isCLIPartialAssistantMessage, - isControlRequest, - isControlResponse, - isControlCancel, -} from '@qwen-code/qwen-code/protocol'; diff --git a/packages/sdk/typescript/src/utils/Stream.ts b/packages/sdk/typescript/src/utils/Stream.ts deleted file mode 100644 index cead9d7a..00000000 --- a/packages/sdk/typescript/src/utils/Stream.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * Async iterable queue for streaming messages between producer and consumer. - */ - -export class Stream implements AsyncIterable { - private queue: T[] = []; - private isDone = false; - private streamError: Error | null = null; - private readResolve: ((result: IteratorResult) => void) | null = null; - private readReject: ((error: Error) => void) | null = null; - private maxQueueSize: number = 10000; // Prevent memory leaks - private droppedMessageCount = 0; - - /** - * Add a value to the stream. - */ - enqueue(value: T): void { - if (this.isDone) { - throw new Error('Cannot enqueue to completed stream'); - } - if (this.streamError) { - throw new Error('Cannot enqueue to stream with error'); - } - - // Fast path: consumer is waiting - if (this.readResolve) { - this.readResolve({ value, done: false }); - this.readResolve = null; - this.readReject = null; - } else { - // Slow path: buffer in queue (with size limit) - if (this.queue.length >= this.maxQueueSize) { - // Drop oldest message to prevent memory leak - this.queue.shift(); - this.droppedMessageCount++; - - // Warn about dropped messages (but don't throw) - if (this.droppedMessageCount % 100 === 1) { - console.warn( - `[Stream] Queue full, dropped ${this.droppedMessageCount} messages. ` + - `Consumer may be too slow.`, - ); - } - } - - this.queue.push(value); - } - } - - /** - * Mark the stream as complete. - */ - done(): void { - if (this.isDone) { - return; // Already done, no-op - } - - this.isDone = true; - - // If consumer is waiting, signal completion - if (this.readResolve) { - this.readResolve({ done: true, value: undefined }); - this.readResolve = null; - this.readReject = null; - } - } - - /** - * Set an error state for the stream. - */ - setError(err: Error): void { - if (this.streamError) { - return; // Already has error, no-op - } - - this.streamError = err; - - // If consumer is waiting, reject immediately - if (this.readReject) { - this.readReject(err); - this.readResolve = null; - this.readReject = null; - } - } - - /** - * Get the next value from the stream. - */ - async next(): Promise> { - // Fast path: queue has values - if (this.queue.length > 0) { - const value = this.queue.shift()!; - return { value, done: false }; - } - - // Error path: stream has error - if (this.streamError) { - throw this.streamError; - } - - // Done path: stream is complete - if (this.isDone) { - return { done: true, value: undefined }; - } - - // Wait path: no values yet, wait for producer - return new Promise>((resolve, reject) => { - this.readResolve = resolve; - this.readReject = reject; - // Producer will call resolve/reject when value/done/error occurs - }); - } - - /** - * Enable async iteration with `for await` syntax. - */ - [Symbol.asyncIterator](): AsyncIterator { - return this; - } - - get queueSize(): number { - return this.queue.length; - } - - get isComplete(): boolean { - return this.isDone; - } - - get hasError(): boolean { - return this.streamError !== null; - } - - get droppedMessages(): number { - return this.droppedMessageCount; - } - - /** - * Set the maximum queue size. - */ - setMaxQueueSize(size: number): void { - if (size < 1) { - throw new Error('Max queue size must be at least 1'); - } - this.maxQueueSize = size; - } - - get maxSize(): number { - return this.maxQueueSize; - } - - /** - * Clear all buffered messages. Use only during cleanup or error recovery. - */ - clear(): void { - this.queue = []; - } -} diff --git a/packages/sdk/typescript/src/utils/cliPath.ts b/packages/sdk/typescript/src/utils/cliPath.ts deleted file mode 100644 index ff368067..00000000 --- a/packages/sdk/typescript/src/utils/cliPath.ts +++ /dev/null @@ -1,438 +0,0 @@ -/** - * 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 deleted file mode 100644 index 65fd2ff6..00000000 --- a/packages/sdk/typescript/src/utils/jsonLines.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * 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 deleted file mode 100644 index ebd9a74a..00000000 --- a/packages/sdk/typescript/test/e2e/abort-and-lifecycle.test.ts +++ /dev/null @@ -1,489 +0,0 @@ -/** - * 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 immediate abort', - async () => { - const controller = new AbortController(); - - const q = query({ - prompt: 'Write a very long essay', - options: { - ...SHARED_TEST_OPTIONS, - abortController: controller, - debug: true, - }, - }); - - // Abort immediately - setTimeout(() => { - controller.abort(); - console.log('Aborted!'); - }, 300); - - try { - for await (const _message of q) { - // May receive some messages before abort - } - } catch (error) { - 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 handle AbortError correctly', - async () => { - const controller = new AbortController(); - - const q = query({ - prompt: 'Write a long story', - options: { - ...SHARED_TEST_OPTIONS, - abortController: controller, - debug: false, - }, - }); - - // Abort after short delay - setTimeout(() => controller.abort(), 500); - - try { - for await (const _message of q) { - // May receive some messages - } - } catch (error) { - expect(isAbortError(error)).toBe(true); - expect(error instanceof AbortError).toBe(true); - } 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 deleted file mode 100644 index 558e4120..00000000 --- a/packages/sdk/typescript/test/e2e/basic-usage.test.ts +++ /dev/null @@ -1,515 +0,0 @@ -/** - * 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 deleted file mode 100644 index 6d23fc16..00000000 --- a/packages/sdk/typescript/test/e2e/multi-turn.test.ts +++ /dev/null @@ -1,517 +0,0 @@ -/** - * 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 deleted file mode 100644 index 04129d6e..00000000 --- a/packages/sdk/typescript/test/e2e/simple-query.test.ts +++ /dev/null @@ -1,744 +0,0 @@ -/** - * 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: false, - }, - }); - - 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 deleted file mode 100644 index c470f884..00000000 --- a/packages/sdk/typescript/test/unit/ProcessTransport.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -/** - * 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 on start()', async () => { - // Should call child_process.spawn - expect(true).toBe(true); // Placeholder - }); - - it('should set isReady to true after successful start', async () => { - // isReady should be true after start() 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 deleted file mode 100644 index 5ceeee4b..00000000 --- a/packages/sdk/typescript/test/unit/Query.test.ts +++ /dev/null @@ -1,284 +0,0 @@ -/** - * 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 deleted file mode 100644 index 6bfd61a0..00000000 --- a/packages/sdk/typescript/test/unit/SdkControlServerTransport.test.ts +++ /dev/null @@ -1,259 +0,0 @@ -/** - * Unit tests for SdkControlServerTransport - * - * Tests MCP message proxying between MCP Server and Query's control plane. - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { SdkControlServerTransport } from '../../src/mcp/SdkControlServerTransport.js'; - -describe('SdkControlServerTransport', () => { - let sendToQuery: ReturnType; - let transport: SdkControlServerTransport; - - beforeEach(() => { - sendToQuery = vi.fn().mockResolvedValue({ result: 'success' }); - transport = new SdkControlServerTransport({ - serverName: 'test-server', - sendToQuery, - }); - }); - - describe('Lifecycle', () => { - it('should start successfully', async () => { - await transport.start(); - expect(transport.isStarted()).toBe(true); - }); - - it('should close successfully', async () => { - await transport.start(); - await transport.close(); - expect(transport.isStarted()).toBe(false); - }); - - it('should handle close callback', async () => { - const onclose = vi.fn(); - transport.onclose = onclose; - - await transport.start(); - await transport.close(); - - expect(onclose).toHaveBeenCalled(); - }); - }); - - describe('Message Sending', () => { - it('should send message to Query', async () => { - await transport.start(); - - const message = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/list', - params: {}, - }; - - await transport.send(message); - - expect(sendToQuery).toHaveBeenCalledWith(message); - }); - - it('should throw error when sending before start', async () => { - const message = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/list', - }; - - await expect(transport.send(message)).rejects.toThrow('not started'); - }); - - it('should handle send errors', async () => { - const error = new Error('Network error'); - sendToQuery.mockRejectedValue(error); - - const onerror = vi.fn(); - transport.onerror = onerror; - - await transport.start(); - - const message = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/list', - }; - - await expect(transport.send(message)).rejects.toThrow('Network error'); - expect(onerror).toHaveBeenCalledWith(error); - }); - }); - - describe('Message Receiving', () => { - it('should deliver message to MCP Server via onmessage', async () => { - const onmessage = vi.fn(); - transport.onmessage = onmessage; - - await transport.start(); - - const message = { - jsonrpc: '2.0' as const, - id: 1, - result: { tools: [] }, - }; - - transport.handleMessage(message); - - expect(onmessage).toHaveBeenCalledWith(message); - }); - - it('should warn when receiving message without onmessage handler', async () => { - const consoleWarnSpy = vi - .spyOn(console, 'warn') - .mockImplementation(() => {}); - - await transport.start(); - - const message = { - jsonrpc: '2.0' as const, - id: 1, - result: {}, - }; - - transport.handleMessage(message); - - expect(consoleWarnSpy).toHaveBeenCalled(); - - consoleWarnSpy.mockRestore(); - }); - - it('should warn when receiving message for closed transport', async () => { - const consoleWarnSpy = vi - .spyOn(console, 'warn') - .mockImplementation(() => {}); - const onmessage = vi.fn(); - transport.onmessage = onmessage; - - await transport.start(); - await transport.close(); - - const message = { - jsonrpc: '2.0' as const, - id: 1, - result: {}, - }; - - transport.handleMessage(message); - - expect(consoleWarnSpy).toHaveBeenCalled(); - expect(onmessage).not.toHaveBeenCalled(); - - consoleWarnSpy.mockRestore(); - }); - }); - - describe('Error Handling', () => { - it('should deliver error to MCP Server via onerror', async () => { - const onerror = vi.fn(); - transport.onerror = onerror; - - await transport.start(); - - const error = new Error('Test error'); - transport.handleError(error); - - expect(onerror).toHaveBeenCalledWith(error); - }); - - it('should log error when no onerror handler set', async () => { - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - - await transport.start(); - - const error = new Error('Test error'); - transport.handleError(error); - - expect(consoleErrorSpy).toHaveBeenCalled(); - - consoleErrorSpy.mockRestore(); - }); - }); - - describe('Server Name', () => { - it('should return server name', () => { - expect(transport.getServerName()).toBe('test-server'); - }); - }); - - describe('Bidirectional Communication', () => { - it('should support full message round-trip', async () => { - const onmessage = vi.fn(); - transport.onmessage = onmessage; - - await transport.start(); - - // Send request from MCP Server to CLI - const request = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/list', - params: {}, - }; - - await transport.send(request); - expect(sendToQuery).toHaveBeenCalledWith(request); - - // Receive response from CLI to MCP Server - const response = { - jsonrpc: '2.0' as const, - id: 1, - result: { - tools: [ - { - name: 'test_tool', - description: 'A test tool', - inputSchema: { type: 'object' }, - }, - ], - }, - }; - - transport.handleMessage(response); - expect(onmessage).toHaveBeenCalledWith(response); - }); - - it('should handle multiple messages in sequence', async () => { - const onmessage = vi.fn(); - transport.onmessage = onmessage; - - await transport.start(); - - // Send multiple requests - for (let i = 0; i < 5; i++) { - const message = { - jsonrpc: '2.0' as const, - id: i, - method: 'test', - }; - - await transport.send(message); - } - - expect(sendToQuery).toHaveBeenCalledTimes(5); - - // Receive multiple responses - for (let i = 0; i < 5; i++) { - const message = { - jsonrpc: '2.0' as const, - id: i, - result: {}, - }; - - transport.handleMessage(message); - } - - expect(onmessage).toHaveBeenCalledTimes(5); - }); - }); -}); diff --git a/packages/sdk/typescript/test/unit/Stream.test.ts b/packages/sdk/typescript/test/unit/Stream.test.ts deleted file mode 100644 index adae9b69..00000000 --- a/packages/sdk/typescript/test/unit/Stream.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -/** - * 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 (idempotent)', async () => { - stream.done(); - stream.done(); - stream.done(); - - const result = await stream.next(); - expect(result).toEqual({ done: true, value: undefined }); - }); - - it('should throw when enqueuing to completed stream', () => { - stream.done(); - expect(() => stream.enqueue('value')).toThrow( - 'Cannot enqueue to completed stream', - ); - }); - - 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.setError(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.setError(error); - - await expect(stream.next()).rejects.toThrow('Test error'); - }); - - it('should throw when enqueuing to stream with error', () => { - stream.setError(new Error('Error')); - expect(() => stream.enqueue('value')).toThrow( - 'Cannot enqueue to stream with error', - ); - }); - - it('should only store first error (idempotent)', async () => { - const firstError = new Error('First'); - const secondError = new Error('Second'); - - stream.setError(firstError); - stream.setError(secondError); - - await expect(stream.next()).rejects.toThrow('First'); - }); - - it('should deliver buffered values before throwing error', async () => { - stream.enqueue('buffered'); - stream.setError(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 queue size correctly', () => { - expect(stream.queueSize).toBe(0); - - stream.enqueue('a'); - expect(stream.queueSize).toBe(1); - - stream.enqueue('b'); - expect(stream.queueSize).toBe(2); - }); - - it('should track completion state', () => { - expect(stream.isComplete).toBe(false); - stream.done(); - expect(stream.isComplete).toBe(true); - }); - - it('should track error state', () => { - expect(stream.hasError).toBe(false); - stream.setError(new Error('Test')); - expect(stream.hasError).toBe(true); - }); - }); - - 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 iterations = 100; - const values: number[] = []; - - const producer = async (): Promise => { - for (let i = 0; i < iterations; i++) { - stream.enqueue(i); - await new Promise((resolve) => setImmediate(resolve)); - } - stream.done(); - }; - - const consumer = async (): Promise => { - for await (const value of stream) { - 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' }); - }); - }); -}); diff --git a/packages/sdk/typescript/test/unit/cliPath.test.ts b/packages/sdk/typescript/test/unit/cliPath.test.ts deleted file mode 100644 index 55a87b92..00000000 --- a/packages/sdk/typescript/test/unit/cliPath.test.ts +++ /dev/null @@ -1,668 +0,0 @@ -/** - * Unit tests for CLI path utilities - * Tests executable detection, parsing, and spawn info preparation - */ - -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import * 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 deleted file mode 100644 index e608ba7b..00000000 --- a/packages/sdk/typescript/test/unit/createSdkMcpServer.test.ts +++ /dev/null @@ -1,350 +0,0 @@ -/** - * 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 deleted file mode 100644 index 5fa97a43..00000000 --- a/packages/sdk/typescript/tsconfig.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "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 deleted file mode 100644 index f3909ea4..00000000 --- a/packages/sdk/typescript/vitest.config.ts +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index f9602bac..00000000 --- a/vitest.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - projects: [ - 'packages/cli', - 'packages/core', - 'packages/vscode-ide-companion', - 'packages/sdk/typescript', - 'integration-tests', - 'scripts', - ], - }, -});