mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
fix: update Query and Transport to fix abort control errors
This commit is contained in:
@@ -16,10 +16,6 @@
|
|||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"import": "./dist/index.js"
|
"import": "./dist/index.js"
|
||||||
},
|
|
||||||
"./protocol": {
|
|
||||||
"types": "./dist/src/types/protocol.d.ts",
|
|
||||||
"import": "./dist/src/types/protocol.js"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
69
packages/sdk/typescript/package.json
Normal file
69
packages/sdk/typescript/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
108
packages/sdk/typescript/src/index.ts
Normal file
108
packages/sdk/typescript/src/index.ts
Normal file
@@ -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';
|
||||||
153
packages/sdk/typescript/src/mcp/SdkControlServerTransport.ts
Normal file
153
packages/sdk/typescript/src/mcp/SdkControlServerTransport.ts
Normal file
@@ -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<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
177
packages/sdk/typescript/src/mcp/createSdkMcpServer.ts
Normal file
177
packages/sdk/typescript/src/mcp/createSdkMcpServer.ts
Normal file
@@ -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<string>();
|
||||||
|
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<string, ToolDefinition>();
|
||||||
|
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<ToolDefinition, 'name'> & { 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);
|
||||||
|
}
|
||||||
247
packages/sdk/typescript/src/mcp/formatters.ts
Normal file
247
packages/sdk/typescript/src/mcp/formatters.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
140
packages/sdk/typescript/src/mcp/tool.ts
Normal file
140
packages/sdk/typescript/src/mcp/tool.ts
Normal file
@@ -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<TInput = unknown, TOutput = unknown>(
|
||||||
|
def: ToolDefinition<TInput, TOutput>,
|
||||||
|
): ToolDefinition<TInput, TOutput> {
|
||||||
|
// 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<string, unknown>;
|
||||||
|
|
||||||
|
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<TInput = unknown, TOutput = unknown>(
|
||||||
|
def: ToolDefinition<TInput, TOutput>,
|
||||||
|
): ToolDefinition<TInput, TOutput> {
|
||||||
|
// Validate via tool() function
|
||||||
|
const validated = tool(def);
|
||||||
|
|
||||||
|
// Additional validation
|
||||||
|
validateToolName(validated.name);
|
||||||
|
validateInputSchema(validated.inputSchema);
|
||||||
|
|
||||||
|
return validated;
|
||||||
|
}
|
||||||
874
packages/sdk/typescript/src/query/Query.ts
Normal file
874
packages/sdk/typescript/src/query/Query.ts
Normal file
@@ -0,0 +1,874 @@
|
|||||||
|
/**
|
||||||
|
* Query class - Main orchestrator for SDK
|
||||||
|
*
|
||||||
|
* Manages SDK workflow, routes messages, and handles lifecycle.
|
||||||
|
* Implements AsyncIterator protocol for message consumption.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type {
|
||||||
|
CLIMessage,
|
||||||
|
CLIUserMessage,
|
||||||
|
CLIControlRequest,
|
||||||
|
CLIControlResponse,
|
||||||
|
ControlCancelRequest,
|
||||||
|
PermissionApproval,
|
||||||
|
PermissionSuggestion,
|
||||||
|
} from '../types/protocol.js';
|
||||||
|
import {
|
||||||
|
isCLIUserMessage,
|
||||||
|
isCLIAssistantMessage,
|
||||||
|
isCLISystemMessage,
|
||||||
|
isCLIResultMessage,
|
||||||
|
isCLIPartialAssistantMessage,
|
||||||
|
isControlRequest,
|
||||||
|
isControlResponse,
|
||||||
|
isControlCancel,
|
||||||
|
} from '../types/protocol.js';
|
||||||
|
import type { Transport } from '../transport/Transport.js';
|
||||||
|
import type { CreateQueryOptions } from '../types/config.js';
|
||||||
|
import { Stream } from '../utils/Stream.js';
|
||||||
|
import { serializeJsonLine } from '../utils/jsonLines.js';
|
||||||
|
import { AbortError } from '../types/errors.js';
|
||||||
|
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import type { SdkControlServerTransport } from '../mcp/SdkControlServerTransport.js';
|
||||||
|
import { ControlRequestType } from '../types/controlRequests.js';
|
||||||
|
|
||||||
|
interface PendingControlRequest {
|
||||||
|
resolve: (response: Record<string, unknown> | null) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
timeout: NodeJS.Timeout;
|
||||||
|
abortController: AbortController;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HookRegistration {
|
||||||
|
matcher: Record<string, unknown>;
|
||||||
|
hookCallbackIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TransportWithEndInput extends Transport {
|
||||||
|
endInput(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query class
|
||||||
|
*
|
||||||
|
* Main entry point for SDK users. Orchestrates communication with CLI,
|
||||||
|
* routes messages, handles control plane, and manages lifecycle.
|
||||||
|
*/
|
||||||
|
export class Query implements AsyncIterable<CLIMessage> {
|
||||||
|
private transport: Transport;
|
||||||
|
private options: CreateQueryOptions;
|
||||||
|
private sessionId: string;
|
||||||
|
private inputStream: Stream<CLIMessage>;
|
||||||
|
private sdkMessages: AsyncGenerator<CLIMessage>;
|
||||||
|
private abortController: AbortController;
|
||||||
|
private pendingControlRequests: Map<string, PendingControlRequest> =
|
||||||
|
new Map();
|
||||||
|
private sdkMcpTransports: Map<string, SdkControlServerTransport> = new Map();
|
||||||
|
private initialized: Promise<void>;
|
||||||
|
private closed = false;
|
||||||
|
private messageRouterStarted = false;
|
||||||
|
|
||||||
|
private firstResultReceivedPromise?: Promise<void>;
|
||||||
|
private firstResultReceivedResolve?: () => void;
|
||||||
|
|
||||||
|
private hookCallbacks = new Map<
|
||||||
|
string,
|
||||||
|
(
|
||||||
|
input: unknown,
|
||||||
|
toolUseId: string | null,
|
||||||
|
options: { signal: AbortSignal },
|
||||||
|
) => Promise<unknown>
|
||||||
|
>();
|
||||||
|
private nextCallbackId = 0;
|
||||||
|
|
||||||
|
private readonly isSingleTurn: boolean;
|
||||||
|
|
||||||
|
constructor(transport: Transport, options: CreateQueryOptions) {
|
||||||
|
this.transport = transport;
|
||||||
|
this.options = options;
|
||||||
|
this.sessionId = randomUUID();
|
||||||
|
this.inputStream = new Stream<CLIMessage>();
|
||||||
|
this.abortController = options.abortController ?? new AbortController();
|
||||||
|
this.isSingleTurn = options.singleTurn ?? false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create async generator proxy to ensure stream.next() is called at least once.
|
||||||
|
* The generator will start iterating when the user begins iteration.
|
||||||
|
* This ensures readResolve/readReject are set up as soon as iteration starts.
|
||||||
|
* If errors occur before iteration starts, they'll be stored in hasError and
|
||||||
|
* properly rejected when the user starts iterating.
|
||||||
|
*/
|
||||||
|
this.sdkMessages = this.readSdkMessages();
|
||||||
|
|
||||||
|
this.firstResultReceivedPromise = new Promise((resolve) => {
|
||||||
|
this.firstResultReceivedResolve = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle abort signal if controller is provided and already aborted or will be aborted.
|
||||||
|
* If already aborted, set error immediately. Otherwise, listen for abort events
|
||||||
|
* and set abort error on the stream before closing.
|
||||||
|
*/
|
||||||
|
if (this.abortController.signal.aborted) {
|
||||||
|
this.inputStream.error(new AbortError('Query aborted by user'));
|
||||||
|
this.close().catch((err) => {
|
||||||
|
console.error('[Query] Error during abort cleanup:', err);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.abortController.signal.addEventListener('abort', () => {
|
||||||
|
this.inputStream.error(new AbortError('Query aborted by user'));
|
||||||
|
this.close().catch((err) => {
|
||||||
|
console.error('[Query] Error during abort cleanup:', err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.initialized = this.initialize();
|
||||||
|
this.initialized.catch(() => {});
|
||||||
|
|
||||||
|
this.startMessageRouter();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the query
|
||||||
|
*/
|
||||||
|
private async initialize(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.setupSdkMcpServers();
|
||||||
|
|
||||||
|
let hooks: Record<string, HookRegistration[]> | undefined;
|
||||||
|
if (this.options.hooks) {
|
||||||
|
hooks = {};
|
||||||
|
for (const [event, matchers] of Object.entries(this.options.hooks)) {
|
||||||
|
if (matchers.length > 0) {
|
||||||
|
hooks[event] = matchers.map((matcher) => {
|
||||||
|
const callbackIds: string[] = [];
|
||||||
|
for (const callback of matcher.hooks) {
|
||||||
|
const callbackId = `hook_${this.nextCallbackId++}`;
|
||||||
|
this.hookCallbacks.set(callbackId, callback);
|
||||||
|
callbackIds.push(callbackId);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
matcher: matcher.matcher,
|
||||||
|
hookCallbackIds: callbackIds,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sdkMcpServerNames = Array.from(this.sdkMcpTransports.keys());
|
||||||
|
await this.sendControlRequest(ControlRequestType.INITIALIZE, {
|
||||||
|
hooks: hooks ? Object.values(hooks).flat() : null,
|
||||||
|
sdkMcpServers:
|
||||||
|
sdkMcpServerNames.length > 0 ? sdkMcpServerNames : undefined,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Query] Initialization error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup SDK-embedded MCP servers
|
||||||
|
*/
|
||||||
|
private async setupSdkMcpServers(): Promise<void> {
|
||||||
|
if (!this.options.sdkMcpServers) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const externalNames = Object.keys(this.options.mcpServers ?? {});
|
||||||
|
const sdkNames = Object.keys(this.options.sdkMcpServers);
|
||||||
|
|
||||||
|
const conflicts = sdkNames.filter((name) => externalNames.includes(name));
|
||||||
|
if (conflicts.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`MCP server name conflicts between mcpServers and sdkMcpServers: ${conflicts.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import SdkControlServerTransport dynamically to avoid circular dependencies.
|
||||||
|
* Create transport for each server that sends MCP messages via control plane.
|
||||||
|
*/
|
||||||
|
const { SdkControlServerTransport } = await import(
|
||||||
|
'../mcp/SdkControlServerTransport.js'
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [name, server] of Object.entries(this.options.sdkMcpServers)) {
|
||||||
|
const transport = new SdkControlServerTransport({
|
||||||
|
serverName: name,
|
||||||
|
sendToQuery: async (message: JSONRPCMessage) => {
|
||||||
|
await this.sendControlRequest(ControlRequestType.MCP_MESSAGE, {
|
||||||
|
server_name: name,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await transport.start();
|
||||||
|
await server.connect(transport);
|
||||||
|
this.sdkMcpTransports.set(name, transport);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start message router (background task)
|
||||||
|
*/
|
||||||
|
private startMessageRouter(): void {
|
||||||
|
if (this.messageRouterStarted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.messageRouterStarted = true;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
for await (const message of this.transport.readMessages()) {
|
||||||
|
await this.routeMessage(message);
|
||||||
|
|
||||||
|
if (this.closed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.abortController.signal.aborted) {
|
||||||
|
this.inputStream.error(new AbortError('Query aborted'));
|
||||||
|
} else {
|
||||||
|
this.inputStream.done();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.inputStream.error(
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route incoming message
|
||||||
|
*/
|
||||||
|
private async routeMessage(message: unknown): Promise<void> {
|
||||||
|
if (isControlRequest(message)) {
|
||||||
|
await this.handleControlRequest(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isControlResponse(message)) {
|
||||||
|
this.handleControlResponse(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isControlCancel(message)) {
|
||||||
|
this.handleControlCancelRequest(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCLISystemMessage(message)) {
|
||||||
|
/**
|
||||||
|
* SystemMessage contains session info (cwd, tools, model, etc.)
|
||||||
|
* that should be passed to user.
|
||||||
|
*/
|
||||||
|
this.inputStream.enqueue(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCLIResultMessage(message)) {
|
||||||
|
if (this.firstResultReceivedResolve) {
|
||||||
|
this.firstResultReceivedResolve();
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* In single-turn mode, automatically close input after receiving result
|
||||||
|
* to signal completion to the CLI.
|
||||||
|
*/
|
||||||
|
if (this.isSingleTurn && 'endInput' in this.transport) {
|
||||||
|
(this.transport as TransportWithEndInput).endInput();
|
||||||
|
}
|
||||||
|
this.inputStream.enqueue(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isCLIAssistantMessage(message) ||
|
||||||
|
isCLIUserMessage(message) ||
|
||||||
|
isCLIPartialAssistantMessage(message)
|
||||||
|
) {
|
||||||
|
this.inputStream.enqueue(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env['DEBUG_SDK']) {
|
||||||
|
console.warn('[Query] Unknown message type:', message);
|
||||||
|
}
|
||||||
|
this.inputStream.enqueue(message as CLIMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle control request from CLI
|
||||||
|
*/
|
||||||
|
private async handleControlRequest(
|
||||||
|
request: CLIControlRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
const { request_id, request: payload } = request;
|
||||||
|
|
||||||
|
const requestAbortController = new AbortController();
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
|
switch (payload.subtype) {
|
||||||
|
case 'can_use_tool':
|
||||||
|
response = (await this.handlePermissionRequest(
|
||||||
|
payload.tool_name,
|
||||||
|
payload.input as Record<string, unknown>,
|
||||||
|
payload.permission_suggestions,
|
||||||
|
requestAbortController.signal,
|
||||||
|
)) as unknown as Record<string, unknown>;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'mcp_message':
|
||||||
|
response = await this.handleMcpMessage(
|
||||||
|
payload.server_name,
|
||||||
|
payload.message as unknown as JSONRPCMessage,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'hook_callback':
|
||||||
|
response = await this.handleHookCallback(
|
||||||
|
payload.callback_id,
|
||||||
|
payload.input,
|
||||||
|
payload.tool_use_id,
|
||||||
|
requestAbortController.signal,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(
|
||||||
|
`Unknown control request subtype: ${payload.subtype}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.sendControlResponse(request_id, true, response);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
await this.sendControlResponse(request_id, false, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle permission request (can_use_tool)
|
||||||
|
*/
|
||||||
|
private async handlePermissionRequest(
|
||||||
|
toolName: string,
|
||||||
|
toolInput: Record<string, unknown>,
|
||||||
|
permissionSuggestions: PermissionSuggestion[] | null,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<PermissionApproval> {
|
||||||
|
if (!this.options.canUseTool) {
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const timeoutMs = 30000;
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
setTimeout(
|
||||||
|
() => reject(new Error('Permission callback timeout')),
|
||||||
|
timeoutMs,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await Promise.race([
|
||||||
|
Promise.resolve(
|
||||||
|
this.options.canUseTool(toolName, toolInput, {
|
||||||
|
signal,
|
||||||
|
suggestions: permissionSuggestions,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
timeoutPromise,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (typeof result === 'boolean') {
|
||||||
|
return { allowed: result };
|
||||||
|
}
|
||||||
|
return result as PermissionApproval;
|
||||||
|
} catch (error) {
|
||||||
|
/**
|
||||||
|
* Timeout or error → deny (fail-safe).
|
||||||
|
* This ensures that any issues with the permission callback
|
||||||
|
* result in a safe default of denying access.
|
||||||
|
*/
|
||||||
|
console.warn(
|
||||||
|
'[Query] Permission callback error (denying by default):',
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
);
|
||||||
|
return { allowed: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle MCP message routing
|
||||||
|
*/
|
||||||
|
private async handleMcpMessage(
|
||||||
|
serverName: string,
|
||||||
|
message: JSONRPCMessage,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const transport = this.sdkMcpTransports.get(serverName);
|
||||||
|
if (!transport) {
|
||||||
|
throw new Error(
|
||||||
|
`MCP server '${serverName}' not found in SDK-embedded servers`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this is a request (has method and id) or notification.
|
||||||
|
* Requests need to wait for a response, while notifications are just routed.
|
||||||
|
*/
|
||||||
|
const isRequest =
|
||||||
|
'method' in message && 'id' in message && message.id !== null;
|
||||||
|
|
||||||
|
if (isRequest) {
|
||||||
|
const response = await this.handleMcpRequest(
|
||||||
|
serverName,
|
||||||
|
message,
|
||||||
|
transport,
|
||||||
|
);
|
||||||
|
return { mcp_response: response };
|
||||||
|
} else {
|
||||||
|
transport.handleMessage(message);
|
||||||
|
return { mcp_response: { jsonrpc: '2.0', result: {}, id: 0 } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle MCP request and wait for response
|
||||||
|
*/
|
||||||
|
private handleMcpRequest(
|
||||||
|
_serverName: string,
|
||||||
|
message: JSONRPCMessage,
|
||||||
|
transport: SdkControlServerTransport,
|
||||||
|
): Promise<JSONRPCMessage> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
reject(new Error('MCP request timeout'));
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
const messageId = 'id' in message ? message.id : null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook into transport to capture response.
|
||||||
|
* Temporarily replace sendToQuery to intercept the response message
|
||||||
|
* matching this request's ID, then restore the original handler.
|
||||||
|
*/
|
||||||
|
const originalSend = transport.sendToQuery;
|
||||||
|
transport.sendToQuery = async (responseMessage: JSONRPCMessage) => {
|
||||||
|
if ('id' in responseMessage && responseMessage.id === messageId) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
transport.sendToQuery = originalSend;
|
||||||
|
resolve(responseMessage);
|
||||||
|
}
|
||||||
|
return originalSend(responseMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
transport.handleMessage(message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle control response from CLI
|
||||||
|
*/
|
||||||
|
private handleControlResponse(response: CLIControlResponse): void {
|
||||||
|
const { response: payload } = response;
|
||||||
|
const request_id = payload.request_id;
|
||||||
|
|
||||||
|
const pending = this.pendingControlRequests.get(request_id);
|
||||||
|
if (!pending) {
|
||||||
|
console.warn(
|
||||||
|
'[Query] Received response for unknown request:',
|
||||||
|
request_id,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(pending.timeout);
|
||||||
|
this.pendingControlRequests.delete(request_id);
|
||||||
|
|
||||||
|
if (payload.subtype === 'success') {
|
||||||
|
pending.resolve(payload.response as Record<string, unknown> | null);
|
||||||
|
} else {
|
||||||
|
/**
|
||||||
|
* Extract error message from error field.
|
||||||
|
* Error can be either a string or an object with a message property.
|
||||||
|
*/
|
||||||
|
const errorMessage =
|
||||||
|
typeof payload.error === 'string'
|
||||||
|
? payload.error
|
||||||
|
: (payload.error?.message ?? 'Unknown error');
|
||||||
|
pending.reject(new Error(errorMessage));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle control cancel request from CLI
|
||||||
|
*/
|
||||||
|
private handleControlCancelRequest(request: ControlCancelRequest): void {
|
||||||
|
const { request_id } = request;
|
||||||
|
|
||||||
|
if (!request_id) {
|
||||||
|
console.warn('[Query] Received cancel request without request_id');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = this.pendingControlRequests.get(request_id);
|
||||||
|
if (pending) {
|
||||||
|
pending.abortController.abort();
|
||||||
|
clearTimeout(pending.timeout);
|
||||||
|
this.pendingControlRequests.delete(request_id);
|
||||||
|
pending.reject(new AbortError('Request cancelled'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle hook callback request
|
||||||
|
*/
|
||||||
|
private async handleHookCallback(
|
||||||
|
callbackId: string,
|
||||||
|
input: unknown,
|
||||||
|
toolUseId: string | null,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const callback = this.hookCallbacks.get(callbackId);
|
||||||
|
if (!callback) {
|
||||||
|
throw new Error(`No hook callback found for ID: ${callbackId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await callback(input, toolUseId, { signal });
|
||||||
|
return result as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send control request to CLI
|
||||||
|
*/
|
||||||
|
private async sendControlRequest(
|
||||||
|
subtype: string,
|
||||||
|
data: Record<string, unknown> = {},
|
||||||
|
): Promise<Record<string, unknown> | null> {
|
||||||
|
const requestId = randomUUID();
|
||||||
|
|
||||||
|
const request: CLIControlRequest = {
|
||||||
|
type: 'control_request',
|
||||||
|
request_id: requestId,
|
||||||
|
request: {
|
||||||
|
subtype: subtype as never,
|
||||||
|
...data,
|
||||||
|
} as CLIControlRequest['request'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const responsePromise = new Promise<Record<string, unknown> | null>(
|
||||||
|
(resolve, reject) => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
this.pendingControlRequests.delete(requestId);
|
||||||
|
reject(new Error(`Control request timeout: ${subtype}`));
|
||||||
|
}, 300000);
|
||||||
|
|
||||||
|
this.pendingControlRequests.set(requestId, {
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
timeout,
|
||||||
|
abortController,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.transport.write(serializeJsonLine(request));
|
||||||
|
return responsePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send control response to CLI
|
||||||
|
*/
|
||||||
|
private async sendControlResponse(
|
||||||
|
requestId: string,
|
||||||
|
success: boolean,
|
||||||
|
responseOrError: Record<string, unknown> | null | string,
|
||||||
|
): Promise<void> {
|
||||||
|
const response: CLIControlResponse = {
|
||||||
|
type: 'control_response',
|
||||||
|
response: success
|
||||||
|
? {
|
||||||
|
subtype: 'success',
|
||||||
|
request_id: requestId,
|
||||||
|
response: responseOrError as Record<string, unknown> | 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<void> {
|
||||||
|
if (this.closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.closed = true;
|
||||||
|
|
||||||
|
for (const pending of this.pendingControlRequests.values()) {
|
||||||
|
pending.abortController.abort();
|
||||||
|
clearTimeout(pending.timeout);
|
||||||
|
}
|
||||||
|
this.pendingControlRequests.clear();
|
||||||
|
|
||||||
|
this.hookCallbacks.clear();
|
||||||
|
|
||||||
|
await this.transport.close();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete input stream - check if aborted first.
|
||||||
|
* Only set error/done if stream doesn't already have an error state.
|
||||||
|
*/
|
||||||
|
if (this.inputStream.hasError === undefined) {
|
||||||
|
if (this.abortController.signal.aborted) {
|
||||||
|
this.inputStream.error(new AbortError('Query aborted'));
|
||||||
|
} else {
|
||||||
|
this.inputStream.done();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const transport of this.sdkMcpTransports.values()) {
|
||||||
|
try {
|
||||||
|
await transport.close();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Query] Error closing MCP transport:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.sdkMcpTransports.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read SDK messages from input stream (async generator proxy).
|
||||||
|
* This ensures stream.next() is called at least once, making readReject non-null.
|
||||||
|
*
|
||||||
|
* We don't wait for initialization here to ensure inputStream.next() is called
|
||||||
|
* as early as possible, so that readResolve/readReject are set up before any errors
|
||||||
|
* (like abort) can occur. Messages will be queued in the stream if they arrive before
|
||||||
|
* iteration starts.
|
||||||
|
*
|
||||||
|
* Starting iteration immediately ensures errors (like abort) can be properly propagated
|
||||||
|
* even if they occur before initialization completes.
|
||||||
|
*/
|
||||||
|
private async *readSdkMessages(): AsyncGenerator<CLIMessage> {
|
||||||
|
for await (const message of this.inputStream) {
|
||||||
|
yield message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AsyncIterator protocol: next()
|
||||||
|
*/
|
||||||
|
async next(...args: [] | [unknown]): Promise<IteratorResult<CLIMessage>> {
|
||||||
|
return this.sdkMessages.next(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AsyncIterator protocol: return()
|
||||||
|
*/
|
||||||
|
async return(value?: unknown): Promise<IteratorResult<CLIMessage>> {
|
||||||
|
return this.sdkMessages.return(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AsyncIterator protocol: throw()
|
||||||
|
*/
|
||||||
|
async throw(e?: unknown): Promise<IteratorResult<CLIMessage>> {
|
||||||
|
return this.sdkMessages.throw(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AsyncIterable protocol: Symbol.asyncIterator
|
||||||
|
*/
|
||||||
|
[Symbol.asyncIterator](): AsyncIterator<CLIMessage> {
|
||||||
|
return this.sdkMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send follow-up messages for multi-turn conversations
|
||||||
|
*
|
||||||
|
* @param messages - Async iterable of user messages to send
|
||||||
|
* @throws Error if query is closed
|
||||||
|
*/
|
||||||
|
async streamInput(messages: AsyncIterable<CLIUserMessage>): Promise<void> {
|
||||||
|
if (this.closed) {
|
||||||
|
throw new Error('Query is closed');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
/**
|
||||||
|
* Wait for initialization to complete before sending messages.
|
||||||
|
* This prevents "write after end" errors when streamInput is called
|
||||||
|
* with an empty iterable before initialization finishes.
|
||||||
|
*/
|
||||||
|
await this.initialized;
|
||||||
|
|
||||||
|
for await (const message of messages) {
|
||||||
|
if (this.abortController.signal.aborted) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.transport.write(serializeJsonLine(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In multi-turn mode with MCP servers, wait for first result
|
||||||
|
* to ensure MCP servers have time to process before next input.
|
||||||
|
* This prevents race conditions where the next input arrives before
|
||||||
|
* MCP servers have finished processing the current request.
|
||||||
|
*/
|
||||||
|
if (
|
||||||
|
!this.isSingleTurn &&
|
||||||
|
this.sdkMcpTransports.size > 0 &&
|
||||||
|
this.firstResultReceivedPromise
|
||||||
|
) {
|
||||||
|
const STREAM_CLOSE_TIMEOUT = 10000;
|
||||||
|
|
||||||
|
await Promise.race([
|
||||||
|
this.firstResultReceivedPromise,
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve();
|
||||||
|
}, STREAM_CLOSE_TIMEOUT);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.endInput();
|
||||||
|
} catch (error) {
|
||||||
|
if (this.abortController.signal.aborted) {
|
||||||
|
console.log('[Query] Aborted during input streaming');
|
||||||
|
this.inputStream.error(
|
||||||
|
new AbortError('Query aborted during input streaming'),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End input stream (close stdin to CLI)
|
||||||
|
*
|
||||||
|
* @throws Error if query is closed
|
||||||
|
*/
|
||||||
|
endInput(): void {
|
||||||
|
if (this.closed) {
|
||||||
|
throw new Error('Query is closed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
'endInput' in this.transport &&
|
||||||
|
typeof this.transport.endInput === 'function'
|
||||||
|
) {
|
||||||
|
(this.transport as TransportWithEndInput).endInput();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interrupt the current operation
|
||||||
|
*
|
||||||
|
* @throws Error if query is closed
|
||||||
|
*/
|
||||||
|
async interrupt(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<Record<string, unknown> | 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<Record<string, unknown> | 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
206
packages/sdk/typescript/src/query/createQuery.ts
Normal file
206
packages/sdk/typescript/src/query/createQuery.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* Factory function for creating Query instances.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CLIUserMessage } from '../types/protocol.js';
|
||||||
|
import { serializeJsonLine } from '../utils/jsonLines.js';
|
||||||
|
import type {
|
||||||
|
CreateQueryOptions,
|
||||||
|
PermissionMode,
|
||||||
|
PermissionCallback,
|
||||||
|
ExternalMcpServerConfig,
|
||||||
|
} from '../types/config.js';
|
||||||
|
import { ProcessTransport } from '../transport/ProcessTransport.js';
|
||||||
|
import { parseExecutableSpec } from '../utils/cliPath.js';
|
||||||
|
import { Query } from './Query.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration options for creating a Query.
|
||||||
|
*/
|
||||||
|
export type QueryOptions = {
|
||||||
|
cwd?: string;
|
||||||
|
model?: string;
|
||||||
|
pathToQwenExecutable?: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
permissionMode?: PermissionMode;
|
||||||
|
canUseTool?: PermissionCallback;
|
||||||
|
mcpServers?: Record<string, ExternalMcpServerConfig>;
|
||||||
|
sdkMcpServers?: Record<
|
||||||
|
string,
|
||||||
|
{ connect: (transport: unknown) => Promise<void> }
|
||||||
|
>;
|
||||||
|
abortController?: AbortController;
|
||||||
|
debug?: boolean;
|
||||||
|
stderr?: (message: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Query instance for interacting with the Qwen CLI.
|
||||||
|
*
|
||||||
|
* Supports both single-turn (string) and multi-turn (AsyncIterable) prompts.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const q = query({
|
||||||
|
* prompt: 'What files are in this directory?',
|
||||||
|
* options: { cwd: process.cwd() },
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* for await (const msg of q) {
|
||||||
|
* if (msg.type === 'assistant') {
|
||||||
|
* console.log(msg.message.content);
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function query({
|
||||||
|
prompt,
|
||||||
|
options = {},
|
||||||
|
}: {
|
||||||
|
prompt: string | AsyncIterable<CLIUserMessage>;
|
||||||
|
options?: QueryOptions;
|
||||||
|
}): Query {
|
||||||
|
// Validate options and obtain normalized executable metadata
|
||||||
|
const parsedExecutable = validateOptions(options);
|
||||||
|
|
||||||
|
// Determine if this is a single-turn or multi-turn query
|
||||||
|
// Single-turn: string prompt (simple Q&A)
|
||||||
|
// Multi-turn: AsyncIterable prompt (streaming conversation)
|
||||||
|
const isSingleTurn = typeof prompt === 'string';
|
||||||
|
|
||||||
|
// Build CreateQueryOptions
|
||||||
|
const queryOptions: CreateQueryOptions = {
|
||||||
|
...options,
|
||||||
|
singleTurn: isSingleTurn,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolve CLI specification while preserving explicit runtime directives
|
||||||
|
const pathToQwenExecutable =
|
||||||
|
options.pathToQwenExecutable ?? parsedExecutable.executablePath;
|
||||||
|
|
||||||
|
// Use provided abortController or create a new one
|
||||||
|
const abortController = options.abortController ?? new AbortController();
|
||||||
|
|
||||||
|
// Create transport with abortController
|
||||||
|
const transport = new ProcessTransport({
|
||||||
|
pathToQwenExecutable,
|
||||||
|
cwd: options.cwd,
|
||||||
|
model: options.model,
|
||||||
|
permissionMode: options.permissionMode,
|
||||||
|
mcpServers: options.mcpServers,
|
||||||
|
env: options.env,
|
||||||
|
abortController,
|
||||||
|
debug: options.debug,
|
||||||
|
stderr: options.stderr,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build query options with abortController
|
||||||
|
const finalQueryOptions: CreateQueryOptions = {
|
||||||
|
...queryOptions,
|
||||||
|
abortController,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create Query
|
||||||
|
const queryInstance = new Query(transport, finalQueryOptions);
|
||||||
|
|
||||||
|
// Handle prompt based on type
|
||||||
|
if (isSingleTurn) {
|
||||||
|
// For single-turn queries, send the prompt directly via transport
|
||||||
|
const stringPrompt = prompt as string;
|
||||||
|
const message: CLIUserMessage = {
|
||||||
|
type: 'user',
|
||||||
|
session_id: queryInstance.getSessionId(),
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: stringPrompt,
|
||||||
|
},
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
transport.write(serializeJsonLine(message));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[query] Error sending single-turn prompt:', err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
} else {
|
||||||
|
// For multi-turn queries, stream the input
|
||||||
|
queryInstance
|
||||||
|
.streamInput(prompt as AsyncIterable<CLIUserMessage>)
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('[query] Error streaming input:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backward compatibility alias
|
||||||
|
* @deprecated Use query() instead
|
||||||
|
*/
|
||||||
|
export const createQuery = query;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate query configuration options and normalize CLI executable details.
|
||||||
|
*
|
||||||
|
* Performs strict validation for each supported option, including
|
||||||
|
* permission mode, callbacks, AbortController usage, and executable spec.
|
||||||
|
* Returns the parsed executable description so callers can retain
|
||||||
|
* explicit runtime directives (e.g., `bun:/path/to/cli.js`) while still
|
||||||
|
* benefiting from early validation and auto-detection fallbacks when the
|
||||||
|
* specification is omitted.
|
||||||
|
*/
|
||||||
|
function validateOptions(
|
||||||
|
options: QueryOptions,
|
||||||
|
): ReturnType<typeof parseExecutableSpec> {
|
||||||
|
let parsedExecutable: ReturnType<typeof parseExecutableSpec>;
|
||||||
|
|
||||||
|
// Validate permission mode if provided
|
||||||
|
if (options.permissionMode) {
|
||||||
|
const validModes = ['default', 'plan', 'auto-edit', 'yolo'];
|
||||||
|
if (!validModes.includes(options.permissionMode)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid permissionMode: ${options.permissionMode}. Valid values are: ${validModes.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate canUseTool is a function if provided
|
||||||
|
if (options.canUseTool && typeof options.canUseTool !== 'function') {
|
||||||
|
throw new Error('canUseTool must be a function');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate abortController is AbortController if provided
|
||||||
|
if (
|
||||||
|
options.abortController &&
|
||||||
|
!(options.abortController instanceof AbortController)
|
||||||
|
) {
|
||||||
|
throw new Error('abortController must be an AbortController instance');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate executable path early to provide clear error messages
|
||||||
|
try {
|
||||||
|
parsedExecutable = parseExecutableSpec(options.pathToQwenExecutable);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new Error(`Invalid pathToQwenExecutable: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate no MCP server name conflicts
|
||||||
|
if (options.mcpServers && options.sdkMcpServers) {
|
||||||
|
const externalNames = Object.keys(options.mcpServers);
|
||||||
|
const sdkNames = Object.keys(options.sdkMcpServers);
|
||||||
|
|
||||||
|
const conflicts = externalNames.filter((name) => sdkNames.includes(name));
|
||||||
|
if (conflicts.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`MCP server name conflicts between mcpServers and sdkMcpServers: ${conflicts.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedExecutable;
|
||||||
|
}
|
||||||
379
packages/sdk/typescript/src/transport/ProcessTransport.ts
Normal file
379
packages/sdk/typescript/src/transport/ProcessTransport.ts
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
/**
|
||||||
|
* ProcessTransport - Subprocess-based transport for SDK-CLI communication
|
||||||
|
*
|
||||||
|
* Manages CLI subprocess lifecycle and provides IPC via stdin/stdout using JSON Lines protocol.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn, type ChildProcess } from 'node:child_process';
|
||||||
|
import * as readline from 'node:readline';
|
||||||
|
import type { Writable, Readable } from 'node:stream';
|
||||||
|
import type { TransportOptions } from '../types/config.js';
|
||||||
|
import type { Transport } from './Transport.js';
|
||||||
|
import { parseJsonLinesStream } from '../utils/jsonLines.js';
|
||||||
|
import { prepareSpawnInfo } from '../utils/cliPath.js';
|
||||||
|
import { AbortError } from '../types/errors.js';
|
||||||
|
|
||||||
|
type ExitListener = {
|
||||||
|
callback: (error?: Error) => void;
|
||||||
|
handler: (code: number | null, signal: NodeJS.Signals | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ProcessTransport implements Transport {
|
||||||
|
private childProcess: ChildProcess | null = null;
|
||||||
|
private childStdin: Writable | null = null;
|
||||||
|
private childStdout: Readable | null = null;
|
||||||
|
private options: TransportOptions;
|
||||||
|
private ready = false;
|
||||||
|
private _exitError: Error | null = null;
|
||||||
|
private closed = false;
|
||||||
|
private abortController: AbortController;
|
||||||
|
private exitListeners: ExitListener[] = [];
|
||||||
|
private processExitHandler: (() => void) | null = null;
|
||||||
|
private abortHandler: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor(options: TransportOptions) {
|
||||||
|
this.options = options;
|
||||||
|
this.abortController =
|
||||||
|
this.options.abortController ?? new AbortController();
|
||||||
|
this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initialize(): void {
|
||||||
|
try {
|
||||||
|
if (this.abortController.signal.aborted) {
|
||||||
|
throw new AbortError('Transport start aborted');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cliArgs = this.buildCliArguments();
|
||||||
|
const cwd = this.options.cwd ?? process.cwd();
|
||||||
|
const env = { ...process.env, ...this.options.env };
|
||||||
|
|
||||||
|
const spawnInfo = prepareSpawnInfo(this.options.pathToQwenExecutable);
|
||||||
|
|
||||||
|
const stderrMode =
|
||||||
|
this.options.debug || this.options.stderr ? 'pipe' : 'ignore';
|
||||||
|
|
||||||
|
this.logForDebugging(
|
||||||
|
`Spawning CLI (${spawnInfo.type}): ${spawnInfo.command} ${[...spawnInfo.args, ...cliArgs].join(' ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.childProcess = spawn(
|
||||||
|
spawnInfo.command,
|
||||||
|
[...spawnInfo.args, ...cliArgs],
|
||||||
|
{
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
stdio: ['pipe', 'pipe', stderrMode],
|
||||||
|
signal: this.abortController.signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.childStdin = this.childProcess.stdin;
|
||||||
|
this.childStdout = this.childProcess.stdout;
|
||||||
|
|
||||||
|
if (this.options.debug || this.options.stderr) {
|
||||||
|
this.childProcess.stderr?.on('data', (data) => {
|
||||||
|
this.logForDebugging(data.toString());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanup = (): void => {
|
||||||
|
if (this.childProcess && !this.childProcess.killed) {
|
||||||
|
this.childProcess.kill('SIGTERM');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.processExitHandler = cleanup;
|
||||||
|
this.abortHandler = cleanup;
|
||||||
|
process.on('exit', this.processExitHandler);
|
||||||
|
this.abortController.signal.addEventListener('abort', this.abortHandler);
|
||||||
|
|
||||||
|
this.setupEventHandlers();
|
||||||
|
|
||||||
|
this.ready = true;
|
||||||
|
} catch (error) {
|
||||||
|
this.ready = false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupEventHandlers(): void {
|
||||||
|
if (!this.childProcess) return;
|
||||||
|
|
||||||
|
this.childProcess.on('error', (error) => {
|
||||||
|
this.ready = false;
|
||||||
|
if (this.abortController.signal.aborted) {
|
||||||
|
this._exitError = new AbortError('CLI process aborted by user');
|
||||||
|
} else {
|
||||||
|
this._exitError = new Error(`CLI process error: ${error.message}`);
|
||||||
|
this.logForDebugging(this._exitError.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.childProcess.on('close', (code, signal) => {
|
||||||
|
this.ready = false;
|
||||||
|
if (this.abortController.signal.aborted) {
|
||||||
|
this._exitError = new AbortError('CLI process aborted by user');
|
||||||
|
} else {
|
||||||
|
const error = this.getProcessExitError(code, signal);
|
||||||
|
if (error) {
|
||||||
|
this._exitError = error;
|
||||||
|
this.logForDebugging(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = this._exitError;
|
||||||
|
for (const listener of this.exitListeners) {
|
||||||
|
try {
|
||||||
|
listener.callback(error || undefined);
|
||||||
|
} catch (err) {
|
||||||
|
this.logForDebugging(`Exit listener error: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getProcessExitError(
|
||||||
|
code: number | null,
|
||||||
|
signal: NodeJS.Signals | null,
|
||||||
|
): Error | undefined {
|
||||||
|
if (code !== 0 && code !== null) {
|
||||||
|
return new Error(`CLI process exited with code ${code}`);
|
||||||
|
} else if (signal) {
|
||||||
|
return new Error(`CLI process terminated by signal ${signal}`);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
private buildCliArguments(): string[] {
|
||||||
|
const args: string[] = [
|
||||||
|
'--input-format',
|
||||||
|
'stream-json',
|
||||||
|
'--output-format',
|
||||||
|
'stream-json',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (this.options.model) {
|
||||||
|
args.push('--model', this.options.model);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.permissionMode) {
|
||||||
|
args.push('--approval-mode', this.options.permissionMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.mcpServers) {
|
||||||
|
for (const [name, config] of Object.entries(this.options.mcpServers)) {
|
||||||
|
args.push('--mcp-server', JSON.stringify({ name, ...config }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
if (this.childStdin) {
|
||||||
|
this.childStdin.end();
|
||||||
|
this.childStdin = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.processExitHandler) {
|
||||||
|
process.off('exit', this.processExitHandler);
|
||||||
|
this.processExitHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.abortHandler) {
|
||||||
|
this.abortController.signal.removeEventListener(
|
||||||
|
'abort',
|
||||||
|
this.abortHandler,
|
||||||
|
);
|
||||||
|
this.abortHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { handler } of this.exitListeners) {
|
||||||
|
this.childProcess?.off('close', handler);
|
||||||
|
}
|
||||||
|
this.exitListeners = [];
|
||||||
|
|
||||||
|
if (this.childProcess && !this.childProcess.killed) {
|
||||||
|
this.childProcess.kill('SIGTERM');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.childProcess && !this.childProcess.killed) {
|
||||||
|
this.childProcess.kill('SIGKILL');
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ready = false;
|
||||||
|
this.closed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForExit(): Promise<void> {
|
||||||
|
if (!this.childProcess) {
|
||||||
|
if (this._exitError) {
|
||||||
|
throw this._exitError;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.childProcess.exitCode !== null || this.childProcess.killed) {
|
||||||
|
if (this._exitError) {
|
||||||
|
throw this._exitError;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const exitHandler = (
|
||||||
|
code: number | null,
|
||||||
|
signal: NodeJS.Signals | null,
|
||||||
|
) => {
|
||||||
|
if (this.abortController.signal.aborted) {
|
||||||
|
reject(new AbortError('Operation aborted'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = this.getProcessExitError(code, signal);
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.childProcess!.once('close', exitHandler);
|
||||||
|
|
||||||
|
const errorHandler = (error: Error) => {
|
||||||
|
this.childProcess!.off('close', exitHandler);
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.childProcess!.once('error', errorHandler);
|
||||||
|
|
||||||
|
this.childProcess!.once('close', () => {
|
||||||
|
this.childProcess!.off('error', errorHandler);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
write(message: string): void {
|
||||||
|
if (this.abortController.signal.aborted) {
|
||||||
|
throw new AbortError('Cannot write: operation aborted');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.ready || !this.childStdin) {
|
||||||
|
throw new Error('Transport not ready for writing');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.closed) {
|
||||||
|
throw new Error('Cannot write to closed transport');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.childProcess?.killed || this.childProcess?.exitCode !== null) {
|
||||||
|
throw new Error('Cannot write to terminated process');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._exitError) {
|
||||||
|
throw new Error(
|
||||||
|
`Cannot write to process that exited with error: ${this._exitError.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env['DEBUG_SDK']) {
|
||||||
|
this.logForDebugging(
|
||||||
|
`[ProcessTransport] Writing to stdin: ${message.substring(0, 100)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const written = this.childStdin.write(message);
|
||||||
|
if (!written && process.env['DEBUG_SDK']) {
|
||||||
|
this.logForDebugging(
|
||||||
|
'[ProcessTransport] Write buffer full, data queued',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.ready = false;
|
||||||
|
throw new Error(
|
||||||
|
`Failed to write to stdin: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async *readMessages(): AsyncGenerator<unknown, void, unknown> {
|
||||||
|
if (!this.childStdout) {
|
||||||
|
throw new Error('Cannot read messages: process not started');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: this.childStdout,
|
||||||
|
crlfDelay: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of parseJsonLinesStream(
|
||||||
|
rl,
|
||||||
|
'ProcessTransport',
|
||||||
|
)) {
|
||||||
|
yield message;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.waitForExit();
|
||||||
|
} finally {
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get isReady(): boolean {
|
||||||
|
return this.ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
get exitError(): Error | null {
|
||||||
|
return this._exitError;
|
||||||
|
}
|
||||||
|
|
||||||
|
onExit(callback: (error?: Error) => void): () => void {
|
||||||
|
if (!this.childProcess) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = (code: number | null, signal: NodeJS.Signals | null) => {
|
||||||
|
const error = this.getProcessExitError(code, signal);
|
||||||
|
callback(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.childProcess.on('close', handler);
|
||||||
|
this.exitListeners.push({ callback, handler });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (this.childProcess) {
|
||||||
|
this.childProcess.off('close', handler);
|
||||||
|
}
|
||||||
|
const index = this.exitListeners.findIndex((l) => l.handler === handler);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.exitListeners.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
endInput(): void {
|
||||||
|
if (this.childStdin) {
|
||||||
|
this.childStdin.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getInputStream(): Writable | undefined {
|
||||||
|
return this.childStdin || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
getOutputStream(): Readable | undefined {
|
||||||
|
return this.childStdout || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private logForDebugging(message: string): void {
|
||||||
|
if (this.options.debug || process.env['DEBUG']) {
|
||||||
|
process.stderr.write(`[ProcessTransport] ${message}\n`);
|
||||||
|
}
|
||||||
|
if (this.options.stderr) {
|
||||||
|
this.options.stderr(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
93
packages/sdk/typescript/src/transport/Transport.ts
Normal file
93
packages/sdk/typescript/src/transport/Transport.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* Transport interface for SDK-CLI communication
|
||||||
|
*
|
||||||
|
* The Transport abstraction enables communication between SDK and CLI via different mechanisms:
|
||||||
|
* - ProcessTransport: Local subprocess via stdin/stdout (initial implementation)
|
||||||
|
* - HttpTransport: Remote CLI via HTTP (future)
|
||||||
|
* - WebSocketTransport: Remote CLI via WebSocket (future)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract Transport interface
|
||||||
|
*
|
||||||
|
* Provides bidirectional communication with lifecycle management.
|
||||||
|
* Implements async generator pattern for reading messages with automatic backpressure.
|
||||||
|
*
|
||||||
|
* Transport initialization happens in the constructor. For ProcessTransport,
|
||||||
|
* the subprocess is spawned during construction. Future transports (HTTP/WebSocket)
|
||||||
|
* may use async factory methods if asynchronous initialization is needed.
|
||||||
|
*/
|
||||||
|
export interface Transport {
|
||||||
|
/**
|
||||||
|
* Close the transport gracefully.
|
||||||
|
*
|
||||||
|
* For ProcessTransport: sends SIGTERM, waits 5s, then SIGKILL
|
||||||
|
* For HttpTransport: sends close request, closes connection
|
||||||
|
* For WebSocketTransport: sends close frame
|
||||||
|
*
|
||||||
|
* Idempotent - safe to call multiple times.
|
||||||
|
*/
|
||||||
|
close(): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<unknown, void, unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
145
packages/sdk/typescript/src/types/config.ts
Normal file
145
packages/sdk/typescript/src/types/config.ts
Normal file
@@ -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<boolean|unknown> or boolean|unknown - true to allow, false to deny, or custom response
|
||||||
|
*/
|
||||||
|
export type PermissionCallback = (
|
||||||
|
toolName: string,
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
options?: {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
suggestions?: unknown;
|
||||||
|
},
|
||||||
|
) => Promise<boolean | unknown> | 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<unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook matcher configuration
|
||||||
|
*/
|
||||||
|
export interface HookMatcher {
|
||||||
|
matcher: Record<string, unknown>;
|
||||||
|
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<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, string>;
|
||||||
|
|
||||||
|
// 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<string, ExternalMcpServerConfig>;
|
||||||
|
/** SDK-embedded MCP servers (run in Node.js process) */
|
||||||
|
sdkMcpServers?: Record<
|
||||||
|
string,
|
||||||
|
{ connect: (transport: unknown) => Promise<void> }
|
||||||
|
>; // Server from @modelcontextprotocol/sdk
|
||||||
|
|
||||||
|
// Conversation mode
|
||||||
|
/**
|
||||||
|
* Single-turn mode: automatically close input after receiving result
|
||||||
|
* Multi-turn mode: keep input open for follow-up messages
|
||||||
|
* @default false (multi-turn)
|
||||||
|
*/
|
||||||
|
singleTurn?: boolean;
|
||||||
|
|
||||||
|
// Advanced options
|
||||||
|
/** AbortController for cancellation support */
|
||||||
|
abortController?: AbortController;
|
||||||
|
/** Enable debug output (inherits stderr) */
|
||||||
|
debug?: boolean;
|
||||||
|
/** Callback for stderr output */
|
||||||
|
stderr?: (message: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transport options for ProcessTransport
|
||||||
|
*/
|
||||||
|
export type TransportOptions = {
|
||||||
|
/** Path to qwen executable */
|
||||||
|
pathToQwenExecutable: string;
|
||||||
|
/** Working directory for CLI execution */
|
||||||
|
cwd?: string;
|
||||||
|
/** Model name */
|
||||||
|
model?: string;
|
||||||
|
/** Permission mode */
|
||||||
|
permissionMode?: PermissionMode;
|
||||||
|
/** External MCP servers */
|
||||||
|
mcpServers?: Record<string, ExternalMcpServerConfig>;
|
||||||
|
/** Environment variables */
|
||||||
|
env?: Record<string, string>;
|
||||||
|
/** AbortController for cancellation support */
|
||||||
|
abortController?: AbortController;
|
||||||
|
/** Enable debug output */
|
||||||
|
debug?: boolean;
|
||||||
|
/** Callback for stderr output */
|
||||||
|
stderr?: (message: string) => void;
|
||||||
|
};
|
||||||
50
packages/sdk/typescript/src/types/controlRequests.ts
Normal file
50
packages/sdk/typescript/src/types/controlRequests.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
27
packages/sdk/typescript/src/types/errors.ts
Normal file
27
packages/sdk/typescript/src/types/errors.ts
Normal file
@@ -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')
|
||||||
|
);
|
||||||
|
}
|
||||||
32
packages/sdk/typescript/src/types/mcp.ts
Normal file
32
packages/sdk/typescript/src/types/mcp.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* MCP integration types for SDK
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON Schema definition
|
||||||
|
* Used for tool input validation
|
||||||
|
*/
|
||||||
|
export type JSONSchema = {
|
||||||
|
type: string;
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
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<TInput = unknown, TOutput = unknown> = {
|
||||||
|
/** 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<TOutput>;
|
||||||
|
};
|
||||||
509
packages/sdk/typescript/src/types/protocol.ts
Normal file
509
packages/sdk/typescript/src/types/protocol.ts
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Annotation for attaching metadata to content blocks
|
||||||
|
*/
|
||||||
|
export interface Annotation {
|
||||||
|
type: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Usage information types
|
||||||
|
*/
|
||||||
|
export interface Usage {
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
cache_creation_input_tokens?: number;
|
||||||
|
cache_read_input_tokens?: number;
|
||||||
|
total_tokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtendedUsage extends Usage {
|
||||||
|
server_tool_use?: {
|
||||||
|
web_search_requests: number;
|
||||||
|
};
|
||||||
|
service_tier?: string;
|
||||||
|
cache_creation?: {
|
||||||
|
ephemeral_1h_input_tokens: number;
|
||||||
|
ephemeral_5m_input_tokens: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelUsage {
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
cacheReadInputTokens: number;
|
||||||
|
cacheCreationInputTokens: number;
|
||||||
|
webSearchRequests: number;
|
||||||
|
contextWindow: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission denial information
|
||||||
|
*/
|
||||||
|
export interface CLIPermissionDenial {
|
||||||
|
tool_name: string;
|
||||||
|
tool_use_id: string;
|
||||||
|
tool_input: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content block types from Anthropic SDK
|
||||||
|
*/
|
||||||
|
export interface TextBlock {
|
||||||
|
type: 'text';
|
||||||
|
text: string;
|
||||||
|
annotations?: Annotation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThinkingBlock {
|
||||||
|
type: 'thinking';
|
||||||
|
thinking: string;
|
||||||
|
signature?: string;
|
||||||
|
annotations?: Annotation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolUseBlock {
|
||||||
|
type: 'tool_use';
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
input: unknown;
|
||||||
|
annotations?: Annotation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolResultBlock {
|
||||||
|
type: 'tool_result';
|
||||||
|
tool_use_id: string;
|
||||||
|
content?: string | ContentBlock[];
|
||||||
|
is_error?: boolean;
|
||||||
|
annotations?: Annotation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContentBlock =
|
||||||
|
| TextBlock
|
||||||
|
| ThinkingBlock
|
||||||
|
| ToolUseBlock
|
||||||
|
| ToolResultBlock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anthropic SDK Message types
|
||||||
|
*/
|
||||||
|
export interface APIUserMessage {
|
||||||
|
role: 'user';
|
||||||
|
content: string | ContentBlock[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface APIAssistantMessage {
|
||||||
|
id: string;
|
||||||
|
type: 'message';
|
||||||
|
role: 'assistant';
|
||||||
|
model: string;
|
||||||
|
content: ContentBlock[];
|
||||||
|
stop_reason?: string | null;
|
||||||
|
usage: Usage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLI Message wrapper types
|
||||||
|
*/
|
||||||
|
export interface CLIUserMessage {
|
||||||
|
type: 'user';
|
||||||
|
uuid?: string;
|
||||||
|
session_id: string;
|
||||||
|
message: APIUserMessage;
|
||||||
|
parent_tool_use_id: string | null;
|
||||||
|
options?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CLIAssistantMessage {
|
||||||
|
type: 'assistant';
|
||||||
|
uuid: string;
|
||||||
|
session_id: string;
|
||||||
|
message: APIAssistantMessage;
|
||||||
|
parent_tool_use_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CLISystemMessage {
|
||||||
|
type: 'system';
|
||||||
|
subtype: string;
|
||||||
|
uuid: string;
|
||||||
|
session_id: string;
|
||||||
|
data?: unknown;
|
||||||
|
cwd?: string;
|
||||||
|
tools?: string[];
|
||||||
|
mcp_servers?: Array<{
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
}>;
|
||||||
|
model?: string;
|
||||||
|
permissionMode?: string;
|
||||||
|
slash_commands?: string[];
|
||||||
|
apiKeySource?: string;
|
||||||
|
qwen_code_version?: string;
|
||||||
|
output_style?: string;
|
||||||
|
agents?: string[];
|
||||||
|
skills?: string[];
|
||||||
|
capabilities?: Record<string, unknown>;
|
||||||
|
compact_metadata?: {
|
||||||
|
trigger: 'manual' | 'auto';
|
||||||
|
pre_tokens: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CLIResultMessageSuccess {
|
||||||
|
type: 'result';
|
||||||
|
subtype: 'success';
|
||||||
|
uuid: string;
|
||||||
|
session_id: string;
|
||||||
|
is_error: false;
|
||||||
|
duration_ms: number;
|
||||||
|
duration_api_ms: number;
|
||||||
|
num_turns: number;
|
||||||
|
result: string;
|
||||||
|
usage: ExtendedUsage;
|
||||||
|
modelUsage?: Record<string, ModelUsage>;
|
||||||
|
permission_denials: CLIPermissionDenial[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CLIResultMessageError {
|
||||||
|
type: 'result';
|
||||||
|
subtype: 'error_max_turns' | 'error_during_execution';
|
||||||
|
uuid: string;
|
||||||
|
session_id: string;
|
||||||
|
is_error: true;
|
||||||
|
duration_ms: number;
|
||||||
|
duration_api_ms: number;
|
||||||
|
num_turns: number;
|
||||||
|
usage: ExtendedUsage;
|
||||||
|
modelUsage?: Record<string, ModelUsage>;
|
||||||
|
permission_denials: CLIPermissionDenial[];
|
||||||
|
error?: {
|
||||||
|
type?: string;
|
||||||
|
message: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CLIResultMessage = CLIResultMessageSuccess | CLIResultMessageError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream event types for real-time message updates
|
||||||
|
*/
|
||||||
|
export interface MessageStartStreamEvent {
|
||||||
|
type: 'message_start';
|
||||||
|
message: {
|
||||||
|
id: string;
|
||||||
|
role: 'assistant';
|
||||||
|
model: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentBlockStartEvent {
|
||||||
|
type: 'content_block_start';
|
||||||
|
index: number;
|
||||||
|
content_block: ContentBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContentBlockDelta =
|
||||||
|
| {
|
||||||
|
type: 'text_delta';
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'thinking_delta';
|
||||||
|
thinking: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'input_json_delta';
|
||||||
|
partial_json: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ContentBlockDeltaEvent {
|
||||||
|
type: 'content_block_delta';
|
||||||
|
index: number;
|
||||||
|
delta: ContentBlockDelta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentBlockStopEvent {
|
||||||
|
type: 'content_block_stop';
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageStopStreamEvent {
|
||||||
|
type: 'message_stop';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StreamEvent =
|
||||||
|
| MessageStartStreamEvent
|
||||||
|
| ContentBlockStartEvent
|
||||||
|
| ContentBlockDeltaEvent
|
||||||
|
| ContentBlockStopEvent
|
||||||
|
| MessageStopStreamEvent;
|
||||||
|
|
||||||
|
export interface CLIPartialAssistantMessage {
|
||||||
|
type: 'stream_event';
|
||||||
|
uuid: string;
|
||||||
|
session_id: string;
|
||||||
|
event: StreamEvent;
|
||||||
|
parent_tool_use_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PermissionMode = 'default' | 'plan' | 'auto-edit' | 'yolo';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission suggestion for tool use requests
|
||||||
|
* TODO: Align with `ToolCallConfirmationDetails`
|
||||||
|
*/
|
||||||
|
export interface PermissionSuggestion {
|
||||||
|
type: 'allow' | 'deny' | 'modify';
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
modifiedInput?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook callback placeholder for future implementation
|
||||||
|
*/
|
||||||
|
export interface HookRegistration {
|
||||||
|
event: string;
|
||||||
|
callback_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook callback result placeholder for future implementation
|
||||||
|
*/
|
||||||
|
export interface HookCallbackResult {
|
||||||
|
shouldSkip?: boolean;
|
||||||
|
shouldInterrupt?: boolean;
|
||||||
|
suppressOutput?: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CLIControlInterruptRequest {
|
||||||
|
subtype: 'interrupt';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CLIControlPermissionRequest {
|
||||||
|
subtype: 'can_use_tool';
|
||||||
|
tool_name: string;
|
||||||
|
tool_use_id: string;
|
||||||
|
input: unknown;
|
||||||
|
permission_suggestions: PermissionSuggestion[] | null;
|
||||||
|
blocked_path: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CLIControlInitializeRequest {
|
||||||
|
subtype: 'initialize';
|
||||||
|
hooks?: HookRegistration[] | null;
|
||||||
|
sdkMcpServers?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CLIControlSetPermissionModeRequest {
|
||||||
|
subtype: 'set_permission_mode';
|
||||||
|
mode: PermissionMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CLIHookCallbackRequest {
|
||||||
|
subtype: 'hook_callback';
|
||||||
|
callback_id: string;
|
||||||
|
input: unknown;
|
||||||
|
tool_use_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CLIControlMcpMessageRequest {
|
||||||
|
subtype: 'mcp_message';
|
||||||
|
server_name: string;
|
||||||
|
message: {
|
||||||
|
jsonrpc?: string;
|
||||||
|
method: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
id?: string | number | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CLIControlSetModelRequest {
|
||||||
|
subtype: 'set_model';
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CLIControlMcpStatusRequest {
|
||||||
|
subtype: 'mcp_server_status';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CLIControlSupportedCommandsRequest {
|
||||||
|
subtype: 'supported_commands';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ControlRequestPayload =
|
||||||
|
| CLIControlInterruptRequest
|
||||||
|
| CLIControlPermissionRequest
|
||||||
|
| CLIControlInitializeRequest
|
||||||
|
| CLIControlSetPermissionModeRequest
|
||||||
|
| CLIHookCallbackRequest
|
||||||
|
| CLIControlMcpMessageRequest
|
||||||
|
| CLIControlSetModelRequest
|
||||||
|
| CLIControlMcpStatusRequest
|
||||||
|
| CLIControlSupportedCommandsRequest;
|
||||||
|
|
||||||
|
export interface CLIControlRequest {
|
||||||
|
type: 'control_request';
|
||||||
|
request_id: string;
|
||||||
|
request: ControlRequestPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission approval result
|
||||||
|
*/
|
||||||
|
export interface PermissionApproval {
|
||||||
|
allowed: boolean;
|
||||||
|
reason?: string;
|
||||||
|
modifiedInput?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ControlResponse {
|
||||||
|
subtype: 'success';
|
||||||
|
request_id: string;
|
||||||
|
response: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ControlErrorResponse {
|
||||||
|
subtype: 'error';
|
||||||
|
request_id: string;
|
||||||
|
error: string | { message: string; [key: string]: unknown };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CLIControlResponse {
|
||||||
|
type: 'control_response';
|
||||||
|
response: ControlResponse | ControlErrorResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ControlCancelRequest {
|
||||||
|
type: 'control_cancel_request';
|
||||||
|
request_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ControlMessage =
|
||||||
|
| CLIControlRequest
|
||||||
|
| CLIControlResponse
|
||||||
|
| ControlCancelRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union of all CLI message types
|
||||||
|
*/
|
||||||
|
export type CLIMessage =
|
||||||
|
| CLIUserMessage
|
||||||
|
| CLIAssistantMessage
|
||||||
|
| CLISystemMessage
|
||||||
|
| CLIResultMessage
|
||||||
|
| CLIPartialAssistantMessage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard functions for message discrimination
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function isCLIUserMessage(msg: any): msg is CLIUserMessage {
|
||||||
|
return (
|
||||||
|
msg && typeof msg === 'object' && msg.type === 'user' && 'message' in msg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCLIAssistantMessage(msg: any): msg is CLIAssistantMessage {
|
||||||
|
return (
|
||||||
|
msg &&
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg.type === 'assistant' &&
|
||||||
|
'uuid' in msg &&
|
||||||
|
'message' in msg &&
|
||||||
|
'session_id' in msg &&
|
||||||
|
'parent_tool_use_id' in msg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCLISystemMessage(msg: any): msg is CLISystemMessage {
|
||||||
|
return (
|
||||||
|
msg &&
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg.type === 'system' &&
|
||||||
|
'subtype' in msg &&
|
||||||
|
'uuid' in msg &&
|
||||||
|
'session_id' in msg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCLIResultMessage(msg: any): msg is CLIResultMessage {
|
||||||
|
return (
|
||||||
|
msg &&
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg.type === 'result' &&
|
||||||
|
'subtype' in msg &&
|
||||||
|
'duration_ms' in msg &&
|
||||||
|
'is_error' in msg &&
|
||||||
|
'uuid' in msg &&
|
||||||
|
'session_id' in msg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCLIPartialAssistantMessage(
|
||||||
|
msg: any,
|
||||||
|
): msg is CLIPartialAssistantMessage {
|
||||||
|
return (
|
||||||
|
msg &&
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg.type === 'stream_event' &&
|
||||||
|
'uuid' in msg &&
|
||||||
|
'session_id' in msg &&
|
||||||
|
'event' in msg &&
|
||||||
|
'parent_tool_use_id' in msg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isControlRequest(msg: any): msg is CLIControlRequest {
|
||||||
|
return (
|
||||||
|
msg &&
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg.type === 'control_request' &&
|
||||||
|
'request_id' in msg &&
|
||||||
|
'request' in msg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isControlResponse(msg: any): msg is CLIControlResponse {
|
||||||
|
return (
|
||||||
|
msg &&
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg.type === 'control_response' &&
|
||||||
|
'response' in msg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isControlCancel(msg: any): msg is ControlCancelRequest {
|
||||||
|
return (
|
||||||
|
msg &&
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg.type === 'control_cancel_request' &&
|
||||||
|
'request_id' in msg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content block type guards
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function isTextBlock(block: any): block is TextBlock {
|
||||||
|
return block && typeof block === 'object' && block.type === 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isThinkingBlock(block: any): block is ThinkingBlock {
|
||||||
|
return block && typeof block === 'object' && block.type === 'thinking';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isToolUseBlock(block: any): block is ToolUseBlock {
|
||||||
|
return block && typeof block === 'object' && block.type === 'tool_use';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isToolResultBlock(block: any): block is ToolResultBlock {
|
||||||
|
return block && typeof block === 'object' && block.type === 'tool_result';
|
||||||
|
}
|
||||||
91
packages/sdk/typescript/src/utils/Stream.ts
Normal file
91
packages/sdk/typescript/src/utils/Stream.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Async iterable queue for streaming messages between producer and consumer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class Stream<T> implements AsyncIterable<T> {
|
||||||
|
private returned: (() => void) | undefined;
|
||||||
|
private queue: T[] = [];
|
||||||
|
private readResolve: ((result: IteratorResult<T>) => void) | undefined;
|
||||||
|
private readReject: ((error: Error) => void) | undefined;
|
||||||
|
private isDone = false;
|
||||||
|
hasError: Error | undefined;
|
||||||
|
private started = false;
|
||||||
|
|
||||||
|
constructor(returned?: () => void) {
|
||||||
|
this.returned = returned;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Symbol.asyncIterator](): AsyncIterator<T> {
|
||||||
|
if (this.started) {
|
||||||
|
throw new Error('Stream can only be iterated once');
|
||||||
|
}
|
||||||
|
this.started = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async next(): Promise<IteratorResult<T>> {
|
||||||
|
// Check queue first - if there are queued items, return immediately
|
||||||
|
if (this.queue.length > 0) {
|
||||||
|
return Promise.resolve({
|
||||||
|
done: false,
|
||||||
|
value: this.queue.shift()!,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Check if stream is done
|
||||||
|
if (this.isDone) {
|
||||||
|
return Promise.resolve({ done: true, value: undefined });
|
||||||
|
}
|
||||||
|
// Check for errors that occurred before next() was called
|
||||||
|
// This ensures errors set via error() before iteration starts are properly rejected
|
||||||
|
if (this.hasError) {
|
||||||
|
return Promise.reject(this.hasError);
|
||||||
|
}
|
||||||
|
// No queued items, not done, no error - set up promise for next value/error
|
||||||
|
return new Promise<IteratorResult<T>>((resolve, reject) => {
|
||||||
|
this.readResolve = resolve;
|
||||||
|
this.readReject = reject;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue(value: T): void {
|
||||||
|
if (this.readResolve) {
|
||||||
|
const resolve = this.readResolve;
|
||||||
|
this.readResolve = undefined;
|
||||||
|
this.readReject = undefined;
|
||||||
|
resolve({ done: false, value });
|
||||||
|
} else {
|
||||||
|
this.queue.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
done(): void {
|
||||||
|
this.isDone = true;
|
||||||
|
if (this.readResolve) {
|
||||||
|
const resolve = this.readResolve;
|
||||||
|
this.readResolve = undefined;
|
||||||
|
this.readReject = undefined;
|
||||||
|
resolve({ done: true, value: undefined });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
error(error: Error): void {
|
||||||
|
this.hasError = error;
|
||||||
|
// If readReject exists (next() has been called), reject immediately
|
||||||
|
if (this.readReject) {
|
||||||
|
const reject = this.readReject;
|
||||||
|
this.readResolve = undefined;
|
||||||
|
this.readReject = undefined;
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
// Otherwise, error is stored in hasError and will be rejected when next() is called
|
||||||
|
// This handles the case where error() is called before the first next() call
|
||||||
|
}
|
||||||
|
|
||||||
|
return(): Promise<IteratorResult<T>> {
|
||||||
|
this.isDone = true;
|
||||||
|
if (this.returned) {
|
||||||
|
this.returned();
|
||||||
|
}
|
||||||
|
return Promise.resolve({ done: true, value: undefined });
|
||||||
|
}
|
||||||
|
}
|
||||||
438
packages/sdk/typescript/src/utils/cliPath.ts
Normal file
438
packages/sdk/typescript/src/utils/cliPath.ts
Normal file
@@ -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<string | undefined> = [
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
137
packages/sdk/typescript/src/utils/jsonLines.ts
Normal file
137
packages/sdk/typescript/src/utils/jsonLines.ts
Normal file
@@ -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<string>,
|
||||||
|
context = 'JsonLines',
|
||||||
|
): AsyncGenerator<unknown, void, unknown> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
530
packages/sdk/typescript/test/e2e/abort-and-lifecycle.test.ts
Normal file
530
packages/sdk/typescript/test/e2e/abort-and-lifecycle.test.ts
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
/**
|
||||||
|
* E2E tests based on abort-and-lifecycle.ts example
|
||||||
|
* Tests AbortController integration and process lifecycle management
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
query,
|
||||||
|
AbortError,
|
||||||
|
isAbortError,
|
||||||
|
isCLIAssistantMessage,
|
||||||
|
type TextBlock,
|
||||||
|
type ContentBlock,
|
||||||
|
} from '../../src/index.js';
|
||||||
|
|
||||||
|
// Test configuration
|
||||||
|
const TEST_CLI_PATH =
|
||||||
|
'/Users/mingholy/Work/Projects/qwen-code/packages/cli/index.ts';
|
||||||
|
const TEST_TIMEOUT = 30000;
|
||||||
|
|
||||||
|
// Shared test options with permissionMode to allow all tools
|
||||||
|
const SHARED_TEST_OPTIONS = {
|
||||||
|
pathToQwenExecutable: TEST_CLI_PATH,
|
||||||
|
permissionMode: 'yolo' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('AbortController and Process Lifecycle (E2E)', () => {
|
||||||
|
describe('Basic AbortController Usage', () => {
|
||||||
|
/* TODO: Currently query does not throw AbortError when aborted */
|
||||||
|
it(
|
||||||
|
'should support AbortController cancellation',
|
||||||
|
async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
// Abort after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
controller.abort();
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Write a very long story about TypeScript programming',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
abortController: controller,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isCLIAssistantMessage(message)) {
|
||||||
|
const textBlocks = message.message.content.filter(
|
||||||
|
(block): block is TextBlock => block.type === 'text',
|
||||||
|
);
|
||||||
|
const text = textBlocks
|
||||||
|
.map((b) => b.text)
|
||||||
|
.join('')
|
||||||
|
.slice(0, 100);
|
||||||
|
|
||||||
|
// Should receive some content before abort
|
||||||
|
expect(text.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should not reach here - query should be aborted
|
||||||
|
expect(false).toBe(true);
|
||||||
|
} catch (error) {
|
||||||
|
expect(isAbortError(error)).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should handle abort during query execution',
|
||||||
|
async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Write a detailed explanation about TypeScript',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
abortController: controller,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let receivedFirstMessage = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isCLIAssistantMessage(message)) {
|
||||||
|
if (!receivedFirstMessage) {
|
||||||
|
// Abort immediately after receiving first assistant message
|
||||||
|
receivedFirstMessage = true;
|
||||||
|
controller.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
expect(isAbortError(error)).toBe(true);
|
||||||
|
expect(error instanceof AbortError).toBe(true);
|
||||||
|
// Should have received at least one message before abort
|
||||||
|
expect(receivedFirstMessage).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should handle abort immediately after query starts',
|
||||||
|
async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Write a very long essay',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
abortController: controller,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Abort immediately after query initialization
|
||||||
|
setTimeout(() => {
|
||||||
|
controller.abort();
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const _message of q) {
|
||||||
|
// May or may not receive messages before abort
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
expect(isAbortError(error)).toBe(true);
|
||||||
|
expect(error instanceof AbortError).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Process Lifecycle Monitoring', () => {
|
||||||
|
it(
|
||||||
|
'should handle normal process completion',
|
||||||
|
async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Why do we choose to go to the moon?',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let completedSuccessfully = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isCLIAssistantMessage(message)) {
|
||||||
|
const textBlocks = message.message.content.filter(
|
||||||
|
(block): block is TextBlock => block.type === 'text',
|
||||||
|
);
|
||||||
|
const text = textBlocks
|
||||||
|
.map((b) => b.text)
|
||||||
|
.join('')
|
||||||
|
.slice(0, 100);
|
||||||
|
expect(text.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
completedSuccessfully = true;
|
||||||
|
} catch (error) {
|
||||||
|
// Should not throw for normal completion
|
||||||
|
expect(false).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
expect(completedSuccessfully).toBe(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should handle process cleanup after error',
|
||||||
|
async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Hello world',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isCLIAssistantMessage(message)) {
|
||||||
|
const textBlocks = message.message.content.filter(
|
||||||
|
(block): block is TextBlock => block.type === 'text',
|
||||||
|
);
|
||||||
|
const text = textBlocks
|
||||||
|
.map((b) => b.text)
|
||||||
|
.join('')
|
||||||
|
.slice(0, 50);
|
||||||
|
expect(text.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Expected to potentially have errors
|
||||||
|
} finally {
|
||||||
|
// Should cleanup successfully even after error
|
||||||
|
await q.close();
|
||||||
|
expect(true).toBe(true); // Cleanup completed
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Input Stream Control', () => {
|
||||||
|
it(
|
||||||
|
'should support endInput() method',
|
||||||
|
async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt: 'What is 2 + 2?',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let receivedResponse = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isCLIAssistantMessage(message)) {
|
||||||
|
const textBlocks = message.message.content.filter(
|
||||||
|
(block: ContentBlock): block is TextBlock =>
|
||||||
|
block.type === 'text',
|
||||||
|
);
|
||||||
|
const text = textBlocks
|
||||||
|
.map((b: TextBlock) => b.text)
|
||||||
|
.join('')
|
||||||
|
.slice(0, 100);
|
||||||
|
|
||||||
|
expect(text.length).toBeGreaterThan(0);
|
||||||
|
receivedResponse = true;
|
||||||
|
|
||||||
|
// End input after receiving first response
|
||||||
|
q.endInput();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(receivedResponse).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error Handling and Recovery', () => {
|
||||||
|
it(
|
||||||
|
'should handle invalid executable path',
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Hello world',
|
||||||
|
options: {
|
||||||
|
pathToQwenExecutable: '/nonexistent/path/to/cli',
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should not reach here - query() should throw immediately
|
||||||
|
for await (const _message of q) {
|
||||||
|
// Should not reach here
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should not reach here
|
||||||
|
expect(false).toBe(true);
|
||||||
|
} catch (error) {
|
||||||
|
expect(error instanceof Error).toBe(true);
|
||||||
|
expect((error as Error).message).toBeDefined();
|
||||||
|
expect((error as Error).message).toContain(
|
||||||
|
'Invalid pathToQwenExecutable',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should throw AbortError with correct properties',
|
||||||
|
async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Explain the concept of async programming',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
abortController: controller,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Abort after allowing query to start
|
||||||
|
setTimeout(() => controller.abort(), 1000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const _message of q) {
|
||||||
|
// May receive some messages before abort
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Verify error type and helper functions
|
||||||
|
expect(isAbortError(error)).toBe(true);
|
||||||
|
expect(error instanceof AbortError).toBe(true);
|
||||||
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
expect((error as Error).message).toBeDefined();
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Debugging with stderr callback', () => {
|
||||||
|
it(
|
||||||
|
'should capture stderr messages when debug is enabled',
|
||||||
|
async () => {
|
||||||
|
const stderrMessages: string[] = [];
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Why do we choose to go to the moon?',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
debug: true,
|
||||||
|
stderr: (message: string) => {
|
||||||
|
stderrMessages.push(message);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isCLIAssistantMessage(message)) {
|
||||||
|
const textBlocks = message.message.content.filter(
|
||||||
|
(block): block is TextBlock => block.type === 'text',
|
||||||
|
);
|
||||||
|
const text = textBlocks
|
||||||
|
.map((b) => b.text)
|
||||||
|
.join('')
|
||||||
|
.slice(0, 50);
|
||||||
|
expect(text.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
expect(stderrMessages.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should not capture stderr when debug is disabled',
|
||||||
|
async () => {
|
||||||
|
const stderrMessages: string[] = [];
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Hello',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
debug: false,
|
||||||
|
stderr: (message: string) => {
|
||||||
|
stderrMessages.push(message);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isCLIAssistantMessage(message)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
// Should have minimal or no stderr output when debug is false
|
||||||
|
expect(stderrMessages.length).toBeLessThan(10);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Abort with Cleanup', () => {
|
||||||
|
it(
|
||||||
|
'should cleanup properly after abort',
|
||||||
|
async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Write a very long essay about programming',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
abortController: controller,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Abort immediately
|
||||||
|
setTimeout(() => controller.abort(), 100);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const _message of q) {
|
||||||
|
// May receive some messages before abort
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AbortError) {
|
||||||
|
expect(true).toBe(true); // Expected abort error
|
||||||
|
} else {
|
||||||
|
throw error; // Unexpected error
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
expect(true).toBe(true); // Cleanup completed after abort
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should handle multiple abort calls gracefully',
|
||||||
|
async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Count to 100',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
abortController: controller,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Multiple abort calls
|
||||||
|
setTimeout(() => controller.abort(), 100);
|
||||||
|
setTimeout(() => controller.abort(), 200);
|
||||||
|
setTimeout(() => controller.abort(), 300);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const _message of q) {
|
||||||
|
// Should be interrupted
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
expect(isAbortError(error)).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Resource Management Edge Cases', () => {
|
||||||
|
it(
|
||||||
|
'should handle close() called multiple times',
|
||||||
|
async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Hello',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start the query
|
||||||
|
const iterator = q[Symbol.asyncIterator]();
|
||||||
|
await iterator.next();
|
||||||
|
|
||||||
|
// Close multiple times
|
||||||
|
await q.close();
|
||||||
|
await q.close();
|
||||||
|
await q.close();
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
expect(true).toBe(true);
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should handle abort after close',
|
||||||
|
async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Hello',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
abortController: controller,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start and close immediately
|
||||||
|
const iterator = q[Symbol.asyncIterator]();
|
||||||
|
await iterator.next();
|
||||||
|
await q.close();
|
||||||
|
|
||||||
|
// Abort after close
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
expect(true).toBe(true);
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
515
packages/sdk/typescript/test/e2e/basic-usage.test.ts
Normal file
515
packages/sdk/typescript/test/e2e/basic-usage.test.ts
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
/**
|
||||||
|
* E2E tests based on basic-usage.ts example
|
||||||
|
* Tests message type recognition and basic query patterns
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { query } from '../../src/index.js';
|
||||||
|
import {
|
||||||
|
isCLIUserMessage,
|
||||||
|
isCLIAssistantMessage,
|
||||||
|
isCLISystemMessage,
|
||||||
|
isCLIResultMessage,
|
||||||
|
isCLIPartialAssistantMessage,
|
||||||
|
isControlRequest,
|
||||||
|
isControlResponse,
|
||||||
|
isControlCancel,
|
||||||
|
type TextBlock,
|
||||||
|
type ContentBlock,
|
||||||
|
type CLIMessage,
|
||||||
|
type ControlMessage,
|
||||||
|
type CLISystemMessage,
|
||||||
|
type CLIUserMessage,
|
||||||
|
type CLIAssistantMessage,
|
||||||
|
type ToolUseBlock,
|
||||||
|
type ToolResultBlock,
|
||||||
|
} from '../../src/types/protocol.js';
|
||||||
|
|
||||||
|
// Test configuration
|
||||||
|
const TEST_CLI_PATH =
|
||||||
|
'/Users/mingholy/Work/Projects/qwen-code/packages/cli/index.ts';
|
||||||
|
const TEST_TIMEOUT = 30000;
|
||||||
|
|
||||||
|
// Shared test options with permissionMode to allow all tools
|
||||||
|
const SHARED_TEST_OPTIONS = {
|
||||||
|
pathToQwenExecutable: TEST_CLI_PATH,
|
||||||
|
permissionMode: 'yolo' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the message type using protocol type guards
|
||||||
|
*/
|
||||||
|
function getMessageType(message: CLIMessage | ControlMessage): string {
|
||||||
|
if (isCLIUserMessage(message)) {
|
||||||
|
return '🧑 USER';
|
||||||
|
} else if (isCLIAssistantMessage(message)) {
|
||||||
|
return '🤖 ASSISTANT';
|
||||||
|
} else if (isCLISystemMessage(message)) {
|
||||||
|
return `🖥️ SYSTEM(${message.subtype})`;
|
||||||
|
} else if (isCLIResultMessage(message)) {
|
||||||
|
return `✅ RESULT(${message.subtype})`;
|
||||||
|
} else if (isCLIPartialAssistantMessage(message)) {
|
||||||
|
return '⏳ STREAM_EVENT';
|
||||||
|
} else if (isControlRequest(message)) {
|
||||||
|
return `🎮 CONTROL_REQUEST(${message.request.subtype})`;
|
||||||
|
} else if (isControlResponse(message)) {
|
||||||
|
return `📭 CONTROL_RESPONSE(${message.response.subtype})`;
|
||||||
|
} else if (isControlCancel(message)) {
|
||||||
|
return '🛑 CONTROL_CANCEL';
|
||||||
|
} else {
|
||||||
|
return '❓ UNKNOWN';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Basic Usage (E2E)', () => {
|
||||||
|
describe('Message Type Recognition', () => {
|
||||||
|
it('should correctly identify message types using type guards', async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt:
|
||||||
|
'What files are in the current directory? List only the top-level files and folders.',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
debug: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages: CLIMessage[] = [];
|
||||||
|
const messageTypes: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
messages.push(message);
|
||||||
|
const messageType = getMessageType(message);
|
||||||
|
messageTypes.push(messageType);
|
||||||
|
|
||||||
|
if (isCLIResultMessage(message)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(messages.length).toBeGreaterThan(0);
|
||||||
|
expect(messageTypes.length).toBe(messages.length);
|
||||||
|
|
||||||
|
// Should have at least assistant and result messages
|
||||||
|
expect(messageTypes.some((type) => type.includes('ASSISTANT'))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(messageTypes.some((type) => type.includes('RESULT'))).toBe(true);
|
||||||
|
|
||||||
|
// Verify type guards work correctly
|
||||||
|
const assistantMessages = messages.filter(isCLIAssistantMessage);
|
||||||
|
const resultMessages = messages.filter(isCLIResultMessage);
|
||||||
|
|
||||||
|
expect(assistantMessages.length).toBeGreaterThan(0);
|
||||||
|
expect(resultMessages.length).toBeGreaterThan(0);
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should handle message content extraction',
|
||||||
|
async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Say hello and explain what you are',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
debug: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let assistantMessage: CLIAssistantMessage | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isCLIAssistantMessage(message)) {
|
||||||
|
assistantMessage = message;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(assistantMessage).not.toBeNull();
|
||||||
|
expect(assistantMessage!.message.content).toBeDefined();
|
||||||
|
|
||||||
|
// Extract text blocks
|
||||||
|
const textBlocks = assistantMessage!.message.content.filter(
|
||||||
|
(block: ContentBlock): block is TextBlock => block.type === 'text',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(textBlocks.length).toBeGreaterThan(0);
|
||||||
|
expect(textBlocks[0].text).toBeDefined();
|
||||||
|
expect(textBlocks[0].text.length).toBeGreaterThan(0);
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Basic Query Patterns', () => {
|
||||||
|
it(
|
||||||
|
'should handle simple question-answer pattern',
|
||||||
|
async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt: 'What is 2 + 2?',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
debug: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages: CLIMessage[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
messages.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(messages.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Should have assistant response
|
||||||
|
const assistantMessages = messages.filter(isCLIAssistantMessage);
|
||||||
|
expect(assistantMessages.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Should end with result
|
||||||
|
const lastMessage = messages[messages.length - 1];
|
||||||
|
expect(isCLIResultMessage(lastMessage)).toBe(true);
|
||||||
|
if (isCLIResultMessage(lastMessage)) {
|
||||||
|
expect(lastMessage.subtype).toBe('success');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should handle file system query pattern',
|
||||||
|
async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt:
|
||||||
|
'What files are in the current directory? List only the top-level files and folders.',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
debug: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages: CLIMessage[] = [];
|
||||||
|
let hasToolUse = false;
|
||||||
|
let hasToolResult = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
messages.push(message);
|
||||||
|
|
||||||
|
if (isCLIAssistantMessage(message)) {
|
||||||
|
const toolUseBlock = message.message.content.find(
|
||||||
|
(block: ContentBlock): block is ToolUseBlock =>
|
||||||
|
block.type === 'tool_use',
|
||||||
|
);
|
||||||
|
if (toolUseBlock) {
|
||||||
|
hasToolUse = true;
|
||||||
|
expect(toolUseBlock.name).toBeDefined();
|
||||||
|
expect(toolUseBlock.id).toBeDefined();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCLIUserMessage(message)) {
|
||||||
|
// Tool results are sent as user messages with ToolResultBlock[] content
|
||||||
|
if (Array.isArray(message.message.content)) {
|
||||||
|
const toolResultBlock = message.message.content.find(
|
||||||
|
(block: ToolResultBlock): block is ToolResultBlock =>
|
||||||
|
block.type === 'tool_result',
|
||||||
|
);
|
||||||
|
if (toolResultBlock) {
|
||||||
|
hasToolResult = true;
|
||||||
|
expect(toolResultBlock.tool_use_id).toBeDefined();
|
||||||
|
expect(toolResultBlock.content).toBeDefined();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCLIResultMessage(message)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(messages.length).toBeGreaterThan(0);
|
||||||
|
expect(hasToolUse).toBe(true);
|
||||||
|
expect(hasToolResult).toBe(true);
|
||||||
|
|
||||||
|
// Should have assistant response after tool execution
|
||||||
|
const assistantMessages = messages.filter(isCLIAssistantMessage);
|
||||||
|
expect(assistantMessages.length).toBeGreaterThan(0);
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Configuration and Options', () => {
|
||||||
|
it(
|
||||||
|
'should respect debug option',
|
||||||
|
async () => {
|
||||||
|
const stderrMessages: string[] = [];
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Hello',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
debug: true,
|
||||||
|
stderr: (message: string) => {
|
||||||
|
stderrMessages.push(message);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isCLIAssistantMessage(message)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug mode should produce stderr output
|
||||||
|
expect(stderrMessages.length).toBeGreaterThan(0);
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should respect cwd option',
|
||||||
|
async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt: 'List files in current directory',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let hasResponse = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isCLIAssistantMessage(message)) {
|
||||||
|
hasResponse = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(hasResponse).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SDK-CLI Handshaking Process', () => {
|
||||||
|
it(
|
||||||
|
'should receive system message after initialization',
|
||||||
|
async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Hello',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages: CLIMessage[] = [];
|
||||||
|
let systemMessage: CLISystemMessage | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
messages.push(message);
|
||||||
|
|
||||||
|
// Capture system message
|
||||||
|
if (isCLISystemMessage(message) && message.subtype === 'init') {
|
||||||
|
systemMessage = message;
|
||||||
|
break; // Exit early once we get the system message
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop after getting assistant response to avoid long execution
|
||||||
|
if (isCLIAssistantMessage(message)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify system message was received after initialization
|
||||||
|
expect(systemMessage).not.toBeNull();
|
||||||
|
expect(systemMessage!.type).toBe('system');
|
||||||
|
expect(systemMessage!.subtype).toBe('init');
|
||||||
|
|
||||||
|
// Validate system message structure matches sendSystemMessage()
|
||||||
|
expect(systemMessage!.uuid).toBeDefined();
|
||||||
|
expect(systemMessage!.session_id).toBeDefined();
|
||||||
|
expect(systemMessage!.cwd).toBeDefined();
|
||||||
|
expect(systemMessage!.tools).toBeDefined();
|
||||||
|
expect(Array.isArray(systemMessage!.tools)).toBe(true);
|
||||||
|
expect(systemMessage!.mcp_servers).toBeDefined();
|
||||||
|
expect(Array.isArray(systemMessage!.mcp_servers)).toBe(true);
|
||||||
|
expect(systemMessage!.model).toBeDefined();
|
||||||
|
expect(systemMessage!.permissionMode).toBeDefined();
|
||||||
|
expect(systemMessage!.slash_commands).toBeDefined();
|
||||||
|
expect(Array.isArray(systemMessage!.slash_commands)).toBe(true);
|
||||||
|
// expect(systemMessage!.apiKeySource).toBeDefined();
|
||||||
|
expect(systemMessage!.qwen_code_version).toBeDefined();
|
||||||
|
// expect(systemMessage!.output_style).toBeDefined();
|
||||||
|
expect(systemMessage!.agents).toBeDefined();
|
||||||
|
expect(Array.isArray(systemMessage!.agents)).toBe(true);
|
||||||
|
// expect(systemMessage!.skills).toBeDefined();
|
||||||
|
// expect(Array.isArray(systemMessage!.skills)).toBe(true);
|
||||||
|
|
||||||
|
// Verify system message appears early in the message sequence
|
||||||
|
const systemMessageIndex = messages.findIndex(
|
||||||
|
(msg) => isCLISystemMessage(msg) && msg.subtype === 'init',
|
||||||
|
);
|
||||||
|
expect(systemMessageIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(systemMessageIndex).toBeLessThan(3); // Should be one of the first few messages
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should handle initialization with session ID consistency',
|
||||||
|
async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Hello',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let systemMessage: CLISystemMessage | null = null;
|
||||||
|
let userMessage: CLIUserMessage | null = null;
|
||||||
|
const sessionId = q.getSessionId();
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
// Capture system message
|
||||||
|
if (isCLISystemMessage(message) && message.subtype === 'init') {
|
||||||
|
systemMessage = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture user message
|
||||||
|
if (isCLIUserMessage(message)) {
|
||||||
|
userMessage = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop after getting assistant response to avoid long execution
|
||||||
|
if (isCLIAssistantMessage(message)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify session IDs are consistent within the system
|
||||||
|
expect(sessionId).toBeDefined();
|
||||||
|
expect(systemMessage).not.toBeNull();
|
||||||
|
expect(systemMessage!.session_id).toBeDefined();
|
||||||
|
expect(systemMessage!.uuid).toBeDefined();
|
||||||
|
|
||||||
|
// System message should have consistent session_id and uuid
|
||||||
|
expect(systemMessage!.session_id).toBe(systemMessage!.uuid);
|
||||||
|
|
||||||
|
if (userMessage) {
|
||||||
|
expect(userMessage.session_id).toBeDefined();
|
||||||
|
// User message should have the same session_id as system message
|
||||||
|
expect(userMessage.session_id).toBe(systemMessage!.session_id);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Message Flow Validation', () => {
|
||||||
|
it(
|
||||||
|
'should follow expected message sequence',
|
||||||
|
async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt: 'What is the current time?',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const messageSequence: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
messageSequence.push(message.type);
|
||||||
|
|
||||||
|
if (isCLIResultMessage(message)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(messageSequence.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Should end with result
|
||||||
|
expect(messageSequence[messageSequence.length - 1]).toBe('result');
|
||||||
|
|
||||||
|
// Should have at least one assistant message
|
||||||
|
expect(messageSequence).toContain('assistant');
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should handle graceful completion',
|
||||||
|
async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Say goodbye',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
debug: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let completedNaturally = false;
|
||||||
|
let messageCount = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
messageCount++;
|
||||||
|
|
||||||
|
if (isCLIResultMessage(message)) {
|
||||||
|
completedNaturally = true;
|
||||||
|
expect(message.subtype).toBe('success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(messageCount).toBeGreaterThan(0);
|
||||||
|
expect(completedNaturally).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
517
packages/sdk/typescript/test/e2e/multi-turn.test.ts
Normal file
517
packages/sdk/typescript/test/e2e/multi-turn.test.ts
Normal file
@@ -0,0 +1,517 @@
|
|||||||
|
/**
|
||||||
|
* E2E tests based on multi-turn.ts example
|
||||||
|
* Tests multi-turn conversation functionality with real CLI
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { query } from '../../src/index.js';
|
||||||
|
import {
|
||||||
|
isCLIUserMessage,
|
||||||
|
isCLIAssistantMessage,
|
||||||
|
isCLISystemMessage,
|
||||||
|
isCLIResultMessage,
|
||||||
|
isCLIPartialAssistantMessage,
|
||||||
|
isControlRequest,
|
||||||
|
isControlResponse,
|
||||||
|
isControlCancel,
|
||||||
|
type CLIUserMessage,
|
||||||
|
type CLIAssistantMessage,
|
||||||
|
type TextBlock,
|
||||||
|
type ContentBlock,
|
||||||
|
type CLIMessage,
|
||||||
|
type ControlMessage,
|
||||||
|
type ToolUseBlock,
|
||||||
|
} from '../../src/types/protocol.js';
|
||||||
|
|
||||||
|
// Test configuration
|
||||||
|
const TEST_CLI_PATH =
|
||||||
|
'/Users/mingholy/Work/Projects/qwen-code/packages/cli/index.ts';
|
||||||
|
const TEST_TIMEOUT = 60000; // Longer timeout for multi-turn conversations
|
||||||
|
|
||||||
|
// Shared test options with permissionMode to allow all tools
|
||||||
|
const SHARED_TEST_OPTIONS = {
|
||||||
|
pathToQwenExecutable: TEST_CLI_PATH,
|
||||||
|
permissionMode: 'yolo' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the message type using protocol type guards
|
||||||
|
*/
|
||||||
|
function getMessageType(message: CLIMessage | ControlMessage): string {
|
||||||
|
if (isCLIUserMessage(message)) {
|
||||||
|
return '🧑 USER';
|
||||||
|
} else if (isCLIAssistantMessage(message)) {
|
||||||
|
return '🤖 ASSISTANT';
|
||||||
|
} else if (isCLISystemMessage(message)) {
|
||||||
|
return `🖥️ SYSTEM(${message.subtype})`;
|
||||||
|
} else if (isCLIResultMessage(message)) {
|
||||||
|
return `✅ RESULT(${message.subtype})`;
|
||||||
|
} else if (isCLIPartialAssistantMessage(message)) {
|
||||||
|
return '⏳ STREAM_EVENT';
|
||||||
|
} else if (isControlRequest(message)) {
|
||||||
|
return `🎮 CONTROL_REQUEST(${message.request.subtype})`;
|
||||||
|
} else if (isControlResponse(message)) {
|
||||||
|
return `📭 CONTROL_RESPONSE(${message.response.subtype})`;
|
||||||
|
} else if (isControlCancel(message)) {
|
||||||
|
return '🛑 CONTROL_CANCEL';
|
||||||
|
} else {
|
||||||
|
return '❓ UNKNOWN';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to extract text from ContentBlock array
|
||||||
|
*/
|
||||||
|
function extractText(content: ContentBlock[]): string {
|
||||||
|
return content
|
||||||
|
.filter((block): block is TextBlock => block.type === 'text')
|
||||||
|
.map((block) => block.text)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Multi-Turn Conversations (E2E)', () => {
|
||||||
|
describe('AsyncIterable Prompt Support', () => {
|
||||||
|
it(
|
||||||
|
'should handle multi-turn conversation using AsyncIterable prompt',
|
||||||
|
async () => {
|
||||||
|
// Create multi-turn conversation generator
|
||||||
|
async function* createMultiTurnConversation(): AsyncIterable<CLIUserMessage> {
|
||||||
|
const sessionId = crypto.randomUUID();
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: 'user',
|
||||||
|
session_id: sessionId,
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content:
|
||||||
|
'What is the name of this project? Check the package.json file.',
|
||||||
|
},
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
} as CLIUserMessage;
|
||||||
|
|
||||||
|
// Wait a bit to simulate user thinking time
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: 'user',
|
||||||
|
session_id: sessionId,
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: 'What version is it currently on?',
|
||||||
|
},
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
} as CLIUserMessage;
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: 'user',
|
||||||
|
session_id: sessionId,
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: 'What are the main dependencies?',
|
||||||
|
},
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
} as CLIUserMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create multi-turn query using AsyncIterable prompt
|
||||||
|
const q = query({
|
||||||
|
prompt: createMultiTurnConversation(),
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages: CLIMessage[] = [];
|
||||||
|
const assistantMessages: CLIAssistantMessage[] = [];
|
||||||
|
let turnCount = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
messages.push(message);
|
||||||
|
|
||||||
|
if (isCLIAssistantMessage(message)) {
|
||||||
|
assistantMessages.push(message);
|
||||||
|
turnCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(messages.length).toBeGreaterThan(0);
|
||||||
|
expect(assistantMessages.length).toBeGreaterThanOrEqual(3); // Should have responses to all 3 questions
|
||||||
|
expect(turnCount).toBeGreaterThanOrEqual(3);
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should maintain session context across turns',
|
||||||
|
async () => {
|
||||||
|
async function* createContextualConversation(): AsyncIterable<CLIUserMessage> {
|
||||||
|
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<CLIUserMessage> {
|
||||||
|
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<CLIUserMessage> {
|
||||||
|
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<CLIUserMessage> {
|
||||||
|
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<CLIUserMessage> {
|
||||||
|
// 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<CLIUserMessage> {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
747
packages/sdk/typescript/test/e2e/simple-query.test.ts
Normal file
747
packages/sdk/typescript/test/e2e/simple-query.test.ts
Normal file
@@ -0,0 +1,747 @@
|
|||||||
|
/**
|
||||||
|
* End-to-End tests for simple query execution with real CLI
|
||||||
|
* Tests the complete SDK workflow with actual CLI subprocess
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
query,
|
||||||
|
AbortError,
|
||||||
|
isAbortError,
|
||||||
|
isCLIAssistantMessage,
|
||||||
|
isCLIUserMessage,
|
||||||
|
isCLIResultMessage,
|
||||||
|
type TextBlock,
|
||||||
|
type ToolUseBlock,
|
||||||
|
type ToolResultBlock,
|
||||||
|
type ContentBlock,
|
||||||
|
type CLIMessage,
|
||||||
|
type CLIAssistantMessage,
|
||||||
|
} from '../../src/index.js';
|
||||||
|
|
||||||
|
// Test configuration
|
||||||
|
const TEST_CLI_PATH =
|
||||||
|
'/Users/mingholy/Work/Projects/qwen-code/packages/cli/index.ts';
|
||||||
|
const TEST_TIMEOUT = 30000;
|
||||||
|
|
||||||
|
// Shared test options with permissionMode to allow all tools
|
||||||
|
const SHARED_TEST_OPTIONS = {
|
||||||
|
pathToQwenExecutable: TEST_CLI_PATH,
|
||||||
|
permissionMode: 'yolo' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Simple Query Execution (E2E)', () => {
|
||||||
|
describe('Basic Query Flow', () => {
|
||||||
|
it(
|
||||||
|
'should execute simple text query',
|
||||||
|
async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt: 'What is 2 + 2?',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
debug: true,
|
||||||
|
env: {
|
||||||
|
DEBUG: '1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages: CLIMessage[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
messages.push(message);
|
||||||
|
if (isCLIResultMessage(message)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(messages.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Should have at least one assistant message
|
||||||
|
const assistantMessages = messages.filter(isCLIAssistantMessage);
|
||||||
|
expect(assistantMessages.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Should end with result message
|
||||||
|
const lastMessage = messages[messages.length - 1];
|
||||||
|
expect(isCLIResultMessage(lastMessage)).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should receive assistant response',
|
||||||
|
async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Say hello',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let hasAssistantMessage = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isCLIAssistantMessage(message)) {
|
||||||
|
hasAssistantMessage = true;
|
||||||
|
const textBlocks = message.message.content.filter(
|
||||||
|
(block): block is TextBlock => block.type === 'text',
|
||||||
|
);
|
||||||
|
expect(textBlocks.length).toBeGreaterThan(0);
|
||||||
|
expect(textBlocks[0].text.length).toBeGreaterThan(0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(hasAssistantMessage).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should receive result message at end',
|
||||||
|
async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Simple test',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages: CLIMessage[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
messages.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(messages.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const lastMessage = messages[messages.length - 1];
|
||||||
|
expect(isCLIResultMessage(lastMessage)).toBe(true);
|
||||||
|
if (isCLIResultMessage(lastMessage)) {
|
||||||
|
expect(lastMessage.subtype).toBe('success');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should complete iteration after result',
|
||||||
|
async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Hello, who are you?',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let messageCount = 0;
|
||||||
|
let completedNaturally = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
messageCount++;
|
||||||
|
if (isCLIResultMessage(message)) {
|
||||||
|
// Should be the last message
|
||||||
|
completedNaturally = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(messageCount).toBeGreaterThan(0);
|
||||||
|
expect(completedNaturally).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Query with Tool Usage', () => {
|
||||||
|
it(
|
||||||
|
'should handle query requiring tool execution',
|
||||||
|
async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt:
|
||||||
|
'What files are in the current directory? List only the top-level files and folders.',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages: CLIMessage[] = [];
|
||||||
|
let hasToolUse = false;
|
||||||
|
let hasAssistantResponse = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
messages.push(message);
|
||||||
|
|
||||||
|
if (isCLIAssistantMessage(message)) {
|
||||||
|
hasAssistantResponse = true;
|
||||||
|
const hasToolUseBlock = message.message.content.some(
|
||||||
|
(block: ContentBlock) => block.type === 'tool_use',
|
||||||
|
);
|
||||||
|
if (hasToolUseBlock) {
|
||||||
|
hasToolUse = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCLIResultMessage(message)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(messages.length).toBeGreaterThan(0);
|
||||||
|
expect(hasToolUse).toBe(true);
|
||||||
|
expect(hasAssistantResponse).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should yield tool_use messages',
|
||||||
|
async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt: 'List files in current directory',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let toolUseMessage: ToolUseBlock | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isCLIAssistantMessage(message)) {
|
||||||
|
const toolUseBlock = message.message.content.find(
|
||||||
|
(block: ContentBlock): block is ToolUseBlock =>
|
||||||
|
block.type === 'tool_use',
|
||||||
|
);
|
||||||
|
if (toolUseBlock) {
|
||||||
|
toolUseMessage = toolUseBlock;
|
||||||
|
expect(toolUseBlock.name).toBeDefined();
|
||||||
|
expect(toolUseBlock.id).toBeDefined();
|
||||||
|
expect(toolUseBlock.input).toBeDefined();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(toolUseMessage).not.toBeNull();
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should yield tool_result messages',
|
||||||
|
async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt: 'List files in current directory',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let toolResultMessage: ToolResultBlock | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isCLIUserMessage(message)) {
|
||||||
|
// Tool results are sent as user messages with ToolResultBlock[] content
|
||||||
|
if (Array.isArray(message.message.content)) {
|
||||||
|
const toolResultBlock = message.message.content.find(
|
||||||
|
(block: ContentBlock): block is ToolResultBlock =>
|
||||||
|
block.type === 'tool_result',
|
||||||
|
);
|
||||||
|
if (toolResultBlock) {
|
||||||
|
toolResultMessage = toolResultBlock;
|
||||||
|
expect(toolResultBlock.tool_use_id).toBeDefined();
|
||||||
|
expect(toolResultBlock.content).toBeDefined();
|
||||||
|
// Content should not be a simple string but structured data
|
||||||
|
expect(typeof toolResultBlock.content).not.toBe('undefined');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(toolResultMessage).not.toBeNull();
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should yield final assistant response',
|
||||||
|
async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt: 'List files in current directory and tell me what you found',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const assistantMessages: CLIAssistantMessage[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isCLIAssistantMessage(message)) {
|
||||||
|
assistantMessages.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCLIResultMessage(message)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(assistantMessages.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Final assistant message should contain summary
|
||||||
|
const finalAssistant =
|
||||||
|
assistantMessages[assistantMessages.length - 1];
|
||||||
|
const textBlocks = finalAssistant.message.content.filter(
|
||||||
|
(block: ContentBlock): block is TextBlock => block.type === 'text',
|
||||||
|
);
|
||||||
|
expect(textBlocks.length).toBeGreaterThan(0);
|
||||||
|
expect(textBlocks[0].text.length).toBeGreaterThan(0);
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Configuration Options', () => {
|
||||||
|
it(
|
||||||
|
'should respect cwd option',
|
||||||
|
async () => {
|
||||||
|
const testDir = '/tmp';
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: 'What is the current working directory?',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
cwd: testDir,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let hasResponse = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isCLIAssistantMessage(message)) {
|
||||||
|
hasResponse = true;
|
||||||
|
// Should execute in specified directory
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(hasResponse).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should use explicit CLI path when provided',
|
||||||
|
async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Hello',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let hasResponse = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isCLIAssistantMessage(message)) {
|
||||||
|
hasResponse = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(hasResponse).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Resource Management', () => {
|
||||||
|
it(
|
||||||
|
'should cleanup subprocess on close()',
|
||||||
|
async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Hello',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start and immediately close
|
||||||
|
const iterator = q[Symbol.asyncIterator]();
|
||||||
|
await iterator.next();
|
||||||
|
|
||||||
|
// Should close without error
|
||||||
|
await q.close();
|
||||||
|
expect(true).toBe(true); // Cleanup completed
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
it(
|
||||||
|
'should throw if CLI not found',
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Hello',
|
||||||
|
options: {
|
||||||
|
pathToQwenExecutable: '/nonexistent/path/to/cli',
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should not reach here - query() should throw immediately
|
||||||
|
for await (const _message of q) {
|
||||||
|
// Should not reach here
|
||||||
|
}
|
||||||
|
expect(false).toBe(true); // Should have thrown
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(error instanceof Error).toBe(true);
|
||||||
|
expect((error as Error).message).toContain(
|
||||||
|
'Invalid pathToQwenExecutable',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Timeout and Cancellation', () => {
|
||||||
|
it(
|
||||||
|
'should support AbortSignal cancellation',
|
||||||
|
async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
// Abort after 2 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
controller.abort();
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Write a very long story about TypeScript',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
abortController: controller,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const _message of q) {
|
||||||
|
// Should be interrupted by abort
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should not reach here
|
||||||
|
expect(false).toBe(true);
|
||||||
|
} catch (error) {
|
||||||
|
expect(isAbortError(error)).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should cleanup on cancellation',
|
||||||
|
async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const q = query({
|
||||||
|
prompt: 'Write a very long essay',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
abortController: controller,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Abort immediately
|
||||||
|
setTimeout(() => controller.abort(), 100);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const _message of q) {
|
||||||
|
// Should be interrupted
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
expect(error instanceof AbortError).toBe(true);
|
||||||
|
} finally {
|
||||||
|
// Should cleanup successfully even after abort
|
||||||
|
await q.close();
|
||||||
|
expect(true).toBe(true); // Cleanup completed
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Message Collection Patterns', () => {
|
||||||
|
it(
|
||||||
|
'should collect all messages in array',
|
||||||
|
async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt: 'What is 2 + 2?',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages: CLIMessage[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
messages.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(messages.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Should have various message types
|
||||||
|
const messageTypes = messages.map((m) => m.type);
|
||||||
|
expect(messageTypes).toContain('assistant');
|
||||||
|
expect(messageTypes).toContain('result');
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should extract final answer',
|
||||||
|
async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt: 'What is the capital of France?',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages: CLIMessage[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
messages.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get last assistant message content
|
||||||
|
const assistantMessages = messages.filter(isCLIAssistantMessage);
|
||||||
|
expect(assistantMessages.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const lastAssistant = assistantMessages[assistantMessages.length - 1];
|
||||||
|
const textBlocks = lastAssistant.message.content.filter(
|
||||||
|
(block: ContentBlock): block is TextBlock => block.type === 'text',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(textBlocks.length).toBeGreaterThan(0);
|
||||||
|
expect(textBlocks[0].text).toContain('Paris');
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should track tool usage',
|
||||||
|
async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt: 'List files in current directory',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages: CLIMessage[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
messages.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count tool_use blocks in assistant messages and tool_result blocks in user messages
|
||||||
|
let toolUseCount = 0;
|
||||||
|
let toolResultCount = 0;
|
||||||
|
|
||||||
|
messages.forEach((message) => {
|
||||||
|
if (isCLIAssistantMessage(message)) {
|
||||||
|
message.message.content.forEach((block: ContentBlock) => {
|
||||||
|
if (block.type === 'tool_use') {
|
||||||
|
toolUseCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (isCLIUserMessage(message)) {
|
||||||
|
// Tool results are in user messages
|
||||||
|
if (Array.isArray(message.message.content)) {
|
||||||
|
message.message.content.forEach((block: ContentBlock) => {
|
||||||
|
if (block.type === 'tool_result') {
|
||||||
|
toolResultCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(toolUseCount).toBeGreaterThan(0);
|
||||||
|
expect(toolResultCount).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Each tool_use should have a corresponding tool_result
|
||||||
|
expect(toolResultCount).toBeGreaterThanOrEqual(toolUseCount);
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Real-World Scenarios', () => {
|
||||||
|
it(
|
||||||
|
'should handle code analysis query',
|
||||||
|
async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt:
|
||||||
|
'What is the main export of the package.json file in this directory?',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let hasAnalysis = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isCLIAssistantMessage(message)) {
|
||||||
|
const textBlocks = message.message.content.filter(
|
||||||
|
(block: ContentBlock): block is TextBlock =>
|
||||||
|
block.type === 'text',
|
||||||
|
);
|
||||||
|
if (textBlocks.length > 0 && textBlocks[0].text.length > 0) {
|
||||||
|
hasAnalysis = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(hasAnalysis).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should handle multi-step query',
|
||||||
|
async () => {
|
||||||
|
const q = query({
|
||||||
|
prompt:
|
||||||
|
'List the files in this directory and tell me what type of project this is',
|
||||||
|
options: {
|
||||||
|
...SHARED_TEST_OPTIONS,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
debug: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let hasToolUse = false;
|
||||||
|
let hasAnalysis = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of q) {
|
||||||
|
if (isCLIAssistantMessage(message)) {
|
||||||
|
const hasToolUseBlock = message.message.content.some(
|
||||||
|
(block: ContentBlock) => block.type === 'tool_use',
|
||||||
|
);
|
||||||
|
if (hasToolUseBlock) {
|
||||||
|
hasToolUse = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCLIAssistantMessage(message)) {
|
||||||
|
const textBlocks = message.message.content.filter(
|
||||||
|
(block: ContentBlock): block is TextBlock =>
|
||||||
|
block.type === 'text',
|
||||||
|
);
|
||||||
|
if (textBlocks.length > 0 && textBlocks[0].text.length > 0) {
|
||||||
|
hasAnalysis = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCLIResultMessage(message)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(hasToolUse).toBe(true);
|
||||||
|
expect(hasAnalysis).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await q.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
207
packages/sdk/typescript/test/unit/ProcessTransport.test.ts
Normal file
207
packages/sdk/typescript/test/unit/ProcessTransport.test.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for ProcessTransport
|
||||||
|
* Tests subprocess lifecycle management and IPC
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
// Note: This is a placeholder test file
|
||||||
|
// ProcessTransport will be implemented in Phase 3 Implementation (T021)
|
||||||
|
// These tests are written first following TDD approach
|
||||||
|
|
||||||
|
describe('ProcessTransport', () => {
|
||||||
|
describe('Construction and Initialization', () => {
|
||||||
|
it('should create transport with required options', () => {
|
||||||
|
// Test will be implemented with actual ProcessTransport class
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate pathToQwenExecutable exists', () => {
|
||||||
|
// Should throw if pathToQwenExecutable does not exist
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should build CLI arguments correctly', () => {
|
||||||
|
// Should include --input-format stream-json --output-format stream-json
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Lifecycle Management', () => {
|
||||||
|
it('should spawn subprocess during construction', async () => {
|
||||||
|
// Should call child_process.spawn in constructor
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set isReady to true after successful initialization', async () => {
|
||||||
|
// isReady should be true after construction completes
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw if subprocess fails to spawn', async () => {
|
||||||
|
// Should throw Error if ENOENT or spawn fails
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should close subprocess gracefully with SIGTERM', async () => {
|
||||||
|
// Should send SIGTERM first
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should force kill with SIGKILL after timeout', async () => {
|
||||||
|
// Should send SIGKILL after 5s if process doesn\'t exit
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be idempotent when calling close() multiple times', async () => {
|
||||||
|
// Multiple close() calls should not error
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should wait for process exit in waitForExit()', async () => {
|
||||||
|
// Should resolve when process exits
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Message Reading', () => {
|
||||||
|
it('should read JSON Lines from stdout', async () => {
|
||||||
|
// Should use readline to read lines and parse JSON
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should yield parsed messages via readMessages()', async () => {
|
||||||
|
// Should yield messages as async generator
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip malformed JSON lines with warning', async () => {
|
||||||
|
// Should log warning and continue on parse error
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should complete generator when process exits', async () => {
|
||||||
|
// readMessages() should complete when stdout closes
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set exitError on unexpected process crash', async () => {
|
||||||
|
// exitError should be set if process crashes
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Message Writing', () => {
|
||||||
|
it('should write JSON Lines to stdin', () => {
|
||||||
|
// Should write JSON + newline to stdin
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw if writing before transport is ready', () => {
|
||||||
|
// write() should throw if isReady is false
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw if writing to closed transport', () => {
|
||||||
|
// write() should throw if transport is closed
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
it('should handle process spawn errors', async () => {
|
||||||
|
// Should throw descriptive error on spawn failure
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle process exit with non-zero code', async () => {
|
||||||
|
// Should set exitError when process exits with error
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle write errors to closed stdin', () => {
|
||||||
|
// Should throw if stdin is closed
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Resource Cleanup', () => {
|
||||||
|
it('should register cleanup on parent process exit', () => {
|
||||||
|
// Should register process.on(\'exit\') handler
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should kill subprocess on parent exit', () => {
|
||||||
|
// Cleanup should kill child process
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove event listeners on close', async () => {
|
||||||
|
// Should clean up all event listeners
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CLI Arguments', () => {
|
||||||
|
it('should include --input-format stream-json', () => {
|
||||||
|
// Args should always include input format flag
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include --output-format stream-json', () => {
|
||||||
|
// Args should always include output format flag
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include --model if provided', () => {
|
||||||
|
// Args should include model flag if specified
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include --permission-mode if provided', () => {
|
||||||
|
// Args should include permission mode flag if specified
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include --mcp-server for external MCP servers', () => {
|
||||||
|
// Args should include MCP server configs
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Working Directory', () => {
|
||||||
|
it('should spawn process in specified cwd', async () => {
|
||||||
|
// Should use cwd option for child_process.spawn
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default to process.cwd() if not specified', async () => {
|
||||||
|
// Should use current working directory by default
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Environment Variables', () => {
|
||||||
|
it('should pass environment variables to subprocess', async () => {
|
||||||
|
// Should merge env with process.env
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should inherit parent env by default', async () => {
|
||||||
|
// Should use process.env if no env option
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Debug Mode', () => {
|
||||||
|
it('should inherit stderr when debug is true', async () => {
|
||||||
|
// Should set stderr: \'inherit\' if debug flag set
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore stderr when debug is false', async () => {
|
||||||
|
// Should set stderr: \'ignore\' if debug flag not set
|
||||||
|
expect(true).toBe(true); // Placeholder
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
284
packages/sdk/typescript/test/unit/Query.test.ts
Normal file
284
packages/sdk/typescript/test/unit/Query.test.ts
Normal file
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof vi.fn>;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
255
packages/sdk/typescript/test/unit/Stream.test.ts
Normal file
255
packages/sdk/typescript/test/unit/Stream.test.ts
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for Stream class
|
||||||
|
* Tests producer-consumer patterns and async iteration
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { Stream } from '../../src/utils/Stream.js';
|
||||||
|
|
||||||
|
describe('Stream', () => {
|
||||||
|
let stream: Stream<string>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
stream = new Stream<string>();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Producer-Consumer Patterns', () => {
|
||||||
|
it('should deliver enqueued value immediately to waiting consumer', async () => {
|
||||||
|
// Start consumer (waits for value)
|
||||||
|
const consumerPromise = stream.next();
|
||||||
|
|
||||||
|
// Producer enqueues value
|
||||||
|
stream.enqueue('hello');
|
||||||
|
|
||||||
|
// Consumer should receive value immediately
|
||||||
|
const result = await consumerPromise;
|
||||||
|
expect(result).toEqual({ value: 'hello', done: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should buffer values when consumer is slow', async () => {
|
||||||
|
// Producer enqueues multiple values
|
||||||
|
stream.enqueue('first');
|
||||||
|
stream.enqueue('second');
|
||||||
|
stream.enqueue('third');
|
||||||
|
|
||||||
|
// Consumer reads buffered values
|
||||||
|
expect(await stream.next()).toEqual({ value: 'first', done: false });
|
||||||
|
expect(await stream.next()).toEqual({ value: 'second', done: false });
|
||||||
|
expect(await stream.next()).toEqual({ value: 'third', done: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle fast producer and fast consumer', async () => {
|
||||||
|
const values: string[] = [];
|
||||||
|
|
||||||
|
// Produce and consume simultaneously
|
||||||
|
const consumerPromise = (async () => {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const result = await stream.next();
|
||||||
|
if (!result.done) {
|
||||||
|
values.push(result.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
stream.enqueue('a');
|
||||||
|
stream.enqueue('b');
|
||||||
|
stream.enqueue('c');
|
||||||
|
|
||||||
|
await consumerPromise;
|
||||||
|
expect(values).toEqual(['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle async iteration with for await loop', async () => {
|
||||||
|
const values: string[] = [];
|
||||||
|
|
||||||
|
// Start consumer
|
||||||
|
const consumerPromise = (async () => {
|
||||||
|
for await (const value of stream) {
|
||||||
|
values.push(value);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Producer enqueues and completes
|
||||||
|
stream.enqueue('x');
|
||||||
|
stream.enqueue('y');
|
||||||
|
stream.enqueue('z');
|
||||||
|
stream.done();
|
||||||
|
|
||||||
|
await consumerPromise;
|
||||||
|
expect(values).toEqual(['x', 'y', 'z']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Stream Completion', () => {
|
||||||
|
it('should signal completion when done() is called', async () => {
|
||||||
|
stream.done();
|
||||||
|
const result = await stream.next();
|
||||||
|
expect(result).toEqual({ done: true, value: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should complete waiting consumer immediately', async () => {
|
||||||
|
const consumerPromise = stream.next();
|
||||||
|
stream.done();
|
||||||
|
const result = await consumerPromise;
|
||||||
|
expect(result).toEqual({ done: true, value: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow done() to be called multiple times', async () => {
|
||||||
|
stream.done();
|
||||||
|
stream.done();
|
||||||
|
stream.done();
|
||||||
|
|
||||||
|
const result = await stream.next();
|
||||||
|
expect(result).toEqual({ done: true, value: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow enqueuing to completed stream (no check in reference)', async () => {
|
||||||
|
stream.done();
|
||||||
|
// Reference version doesn't check for done in enqueue
|
||||||
|
stream.enqueue('value');
|
||||||
|
// Verify value was enqueued by reading it
|
||||||
|
expect(await stream.next()).toEqual({ value: 'value', done: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deliver buffered values before completion', async () => {
|
||||||
|
stream.enqueue('first');
|
||||||
|
stream.enqueue('second');
|
||||||
|
stream.done();
|
||||||
|
|
||||||
|
expect(await stream.next()).toEqual({ value: 'first', done: false });
|
||||||
|
expect(await stream.next()).toEqual({ value: 'second', done: false });
|
||||||
|
expect(await stream.next()).toEqual({ done: true, value: undefined });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
it('should propagate error to waiting consumer', async () => {
|
||||||
|
const consumerPromise = stream.next();
|
||||||
|
const error = new Error('Stream error');
|
||||||
|
stream.error(error);
|
||||||
|
|
||||||
|
await expect(consumerPromise).rejects.toThrow('Stream error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error on next read after error is set', async () => {
|
||||||
|
const error = new Error('Test error');
|
||||||
|
stream.error(error);
|
||||||
|
|
||||||
|
await expect(stream.next()).rejects.toThrow('Test error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow enqueuing to stream with error (no check in reference)', async () => {
|
||||||
|
stream.error(new Error('Error'));
|
||||||
|
// Reference version doesn't check for error in enqueue
|
||||||
|
stream.enqueue('value');
|
||||||
|
// Verify value was enqueued by reading it
|
||||||
|
expect(await stream.next()).toEqual({ value: 'value', done: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store last error (reference overwrites)', async () => {
|
||||||
|
const firstError = new Error('First');
|
||||||
|
const secondError = new Error('Second');
|
||||||
|
|
||||||
|
stream.error(firstError);
|
||||||
|
stream.error(secondError);
|
||||||
|
|
||||||
|
await expect(stream.next()).rejects.toThrow('Second');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deliver buffered values before throwing error', async () => {
|
||||||
|
stream.enqueue('buffered');
|
||||||
|
stream.error(new Error('Stream error'));
|
||||||
|
|
||||||
|
expect(await stream.next()).toEqual({ value: 'buffered', done: false });
|
||||||
|
await expect(stream.next()).rejects.toThrow('Stream error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('State Properties', () => {
|
||||||
|
it('should track error state', () => {
|
||||||
|
expect(stream.hasError).toBeUndefined();
|
||||||
|
stream.error(new Error('Test'));
|
||||||
|
expect(stream.hasError).toBeInstanceOf(Error);
|
||||||
|
expect(stream.hasError?.message).toBe('Test');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
it('should handle empty stream', async () => {
|
||||||
|
stream.done();
|
||||||
|
const result = await stream.next();
|
||||||
|
expect(result.done).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single value', async () => {
|
||||||
|
stream.enqueue('only');
|
||||||
|
stream.done();
|
||||||
|
|
||||||
|
expect(await stream.next()).toEqual({ value: 'only', done: false });
|
||||||
|
expect(await stream.next()).toEqual({ done: true, value: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle rapid enqueue-dequeue cycles', async () => {
|
||||||
|
const numberStream = new Stream<number>();
|
||||||
|
const iterations = 100;
|
||||||
|
const values: number[] = [];
|
||||||
|
|
||||||
|
const producer = async (): Promise<void> => {
|
||||||
|
for (let i = 0; i < iterations; i++) {
|
||||||
|
numberStream.enqueue(i);
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
}
|
||||||
|
numberStream.done();
|
||||||
|
};
|
||||||
|
|
||||||
|
const consumer = async (): Promise<void> => {
|
||||||
|
for await (const value of numberStream) {
|
||||||
|
values.push(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all([producer(), consumer()]);
|
||||||
|
expect(values).toHaveLength(iterations);
|
||||||
|
expect(values[0]).toBe(0);
|
||||||
|
expect(values[iterations - 1]).toBe(iterations - 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TypeScript Types', () => {
|
||||||
|
it('should handle different value types', async () => {
|
||||||
|
const numberStream = new Stream<number>();
|
||||||
|
numberStream.enqueue(42);
|
||||||
|
numberStream.done();
|
||||||
|
|
||||||
|
const result = await numberStream.next();
|
||||||
|
expect(result.value).toBe(42);
|
||||||
|
|
||||||
|
const objectStream = new Stream<{ id: number; name: string }>();
|
||||||
|
objectStream.enqueue({ id: 1, name: 'test' });
|
||||||
|
objectStream.done();
|
||||||
|
|
||||||
|
const objectResult = await objectStream.next();
|
||||||
|
expect(objectResult.value).toEqual({ id: 1, name: 'test' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Iteration Restrictions', () => {
|
||||||
|
it('should only allow iteration once', async () => {
|
||||||
|
const stream = new Stream<string>();
|
||||||
|
stream.enqueue('test');
|
||||||
|
stream.done();
|
||||||
|
|
||||||
|
// First iteration should work
|
||||||
|
const iterator1 = stream[Symbol.asyncIterator]();
|
||||||
|
expect(await iterator1.next()).toEqual({
|
||||||
|
value: 'test',
|
||||||
|
done: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Second iteration should throw
|
||||||
|
expect(() => stream[Symbol.asyncIterator]()).toThrow(
|
||||||
|
'Stream can only be iterated once',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
668
packages/sdk/typescript/test/unit/cliPath.test.ts
Normal file
668
packages/sdk/typescript/test/unit/cliPath.test.ts
Normal file
@@ -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<typeof import('fs').statSync>);
|
||||||
|
});
|
||||||
|
|
||||||
|
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<typeof import('fs').statSync>);
|
||||||
|
|
||||||
|
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<typeof import('fs').statSync>);
|
||||||
|
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
350
packages/sdk/typescript/test/unit/createSdkMcpServer.test.ts
Normal file
350
packages/sdk/typescript/test/unit/createSdkMcpServer.test.ts
Normal file
@@ -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<ToolOutput> => {
|
||||||
|
return {
|
||||||
|
greeting: `Hello ${input.name}, age ${input.age}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const typedTool = tool<ToolInput, ToolOutput>({
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
41
packages/sdk/typescript/tsconfig.json
Normal file
41
packages/sdk/typescript/tsconfig.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
36
packages/sdk/typescript/vitest.config.ts
Normal file
36
packages/sdk/typescript/vitest.config.ts
Normal file
@@ -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'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ export default defineConfig({
|
|||||||
'packages/cli',
|
'packages/cli',
|
||||||
'packages/core',
|
'packages/core',
|
||||||
'packages/vscode-ide-companion',
|
'packages/vscode-ide-companion',
|
||||||
|
'packages/sdk/typescript',
|
||||||
'integration-tests',
|
'integration-tests',
|
||||||
'scripts',
|
'scripts',
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user