From 1aa282c0542a9895d62f30cf3902b8e5b62ceb82 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Thu, 30 Oct 2025 18:18:41 +0800 Subject: [PATCH] feat: create draft framework for SDK-support CLI --- .gitignore | 1 + packages/cli/package.json | 11 + packages/cli/src/config/config.ts | 17 +- packages/cli/src/gemini.tsx | 76 +- packages/cli/src/nonInteractiveStreamJson.ts | 732 +++++++++++++++ packages/cli/src/services/MessageRouter.ts | 111 +++ packages/cli/src/services/StreamJson.ts | 633 +++++++++++++ .../src/services/control/ControlContext.ts | 73 ++ .../src/services/control/ControlDispatcher.ts | 351 +++++++ .../control/controllers/baseController.ts | 180 ++++ .../control/controllers/hookController.ts | 56 ++ .../control/controllers/mcpController.ts | 287 ++++++ .../controllers/permissionController.ts | 480 ++++++++++ .../control/controllers/systemController.ts | 292 ++++++ packages/cli/src/types/protocol.ts | 485 ++++++++++ packages/core/src/config/config.ts | 21 +- packages/core/src/output/types.ts | 6 + packages/sdk/typescript/package.json | 69 ++ packages/sdk/typescript/src/index.ts | 108 +++ .../src/mcp/SdkControlServerTransport.ts | 153 +++ .../typescript/src/mcp/createSdkMcpServer.ts | 177 ++++ packages/sdk/typescript/src/mcp/formatters.ts | 247 +++++ packages/sdk/typescript/src/mcp/tool.ts | 140 +++ packages/sdk/typescript/src/query/Query.ts | 882 ++++++++++++++++++ .../sdk/typescript/src/query/createQuery.ts | 185 ++++ .../src/transport/ProcessTransport.ts | 496 ++++++++++ .../sdk/typescript/src/transport/Transport.ts | 102 ++ packages/sdk/typescript/src/types/config.ts | 145 +++ .../typescript/src/types/controlRequests.ts | 50 + packages/sdk/typescript/src/types/errors.ts | 27 + packages/sdk/typescript/src/types/mcp.ts | 32 + packages/sdk/typescript/src/types/protocol.ts | 50 + packages/sdk/typescript/src/utils/Stream.ts | 157 ++++ packages/sdk/typescript/src/utils/cliPath.ts | 438 +++++++++ .../sdk/typescript/src/utils/jsonLines.ts | 137 +++ .../test/e2e/abort-and-lifecycle.test.ts | 486 ++++++++++ .../typescript/test/e2e/basic-usage.test.ts | 521 +++++++++++ .../typescript/test/e2e/multi-turn.test.ts | 519 +++++++++++ .../typescript/test/e2e/simple-query.test.ts | 744 +++++++++++++++ .../test/unit/ProcessTransport.test.ts | 207 ++++ .../sdk/typescript/test/unit/Query.test.ts | 284 ++++++ .../unit/SdkControlServerTransport.test.ts | 259 +++++ .../sdk/typescript/test/unit/Stream.test.ts | 247 +++++ .../sdk/typescript/test/unit/cliPath.test.ts | 668 +++++++++++++ .../test/unit/createSdkMcpServer.test.ts | 350 +++++++ packages/sdk/typescript/tsconfig.json | 41 + packages/sdk/typescript/vitest.config.ts | 36 + vitest.config.ts | 1 + 48 files changed, 11714 insertions(+), 56 deletions(-) create mode 100644 packages/cli/src/nonInteractiveStreamJson.ts create mode 100644 packages/cli/src/services/MessageRouter.ts create mode 100644 packages/cli/src/services/StreamJson.ts create mode 100644 packages/cli/src/services/control/ControlContext.ts create mode 100644 packages/cli/src/services/control/ControlDispatcher.ts create mode 100644 packages/cli/src/services/control/controllers/baseController.ts create mode 100644 packages/cli/src/services/control/controllers/hookController.ts create mode 100644 packages/cli/src/services/control/controllers/mcpController.ts create mode 100644 packages/cli/src/services/control/controllers/permissionController.ts create mode 100644 packages/cli/src/services/control/controllers/systemController.ts create mode 100644 packages/cli/src/types/protocol.ts create mode 100644 packages/sdk/typescript/package.json create mode 100644 packages/sdk/typescript/src/index.ts create mode 100644 packages/sdk/typescript/src/mcp/SdkControlServerTransport.ts create mode 100644 packages/sdk/typescript/src/mcp/createSdkMcpServer.ts create mode 100644 packages/sdk/typescript/src/mcp/formatters.ts create mode 100644 packages/sdk/typescript/src/mcp/tool.ts create mode 100644 packages/sdk/typescript/src/query/Query.ts create mode 100644 packages/sdk/typescript/src/query/createQuery.ts create mode 100644 packages/sdk/typescript/src/transport/ProcessTransport.ts create mode 100644 packages/sdk/typescript/src/transport/Transport.ts create mode 100644 packages/sdk/typescript/src/types/config.ts create mode 100644 packages/sdk/typescript/src/types/controlRequests.ts create mode 100644 packages/sdk/typescript/src/types/errors.ts create mode 100644 packages/sdk/typescript/src/types/mcp.ts create mode 100644 packages/sdk/typescript/src/types/protocol.ts create mode 100644 packages/sdk/typescript/src/utils/Stream.ts create mode 100644 packages/sdk/typescript/src/utils/cliPath.ts create mode 100644 packages/sdk/typescript/src/utils/jsonLines.ts create mode 100644 packages/sdk/typescript/test/e2e/abort-and-lifecycle.test.ts create mode 100644 packages/sdk/typescript/test/e2e/basic-usage.test.ts create mode 100644 packages/sdk/typescript/test/e2e/multi-turn.test.ts create mode 100644 packages/sdk/typescript/test/e2e/simple-query.test.ts create mode 100644 packages/sdk/typescript/test/unit/ProcessTransport.test.ts create mode 100644 packages/sdk/typescript/test/unit/Query.test.ts create mode 100644 packages/sdk/typescript/test/unit/SdkControlServerTransport.test.ts create mode 100644 packages/sdk/typescript/test/unit/Stream.test.ts create mode 100644 packages/sdk/typescript/test/unit/cliPath.test.ts create mode 100644 packages/sdk/typescript/test/unit/createSdkMcpServer.test.ts create mode 100644 packages/sdk/typescript/tsconfig.json create mode 100644 packages/sdk/typescript/vitest.config.ts diff --git a/.gitignore b/.gitignore index 2c3156b9..2484d9e8 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,4 @@ gha-creds-*.json # Log files patch_output.log +QWEN.md diff --git a/packages/cli/package.json b/packages/cli/package.json index 21dcc391..ca10a1d0 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -8,9 +8,20 @@ }, "type": "module", "main": "dist/index.js", + "types": "dist/index.d.ts", "bin": { "qwen": "dist/index.js" }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./protocol": { + "types": "./dist/src/types/protocol.d.ts", + "import": "./dist/src/types/protocol.js" + } + }, "scripts": { "build": "node ../../scripts/build_package.js", "start": "node dist/index.js", diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 739402d3..72852d98 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -23,6 +23,7 @@ import { WriteFileTool, resolveTelemetrySettings, FatalConfigError, + InputFormat, OutputFormat, } from '@qwen-code/qwen-code-core'; import type { Settings } from './settings.js'; @@ -126,12 +127,12 @@ export interface CliArgs { function normalizeOutputFormat( format: string | OutputFormat | undefined, -): OutputFormat | 'stream-json' | undefined { +): OutputFormat | undefined { if (!format) { return undefined; } if (format === 'stream-json') { - return 'stream-json'; + return OutputFormat.STREAM_JSON; } if (format === 'json' || format === OutputFormat.JSON) { return OutputFormat.JSON; @@ -210,8 +211,7 @@ export async function parseArguments(settings: Settings): Promise { }) .option('proxy', { type: 'string', - description: - 'Proxy for Qwen Code, like schema://user:password@host:port', + description: 'Proxy for Qwen Code, like schema://user:password@host:port', }) .deprecateOption( 'proxy', @@ -601,8 +601,8 @@ export async function loadCliConfig( let mcpServers = mergeMcpServers(settings, activeExtensions); const question = argv.promptInteractive || argv.prompt || ''; - const inputFormat = - (argv.inputFormat as 'text' | 'stream-json' | undefined) ?? 'text'; + const inputFormat: InputFormat = + (argv.inputFormat as InputFormat | undefined) ?? InputFormat.TEXT; const argvOutputFormat = normalizeOutputFormat( argv.outputFormat as string | OutputFormat | undefined, ); @@ -610,8 +610,9 @@ export async function loadCliConfig( const outputFormat = argvOutputFormat ?? settingsOutputFormat ?? OutputFormat.TEXT; const outputSettingsFormat: OutputFormat = - outputFormat === 'stream-json' - ? settingsOutputFormat && settingsOutputFormat !== 'stream-json' + outputFormat === OutputFormat.STREAM_JSON + ? settingsOutputFormat && + settingsOutputFormat !== OutputFormat.STREAM_JSON ? settingsOutputFormat : OutputFormat.TEXT : (outputFormat as OutputFormat); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 99a9f732..401e9123 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -4,59 +4,59 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; -import { render } from 'ink'; -import { AppContainer } from './ui/AppContainer.js'; -import { loadCliConfig, parseArguments } from './config/config.js'; -import * as cliConfig from './config/config.js'; -import { readStdin } from './utils/readStdin.js'; -import { basename } from 'node:path'; -import v8 from 'node:v8'; -import os from 'node:os'; -import dns from 'node:dns'; -import { randomUUID } from 'node:crypto'; -import { start_sandbox } from './utils/sandbox.js'; -import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js'; -import { loadSettings, migrateDeprecatedSettings } from './config/settings.js'; -import { themeManager } from './ui/themes/theme-manager.js'; -import { getStartupWarnings } from './utils/startupWarnings.js'; -import { getUserStartupWarnings } from './utils/userStartupWarnings.js'; -import { ConsolePatcher } from './ui/utils/ConsolePatcher.js'; -import { runNonInteractive } from './nonInteractiveCli.js'; -import { runStreamJsonSession } from './streamJson/session.js'; -import { ExtensionStorage, loadExtensions } from './config/extension.js'; -import { - cleanupCheckpoints, - registerCleanup, - runExitCleanup, -} from './utils/cleanup.js'; -import { getCliVersion } from './utils/version.js'; import type { Config } from '@qwen-code/qwen-code-core'; import { AuthType, getOauthClient, logUserPrompt, } from '@qwen-code/qwen-code-core'; +import { render } from 'ink'; +import { randomUUID } from 'node:crypto'; +import dns from 'node:dns'; +import os from 'node:os'; +import { basename } from 'node:path'; +import v8 from 'node:v8'; +import React from 'react'; +import { validateAuthMethod } from './config/auth.js'; +import * as cliConfig from './config/config.js'; +import { loadCliConfig, parseArguments } from './config/config.js'; +import { ExtensionStorage, loadExtensions } from './config/extension.js'; +import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js'; +import { loadSettings, migrateDeprecatedSettings } from './config/settings.js'; import { initializeApp, type InitializationResult, } from './core/initializer.js'; -import { validateAuthMethod } from './config/auth.js'; +import { runNonInteractive } from './nonInteractiveCli.js'; +import { runStreamJsonSession } from './streamJson/session.js'; +import { AppContainer } from './ui/AppContainer.js'; import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js'; +import { KeypressProvider } from './ui/contexts/KeypressContext.js'; +import { SessionStatsProvider } from './ui/contexts/SessionContext.js'; import { SettingsContext } from './ui/contexts/SettingsContext.js'; +import { VimModeProvider } from './ui/contexts/VimModeContext.js'; +import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js'; +import { themeManager } from './ui/themes/theme-manager.js'; +import { ConsolePatcher } from './ui/utils/ConsolePatcher.js'; import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js'; import { checkForUpdates } from './ui/utils/updateCheck.js'; -import { handleAutoUpdate } from './utils/handleAutoUpdate.js'; -import { computeWindowTitle } from './utils/windowTitle.js'; -import { SessionStatsProvider } from './ui/contexts/SessionContext.js'; -import { VimModeProvider } from './ui/contexts/VimModeContext.js'; -import { KeypressProvider } from './ui/contexts/KeypressContext.js'; -import { appEvents, AppEvent } from './utils/events.js'; -import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js'; import { - relaunchOnExitCode, + cleanupCheckpoints, + registerCleanup, + runExitCleanup, +} from './utils/cleanup.js'; +import { AppEvent, appEvents } from './utils/events.js'; +import { handleAutoUpdate } from './utils/handleAutoUpdate.js'; +import { readStdin } from './utils/readStdin.js'; +import { relaunchAppInChildProcess, + relaunchOnExitCode, } from './utils/relaunch.js'; +import { start_sandbox } from './utils/sandbox.js'; +import { getStartupWarnings } from './utils/startupWarnings.js'; +import { getUserStartupWarnings } from './utils/userStartupWarnings.js'; +import { getCliVersion } from './utils/version.js'; +import { computeWindowTitle } from './utils/windowTitle.js'; import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; export function validateDnsResolutionOrder( @@ -107,9 +107,9 @@ function getNodeMemoryArgs(isDebugMode: boolean): string[] { return []; } -import { runZedIntegration } from './zed-integration/zedIntegration.js'; -import { loadSandboxConfig } from './config/sandboxConfig.js'; import { ExtensionEnablementManager } from './config/extensions/extensionEnablement.js'; +import { loadSandboxConfig } from './config/sandboxConfig.js'; +import { runZedIntegration } from './zed-integration/zedIntegration.js'; export function setupUnhandledRejectionHandler() { let unhandledRejectionOccurred = false; diff --git a/packages/cli/src/nonInteractiveStreamJson.ts b/packages/cli/src/nonInteractiveStreamJson.ts new file mode 100644 index 00000000..e49f845d --- /dev/null +++ b/packages/cli/src/nonInteractiveStreamJson.ts @@ -0,0 +1,732 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Stream JSON Runner with Session State Machine + * + * Handles stream-json input/output format with: + * - Initialize handshake + * - Message routing (control vs user messages) + * - FIFO user message queue + * - Sequential message processing + * - Graceful shutdown + */ + +import type { Config, ToolCallRequestInfo } from '@qwen-code/qwen-code-core'; +import { GeminiEventType, executeToolCall } from '@qwen-code/qwen-code-core'; +import type { Part, PartListUnion } from '@google/genai'; +import { ConsolePatcher } from './ui/utils/ConsolePatcher.js'; +import { handleAtCommand } from './ui/hooks/atCommandProcessor.js'; +import { StreamJson, extractUserMessageText } from './services/StreamJson.js'; +import { MessageRouter, type RoutedMessage } from './services/MessageRouter.js'; +import { ControlContext } from './services/control/ControlContext.js'; +import { ControlDispatcher } from './services/control/ControlDispatcher.js'; +import type { + CLIMessage, + CLIUserMessage, + CLIResultMessage, + ToolResultBlock, + CLIControlRequest, + CLIControlResponse, + ControlCancelRequest, +} from './types/protocol.js'; + +const SESSION_STATE = { + INITIALIZING: 'initializing', + IDLE: 'idle', + PROCESSING_QUERY: 'processing_query', + SHUTTING_DOWN: 'shutting_down', +} as const; + +type SessionState = (typeof SESSION_STATE)[keyof typeof SESSION_STATE]; + +/** + * Session Manager + * + * Manages the session lifecycle and message processing state machine. + */ +class SessionManager { + private state: SessionState = SESSION_STATE.INITIALIZING; + private userMessageQueue: CLIUserMessage[] = []; + private abortController: AbortController; + private config: Config; + private sessionId: string; + private promptIdCounter: number = 0; + private streamJson: StreamJson; + private router: MessageRouter; + private controlContext: ControlContext; + private dispatcher: ControlDispatcher; + private consolePatcher: ConsolePatcher; + private debugMode: boolean; + + constructor(config: Config) { + this.config = config; + this.sessionId = config.getSessionId(); + this.debugMode = config.getDebugMode(); + this.abortController = new AbortController(); + + this.consolePatcher = new ConsolePatcher({ + stderr: true, + debugMode: this.debugMode, + }); + + this.streamJson = new StreamJson({ + input: process.stdin, + output: process.stdout, + }); + + this.router = new MessageRouter(config); + + // Create control context + this.controlContext = new ControlContext({ + config, + streamJson: this.streamJson, + sessionId: this.sessionId, + abortSignal: this.abortController.signal, + permissionMode: this.config.getApprovalMode(), + onInterrupt: () => this.handleInterrupt(), + }); + + // Create dispatcher with context (creates controllers internally) + this.dispatcher = new ControlDispatcher(this.controlContext); + + // Setup signal handlers for graceful shutdown + this.setupSignalHandlers(); + } + + /** + * Get next prompt ID + */ + private getNextPromptId(): string { + this.promptIdCounter++; + return `${this.sessionId}########${this.promptIdCounter}`; + } + + /** + * Main entry point - run the session + */ + async run(): Promise { + try { + this.consolePatcher.patch(); + + if (this.debugMode) { + console.error('[SessionManager] Starting session', this.sessionId); + } + + // Main message processing loop + for await (const message of this.streamJson.readMessages()) { + if (this.abortController.signal.aborted) { + break; + } + + await this.processMessage(message); + + // Check if we should exit + if (this.state === SESSION_STATE.SHUTTING_DOWN) { + break; + } + } + + // Stream closed, shutdown + await this.shutdown(); + } catch (error) { + if (this.debugMode) { + console.error('[SessionManager] Error:', error); + } + await this.shutdown(); + throw error; + } finally { + this.consolePatcher.cleanup(); + } + } + + /** + * Process a single message from the stream + */ + private async processMessage( + message: + | CLIMessage + | CLIControlRequest + | CLIControlResponse + | ControlCancelRequest, + ): Promise { + const routed = this.router.route(message); + + if (this.debugMode) { + console.error( + `[SessionManager] State: ${this.state}, Message type: ${routed.type}`, + ); + } + + switch (this.state) { + case SESSION_STATE.INITIALIZING: + await this.handleInitializingState(routed); + break; + + case SESSION_STATE.IDLE: + await this.handleIdleState(routed); + break; + + case SESSION_STATE.PROCESSING_QUERY: + await this.handleProcessingState(routed); + break; + + case SESSION_STATE.SHUTTING_DOWN: + // Ignore all messages during shutdown + break; + + default: { + // Exhaustive check + const _exhaustiveCheck: never = this.state; + if (this.debugMode) { + console.error('[SessionManager] Unknown state:', _exhaustiveCheck); + } + break; + } + } + } + + /** + * Handle messages in initializing state + */ + private async handleInitializingState(routed: RoutedMessage): Promise { + if (routed.type === 'control_request') { + const request = routed.message as CLIControlRequest; + if (request.request.subtype === 'initialize') { + await this.dispatcher.dispatch(request); + this.state = SESSION_STATE.IDLE; + if (this.debugMode) { + console.error('[SessionManager] Initialized, transitioning to idle'); + } + } else { + if (this.debugMode) { + console.error( + '[SessionManager] Ignoring non-initialize control request during initialization', + ); + } + } + } else { + if (this.debugMode) { + console.error( + '[SessionManager] Ignoring non-control message during initialization', + ); + } + } + } + + /** + * Handle messages in idle state + */ + private async handleIdleState(routed: RoutedMessage): Promise { + if (routed.type === 'control_request') { + const request = routed.message as CLIControlRequest; + await this.dispatcher.dispatch(request); + // Stay in idle state + } else if (routed.type === 'control_response') { + const response = routed.message as CLIControlResponse; + this.dispatcher.handleControlResponse(response); + // Stay in idle state + } else if (routed.type === 'control_cancel') { + // Handle cancellation + const cancelRequest = routed.message as ControlCancelRequest; + this.dispatcher.handleCancel(cancelRequest.request_id); + } else if (routed.type === 'user') { + const userMessage = routed.message as CLIUserMessage; + this.userMessageQueue.push(userMessage); + // Start processing queue + await this.processUserMessageQueue(); + } else { + if (this.debugMode) { + console.error( + '[SessionManager] Ignoring message type in idle state:', + routed.type, + ); + } + } + } + + /** + * Handle messages in processing state + */ + private async handleProcessingState(routed: RoutedMessage): Promise { + if (routed.type === 'control_request') { + const request = routed.message as CLIControlRequest; + await this.dispatcher.dispatch(request); + // Continue processing + } else if (routed.type === 'control_response') { + const response = routed.message as CLIControlResponse; + this.dispatcher.handleControlResponse(response); + // Continue processing + } else if (routed.type === 'user') { + // Enqueue for later + const userMessage = routed.message as CLIUserMessage; + this.userMessageQueue.push(userMessage); + if (this.debugMode) { + console.error( + '[SessionManager] Enqueued user message during processing', + ); + } + } else { + if (this.debugMode) { + console.error( + '[SessionManager] Ignoring message type during processing:', + routed.type, + ); + } + } + } + + /** + * Process user message queue (FIFO) + */ + private async processUserMessageQueue(): Promise { + while ( + this.userMessageQueue.length > 0 && + !this.abortController.signal.aborted + ) { + this.state = SESSION_STATE.PROCESSING_QUERY; + const userMessage = this.userMessageQueue.shift()!; + + try { + await this.processUserMessage(userMessage); + } catch (error) { + if (this.debugMode) { + console.error( + '[SessionManager] Error processing user message:', + error, + ); + } + // Send error result + this.sendErrorResult( + error instanceof Error ? error.message : String(error), + ); + } + } + + // Return to idle after processing queue + if ( + !this.abortController.signal.aborted && + this.state === SESSION_STATE.PROCESSING_QUERY + ) { + this.state = SESSION_STATE.IDLE; + if (this.debugMode) { + console.error('[SessionManager] Queue processed, returning to idle'); + } + } + } + + /** + * Process a single user message + */ + private async processUserMessage(userMessage: CLIUserMessage): Promise { + // Extract text from user message + const texts = extractUserMessageText(userMessage); + if (texts.length === 0) { + if (this.debugMode) { + console.error('[SessionManager] No text content in user message'); + } + return; + } + + const input = texts.join('\n'); + + // Handle @command preprocessing + const { processedQuery, shouldProceed } = await handleAtCommand({ + query: input, + config: this.config, + addItem: (_item, _timestamp) => 0, + onDebugMessage: () => {}, + messageId: Date.now(), + signal: this.abortController.signal, + }); + + if (!shouldProceed || !processedQuery) { + this.sendErrorResult('Error processing input'); + return; + } + + // Execute query via Gemini client + await this.executeQuery(processedQuery); + } + + /** + * Execute query through Gemini client + */ + private async executeQuery(query: PartListUnion): Promise { + const geminiClient = this.config.getGeminiClient(); + const promptId = this.getNextPromptId(); + let accumulatedContent = ''; + let turnCount = 0; + const maxTurns = this.config.getMaxSessionTurns(); + + try { + let currentMessages: PartListUnion = query; + + while (true) { + turnCount++; + + if (maxTurns >= 0 && turnCount > maxTurns) { + this.sendErrorResult(`Reached max turns: ${turnCount}`); + return; + } + + const toolCallRequests: ToolCallRequestInfo[] = []; + + // Create assistant message builder for this turn + const assistantBuilder = this.streamJson.createAssistantBuilder( + this.sessionId, + null, // parent_tool_use_id + this.config.getModel(), + false, // includePartialMessages - TODO: make this configurable + ); + + // Stream response from Gemini + const responseStream = geminiClient.sendMessageStream( + currentMessages, + this.abortController.signal, + promptId, + ); + + for await (const event of responseStream) { + if (this.abortController.signal.aborted) { + return; + } + + switch (event.type) { + case GeminiEventType.Content: + // Process content through builder + assistantBuilder.processEvent(event); + accumulatedContent += event.value; + break; + + case GeminiEventType.Thought: + // Process thinking through builder + assistantBuilder.processEvent(event); + break; + + case GeminiEventType.ToolCallRequest: + // Process tool call through builder + assistantBuilder.processEvent(event); + toolCallRequests.push(event.value); + break; + + case GeminiEventType.Finished: { + // Finalize and send assistant message + assistantBuilder.processEvent(event); + const assistantMessage = assistantBuilder.finalize(); + this.streamJson.send(assistantMessage); + break; + } + + case GeminiEventType.Error: + this.sendErrorResult(event.value.error.message); + return; + + case GeminiEventType.MaxSessionTurns: + this.sendErrorResult('Max session turns exceeded'); + return; + + case GeminiEventType.SessionTokenLimitExceeded: + this.sendErrorResult(event.value.message); + return; + + default: + // Ignore other event types + break; + } + } + + // Handle tool calls - execute tools and continue conversation + if (toolCallRequests.length > 0) { + // Execute tools and prepare response + const toolResponseParts: Part[] = []; + for (const requestInfo of toolCallRequests) { + // Check permissions before executing tool + const permissionResult = + await this.checkToolPermission(requestInfo); + if (!permissionResult.allowed) { + if (this.debugMode) { + console.error( + `[SessionManager] Tool execution denied: ${requestInfo.name} - ${permissionResult.message}`, + ); + } + // Skip this tool and continue with others + continue; + } + + // Use updated args if provided by permission check + const finalRequestInfo = permissionResult.updatedArgs + ? { ...requestInfo, args: permissionResult.updatedArgs } + : requestInfo; + + // Execute tool + const toolResponse = await executeToolCall( + this.config, + finalRequestInfo, + this.abortController.signal, + { + onToolCallsUpdate: + this.dispatcher.permissionController.getToolCallUpdateCallback(), + }, + ); + + if (toolResponse.responseParts) { + toolResponseParts.push(...toolResponse.responseParts); + } + + if (toolResponse.error && this.debugMode) { + console.error( + `[SessionManager] Tool execution error: ${requestInfo.name}`, + toolResponse.error, + ); + } + } + + // Send tool results as user message + this.sendToolResultsAsUserMessage( + toolCallRequests, + toolResponseParts, + ); + + // Continue with tool responses for next turn + currentMessages = toolResponseParts; + } else { + // No more tool calls, done + this.sendSuccessResult(accumulatedContent); + return; + } + } + } catch (error) { + if (this.debugMode) { + console.error('[SessionManager] Query execution error:', error); + } + this.sendErrorResult( + error instanceof Error ? error.message : String(error), + ); + } + } + + /** + * Check tool permission before execution + */ + private async checkToolPermission(requestInfo: ToolCallRequestInfo): Promise<{ + allowed: boolean; + message?: string; + updatedArgs?: Record; + }> { + try { + // Get permission controller from dispatcher + const permissionController = this.dispatcher.permissionController; + if (!permissionController) { + // Fallback: allow if no permission controller available + if (this.debugMode) { + console.error( + '[SessionManager] No permission controller available, allowing tool execution', + ); + } + return { allowed: true }; + } + + // Check permission using the controller + return await permissionController.shouldAllowTool(requestInfo); + } catch (error) { + if (this.debugMode) { + console.error( + '[SessionManager] Error checking tool permission:', + error, + ); + } + // Fail safe: deny on error + return { + allowed: false, + message: + error instanceof Error + ? `Permission check failed: ${error.message}` + : 'Permission check failed', + }; + } + } + + /** + * Send tool results as user message + */ + private sendToolResultsAsUserMessage( + toolCallRequests: ToolCallRequestInfo[], + toolResponseParts: Part[], + ): void { + // Create a map of function response names to call IDs + const callIdMap = new Map(); + for (const request of toolCallRequests) { + callIdMap.set(request.name, request.callId); + } + + // Convert Part[] to ToolResultBlock[] + const toolResultBlocks: ToolResultBlock[] = []; + + for (const part of toolResponseParts) { + if (part.functionResponse) { + const functionName = part.functionResponse.name; + if (!functionName) continue; + + const callId = callIdMap.get(functionName) || functionName; + + // Extract content from function response + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let content: string | Array> | null = null; + if (part.functionResponse.response?.['output']) { + const output = part.functionResponse.response['output']; + if (typeof output === 'string') { + content = output; + } else if (Array.isArray(output)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + content = output as Array>; + } else { + content = JSON.stringify(output); + } + } + + const toolResultBlock: ToolResultBlock = { + type: 'tool_result', + tool_use_id: callId, + content, + is_error: false, + }; + toolResultBlocks.push(toolResultBlock); + } + } + + // Only send if we have tool result blocks + if (toolResultBlocks.length > 0) { + const userMessage: CLIUserMessage = { + type: 'user', + uuid: `${this.sessionId}-tool-result-${Date.now()}`, + session_id: this.sessionId, + message: { + role: 'user', + content: toolResultBlocks, + }, + parent_tool_use_id: null, + }; + this.streamJson.send(userMessage); + } + } + + /** + * Send success result + */ + private sendSuccessResult(message: string): void { + const result: CLIResultMessage = { + type: 'result', + subtype: 'success', + uuid: `${this.sessionId}-result-${Date.now()}`, + session_id: this.sessionId, + is_error: false, + duration_ms: 0, + duration_api_ms: 0, + num_turns: 0, + result: message || 'Query completed successfully', + total_cost_usd: 0, + usage: { + input_tokens: 0, + output_tokens: 0, + }, + permission_denials: [], + }; + this.streamJson.send(result); + } + + /** + * Send error result + */ + private sendErrorResult(_errorMessage: string): void { + // Note: CLIResultMessageError doesn't have a result field + // Error details would need to be logged separately or the type needs updating + const result: CLIResultMessage = { + type: 'result', + subtype: 'error_during_execution', + uuid: `${this.sessionId}-result-${Date.now()}`, + session_id: this.sessionId, + is_error: true, + duration_ms: 0, + duration_api_ms: 0, + num_turns: 0, + total_cost_usd: 0, + usage: { + input_tokens: 0, + output_tokens: 0, + }, + permission_denials: [], + }; + this.streamJson.send(result); + } + + /** + * Handle interrupt control request + */ + private handleInterrupt(): void { + if (this.debugMode) { + console.error('[SessionManager] Interrupt requested'); + } + // Abort current query if processing + if (this.state === SESSION_STATE.PROCESSING_QUERY) { + this.abortController.abort(); + this.abortController = new AbortController(); // Create new controller for next query + } + } + + /** + * Setup signal handlers for graceful shutdown + */ + private setupSignalHandlers(): void { + const shutdownHandler = () => { + if (this.debugMode) { + console.error('[SessionManager] Shutdown signal received'); + } + this.abortController.abort(); + this.state = SESSION_STATE.SHUTTING_DOWN; + }; + + process.on('SIGINT', shutdownHandler); + process.on('SIGTERM', shutdownHandler); + + // Handle stdin close - let the session complete naturally + // instead of immediately aborting when input stream ends + process.stdin.on('close', () => { + if (this.debugMode) { + console.error( + '[SessionManager] stdin closed - waiting for generation to complete', + ); + } + // Don't abort immediately - let the message processing loop exit naturally + // when streamJson.readMessages() completes, which will trigger shutdown() + }); + } + + /** + * Shutdown session and cleanup resources + */ + private async shutdown(): Promise { + if (this.debugMode) { + console.error('[SessionManager] Shutting down'); + } + + this.state = SESSION_STATE.SHUTTING_DOWN; + this.dispatcher.shutdown(); + this.streamJson.cleanup(); + } +} + +/** + * Entry point for stream-json mode + */ +export async function runNonInteractiveStreamJson( + config: Config, + _input: string, + _promptId: string, +): Promise { + const manager = new SessionManager(config); + await manager.run(); +} diff --git a/packages/cli/src/services/MessageRouter.ts b/packages/cli/src/services/MessageRouter.ts new file mode 100644 index 00000000..e68cb6fe --- /dev/null +++ b/packages/cli/src/services/MessageRouter.ts @@ -0,0 +1,111 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Message Router + * + * Routes incoming messages to appropriate handlers based on message type. + * Provides classification for control messages vs data messages. + */ + +import type { Config } from '@qwen-code/qwen-code-core'; +import type { + CLIMessage, + CLIControlRequest, + CLIControlResponse, + ControlCancelRequest, +} from '../types/protocol.js'; +import { + isCLIUserMessage, + isCLIAssistantMessage, + isCLISystemMessage, + isCLIResultMessage, + isCLIPartialAssistantMessage, + isControlRequest, + isControlResponse, + isControlCancel, +} from '../types/protocol.js'; + +export type MessageType = + | 'control_request' + | 'control_response' + | 'control_cancel' + | 'user' + | 'assistant' + | 'system' + | 'result' + | 'stream_event' + | 'unknown'; + +export interface RoutedMessage { + type: MessageType; + message: + | CLIMessage + | CLIControlRequest + | CLIControlResponse + | ControlCancelRequest; +} + +/** + * Message Router + * + * Classifies incoming messages and routes them to appropriate handlers. + */ +export class MessageRouter { + private debugMode: boolean; + + constructor(config: Config) { + this.debugMode = config.getDebugMode(); + } + + /** + * Route a message to the appropriate handler based on its type + */ + route( + message: + | CLIMessage + | CLIControlRequest + | CLIControlResponse + | ControlCancelRequest, + ): RoutedMessage { + // Check control messages first + if (isControlRequest(message)) { + return { type: 'control_request', message }; + } + if (isControlResponse(message)) { + return { type: 'control_response', message }; + } + if (isControlCancel(message)) { + return { type: 'control_cancel', message }; + } + + // Check data messages + if (isCLIUserMessage(message)) { + return { type: 'user', message }; + } + if (isCLIAssistantMessage(message)) { + return { type: 'assistant', message }; + } + if (isCLISystemMessage(message)) { + return { type: 'system', message }; + } + if (isCLIResultMessage(message)) { + return { type: 'result', message }; + } + if (isCLIPartialAssistantMessage(message)) { + return { type: 'stream_event', message }; + } + + // Unknown message type + if (this.debugMode) { + console.error( + '[MessageRouter] Unknown message type:', + JSON.stringify(message, null, 2), + ); + } + return { type: 'unknown', message }; + } +} diff --git a/packages/cli/src/services/StreamJson.ts b/packages/cli/src/services/StreamJson.ts new file mode 100644 index 00000000..4f86fb4d --- /dev/null +++ b/packages/cli/src/services/StreamJson.ts @@ -0,0 +1,633 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/** + * Transport-agnostic JSON Lines protocol handler for bidirectional communication. + * Works with any Readable/Writable stream (stdin/stdout, HTTP, WebSocket, etc.) + */ + +import * as readline from 'node:readline'; +import { randomUUID } from 'node:crypto'; +import type { Readable, Writable } from 'node:stream'; +import type { + CLIMessage, + CLIUserMessage, + ContentBlock, + CLIControlRequest, + CLIControlResponse, + ControlCancelRequest, + CLIAssistantMessage, + CLIPartialAssistantMessage, + StreamEvent, + TextBlock, + ThinkingBlock, + ToolUseBlock, + Usage, +} from '../types/protocol.js'; +import type { ServerGeminiStreamEvent } from '@qwen-code/qwen-code-core'; +import { GeminiEventType } from '@qwen-code/qwen-code-core'; + +/** + * ============================================================================ + * Stream JSON I/O Class + * ============================================================================ + */ + +export interface StreamJsonOptions { + input?: Readable; + output?: Writable; + onError?: (error: Error) => void; +} + +/** + * Handles JSON Lines communication over arbitrary streams. + */ +export class StreamJson { + private input: Readable; + private output: Writable; + private rl?: readline.Interface; + private onError?: (error: Error) => void; + + constructor(options: StreamJsonOptions = {}) { + this.input = options.input || process.stdin; + this.output = options.output || process.stdout; + this.onError = options.onError; + } + + /** + * Read messages from input stream as async generator. + */ + async *readMessages(): AsyncGenerator< + CLIMessage | CLIControlRequest | CLIControlResponse | ControlCancelRequest, + void, + unknown + > { + this.rl = readline.createInterface({ + input: this.input, + crlfDelay: Infinity, + terminal: false, + }); + + try { + for await (const line of this.rl) { + if (!line.trim()) { + continue; // Skip empty lines + } + + try { + const message = JSON.parse(line); + yield message; + } catch (error) { + console.error( + '[StreamJson] Failed to parse message:', + line.substring(0, 100), + error, + ); + // Continue processing (skip bad line) + } + } + } finally { + // Cleanup on exit + } + } + + /** + * Send a message to output stream. + */ + send(message: CLIMessage | CLIControlResponse | CLIControlRequest): void { + try { + const line = JSON.stringify(message) + '\n'; + this.output.write(line); + } catch (error) { + console.error('[StreamJson] Failed to send message:', error); + if (this.onError) { + this.onError(error as Error); + } + } + } + + /** + * Create an assistant message builder. + */ + createAssistantBuilder( + sessionId: string, + parentToolUseId: string | null, + model: string, + includePartialMessages: boolean = false, + ): AssistantMessageBuilder { + return new AssistantMessageBuilder({ + sessionId, + parentToolUseId, + includePartialMessages, + model, + streamJson: this, + }); + } + + /** + * Cleanup resources. + */ + cleanup(): void { + if (this.rl) { + this.rl.close(); + this.rl = undefined; + } + } +} + +/** + * ============================================================================ + * Assistant Message Builder + * ============================================================================ + */ + +export interface AssistantMessageBuilderOptions { + sessionId: string; + parentToolUseId: string | null; + includePartialMessages: boolean; + model: string; + streamJson: StreamJson; +} + +/** + * Builds assistant messages from Gemini stream events. + * Accumulates content blocks and emits streaming events in real-time. + */ +export class AssistantMessageBuilder { + private sessionId: string; + private parentToolUseId: string | null; + private includePartialMessages: boolean; + private model: string; + private streamJson: StreamJson; + + private messageId: string; + private contentBlocks: ContentBlock[] = []; + private openBlocks = new Set(); + private messageStarted: boolean = false; + private finalized: boolean = false; + private usage: Usage | null = null; + + // Current block state + private currentBlockType: 'text' | 'thinking' | null = null; + private currentTextContent: string = ''; + private currentThinkingContent: string = ''; + private currentThinkingSignature: string = ''; + + constructor(options: AssistantMessageBuilderOptions) { + this.sessionId = options.sessionId; + this.parentToolUseId = options.parentToolUseId; + this.includePartialMessages = options.includePartialMessages; + this.model = options.model; + this.streamJson = options.streamJson; + this.messageId = randomUUID(); + } + + /** + * Process a Gemini stream event and update internal state. + */ + processEvent(event: ServerGeminiStreamEvent): void { + if (this.finalized) { + return; + } + + switch (event.type) { + case GeminiEventType.Content: + this.handleContentEvent(event.value); + break; + + case GeminiEventType.Thought: + this.handleThoughtEvent(event.value.subject, event.value.description); + break; + + case GeminiEventType.ToolCallRequest: + this.handleToolCallRequest(event.value); + break; + + case GeminiEventType.Finished: + this.finalizePendingBlocks(); + break; + + default: + // Ignore other event types + break; + } + } + + /** + * Handle text content event. + */ + private handleContentEvent(content: string): void { + if (!content) { + return; + } + + this.ensureMessageStarted(); + + // If we're not in a text block, switch to text mode + if (this.currentBlockType !== 'text') { + this.switchToTextBlock(); + } + + // Accumulate content + this.currentTextContent += content; + + // Emit delta for streaming updates + const currentIndex = this.contentBlocks.length; + this.emitContentBlockDelta(currentIndex, { + type: 'text_delta', + text: content, + }); + } + + /** + * Handle thinking event. + */ + private handleThoughtEvent(subject: string, description: string): void { + this.ensureMessageStarted(); + + const thinkingFragment = `${subject}: ${description}`; + + // If we're not in a thinking block, switch to thinking mode + if (this.currentBlockType !== 'thinking') { + this.switchToThinkingBlock(subject); + } + + // Accumulate thinking content + this.currentThinkingContent += thinkingFragment; + + // Emit delta for streaming updates + const currentIndex = this.contentBlocks.length; + this.emitContentBlockDelta(currentIndex, { + type: 'thinking_delta', + thinking: thinkingFragment, + }); + } + + /** + * Handle tool call request. + */ + private handleToolCallRequest(request: any): void { + this.ensureMessageStarted(); + + // Finalize any open blocks first + this.finalizePendingBlocks(); + + // Create and add tool use block + const index = this.contentBlocks.length; + const toolUseBlock: ToolUseBlock = { + type: 'tool_use', + id: request.callId, + name: request.name, + input: request.args, + }; + + this.contentBlocks.push(toolUseBlock); + this.openBlock(index, toolUseBlock); + this.closeBlock(index); + } + + /** + * Finalize any pending content blocks. + */ + private finalizePendingBlocks(): void { + if (this.currentBlockType === 'text' && this.currentTextContent) { + this.finalizeTextBlock(); + } else if ( + this.currentBlockType === 'thinking' && + this.currentThinkingContent + ) { + this.finalizeThinkingBlock(); + } + } + + /** + * Switch to text block mode. + */ + private switchToTextBlock(): void { + this.finalizePendingBlocks(); + + this.currentBlockType = 'text'; + this.currentTextContent = ''; + + const index = this.contentBlocks.length; + const textBlock: TextBlock = { + type: 'text', + text: '', + }; + + this.openBlock(index, textBlock); + } + + /** + * Switch to thinking block mode. + */ + private switchToThinkingBlock(signature: string): void { + this.finalizePendingBlocks(); + + this.currentBlockType = 'thinking'; + this.currentThinkingContent = ''; + this.currentThinkingSignature = signature; + + const index = this.contentBlocks.length; + const thinkingBlock: ThinkingBlock = { + type: 'thinking', + thinking: '', + signature, + }; + + this.openBlock(index, thinkingBlock); + } + + /** + * Finalize current text block. + */ + private finalizeTextBlock(): void { + if (!this.currentTextContent) { + return; + } + + const index = this.contentBlocks.length; + const textBlock: TextBlock = { + type: 'text', + text: this.currentTextContent, + }; + this.contentBlocks.push(textBlock); + this.closeBlock(index); + + this.currentBlockType = null; + this.currentTextContent = ''; + } + + /** + * Finalize current thinking block. + */ + private finalizeThinkingBlock(): void { + if (!this.currentThinkingContent) { + return; + } + + const index = this.contentBlocks.length; + const thinkingBlock: ThinkingBlock = { + type: 'thinking', + thinking: this.currentThinkingContent, + signature: this.currentThinkingSignature, + }; + this.contentBlocks.push(thinkingBlock); + this.closeBlock(index); + + this.currentBlockType = null; + this.currentThinkingContent = ''; + this.currentThinkingSignature = ''; + } + + /** + * Set usage information for the final message. + */ + setUsage(usage: Usage): void { + this.usage = usage; + } + + /** + * Build and return the final assistant message. + */ + finalize(): CLIAssistantMessage { + if (this.finalized) { + return this.buildFinalMessage(); + } + + this.finalized = true; + + // Finalize any pending blocks + this.finalizePendingBlocks(); + + // Close all open blocks in order + const orderedOpenBlocks = [...this.openBlocks].sort((a, b) => a - b); + for (const index of orderedOpenBlocks) { + this.closeBlock(index); + } + + // Emit message stop event + if (this.messageStarted) { + this.emitMessageStop(); + } + + return this.buildFinalMessage(); + } + + /** + * Build the final message structure. + */ + private buildFinalMessage(): CLIAssistantMessage { + return { + type: 'assistant', + uuid: this.messageId, + session_id: this.sessionId, + parent_tool_use_id: this.parentToolUseId, + message: { + id: this.messageId, + type: 'message', + role: 'assistant', + model: this.model, + content: this.contentBlocks, + stop_reason: null, + usage: this.usage || { + input_tokens: 0, + output_tokens: 0, + }, + }, + }; + } + + /** + * Ensure message has been started. + */ + private ensureMessageStarted(): void { + if (this.messageStarted) { + return; + } + this.messageStarted = true; + this.emitMessageStart(); + } + + /** + * Open a content block and emit start event. + */ + private openBlock(index: number, block: ContentBlock): void { + this.openBlocks.add(index); + this.emitContentBlockStart(index, block); + } + + /** + * Close a content block and emit stop event. + */ + private closeBlock(index: number): void { + if (!this.openBlocks.has(index)) { + return; + } + this.openBlocks.delete(index); + this.emitContentBlockStop(index); + } + + /** + * Emit message_start stream event. + */ + private emitMessageStart(): void { + const event: StreamEvent = { + type: 'message_start', + message: { + id: this.messageId, + role: 'assistant', + model: this.model, + }, + }; + this.emitStreamEvent(event); + } + + /** + * Emit content_block_start stream event. + */ + private emitContentBlockStart( + index: number, + contentBlock: ContentBlock, + ): void { + const event: StreamEvent = { + type: 'content_block_start', + index, + content_block: contentBlock, + }; + this.emitStreamEvent(event); + } + + /** + * Emit content_block_delta stream event. + */ + private emitContentBlockDelta( + index: number, + delta: { + type: 'text_delta' | 'thinking_delta'; + text?: string; + thinking?: string; + }, + ): void { + const event: StreamEvent = { + type: 'content_block_delta', + index, + delta, + }; + this.emitStreamEvent(event); + } + + /** + * Emit content_block_stop stream event + */ + private emitContentBlockStop(index: number): void { + const event: StreamEvent = { + type: 'content_block_stop', + index, + }; + this.emitStreamEvent(event); + } + + /** + * Emit message_stop stream event + */ + private emitMessageStop(): void { + const event: StreamEvent = { + type: 'message_stop', + }; + this.emitStreamEvent(event); + } + + /** + * Emit a stream event as SDKPartialAssistantMessage + */ + private emitStreamEvent(event: StreamEvent): void { + if (!this.includePartialMessages) return; + + const message: CLIPartialAssistantMessage = { + type: 'stream_event', + uuid: randomUUID(), + session_id: this.sessionId, + event, + parent_tool_use_id: this.parentToolUseId, + }; + this.streamJson.send(message); + } +} + +/** + * Extract text content from user message + */ +export function extractUserMessageText(message: CLIUserMessage): string[] { + const texts: string[] = []; + const content = message.message.content; + + if (typeof content === 'string') { + texts.push(content); + } else if (Array.isArray(content)) { + for (const block of content) { + if ('content' in block && typeof block.content === 'string') { + texts.push(block.content); + } + } + } + + return texts; +} + +/** + * Extract text content from content blocks + */ +export function extractTextFromContent(content: ContentBlock[]): string { + return content + .filter((block) => block.type === 'text') + .map((block) => (block.type === 'text' ? block.text : '')) + .join(''); +} + +/** + * Create text content block + */ +export function createTextContent(text: string): ContentBlock { + return { + type: 'text', + text, + }; +} + +/** + * Create tool use content block + */ +export function createToolUseContent( + id: string, + name: string, + input: Record, +): ContentBlock { + return { + type: 'tool_use', + id, + name, + input, + }; +} + +/** + * Create tool result content block + */ +export function createToolResultContent( + tool_use_id: string, + content: string | Array> | null, + is_error?: boolean, +): ContentBlock { + return { + type: 'tool_result', + tool_use_id, + content, + is_error, + }; +} diff --git a/packages/cli/src/services/control/ControlContext.ts b/packages/cli/src/services/control/ControlContext.ts new file mode 100644 index 00000000..3f6a5a4e --- /dev/null +++ b/packages/cli/src/services/control/ControlContext.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Control Context + * + * Shared context for control plane communication, providing access to + * session state, configuration, and I/O without prop drilling. + */ + +import type { Config, MCPServerConfig } from '@qwen-code/qwen-code-core'; +import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import type { StreamJson } from '../StreamJson.js'; +import type { PermissionMode } from '../../types/protocol.js'; + +/** + * Control Context interface + * + * Provides shared access to session-scoped resources and mutable state + * for all controllers. + */ +export interface IControlContext { + readonly config: Config; + readonly streamJson: StreamJson; + readonly sessionId: string; + readonly abortSignal: AbortSignal; + readonly debugMode: boolean; + + permissionMode: PermissionMode; + sdkMcpServers: Set; + mcpClients: Map; + + onInterrupt?: () => void; +} + +/** + * Control Context implementation + */ +export class ControlContext implements IControlContext { + readonly config: Config; + readonly streamJson: StreamJson; + readonly sessionId: string; + readonly abortSignal: AbortSignal; + readonly debugMode: boolean; + + permissionMode: PermissionMode; + sdkMcpServers: Set; + mcpClients: Map; + + onInterrupt?: () => void; + + constructor(options: { + config: Config; + streamJson: StreamJson; + sessionId: string; + abortSignal: AbortSignal; + permissionMode?: PermissionMode; + onInterrupt?: () => void; + }) { + this.config = options.config; + this.streamJson = options.streamJson; + this.sessionId = options.sessionId; + this.abortSignal = options.abortSignal; + this.debugMode = options.config.getDebugMode(); + this.permissionMode = options.permissionMode || 'default'; + this.sdkMcpServers = new Set(); + this.mcpClients = new Map(); + this.onInterrupt = options.onInterrupt; + } +} diff --git a/packages/cli/src/services/control/ControlDispatcher.ts b/packages/cli/src/services/control/ControlDispatcher.ts new file mode 100644 index 00000000..3270c6d1 --- /dev/null +++ b/packages/cli/src/services/control/ControlDispatcher.ts @@ -0,0 +1,351 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Control Dispatcher + * + * Routes control requests between SDK and CLI to appropriate controllers. + * Manages pending request registry and handles cancellation/cleanup. + * + * Controllers: + * - SystemController: initialize, interrupt, set_model, supported_commands + * - PermissionController: can_use_tool, set_permission_mode + * - MCPController: mcp_message, mcp_server_status + * - HookController: hook_callback + * + * Note: Control request types are centrally defined in the ControlRequestType + * enum in packages/sdk/typescript/src/types/controlRequests.ts + */ + +import type { IControlContext } from './ControlContext.js'; +import type { IPendingRequestRegistry } from './controllers/baseController.js'; +import { SystemController } from './controllers/systemController.js'; +import { PermissionController } from './controllers/permissionController.js'; +import { MCPController } from './controllers/mcpController.js'; +import { HookController } from './controllers/hookController.js'; +import type { + CLIControlRequest, + CLIControlResponse, + ControlResponse, + ControlRequestPayload, +} from '../../types/protocol.js'; + +/** + * Tracks an incoming request from SDK awaiting CLI response + */ +interface PendingIncomingRequest { + controller: string; + abortController: AbortController; + timeoutId: NodeJS.Timeout; +} + +/** + * Tracks an outgoing request from CLI awaiting SDK response + */ +interface PendingOutgoingRequest { + controller: string; + resolve: (response: ControlResponse) => void; + reject: (error: Error) => void; + timeoutId: NodeJS.Timeout; +} + +/** + * Central coordinator for control plane communication. + * Routes requests to controllers and manages request lifecycle. + */ +export class ControlDispatcher implements IPendingRequestRegistry { + private context: IControlContext; + + // Make controllers publicly accessible + readonly systemController: SystemController; + readonly permissionController: PermissionController; + readonly mcpController: MCPController; + readonly hookController: HookController; + + // Central pending request registries + private pendingIncomingRequests: Map = + new Map(); + private pendingOutgoingRequests: Map = + new Map(); + + constructor(context: IControlContext) { + this.context = context; + + // Create domain controllers with context and registry + this.systemController = new SystemController( + context, + this, + 'SystemController', + ); + this.permissionController = new PermissionController( + context, + this, + 'PermissionController', + ); + this.mcpController = new MCPController(context, this, 'MCPController'); + this.hookController = new HookController(context, this, 'HookController'); + + // Listen for main abort signal + this.context.abortSignal.addEventListener('abort', () => { + this.shutdown(); + }); + } + + /** + * Routes an incoming request to the appropriate controller and sends response + */ + async dispatch(request: CLIControlRequest): Promise { + const { request_id, request: payload } = request; + + try { + // Route to appropriate controller + const controller = this.getControllerForRequest(payload.subtype); + const response = await controller.handleRequest(payload, request_id); + + // Send success response + this.sendSuccessResponse(request_id, response); + + // Special handling for initialize: send SystemMessage after success response + if (payload.subtype === 'initialize') { + this.systemController.sendSystemMessage(); + } + } catch (error) { + // Send error response + const errorMessage = + error instanceof Error ? error.message : String(error); + this.sendErrorResponse(request_id, errorMessage); + } + } + + /** + * Processes response from SDK for an outgoing request + */ + handleControlResponse(response: CLIControlResponse): void { + const responsePayload = response.response; + const requestId = responsePayload.request_id; + + const pending = this.pendingOutgoingRequests.get(requestId); + if (!pending) { + // No pending request found - may have timed out or been cancelled + if (this.context.debugMode) { + console.error( + `[ControlDispatcher] No pending outgoing request for: ${requestId}`, + ); + } + return; + } + + // Deregister + this.deregisterOutgoingRequest(requestId); + + // Resolve or reject based on response type + if (responsePayload.subtype === 'success') { + pending.resolve(responsePayload); + } else { + pending.reject(new Error(responsePayload.error)); + } + } + + /** + * Sends a control request to SDK and waits for response + */ + async sendControlRequest( + payload: ControlRequestPayload, + timeoutMs?: number, + ): Promise { + // Delegate to system controller (or any controller, they all have the same method) + return this.systemController.sendControlRequest(payload, timeoutMs); + } + + /** + * Cancels a specific request or all pending requests + */ + handleCancel(requestId?: string): void { + if (requestId) { + // Cancel specific incoming request + const pending = this.pendingIncomingRequests.get(requestId); + if (pending) { + pending.abortController.abort(); + this.deregisterIncomingRequest(requestId); + this.sendErrorResponse(requestId, 'Request cancelled'); + + if (this.context.debugMode) { + console.error( + `[ControlDispatcher] Cancelled incoming request: ${requestId}`, + ); + } + } + } else { + // Cancel ALL pending incoming requests + const requestIds = Array.from(this.pendingIncomingRequests.keys()); + for (const id of requestIds) { + const pending = this.pendingIncomingRequests.get(id); + if (pending) { + pending.abortController.abort(); + this.deregisterIncomingRequest(id); + this.sendErrorResponse(id, 'All requests cancelled'); + } + } + + if (this.context.debugMode) { + console.error( + `[ControlDispatcher] Cancelled all ${requestIds.length} pending incoming requests`, + ); + } + } + } + + /** + * Stops all pending requests and cleans up all controllers + */ + shutdown(): void { + if (this.context.debugMode) { + console.error('[ControlDispatcher] Shutting down'); + } + + // Cancel all incoming requests + for (const [ + _requestId, + pending, + ] of this.pendingIncomingRequests.entries()) { + pending.abortController.abort(); + clearTimeout(pending.timeoutId); + } + this.pendingIncomingRequests.clear(); + + // Cancel all outgoing requests + for (const [ + _requestId, + pending, + ] of this.pendingOutgoingRequests.entries()) { + clearTimeout(pending.timeoutId); + pending.reject(new Error('Dispatcher shutdown')); + } + this.pendingOutgoingRequests.clear(); + + // Cleanup controllers (MCP controller will close all clients) + this.systemController.cleanup(); + this.permissionController.cleanup(); + this.mcpController.cleanup(); + this.hookController.cleanup(); + } + + /** + * Registers an incoming request in the pending registry + */ + registerIncomingRequest( + requestId: string, + controller: string, + abortController: AbortController, + timeoutId: NodeJS.Timeout, + ): void { + this.pendingIncomingRequests.set(requestId, { + controller, + abortController, + timeoutId, + }); + } + + /** + * Removes an incoming request from the pending registry + */ + deregisterIncomingRequest(requestId: string): void { + const pending = this.pendingIncomingRequests.get(requestId); + if (pending) { + clearTimeout(pending.timeoutId); + this.pendingIncomingRequests.delete(requestId); + } + } + + /** + * Registers an outgoing request in the pending registry + */ + registerOutgoingRequest( + requestId: string, + controller: string, + resolve: (response: ControlResponse) => void, + reject: (error: Error) => void, + timeoutId: NodeJS.Timeout, + ): void { + this.pendingOutgoingRequests.set(requestId, { + controller, + resolve, + reject, + timeoutId, + }); + } + + /** + * Removes an outgoing request from the pending registry + */ + deregisterOutgoingRequest(requestId: string): void { + const pending = this.pendingOutgoingRequests.get(requestId); + if (pending) { + clearTimeout(pending.timeoutId); + this.pendingOutgoingRequests.delete(requestId); + } + } + + /** + * Returns the controller that handles the given request subtype + */ + private getControllerForRequest(subtype: string) { + switch (subtype) { + case 'initialize': + case 'interrupt': + case 'set_model': + case 'supported_commands': + return this.systemController; + + case 'can_use_tool': + case 'set_permission_mode': + return this.permissionController; + + case 'mcp_message': + case 'mcp_server_status': + return this.mcpController; + + case 'hook_callback': + return this.hookController; + + default: + throw new Error(`Unknown control request subtype: ${subtype}`); + } + } + + /** + * Sends a success response back to SDK + */ + private sendSuccessResponse( + requestId: string, + response: Record, + ): void { + const controlResponse: CLIControlResponse = { + type: 'control_response', + response: { + subtype: 'success', + request_id: requestId, + response, + }, + }; + this.context.streamJson.send(controlResponse); + } + + /** + * Sends an error response back to SDK + */ + private sendErrorResponse(requestId: string, error: string): void { + const controlResponse: CLIControlResponse = { + type: 'control_response', + response: { + subtype: 'error', + request_id: requestId, + error, + }, + }; + this.context.streamJson.send(controlResponse); + } +} diff --git a/packages/cli/src/services/control/controllers/baseController.ts b/packages/cli/src/services/control/controllers/baseController.ts new file mode 100644 index 00000000..a399f433 --- /dev/null +++ b/packages/cli/src/services/control/controllers/baseController.ts @@ -0,0 +1,180 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Base Controller + * + * Abstract base class for domain-specific control plane controllers. + * Provides common functionality for: + * - Handling incoming control requests (SDK -> CLI) + * - Sending outgoing control requests (CLI -> SDK) + * - Request lifecycle management with timeout and cancellation + * - Integration with central pending request registry + */ + +import { randomUUID } from 'node:crypto'; +import type { IControlContext } from '../ControlContext.js'; +import type { + ControlRequestPayload, + ControlResponse, + CLIControlRequest, +} from '../../../types/protocol.js'; + +const DEFAULT_REQUEST_TIMEOUT_MS = 30000; // 30 seconds + +/** + * Registry interface for controllers to register/deregister pending requests + */ +export interface IPendingRequestRegistry { + registerIncomingRequest( + requestId: string, + controller: string, + abortController: AbortController, + timeoutId: NodeJS.Timeout, + ): void; + deregisterIncomingRequest(requestId: string): void; + + registerOutgoingRequest( + requestId: string, + controller: string, + resolve: (response: ControlResponse) => void, + reject: (error: Error) => void, + timeoutId: NodeJS.Timeout, + ): void; + deregisterOutgoingRequest(requestId: string): void; +} + +/** + * Abstract base controller class + * + * Subclasses should implement handleRequestPayload() to process specific + * control request types. + */ +export abstract class BaseController { + protected context: IControlContext; + protected registry: IPendingRequestRegistry; + protected controllerName: string; + + constructor( + context: IControlContext, + registry: IPendingRequestRegistry, + controllerName: string, + ) { + this.context = context; + this.registry = registry; + this.controllerName = controllerName; + } + + /** + * Handle an incoming control request + * + * Manages lifecycle: register -> process -> deregister + */ + async handleRequest( + payload: ControlRequestPayload, + requestId: string, + ): Promise> { + const requestAbortController = new AbortController(); + + // Setup timeout + const timeoutId = setTimeout(() => { + requestAbortController.abort(); + this.registry.deregisterIncomingRequest(requestId); + if (this.context.debugMode) { + console.error(`[${this.controllerName}] Request timeout: ${requestId}`); + } + }, DEFAULT_REQUEST_TIMEOUT_MS); + + // Register with central registry + this.registry.registerIncomingRequest( + requestId, + this.controllerName, + requestAbortController, + timeoutId, + ); + + try { + const response = await this.handleRequestPayload( + payload, + requestAbortController.signal, + ); + + // Success - deregister + this.registry.deregisterIncomingRequest(requestId); + + return response; + } catch (error) { + // Error - deregister + this.registry.deregisterIncomingRequest(requestId); + throw error; + } + } + + /** + * Send an outgoing control request to SDK + * + * Manages lifecycle: register -> send -> wait for response -> deregister + */ + async sendControlRequest( + payload: ControlRequestPayload, + timeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS, + ): Promise { + const requestId = randomUUID(); + + return new Promise((resolve, reject) => { + // Setup timeout + const timeoutId = setTimeout(() => { + this.registry.deregisterOutgoingRequest(requestId); + reject(new Error('Control request timeout')); + if (this.context.debugMode) { + console.error( + `[${this.controllerName}] Outgoing request timeout: ${requestId}`, + ); + } + }, timeoutMs); + + // Register with central registry + this.registry.registerOutgoingRequest( + requestId, + this.controllerName, + resolve, + reject, + timeoutId, + ); + + // Send control request + const request: CLIControlRequest = { + type: 'control_request', + request_id: requestId, + request: payload, + }; + + try { + this.context.streamJson.send(request); + } catch (error) { + this.registry.deregisterOutgoingRequest(requestId); + reject(error); + } + }); + } + + /** + * Abstract method: Handle specific request payload + * + * Subclasses must implement this to process their domain-specific requests. + */ + protected abstract handleRequestPayload( + payload: ControlRequestPayload, + signal: AbortSignal, + ): Promise>; + + /** + * Cleanup resources + */ + cleanup(): void { + // Subclasses can override to add cleanup logic + } +} diff --git a/packages/cli/src/services/control/controllers/hookController.ts b/packages/cli/src/services/control/controllers/hookController.ts new file mode 100644 index 00000000..99335bd2 --- /dev/null +++ b/packages/cli/src/services/control/controllers/hookController.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Hook Controller + * + * Handles hook-related control requests: + * - hook_callback: Process hook callbacks (placeholder for future) + */ + +import { BaseController } from './baseController.js'; +import type { + ControlRequestPayload, + CLIHookCallbackRequest, +} from '../../../types/protocol.js'; + +export class HookController extends BaseController { + /** + * Handle hook control requests + */ + protected async handleRequestPayload( + payload: ControlRequestPayload, + _signal: AbortSignal, + ): Promise> { + switch (payload.subtype) { + case 'hook_callback': + return this.handleHookCallback(payload as CLIHookCallbackRequest); + + default: + throw new Error(`Unsupported request subtype in HookController`); + } + } + + /** + * Handle hook_callback request + * + * Processes hook callbacks (placeholder implementation) + */ + private async handleHookCallback( + payload: CLIHookCallbackRequest, + ): Promise> { + if (this.context.debugMode) { + console.error(`[HookController] Hook callback: ${payload.callback_id}`); + } + + // Hook callback processing not yet implemented + return { + result: 'Hook callback processing not yet implemented', + callback_id: payload.callback_id, + tool_use_id: payload.tool_use_id, + }; + } +} diff --git a/packages/cli/src/services/control/controllers/mcpController.ts b/packages/cli/src/services/control/controllers/mcpController.ts new file mode 100644 index 00000000..b976c10b --- /dev/null +++ b/packages/cli/src/services/control/controllers/mcpController.ts @@ -0,0 +1,287 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * MCP Controller + * + * Handles MCP-related control requests: + * - mcp_message: Route MCP messages + * - mcp_server_status: Return MCP server status + */ + +import { BaseController } from './baseController.js'; +import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { ResultSchema } from '@modelcontextprotocol/sdk/types.js'; +import type { + ControlRequestPayload, + CLIControlMcpMessageRequest, +} from '../../../types/protocol.js'; +import type { + MCPServerConfig, + WorkspaceContext, +} from '@qwen-code/qwen-code-core'; +import { + connectToMcpServer, + MCP_DEFAULT_TIMEOUT_MSEC, +} from '@qwen-code/qwen-code-core'; + +export class MCPController extends BaseController { + /** + * Handle MCP control requests + */ + protected async handleRequestPayload( + payload: ControlRequestPayload, + _signal: AbortSignal, + ): Promise> { + switch (payload.subtype) { + case 'mcp_message': + return this.handleMcpMessage(payload as CLIControlMcpMessageRequest); + + case 'mcp_server_status': + return this.handleMcpStatus(); + + default: + throw new Error(`Unsupported request subtype in MCPController`); + } + } + + /** + * Handle mcp_message request + * + * Routes JSON-RPC messages to MCP servers + */ + private async handleMcpMessage( + payload: CLIControlMcpMessageRequest, + ): Promise> { + const serverNameRaw = payload.server_name; + if ( + typeof serverNameRaw !== 'string' || + serverNameRaw.trim().length === 0 + ) { + throw new Error('Missing server_name in mcp_message request'); + } + + const message = payload.message; + if (!message || typeof message !== 'object') { + throw new Error( + 'Missing or invalid message payload for mcp_message request', + ); + } + + // Get or create MCP client + let clientEntry: { client: Client; config: MCPServerConfig }; + try { + clientEntry = await this.getOrCreateMcpClient(serverNameRaw.trim()); + } catch (error) { + throw new Error( + error instanceof Error + ? error.message + : 'Failed to connect to MCP server', + ); + } + + const method = message.method; + if (typeof method !== 'string' || method.trim().length === 0) { + throw new Error('Invalid MCP message: missing method'); + } + + const jsonrpcVersion = + typeof message.jsonrpc === 'string' ? message.jsonrpc : '2.0'; + const messageId = message.id; + const params = message.params; + const timeout = + typeof clientEntry.config.timeout === 'number' + ? clientEntry.config.timeout + : MCP_DEFAULT_TIMEOUT_MSEC; + + try { + // Handle notification (no id) + if (messageId === undefined) { + await clientEntry.client.notification({ + method, + params, + }); + return { + subtype: 'mcp_message', + mcp_response: { + jsonrpc: jsonrpcVersion, + id: null, + result: { success: true, acknowledged: true }, + }, + }; + } + + // Handle request (with id) + const result = await clientEntry.client.request( + { + method, + params, + }, + ResultSchema, + { timeout }, + ); + + return { + subtype: 'mcp_message', + mcp_response: { + jsonrpc: jsonrpcVersion, + id: messageId, + result, + }, + }; + } catch (error) { + // If connection closed, remove from cache + if (error instanceof Error && /closed/i.test(error.message)) { + this.context.mcpClients.delete(serverNameRaw.trim()); + } + + const errorCode = + typeof (error as { code?: unknown })?.code === 'number' + ? ((error as { code: number }).code as number) + : -32603; + const errorMessage = + error instanceof Error + ? error.message + : 'Failed to execute MCP request'; + const errorData = (error as { data?: unknown })?.data; + + const errorBody: Record = { + code: errorCode, + message: errorMessage, + }; + if (errorData !== undefined) { + errorBody['data'] = errorData; + } + + return { + subtype: 'mcp_message', + mcp_response: { + jsonrpc: jsonrpcVersion, + id: messageId ?? null, + error: errorBody, + }, + }; + } + } + + /** + * Handle mcp_server_status request + * + * Returns status of registered MCP servers + */ + private async handleMcpStatus(): Promise> { + const status: Record = {}; + + // Include SDK MCP servers + for (const serverName of this.context.sdkMcpServers) { + status[serverName] = 'connected'; + } + + // Include CLI-managed MCP clients + for (const serverName of this.context.mcpClients.keys()) { + status[serverName] = 'connected'; + } + + if (this.context.debugMode) { + console.error( + `[MCPController] MCP status: ${Object.keys(status).length} servers`, + ); + } + + return status; + } + + /** + * Get or create MCP client for a server + * + * Implements lazy connection and caching + */ + private async getOrCreateMcpClient( + serverName: string, + ): Promise<{ client: Client; config: MCPServerConfig }> { + // Check cache first + const cached = this.context.mcpClients.get(serverName); + if (cached) { + return cached; + } + + // Get server configuration + const provider = this.context.config as unknown as { + getMcpServers?: () => Record | undefined; + getDebugMode?: () => boolean; + getWorkspaceContext?: () => unknown; + }; + + if (typeof provider.getMcpServers !== 'function') { + throw new Error(`MCP server "${serverName}" is not configured`); + } + + const servers = provider.getMcpServers() ?? {}; + const serverConfig = servers[serverName]; + if (!serverConfig) { + throw new Error(`MCP server "${serverName}" is not configured`); + } + + const debugMode = + typeof provider.getDebugMode === 'function' + ? provider.getDebugMode() + : false; + + const workspaceContext = + typeof provider.getWorkspaceContext === 'function' + ? provider.getWorkspaceContext() + : undefined; + + if (!workspaceContext) { + throw new Error('Workspace context is not available for MCP connection'); + } + + // Connect to MCP server + const client = await connectToMcpServer( + serverName, + serverConfig, + debugMode, + workspaceContext as WorkspaceContext, + ); + + // Cache the client + const entry = { client, config: serverConfig }; + this.context.mcpClients.set(serverName, entry); + + if (this.context.debugMode) { + console.error(`[MCPController] Connected to MCP server: ${serverName}`); + } + + return entry; + } + + /** + * Cleanup MCP clients + */ + override cleanup(): void { + if (this.context.debugMode) { + console.error( + `[MCPController] Cleaning up ${this.context.mcpClients.size} MCP clients`, + ); + } + + // Close all MCP clients + for (const [serverName, { client }] of this.context.mcpClients.entries()) { + try { + client.close(); + } catch (error) { + if (this.context.debugMode) { + console.error( + `[MCPController] Failed to close MCP client ${serverName}:`, + error, + ); + } + } + } + + this.context.mcpClients.clear(); + } +} diff --git a/packages/cli/src/services/control/controllers/permissionController.ts b/packages/cli/src/services/control/controllers/permissionController.ts new file mode 100644 index 00000000..46eeeb08 --- /dev/null +++ b/packages/cli/src/services/control/controllers/permissionController.ts @@ -0,0 +1,480 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Permission Controller + * + * Handles permission-related control requests: + * - can_use_tool: Check if tool usage is allowed + * - set_permission_mode: Change permission mode at runtime + * + * Abstracts all permission logic from the session manager to keep it clean. + */ + +import type { + ToolCallRequestInfo, + WaitingToolCall, +} from '@qwen-code/qwen-code-core'; +import { ToolConfirmationOutcome } from '@qwen-code/qwen-code-core'; +import type { + CLIControlPermissionRequest, + CLIControlSetPermissionModeRequest, + ControlRequestPayload, + PermissionMode, + PermissionSuggestion, +} from '../../../types/protocol.js'; +import { BaseController } from './baseController.js'; + +// Import ToolCallConfirmationDetails types for type alignment +type ToolConfirmationType = 'edit' | 'exec' | 'mcp' | 'info' | 'plan'; + +export class PermissionController extends BaseController { + private pendingOutgoingRequests = new Set(); + + /** + * Handle permission control requests + */ + protected async handleRequestPayload( + payload: ControlRequestPayload, + _signal: AbortSignal, + ): Promise> { + switch (payload.subtype) { + case 'can_use_tool': + return this.handleCanUseTool(payload as CLIControlPermissionRequest); + + case 'set_permission_mode': + return this.handleSetPermissionMode( + payload as CLIControlSetPermissionModeRequest, + ); + + default: + throw new Error(`Unsupported request subtype in PermissionController`); + } + } + + /** + * Handle can_use_tool request + * + * Comprehensive permission evaluation based on: + * - Permission mode (approval level) + * - Tool registry validation + * - Error handling with safe defaults + */ + private async handleCanUseTool( + payload: CLIControlPermissionRequest, + ): Promise> { + const toolName = payload.tool_name; + if ( + !toolName || + typeof toolName !== 'string' || + toolName.trim().length === 0 + ) { + return { + subtype: 'can_use_tool', + behavior: 'deny', + message: 'Missing or invalid tool_name in can_use_tool request', + }; + } + + let behavior: 'allow' | 'deny' = 'allow'; + let message: string | undefined; + + try { + // Check permission mode first + const permissionResult = this.checkPermissionMode(); + if (!permissionResult.allowed) { + behavior = 'deny'; + message = permissionResult.message; + } + + // Check tool registry if permission mode allows + if (behavior === 'allow') { + const registryResult = this.checkToolRegistry(toolName); + if (!registryResult.allowed) { + behavior = 'deny'; + message = registryResult.message; + } + } + } catch (error) { + behavior = 'deny'; + message = + error instanceof Error + ? `Failed to evaluate tool permission: ${error.message}` + : 'Failed to evaluate tool permission'; + } + + const response: Record = { + subtype: 'can_use_tool', + behavior, + }; + + if (message) { + response['message'] = message; + } + + return response; + } + + /** + * Check permission mode for tool execution + */ + private checkPermissionMode(): { allowed: boolean; message?: string } { + const mode = this.context.permissionMode; + + // Map permission modes to approval logic (aligned with VALID_APPROVAL_MODE_VALUES) + switch (mode) { + case 'yolo': // Allow all tools + case 'auto-edit': // Auto-approve edit operations + case 'plan': // Auto-approve planning operations + return { allowed: true }; + + case 'default': // TODO: allow all tools for test + default: + return { + allowed: false, + message: + 'Tool execution requires manual approval. Update permission mode or approve via host.', + }; + } + } + + /** + * Check if tool exists in registry + */ + private checkToolRegistry(toolName: string): { + allowed: boolean; + message?: string; + } { + try { + // Access tool registry through config + const config = this.context.config; + const registryProvider = config as unknown as { + getToolRegistry?: () => { + getTool?: (name: string) => unknown; + }; + }; + + if (typeof registryProvider.getToolRegistry === 'function') { + const registry = registryProvider.getToolRegistry(); + if ( + registry && + typeof registry.getTool === 'function' && + !registry.getTool(toolName) + ) { + return { + allowed: false, + message: `Tool "${toolName}" is not registered.`, + }; + } + } + + return { allowed: true }; + } catch (error) { + return { + allowed: false, + message: `Failed to check tool registry: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + } + + /** + * Handle set_permission_mode request + * + * Updates the permission mode in the context + */ + private async handleSetPermissionMode( + payload: CLIControlSetPermissionModeRequest, + ): Promise> { + const mode = payload.mode; + const validModes: PermissionMode[] = [ + 'default', + 'plan', + 'auto-edit', + 'yolo', + ]; + + if (!validModes.includes(mode)) { + throw new Error( + `Invalid permission mode: ${mode}. Valid values are: ${validModes.join(', ')}`, + ); + } + + this.context.permissionMode = mode; + + if (this.context.debugMode) { + console.error( + `[PermissionController] Permission mode updated to: ${mode}`, + ); + } + + return { status: 'updated', mode }; + } + + /** + * Build permission suggestions for tool confirmation UI + * + * This method creates UI suggestions based on tool confirmation details, + * helping the host application present appropriate permission options. + */ + buildPermissionSuggestions( + confirmationDetails: unknown, + ): PermissionSuggestion[] | null { + if ( + !confirmationDetails || + typeof confirmationDetails !== 'object' || + !('type' in confirmationDetails) + ) { + return null; + } + + const details = confirmationDetails as Record; + const type = String(details['type'] ?? ''); + const title = + typeof details['title'] === 'string' ? details['title'] : undefined; + + // Ensure type matches ToolCallConfirmationDetails union + const confirmationType = type as ToolConfirmationType; + + switch (confirmationType) { + case 'exec': // ToolExecuteConfirmationDetails + return [ + { + type: 'allow', + label: 'Allow Command', + description: `Execute: ${details['command']}`, + }, + { + type: 'deny', + label: 'Deny', + description: 'Block this command execution', + }, + ]; + + case 'edit': // ToolEditConfirmationDetails + return [ + { + type: 'allow', + label: 'Allow Edit', + description: `Edit file: ${details['fileName']}`, + }, + { + type: 'deny', + label: 'Deny', + description: 'Block this file edit', + }, + { + type: 'modify', + label: 'Review Changes', + description: 'Review the proposed changes before applying', + }, + ]; + + case 'plan': // ToolPlanConfirmationDetails + return [ + { + type: 'allow', + label: 'Approve Plan', + description: title || 'Execute the proposed plan', + }, + { + type: 'deny', + label: 'Reject Plan', + description: 'Do not execute this plan', + }, + ]; + + case 'mcp': // ToolMcpConfirmationDetails + return [ + { + type: 'allow', + label: 'Allow MCP Call', + description: `${details['serverName']}: ${details['toolName']}`, + }, + { + type: 'deny', + label: 'Deny', + description: 'Block this MCP server call', + }, + ]; + + case 'info': // ToolInfoConfirmationDetails + return [ + { + type: 'allow', + label: 'Allow Info Request', + description: title || 'Allow information request', + }, + { + type: 'deny', + label: 'Deny', + description: 'Block this information request', + }, + ]; + + default: + // Fallback for unknown types + return [ + { + type: 'allow', + label: 'Allow', + description: title || `Allow ${type} operation`, + }, + { + type: 'deny', + label: 'Deny', + description: `Block ${type} operation`, + }, + ]; + } + } + + /** + * Check if a tool should be executed based on current permission settings + * + * This is a convenience method for direct tool execution checks without + * going through the control request flow. + */ + async shouldAllowTool( + toolRequest: ToolCallRequestInfo, + confirmationDetails?: unknown, + ): Promise<{ + allowed: boolean; + message?: string; + updatedArgs?: Record; + }> { + // Check permission mode + const modeResult = this.checkPermissionMode(); + if (!modeResult.allowed) { + return { + allowed: false, + message: modeResult.message, + }; + } + + // Check tool registry + const registryResult = this.checkToolRegistry(toolRequest.name); + if (!registryResult.allowed) { + return { + allowed: false, + message: registryResult.message, + }; + } + + // If we have confirmation details, we could potentially modify args + // This is a hook for future enhancement + if (confirmationDetails) { + // Future: handle argument modifications based on confirmation details + } + + return { allowed: true }; + } + + /** + * Get callback for monitoring tool calls and handling outgoing permission requests + * This is passed to executeToolCall to hook into CoreToolScheduler updates + */ + getToolCallUpdateCallback(): (toolCalls: unknown[]) => void { + return (toolCalls: unknown[]) => { + for (const call of toolCalls) { + if ( + call && + typeof call === 'object' && + (call as { status?: string }).status === 'awaiting_approval' + ) { + const awaiting = call as WaitingToolCall; + if ( + typeof awaiting.confirmationDetails?.onConfirm === 'function' && + !this.pendingOutgoingRequests.has(awaiting.request.callId) + ) { + this.pendingOutgoingRequests.add(awaiting.request.callId); + void this.handleOutgoingPermissionRequest(awaiting); + } + } + } + }; + } + + /** + * Handle outgoing permission request + * + * Behavior depends on input format: + * - stream-json mode: Send can_use_tool to SDK and await response + * - Other modes: Check local approval mode and decide immediately + */ + private async handleOutgoingPermissionRequest( + toolCall: WaitingToolCall, + ): Promise { + try { + const inputFormat = this.context.config.getInputFormat?.(); + const isStreamJsonMode = inputFormat === 'stream-json'; + + if (!isStreamJsonMode) { + // No SDK available - use local permission check + const modeCheck = this.checkPermissionMode(); + const outcome = modeCheck.allowed + ? ToolConfirmationOutcome.ProceedOnce + : ToolConfirmationOutcome.Cancel; + + await toolCall.confirmationDetails.onConfirm(outcome); + return; + } + + // Stream-json mode: ask SDK for permission + const permissionSuggestions = this.buildPermissionSuggestions( + toolCall.confirmationDetails, + ); + + const response = await this.sendControlRequest( + { + subtype: 'can_use_tool', + tool_name: toolCall.request.name, + tool_use_id: toolCall.request.callId, + input: toolCall.request.args, + permission_suggestions: permissionSuggestions, + blocked_path: null, + } as CLIControlPermissionRequest, + 30000, + ); + + if (response.subtype !== 'success') { + await toolCall.confirmationDetails.onConfirm( + ToolConfirmationOutcome.Cancel, + ); + return; + } + + const payload = (response.response || {}) as Record; + const behavior = String(payload['behavior'] || '').toLowerCase(); + + if (behavior === 'allow') { + // Handle updated input if provided + const updatedInput = payload['updatedInput']; + if (updatedInput && typeof updatedInput === 'object') { + toolCall.request.args = updatedInput as Record; + } + await toolCall.confirmationDetails.onConfirm( + ToolConfirmationOutcome.ProceedOnce, + ); + } else { + await toolCall.confirmationDetails.onConfirm( + ToolConfirmationOutcome.Cancel, + ); + } + } catch (error) { + if (this.context.debugMode) { + console.error( + '[PermissionController] Outgoing permission failed:', + error, + ); + } + await toolCall.confirmationDetails.onConfirm( + ToolConfirmationOutcome.Cancel, + ); + } finally { + this.pendingOutgoingRequests.delete(toolCall.request.callId); + } + } +} diff --git a/packages/cli/src/services/control/controllers/systemController.ts b/packages/cli/src/services/control/controllers/systemController.ts new file mode 100644 index 00000000..a2c4b627 --- /dev/null +++ b/packages/cli/src/services/control/controllers/systemController.ts @@ -0,0 +1,292 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * System Controller + * + * Handles system-level control requests: + * - initialize: Setup session and return system info + * - interrupt: Cancel current operations + * - set_model: Switch model (placeholder) + */ + +import { BaseController } from './baseController.js'; +import { CommandService } from '../../CommandService.js'; +import { BuiltinCommandLoader } from '../../BuiltinCommandLoader.js'; +import type { + ControlRequestPayload, + CLIControlInitializeRequest, + CLIControlSetModelRequest, + CLISystemMessage, +} from '../../../types/protocol.js'; + +export class SystemController extends BaseController { + /** + * Handle system control requests + */ + protected async handleRequestPayload( + payload: ControlRequestPayload, + _signal: AbortSignal, + ): Promise> { + switch (payload.subtype) { + case 'initialize': + return this.handleInitialize(payload as CLIControlInitializeRequest); + + case 'interrupt': + return this.handleInterrupt(); + + case 'set_model': + return this.handleSetModel(payload as CLIControlSetModelRequest); + + case 'supported_commands': + return this.handleSupportedCommands(); + + default: + throw new Error(`Unsupported request subtype in SystemController`); + } + } + + /** + * Handle initialize request + * + * Registers SDK MCP servers and returns capabilities + */ + private async handleInitialize( + payload: CLIControlInitializeRequest, + ): Promise> { + // Register SDK MCP servers if provided + if (payload.sdkMcpServers && Array.isArray(payload.sdkMcpServers)) { + for (const serverName of payload.sdkMcpServers) { + this.context.sdkMcpServers.add(serverName); + } + } + + // Build capabilities for response + const capabilities = this.buildControlCapabilities(); + + if (this.context.debugMode) { + console.error( + `[SystemController] Initialized with ${this.context.sdkMcpServers.size} SDK MCP servers`, + ); + } + + return { + subtype: 'initialize', + capabilities, + }; + } + + /** + * Send system message to SDK + * + * Called after successful initialize response is sent + */ + async sendSystemMessage(): Promise { + const toolRegistry = this.context.config.getToolRegistry(); + const tools = toolRegistry ? toolRegistry.getAllToolNames() : []; + + const mcpServers = this.context.config.getMcpServers(); + const mcpServerList = mcpServers + ? Object.keys(mcpServers).map((name) => ({ + name, + status: 'connected', + })) + : []; + + // Load slash commands + const slashCommands = await this.loadSlashCommandNames(); + + // Build capabilities + const capabilities = this.buildControlCapabilities(); + + const systemMessage: CLISystemMessage = { + type: 'system', + subtype: 'init', + uuid: this.context.sessionId, + session_id: this.context.sessionId, + cwd: this.context.config.getTargetDir(), + tools, + mcp_servers: mcpServerList, + model: this.context.config.getModel(), + permissionMode: this.context.permissionMode, + slash_commands: slashCommands, + apiKeySource: 'none', + qwen_code_version: this.context.config.getCliVersion() || 'unknown', + output_style: 'default', + agents: [], + skills: [], + capabilities, + }; + + this.context.streamJson.send(systemMessage); + + if (this.context.debugMode) { + console.error('[SystemController] System message sent'); + } + } + + /** + * Build control capabilities for initialize response + */ + private buildControlCapabilities(): Record { + const capabilities: Record = { + can_handle_can_use_tool: true, + can_handle_hook_callback: true, + can_set_permission_mode: + typeof this.context.config.setApprovalMode === 'function', + can_set_model: typeof this.context.config.setModel === 'function', + }; + + // Check if MCP message handling is available + try { + const mcpProvider = this.context.config as unknown as { + getMcpServers?: () => Record | undefined; + }; + if (typeof mcpProvider.getMcpServers === 'function') { + const servers = mcpProvider.getMcpServers(); + capabilities['can_handle_mcp_message'] = Boolean( + servers && Object.keys(servers).length > 0, + ); + } else { + capabilities['can_handle_mcp_message'] = false; + } + } catch (error) { + if (this.context.debugMode) { + console.error( + '[SystemController] Failed to determine MCP capability:', + error, + ); + } + capabilities['can_handle_mcp_message'] = false; + } + + return capabilities; + } + + /** + * Handle interrupt request + * + * Triggers the interrupt callback to cancel current operations + */ + private async handleInterrupt(): Promise> { + // Trigger interrupt callback if available + if (this.context.onInterrupt) { + this.context.onInterrupt(); + } + + // Abort the main signal to cancel ongoing operations + if (this.context.abortSignal && !this.context.abortSignal.aborted) { + // Note: We can't directly abort the signal, but the onInterrupt callback should handle this + if (this.context.debugMode) { + console.error('[SystemController] Interrupt signal triggered'); + } + } + + if (this.context.debugMode) { + console.error('[SystemController] Interrupt handled'); + } + + return { subtype: 'interrupt' }; + } + + /** + * Handle set_model request + * + * Implements actual model switching with validation and error handling + */ + private async handleSetModel( + payload: CLIControlSetModelRequest, + ): Promise> { + const model = payload.model; + + // Validate model parameter + if (typeof model !== 'string' || model.trim() === '') { + throw new Error('Invalid model specified for set_model request'); + } + + try { + // Attempt to set the model using config + await this.context.config.setModel(model); + + if (this.context.debugMode) { + console.error(`[SystemController] Model switched to: ${model}`); + } + + return { + subtype: 'set_model', + model, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Failed to set model'; + + if (this.context.debugMode) { + console.error( + `[SystemController] Failed to set model ${model}:`, + error, + ); + } + + throw new Error(errorMessage); + } + } + + /** + * Handle supported_commands request + * + * Returns list of supported control commands + * + * Note: This list should match the ControlRequestType enum in + * packages/sdk/typescript/src/types/controlRequests.ts + */ + private async handleSupportedCommands(): Promise> { + const commands = [ + 'initialize', + 'interrupt', + 'set_model', + 'supported_commands', + 'can_use_tool', + 'set_permission_mode', + 'mcp_message', + 'mcp_server_status', + 'hook_callback', + ]; + + return { + subtype: 'supported_commands', + commands, + }; + } + + /** + * Load slash command names using CommandService + */ + private async loadSlashCommandNames(): Promise { + const controller = new AbortController(); + try { + const service = await CommandService.create( + [new BuiltinCommandLoader(this.context.config)], + controller.signal, + ); + const names = new Set(); + const commands = service.getCommands(); + for (const command of commands) { + names.add(command.name); + } + return Array.from(names).sort(); + } catch (error) { + if (this.context.debugMode) { + console.error( + '[SystemController] Failed to load slash commands:', + error, + ); + } + return []; + } finally { + controller.abort(); + } + } +} diff --git a/packages/cli/src/types/protocol.ts b/packages/cli/src/types/protocol.ts new file mode 100644 index 00000000..2343a622 --- /dev/null +++ b/packages/cli/src/types/protocol.ts @@ -0,0 +1,485 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/** + * Usage information types + */ +export interface Usage { + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; +} + +export interface ExtendedUsage extends Usage { + server_tool_use?: { + web_search_requests: number; + }; + service_tier?: string; + cache_creation?: { + ephemeral_1h_input_tokens: number; + ephemeral_5m_input_tokens: number; + }; +} + +export interface ModelUsage { + inputTokens: number; + outputTokens: number; + cacheReadInputTokens: number; + cacheCreationInputTokens: number; + webSearchRequests: number; + costUSD: number; + contextWindow: number; +} + +/** + * Permission denial information + */ +export interface CLIPermissionDenial { + tool_name: string; + tool_use_id: string; + tool_input: Record; +} + +/** + * Content block types from Anthropic SDK + */ +export interface TextBlock { + type: 'text'; + text: string; +} + +export interface ThinkingBlock { + type: 'thinking'; + thinking: string; + signature: string; +} + +export interface ToolUseBlock { + type: 'tool_use'; + id: string; + name: string; + input: Record; +} + +export interface ToolResultBlock { + type: 'tool_result'; + tool_use_id: string; + content: string | Array> | null; + is_error?: boolean; +} + +export type ContentBlock = + | TextBlock + | ThinkingBlock + | ToolUseBlock + | ToolResultBlock; + +/** + * Anthropic SDK Message types + */ +export interface APIUserMessage { + role: 'user'; + content: string | ToolResultBlock[]; +} + +export interface APIAssistantMessage { + id: string; + type: 'message'; + role: 'assistant'; + model: string; + content: ContentBlock[]; + stop_reason?: string | null; + usage: Usage; +} + +/** + * CLI Message wrapper types + */ +export interface CLIUserMessage { + type: 'user'; + uuid?: string; + session_id: string; + message: APIUserMessage; + parent_tool_use_id: string | null; +} + +export interface CLIAssistantMessage { + type: 'assistant'; + uuid: string; + session_id: string; + message: APIAssistantMessage; + parent_tool_use_id: string | null; +} + +export interface CLISystemMessage { + type: 'system'; + subtype: 'init' | 'compact_boundary'; + uuid: string; + session_id: string; + cwd?: string; + tools?: string[]; + mcp_servers?: Array<{ + name: string; + status: string; + }>; + model?: string; + permissionMode?: string; + slash_commands?: string[]; + apiKeySource?: string; + qwen_code_version?: string; + output_style?: string; + agents?: string[]; + skills?: string[]; + capabilities?: Record; + compact_metadata?: { + trigger: 'manual' | 'auto'; + pre_tokens: number; + }; +} + +export interface CLIResultMessageSuccess { + type: 'result'; + subtype: 'success'; + uuid: string; + session_id: string; + is_error: false; + duration_ms: number; + duration_api_ms: number; + num_turns: number; + result: string; + total_cost_usd: number; + usage: ExtendedUsage; + modelUsage?: Record; + permission_denials: CLIPermissionDenial[]; +} + +export interface CLIResultMessageError { + type: 'result'; + subtype: 'error_max_turns' | 'error_during_execution'; + uuid: string; + session_id: string; + is_error: true; + duration_ms: number; + duration_api_ms: number; + num_turns: number; + total_cost_usd: number; + usage: ExtendedUsage; + modelUsage?: Record; + permission_denials: CLIPermissionDenial[]; +} + +export type CLIResultMessage = CLIResultMessageSuccess | CLIResultMessageError; + +/** + * Stream event types for real-time message updates + */ +export interface MessageStartStreamEvent { + type: 'message_start'; + message: { + id: string; + role: 'assistant'; + model: string; + }; +} + +export interface ContentBlockStartEvent { + type: 'content_block_start'; + index: number; + content_block: ContentBlock; +} + +export interface ContentBlockDeltaEvent { + type: 'content_block_delta'; + index: number; + delta: { + type: 'text_delta' | 'thinking_delta'; + text?: string; + thinking?: string; + }; +} + +export interface ContentBlockStopEvent { + type: 'content_block_stop'; + index: number; +} + +export interface MessageStopStreamEvent { + type: 'message_stop'; +} + +export type StreamEvent = + | MessageStartStreamEvent + | ContentBlockStartEvent + | ContentBlockDeltaEvent + | ContentBlockStopEvent + | MessageStopStreamEvent; + +export interface CLIPartialAssistantMessage { + type: 'stream_event'; + uuid: string; + session_id: string; + event: StreamEvent; + parent_tool_use_id: string | null; +} + +export type PermissionMode = 'default' | 'plan' | 'auto-edit' | 'yolo'; + +/** + * Permission suggestion for tool use requests + * TODO: Align with `ToolCallConfirmationDetails` + */ +export interface PermissionSuggestion { + type: 'allow' | 'deny' | 'modify'; + label: string; + description?: string; + modifiedInput?: Record; +} + +/** + * Hook callback placeholder for future implementation + */ +export interface HookRegistration { + event: string; + callback_id: string; +} + +/** + * Hook callback result placeholder for future implementation + */ +export interface HookCallbackResult { + shouldSkip?: boolean; + shouldInterrupt?: boolean; + suppressOutput?: boolean; + message?: string; +} + +export interface CLIControlInterruptRequest { + subtype: 'interrupt'; +} + +export interface CLIControlPermissionRequest { + subtype: 'can_use_tool'; + tool_name: string; + tool_use_id: string; + input: Record; + permission_suggestions: PermissionSuggestion[] | null; + blocked_path: string | null; +} + +export interface CLIControlInitializeRequest { + subtype: 'initialize'; + hooks?: HookRegistration[] | null; + sdkMcpServers?: string[]; +} + +export interface CLIControlSetPermissionModeRequest { + subtype: 'set_permission_mode'; + mode: PermissionMode; +} + +export interface CLIHookCallbackRequest { + subtype: 'hook_callback'; + callback_id: string; + input: any; + tool_use_id: string | null; +} + +export interface CLIControlMcpMessageRequest { + subtype: 'mcp_message'; + server_name: string; + message: { + jsonrpc?: string; + method: string; + params?: Record; + id?: string | number | null; + }; +} + +export interface CLIControlSetModelRequest { + subtype: 'set_model'; + model: string; +} + +export interface CLIControlMcpStatusRequest { + subtype: 'mcp_server_status'; +} + +export interface CLIControlSupportedCommandsRequest { + subtype: 'supported_commands'; +} + +export type ControlRequestPayload = + | CLIControlInterruptRequest + | CLIControlPermissionRequest + | CLIControlInitializeRequest + | CLIControlSetPermissionModeRequest + | CLIHookCallbackRequest + | CLIControlMcpMessageRequest + | CLIControlSetModelRequest + | CLIControlMcpStatusRequest + | CLIControlSupportedCommandsRequest; + +export interface CLIControlRequest { + type: 'control_request'; + request_id: string; + request: ControlRequestPayload; +} + +/** + * Permission approval result + */ +export interface PermissionApproval { + allowed: boolean; + reason?: string; + modifiedInput?: Record; +} + +export interface ControlResponse { + subtype: 'success'; + request_id: string; + response: Record | null; +} + +export interface ControlErrorResponse { + subtype: 'error'; + request_id: string; + error: string; +} + +export interface CLIControlResponse { + type: 'control_response'; + response: ControlResponse | ControlErrorResponse; +} + +export interface ControlCancelRequest { + type: 'control_cancel_request'; + request_id?: string; +} + +export type ControlMessage = + | CLIControlRequest + | CLIControlResponse + | ControlCancelRequest; + +/** + * Union of all CLI message types + */ +export type CLIMessage = + | CLIUserMessage + | CLIAssistantMessage + | CLISystemMessage + | CLIResultMessage + | CLIPartialAssistantMessage; + +/** + * Type guard functions for message discrimination + */ + +export function isCLIUserMessage(msg: any): msg is CLIUserMessage { + return ( + msg && + typeof msg === 'object' && + msg.type === 'user' && + 'message' in msg && + 'session_id' in msg && + 'parent_tool_use_id' in msg + ); +} + +export function isCLIAssistantMessage(msg: any): msg is CLIAssistantMessage { + return ( + msg && + typeof msg === 'object' && + msg.type === 'assistant' && + 'uuid' in msg && + 'message' in msg && + 'session_id' in msg && + 'parent_tool_use_id' in msg + ); +} + +export function isCLISystemMessage(msg: any): msg is CLISystemMessage { + return ( + msg && + typeof msg === 'object' && + msg.type === 'system' && + 'subtype' in msg && + 'uuid' in msg && + 'session_id' in msg + ); +} + +export function isCLIResultMessage(msg: any): msg is CLIResultMessage { + return ( + msg && + typeof msg === 'object' && + msg.type === 'result' && + 'subtype' in msg && + 'duration_ms' in msg && + 'is_error' in msg && + 'uuid' in msg && + 'session_id' in msg + ); +} + +export function isCLIPartialAssistantMessage( + msg: any, +): msg is CLIPartialAssistantMessage { + return ( + msg && + typeof msg === 'object' && + msg.type === 'stream_event' && + 'uuid' in msg && + 'session_id' in msg && + 'event' in msg && + 'parent_tool_use_id' in msg + ); +} + +export function isControlRequest(msg: any): msg is CLIControlRequest { + return ( + msg && + typeof msg === 'object' && + msg.type === 'control_request' && + 'request_id' in msg && + 'request' in msg + ); +} + +export function isControlResponse(msg: any): msg is CLIControlResponse { + return ( + msg && + typeof msg === 'object' && + msg.type === 'control_response' && + 'response' in msg + ); +} + +export function isControlCancel(msg: any): msg is ControlCancelRequest { + return ( + msg && + typeof msg === 'object' && + msg.type === 'control_cancel_request' && + 'request_id' in msg + ); +} + +/** + * Content block type guards + */ + +export function isTextBlock(block: any): block is TextBlock { + return block && typeof block === 'object' && block.type === 'text'; +} + +export function isThinkingBlock(block: any): block is ThinkingBlock { + return block && typeof block === 'object' && block.type === 'thinking'; +} + +export function isToolUseBlock(block: any): block is ToolUseBlock { + return block && typeof block === 'object' && block.type === 'tool_use'; +} + +export function isToolResultBlock(block: any): block is ToolResultBlock { + return block && typeof block === 'object' && block.type === 'tool_result'; +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index b648670b..4521cdab 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -62,7 +62,7 @@ import { WriteFileTool } from '../tools/write-file.js'; // Other modules import { ideContextStore } from '../ide/ideContext.js'; -import { OutputFormat } from '../output/types.js'; +import { InputFormat, OutputFormat } from '../output/types.js'; import { PromptRegistry } from '../prompts/prompt-registry.js'; import { SubagentManager } from '../subagents/subagent-manager.js'; import { @@ -214,8 +214,6 @@ export interface ConfigParameters { sandbox?: SandboxConfig; targetDir: string; debugMode: boolean; - inputFormat?: 'text' | 'stream-json'; - outputFormat?: OutputFormat | 'text' | 'json' | 'stream-json'; includePartialMessages?: boolean; question?: string; fullContext?: boolean; @@ -283,17 +281,19 @@ export interface ConfigParameters { eventEmitter?: EventEmitter; useSmartEdit?: boolean; output?: OutputSettings; + inputFormat?: InputFormat; + outputFormat?: OutputFormat; } function normalizeConfigOutputFormat( - format: OutputFormat | 'text' | 'json' | 'stream-json' | undefined, -): OutputFormat | 'stream-json' | undefined { + format: OutputFormat | undefined, +): OutputFormat | undefined { if (!format) { return undefined; } switch (format) { case 'stream-json': - return 'stream-json'; + return OutputFormat.STREAM_JSON; case 'json': case OutputFormat.JSON: return OutputFormat.JSON; @@ -318,8 +318,8 @@ export class Config { private readonly targetDir: string; private workspaceContext: WorkspaceContext; private readonly debugMode: boolean; - private readonly inputFormat: 'text' | 'stream-json'; - private readonly outputFormat: OutputFormat | 'stream-json'; + private readonly inputFormat: InputFormat; + private readonly outputFormat: OutputFormat; private readonly includePartialMessages: boolean; private readonly question: string | undefined; private readonly fullContext: boolean; @@ -407,7 +407,7 @@ export class Config { params.includeDirectories ?? [], ); this.debugMode = params.debugMode; - this.inputFormat = params.inputFormat ?? 'text'; + this.inputFormat = params.inputFormat ?? InputFormat.TEXT; const normalizedOutputFormat = normalizeConfigOutputFormat( params.outputFormat ?? params.output?.format, ); @@ -508,6 +508,7 @@ export class Config { this.storage = new Storage(this.targetDir); this.enablePromptCompletion = params.enablePromptCompletion ?? false; this.vlmSwitchMode = params.vlmSwitchMode; + this.inputFormat = params.inputFormat ?? InputFormat.TEXT; this.fileExclusions = new FileExclusions(this); this.eventEmitter = params.eventEmitter; if (params.contextFileName) { @@ -1082,7 +1083,7 @@ export class Config { return this.useSmartEdit; } - getOutputFormat(): OutputFormat | 'stream-json' { + getOutputFormat(): OutputFormat { return this.outputFormat; } diff --git a/packages/core/src/output/types.ts b/packages/core/src/output/types.ts index 08477d21..4a300a43 100644 --- a/packages/core/src/output/types.ts +++ b/packages/core/src/output/types.ts @@ -6,9 +6,15 @@ import type { SessionMetrics } from '../telemetry/uiTelemetry.js'; +export enum InputFormat { + TEXT = 'text', + STREAM_JSON = 'stream-json', +} + export enum OutputFormat { TEXT = 'text', JSON = 'json', + STREAM_JSON = 'stream-json', } export interface JsonError { diff --git a/packages/sdk/typescript/package.json b/packages/sdk/typescript/package.json new file mode 100644 index 00000000..b0b7885f --- /dev/null +++ b/packages/sdk/typescript/package.json @@ -0,0 +1,69 @@ +{ + "name": "@qwen-code/sdk-typescript", + "version": "0.1.0", + "description": "TypeScript SDK for programmatic access to qwen-code CLI", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsc", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "lint": "eslint src test", + "lint:fix": "eslint src test --fix", + "clean": "rm -rf dist", + "prepublishOnly": "npm run clean && npm run build" + }, + "keywords": [ + "qwen", + "qwen-code", + "ai", + "code-assistant", + "sdk", + "typescript" + ], + "author": "Qwen Team", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.4", + "@qwen-code/qwen-code": "file:../../cli" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "@typescript-eslint/eslint-plugin": "^7.13.0", + "@typescript-eslint/parser": "^7.13.0", + "@vitest/coverage-v8": "^1.6.0", + "eslint": "^8.57.0", + "typescript": "^5.4.5", + "vitest": "^1.6.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/qwen-ai/qwen-code.git", + "directory": "packages/sdk/typescript" + }, + "bugs": { + "url": "https://github.com/qwen-ai/qwen-code/issues" + }, + "homepage": "https://github.com/qwen-ai/qwen-code#readme" +} diff --git a/packages/sdk/typescript/src/index.ts b/packages/sdk/typescript/src/index.ts new file mode 100644 index 00000000..a5b3b253 --- /dev/null +++ b/packages/sdk/typescript/src/index.ts @@ -0,0 +1,108 @@ +/** + * TypeScript SDK for programmatic access to qwen-code CLI + * + * @example + * ```typescript + * import { query } from '@qwen-code/sdk-typescript'; + * + * const q = query({ + * prompt: 'What files are in this directory?', + * options: { cwd: process.cwd() }, + * }); + * + * for await (const message of q) { + * if (message.type === 'assistant') { + * console.log(message.message.content); + * } + * } + * + * await q.close(); + * ``` + */ + +// Main API +export { query } from './query/createQuery.js'; + +/** @deprecated Use query() instead */ +export { createQuery } from './query/createQuery.js'; + +export { Query } from './query/Query.js'; + +// Configuration types +export type { + CreateQueryOptions, + PermissionMode, + PermissionCallback, + ExternalMcpServerConfig, + TransportOptions, +} from './types/config.js'; + +export type { QueryOptions } from './query/createQuery.js'; + +// Protocol types +export type { + ContentBlock, + TextBlock, + ThinkingBlock, + ToolUseBlock, + ToolResultBlock, + CLIUserMessage, + CLIAssistantMessage, + CLISystemMessage, + CLIResultMessage, + CLIPartialAssistantMessage, + CLIMessage, +} from './types/protocol.js'; + +export { + isCLIUserMessage, + isCLIAssistantMessage, + isCLISystemMessage, + isCLIResultMessage, + isCLIPartialAssistantMessage, +} from './types/protocol.js'; + +export type { JSONSchema } from './types/mcp.js'; + +export { AbortError, isAbortError } from './types/errors.js'; + +// Control Request Types +export { + ControlRequestType, + getAllControlRequestTypes, + isValidControlRequestType, +} from './types/controlRequests.js'; + +// Transport +export { ProcessTransport } from './transport/ProcessTransport.js'; +export type { Transport } from './transport/Transport.js'; + +// Utilities +export { Stream } from './utils/Stream.js'; +export { + serializeJsonLine, + parseJsonLine, + parseJsonLineSafe, + isValidMessage, + parseJsonLinesStream, +} from './utils/jsonLines.js'; +export { + findCliPath, + resolveCliPath, + prepareSpawnInfo, +} from './utils/cliPath.js'; +export type { SpawnInfo } from './utils/cliPath.js'; + +// MCP helpers +export { + createSdkMcpServer, + createSimpleMcpServer, +} from './mcp/createSdkMcpServer.js'; +export { + tool, + createTool, + validateToolName, + validateInputSchema, +} from './mcp/tool.js'; + +export type { ToolDefinition } from './types/config.js'; diff --git a/packages/sdk/typescript/src/mcp/SdkControlServerTransport.ts b/packages/sdk/typescript/src/mcp/SdkControlServerTransport.ts new file mode 100644 index 00000000..d7540c17 --- /dev/null +++ b/packages/sdk/typescript/src/mcp/SdkControlServerTransport.ts @@ -0,0 +1,153 @@ +/** + * SdkControlServerTransport - bridges MCP Server with Query's control plane + * + * Implements @modelcontextprotocol/sdk Transport interface to enable + * SDK-embedded MCP servers. Messages flow bidirectionally: + * + * MCP Server → send() → Query → control_request (mcp_message) → CLI + * CLI → control_request (mcp_message) → Query → handleMessage() → MCP Server + */ + +import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; + +/** + * Callback type for sending messages to Query + */ +export type SendToQueryCallback = (message: JSONRPCMessage) => Promise; + +/** + * SdkControlServerTransport options + */ +export interface SdkControlServerTransportOptions { + sendToQuery: SendToQueryCallback; + serverName: string; +} + +/** + * Transport adapter that bridges MCP Server with Query's control plane + */ +export class SdkControlServerTransport { + public sendToQuery: SendToQueryCallback; + private serverName: string; + private started = false; + + /** + * Callbacks set by MCP Server + */ + onmessage?: (message: JSONRPCMessage) => void; + onerror?: (error: Error) => void; + onclose?: () => void; + + constructor(options: SdkControlServerTransportOptions) { + this.sendToQuery = options.sendToQuery; + this.serverName = options.serverName; + } + + /** + * Start the transport + */ + async start(): Promise { + this.started = true; + } + + /** + * Send message from MCP Server to CLI via Query's control plane + * + * @param message - JSON-RPC message from MCP Server + */ + async send(message: JSONRPCMessage): Promise { + if (!this.started) { + throw new Error( + `SdkControlServerTransport (${this.serverName}) not started. Call start() first.`, + ); + } + + try { + // Send via Query's control plane + await this.sendToQuery(message); + } catch (error) { + // Invoke error callback if set + if (this.onerror) { + this.onerror(error instanceof Error ? error : new Error(String(error))); + } + throw error; + } + } + + /** + * Close the transport + */ + async close(): Promise { + if (!this.started) { + return; // Already closed + } + + this.started = false; + + // Notify MCP Server + if (this.onclose) { + this.onclose(); + } + } + + /** + * Handle incoming message from CLI + * + * @param message - JSON-RPC message from CLI + */ + handleMessage(message: JSONRPCMessage): void { + if (!this.started) { + console.warn( + `[SdkControlServerTransport] Received message for closed transport (${this.serverName})`, + ); + return; + } + + if (this.onmessage) { + this.onmessage(message); + } else { + console.warn( + `[SdkControlServerTransport] No onmessage handler set for ${this.serverName}`, + ); + } + } + + /** + * Handle incoming error from CLI + * + * @param error - Error from CLI + */ + handleError(error: Error): void { + if (this.onerror) { + this.onerror(error); + } else { + console.error( + `[SdkControlServerTransport] Error for ${this.serverName}:`, + error, + ); + } + } + + /** + * Check if transport is started + */ + isStarted(): boolean { + return this.started; + } + + /** + * Get server name + */ + getServerName(): string { + return this.serverName; + } +} + +/** + * Create SdkControlServerTransport instance + */ +export function createSdkControlServerTransport( + options: SdkControlServerTransportOptions, +): SdkControlServerTransport { + return new SdkControlServerTransport(options); +} diff --git a/packages/sdk/typescript/src/mcp/createSdkMcpServer.ts b/packages/sdk/typescript/src/mcp/createSdkMcpServer.ts new file mode 100644 index 00000000..df1bd256 --- /dev/null +++ b/packages/sdk/typescript/src/mcp/createSdkMcpServer.ts @@ -0,0 +1,177 @@ +/** + * Factory function to create SDK-embedded MCP servers + * + * Creates MCP Server instances that run in the user's Node.js process + * and are proxied to the CLI via the control plane. + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { + ListToolsRequestSchema, + CallToolRequestSchema, + CallToolResult, +} from '@modelcontextprotocol/sdk/types.js'; +import type { ToolDefinition } from '../types/config.js'; +import { formatToolResult, formatToolError } from './formatters.js'; +import { validateToolName } from './tool.js'; + +/** + * Create an SDK-embedded MCP server with custom tools + * + * The server runs in your Node.js process and is proxied to the CLI. + * + * @param name - Server name (must be unique) + * @param version - Server version + * @param tools - Array of tool definitions + * @returns MCP Server instance + * + * @example + * ```typescript + * const server = createSdkMcpServer('database', '1.0.0', [ + * tool({ + * name: 'query_db', + * description: 'Query the database', + * inputSchema: { + * type: 'object', + * properties: { query: { type: 'string' } }, + * required: ['query'] + * }, + * handler: async (input) => db.query(input.query) + * }) + * ]); + * ``` + */ +export function createSdkMcpServer( + name: string, + version: string, + tools: ToolDefinition[], +): Server { + // Validate server name + if (!name || typeof name !== 'string') { + throw new Error('MCP server name must be a non-empty string'); + } + + if (!version || typeof version !== 'string') { + throw new Error('MCP server version must be a non-empty string'); + } + + if (!Array.isArray(tools)) { + throw new Error('Tools must be an array'); + } + + // Validate tool names are unique + const toolNames = new Set(); + for (const tool of tools) { + validateToolName(tool.name); + + if (toolNames.has(tool.name)) { + throw new Error( + `Duplicate tool name '${tool.name}' in MCP server '${name}'`, + ); + } + toolNames.add(tool.name); + } + + // Create MCP Server instance + const server = new Server( + { + name, + version, + }, + { + capabilities: { + tools: {}, + }, + }, + ); + + // Create tool map for fast lookup + const toolMap = new Map(); + for (const tool of tools) { + toolMap.set(tool.name, tool); + } + + // Register list_tools handler + server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: tools.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })), + }; + }); + + // Register call_tool handler + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name: toolName, arguments: toolArgs } = request.params; + + // Find tool + const tool = toolMap.get(toolName); + if (!tool) { + return formatToolError( + new Error(`Tool '${toolName}' not found in server '${name}'`), + ) as CallToolResult; + } + + try { + // Invoke tool handler + const result = await tool.handler(toolArgs); + + // Format result + return formatToolResult(result) as CallToolResult; + } catch (error) { + // Handle tool execution error + return formatToolError( + error instanceof Error + ? error + : new Error(`Tool '${toolName}' failed: ${String(error)}`), + ) as CallToolResult; + } + }); + + return server; +} + +/** + * Create MCP server with inline tool definitions + * + * @param name - Server name + * @param version - Server version + * @param toolDefinitions - Object mapping tool names to definitions + * @returns MCP Server instance + * + * @example + * ```typescript + * const server = createSimpleMcpServer('utils', '1.0.0', { + * greeting: { + * description: 'Generate a greeting', + * inputSchema: { + * type: 'object', + * properties: { name: { type: 'string' } }, + * required: ['name'] + * }, + * handler: async ({ name }) => `Hello, ${name}!` + * } + * }); + * ``` + */ +export function createSimpleMcpServer( + name: string, + version: string, + toolDefinitions: Record< + string, + Omit & { name?: string } + >, +): Server { + const tools: ToolDefinition[] = Object.entries(toolDefinitions).map( + ([toolName, def]) => ({ + name: def.name || toolName, + description: def.description, + inputSchema: def.inputSchema, + handler: def.handler, + }), + ); + + return createSdkMcpServer(name, version, tools); +} diff --git a/packages/sdk/typescript/src/mcp/formatters.ts b/packages/sdk/typescript/src/mcp/formatters.ts new file mode 100644 index 00000000..4406db51 --- /dev/null +++ b/packages/sdk/typescript/src/mcp/formatters.ts @@ -0,0 +1,247 @@ +/** + * Tool result formatting utilities for MCP responses + * + * Converts various output types to MCP content blocks. + */ + +/** + * MCP content block types + */ +export type McpContentBlock = + | { type: 'text'; text: string } + | { type: 'image'; data: string; mimeType: string } + | { type: 'resource'; uri: string; mimeType?: string; text?: string }; + +/** + * Tool result structure + */ +export interface ToolResult { + content: McpContentBlock[]; + isError?: boolean; +} + +/** + * Format tool result for MCP response + * + * Converts any value to MCP content blocks (strings, objects, errors, etc.) + * + * @param result - Tool handler output or error + * @returns Formatted tool result + * + * @example + * ```typescript + * formatToolResult('Hello') + * // → { content: [{ type: 'text', text: 'Hello' }] } + * + * formatToolResult({ temperature: 72 }) + * // → { content: [{ type: 'text', text: '{"temperature":72}' }] } + * ``` + */ +export function formatToolResult(result: unknown): ToolResult { + // Handle Error objects + if (result instanceof Error) { + return { + content: [ + { + type: 'text', + text: result.message || 'Unknown error', + }, + ], + isError: true, + }; + } + + // Handle null/undefined + if (result === null || result === undefined) { + return { + content: [ + { + type: 'text', + text: '', + }, + ], + }; + } + + // Handle string + if (typeof result === 'string') { + return { + content: [ + { + type: 'text', + text: result, + }, + ], + }; + } + + // Handle number + if (typeof result === 'number') { + return { + content: [ + { + type: 'text', + text: String(result), + }, + ], + }; + } + + // Handle boolean + if (typeof result === 'boolean') { + return { + content: [ + { + type: 'text', + text: String(result), + }, + ], + }; + } + + // Handle object (including arrays) + if (typeof result === 'object') { + try { + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } catch { + // JSON.stringify failed + return { + content: [ + { + type: 'text', + text: String(result), + }, + ], + }; + } + } + + // Fallback: convert to string + return { + content: [ + { + type: 'text', + text: String(result), + }, + ], + }; +} + +/** + * Format error for MCP response + * + * @param error - Error object or string + * @returns Tool result with error flag + */ +export function formatToolError(error: Error | string): ToolResult { + const message = error instanceof Error ? error.message : error; + + return { + content: [ + { + type: 'text', + text: message, + }, + ], + isError: true, + }; +} + +/** + * Format text content for MCP response + * + * @param text - Text content + * @returns Tool result with text content + */ +export function formatTextResult(text: string): ToolResult { + return { + content: [ + { + type: 'text', + text, + }, + ], + }; +} + +/** + * Format JSON content for MCP response + * + * @param data - Data to serialize as JSON + * @returns Tool result with JSON text content + */ +export function formatJsonResult(data: unknown): ToolResult { + return { + content: [ + { + type: 'text', + text: JSON.stringify(data, null, 2), + }, + ], + }; +} + +/** + * Merge multiple tool results into a single result + * + * @param results - Array of tool results + * @returns Merged tool result + */ +export function mergeToolResults(results: ToolResult[]): ToolResult { + const mergedContent: McpContentBlock[] = []; + let hasError = false; + + for (const result of results) { + mergedContent.push(...result.content); + if (result.isError) { + hasError = true; + } + } + + return { + content: mergedContent, + isError: hasError, + }; +} + +/** + * Validate MCP content block + * + * @param block - Content block to validate + * @returns True if valid + */ +export function isValidContentBlock(block: unknown): block is McpContentBlock { + if (!block || typeof block !== 'object') { + return false; + } + + const blockObj = block as Record; + + if (!blockObj.type || typeof blockObj.type !== 'string') { + return false; + } + + switch (blockObj.type) { + case 'text': + return typeof blockObj.text === 'string'; + + case 'image': + return ( + typeof blockObj.data === 'string' && + typeof blockObj.mimeType === 'string' + ); + + case 'resource': + return typeof blockObj.uri === 'string'; + + default: + return false; + } +} diff --git a/packages/sdk/typescript/src/mcp/tool.ts b/packages/sdk/typescript/src/mcp/tool.ts new file mode 100644 index 00000000..8e7eb7c2 --- /dev/null +++ b/packages/sdk/typescript/src/mcp/tool.ts @@ -0,0 +1,140 @@ +/** + * Tool definition helper for SDK-embedded MCP servers + * + * Provides type-safe tool definitions with generic input/output types. + */ + +import type { ToolDefinition } from '../types/config.js'; + +/** + * Create a type-safe tool definition + * + * Validates the tool definition and provides type inference for input/output types. + * + * @param def - Tool definition with handler + * @returns The same tool definition (for type safety) + * + * @example + * ```typescript + * const weatherTool = tool<{ location: string }, { temperature: number }>({ + * name: 'get_weather', + * description: 'Get weather for a location', + * inputSchema: { + * type: 'object', + * properties: { + * location: { type: 'string' } + * }, + * required: ['location'] + * }, + * handler: async (input) => { + * return { temperature: await fetchWeather(input.location) }; + * } + * }); + * ``` + */ +export function tool( + def: ToolDefinition, +): ToolDefinition { + // Validate tool definition + if (!def.name || typeof def.name !== 'string') { + throw new Error('Tool definition must have a name (string)'); + } + + if (!def.description || typeof def.description !== 'string') { + throw new Error( + `Tool definition for '${def.name}' must have a description (string)`, + ); + } + + if (!def.inputSchema || typeof def.inputSchema !== 'object') { + throw new Error( + `Tool definition for '${def.name}' must have an inputSchema (object)`, + ); + } + + if (!def.handler || typeof def.handler !== 'function') { + throw new Error( + `Tool definition for '${def.name}' must have a handler (function)`, + ); + } + + // Return definition (pass-through for type safety) + return def; +} + +/** + * Validate tool name + * + * Tool names must: + * - Start with a letter + * - Contain only letters, numbers, and underscores + * - Be between 1 and 64 characters + * + * @param name - Tool name to validate + * @throws Error if name is invalid + */ +export function validateToolName(name: string): void { + if (!name) { + throw new Error('Tool name cannot be empty'); + } + + if (name.length > 64) { + throw new Error( + `Tool name '${name}' is too long (max 64 characters): ${name.length}`, + ); + } + + if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(name)) { + throw new Error( + `Tool name '${name}' is invalid. Must start with a letter and contain only letters, numbers, and underscores.`, + ); + } +} + +/** + * Validate tool input schema (JSON Schema compliance) + * + * @param schema - Input schema to validate + * @throws Error if schema is invalid + */ +export function validateInputSchema(schema: unknown): void { + if (!schema || typeof schema !== 'object') { + throw new Error('Input schema must be an object'); + } + + const schemaObj = schema as Record; + + if (!schemaObj.type) { + throw new Error('Input schema must have a type field'); + } + + // For object schemas, validate properties + if (schemaObj.type === 'object') { + if (schemaObj.properties && typeof schemaObj.properties !== 'object') { + throw new Error('Input schema properties must be an object'); + } + + if (schemaObj.required && !Array.isArray(schemaObj.required)) { + throw new Error('Input schema required must be an array'); + } + } +} + +/** + * Create tool definition with strict validation + * + * @param def - Tool definition + * @returns Validated tool definition + */ +export function createTool( + def: ToolDefinition, +): ToolDefinition { + // Validate via tool() function + const validated = tool(def); + + // Additional validation + validateToolName(validated.name); + validateInputSchema(validated.inputSchema); + + return validated; +} diff --git a/packages/sdk/typescript/src/query/Query.ts b/packages/sdk/typescript/src/query/Query.ts new file mode 100644 index 00000000..e402c38a --- /dev/null +++ b/packages/sdk/typescript/src/query/Query.ts @@ -0,0 +1,882 @@ +/** + * 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(); + this.abortController = new AbortController(); + this.isSingleTurn = options.singleTurn ?? false; + + // Setup first result tracking + this.firstResultReceivedPromise = new Promise((resolve) => { + this.firstResultReceivedResolve = resolve; + }); + + // Handle external abort signal + if (options.signal) { + options.signal.addEventListener('abort', () => { + this.abortController.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, + 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); + } else { + pending.reject(new Error(payload.error ?? 'Unknown error')); + } + } + + /** + * 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) { + 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 new file mode 100644 index 00000000..b20cb22d --- /dev/null +++ b/packages/sdk/typescript/src/query/createQuery.ts @@ -0,0 +1,185 @@ +/** + * 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 { resolveCliPath, 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 } + >; + signal?: AbortSignal; + 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 + 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 path (auto-detect if not provided) + const pathToQwenExecutable = resolveCliPath(options.pathToQwenExecutable); + + // Pass signal to transport (it will handle AbortController internally) + const signal = options.signal; + + // Create transport + const transport = new ProcessTransport({ + pathToQwenExecutable, + cwd: options.cwd, + model: options.model, + permissionMode: options.permissionMode, + mcpServers: options.mcpServers, + env: options.env, + signal, + debug: options.debug, + stderr: options.stderr, + }); + + // Create Query + const queryInstance = new Query(transport, queryOptions); + + // 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, + }; + + // Send message after query is initialized + (async () => { + try { + // Wait a bit for initialization to complete + 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; + +/** + * Validates query configuration options. + */ +function validateOptions(options: QueryOptions): void { + // 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 signal is AbortSignal if provided + if (options.signal && !(options.signal instanceof AbortSignal)) { + throw new Error('signal must be an AbortSignal instance'); + } + + // Validate executable path early to provide clear error messages + try { + 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(', ')}`, + ); + } + } +} diff --git a/packages/sdk/typescript/src/transport/ProcessTransport.ts b/packages/sdk/typescript/src/transport/ProcessTransport.ts new file mode 100644 index 00000000..c8f4a47b --- /dev/null +++ b/packages/sdk/typescript/src/transport/ProcessTransport.ts @@ -0,0 +1,496 @@ +/** + * 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 abortHandler: (() => void) | 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 + } + + // Check if already aborted + if (this.options.signal?.aborted) { + throw new AbortError('Transport start aborted by signal'); + } + + const cliArgs = this.buildCliArguments(); + const cwd = this.options.cwd ?? process.cwd(); + const env = { ...process.env, ...this.options.env }; + + // Setup internal AbortController if signal provided + if (this.options.signal) { + this.abortController = new AbortController(); + this.abortHandler = () => { + this.logForDebugging('Transport aborted by user signal'); + this._exitError = new AbortError('Operation aborted by user'); + this._isReady = false; + void this.close(); + }; + this.options.signal.addEventListener('abort', this.abortHandler); + } + + // 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 internal AbortController signal if available + 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.options.signal?.aborted || + 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.options.signal?.aborted || + 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 abort handler + if (this.abortHandler && this.options.signal) { + this.options.signal.removeEventListener('abort', this.abortHandler); + this.abortHandler = null; + } + + // 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.options.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.options.signal?.aborted || + 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 new file mode 100644 index 00000000..caff806c --- /dev/null +++ b/packages/sdk/typescript/src/transport/Transport.ts @@ -0,0 +1,102 @@ +/** + * 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 new file mode 100644 index 00000000..d5bfc178 --- /dev/null +++ b/packages/sdk/typescript/src/types/config.ts @@ -0,0 +1,145 @@ +/** + * Configuration types for SDK + */ + +import type { ToolDefinition as ToolDef } from './mcp.js'; +import type { PermissionMode } from './protocol.js'; + +export type { ToolDef as ToolDefinition }; +export type { PermissionMode }; + +/** + * Permission callback function + * Called before each tool execution to determine if it should be allowed + * + * @param toolName - Name of the tool being executed + * @param input - Input parameters for the tool + * @param options - Additional options (signal for cancellation, suggestions) + * @returns Promise or boolean|unknown - true to allow, false to deny, or custom response + */ +export type PermissionCallback = ( + toolName: string, + input: Record, + options?: { + signal?: AbortSignal; + suggestions?: unknown; + }, +) => Promise | boolean | unknown; + +/** + * Hook callback function + * Called at specific points in tool execution lifecycle + * + * @param input - Hook input data + * @param toolUseId - Tool execution ID (null if not associated with a tool) + * @param options - Options including abort signal + * @returns Promise with hook result + */ +export type HookCallback = ( + input: unknown, + toolUseId: string | null, + options: { signal: AbortSignal }, +) => Promise; + +/** + * Hook matcher configuration + */ +export interface HookMatcher { + matcher: Record; + hooks: HookCallback[]; +} + +/** + * Hook configuration by event type + */ +export type HookConfig = { + [event: string]: HookMatcher[]; +}; + +/** + * External MCP server configuration (spawned by CLI) + */ +export type ExternalMcpServerConfig = { + /** Command to execute (e.g., 'mcp-server-filesystem') */ + command: string; + /** Command-line arguments */ + args?: string[]; + /** Environment variables */ + env?: Record; +}; + +/** + * Options for creating a Query instance + */ +export type CreateQueryOptions = { + // Basic configuration + /** Working directory for CLI execution */ + cwd?: string; + /** Model name (e.g., 'qwen-2.5-coder-32b-instruct') */ + model?: string; + + // Transport configuration + /** Path to qwen executable (auto-detected if omitted) */ + pathToQwenExecutable?: string; + /** Environment variables for CLI process */ + env?: Record; + + // Permission control + /** Permission mode ('default' | 'plan' | 'auto-edit' | 'yolo') */ + permissionMode?: PermissionMode; + /** Callback invoked before each tool execution */ + canUseTool?: PermissionCallback; + + // Hook system + /** Hook configuration for tool execution lifecycle */ + hooks?: HookConfig; + + // MCP server configuration + /** External MCP servers (spawned by CLI) */ + mcpServers?: Record; + /** SDK-embedded MCP servers (run in Node.js process) */ + sdkMcpServers?: Record< + string, + { connect: (transport: unknown) => Promise } + >; // Server from @modelcontextprotocol/sdk + + // Conversation mode + /** + * Single-turn mode: automatically close input after receiving result + * Multi-turn mode: keep input open for follow-up messages + * @default false (multi-turn) + */ + singleTurn?: boolean; + + // Advanced options + /** AbortSignal for cancellation support */ + signal?: AbortSignal; + /** 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; + /** AbortSignal for cancellation support */ + signal?: AbortSignal; + /** Enable debug output */ + debug?: boolean; + /** Callback for stderr output */ + stderr?: (message: string) => void; +}; diff --git a/packages/sdk/typescript/src/types/controlRequests.ts b/packages/sdk/typescript/src/types/controlRequests.ts new file mode 100644 index 00000000..b2634d3c --- /dev/null +++ b/packages/sdk/typescript/src/types/controlRequests.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Control Request Types + * + * Centralized enum for all control request subtypes supported by the CLI. + * This enum should be kept in sync with the controllers in: + * - packages/cli/src/services/control/controllers/systemController.ts + * - packages/cli/src/services/control/controllers/permissionController.ts + * - packages/cli/src/services/control/controllers/mcpController.ts + * - packages/cli/src/services/control/controllers/hookController.ts + */ +export enum ControlRequestType { + // SystemController requests + INITIALIZE = 'initialize', + INTERRUPT = 'interrupt', + SET_MODEL = 'set_model', + SUPPORTED_COMMANDS = 'supported_commands', + + // PermissionController requests + CAN_USE_TOOL = 'can_use_tool', + SET_PERMISSION_MODE = 'set_permission_mode', + + // MCPController requests + MCP_MESSAGE = 'mcp_message', + MCP_SERVER_STATUS = 'mcp_server_status', + + // HookController requests + HOOK_CALLBACK = 'hook_callback', +} + +/** + * Get all available control request types as a string array + */ +export function getAllControlRequestTypes(): string[] { + return Object.values(ControlRequestType); +} + +/** + * Check if a string is a valid control request type + */ +export function isValidControlRequestType( + type: string, +): type is ControlRequestType { + return getAllControlRequestTypes().includes(type); +} diff --git a/packages/sdk/typescript/src/types/errors.ts b/packages/sdk/typescript/src/types/errors.ts new file mode 100644 index 00000000..137893cd --- /dev/null +++ b/packages/sdk/typescript/src/types/errors.ts @@ -0,0 +1,27 @@ +/** + * Error types for SDK + */ + +/** + * Error thrown when an operation is aborted via AbortSignal + */ +export class AbortError extends Error { + constructor(message = 'Operation aborted') { + super(message); + this.name = 'AbortError'; + Object.setPrototypeOf(this, AbortError.prototype); + } +} + +/** + * Check if an error is an AbortError + */ +export function isAbortError(error: unknown): error is AbortError { + return ( + error instanceof AbortError || + (typeof error === 'object' && + error !== null && + 'name' in error && + error.name === 'AbortError') + ); +} diff --git a/packages/sdk/typescript/src/types/mcp.ts b/packages/sdk/typescript/src/types/mcp.ts new file mode 100644 index 00000000..53a8bfc9 --- /dev/null +++ b/packages/sdk/typescript/src/types/mcp.ts @@ -0,0 +1,32 @@ +/** + * MCP integration types for SDK + */ + +/** + * JSON Schema definition + * Used for tool input validation + */ +export type JSONSchema = { + type: string; + properties?: Record; + required?: string[]; + description?: string; + [key: string]: unknown; +}; + +/** + * Tool definition for SDK-embedded MCP servers + * + * @template TInput - Type of tool input (inferred from handler) + * @template TOutput - Type of tool output (inferred from handler return) + */ +export type ToolDefinition = { + /** Unique tool name */ + name: string; + /** Human-readable description (helps agent decide when to use it) */ + description: string; + /** JSON Schema for input validation */ + inputSchema: JSONSchema; + /** Async handler function that executes the tool */ + handler: (input: TInput) => Promise; +}; diff --git a/packages/sdk/typescript/src/types/protocol.ts b/packages/sdk/typescript/src/types/protocol.ts new file mode 100644 index 00000000..723f69db --- /dev/null +++ b/packages/sdk/typescript/src/types/protocol.ts @@ -0,0 +1,50 @@ +/** + * 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 new file mode 100644 index 00000000..cead9d7a --- /dev/null +++ b/packages/sdk/typescript/src/utils/Stream.ts @@ -0,0 +1,157 @@ +/** + * 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 new file mode 100644 index 00000000..ff368067 --- /dev/null +++ b/packages/sdk/typescript/src/utils/cliPath.ts @@ -0,0 +1,438 @@ +/** + * CLI path auto-detection and subprocess spawning utilities + * + * Supports multiple execution modes: + * 1. Native binary: 'qwen' (production) + * 2. Node.js bundle: 'node /path/to/cli.js' (production validation) + * 3. Bun bundle: 'bun /path/to/cli.js' (alternative runtime) + * 4. TypeScript source: 'tsx /path/to/index.ts' (development) + * + * Auto-detection locations for native binary: + * 1. QWEN_CODE_CLI_PATH environment variable + * 2. ~/.volta/bin/qwen + * 3. ~/.npm-global/bin/qwen + * 4. /usr/local/bin/qwen + * 5. ~/.local/bin/qwen + * 6. ~/node_modules/.bin/qwen + * 7. ~/.yarn/bin/qwen + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { execSync } from 'node:child_process'; + +/** + * Executable types supported by the SDK + */ +export type ExecutableType = 'native' | 'node' | 'bun' | 'tsx' | 'deno'; + +/** + * Spawn information for CLI process + */ +export type SpawnInfo = { + /** Command to execute (e.g., 'qwen', 'node', 'bun', 'tsx') */ + command: string; + /** Arguments to pass to command */ + args: string[]; + /** Type of executable detected */ + type: ExecutableType; + /** Original input that was resolved */ + originalInput: string; +}; + +/** + * Find native CLI executable path + * + * Searches global installation locations in order of priority. + * Only looks for native 'qwen' binary, not JS/TS files. + * + * @returns Absolute path to CLI executable + * @throws Error if CLI not found + */ +export function findNativeCliPath(): string { + const homeDir = process.env['HOME'] || process.env['USERPROFILE'] || ''; + + const candidates: Array = [ + // 1. Environment variable (highest priority) + process.env['QWEN_CODE_CLI_PATH'], + + // 2. Volta bin + path.join(homeDir, '.volta', 'bin', 'qwen'), + + // 3. Global npm installations + path.join(homeDir, '.npm-global', 'bin', 'qwen'), + + // 4. Common Unix binary locations + '/usr/local/bin/qwen', + + // 5. User local bin + path.join(homeDir, '.local', 'bin', 'qwen'), + + // 6. Node modules bin in home directory + path.join(homeDir, 'node_modules', '.bin', 'qwen'), + + // 7. Yarn global bin + path.join(homeDir, '.yarn', 'bin', 'qwen'), + ]; + + // Find first existing candidate + for (const candidate of candidates) { + if (candidate && fs.existsSync(candidate)) { + return path.resolve(candidate); + } + } + + // Not found - throw helpful error + throw new Error( + 'qwen CLI not found. Please:\n' + + ' 1. Install qwen globally: npm install -g qwen\n' + + ' 2. Or provide explicit executable: query({ pathToQwenExecutable: "/path/to/qwen" })\n' + + ' 3. Or set environment variable: QWEN_CODE_CLI_PATH="/path/to/qwen"\n' + + '\n' + + 'For development/testing, you can also use:\n' + + ' • TypeScript source: query({ pathToQwenExecutable: "/path/to/index.ts" })\n' + + ' • Node.js bundle: query({ pathToQwenExecutable: "/path/to/cli.js" })\n' + + ' • Force specific runtime: query({ pathToQwenExecutable: "bun:/path/to/cli.js" })', + ); +} + +/** + * Check if a command is available in the system PATH + * + * @param command - Command to check (e.g., 'bun', 'tsx', 'deno') + * @returns true if command is available + */ +function isCommandAvailable(command: string): boolean { + try { + // Use 'which' on Unix-like systems, 'where' on Windows + const whichCommand = process.platform === 'win32' ? 'where' : 'which'; + execSync(`${whichCommand} ${command}`, { + stdio: 'ignore', + timeout: 5000, // 5 second timeout + }); + return true; + } catch { + return false; + } +} + +/** + * Validate that a runtime is available on the system + * + * @param runtime - Runtime to validate (node, bun, tsx, deno) + * @returns true if runtime is available + */ +function validateRuntimeAvailability(runtime: string): boolean { + // Node.js is always available since we're running in Node.js + if (runtime === 'node') { + return true; + } + + // Check if the runtime command is available in PATH + return isCommandAvailable(runtime); +} + +/** + * Validate file extension matches expected runtime + * + * @param filePath - Path to the file + * @param runtime - Expected runtime + * @returns true if extension is compatible + */ +function validateFileExtensionForRuntime( + filePath: string, + runtime: string, +): boolean { + const ext = path.extname(filePath).toLowerCase(); + + switch (runtime) { + case 'node': + case 'bun': + return ['.js', '.mjs', '.cjs'].includes(ext); + case 'tsx': + return ['.ts', '.tsx'].includes(ext); + case 'deno': + return ['.ts', '.tsx', '.js', '.mjs'].includes(ext); + default: + return true; // Unknown runtime, let it pass + } +} + +/** + * Parse executable specification into components with comprehensive validation + * + * Supports multiple formats: + * - 'qwen' -> native binary (auto-detected) + * - '/path/to/qwen' -> native binary (explicit path) + * - '/path/to/cli.js' -> Node.js bundle (default for .js files) + * - '/path/to/index.ts' -> TypeScript source (requires tsx) + * + * Advanced runtime specification (for overriding defaults): + * - 'bun:/path/to/cli.js' -> Force Bun runtime + * - 'node:/path/to/cli.js' -> Force Node.js runtime + * - 'tsx:/path/to/index.ts' -> Force tsx runtime + * - 'deno:/path/to/cli.ts' -> Force Deno runtime + * + * @param executableSpec - Executable specification + * @returns Parsed executable information + * @throws Error if specification is invalid or files don't exist + */ +export function parseExecutableSpec(executableSpec?: string): { + runtime?: string; + executablePath: string; + isExplicitRuntime: boolean; +} { + // Handle empty string case first (before checking for undefined/null) + if ( + executableSpec === '' || + (executableSpec && executableSpec.trim() === '') + ) { + throw new Error('Command name cannot be empty'); + } + + if (!executableSpec) { + // Auto-detect native CLI + return { + executablePath: findNativeCliPath(), + isExplicitRuntime: false, + }; + } + + // Check for runtime prefix (e.g., 'bun:/path/to/cli.js') + const runtimeMatch = executableSpec.match(/^([^:]+):(.+)$/); + if (runtimeMatch) { + const [, runtime, filePath] = runtimeMatch; + if (!runtime || !filePath) { + throw new Error(`Invalid runtime specification: '${executableSpec}'`); + } + + // Validate runtime is supported + const supportedRuntimes = ['node', 'bun', 'tsx', 'deno']; + if (!supportedRuntimes.includes(runtime)) { + throw new Error( + `Unsupported runtime '${runtime}'. Supported runtimes: ${supportedRuntimes.join(', ')}`, + ); + } + + // Validate runtime availability + if (!validateRuntimeAvailability(runtime)) { + throw new Error( + `Runtime '${runtime}' is not available on this system. Please install it first.`, + ); + } + + const resolvedPath = path.resolve(filePath); + + // Validate file exists + if (!fs.existsSync(resolvedPath)) { + throw new Error( + `Executable file not found at '${resolvedPath}' for runtime '${runtime}'. ` + + 'Please check the file path and ensure the file exists.', + ); + } + + // Validate file extension matches runtime + if (!validateFileExtensionForRuntime(resolvedPath, runtime)) { + const ext = path.extname(resolvedPath); + throw new Error( + `File extension '${ext}' is not compatible with runtime '${runtime}'. ` + + `Expected extensions for ${runtime}: ${getExpectedExtensions(runtime).join(', ')}`, + ); + } + + return { + runtime, + executablePath: resolvedPath, + isExplicitRuntime: true, + }; + } + + // Check if it's a command name (no path separators) or a file path + const isCommandName = + !executableSpec.includes('/') && !executableSpec.includes('\\'); + + if (isCommandName) { + // It's a command name like 'qwen' - validate it's a reasonable command name + if (!executableSpec || executableSpec.trim() === '') { + throw new Error('Command name cannot be empty'); + } + + // Basic validation for command names + if (!/^[a-zA-Z0-9._-]+$/.test(executableSpec)) { + throw new Error( + `Invalid command name '${executableSpec}'. Command names should only contain letters, numbers, dots, hyphens, and underscores.`, + ); + } + + return { + executablePath: executableSpec, + isExplicitRuntime: false, + }; + } + + // It's a file path - validate and resolve + const resolvedPath = path.resolve(executableSpec); + + if (!fs.existsSync(resolvedPath)) { + throw new Error( + `Executable file not found at '${resolvedPath}'. ` + + 'Please check the file path and ensure the file exists. ' + + 'You can also:\n' + + ' • Set QWEN_CODE_CLI_PATH environment variable\n' + + ' • Install qwen globally: npm install -g qwen\n' + + ' • For TypeScript files, ensure tsx is installed: npm install -g tsx\n' + + ' • Force specific runtime: bun:/path/to/cli.js or tsx:/path/to/index.ts', + ); + } + + // Additional validation for file paths + const stats = fs.statSync(resolvedPath); + if (!stats.isFile()) { + throw new Error( + `Path '${resolvedPath}' exists but is not a file. Please provide a path to an executable file.`, + ); + } + + return { + executablePath: resolvedPath, + isExplicitRuntime: false, + }; +} + +/** + * Get expected file extensions for a runtime + */ +function getExpectedExtensions(runtime: string): string[] { + switch (runtime) { + case 'node': + case 'bun': + return ['.js', '.mjs', '.cjs']; + case 'tsx': + return ['.ts', '.tsx']; + case 'deno': + return ['.ts', '.tsx', '.js', '.mjs']; + default: + return []; + } +} + +/** + * Resolve CLI path from options (backward compatibility) + * + * @param explicitPath - Optional explicit CLI path or command name + * @returns Resolved CLI path + * @throws Error if CLI not found + * @deprecated Use parseExecutableSpec and prepareSpawnInfo instead + */ +export function resolveCliPath(explicitPath?: string): string { + const parsed = parseExecutableSpec(explicitPath); + return parsed.executablePath; +} + +/** + * Detect runtime for file based on extension + * + * Uses sensible defaults: + * - JavaScript files (.js, .mjs, .cjs) -> Node.js (default choice) + * - TypeScript files (.ts, .tsx) -> tsx (if available) + * + * @param filePath - Path to the file + * @returns Suggested runtime or undefined for native executables + */ +function detectRuntimeFromExtension(filePath: string): string | undefined { + const ext = path.extname(filePath).toLowerCase(); + + if (['.js', '.mjs', '.cjs'].includes(ext)) { + // Default to Node.js for JavaScript files + return 'node'; + } + + if (['.ts', '.tsx'].includes(ext)) { + // Check if tsx is available for TypeScript files + if (isCommandAvailable('tsx')) { + return 'tsx'; + } + // If tsx is not available, suggest it in error message + throw new Error( + `TypeScript file '${filePath}' requires 'tsx' runtime, but it's not available. ` + + 'Please install tsx: npm install -g tsx, or use explicit runtime: tsx:/path/to/file.ts', + ); + } + + // Native executable or unknown extension + return undefined; +} + +/** + * Prepare spawn information for CLI process + * + * Handles all supported executable formats with clear separation of concerns: + * 1. Parse the executable specification + * 2. Determine the appropriate runtime + * 3. Build the spawn command and arguments + * + * @param executableSpec - Executable specification (path, command, or runtime:path) + * @returns SpawnInfo with command and args for spawning + * + * @example + * ```typescript + * // Native binary (production) + * prepareSpawnInfo('qwen') // -> { command: 'qwen', args: [], type: 'native' } + * + * // Node.js bundle (default for .js files) + * prepareSpawnInfo('/path/to/cli.js') // -> { command: 'node', args: ['/path/to/cli.js'], type: 'node' } + * + * // TypeScript source (development, requires tsx) + * prepareSpawnInfo('/path/to/index.ts') // -> { command: 'tsx', args: ['/path/to/index.ts'], type: 'tsx' } + * + * // Advanced: Force specific runtime + * prepareSpawnInfo('bun:/path/to/cli.js') // -> { command: 'bun', args: ['/path/to/cli.js'], type: 'bun' } + * ``` + */ +export function prepareSpawnInfo(executableSpec?: string): SpawnInfo { + const parsed = parseExecutableSpec(executableSpec); + const { runtime, executablePath, isExplicitRuntime } = parsed; + + // If runtime is explicitly specified, use it + if (isExplicitRuntime && runtime) { + const runtimeCommand = runtime === 'node' ? process.execPath : runtime; + + return { + command: runtimeCommand, + args: [executablePath], + type: runtime as ExecutableType, + originalInput: executableSpec || '', + }; + } + + // If no explicit runtime, try to detect from file extension + const detectedRuntime = detectRuntimeFromExtension(executablePath); + + if (detectedRuntime) { + const runtimeCommand = + detectedRuntime === 'node' ? process.execPath : detectedRuntime; + + return { + command: runtimeCommand, + args: [executablePath], + type: detectedRuntime as ExecutableType, + originalInput: executableSpec || '', + }; + } + + // Native executable or command name - use it directly + return { + command: executablePath, + args: [], + type: 'native', + originalInput: executableSpec || '', + }; +} + +/** + * Legacy function for backward compatibility + * @deprecated Use prepareSpawnInfo() instead + */ +export function findCliPath(): string { + return findNativeCliPath(); +} diff --git a/packages/sdk/typescript/src/utils/jsonLines.ts b/packages/sdk/typescript/src/utils/jsonLines.ts new file mode 100644 index 00000000..65fd2ff6 --- /dev/null +++ b/packages/sdk/typescript/src/utils/jsonLines.ts @@ -0,0 +1,137 @@ +/** + * JSON Lines protocol utilities + * + * JSON Lines format: one JSON object per line, newline-delimited + * Example: + * {"type":"user","message":{...}} + * {"type":"assistant","message":{...}} + * + * Used for SDK-CLI communication over stdin/stdout streams. + */ + +/** + * Serialize a message to JSON Lines format + * + * Converts object to JSON and appends newline. + * + * @param message - Object to serialize + * @returns JSON string with trailing newline + * @throws Error if JSON serialization fails + */ +export function serializeJsonLine(message: unknown): string { + try { + return JSON.stringify(message) + '\n'; + } catch (error) { + throw new Error( + `Failed to serialize message to JSON: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +/** + * Parse a JSON Lines message + * + * Parses single line of JSON (without newline). + * + * @param line - JSON string (without trailing newline) + * @returns Parsed object + * @throws Error if JSON parsing fails + */ +export function parseJsonLine(line: string): unknown { + try { + return JSON.parse(line); + } catch (error) { + throw new Error( + `Failed to parse JSON line: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +/** + * Parse JSON Lines with error handling + * + * Attempts to parse JSON line, logs warning and returns null on failure. + * Useful for robust parsing where malformed messages should be skipped. + * + * @param line - JSON string (without trailing newline) + * @param context - Context string for error logging (e.g., 'Transport') + * @returns Parsed object or null if parsing fails + */ +export function parseJsonLineSafe( + line: string, + context = 'JsonLines', +): unknown | null { + try { + return JSON.parse(line); + } catch (error) { + console.warn( + `[${context}] Failed to parse JSON line, skipping:`, + line.substring(0, 100), + error instanceof Error ? error.message : String(error), + ); + return null; + } +} + +/** + * Validate message has required type field + * + * Ensures message conforms to basic message protocol. + * + * @param message - Parsed message object + * @returns true if valid, false otherwise + */ +export function isValidMessage(message: unknown): boolean { + return ( + message !== null && + typeof message === 'object' && + 'type' in message && + typeof (message as { type: unknown }).type === 'string' + ); +} + +/** + * Async generator that yields parsed JSON Lines from async iterable of strings + * + * Usage: + * ```typescript + * const lines = readline.createInterface({ input: stream }); + * for await (const message of parseJsonLinesStream(lines)) { + * console.log(message); + * } + * ``` + * + * @param lines - AsyncIterable of line strings + * @param context - Context string for error logging + * @yields Parsed message objects (skips malformed lines) + */ +export async function* parseJsonLinesStream( + lines: AsyncIterable, + context = 'JsonLines', +): AsyncGenerator { + for await (const line of lines) { + // Skip empty lines + if (line.trim().length === 0) { + continue; + } + + // Parse with error handling + const message = parseJsonLineSafe(line, context); + + // Skip malformed messages + if (message === null) { + continue; + } + + // Validate message structure + if (!isValidMessage(message)) { + console.warn( + `[${context}] Invalid message structure (missing 'type' field), skipping:`, + line.substring(0, 100), + ); + continue; + } + + yield message; + } +} diff --git a/packages/sdk/typescript/test/e2e/abort-and-lifecycle.test.ts b/packages/sdk/typescript/test/e2e/abort-and-lifecycle.test.ts new file mode 100644 index 00000000..9a179278 --- /dev/null +++ b/packages/sdk/typescript/test/e2e/abort-and-lifecycle.test.ts @@ -0,0 +1,486 @@ +/** + * 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 2 seconds + setTimeout(() => { + controller.abort(); + }, 2000); + + const q = query({ + prompt: 'Write a very long story about TypeScript programming', + options: { + ...SHARED_TEST_OPTIONS, + signal: controller.signal, + 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, + signal: controller.signal, + debug: false, + }, + }); + + // Abort immediately + setTimeout(() => controller.abort(), 100); + + 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, + signal: controller.signal, + 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, + signal: controller.signal, + 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, + signal: controller.signal, + 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, + signal: controller.signal, + debug: false, + }, + }); + + // Start and close immediately + const iterator = q[Symbol.asyncIterator](); + await iterator.next(); + await q.close(); + + // Abort after close + controller.abort(); + + // Should not throw + expect(true).toBe(true); + }, + TEST_TIMEOUT, + ); + }); +}); diff --git a/packages/sdk/typescript/test/e2e/basic-usage.test.ts b/packages/sdk/typescript/test/e2e/basic-usage.test.ts new file mode 100644 index 00000000..820de698 --- /dev/null +++ b/packages/sdk/typescript/test/e2e/basic-usage.test.ts @@ -0,0 +1,521 @@ +/** + * 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: false, + }, + }); + + 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(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should handle message content extraction', + async () => { + const q = query({ + prompt: 'Say hello and explain what you are', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + let assistantMessage: CLIAssistantMessage | null = null; + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + assistantMessage = message; + break; + } + } + + expect(assistantMessage).not.toBeNull(); + expect(assistantMessage!.message.content).toBeDefined(); + + // Extract text blocks + const textBlocks = assistantMessage!.message.content.filter( + (block: ContentBlock): block is TextBlock => block.type === 'text', + ); + + expect(textBlocks.length).toBeGreaterThan(0); + expect(textBlocks[0].text).toBeDefined(); + expect(textBlocks[0].text.length).toBeGreaterThan(0); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('Basic Query Patterns', () => { + it( + 'should handle simple question-answer pattern', + async () => { + const q = query({ + prompt: 'What is 2 + 2?', + options: { + ...SHARED_TEST_OPTIONS, + debug: true, + }, + }); + + const messages: CLIMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + expect(messages.length).toBeGreaterThan(0); + + // Should have assistant response + const assistantMessages = messages.filter(isCLIAssistantMessage); + expect(assistantMessages.length).toBeGreaterThan(0); + + // Should end with result + const lastMessage = messages[messages.length - 1]; + expect(isCLIResultMessage(lastMessage)).toBe(true); + if (isCLIResultMessage(lastMessage)) { + expect(lastMessage.subtype).toBe('success'); + } + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should handle file system query pattern', + async () => { + const q = query({ + prompt: + 'What files are in the current directory? List only the top-level files and folders.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: process.cwd(), + debug: true, + }, + }); + + const messages: CLIMessage[] = []; + let hasToolUse = false; + let hasToolResult = false; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + const toolUseBlock = message.message.content.find( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use', + ); + if (toolUseBlock) { + hasToolUse = true; + expect(toolUseBlock.name).toBeDefined(); + expect(toolUseBlock.id).toBeDefined(); + } + } + + if (isCLIUserMessage(message)) { + // Tool results are sent as user messages with ToolResultBlock[] content + if (Array.isArray(message.message.content)) { + const toolResultBlock = message.message.content.find( + (block: ToolResultBlock): block is ToolResultBlock => + block.type === 'tool_result', + ); + if (toolResultBlock) { + hasToolResult = true; + expect(toolResultBlock.tool_use_id).toBeDefined(); + expect(toolResultBlock.content).toBeDefined(); + } + } + } + + if (isCLIResultMessage(message)) { + break; + } + } + + expect(messages.length).toBeGreaterThan(0); + expect(hasToolUse).toBe(true); + expect(hasToolResult).toBe(true); + + // Should have assistant response after tool execution + const assistantMessages = messages.filter(isCLIAssistantMessage); + expect(assistantMessages.length).toBeGreaterThan(0); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('Configuration and Options', () => { + it( + 'should respect debug option', + async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + debug: true, + stderr: (message: string) => { + stderrMessages.push(message); + }, + }, + }); + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + break; + } + } + + // Debug mode should produce stderr output + expect(stderrMessages.length).toBeGreaterThan(0); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should respect cwd option', + async () => { + const q = query({ + prompt: 'List files in current directory', + options: { + ...SHARED_TEST_OPTIONS, + cwd: process.cwd(), + debug: false, + }, + }); + + let hasResponse = false; + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + hasResponse = true; + break; + } + } + + expect(hasResponse).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('SDK-CLI Handshaking Process', () => { + it( + 'should receive system message after initialization', + async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + let systemMessage: CLISystemMessage | null = null; + + try { + for await (const message of q) { + messages.push(message); + + // Capture system message + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + break; // Exit early once we get the system message + } + + // Stop after getting assistant response to avoid long execution + if (isCLIAssistantMessage(message)) { + break; + } + } + + // Verify system message was received after initialization + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.type).toBe('system'); + expect(systemMessage!.subtype).toBe('init'); + + // Validate system message structure matches sendSystemMessage() + expect(systemMessage!.uuid).toBeDefined(); + expect(systemMessage!.session_id).toBeDefined(); + expect(systemMessage!.cwd).toBeDefined(); + expect(systemMessage!.tools).toBeDefined(); + expect(Array.isArray(systemMessage!.tools)).toBe(true); + expect(systemMessage!.mcp_servers).toBeDefined(); + expect(Array.isArray(systemMessage!.mcp_servers)).toBe(true); + expect(systemMessage!.model).toBeDefined(); + expect(systemMessage!.permissionMode).toBeDefined(); + expect(systemMessage!.slash_commands).toBeDefined(); + expect(Array.isArray(systemMessage!.slash_commands)).toBe(true); + expect(systemMessage!.apiKeySource).toBeDefined(); + expect(systemMessage!.qwen_code_version).toBeDefined(); + expect(systemMessage!.output_style).toBeDefined(); + expect(systemMessage!.agents).toBeDefined(); + expect(Array.isArray(systemMessage!.agents)).toBe(true); + expect(systemMessage!.skills).toBeDefined(); + expect(Array.isArray(systemMessage!.skills)).toBe(true); + + // Verify system message appears early in the message sequence + const systemMessageIndex = messages.findIndex( + (msg) => isCLISystemMessage(msg) && msg.subtype === 'init', + ); + expect(systemMessageIndex).toBeGreaterThanOrEqual(0); + expect(systemMessageIndex).toBeLessThan(3); // Should be one of the first few messages + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should handle initialization with session ID consistency', + async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + let systemMessage: CLISystemMessage | null = null; + let userMessage: CLIUserMessage | null = null; + const sessionId = q.getSessionId(); + + try { + for await (const message of q) { + // Capture system message + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + + // Capture user message + if (isCLIUserMessage(message)) { + userMessage = message; + } + + // Stop after getting assistant response to avoid long execution + if (isCLIAssistantMessage(message)) { + break; + } + } + + // Verify session IDs are consistent within the system + expect(sessionId).toBeDefined(); + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.session_id).toBeDefined(); + expect(systemMessage!.uuid).toBeDefined(); + + // System message should have consistent session_id and uuid + expect(systemMessage!.session_id).toBe(systemMessage!.uuid); + + if (userMessage) { + expect(userMessage.session_id).toBeDefined(); + // User message should have the same session_id as system message + expect(userMessage.session_id).toBe(systemMessage!.session_id); + } + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('Message Flow Validation', () => { + it( + 'should follow expected message sequence', + async () => { + const q = query({ + prompt: 'What is the current time?', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const messageSequence: string[] = []; + + try { + for await (const message of q) { + messageSequence.push(message.type); + + if (isCLIResultMessage(message)) { + break; + } + } + + expect(messageSequence.length).toBeGreaterThan(0); + + // Should end with result + expect(messageSequence[messageSequence.length - 1]).toBe('result'); + + // Should have at least one assistant message + expect(messageSequence).toContain('assistant'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should handle graceful completion', + async () => { + const q = query({ + prompt: 'Say goodbye', + options: { + ...SHARED_TEST_OPTIONS, + debug: true, + }, + }); + + let completedNaturally = false; + let messageCount = 0; + + try { + for await (const message of q) { + messageCount++; + + if (isCLIResultMessage(message)) { + completedNaturally = true; + expect(message.subtype).toBe('success'); + } + } + + expect(messageCount).toBeGreaterThan(0); + expect(completedNaturally).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); +}); diff --git a/packages/sdk/typescript/test/e2e/multi-turn.test.ts b/packages/sdk/typescript/test/e2e/multi-turn.test.ts new file mode 100644 index 00000000..21501a97 --- /dev/null +++ b/packages/sdk/typescript/test/e2e/multi-turn.test.ts @@ -0,0 +1,519 @@ +/** + * 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); + const text = extractText(message.message.content); + expect(text.length).toBeGreaterThan(0); + turnCount++; + } + } + + expect(messages.length).toBeGreaterThan(0); + expect(assistantMessages.length).toBeGreaterThanOrEqual(3); // Should have responses to all 3 questions + expect(turnCount).toBeGreaterThanOrEqual(3); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should maintain session context across turns', + async () => { + async function* createContextualConversation(): AsyncIterable { + const sessionId = crypto.randomUUID(); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: + 'My name is Alice. Remember this during our current conversation.', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 200)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'What is my name?', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + } + + const q = query({ + prompt: createContextualConversation(), + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const assistantMessages: CLIAssistantMessage[] = []; + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + assistantMessages.push(message); + } + } + + expect(assistantMessages.length).toBeGreaterThanOrEqual(2); + + // The second response should reference the name Alice + const secondResponse = extractText( + assistantMessages[1].message.content, + ); + expect(secondResponse.toLowerCase()).toContain('alice'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('Tool Usage in Multi-Turn', () => { + it( + 'should handle tool usage across multiple turns', + async () => { + async function* createToolConversation(): AsyncIterable { + const sessionId = crypto.randomUUID(); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'List the files in the current directory', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 200)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'Now tell me about the package.json file specifically', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + } + + const q = query({ + prompt: createToolConversation(), + options: { + ...SHARED_TEST_OPTIONS, + cwd: process.cwd(), + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + let toolUseCount = 0; + let assistantCount = 0; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + const hasToolUseBlock = message.message.content.some( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use', + ); + if (hasToolUseBlock) { + toolUseCount++; + } + } + + if (isCLIAssistantMessage(message)) { + assistantCount++; + } + + if (isCLIResultMessage(message)) { + break; + } + } + + expect(messages.length).toBeGreaterThan(0); + expect(toolUseCount).toBeGreaterThan(0); // Should use tools + expect(assistantCount).toBeGreaterThanOrEqual(2); // Should have responses to both questions + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('Message Flow and Sequencing', () => { + it( + 'should process messages in correct sequence', + async () => { + async function* createSequentialConversation(): AsyncIterable { + const sessionId = crypto.randomUUID(); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'First question: What is 1 + 1?', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 100)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'Second question: What is 2 + 2?', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + } + + const q = query({ + prompt: createSequentialConversation(), + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const messageSequence: string[] = []; + const assistantResponses: string[] = []; + + try { + for await (const message of q) { + const messageType = getMessageType(message); + messageSequence.push(messageType); + + if (isCLIAssistantMessage(message)) { + const text = extractText(message.message.content); + assistantResponses.push(text); + } + } + + expect(messageSequence.length).toBeGreaterThan(0); + expect(assistantResponses.length).toBeGreaterThanOrEqual(2); + + // Should end with result + expect(messageSequence[messageSequence.length - 1]).toContain( + 'RESULT', + ); + + // Should have assistant responses + expect( + messageSequence.some((type) => type.includes('ASSISTANT')), + ).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should handle conversation completion correctly', + async () => { + async function* createSimpleConversation(): AsyncIterable { + const sessionId = crypto.randomUUID(); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'Hello', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 100)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'Goodbye', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + } + + const q = query({ + prompt: createSimpleConversation(), + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + let completedNaturally = false; + let messageCount = 0; + + try { + for await (const message of q) { + messageCount++; + + if (isCLIResultMessage(message)) { + completedNaturally = true; + expect(message.subtype).toBe('success'); + } + } + + expect(messageCount).toBeGreaterThan(0); + expect(completedNaturally).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('Error Handling in Multi-Turn', () => { + it( + 'should handle empty conversation gracefully', + async () => { + async function* createEmptyConversation(): AsyncIterable { + // Generator that yields nothing + /* eslint-disable no-constant-condition */ + if (false) { + yield {} as CLIUserMessage; // Unreachable, but satisfies TypeScript + } + } + + const q = query({ + prompt: createEmptyConversation(), + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIResultMessage(message)) { + break; + } + } + + // Should handle empty conversation without crashing + expect(true).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should handle conversation with delays', + async () => { + async function* createDelayedConversation(): AsyncIterable { + const sessionId = crypto.randomUUID(); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'First message', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + + // Longer delay to test patience + await new Promise((resolve) => setTimeout(resolve, 500)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'Second message after delay', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + } + + const q = query({ + prompt: createDelayedConversation(), + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const assistantMessages: CLIAssistantMessage[] = []; + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + assistantMessages.push(message); + } + } + + expect(assistantMessages.length).toBeGreaterThanOrEqual(2); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); +}); diff --git a/packages/sdk/typescript/test/e2e/simple-query.test.ts b/packages/sdk/typescript/test/e2e/simple-query.test.ts new file mode 100644 index 00000000..1340f096 --- /dev/null +++ b/packages/sdk/typescript/test/e2e/simple-query.test.ts @@ -0,0 +1,744 @@ +/** + * 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: 'Test completion', + 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, + signal: controller.signal, + 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, + signal: controller.signal, + debug: false, + }, + }); + + // Abort immediately + setTimeout(() => controller.abort(), 100); + + try { + for await (const _message of q) { + // Should be interrupted + } + } catch (error) { + expect(error instanceof AbortError).toBe(true); + } finally { + // Should cleanup successfully even after abort + await q.close(); + expect(true).toBe(true); // Cleanup completed + } + }, + TEST_TIMEOUT, + ); + }); + + describe('Message Collection Patterns', () => { + it( + 'should collect all messages in array', + async () => { + const q = query({ + prompt: 'What is 2 + 2?', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + expect(messages.length).toBeGreaterThan(0); + + // Should have various message types + const messageTypes = messages.map((m) => m.type); + expect(messageTypes).toContain('assistant'); + expect(messageTypes).toContain('result'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should extract final answer', + async () => { + const q = query({ + prompt: 'What is the capital of France?', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Get last assistant message content + const assistantMessages = messages.filter(isCLIAssistantMessage); + expect(assistantMessages.length).toBeGreaterThan(0); + + const lastAssistant = assistantMessages[assistantMessages.length - 1]; + const textBlocks = lastAssistant.message.content.filter( + (block: ContentBlock): block is TextBlock => block.type === 'text', + ); + + expect(textBlocks.length).toBeGreaterThan(0); + expect(textBlocks[0].text).toContain('Paris'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should track tool usage', + async () => { + const q = query({ + prompt: 'List files in current directory', + options: { + ...SHARED_TEST_OPTIONS, + cwd: process.cwd(), + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Count tool_use blocks in assistant messages and tool_result blocks in user messages + let toolUseCount = 0; + let toolResultCount = 0; + + messages.forEach((message) => { + if (isCLIAssistantMessage(message)) { + message.message.content.forEach((block: ContentBlock) => { + if (block.type === 'tool_use') { + toolUseCount++; + } + }); + } else if (isCLIUserMessage(message)) { + // Tool results are in user messages + if (Array.isArray(message.message.content)) { + message.message.content.forEach((block: ContentBlock) => { + if (block.type === 'tool_result') { + toolResultCount++; + } + }); + } + } + }); + + expect(toolUseCount).toBeGreaterThan(0); + expect(toolResultCount).toBeGreaterThan(0); + + // Each tool_use should have a corresponding tool_result + expect(toolResultCount).toBeGreaterThanOrEqual(toolUseCount); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('Real-World Scenarios', () => { + it( + 'should handle code analysis query', + async () => { + const q = query({ + prompt: + 'What is the main export of the package.json file in this directory?', + options: { + ...SHARED_TEST_OPTIONS, + cwd: process.cwd(), + debug: false, + }, + }); + + let hasAnalysis = false; + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + const textBlocks = message.message.content.filter( + (block: ContentBlock): block is TextBlock => + block.type === 'text', + ); + if (textBlocks.length > 0 && textBlocks[0].text.length > 0) { + hasAnalysis = true; + break; + } + } + } + + expect(hasAnalysis).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should handle multi-step query', + async () => { + const q = query({ + prompt: + 'List the files in this directory and tell me what type of project this is', + options: { + ...SHARED_TEST_OPTIONS, + cwd: process.cwd(), + debug: false, + }, + }); + + let hasToolUse = false; + let hasAnalysis = false; + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + const hasToolUseBlock = message.message.content.some( + (block: ContentBlock) => block.type === 'tool_use', + ); + if (hasToolUseBlock) { + hasToolUse = true; + } + } + + if (isCLIAssistantMessage(message)) { + const textBlocks = message.message.content.filter( + (block: ContentBlock): block is TextBlock => + block.type === 'text', + ); + if (textBlocks.length > 0 && textBlocks[0].text.length > 0) { + hasAnalysis = true; + } + } + + if (isCLIResultMessage(message)) { + break; + } + } + + expect(hasToolUse).toBe(true); + expect(hasAnalysis).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); +}); diff --git a/packages/sdk/typescript/test/unit/ProcessTransport.test.ts b/packages/sdk/typescript/test/unit/ProcessTransport.test.ts new file mode 100644 index 00000000..c470f884 --- /dev/null +++ b/packages/sdk/typescript/test/unit/ProcessTransport.test.ts @@ -0,0 +1,207 @@ +/** + * Unit tests for ProcessTransport + * Tests subprocess lifecycle management and IPC + */ + +import { describe, expect, it } from 'vitest'; + +// Note: This is a placeholder test file +// ProcessTransport will be implemented in Phase 3 Implementation (T021) +// These tests are written first following TDD approach + +describe('ProcessTransport', () => { + describe('Construction and Initialization', () => { + it('should create transport with required options', () => { + // Test will be implemented with actual ProcessTransport class + expect(true).toBe(true); // Placeholder + }); + + it('should validate pathToQwenExecutable exists', () => { + // Should throw if pathToQwenExecutable does not exist + expect(true).toBe(true); // Placeholder + }); + + it('should build CLI arguments correctly', () => { + // Should include --input-format stream-json --output-format stream-json + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Lifecycle Management', () => { + it('should spawn subprocess 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 new file mode 100644 index 00000000..5ceeee4b --- /dev/null +++ b/packages/sdk/typescript/test/unit/Query.test.ts @@ -0,0 +1,284 @@ +/** + * Unit tests for Query class + * Tests message routing, lifecycle, and orchestration + */ + +import { describe, expect, it } from 'vitest'; + +// Note: This is a placeholder test file +// Query will be implemented in Phase 3 Implementation (T022) +// These tests are written first following TDD approach + +describe('Query', () => { + describe('Construction and Initialization', () => { + it('should create Query with transport and options', () => { + // Should accept Transport and CreateQueryOptions + expect(true).toBe(true); // Placeholder + }); + + it('should generate unique session ID', () => { + // Each Query should have unique session_id + expect(true).toBe(true); // Placeholder + }); + + it('should validate MCP server name conflicts', () => { + // Should throw if mcpServers and sdkMcpServers have same keys + expect(true).toBe(true); // Placeholder + }); + + it('should lazy initialize on first message consumption', async () => { + // Should not call initialize() until messages are read + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Message Routing', () => { + it('should route user messages to CLI', async () => { + // Initial prompt should be sent as user message + expect(true).toBe(true); // Placeholder + }); + + it('should route assistant messages to output stream', async () => { + // Assistant messages from CLI should be yielded to user + expect(true).toBe(true); // Placeholder + }); + + it('should route tool_use messages to output stream', async () => { + // Tool use messages should be yielded to user + expect(true).toBe(true); // Placeholder + }); + + it('should route tool_result messages to output stream', async () => { + // Tool result messages should be yielded to user + expect(true).toBe(true); // Placeholder + }); + + it('should route result messages to output stream', async () => { + // Result messages should be yielded to user + expect(true).toBe(true); // Placeholder + }); + + it('should filter keep_alive messages from output', async () => { + // Keep alive messages should not be yielded to user + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Control Plane - Permission Control', () => { + it('should handle can_use_tool control requests', async () => { + // Should invoke canUseTool callback + expect(true).toBe(true); // Placeholder + }); + + it('should send control response with permission result', async () => { + // Should send response with allowed: true/false + expect(true).toBe(true); // Placeholder + }); + + it('should default to allowing tools if no callback', async () => { + // If canUseTool not provided, should allow all + expect(true).toBe(true); // Placeholder + }); + + it('should handle permission callback timeout', async () => { + // Should deny permission if callback exceeds 30s + expect(true).toBe(true); // Placeholder + }); + + it('should handle permission callback errors', async () => { + // Should deny permission if callback throws + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Control Plane - MCP Messages', () => { + it('should route MCP messages to SDK-embedded servers', async () => { + // Should find SdkControlServerTransport by server name + expect(true).toBe(true); // Placeholder + }); + + it('should handle MCP message responses', async () => { + // Should send response back to CLI + expect(true).toBe(true); // Placeholder + }); + + it('should handle MCP message timeout', async () => { + // Should return error if MCP server doesn\'t respond in 30s + expect(true).toBe(true); // Placeholder + }); + + it('should handle unknown MCP server names', async () => { + // Should return error if server name not found + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Control Plane - Other Requests', () => { + it('should handle initialize control request', async () => { + // Should register SDK MCP servers with CLI + expect(true).toBe(true); // Placeholder + }); + + it('should handle interrupt control request', async () => { + // Should send interrupt message to CLI + expect(true).toBe(true); // Placeholder + }); + + it('should handle set_permission_mode control request', async () => { + // Should send permission mode update to CLI + expect(true).toBe(true); // Placeholder + }); + + it('should handle supported_commands control request', async () => { + // Should query CLI capabilities + expect(true).toBe(true); // Placeholder + }); + + it('should handle mcp_server_status control request', async () => { + // Should check MCP server health + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Multi-Turn Conversation', () => { + it('should support streamInput() for follow-up messages', async () => { + // Should accept async iterable of messages + expect(true).toBe(true); // Placeholder + }); + + it('should maintain session context across turns', async () => { + // All messages should have same session_id + expect(true).toBe(true); // Placeholder + }); + + it('should throw if streamInput() called on closed query', async () => { + // Should throw Error if query is closed + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Lifecycle Management', () => { + it('should close transport on close()', async () => { + // Should call transport.close() + expect(true).toBe(true); // Placeholder + }); + + it('should mark query as closed', async () => { + // closed flag should be true after close() + expect(true).toBe(true); // Placeholder + }); + + it('should complete output stream on close()', async () => { + // inputStream should be marked done + expect(true).toBe(true); // Placeholder + }); + + it('should be idempotent when closing multiple times', async () => { + // Multiple close() calls should not error + expect(true).toBe(true); // Placeholder + }); + + it('should cleanup MCP transports on close()', async () => { + // Should close all SdkControlServerTransport instances + expect(true).toBe(true); // Placeholder + }); + + it('should handle abort signal cancellation', async () => { + // Should abort on AbortSignal + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Async Iteration', () => { + it('should support for await loop', async () => { + // Should implement AsyncIterator protocol + expect(true).toBe(true); // Placeholder + }); + + it('should yield messages in order', async () => { + // Messages should be yielded in received order + expect(true).toBe(true); // Placeholder + }); + + it('should complete iteration when query closes', async () => { + // for await loop should exit when query closes + expect(true).toBe(true); // Placeholder + }); + + it('should propagate transport errors', async () => { + // Should throw if transport encounters error + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Public API Methods', () => { + it('should provide interrupt() method', async () => { + // Should send interrupt control request + expect(true).toBe(true); // Placeholder + }); + + it('should provide setPermissionMode() method', async () => { + // Should send set_permission_mode control request + expect(true).toBe(true); // Placeholder + }); + + it('should provide supportedCommands() method', async () => { + // Should query CLI capabilities + expect(true).toBe(true); // Placeholder + }); + + it('should provide mcpServerStatus() method', async () => { + // Should check MCP server health + expect(true).toBe(true); // Placeholder + }); + + it('should throw if methods called on closed query', async () => { + // Public methods should throw if query is closed + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Error Handling', () => { + it('should propagate transport errors to stream', async () => { + // Transport errors should be surfaced in for await loop + expect(true).toBe(true); // Placeholder + }); + + it('should handle control request timeout', async () => { + // Should return error if control request doesn\'t respond + expect(true).toBe(true); // Placeholder + }); + + it('should handle malformed control responses', async () => { + // Should handle invalid response structures + expect(true).toBe(true); // Placeholder + }); + + it('should handle CLI sending error message', async () => { + // Should yield error message to user + expect(true).toBe(true); // Placeholder + }); + }); + + describe('State Management', () => { + it('should track pending control requests', () => { + // Should maintain map of request_id -> Promise + expect(true).toBe(true); // Placeholder + }); + + it('should track SDK MCP transports', () => { + // Should maintain map of server_name -> SdkControlServerTransport + expect(true).toBe(true); // Placeholder + }); + + it('should track initialization state', () => { + // Should have initialized Promise + expect(true).toBe(true); // Placeholder + }); + + it('should track closed state', () => { + // Should have closed boolean flag + expect(true).toBe(true); // Placeholder + }); + }); +}); diff --git a/packages/sdk/typescript/test/unit/SdkControlServerTransport.test.ts b/packages/sdk/typescript/test/unit/SdkControlServerTransport.test.ts new file mode 100644 index 00000000..6bfd61a0 --- /dev/null +++ b/packages/sdk/typescript/test/unit/SdkControlServerTransport.test.ts @@ -0,0 +1,259 @@ +/** + * Unit tests for SdkControlServerTransport + * + * Tests MCP message proxying between MCP Server and Query's control plane. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SdkControlServerTransport } from '../../src/mcp/SdkControlServerTransport.js'; + +describe('SdkControlServerTransport', () => { + let sendToQuery: ReturnType; + let transport: SdkControlServerTransport; + + beforeEach(() => { + sendToQuery = vi.fn().mockResolvedValue({ result: 'success' }); + transport = new SdkControlServerTransport({ + serverName: 'test-server', + sendToQuery, + }); + }); + + describe('Lifecycle', () => { + it('should start successfully', async () => { + await transport.start(); + expect(transport.isStarted()).toBe(true); + }); + + it('should close successfully', async () => { + await transport.start(); + await transport.close(); + expect(transport.isStarted()).toBe(false); + }); + + it('should handle close callback', async () => { + const onclose = vi.fn(); + transport.onclose = onclose; + + await transport.start(); + await transport.close(); + + expect(onclose).toHaveBeenCalled(); + }); + }); + + describe('Message Sending', () => { + it('should send message to Query', async () => { + await transport.start(); + + const message = { + jsonrpc: '2.0' as const, + id: 1, + method: 'tools/list', + params: {}, + }; + + await transport.send(message); + + expect(sendToQuery).toHaveBeenCalledWith(message); + }); + + it('should throw error when sending before start', async () => { + const message = { + jsonrpc: '2.0' as const, + id: 1, + method: 'tools/list', + }; + + await expect(transport.send(message)).rejects.toThrow('not started'); + }); + + it('should handle send errors', async () => { + const error = new Error('Network error'); + sendToQuery.mockRejectedValue(error); + + const onerror = vi.fn(); + transport.onerror = onerror; + + await transport.start(); + + const message = { + jsonrpc: '2.0' as const, + id: 1, + method: 'tools/list', + }; + + await expect(transport.send(message)).rejects.toThrow('Network error'); + expect(onerror).toHaveBeenCalledWith(error); + }); + }); + + describe('Message Receiving', () => { + it('should deliver message to MCP Server via onmessage', async () => { + const onmessage = vi.fn(); + transport.onmessage = onmessage; + + await transport.start(); + + const message = { + jsonrpc: '2.0' as const, + id: 1, + result: { tools: [] }, + }; + + transport.handleMessage(message); + + expect(onmessage).toHaveBeenCalledWith(message); + }); + + it('should warn when receiving message without onmessage handler', async () => { + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + await transport.start(); + + const message = { + jsonrpc: '2.0' as const, + id: 1, + result: {}, + }; + + transport.handleMessage(message); + + expect(consoleWarnSpy).toHaveBeenCalled(); + + consoleWarnSpy.mockRestore(); + }); + + it('should warn when receiving message for closed transport', async () => { + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); + const onmessage = vi.fn(); + transport.onmessage = onmessage; + + await transport.start(); + await transport.close(); + + const message = { + jsonrpc: '2.0' as const, + id: 1, + result: {}, + }; + + transport.handleMessage(message); + + expect(consoleWarnSpy).toHaveBeenCalled(); + expect(onmessage).not.toHaveBeenCalled(); + + consoleWarnSpy.mockRestore(); + }); + }); + + describe('Error Handling', () => { + it('should deliver error to MCP Server via onerror', async () => { + const onerror = vi.fn(); + transport.onerror = onerror; + + await transport.start(); + + const error = new Error('Test error'); + transport.handleError(error); + + expect(onerror).toHaveBeenCalledWith(error); + }); + + it('should log error when no onerror handler set', async () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await transport.start(); + + const error = new Error('Test error'); + transport.handleError(error); + + expect(consoleErrorSpy).toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('Server Name', () => { + it('should return server name', () => { + expect(transport.getServerName()).toBe('test-server'); + }); + }); + + describe('Bidirectional Communication', () => { + it('should support full message round-trip', async () => { + const onmessage = vi.fn(); + transport.onmessage = onmessage; + + await transport.start(); + + // Send request from MCP Server to CLI + const request = { + jsonrpc: '2.0' as const, + id: 1, + method: 'tools/list', + params: {}, + }; + + await transport.send(request); + expect(sendToQuery).toHaveBeenCalledWith(request); + + // Receive response from CLI to MCP Server + const response = { + jsonrpc: '2.0' as const, + id: 1, + result: { + tools: [ + { + name: 'test_tool', + description: 'A test tool', + inputSchema: { type: 'object' }, + }, + ], + }, + }; + + transport.handleMessage(response); + expect(onmessage).toHaveBeenCalledWith(response); + }); + + it('should handle multiple messages in sequence', async () => { + const onmessage = vi.fn(); + transport.onmessage = onmessage; + + await transport.start(); + + // Send multiple requests + for (let i = 0; i < 5; i++) { + const message = { + jsonrpc: '2.0' as const, + id: i, + method: 'test', + }; + + await transport.send(message); + } + + expect(sendToQuery).toHaveBeenCalledTimes(5); + + // Receive multiple responses + for (let i = 0; i < 5; i++) { + const message = { + jsonrpc: '2.0' as const, + id: i, + result: {}, + }; + + transport.handleMessage(message); + } + + expect(onmessage).toHaveBeenCalledTimes(5); + }); + }); +}); diff --git a/packages/sdk/typescript/test/unit/Stream.test.ts b/packages/sdk/typescript/test/unit/Stream.test.ts new file mode 100644 index 00000000..adae9b69 --- /dev/null +++ b/packages/sdk/typescript/test/unit/Stream.test.ts @@ -0,0 +1,247 @@ +/** + * 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 new file mode 100644 index 00000000..55a87b92 --- /dev/null +++ b/packages/sdk/typescript/test/unit/cliPath.test.ts @@ -0,0 +1,668 @@ +/** + * Unit tests for CLI path utilities + * Tests executable detection, parsing, and spawn info preparation + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { execSync } from 'node:child_process'; +import { + parseExecutableSpec, + prepareSpawnInfo, + findNativeCliPath, + resolveCliPath, +} from '../../src/utils/cliPath.js'; + +// Mock fs module +vi.mock('node:fs'); +const mockFs = vi.mocked(fs); + +// Mock child_process module +vi.mock('node:child_process'); +const mockExecSync = vi.mocked(execSync); + +// Mock process.versions for bun detection +const originalVersions = process.versions; + +describe('CLI Path Utilities', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset process.versions + Object.defineProperty(process, 'versions', { + value: { ...originalVersions }, + writable: true, + }); + // Default: tsx is available (can be overridden in specific tests) + mockExecSync.mockReturnValue(Buffer.from('')); + // Default: mock statSync to return a proper file stat object + mockFs.statSync.mockReturnValue({ + isFile: () => true, + } as ReturnType); + }); + + afterEach(() => { + // Restore original process.versions + Object.defineProperty(process, 'versions', { + value: originalVersions, + writable: true, + }); + }); + + describe('parseExecutableSpec', () => { + describe('auto-detection (no spec provided)', () => { + it('should auto-detect native CLI when no spec provided', () => { + // Mock environment variable + const originalEnv = process.env['QWEN_CODE_CLI_PATH']; + process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen'; + mockFs.existsSync.mockReturnValue(true); + + const result = parseExecutableSpec(); + + expect(result).toEqual({ + executablePath: '/usr/local/bin/qwen', + isExplicitRuntime: false, + }); + + // Restore env + process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + }); + + it('should throw when auto-detection fails', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => parseExecutableSpec()).toThrow( + 'qwen CLI not found. Please:', + ); + }); + }); + + describe('runtime prefix parsing', () => { + it('should parse node runtime prefix', () => { + mockFs.existsSync.mockReturnValue(true); + + const result = parseExecutableSpec('node:/path/to/cli.js'); + + expect(result).toEqual({ + runtime: 'node', + executablePath: path.resolve('/path/to/cli.js'), + isExplicitRuntime: true, + }); + }); + + it('should parse bun runtime prefix', () => { + mockFs.existsSync.mockReturnValue(true); + + const result = parseExecutableSpec('bun:/path/to/cli.js'); + + expect(result).toEqual({ + runtime: 'bun', + executablePath: path.resolve('/path/to/cli.js'), + isExplicitRuntime: true, + }); + }); + + it('should parse tsx runtime prefix', () => { + mockFs.existsSync.mockReturnValue(true); + + const result = parseExecutableSpec('tsx:/path/to/index.ts'); + + expect(result).toEqual({ + runtime: 'tsx', + executablePath: path.resolve('/path/to/index.ts'), + isExplicitRuntime: true, + }); + }); + + it('should parse deno runtime prefix', () => { + mockFs.existsSync.mockReturnValue(true); + + const result = parseExecutableSpec('deno:/path/to/cli.ts'); + + expect(result).toEqual({ + runtime: 'deno', + executablePath: path.resolve('/path/to/cli.ts'), + isExplicitRuntime: true, + }); + }); + + it('should throw for invalid runtime prefix format', () => { + expect(() => parseExecutableSpec('invalid:format')).toThrow( + 'Unsupported runtime', + ); + }); + + it('should throw when runtime-prefixed file does not exist', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => parseExecutableSpec('node:/nonexistent/cli.js')).toThrow( + 'Executable file not found at', + ); + }); + }); + + describe('command name detection', () => { + it('should detect command names without path separators', () => { + const result = parseExecutableSpec('qwen'); + + expect(result).toEqual({ + executablePath: 'qwen', + isExplicitRuntime: false, + }); + }); + + it('should detect command names on Windows', () => { + const result = parseExecutableSpec('qwen.exe'); + + expect(result).toEqual({ + executablePath: 'qwen.exe', + isExplicitRuntime: false, + }); + }); + }); + + describe('file path resolution', () => { + it('should resolve absolute file paths', () => { + mockFs.existsSync.mockReturnValue(true); + + const result = parseExecutableSpec('/absolute/path/to/qwen'); + + expect(result).toEqual({ + executablePath: '/absolute/path/to/qwen', + isExplicitRuntime: false, + }); + }); + + it('should resolve relative file paths', () => { + mockFs.existsSync.mockReturnValue(true); + + const result = parseExecutableSpec('./relative/path/to/qwen'); + + expect(result).toEqual({ + executablePath: path.resolve('./relative/path/to/qwen'), + isExplicitRuntime: false, + }); + }); + + it('should throw when file path does not exist', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => parseExecutableSpec('/nonexistent/path')).toThrow( + 'Executable file not found at', + ); + }); + }); + }); + + describe('prepareSpawnInfo', () => { + beforeEach(() => { + mockFs.existsSync.mockReturnValue(true); + }); + + describe('native executables', () => { + it('should prepare spawn info for native binary command', () => { + const result = prepareSpawnInfo('qwen'); + + expect(result).toEqual({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + }); + + it('should prepare spawn info for native binary path', () => { + const result = prepareSpawnInfo('/usr/local/bin/qwen'); + + expect(result).toEqual({ + command: '/usr/local/bin/qwen', + args: [], + type: 'native', + originalInput: '/usr/local/bin/qwen', + }); + }); + }); + + describe('JavaScript files', () => { + it('should use node for .js files', () => { + const result = prepareSpawnInfo('/path/to/cli.js'); + + expect(result).toEqual({ + command: process.execPath, + args: [path.resolve('/path/to/cli.js')], + type: 'node', + originalInput: '/path/to/cli.js', + }); + }); + + it('should default to node for .js files (not auto-detect bun)', () => { + // Even when running under bun, default to node for .js files + Object.defineProperty(process, 'versions', { + value: { ...originalVersions, bun: '1.0.0' }, + writable: true, + }); + + const result = prepareSpawnInfo('/path/to/cli.js'); + + expect(result).toEqual({ + command: process.execPath, + args: [path.resolve('/path/to/cli.js')], + type: 'node', + originalInput: '/path/to/cli.js', + }); + }); + + it('should handle .mjs files', () => { + const result = prepareSpawnInfo('/path/to/cli.mjs'); + + expect(result).toEqual({ + command: process.execPath, + args: [path.resolve('/path/to/cli.mjs')], + type: 'node', + originalInput: '/path/to/cli.mjs', + }); + }); + + it('should handle .cjs files', () => { + const result = prepareSpawnInfo('/path/to/cli.cjs'); + + expect(result).toEqual({ + command: process.execPath, + args: [path.resolve('/path/to/cli.cjs')], + type: 'node', + originalInput: '/path/to/cli.cjs', + }); + }); + }); + + describe('TypeScript files', () => { + it('should use tsx for .ts files when tsx is available', () => { + // tsx is available by default in beforeEach + const result = prepareSpawnInfo('/path/to/index.ts'); + + expect(result).toEqual({ + command: 'tsx', + args: [path.resolve('/path/to/index.ts')], + type: 'tsx', + originalInput: '/path/to/index.ts', + }); + }); + + it('should use tsx for .tsx files when tsx is available', () => { + const result = prepareSpawnInfo('/path/to/cli.tsx'); + + expect(result).toEqual({ + command: 'tsx', + args: [path.resolve('/path/to/cli.tsx')], + type: 'tsx', + originalInput: '/path/to/cli.tsx', + }); + }); + + it('should throw helpful error when tsx is not available', () => { + // Mock tsx not being available + mockExecSync.mockImplementation(() => { + throw new Error('Command not found'); + }); + + expect(() => prepareSpawnInfo('/path/to/index.ts')).toThrow( + "TypeScript file '/path/to/index.ts' requires 'tsx' runtime, but it's not available", + ); + expect(() => prepareSpawnInfo('/path/to/index.ts')).toThrow( + 'Please install tsx: npm install -g tsx', + ); + }); + }); + + describe('explicit runtime specifications', () => { + it('should use explicit node runtime', () => { + const result = prepareSpawnInfo('node:/path/to/cli.js'); + + expect(result).toEqual({ + command: process.execPath, + args: [path.resolve('/path/to/cli.js')], + type: 'node', + originalInput: 'node:/path/to/cli.js', + }); + }); + + it('should use explicit bun runtime', () => { + const result = prepareSpawnInfo('bun:/path/to/cli.js'); + + expect(result).toEqual({ + command: 'bun', + args: [path.resolve('/path/to/cli.js')], + type: 'bun', + originalInput: 'bun:/path/to/cli.js', + }); + }); + + it('should use explicit tsx runtime', () => { + const result = prepareSpawnInfo('tsx:/path/to/index.ts'); + + expect(result).toEqual({ + command: 'tsx', + args: [path.resolve('/path/to/index.ts')], + type: 'tsx', + originalInput: 'tsx:/path/to/index.ts', + }); + }); + + it('should use explicit deno runtime', () => { + const result = prepareSpawnInfo('deno:/path/to/cli.ts'); + + expect(result).toEqual({ + command: 'deno', + args: [path.resolve('/path/to/cli.ts')], + type: 'deno', + originalInput: 'deno:/path/to/cli.ts', + }); + }); + }); + + describe('auto-detection fallback', () => { + it('should auto-detect when no spec provided', () => { + // Mock environment variable + const originalEnv = process.env['QWEN_CODE_CLI_PATH']; + process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen'; + + const result = prepareSpawnInfo(); + + expect(result).toEqual({ + command: '/usr/local/bin/qwen', + args: [], + type: 'native', + originalInput: '', + }); + + // Restore env + process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + }); + }); + }); + + describe('findNativeCliPath', () => { + it('should find CLI from environment variable', () => { + const originalEnv = process.env['QWEN_CODE_CLI_PATH']; + process.env['QWEN_CODE_CLI_PATH'] = '/custom/path/to/qwen'; + mockFs.existsSync.mockReturnValue(true); + + const result = findNativeCliPath(); + + expect(result).toBe('/custom/path/to/qwen'); + + process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + }); + + it('should search common installation locations', () => { + const originalEnv = process.env['QWEN_CODE_CLI_PATH']; + delete process.env['QWEN_CODE_CLI_PATH']; + + // Mock fs.existsSync to return true for volta bin + mockFs.existsSync.mockImplementation((path) => { + return path.toString().includes('.volta/bin/qwen'); + }); + + const result = findNativeCliPath(); + + expect(result).toContain('.volta/bin/qwen'); + + process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + }); + + it('should throw descriptive error when CLI not found', () => { + const originalEnv = process.env['QWEN_CODE_CLI_PATH']; + delete process.env['QWEN_CODE_CLI_PATH']; + mockFs.existsSync.mockReturnValue(false); + + expect(() => findNativeCliPath()).toThrow('qwen CLI not found. Please:'); + + process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + }); + }); + + describe('resolveCliPath (backward compatibility)', () => { + it('should resolve CLI path for backward compatibility', () => { + mockFs.existsSync.mockReturnValue(true); + + const result = resolveCliPath('/path/to/qwen'); + + expect(result).toBe('/path/to/qwen'); + }); + + it('should auto-detect when no path provided', () => { + const originalEnv = process.env['QWEN_CODE_CLI_PATH']; + process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen'; + mockFs.existsSync.mockReturnValue(true); + + const result = resolveCliPath(); + + expect(result).toBe('/usr/local/bin/qwen'); + + process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + }); + }); + + describe('real-world use cases', () => { + beforeEach(() => { + mockFs.existsSync.mockReturnValue(true); + }); + + it('should handle development with TypeScript source', () => { + const devPath = '/Users/dev/qwen-code/packages/cli/index.ts'; + const result = prepareSpawnInfo(devPath); + + expect(result).toEqual({ + command: 'tsx', + args: [path.resolve(devPath)], + type: 'tsx', + originalInput: devPath, + }); + }); + + it('should handle production bundle validation', () => { + const bundlePath = '/path/to/bundled/cli.js'; + const result = prepareSpawnInfo(bundlePath); + + expect(result).toEqual({ + command: process.execPath, + args: [path.resolve(bundlePath)], + type: 'node', + originalInput: bundlePath, + }); + }); + + it('should handle production native binary', () => { + const result = prepareSpawnInfo('qwen'); + + expect(result).toEqual({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + }); + + it('should handle bun runtime with bundle', () => { + const bundlePath = '/path/to/cli.js'; + const result = prepareSpawnInfo(`bun:${bundlePath}`); + + expect(result).toEqual({ + command: 'bun', + args: [path.resolve(bundlePath)], + type: 'bun', + originalInput: `bun:${bundlePath}`, + }); + }); + }); + + describe('error cases', () => { + it('should provide helpful error for missing TypeScript file', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => prepareSpawnInfo('/missing/index.ts')).toThrow( + 'Executable file not found at', + ); + }); + + it('should provide helpful error for missing JavaScript file', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => prepareSpawnInfo('/missing/cli.js')).toThrow( + 'Executable file not found at', + ); + }); + + it('should provide helpful error for invalid runtime specification', () => { + expect(() => prepareSpawnInfo('invalid:spec')).toThrow( + 'Unsupported runtime', + ); + }); + }); + + describe('comprehensive validation', () => { + describe('runtime validation', () => { + it('should reject unsupported runtimes', () => { + expect(() => + parseExecutableSpec('unsupported:/path/to/file.js'), + ).toThrow( + "Unsupported runtime 'unsupported'. Supported runtimes: node, bun, tsx, deno", + ); + }); + + it('should validate runtime availability for explicit runtime specs', () => { + mockFs.existsSync.mockReturnValue(true); + // Mock bun not being available + mockExecSync.mockImplementation((command) => { + if (command.includes('bun')) { + throw new Error('Command not found'); + } + return Buffer.from(''); + }); + + expect(() => parseExecutableSpec('bun:/path/to/cli.js')).toThrow( + "Runtime 'bun' is not available on this system. Please install it first.", + ); + }); + + it('should allow node runtime (always available)', () => { + mockFs.existsSync.mockReturnValue(true); + + expect(() => parseExecutableSpec('node:/path/to/cli.js')).not.toThrow(); + }); + + it('should validate file extension matches runtime', () => { + mockFs.existsSync.mockReturnValue(true); + + expect(() => parseExecutableSpec('tsx:/path/to/file.js')).toThrow( + "File extension '.js' is not compatible with runtime 'tsx'", + ); + }); + + it('should validate node runtime with JavaScript files', () => { + mockFs.existsSync.mockReturnValue(true); + + expect(() => parseExecutableSpec('node:/path/to/file.ts')).toThrow( + "File extension '.ts' is not compatible with runtime 'node'", + ); + }); + + it('should accept valid runtime-file combinations', () => { + mockFs.existsSync.mockReturnValue(true); + + expect(() => parseExecutableSpec('tsx:/path/to/file.ts')).not.toThrow(); + expect(() => + parseExecutableSpec('node:/path/to/file.js'), + ).not.toThrow(); + expect(() => + parseExecutableSpec('bun:/path/to/file.mjs'), + ).not.toThrow(); + }); + }); + + describe('command name validation', () => { + it('should reject empty command names', () => { + expect(() => parseExecutableSpec('')).toThrow( + 'Command name cannot be empty', + ); + expect(() => parseExecutableSpec(' ')).toThrow( + 'Command name cannot be empty', + ); + }); + + it('should reject invalid command name characters', () => { + expect(() => parseExecutableSpec('qwen@invalid')).toThrow( + "Invalid command name 'qwen@invalid'. Command names should only contain letters, numbers, dots, hyphens, and underscores.", + ); + + expect(() => parseExecutableSpec('qwen/invalid')).not.toThrow(); // This is treated as a path + }); + + it('should accept valid command names', () => { + expect(() => parseExecutableSpec('qwen')).not.toThrow(); + expect(() => parseExecutableSpec('qwen-code')).not.toThrow(); + expect(() => parseExecutableSpec('qwen_code')).not.toThrow(); + expect(() => parseExecutableSpec('qwen.exe')).not.toThrow(); + expect(() => parseExecutableSpec('qwen123')).not.toThrow(); + }); + }); + + describe('file path validation', () => { + it('should validate file exists', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => parseExecutableSpec('/nonexistent/path')).toThrow( + 'Executable file not found at', + ); + }); + + it('should validate path points to a file, not directory', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.statSync.mockReturnValue({ + isFile: () => false, + } as ReturnType); + + expect(() => parseExecutableSpec('/path/to/directory')).toThrow( + 'exists but is not a file', + ); + }); + + it('should accept valid file paths', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.statSync.mockReturnValue({ + isFile: () => true, + } as ReturnType); + + expect(() => parseExecutableSpec('/path/to/qwen')).not.toThrow(); + expect(() => parseExecutableSpec('./relative/path')).not.toThrow(); + }); + }); + + describe('error message quality', () => { + it('should provide helpful error for missing runtime-prefixed file', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => parseExecutableSpec('tsx:/missing/file.ts')).toThrow( + 'Executable file not found at', + ); + expect(() => parseExecutableSpec('tsx:/missing/file.ts')).toThrow( + 'Please check the file path and ensure the file exists', + ); + }); + + it('should provide helpful error for missing regular file', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => parseExecutableSpec('/missing/file')).toThrow( + 'Set QWEN_CODE_CLI_PATH environment variable', + ); + expect(() => parseExecutableSpec('/missing/file')).toThrow( + 'Install qwen globally: npm install -g qwen', + ); + expect(() => parseExecutableSpec('/missing/file')).toThrow( + 'Force specific runtime: bun:/path/to/cli.js or tsx:/path/to/index.ts', + ); + }); + }); + }); +}); diff --git a/packages/sdk/typescript/test/unit/createSdkMcpServer.test.ts b/packages/sdk/typescript/test/unit/createSdkMcpServer.test.ts new file mode 100644 index 00000000..e608ba7b --- /dev/null +++ b/packages/sdk/typescript/test/unit/createSdkMcpServer.test.ts @@ -0,0 +1,350 @@ +/** + * Unit tests for createSdkMcpServer + * + * Tests MCP server creation and tool registration. + */ + +import { describe, expect, it, vi } from 'vitest'; +import { createSdkMcpServer } from '../../src/mcp/createSdkMcpServer.js'; +import { tool } from '../../src/mcp/tool.js'; +import type { ToolDefinition } from '../../src/types/config.js'; + +describe('createSdkMcpServer', () => { + describe('Server Creation', () => { + it('should create server with name and version', () => { + const server = createSdkMcpServer('test-server', '1.0.0', []); + + expect(server).toBeDefined(); + }); + + it('should throw error with invalid name', () => { + expect(() => createSdkMcpServer('', '1.0.0', [])).toThrow( + 'name must be a non-empty string', + ); + }); + + it('should throw error with invalid version', () => { + expect(() => createSdkMcpServer('test', '', [])).toThrow( + 'version must be a non-empty string', + ); + }); + + it('should throw error with non-array tools', () => { + expect(() => + createSdkMcpServer('test', '1.0.0', {} as unknown as ToolDefinition[]), + ).toThrow('Tools must be an array'); + }); + }); + + describe('Tool Registration', () => { + it('should register single tool', () => { + const testTool = tool({ + name: 'test_tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: { + input: { type: 'string' }, + }, + }, + handler: async () => 'result', + }); + + const server = createSdkMcpServer('test-server', '1.0.0', [testTool]); + + expect(server).toBeDefined(); + }); + + it('should register multiple tools', () => { + const tool1 = tool({ + name: 'tool1', + description: 'Tool 1', + inputSchema: { type: 'object' }, + handler: async () => 'result1', + }); + + const tool2 = tool({ + name: 'tool2', + description: 'Tool 2', + inputSchema: { type: 'object' }, + handler: async () => 'result2', + }); + + const server = createSdkMcpServer('test-server', '1.0.0', [tool1, tool2]); + + expect(server).toBeDefined(); + }); + + it('should throw error for duplicate tool names', () => { + const tool1 = tool({ + name: 'duplicate', + description: 'Tool 1', + inputSchema: { type: 'object' }, + handler: async () => 'result1', + }); + + const tool2 = tool({ + name: 'duplicate', + description: 'Tool 2', + inputSchema: { type: 'object' }, + handler: async () => 'result2', + }); + + expect(() => + createSdkMcpServer('test-server', '1.0.0', [tool1, tool2]), + ).toThrow("Duplicate tool name 'duplicate'"); + }); + + it('should validate tool names', () => { + const invalidTool = { + name: '123invalid', // Starts with number + description: 'Invalid tool', + inputSchema: { type: 'object' }, + handler: async () => 'result', + }; + + expect(() => + createSdkMcpServer('test-server', '1.0.0', [ + invalidTool as unknown as ToolDefinition, + ]), + ).toThrow('Tool name'); + }); + }); + + describe('Tool Handler Invocation', () => { + it('should invoke tool handler with correct input', async () => { + const handler = vi.fn().mockResolvedValue({ result: 'success' }); + + const testTool = tool({ + name: 'test_tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: { + value: { type: 'string' }, + }, + required: ['value'], + }, + handler, + }); + + createSdkMcpServer('test-server', '1.0.0', [testTool]); + + // Note: Actual invocation testing requires MCP SDK integration + // This test verifies the handler was properly registered + expect(handler).toBeDefined(); + }); + + it('should handle async tool handlers', async () => { + const handler = vi + .fn() + .mockImplementation(async (input: { value: string }) => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return { processed: input.value }; + }); + + const testTool = tool({ + name: 'async_tool', + description: 'An async tool', + inputSchema: { type: 'object' }, + handler, + }); + + const server = createSdkMcpServer('test-server', '1.0.0', [testTool]); + + expect(server).toBeDefined(); + }); + }); + + describe('Type Safety', () => { + it('should preserve input type in handler', async () => { + type ToolInput = { + name: string; + age: number; + }; + + type ToolOutput = { + greeting: string; + }; + + const handler = vi + .fn() + .mockImplementation(async (input: ToolInput): Promise => { + return { + greeting: `Hello ${input.name}, age ${input.age}`, + }; + }); + + const typedTool = tool({ + name: 'typed_tool', + description: 'A typed tool', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + required: ['name', 'age'], + }, + handler, + }); + + const server = createSdkMcpServer('test-server', '1.0.0', [ + typedTool as ToolDefinition, + ]); + + expect(server).toBeDefined(); + }); + }); + + describe('Error Handling in Tools', () => { + it('should handle tool handler errors gracefully', async () => { + const handler = vi.fn().mockRejectedValue(new Error('Tool failed')); + + const errorTool = tool({ + name: 'error_tool', + description: 'A tool that errors', + inputSchema: { type: 'object' }, + handler, + }); + + const server = createSdkMcpServer('test-server', '1.0.0', [errorTool]); + + expect(server).toBeDefined(); + // Error handling occurs during tool invocation + }); + + it('should handle synchronous tool handler errors', async () => { + const handler = vi.fn().mockImplementation(() => { + throw new Error('Sync error'); + }); + + const errorTool = tool({ + name: 'sync_error_tool', + description: 'A tool that errors synchronously', + inputSchema: { type: 'object' }, + handler, + }); + + const server = createSdkMcpServer('test-server', '1.0.0', [errorTool]); + + expect(server).toBeDefined(); + }); + }); + + describe('Complex Tool Scenarios', () => { + it('should support tool with complex input schema', () => { + const complexTool = tool({ + name: 'complex_tool', + description: 'A tool with complex schema', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string' }, + filters: { + type: 'object', + properties: { + category: { type: 'string' }, + minPrice: { type: 'number' }, + }, + }, + options: { + type: 'array', + items: { type: 'string' }, + }, + }, + required: ['query'], + }, + handler: async (input: { filters?: unknown[] }) => { + return { + results: [], + filters: input.filters, + }; + }, + }); + + const server = createSdkMcpServer('test-server', '1.0.0', [ + complexTool as ToolDefinition, + ]); + + expect(server).toBeDefined(); + }); + + it('should support tool returning complex output', () => { + const complexOutputTool = tool({ + name: 'complex_output_tool', + description: 'Returns complex data', + inputSchema: { type: 'object' }, + handler: async () => { + return { + data: [ + { id: 1, name: 'Item 1' }, + { id: 2, name: 'Item 2' }, + ], + metadata: { + total: 2, + page: 1, + }, + nested: { + deep: { + value: 'test', + }, + }, + }; + }, + }); + + const server = createSdkMcpServer('test-server', '1.0.0', [ + complexOutputTool, + ]); + + expect(server).toBeDefined(); + }); + }); + + describe('Multiple Servers', () => { + it('should create multiple independent servers', () => { + const tool1 = tool({ + name: 'tool1', + description: 'Tool in server 1', + inputSchema: { type: 'object' }, + handler: async () => 'result1', + }); + + const tool2 = tool({ + name: 'tool2', + description: 'Tool in server 2', + inputSchema: { type: 'object' }, + handler: async () => 'result2', + }); + + const server1 = createSdkMcpServer('server1', '1.0.0', [tool1]); + const server2 = createSdkMcpServer('server2', '1.0.0', [tool2]); + + expect(server1).toBeDefined(); + expect(server2).toBeDefined(); + }); + + it('should allow same tool name in different servers', () => { + const tool1 = tool({ + name: 'shared_name', + description: 'Tool in server 1', + inputSchema: { type: 'object' }, + handler: async () => 'result1', + }); + + const tool2 = tool({ + name: 'shared_name', + description: 'Tool in server 2', + inputSchema: { type: 'object' }, + handler: async () => 'result2', + }); + + const server1 = createSdkMcpServer('server1', '1.0.0', [tool1]); + const server2 = createSdkMcpServer('server2', '1.0.0', [tool2]); + + expect(server1).toBeDefined(); + expect(server2).toBeDefined(); + }); + }); +}); diff --git a/packages/sdk/typescript/tsconfig.json b/packages/sdk/typescript/tsconfig.json new file mode 100644 index 00000000..5fa97a43 --- /dev/null +++ b/packages/sdk/typescript/tsconfig.json @@ -0,0 +1,41 @@ +{ + "compilerOptions": { + /* Language and Environment */ + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + + /* Emit */ + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "removeComments": true, + "importHelpers": false, + + /* Interop Constraints */ + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + + /* Type Checking */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": false, + + /* Completeness */ + "skipLibCheck": true, + + /* Module Resolution */ + "resolveJsonModule": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/sdk/typescript/vitest.config.ts b/packages/sdk/typescript/vitest.config.ts new file mode 100644 index 00000000..f3909ea4 --- /dev/null +++ b/packages/sdk/typescript/vitest.config.ts @@ -0,0 +1,36 @@ +import { defineConfig } from 'vitest/config'; +import * as path from 'path'; + +export default defineConfig({ + test: { + globals: false, + environment: 'node', + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: [ + 'node_modules/', + 'dist/', + 'test/', + '**/*.d.ts', + '**/*.config.*', + '**/index.ts', // Export-only files + ], + thresholds: { + lines: 80, + functions: 80, + branches: 75, + statements: 80, + }, + }, + include: ['test/**/*.test.ts'], + exclude: ['node_modules/', 'dist/'], + testTimeout: 30000, + hookTimeout: 10000, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts index 20ec6b90..f9602bac 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ 'packages/cli', 'packages/core', 'packages/vscode-ide-companion', + 'packages/sdk/typescript', 'integration-tests', 'scripts', ],