mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 01:23:53 +00:00
feat: create draft framework for cli & sdk
This commit is contained in:
1
.vscode/launch.json
vendored
1
.vscode/launch.json
vendored
@@ -79,7 +79,6 @@
|
||||
"--",
|
||||
"-p",
|
||||
"${input:prompt}",
|
||||
"-y",
|
||||
"--output-format",
|
||||
"stream-json"
|
||||
],
|
||||
|
||||
@@ -150,7 +150,7 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['packages/*/src/**/*.test.{ts,tsx}'],
|
||||
files: ['packages/*/src/**/*.test.{ts,tsx}', 'packages/**/test/**/*.test.{ts,tsx}'],
|
||||
plugins: {
|
||||
vitest,
|
||||
},
|
||||
@@ -158,6 +158,14 @@ export default tseslint.config(
|
||||
...vitest.configs.recommended.rules,
|
||||
'vitest/expect-expect': 'off',
|
||||
'vitest/no-commented-out-tests': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// extra settings for scripts that we run directly with node
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import {
|
||||
ApprovalMode,
|
||||
AuthType,
|
||||
Config,
|
||||
DEFAULT_QWEN_EMBEDDING_MODEL,
|
||||
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
@@ -133,6 +134,10 @@ export interface CliArgs {
|
||||
continue: boolean | undefined;
|
||||
/** Resume a specific session by its ID */
|
||||
resume: string | undefined;
|
||||
maxSessionTurns: number | undefined;
|
||||
coreTools: string[] | undefined;
|
||||
excludeTools: string[] | undefined;
|
||||
authType: string | undefined;
|
||||
}
|
||||
|
||||
function normalizeOutputFormat(
|
||||
@@ -411,6 +416,31 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
description:
|
||||
'Resume a specific session by its ID. Use without an ID to show session picker.',
|
||||
})
|
||||
.option('max-session-turns', {
|
||||
type: 'number',
|
||||
description: 'Maximum number of session turns',
|
||||
})
|
||||
.option('core-tools', {
|
||||
type: 'array',
|
||||
string: true,
|
||||
description: 'Core tool paths',
|
||||
coerce: (tools: string[]) =>
|
||||
// Handle comma-separated values
|
||||
tools.flatMap((tool) => tool.split(',').map((t) => t.trim())),
|
||||
})
|
||||
.option('exclude-tools', {
|
||||
type: 'array',
|
||||
string: true,
|
||||
description: 'Tools to exclude',
|
||||
coerce: (tools: string[]) =>
|
||||
// Handle comma-separated values
|
||||
tools.flatMap((tool) => tool.split(',').map((t) => t.trim())),
|
||||
})
|
||||
.option('auth-type', {
|
||||
type: 'string',
|
||||
choices: [AuthType.USE_OPENAI, AuthType.QWEN_OAUTH],
|
||||
description: 'Authentication type',
|
||||
})
|
||||
.deprecateOption(
|
||||
'show-memory-usage',
|
||||
'Use the "ui.showMemoryUsage" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
@@ -745,8 +775,14 @@ export async function loadCliConfig(
|
||||
interactive = false;
|
||||
}
|
||||
// In non-interactive mode, exclude tools that require a prompt.
|
||||
// However, if stream-json input is used, control can be requested via JSON messages,
|
||||
// so tools should not be excluded in that case.
|
||||
const extraExcludes: string[] = [];
|
||||
if (!interactive && !argv.experimentalAcp) {
|
||||
if (
|
||||
!interactive &&
|
||||
!argv.experimentalAcp &&
|
||||
inputFormat !== InputFormat.STREAM_JSON
|
||||
) {
|
||||
switch (approvalMode) {
|
||||
case ApprovalMode.PLAN:
|
||||
case ApprovalMode.DEFAULT:
|
||||
@@ -770,6 +806,7 @@ export async function loadCliConfig(
|
||||
settings,
|
||||
activeExtensions,
|
||||
extraExcludes.length > 0 ? extraExcludes : undefined,
|
||||
argv.excludeTools,
|
||||
);
|
||||
const blockedMcpServers: Array<{ name: string; extensionName: string }> = [];
|
||||
|
||||
@@ -850,7 +887,7 @@ export async function loadCliConfig(
|
||||
debugMode,
|
||||
question,
|
||||
fullContext: argv.allFiles || false,
|
||||
coreTools: settings.tools?.core || undefined,
|
||||
coreTools: argv.coreTools || settings.tools?.core || undefined,
|
||||
allowedTools: argv.allowedTools || settings.tools?.allowed || undefined,
|
||||
excludeTools,
|
||||
toolDiscoveryCommand: settings.tools?.discoveryCommand,
|
||||
@@ -883,13 +920,16 @@ export async function loadCliConfig(
|
||||
model: resolvedModel,
|
||||
extensionContextFilePaths,
|
||||
sessionTokenLimit: settings.model?.sessionTokenLimit ?? -1,
|
||||
maxSessionTurns: settings.model?.maxSessionTurns ?? -1,
|
||||
maxSessionTurns:
|
||||
argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1,
|
||||
experimentalZedIntegration: argv.experimentalAcp || false,
|
||||
listExtensions: argv.listExtensions || false,
|
||||
extensions: allExtensions,
|
||||
blockedMcpServers,
|
||||
noBrowser: !!process.env['NO_BROWSER'],
|
||||
authType: settings.security?.auth?.selectedType,
|
||||
authType:
|
||||
(argv.authType as AuthType | undefined) ||
|
||||
settings.security?.auth?.selectedType,
|
||||
inputFormat,
|
||||
outputFormat,
|
||||
includePartialMessages,
|
||||
@@ -997,8 +1037,10 @@ function mergeExcludeTools(
|
||||
settings: Settings,
|
||||
extensions: Extension[],
|
||||
extraExcludes?: string[] | undefined,
|
||||
cliExcludeTools?: string[] | undefined,
|
||||
): string[] {
|
||||
const allExcludeTools = new Set([
|
||||
...(cliExcludeTools || []),
|
||||
...(settings.tools?.exclude || []),
|
||||
...(extraExcludes || []),
|
||||
]);
|
||||
|
||||
@@ -272,7 +272,7 @@ describe('gemini.tsx main function', () => {
|
||||
);
|
||||
|
||||
vi.mocked(cleanupModule.cleanupCheckpoints).mockResolvedValue(undefined);
|
||||
vi.mocked(cleanupModule.registerCleanup).mockImplementation(() => {});
|
||||
vi.mocked(cleanupModule.registerCleanup).mockImplementation(() => { });
|
||||
const runExitCleanupMock = vi.mocked(cleanupModule.runExitCleanup);
|
||||
runExitCleanupMock.mockResolvedValue(undefined);
|
||||
vi.spyOn(extensionModule, 'loadExtensions').mockReturnValue([]);
|
||||
@@ -481,6 +481,10 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
includePartialMessages: undefined,
|
||||
continue: undefined,
|
||||
resume: undefined,
|
||||
coreTools: undefined,
|
||||
excludeTools: undefined,
|
||||
authType: undefined,
|
||||
maxSessionTurns: undefined,
|
||||
});
|
||||
|
||||
await main();
|
||||
@@ -494,7 +498,7 @@ describe('validateDnsResolutionOrder', () => {
|
||||
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -383,7 +383,18 @@ export async function main() {
|
||||
|
||||
setMaxSizedBoxDebugging(isDebugMode);
|
||||
|
||||
const initializationResult = await initializeApp(config, settings);
|
||||
// Check input format early to determine initialization flow
|
||||
const inputFormat =
|
||||
typeof config.getInputFormat === 'function'
|
||||
? config.getInputFormat()
|
||||
: InputFormat.TEXT;
|
||||
|
||||
// For stream-json mode, defer config.initialize() until after the initialize control request
|
||||
// For other modes, initialize normally
|
||||
let initializationResult: InitializationResult | undefined;
|
||||
if (inputFormat !== InputFormat.STREAM_JSON) {
|
||||
initializationResult = await initializeApp(config, settings);
|
||||
}
|
||||
|
||||
if (
|
||||
settings.merged.security?.auth?.selectedType ===
|
||||
@@ -417,19 +428,15 @@ export async function main() {
|
||||
settings,
|
||||
startupWarnings,
|
||||
process.cwd(),
|
||||
initializationResult,
|
||||
initializationResult!,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await config.initialize();
|
||||
|
||||
// Check input format BEFORE reading stdin
|
||||
// In STREAM_JSON mode, stdin should be left for StreamJsonInputReader
|
||||
const inputFormat =
|
||||
typeof config.getInputFormat === 'function'
|
||||
? config.getInputFormat()
|
||||
: InputFormat.TEXT;
|
||||
// For non-stream-json mode, initialize config here
|
||||
if (inputFormat !== InputFormat.STREAM_JSON) {
|
||||
await config.initialize();
|
||||
}
|
||||
|
||||
// Only read stdin if NOT in stream-json mode
|
||||
// In stream-json mode, stdin is used for protocol messages (control requests, etc.)
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
import type { IControlContext } from './ControlContext.js';
|
||||
import type { IPendingRequestRegistry } from './controllers/baseController.js';
|
||||
import { SystemController } from './controllers/systemController.js';
|
||||
// import { PermissionController } from './controllers/permissionController.js';
|
||||
import { PermissionController } from './controllers/permissionController.js';
|
||||
// import { MCPController } from './controllers/mcpController.js';
|
||||
// import { HookController } from './controllers/hookController.js';
|
||||
import type {
|
||||
@@ -64,7 +64,7 @@ export class ControlDispatcher implements IPendingRequestRegistry {
|
||||
|
||||
// Make controllers publicly accessible
|
||||
readonly systemController: SystemController;
|
||||
// readonly permissionController: PermissionController;
|
||||
readonly permissionController: PermissionController;
|
||||
// readonly mcpController: MCPController;
|
||||
// readonly hookController: HookController;
|
||||
|
||||
@@ -83,11 +83,11 @@ export class ControlDispatcher implements IPendingRequestRegistry {
|
||||
this,
|
||||
'SystemController',
|
||||
);
|
||||
// this.permissionController = new PermissionController(
|
||||
// context,
|
||||
// this,
|
||||
// 'PermissionController',
|
||||
// );
|
||||
this.permissionController = new PermissionController(
|
||||
context,
|
||||
this,
|
||||
'PermissionController',
|
||||
);
|
||||
// this.mcpController = new MCPController(context, this, 'MCPController');
|
||||
// this.hookController = new HookController(context, this, 'HookController');
|
||||
|
||||
@@ -230,7 +230,7 @@ export class ControlDispatcher implements IPendingRequestRegistry {
|
||||
|
||||
// Cleanup controllers (MCP controller will close all clients)
|
||||
this.systemController.cleanup();
|
||||
// this.permissionController.cleanup();
|
||||
this.permissionController.cleanup();
|
||||
// this.mcpController.cleanup();
|
||||
// this.hookController.cleanup();
|
||||
}
|
||||
@@ -302,9 +302,9 @@ export class ControlDispatcher implements IPendingRequestRegistry {
|
||||
case 'supported_commands':
|
||||
return this.systemController;
|
||||
|
||||
// case 'can_use_tool':
|
||||
// case 'set_permission_mode':
|
||||
// return this.permissionController;
|
||||
case 'can_use_tool':
|
||||
case 'set_permission_mode':
|
||||
return this.permissionController;
|
||||
|
||||
// case 'mcp_message':
|
||||
// case 'mcp_server_status':
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
import type { IControlContext } from './ControlContext.js';
|
||||
import type { ControlDispatcher } from './ControlDispatcher.js';
|
||||
import type {
|
||||
// PermissionServiceAPI,
|
||||
PermissionServiceAPI,
|
||||
SystemServiceAPI,
|
||||
// McpServiceAPI,
|
||||
// HookServiceAPI,
|
||||
@@ -61,43 +61,31 @@ export class ControlService {
|
||||
* Handles tool execution permissions, approval checks, and callbacks.
|
||||
* Delegates to the shared PermissionController instance.
|
||||
*/
|
||||
// get permission(): PermissionServiceAPI {
|
||||
// const controller = this.dispatcher.permissionController;
|
||||
// return {
|
||||
// /**
|
||||
// * Check if a tool should be allowed based on current permission settings
|
||||
// *
|
||||
// * Evaluates permission mode and tool registry to determine if execution
|
||||
// * should proceed. Can optionally modify tool arguments based on confirmation details.
|
||||
// *
|
||||
// * @param toolRequest - Tool call request information
|
||||
// * @param confirmationDetails - Optional confirmation details for UI
|
||||
// * @returns Permission decision with optional updated arguments
|
||||
// */
|
||||
// shouldAllowTool: controller.shouldAllowTool.bind(controller),
|
||||
//
|
||||
// /**
|
||||
// * Build UI suggestions for tool confirmation dialogs
|
||||
// *
|
||||
// * Creates actionable permission suggestions based on tool confirmation details.
|
||||
// *
|
||||
// * @param confirmationDetails - Tool confirmation details
|
||||
// * @returns Array of permission suggestions or null
|
||||
// */
|
||||
// buildPermissionSuggestions:
|
||||
// controller.buildPermissionSuggestions.bind(controller),
|
||||
//
|
||||
// /**
|
||||
// * Get callback for monitoring tool call status updates
|
||||
// *
|
||||
// * Returns callback function for integration with CoreToolScheduler.
|
||||
// *
|
||||
// * @returns Callback function for tool call updates
|
||||
// */
|
||||
// getToolCallUpdateCallback:
|
||||
// controller.getToolCallUpdateCallback.bind(controller),
|
||||
// };
|
||||
// }
|
||||
get permission(): PermissionServiceAPI {
|
||||
const controller = this.dispatcher.permissionController;
|
||||
return {
|
||||
/**
|
||||
* Build UI suggestions for tool confirmation dialogs
|
||||
*
|
||||
* Creates actionable permission suggestions based on tool confirmation details.
|
||||
*
|
||||
* @param confirmationDetails - Tool confirmation details
|
||||
* @returns Array of permission suggestions or null
|
||||
*/
|
||||
buildPermissionSuggestions:
|
||||
controller.buildPermissionSuggestions.bind(controller),
|
||||
|
||||
/**
|
||||
* Get callback for monitoring tool call status updates
|
||||
*
|
||||
* Returns callback function for integration with CoreToolScheduler.
|
||||
*
|
||||
* @returns Callback function for tool call updates
|
||||
*/
|
||||
getToolCallUpdateCallback:
|
||||
controller.getToolCallUpdateCallback.bind(controller),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* System Domain API
|
||||
|
||||
@@ -174,7 +174,5 @@ export abstract class BaseController {
|
||||
/**
|
||||
* Cleanup resources
|
||||
*/
|
||||
cleanup(): void {
|
||||
// Subclasses can override to add cleanup logic
|
||||
}
|
||||
cleanup(): void {}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,10 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
ToolCallRequestInfo,
|
||||
WaitingToolCall,
|
||||
ToolExecuteConfirmationDetails,
|
||||
ToolMcpConfirmationDetails,
|
||||
ApprovalMode,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
InputFormat,
|
||||
@@ -206,6 +208,7 @@ export class PermissionController extends BaseController {
|
||||
}
|
||||
|
||||
this.context.permissionMode = mode;
|
||||
this.context.config.setApprovalMode(mode as ApprovalMode);
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
@@ -334,47 +337,6 @@ export class PermissionController extends BaseController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool should be executed based on current permission settings
|
||||
*
|
||||
* This is a convenience method for direct tool execution checks without
|
||||
* going through the control request flow.
|
||||
*/
|
||||
async shouldAllowTool(
|
||||
toolRequest: ToolCallRequestInfo,
|
||||
confirmationDetails?: unknown,
|
||||
): Promise<{
|
||||
allowed: boolean;
|
||||
message?: string;
|
||||
updatedArgs?: Record<string, unknown>;
|
||||
}> {
|
||||
// Check permission mode
|
||||
const modeResult = this.checkPermissionMode();
|
||||
if (!modeResult.allowed) {
|
||||
return {
|
||||
allowed: false,
|
||||
message: modeResult.message,
|
||||
};
|
||||
}
|
||||
|
||||
// Check tool registry
|
||||
const registryResult = this.checkToolRegistry(toolRequest.name);
|
||||
if (!registryResult.allowed) {
|
||||
return {
|
||||
allowed: false,
|
||||
message: registryResult.message,
|
||||
};
|
||||
}
|
||||
|
||||
// If we have confirmation details, we could potentially modify args
|
||||
// This is a hook for future enhancement
|
||||
if (confirmationDetails) {
|
||||
// Future: handle argument modifications based on confirmation details
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get callback for monitoring tool calls and handling outgoing permission requests
|
||||
* This is passed to executeToolCall to hook into CoreToolScheduler updates
|
||||
@@ -430,17 +392,14 @@ export class PermissionController extends BaseController {
|
||||
toolCall.confirmationDetails,
|
||||
);
|
||||
|
||||
const response = await this.sendControlRequest(
|
||||
{
|
||||
subtype: 'can_use_tool',
|
||||
tool_name: toolCall.request.name,
|
||||
tool_use_id: toolCall.request.callId,
|
||||
input: toolCall.request.args,
|
||||
permission_suggestions: permissionSuggestions,
|
||||
blocked_path: null,
|
||||
} as CLIControlPermissionRequest,
|
||||
30000,
|
||||
);
|
||||
const response = await this.sendControlRequest({
|
||||
subtype: 'can_use_tool',
|
||||
tool_name: toolCall.request.name,
|
||||
tool_use_id: toolCall.request.callId,
|
||||
input: toolCall.request.args,
|
||||
permission_suggestions: permissionSuggestions,
|
||||
blocked_path: null,
|
||||
} as CLIControlPermissionRequest);
|
||||
|
||||
if (response.subtype !== 'success') {
|
||||
await toolCall.confirmationDetails.onConfirm(
|
||||
@@ -462,8 +421,15 @@ export class PermissionController extends BaseController {
|
||||
ToolConfirmationOutcome.ProceedOnce,
|
||||
);
|
||||
} else {
|
||||
// Extract cancel message from response if available
|
||||
const cancelMessage =
|
||||
typeof payload['message'] === 'string'
|
||||
? payload['message']
|
||||
: undefined;
|
||||
|
||||
await toolCall.confirmationDetails.onConfirm(
|
||||
ToolConfirmationOutcome.Cancel,
|
||||
cancelMessage ? { cancelMessage } : undefined,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -473,9 +439,23 @@ export class PermissionController extends BaseController {
|
||||
error,
|
||||
);
|
||||
}
|
||||
await toolCall.confirmationDetails.onConfirm(
|
||||
ToolConfirmationOutcome.Cancel,
|
||||
);
|
||||
// On error, use default cancel message
|
||||
// Only pass payload for exec and mcp types that support it
|
||||
const confirmationType = toolCall.confirmationDetails.type;
|
||||
if (confirmationType === 'exec' || confirmationType === 'mcp') {
|
||||
const execOrMcpDetails = toolCall.confirmationDetails as
|
||||
| ToolExecuteConfirmationDetails
|
||||
| ToolMcpConfirmationDetails;
|
||||
await execOrMcpDetails.onConfirm(
|
||||
ToolConfirmationOutcome.Cancel,
|
||||
undefined,
|
||||
);
|
||||
} else {
|
||||
// For other types, don't pass payload (backward compatible)
|
||||
await toolCall.confirmationDetails.onConfirm(
|
||||
ToolConfirmationOutcome.Cancel,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
this.pendingOutgoingRequests.delete(toolCall.request.callId);
|
||||
}
|
||||
|
||||
@@ -55,12 +55,68 @@ export class SystemController extends BaseController {
|
||||
payload: CLIControlInitializeRequest,
|
||||
): Promise<Record<string, unknown>> {
|
||||
// Register SDK MCP servers if provided
|
||||
if (payload.sdkMcpServers && Array.isArray(payload.sdkMcpServers)) {
|
||||
for (const serverName of payload.sdkMcpServers) {
|
||||
if (payload.sdkMcpServers && typeof payload.sdkMcpServers === 'object') {
|
||||
for (const serverName of Object.keys(payload.sdkMcpServers)) {
|
||||
this.context.sdkMcpServers.add(serverName);
|
||||
}
|
||||
|
||||
// Add SDK MCP servers to config
|
||||
try {
|
||||
this.context.config.addMcpServers(payload.sdkMcpServers);
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[SystemController] Added ${Object.keys(payload.sdkMcpServers).length} SDK MCP servers to config`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
'[SystemController] Failed to add SDK MCP servers:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add MCP servers to config if provided
|
||||
if (payload.mcpServers && typeof payload.mcpServers === 'object') {
|
||||
try {
|
||||
this.context.config.addMcpServers(payload.mcpServers);
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[SystemController] Added ${Object.keys(payload.mcpServers).length} MCP servers to config`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.context.debugMode) {
|
||||
console.error('[SystemController] Failed to add MCP servers:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add session subagents to config if provided
|
||||
if (payload.agents && Array.isArray(payload.agents)) {
|
||||
try {
|
||||
this.context.config.addSessionSubagents(payload.agents);
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[SystemController] Added ${payload.agents.length} session subagents to config`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
'[SystemController] Failed to add session subagents:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set SDK mode to true after handling initialize
|
||||
this.context.config.setSdkMode(true);
|
||||
|
||||
// Build capabilities for response
|
||||
const capabilities = this.buildControlCapabilities();
|
||||
|
||||
@@ -86,7 +142,7 @@ export class SystemController extends BaseController {
|
||||
buildControlCapabilities(): Record<string, unknown> {
|
||||
const capabilities: Record<string, unknown> = {
|
||||
can_handle_can_use_tool: true,
|
||||
can_handle_hook_callback: true,
|
||||
can_handle_hook_callback: false,
|
||||
can_set_permission_mode:
|
||||
typeof this.context.config.setApprovalMode === 'function',
|
||||
can_set_model: typeof this.context.config.setModel === 'function',
|
||||
|
||||
@@ -13,10 +13,7 @@
|
||||
*/
|
||||
|
||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import type {
|
||||
ToolCallRequestInfo,
|
||||
MCPServerConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { MCPServerConfig } from '@qwen-code/qwen-code-core';
|
||||
import type { PermissionSuggestion } from '../../types.js';
|
||||
|
||||
/**
|
||||
@@ -26,25 +23,6 @@ import type { PermissionSuggestion } from '../../types.js';
|
||||
* permission suggestions, and tool call monitoring callbacks.
|
||||
*/
|
||||
export interface PermissionServiceAPI {
|
||||
/**
|
||||
* Check if a tool should be allowed based on current permission settings
|
||||
*
|
||||
* Evaluates permission mode and tool registry to determine if execution
|
||||
* should proceed. Can optionally modify tool arguments based on confirmation details.
|
||||
*
|
||||
* @param toolRequest - Tool call request information containing name, args, and call ID
|
||||
* @param confirmationDetails - Optional confirmation details for UI-driven approvals
|
||||
* @returns Promise resolving to permission decision with optional updated arguments
|
||||
*/
|
||||
shouldAllowTool(
|
||||
toolRequest: ToolCallRequestInfo,
|
||||
confirmationDetails?: unknown,
|
||||
): Promise<{
|
||||
allowed: boolean;
|
||||
message?: string;
|
||||
updatedArgs?: Record<string, unknown>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Build UI suggestions for tool confirmation dialogs
|
||||
*
|
||||
|
||||
@@ -939,9 +939,25 @@ export abstract class BaseJsonOutputAdapter {
|
||||
this.emitMessageImpl(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if responseParts contain any functionResponse with an error.
|
||||
* This handles cancelled responses and other error cases where the error
|
||||
* is embedded in responseParts rather than the top-level error field.
|
||||
* @param responseParts - Array of Part objects
|
||||
* @returns Error message if found, undefined otherwise
|
||||
*/
|
||||
private checkResponsePartsForError(
|
||||
responseParts: Part[] | undefined,
|
||||
): string | undefined {
|
||||
// Use the shared helper function defined at file level
|
||||
return checkResponsePartsForError(responseParts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits a tool result message.
|
||||
* Collects execution denied tool calls for inclusion in result messages.
|
||||
* Handles both explicit errors (response.error) and errors embedded in
|
||||
* responseParts (e.g., cancelled responses).
|
||||
* @param request - Tool call request info
|
||||
* @param response - Tool call response info
|
||||
* @param parentToolUseId - Parent tool use ID (null for main agent)
|
||||
@@ -951,6 +967,14 @@ export abstract class BaseJsonOutputAdapter {
|
||||
response: ToolCallResponseInfo,
|
||||
parentToolUseId: string | null = null,
|
||||
): void {
|
||||
// Check for errors in responseParts (e.g., cancelled responses)
|
||||
const responsePartsError = this.checkResponsePartsForError(
|
||||
response.responseParts,
|
||||
);
|
||||
|
||||
// Determine if this is an error response
|
||||
const hasError = Boolean(response.error) || Boolean(responsePartsError);
|
||||
|
||||
// Track permission denials (execution denied errors)
|
||||
if (
|
||||
response.error &&
|
||||
@@ -967,7 +991,7 @@ export abstract class BaseJsonOutputAdapter {
|
||||
const block: ToolResultBlock = {
|
||||
type: 'tool_result',
|
||||
tool_use_id: request.callId,
|
||||
is_error: Boolean(response.error),
|
||||
is_error: hasError,
|
||||
};
|
||||
const content = toolResultContent(response);
|
||||
if (content !== undefined) {
|
||||
@@ -1173,11 +1197,41 @@ export function partsToString(parts: Part[]): string {
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if responseParts contain any functionResponse with an error.
|
||||
* Helper function for extracting error messages from responseParts.
|
||||
* @param responseParts - Array of Part objects
|
||||
* @returns Error message if found, undefined otherwise
|
||||
*/
|
||||
function checkResponsePartsForError(
|
||||
responseParts: Part[] | undefined,
|
||||
): string | undefined {
|
||||
if (!responseParts || responseParts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const part of responseParts) {
|
||||
if (
|
||||
'functionResponse' in part &&
|
||||
part.functionResponse?.response &&
|
||||
typeof part.functionResponse.response === 'object' &&
|
||||
'error' in part.functionResponse.response &&
|
||||
part.functionResponse.response['error']
|
||||
) {
|
||||
const error = part.functionResponse.response['error'];
|
||||
return typeof error === 'string' ? error : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts content from tool response.
|
||||
* Uses functionResponsePartsToString to properly handle functionResponse parts,
|
||||
* which correctly extracts output content from functionResponse objects rather
|
||||
* than simply concatenating text or JSON.stringify.
|
||||
* Also handles errors embedded in responseParts (e.g., cancelled responses).
|
||||
*
|
||||
* @param response - Tool call response
|
||||
* @returns String content or undefined
|
||||
@@ -1188,6 +1242,11 @@ export function toolResultContent(
|
||||
if (response.error) {
|
||||
return response.error.message;
|
||||
}
|
||||
// Check for errors in responseParts (e.g., cancelled responses)
|
||||
const responsePartsError = checkResponsePartsForError(response.responseParts);
|
||||
if (responsePartsError) {
|
||||
return responsePartsError;
|
||||
}
|
||||
if (
|
||||
typeof response.resultDisplay === 'string' &&
|
||||
response.resultDisplay.trim().length > 0
|
||||
|
||||
@@ -134,7 +134,7 @@ function createControlCancel(requestId: string): ControlCancelRequest {
|
||||
};
|
||||
}
|
||||
|
||||
describe('runNonInteractiveStreamJson', () => {
|
||||
describe('runNonInteractiveStreamJson (refactored)', () => {
|
||||
let config: Config;
|
||||
let mockInputReader: {
|
||||
read: () => AsyncGenerator<
|
||||
|
||||
@@ -4,17 +4,6 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Stream JSON Runner with Session State Machine
|
||||
*
|
||||
* Handles stream-json input/output format with:
|
||||
* - Initialize handshake
|
||||
* - Message routing (control vs user messages)
|
||||
* - FIFO user message queue
|
||||
* - Sequential message processing
|
||||
* - Graceful shutdown
|
||||
*/
|
||||
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import { StreamJsonInputReader } from './io/StreamJsonInputReader.js';
|
||||
import { StreamJsonOutputAdapter } from './io/StreamJsonOutputAdapter.js';
|
||||
@@ -42,48 +31,7 @@ import { createMinimalSettings } from '../config/settings.js';
|
||||
import { runNonInteractive } from '../nonInteractiveCli.js';
|
||||
import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js';
|
||||
|
||||
const SESSION_STATE = {
|
||||
INITIALIZING: 'initializing',
|
||||
IDLE: 'idle',
|
||||
PROCESSING_QUERY: 'processing_query',
|
||||
SHUTTING_DOWN: 'shutting_down',
|
||||
} as const;
|
||||
|
||||
type SessionState = (typeof SESSION_STATE)[keyof typeof SESSION_STATE];
|
||||
|
||||
/**
|
||||
* Message type classification for routing
|
||||
*/
|
||||
type MessageType =
|
||||
| 'control_request'
|
||||
| 'control_response'
|
||||
| 'control_cancel'
|
||||
| 'user'
|
||||
| 'assistant'
|
||||
| 'system'
|
||||
| 'result'
|
||||
| 'stream_event'
|
||||
| 'unknown';
|
||||
|
||||
/**
|
||||
* Routed message with classification
|
||||
*/
|
||||
interface RoutedMessage {
|
||||
type: MessageType;
|
||||
message:
|
||||
| CLIMessage
|
||||
| CLIControlRequest
|
||||
| CLIControlResponse
|
||||
| ControlCancelRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session Manager
|
||||
*
|
||||
* Manages the session lifecycle and message processing state machine.
|
||||
*/
|
||||
class SessionManager {
|
||||
private state: SessionState = SESSION_STATE.INITIALIZING;
|
||||
class Session {
|
||||
private userMessageQueue: CLIUserMessage[] = [];
|
||||
private abortController: AbortController;
|
||||
private config: Config;
|
||||
@@ -98,6 +46,9 @@ class SessionManager {
|
||||
private debugMode: boolean;
|
||||
private shutdownHandler: (() => void) | null = null;
|
||||
private initialPrompt: CLIUserMessage | null = null;
|
||||
private processingPromise: Promise<void> | null = null;
|
||||
private isShuttingDown: boolean = false;
|
||||
private configInitialized: boolean = false;
|
||||
|
||||
constructor(config: Config, initialPrompt?: CLIUserMessage) {
|
||||
this.config = config;
|
||||
@@ -112,146 +63,31 @@ class SessionManager {
|
||||
config.getIncludePartialMessages(),
|
||||
);
|
||||
|
||||
// Setup signal handlers for graceful shutdown
|
||||
this.setupSignalHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next prompt ID
|
||||
*/
|
||||
private getNextPromptId(): string {
|
||||
this.promptIdCounter++;
|
||||
return `${this.sessionId}########${this.promptIdCounter}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a message to the appropriate handler based on its type
|
||||
*
|
||||
* Classifies incoming messages and routes them to appropriate handlers.
|
||||
*/
|
||||
private route(
|
||||
message:
|
||||
| CLIMessage
|
||||
| CLIControlRequest
|
||||
| CLIControlResponse
|
||||
| ControlCancelRequest,
|
||||
): RoutedMessage {
|
||||
// Check control messages first
|
||||
if (isControlRequest(message)) {
|
||||
return { type: 'control_request', message };
|
||||
}
|
||||
if (isControlResponse(message)) {
|
||||
return { type: 'control_response', message };
|
||||
}
|
||||
if (isControlCancel(message)) {
|
||||
return { type: 'control_cancel', message };
|
||||
private async ensureConfigInitialized(): Promise<void> {
|
||||
if (this.configInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check data messages
|
||||
if (isCLIUserMessage(message)) {
|
||||
return { type: 'user', message };
|
||||
}
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
return { type: 'assistant', message };
|
||||
}
|
||||
if (isCLISystemMessage(message)) {
|
||||
return { type: 'system', message };
|
||||
}
|
||||
if (isCLIResultMessage(message)) {
|
||||
return { type: 'result', message };
|
||||
}
|
||||
if (isCLIPartialAssistantMessage(message)) {
|
||||
return { type: 'stream_event', message };
|
||||
}
|
||||
|
||||
// Unknown message type
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Unknown message type:',
|
||||
JSON.stringify(message, null, 2),
|
||||
);
|
||||
}
|
||||
return { type: 'unknown', message };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single message with unified logic for both initial prompt and stream messages.
|
||||
*
|
||||
* Handles:
|
||||
* - Abort check
|
||||
* - First message detection and handling
|
||||
* - Normal message processing
|
||||
* - Shutdown state checks
|
||||
*
|
||||
* @param message - Message to process
|
||||
* @returns true if the calling code should exit (break/return), false to continue
|
||||
*/
|
||||
private async processSingleMessage(
|
||||
message:
|
||||
| CLIMessage
|
||||
| CLIControlRequest
|
||||
| CLIControlResponse
|
||||
| ControlCancelRequest,
|
||||
): Promise<boolean> {
|
||||
// Check for abort
|
||||
if (this.abortController.signal.aborted) {
|
||||
return true;
|
||||
console.error('[Session] Initializing config');
|
||||
}
|
||||
|
||||
// Handle first message if control system not yet initialized
|
||||
if (this.controlSystemEnabled === null) {
|
||||
const handled = await this.handleFirstMessage(message);
|
||||
if (handled) {
|
||||
// If handled, check if we should shutdown
|
||||
return this.state === SESSION_STATE.SHUTTING_DOWN;
|
||||
}
|
||||
// If not handled, fall through to normal processing
|
||||
}
|
||||
|
||||
// Process message normally
|
||||
await this.processMessage(message);
|
||||
|
||||
// Check for shutdown after processing
|
||||
return this.state === SESSION_STATE.SHUTTING_DOWN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point - run the session
|
||||
*/
|
||||
async run(): Promise<void> {
|
||||
try {
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Starting session', this.sessionId);
|
||||
}
|
||||
|
||||
// Process initial prompt if provided
|
||||
if (this.initialPrompt !== null) {
|
||||
const shouldExit = await this.processSingleMessage(this.initialPrompt);
|
||||
if (shouldExit) {
|
||||
await this.shutdown();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Process messages from stream
|
||||
for await (const message of this.inputReader.read()) {
|
||||
const shouldExit = await this.processSingleMessage(message);
|
||||
if (shouldExit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Stream closed, shutdown
|
||||
await this.shutdown();
|
||||
await this.config.initialize();
|
||||
this.configInitialized = true;
|
||||
} catch (error) {
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Error:', error);
|
||||
console.error('[Session] Failed to initialize config:', error);
|
||||
}
|
||||
await this.shutdown();
|
||||
throw error;
|
||||
} finally {
|
||||
// Ensure signal handlers are always cleaned up even if shutdown wasn't called
|
||||
this.cleanupSignalHandlers();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,14 +95,6 @@ class SessionManager {
|
||||
if (this.controlContext && this.dispatcher && this.controlService) {
|
||||
return;
|
||||
}
|
||||
// The control system follows a strict three-layer architecture:
|
||||
// 1. ControlContext (shared session state)
|
||||
// 2. ControlDispatcher (protocol routing SDK ↔ CLI)
|
||||
// 3. ControlService (programmatic API for CLI runtime)
|
||||
//
|
||||
// Application code MUST interact with the control plane exclusively through
|
||||
// ControlService. ControlDispatcher is reserved for protocol-level message
|
||||
// routing and should never be used directly outside of this file.
|
||||
this.controlContext = new ControlContext({
|
||||
config: this.config,
|
||||
streamJson: this.outputAdapter,
|
||||
@@ -299,25 +127,32 @@ class SessionManager {
|
||||
| CLIControlResponse
|
||||
| ControlCancelRequest,
|
||||
): Promise<boolean> {
|
||||
const routed = this.route(message);
|
||||
|
||||
if (routed.type === 'control_request') {
|
||||
const request = routed.message as CLIControlRequest;
|
||||
if (isControlRequest(message)) {
|
||||
const request = message as CLIControlRequest;
|
||||
this.controlSystemEnabled = true;
|
||||
this.ensureControlSystem();
|
||||
if (request.request.subtype === 'initialize') {
|
||||
// Dispatch the initialize request first
|
||||
await this.dispatcher?.dispatch(request);
|
||||
this.state = SESSION_STATE.IDLE;
|
||||
|
||||
// After handling initialize control request, initialize the config
|
||||
// This is the SDK mode where config initialization is deferred
|
||||
await this.ensureConfigInitialized();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[Session] Ignoring non-initialize control request during initialization',
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (routed.type === 'user') {
|
||||
if (isCLIUserMessage(message)) {
|
||||
this.controlSystemEnabled = false;
|
||||
this.state = SESSION_STATE.PROCESSING_QUERY;
|
||||
this.userMessageQueue.push(routed.message as CLIUserMessage);
|
||||
await this.processUserMessageQueue();
|
||||
// For non-SDK mode (direct user message), initialize config if not already done
|
||||
await this.ensureConfigInitialized();
|
||||
this.enqueueUserMessage(message as CLIUserMessage);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -325,241 +160,50 @@ class SessionManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single message from the stream
|
||||
*/
|
||||
private async processMessage(
|
||||
message:
|
||||
| CLIMessage
|
||||
| CLIControlRequest
|
||||
| CLIControlResponse
|
||||
| ControlCancelRequest,
|
||||
private async handleControlRequest(
|
||||
request: CLIControlRequest,
|
||||
): Promise<void> {
|
||||
const routed = this.route(message);
|
||||
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
`[SessionManager] State: ${this.state}, Message type: ${routed.type}`,
|
||||
);
|
||||
}
|
||||
|
||||
switch (this.state) {
|
||||
case SESSION_STATE.INITIALIZING:
|
||||
await this.handleInitializingState(routed);
|
||||
break;
|
||||
|
||||
case SESSION_STATE.IDLE:
|
||||
await this.handleIdleState(routed);
|
||||
break;
|
||||
|
||||
case SESSION_STATE.PROCESSING_QUERY:
|
||||
await this.handleProcessingState(routed);
|
||||
break;
|
||||
|
||||
case SESSION_STATE.SHUTTING_DOWN:
|
||||
// Ignore all messages during shutdown
|
||||
break;
|
||||
|
||||
default: {
|
||||
// Exhaustive check
|
||||
const _exhaustiveCheck: never = this.state;
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Unknown state:', _exhaustiveCheck);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle messages in initializing state
|
||||
*/
|
||||
private async handleInitializingState(routed: RoutedMessage): Promise<void> {
|
||||
if (routed.type === 'control_request') {
|
||||
const request = routed.message as CLIControlRequest;
|
||||
const dispatcher = this.getDispatcher();
|
||||
if (!dispatcher) {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Control request received before control system initialization',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (request.request.subtype === 'initialize') {
|
||||
await dispatcher.dispatch(request);
|
||||
this.state = SESSION_STATE.IDLE;
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Initialized, transitioning to idle');
|
||||
}
|
||||
} else {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Ignoring non-initialize control request during initialization',
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Ignoring non-control message during initialization',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle messages in idle state
|
||||
*/
|
||||
private async handleIdleState(routed: RoutedMessage): Promise<void> {
|
||||
const dispatcher = this.getDispatcher();
|
||||
if (routed.type === 'control_request') {
|
||||
if (!dispatcher) {
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Ignoring control request (disabled)');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const request = routed.message as CLIControlRequest;
|
||||
await dispatcher.dispatch(request);
|
||||
// Stay in idle state
|
||||
} else if (routed.type === 'control_response') {
|
||||
if (!dispatcher) {
|
||||
return;
|
||||
}
|
||||
const response = routed.message as CLIControlResponse;
|
||||
dispatcher.handleControlResponse(response);
|
||||
// Stay in idle state
|
||||
} else if (routed.type === 'control_cancel') {
|
||||
if (!dispatcher) {
|
||||
return;
|
||||
}
|
||||
const cancelRequest = routed.message as ControlCancelRequest;
|
||||
dispatcher.handleCancel(cancelRequest.request_id);
|
||||
} else if (routed.type === 'user') {
|
||||
const userMessage = routed.message as CLIUserMessage;
|
||||
this.userMessageQueue.push(userMessage);
|
||||
// Start processing queue
|
||||
await this.processUserMessageQueue();
|
||||
} else {
|
||||
if (!dispatcher) {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Ignoring message type in idle state:',
|
||||
routed.type,
|
||||
);
|
||||
console.error('[Session] Control system not enabled');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle messages in processing state
|
||||
*/
|
||||
private async handleProcessingState(routed: RoutedMessage): Promise<void> {
|
||||
const dispatcher = this.getDispatcher();
|
||||
if (routed.type === 'control_request') {
|
||||
if (!dispatcher) {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Control request ignored during processing (disabled)',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const request = routed.message as CLIControlRequest;
|
||||
await dispatcher.dispatch(request);
|
||||
// Continue processing
|
||||
} else if (routed.type === 'control_response') {
|
||||
if (!dispatcher) {
|
||||
return;
|
||||
}
|
||||
const response = routed.message as CLIControlResponse;
|
||||
dispatcher.handleControlResponse(response);
|
||||
// Continue processing
|
||||
} else if (routed.type === 'user') {
|
||||
// Enqueue for later
|
||||
const userMessage = routed.message as CLIUserMessage;
|
||||
this.userMessageQueue.push(userMessage);
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Enqueued user message during processing',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Ignoring message type during processing:',
|
||||
routed.type,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process user message queue (FIFO)
|
||||
*/
|
||||
private async processUserMessageQueue(): Promise<void> {
|
||||
while (
|
||||
this.userMessageQueue.length > 0 &&
|
||||
!this.abortController.signal.aborted
|
||||
) {
|
||||
this.state = SESSION_STATE.PROCESSING_QUERY;
|
||||
const userMessage = this.userMessageQueue.shift()!;
|
||||
|
||||
try {
|
||||
await this.processUserMessage(userMessage);
|
||||
} catch (error) {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Error processing user message:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
// Send error result
|
||||
this.emitErrorResult(error);
|
||||
}
|
||||
}
|
||||
|
||||
// If control system is disabled (single-query mode) and queue is empty,
|
||||
// automatically shutdown instead of returning to idle
|
||||
if (
|
||||
!this.abortController.signal.aborted &&
|
||||
this.state === SESSION_STATE.PROCESSING_QUERY &&
|
||||
this.controlSystemEnabled === false &&
|
||||
this.userMessageQueue.length === 0
|
||||
) {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Single-query mode: queue processed, shutting down',
|
||||
);
|
||||
}
|
||||
this.state = SESSION_STATE.SHUTTING_DOWN;
|
||||
return;
|
||||
}
|
||||
|
||||
// Return to idle after processing queue (for multi-query mode with control system)
|
||||
if (
|
||||
!this.abortController.signal.aborted &&
|
||||
this.state === SESSION_STATE.PROCESSING_QUERY
|
||||
) {
|
||||
this.state = SESSION_STATE.IDLE;
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Queue processed, returning to idle');
|
||||
}
|
||||
}
|
||||
await dispatcher.dispatch(request);
|
||||
}
|
||||
|
||||
private handleControlResponse(response: CLIControlResponse): void {
|
||||
const dispatcher = this.getDispatcher();
|
||||
if (!dispatcher) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatcher.handleControlResponse(response);
|
||||
}
|
||||
|
||||
private handleControlCancel(cancelRequest: ControlCancelRequest): void {
|
||||
const dispatcher = this.getDispatcher();
|
||||
if (!dispatcher) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatcher.handleCancel(cancelRequest.request_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single user message
|
||||
*/
|
||||
private async processUserMessage(userMessage: CLIUserMessage): Promise<void> {
|
||||
const input = extractUserMessageText(userMessage);
|
||||
if (!input) {
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] No text content in user message');
|
||||
console.error('[Session] No text content in user message');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure config is initialized before processing user messages
|
||||
await this.ensureConfigInitialized();
|
||||
|
||||
const promptId = this.getNextPromptId();
|
||||
|
||||
try {
|
||||
@@ -575,16 +219,56 @@ class SessionManager {
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
// Error already handled by runNonInteractive via adapter.emitResult
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Query execution error:', error);
|
||||
console.error('[Session] Query execution error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send tool results as user message
|
||||
*/
|
||||
private async processUserMessageQueue(): Promise<void> {
|
||||
if (this.isShuttingDown || this.abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (
|
||||
this.userMessageQueue.length > 0 &&
|
||||
!this.isShuttingDown &&
|
||||
!this.abortController.signal.aborted
|
||||
) {
|
||||
const userMessage = this.userMessageQueue.shift()!;
|
||||
try {
|
||||
await this.processUserMessage(userMessage);
|
||||
} catch (error) {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Error processing user message:', error);
|
||||
}
|
||||
this.emitErrorResult(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enqueueUserMessage(userMessage: CLIUserMessage): void {
|
||||
this.userMessageQueue.push(userMessage);
|
||||
this.ensureProcessingStarted();
|
||||
}
|
||||
|
||||
private ensureProcessingStarted(): void {
|
||||
if (this.processingPromise) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.processingPromise = this.processUserMessageQueue().finally(() => {
|
||||
this.processingPromise = null;
|
||||
if (
|
||||
this.userMessageQueue.length > 0 &&
|
||||
!this.isShuttingDown &&
|
||||
!this.abortController.signal.aborted
|
||||
) {
|
||||
this.ensureProcessingStarted();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private emitErrorResult(
|
||||
error: unknown,
|
||||
numTurns: number = 0,
|
||||
@@ -602,52 +286,51 @@ class SessionManager {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle interrupt control request
|
||||
*/
|
||||
private handleInterrupt(): void {
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Interrupt requested');
|
||||
}
|
||||
// Abort current query if processing
|
||||
if (this.state === SESSION_STATE.PROCESSING_QUERY) {
|
||||
this.abortController.abort();
|
||||
this.abortController = new AbortController(); // Create new controller for next query
|
||||
console.error('[Session] Interrupt requested');
|
||||
}
|
||||
this.abortController.abort();
|
||||
this.abortController = new AbortController();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup signal handlers for graceful shutdown
|
||||
*/
|
||||
private setupSignalHandlers(): void {
|
||||
this.shutdownHandler = () => {
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Shutdown signal received');
|
||||
console.error('[Session] Shutdown signal received');
|
||||
}
|
||||
this.isShuttingDown = true;
|
||||
this.abortController.abort();
|
||||
this.state = SESSION_STATE.SHUTTING_DOWN;
|
||||
};
|
||||
|
||||
process.on('SIGINT', this.shutdownHandler);
|
||||
process.on('SIGTERM', this.shutdownHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown session and cleanup resources
|
||||
*/
|
||||
private async shutdown(): Promise<void> {
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Shutting down');
|
||||
console.error('[Session] Shutting down');
|
||||
}
|
||||
|
||||
this.isShuttingDown = true;
|
||||
|
||||
if (this.processingPromise) {
|
||||
try {
|
||||
await this.processingPromise;
|
||||
} catch (error) {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[Session] Error waiting for processing to complete:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.state = SESSION_STATE.SHUTTING_DOWN;
|
||||
this.dispatcher?.shutdown();
|
||||
this.cleanupSignalHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove signal handlers to prevent memory leaks
|
||||
*/
|
||||
private cleanupSignalHandlers(): void {
|
||||
if (this.shutdownHandler) {
|
||||
process.removeListener('SIGINT', this.shutdownHandler);
|
||||
@@ -655,6 +338,94 @@ class SessionManager {
|
||||
this.shutdownHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
try {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Starting session', this.sessionId);
|
||||
}
|
||||
|
||||
if (this.initialPrompt !== null) {
|
||||
const handled = await this.handleFirstMessage(this.initialPrompt);
|
||||
if (handled && this.isShuttingDown) {
|
||||
await this.shutdown();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
for await (const message of this.inputReader.read()) {
|
||||
if (this.abortController.signal.aborted) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (this.controlSystemEnabled === null) {
|
||||
const handled = await this.handleFirstMessage(message);
|
||||
if (handled) {
|
||||
if (this.isShuttingDown) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (isControlRequest(message)) {
|
||||
await this.handleControlRequest(message as CLIControlRequest);
|
||||
} else if (isControlResponse(message)) {
|
||||
this.handleControlResponse(message as CLIControlResponse);
|
||||
} else if (isControlCancel(message)) {
|
||||
this.handleControlCancel(message as ControlCancelRequest);
|
||||
} else if (isCLIUserMessage(message)) {
|
||||
this.enqueueUserMessage(message as CLIUserMessage);
|
||||
} else if (this.debugMode) {
|
||||
if (
|
||||
!isCLIAssistantMessage(message) &&
|
||||
!isCLISystemMessage(message) &&
|
||||
!isCLIResultMessage(message) &&
|
||||
!isCLIPartialAssistantMessage(message)
|
||||
) {
|
||||
console.error(
|
||||
'[Session] Unknown message type:',
|
||||
JSON.stringify(message, null, 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isShuttingDown) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (streamError) {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Stream reading error:', streamError);
|
||||
}
|
||||
throw streamError;
|
||||
}
|
||||
|
||||
while (this.processingPromise) {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Waiting for final processing to complete');
|
||||
}
|
||||
try {
|
||||
await this.processingPromise;
|
||||
} catch (error) {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Error in final processing:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.shutdown();
|
||||
} catch (error) {
|
||||
if (this.debugMode) {
|
||||
console.error('[Session] Error:', error);
|
||||
}
|
||||
await this.shutdown();
|
||||
throw error;
|
||||
} finally {
|
||||
this.cleanupSignalHandlers();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractUserMessageText(message: CLIUserMessage): string | null {
|
||||
@@ -682,12 +453,6 @@ function extractUserMessageText(message: CLIUserMessage): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point for stream-json mode
|
||||
*
|
||||
* @param config - Configuration object
|
||||
* @param input - Optional initial prompt input to process before reading from stream
|
||||
*/
|
||||
export async function runNonInteractiveStreamJson(
|
||||
config: Config,
|
||||
input: string,
|
||||
@@ -698,7 +463,6 @@ export async function runNonInteractiveStreamJson(
|
||||
consolePatcher.patch();
|
||||
|
||||
try {
|
||||
// Create initial user message from prompt input if provided
|
||||
let initialPrompt: CLIUserMessage | undefined = undefined;
|
||||
if (input && input.trim().length > 0) {
|
||||
const sessionId = config.getSessionId();
|
||||
@@ -713,7 +477,7 @@ export async function runNonInteractiveStreamJson(
|
||||
};
|
||||
}
|
||||
|
||||
const manager = new SessionManager(config, initialPrompt);
|
||||
const manager = new Session(config, initialPrompt);
|
||||
await manager.run();
|
||||
} finally {
|
||||
consolePatcher.cleanup();
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type {
|
||||
MCPServerConfig,
|
||||
SubagentConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
/**
|
||||
* Annotation for attaching metadata to content blocks
|
||||
@@ -295,10 +299,18 @@ export interface CLIControlPermissionRequest {
|
||||
blocked_path: string | null;
|
||||
}
|
||||
|
||||
export enum AuthProviderType {
|
||||
DYNAMIC_DISCOVERY = 'dynamic_discovery',
|
||||
GOOGLE_CREDENTIALS = 'google_credentials',
|
||||
SERVICE_ACCOUNT_IMPERSONATION = 'service_account_impersonation',
|
||||
}
|
||||
|
||||
export interface CLIControlInitializeRequest {
|
||||
subtype: 'initialize';
|
||||
hooks?: HookRegistration[] | null;
|
||||
sdkMcpServers?: string[];
|
||||
sdkMcpServers?: Record<string, MCPServerConfig>;
|
||||
mcpServers?: Record<string, MCPServerConfig>;
|
||||
agents?: SubagentConfig[];
|
||||
}
|
||||
|
||||
export interface CLIControlSetPermissionModeRequest {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
FatalInputError,
|
||||
promptIdContext,
|
||||
OutputFormat,
|
||||
InputFormat,
|
||||
uiTelemetryService,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { Content, Part, PartListUnion } from '@google/genai';
|
||||
@@ -225,40 +226,14 @@ export async function runNonInteractive(
|
||||
for (const requestInfo of toolCallRequests) {
|
||||
const finalRequestInfo = requestInfo;
|
||||
|
||||
/*
|
||||
if (options.controlService) {
|
||||
const permissionResult =
|
||||
await options.controlService.permission.shouldAllowTool(
|
||||
requestInfo,
|
||||
);
|
||||
if (!permissionResult.allowed) {
|
||||
if (config.getDebugMode()) {
|
||||
console.error(
|
||||
`[runNonInteractive] Tool execution denied: ${requestInfo.name}`,
|
||||
permissionResult.message ?? '',
|
||||
);
|
||||
}
|
||||
if (adapter && permissionResult.message) {
|
||||
adapter.emitSystemMessage('tool_denied', {
|
||||
tool: requestInfo.name,
|
||||
message: permissionResult.message,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (permissionResult.updatedArgs) {
|
||||
finalRequestInfo = {
|
||||
...requestInfo,
|
||||
args: permissionResult.updatedArgs,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const toolCallUpdateCallback = options.controlService
|
||||
? options.controlService.permission.getToolCallUpdateCallback()
|
||||
: undefined;
|
||||
*/
|
||||
const inputFormat =
|
||||
typeof config.getInputFormat === 'function'
|
||||
? config.getInputFormat()
|
||||
: InputFormat.TEXT;
|
||||
const toolCallUpdateCallback =
|
||||
inputFormat === InputFormat.STREAM_JSON && options.controlService
|
||||
? options.controlService.permission.getToolCallUpdateCallback()
|
||||
: undefined;
|
||||
|
||||
// Only pass outputUpdateHandler for Task tool
|
||||
const isTaskTool = finalRequestInfo.name === 'task';
|
||||
@@ -277,13 +252,13 @@ export async function runNonInteractive(
|
||||
isTaskTool && taskToolProgressHandler
|
||||
? {
|
||||
outputUpdateHandler: taskToolProgressHandler,
|
||||
/*
|
||||
toolCallUpdateCallback
|
||||
? { onToolCallsUpdate: toolCallUpdateCallback }
|
||||
: undefined,
|
||||
*/
|
||||
onToolCallsUpdate: toolCallUpdateCallback,
|
||||
}
|
||||
: undefined,
|
||||
: toolCallUpdateCallback
|
||||
? {
|
||||
onToolCallsUpdate: toolCallUpdateCallback,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
// Note: In JSON mode, subagent messages are automatically added to the main
|
||||
@@ -303,9 +278,6 @@ export async function runNonInteractive(
|
||||
? toolResponse.resultDisplay
|
||||
: undefined,
|
||||
);
|
||||
// Note: We no longer emit a separate system message for tool errors
|
||||
// in JSON/STREAM_JSON mode, as the error is already captured in the
|
||||
// tool_result block with is_error=true.
|
||||
}
|
||||
|
||||
if (adapter) {
|
||||
|
||||
@@ -218,7 +218,7 @@ export const AgentSelectionStep = ({
|
||||
const renderAgentItem = (
|
||||
agent: {
|
||||
name: string;
|
||||
level: 'project' | 'user' | 'builtin';
|
||||
level: 'project' | 'user' | 'builtin' | 'session';
|
||||
isBuiltin?: boolean;
|
||||
},
|
||||
index: number,
|
||||
|
||||
@@ -65,6 +65,7 @@ import { ideContextStore } from '../ide/ideContext.js';
|
||||
import { InputFormat, OutputFormat } from '../output/types.js';
|
||||
import { PromptRegistry } from '../prompts/prompt-registry.js';
|
||||
import { SubagentManager } from '../subagents/subagent-manager.js';
|
||||
import type { SubagentConfig } from '../subagents/types.js';
|
||||
import {
|
||||
DEFAULT_OTLP_ENDPOINT,
|
||||
DEFAULT_TELEMETRY_TARGET,
|
||||
@@ -333,9 +334,11 @@ export interface ConfigParameters {
|
||||
eventEmitter?: EventEmitter;
|
||||
useSmartEdit?: boolean;
|
||||
output?: OutputSettings;
|
||||
skipStartupContext?: boolean;
|
||||
inputFormat?: InputFormat;
|
||||
outputFormat?: OutputFormat;
|
||||
skipStartupContext?: boolean;
|
||||
sdkMode?: boolean;
|
||||
sessionSubagents?: SubagentConfig[];
|
||||
}
|
||||
|
||||
function normalizeConfigOutputFormat(
|
||||
@@ -383,8 +386,10 @@ export class Config {
|
||||
private readonly toolDiscoveryCommand: string | undefined;
|
||||
private readonly toolCallCommand: string | undefined;
|
||||
private readonly mcpServerCommand: string | undefined;
|
||||
private readonly mcpServers: Record<string, MCPServerConfig> | undefined;
|
||||
private mcpServers: Record<string, MCPServerConfig> | undefined;
|
||||
private sessionSubagents: SubagentConfig[];
|
||||
private userMemory: string;
|
||||
private sdkMode: boolean;
|
||||
private geminiMdFileCount: number;
|
||||
private approvalMode: ApprovalMode;
|
||||
private readonly showMemoryUsage: boolean;
|
||||
@@ -487,6 +492,8 @@ export class Config {
|
||||
this.toolCallCommand = params.toolCallCommand;
|
||||
this.mcpServerCommand = params.mcpServerCommand;
|
||||
this.mcpServers = params.mcpServers;
|
||||
this.sessionSubagents = params.sessionSubagents ?? [];
|
||||
this.sdkMode = params.sdkMode ?? false;
|
||||
this.userMemory = params.userMemory ?? '';
|
||||
this.geminiMdFileCount = params.geminiMdFileCount ?? 0;
|
||||
this.approvalMode = params.approvalMode ?? ApprovalMode.DEFAULT;
|
||||
@@ -842,6 +849,46 @@ export class Config {
|
||||
return this.mcpServers;
|
||||
}
|
||||
|
||||
setMcpServers(servers: Record<string, MCPServerConfig>): void {
|
||||
if (this.initialized) {
|
||||
throw new Error('Cannot modify mcpServers after initialization');
|
||||
}
|
||||
this.mcpServers = servers;
|
||||
}
|
||||
|
||||
addMcpServers(servers: Record<string, MCPServerConfig>): void {
|
||||
if (this.initialized) {
|
||||
throw new Error('Cannot modify mcpServers after initialization');
|
||||
}
|
||||
this.mcpServers = { ...this.mcpServers, ...servers };
|
||||
}
|
||||
|
||||
getSessionSubagents(): SubagentConfig[] {
|
||||
return this.sessionSubagents;
|
||||
}
|
||||
|
||||
setSessionSubagents(subagents: SubagentConfig[]): void {
|
||||
if (this.initialized) {
|
||||
throw new Error('Cannot modify sessionSubagents after initialization');
|
||||
}
|
||||
this.sessionSubagents = subagents;
|
||||
}
|
||||
|
||||
addSessionSubagents(subagents: SubagentConfig[]): void {
|
||||
if (this.initialized) {
|
||||
throw new Error('Cannot modify sessionSubagents after initialization');
|
||||
}
|
||||
this.sessionSubagents = [...this.sessionSubagents, ...subagents];
|
||||
}
|
||||
|
||||
getSdkMode(): boolean {
|
||||
return this.sdkMode;
|
||||
}
|
||||
|
||||
setSdkMode(value: boolean): void {
|
||||
this.sdkMode = value;
|
||||
}
|
||||
|
||||
getUserMemory(): string {
|
||||
return this.userMemory;
|
||||
}
|
||||
|
||||
@@ -916,7 +916,10 @@ export class CoreToolScheduler {
|
||||
|
||||
async handleConfirmationResponse(
|
||||
callId: string,
|
||||
originalOnConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>,
|
||||
originalOnConfirm: (
|
||||
outcome: ToolConfirmationOutcome,
|
||||
payload?: ToolConfirmationPayload,
|
||||
) => Promise<void>,
|
||||
outcome: ToolConfirmationOutcome,
|
||||
signal: AbortSignal,
|
||||
payload?: ToolConfirmationPayload,
|
||||
@@ -925,9 +928,7 @@ export class CoreToolScheduler {
|
||||
(c) => c.request.callId === callId && c.status === 'awaiting_approval',
|
||||
);
|
||||
|
||||
if (toolCall && toolCall.status === 'awaiting_approval') {
|
||||
await originalOnConfirm(outcome);
|
||||
}
|
||||
await originalOnConfirm(outcome, payload);
|
||||
|
||||
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
|
||||
await this.autoApproveCompatiblePendingTools(signal, callId);
|
||||
@@ -936,11 +937,10 @@ export class CoreToolScheduler {
|
||||
this.setToolCallOutcome(callId, outcome);
|
||||
|
||||
if (outcome === ToolConfirmationOutcome.Cancel || signal.aborted) {
|
||||
this.setStatusInternal(
|
||||
callId,
|
||||
'cancelled',
|
||||
'User did not allow tool call',
|
||||
);
|
||||
// Use custom cancel message from payload if provided, otherwise use default
|
||||
const cancelMessage =
|
||||
payload?.cancelMessage || 'User did not allow tool call';
|
||||
this.setStatusInternal(callId, 'cancelled', cancelMessage);
|
||||
} else if (outcome === ToolConfirmationOutcome.ModifyWithEditor) {
|
||||
const waitingToolCall = toolCall as WaitingToolCall;
|
||||
if (isModifiableDeclarativeTool(waitingToolCall.tool)) {
|
||||
@@ -998,7 +998,8 @@ export class CoreToolScheduler {
|
||||
): Promise<void> {
|
||||
if (
|
||||
toolCall.confirmationDetails.type !== 'edit' ||
|
||||
!isModifiableDeclarativeTool(toolCall.tool)
|
||||
!isModifiableDeclarativeTool(toolCall.tool) ||
|
||||
!payload.newContent
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { EventEmitter } from 'events';
|
||||
import type {
|
||||
ToolCallConfirmationDetails,
|
||||
ToolConfirmationOutcome,
|
||||
ToolResultDisplay,
|
||||
} from '../tools/tools.js';
|
||||
import type { Part } from '@google/genai';
|
||||
|
||||
@@ -74,7 +75,7 @@ export interface SubAgentToolResultEvent {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
responseParts?: Part[];
|
||||
resultDisplay?: string;
|
||||
resultDisplay?: ToolResultDisplay;
|
||||
durationMs?: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
@@ -77,6 +77,15 @@ export class SubagentManager {
|
||||
): Promise<void> {
|
||||
this.validator.validateOrThrow(config);
|
||||
|
||||
// Prevent creating session-level agents
|
||||
if (options.level === 'session') {
|
||||
throw new SubagentError(
|
||||
`Cannot create session-level subagent "${config.name}". Session agents are read-only and provided at runtime.`,
|
||||
SubagentErrorCode.INVALID_CONFIG,
|
||||
config.name,
|
||||
);
|
||||
}
|
||||
|
||||
// Determine file path
|
||||
const filePath =
|
||||
options.customPath || this.getSubagentPath(config.name, options.level);
|
||||
@@ -142,6 +151,11 @@ export class SubagentManager {
|
||||
return BuiltinAgentRegistry.getBuiltinAgent(name);
|
||||
}
|
||||
|
||||
if (level === 'session') {
|
||||
const sessionSubagents = this.subagentsCache?.get('session') || [];
|
||||
return sessionSubagents.find((agent) => agent.name === name) || null;
|
||||
}
|
||||
|
||||
return this.findSubagentByNameAtLevel(name, level);
|
||||
}
|
||||
|
||||
@@ -191,6 +205,15 @@ export class SubagentManager {
|
||||
);
|
||||
}
|
||||
|
||||
// Prevent updating session-level agents
|
||||
if (existing.level === 'session') {
|
||||
throw new SubagentError(
|
||||
`Cannot update session-level subagent "${name}"`,
|
||||
SubagentErrorCode.INVALID_CONFIG,
|
||||
name,
|
||||
);
|
||||
}
|
||||
|
||||
// Merge updates with existing configuration
|
||||
const updatedConfig = this.mergeConfigurations(existing, updates);
|
||||
|
||||
@@ -236,8 +259,8 @@ export class SubagentManager {
|
||||
let deleted = false;
|
||||
|
||||
for (const currentLevel of levelsToCheck) {
|
||||
// Skip builtin level for deletion
|
||||
if (currentLevel === 'builtin') {
|
||||
// Skip builtin and session levels for deletion
|
||||
if (currentLevel === 'builtin' || currentLevel === 'session') {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -277,6 +300,38 @@ export class SubagentManager {
|
||||
const subagents: SubagentConfig[] = [];
|
||||
const seenNames = new Set<string>();
|
||||
|
||||
// In SDK mode, only load session-level subagents
|
||||
if (this.config.getSdkMode()) {
|
||||
const sessionSubagents = this.config.getSessionSubagents();
|
||||
if (sessionSubagents && sessionSubagents.length > 0) {
|
||||
this.loadSessionSubagents(sessionSubagents);
|
||||
}
|
||||
|
||||
const levelsToCheck: SubagentLevel[] = options.level
|
||||
? [options.level]
|
||||
: ['session'];
|
||||
|
||||
for (const level of levelsToCheck) {
|
||||
const levelSubagents = this.subagentsCache?.get(level) || [];
|
||||
|
||||
for (const subagent of levelSubagents) {
|
||||
// Apply tool filter if specified
|
||||
if (
|
||||
options.hasTool &&
|
||||
(!subagent.tools || !subagent.tools.includes(options.hasTool))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
subagents.push(subagent);
|
||||
seenNames.add(subagent.name);
|
||||
}
|
||||
}
|
||||
|
||||
return subagents;
|
||||
}
|
||||
|
||||
// Normal mode: load from project, user, and builtin levels
|
||||
const levelsToCheck: SubagentLevel[] = options.level
|
||||
? [options.level]
|
||||
: ['project', 'user', 'builtin'];
|
||||
@@ -322,8 +377,8 @@ export class SubagentManager {
|
||||
comparison = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case 'level': {
|
||||
// Project comes before user, user comes before builtin
|
||||
const levelOrder = { project: 0, user: 1, builtin: 2 };
|
||||
// Project comes before user, user comes before builtin, session comes last
|
||||
const levelOrder = { project: 0, user: 1, builtin: 2, session: 3 };
|
||||
comparison = levelOrder[a.level] - levelOrder[b.level];
|
||||
break;
|
||||
}
|
||||
@@ -339,6 +394,27 @@ export class SubagentManager {
|
||||
return subagents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads session-level subagents into the cache.
|
||||
* Session subagents are provided directly via config and are read-only.
|
||||
*
|
||||
* @param subagents - Array of session subagent configurations
|
||||
*/
|
||||
loadSessionSubagents(subagents: SubagentConfig[]): void {
|
||||
if (!this.subagentsCache) {
|
||||
this.subagentsCache = new Map();
|
||||
}
|
||||
|
||||
const sessionSubagents = subagents.map((config) => ({
|
||||
...config,
|
||||
level: 'session' as SubagentLevel,
|
||||
filePath: `<session:${config.name}>`,
|
||||
}));
|
||||
|
||||
this.subagentsCache.set('session', sessionSubagents);
|
||||
this.notifyChangeListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the subagents cache by loading all subagents from disk.
|
||||
* This method is called automatically when cache is null or when force=true.
|
||||
@@ -693,6 +769,10 @@ export class SubagentManager {
|
||||
return `<builtin:${name}>`;
|
||||
}
|
||||
|
||||
if (level === 'session') {
|
||||
return `<session:${name}>`;
|
||||
}
|
||||
|
||||
const baseDir =
|
||||
level === 'project'
|
||||
? path.join(
|
||||
|
||||
@@ -11,8 +11,9 @@ import type { Content, FunctionDeclaration } from '@google/genai';
|
||||
* - 'project': Stored in `.qwen/agents/` within the project directory
|
||||
* - 'user': Stored in `~/.qwen/agents/` in the user's home directory
|
||||
* - 'builtin': Built-in agents embedded in the codebase, always available
|
||||
* - 'session': Session-level agents provided at runtime, read-only
|
||||
*/
|
||||
export type SubagentLevel = 'project' | 'user' | 'builtin';
|
||||
export type SubagentLevel = 'project' | 'user' | 'builtin' | 'session';
|
||||
|
||||
/**
|
||||
* Core configuration for a subagent as stored in Markdown files.
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
ToolInvocation,
|
||||
ToolMcpConfirmationDetails,
|
||||
ToolResult,
|
||||
ToolConfirmationPayload,
|
||||
} from './tools.js';
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
@@ -98,7 +99,10 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation<
|
||||
serverName: this.serverName,
|
||||
toolName: this.serverToolName, // Display original tool name in confirmation
|
||||
toolDisplayName: this.displayName, // Display global registry name exposed to model and user
|
||||
onConfirm: async (outcome: ToolConfirmationOutcome) => {
|
||||
onConfirm: async (
|
||||
outcome: ToolConfirmationOutcome,
|
||||
_payload?: ToolConfirmationPayload,
|
||||
) => {
|
||||
if (outcome === ToolConfirmationOutcome.ProceedAlwaysServer) {
|
||||
DiscoveredMCPToolInvocation.allowlist.add(serverAllowListKey);
|
||||
} else if (outcome === ToolConfirmationOutcome.ProceedAlwaysTool) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
ToolResultDisplay,
|
||||
ToolCallConfirmationDetails,
|
||||
ToolExecuteConfirmationDetails,
|
||||
ToolConfirmationPayload,
|
||||
} from './tools.js';
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
@@ -102,7 +103,10 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
title: 'Confirm Shell Command',
|
||||
command: this.params.command,
|
||||
rootCommand: commandsToConfirm.join(', '),
|
||||
onConfirm: async (outcome: ToolConfirmationOutcome) => {
|
||||
onConfirm: async (
|
||||
outcome: ToolConfirmationOutcome,
|
||||
_payload?: ToolConfirmationPayload,
|
||||
) => {
|
||||
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
|
||||
commandsToConfirm.forEach((command) => this.allowlist.add(command));
|
||||
}
|
||||
|
||||
@@ -531,13 +531,18 @@ export interface ToolEditConfirmationDetails {
|
||||
export interface ToolConfirmationPayload {
|
||||
// used to override `modifiedProposedContent` for modifiable tools in the
|
||||
// inline modify flow
|
||||
newContent: string;
|
||||
newContent?: string;
|
||||
// used to provide custom cancellation message when outcome is Cancel
|
||||
cancelMessage?: string;
|
||||
}
|
||||
|
||||
export interface ToolExecuteConfirmationDetails {
|
||||
type: 'exec';
|
||||
title: string;
|
||||
onConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>;
|
||||
onConfirm: (
|
||||
outcome: ToolConfirmationOutcome,
|
||||
payload?: ToolConfirmationPayload,
|
||||
) => Promise<void>;
|
||||
command: string;
|
||||
rootCommand: string;
|
||||
}
|
||||
@@ -548,7 +553,10 @@ export interface ToolMcpConfirmationDetails {
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
toolDisplayName: string;
|
||||
onConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>;
|
||||
onConfirm: (
|
||||
outcome: ToolConfirmationOutcome,
|
||||
payload?: ToolConfirmationPayload,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface ToolInfoConfirmationDetails {
|
||||
@@ -573,6 +581,11 @@ export interface ToolPlanConfirmationDetails {
|
||||
onConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO:
|
||||
* 1. support explicit denied outcome
|
||||
* 2. support proceed with modified input
|
||||
*/
|
||||
export enum ToolConfirmationOutcome {
|
||||
ProceedOnce = 'proceed_once',
|
||||
ProceedAlways = 'proceed_always',
|
||||
|
||||
68
packages/sdk-typescript/package.json
Normal file
68
packages/sdk-typescript/package.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "@qwen-code/sdk-typescript",
|
||||
"version": "0.1.0",
|
||||
"description": "TypeScript SDK for programmatic access to qwen-code CLI",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint src test",
|
||||
"lint:fix": "eslint src test --fix",
|
||||
"clean": "rm -rf dist",
|
||||
"prepublishOnly": "npm run clean && npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
"qwen",
|
||||
"qwen-code",
|
||||
"ai",
|
||||
"code-assistant",
|
||||
"sdk",
|
||||
"typescript"
|
||||
],
|
||||
"author": "Qwen Team",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.13.0",
|
||||
"@typescript-eslint/parser": "^7.13.0",
|
||||
"@vitest/coverage-v8": "^1.6.0",
|
||||
"eslint": "^8.57.0",
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.0.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/qwen-ai/qwen-code.git",
|
||||
"directory": "packages/sdk/typescript"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/qwen-ai/qwen-code/issues"
|
||||
},
|
||||
"homepage": "https://github.com/qwen-ai/qwen-code#readme"
|
||||
}
|
||||
66
packages/sdk-typescript/src/index.ts
Normal file
66
packages/sdk-typescript/src/index.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
export { query } from './query/createQuery.js';
|
||||
|
||||
export { Query } from './query/Query.js';
|
||||
|
||||
export type { ExternalMcpServerConfig } from './types/queryOptionsSchema.js';
|
||||
|
||||
export type { QueryOptions } from './query/createQuery.js';
|
||||
|
||||
export type {
|
||||
ContentBlock,
|
||||
TextBlock,
|
||||
ThinkingBlock,
|
||||
ToolUseBlock,
|
||||
ToolResultBlock,
|
||||
CLIUserMessage,
|
||||
CLIAssistantMessage,
|
||||
CLISystemMessage,
|
||||
CLIResultMessage,
|
||||
CLIPartialAssistantMessage,
|
||||
CLIMessage,
|
||||
} from './types/protocol.js';
|
||||
|
||||
export {
|
||||
isCLIUserMessage,
|
||||
isCLIAssistantMessage,
|
||||
isCLISystemMessage,
|
||||
isCLIResultMessage,
|
||||
isCLIPartialAssistantMessage,
|
||||
} from './types/protocol.js';
|
||||
|
||||
export { AbortError, isAbortError } from './types/errors.js';
|
||||
|
||||
export { ControlRequestType } from './types/protocol.js';
|
||||
|
||||
export { ProcessTransport } from './transport/ProcessTransport.js';
|
||||
export type { Transport } from './transport/Transport.js';
|
||||
|
||||
export { Stream } from './utils/Stream.js';
|
||||
export {
|
||||
serializeJsonLine,
|
||||
parseJsonLineSafe,
|
||||
isValidMessage,
|
||||
parseJsonLinesStream,
|
||||
} from './utils/jsonLines.js';
|
||||
export {
|
||||
findCliPath,
|
||||
resolveCliPath,
|
||||
prepareSpawnInfo,
|
||||
} from './utils/cliPath.js';
|
||||
export type { SpawnInfo } from './utils/cliPath.js';
|
||||
|
||||
export { createSdkMcpServer } from './mcp/createSdkMcpServer.js';
|
||||
export {
|
||||
tool,
|
||||
createTool,
|
||||
validateToolName,
|
||||
validateInputSchema,
|
||||
} from './mcp/tool.js';
|
||||
|
||||
export type {
|
||||
JSONSchema,
|
||||
ToolDefinition,
|
||||
PermissionMode,
|
||||
CanUseTool,
|
||||
PermissionResult,
|
||||
} from './types/types.js';
|
||||
111
packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts
Normal file
111
packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* SdkControlServerTransport - bridges MCP Server with Query's control plane
|
||||
*
|
||||
* Implements @modelcontextprotocol/sdk Transport interface to enable
|
||||
* SDK-embedded MCP servers. Messages flow bidirectionally:
|
||||
*
|
||||
* MCP Server → send() → Query → control_request (mcp_message) → CLI
|
||||
* CLI → control_request (mcp_message) → Query → handleMessage() → MCP Server
|
||||
*/
|
||||
|
||||
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
export type SendToQueryCallback = (message: JSONRPCMessage) => Promise<void>;
|
||||
|
||||
export interface SdkControlServerTransportOptions {
|
||||
sendToQuery: SendToQueryCallback;
|
||||
serverName: string;
|
||||
}
|
||||
|
||||
export class SdkControlServerTransport {
|
||||
sendToQuery: SendToQueryCallback;
|
||||
private serverName: string;
|
||||
private started = false;
|
||||
|
||||
onmessage?: (message: JSONRPCMessage) => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onclose?: () => void;
|
||||
|
||||
constructor(options: SdkControlServerTransportOptions) {
|
||||
this.sendToQuery = options.sendToQuery;
|
||||
this.serverName = options.serverName;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.started = true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (!this.started) {
|
||||
return; // Already closed
|
||||
}
|
||||
|
||||
this.started = false;
|
||||
|
||||
// Notify MCP Server
|
||||
if (this.onclose) {
|
||||
this.onclose();
|
||||
}
|
||||
}
|
||||
|
||||
handleMessage(message: JSONRPCMessage): void {
|
||||
if (!this.started) {
|
||||
console.warn(
|
||||
`[SdkControlServerTransport] Received message for closed transport (${this.serverName})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.onmessage) {
|
||||
this.onmessage(message);
|
||||
} else {
|
||||
console.warn(
|
||||
`[SdkControlServerTransport] No onmessage handler set for ${this.serverName}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
handleError(error: Error): void {
|
||||
if (this.onerror) {
|
||||
this.onerror(error);
|
||||
} else {
|
||||
console.error(
|
||||
`[SdkControlServerTransport] Error for ${this.serverName}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
isStarted(): boolean {
|
||||
return this.started;
|
||||
}
|
||||
|
||||
getServerName(): string {
|
||||
return this.serverName;
|
||||
}
|
||||
}
|
||||
|
||||
export function createSdkControlServerTransport(
|
||||
options: SdkControlServerTransportOptions,
|
||||
): SdkControlServerTransport {
|
||||
return new SdkControlServerTransport(options);
|
||||
}
|
||||
109
packages/sdk-typescript/src/mcp/createSdkMcpServer.ts
Normal file
109
packages/sdk-typescript/src/mcp/createSdkMcpServer.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Factory function to create SDK-embedded MCP servers
|
||||
*
|
||||
* Creates MCP Server instances that run in the user's Node.js process
|
||||
* and are proxied to the CLI via the control plane.
|
||||
*/
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import {
|
||||
ListToolsRequestSchema,
|
||||
CallToolRequestSchema,
|
||||
type CallToolResultSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { ToolDefinition } from '../types/types.js';
|
||||
import { formatToolResult, formatToolError } from './formatters.js';
|
||||
import { validateToolName } from './tool.js';
|
||||
import type { z } from 'zod';
|
||||
|
||||
type CallToolResult = z.infer<typeof CallToolResultSchema>;
|
||||
|
||||
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 () => ({
|
||||
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;
|
||||
}
|
||||
194
packages/sdk-typescript/src/mcp/formatters.ts
Normal file
194
packages/sdk-typescript/src/mcp/formatters.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Tool result formatting utilities for MCP responses
|
||||
*
|
||||
* Converts various output types to MCP content blocks.
|
||||
*/
|
||||
|
||||
export type McpContentBlock =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'image'; data: string; mimeType: string }
|
||||
| { type: 'resource'; uri: string; mimeType?: string; text?: string };
|
||||
|
||||
export interface ToolResult {
|
||||
content: McpContentBlock[];
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
export function formatToolResult(result: unknown): ToolResult {
|
||||
// Handle Error objects
|
||||
if (result instanceof Error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: result.message || 'Unknown error',
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle null/undefined
|
||||
if (result === null || result === undefined) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Handle string
|
||||
if (typeof result === 'string') {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: result,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Handle number
|
||||
if (typeof result === 'number') {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: String(result),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Handle boolean
|
||||
if (typeof result === 'boolean') {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: String(result),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Handle object (including arrays)
|
||||
if (typeof result === 'object') {
|
||||
try {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(result, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch {
|
||||
// JSON.stringify failed
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: String(result),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: convert to string
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: String(result),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function formatToolError(error: Error | string): ToolResult {
|
||||
const message = error instanceof Error ? error.message : error;
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: message,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatTextResult(text: string): ToolResult {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function formatJsonResult(data: unknown): ToolResult {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeToolResults(results: ToolResult[]): ToolResult {
|
||||
const mergedContent: McpContentBlock[] = [];
|
||||
let hasError = false;
|
||||
|
||||
for (const result of results) {
|
||||
mergedContent.push(...result.content);
|
||||
if (result.isError) {
|
||||
hasError = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: mergedContent,
|
||||
isError: hasError,
|
||||
};
|
||||
}
|
||||
|
||||
export function isValidContentBlock(block: unknown): block is McpContentBlock {
|
||||
if (!block || typeof block !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const blockObj = block as Record<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;
|
||||
}
|
||||
}
|
||||
91
packages/sdk-typescript/src/mcp/tool.ts
Normal file
91
packages/sdk-typescript/src/mcp/tool.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Tool definition helper for SDK-embedded MCP servers
|
||||
*
|
||||
* Provides type-safe tool definitions with generic input/output types.
|
||||
*/
|
||||
|
||||
import type { ToolDefinition } from '../types/types.js';
|
||||
|
||||
export function tool<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;
|
||||
}
|
||||
|
||||
export function validateToolName(name: string): void {
|
||||
if (!name) {
|
||||
throw new Error('Tool name cannot be empty');
|
||||
}
|
||||
|
||||
if (name.length > 64) {
|
||||
throw new Error(
|
||||
`Tool name '${name}' is too long (max 64 characters): ${name.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(name)) {
|
||||
throw new Error(
|
||||
`Tool name '${name}' is invalid. Must start with a letter and contain only letters, numbers, and underscores.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateInputSchema(schema: unknown): void {
|
||||
if (!schema || typeof schema !== 'object') {
|
||||
throw new Error('Input schema must be an object');
|
||||
}
|
||||
|
||||
const schemaObj = schema as Record<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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
738
packages/sdk-typescript/src/query/Query.ts
Normal file
738
packages/sdk-typescript/src/query/Query.ts
Normal file
@@ -0,0 +1,738 @@
|
||||
/**
|
||||
* Query class - Main orchestrator for SDK
|
||||
*
|
||||
* Manages SDK workflow, routes messages, and handles lifecycle.
|
||||
* Implements AsyncIterator protocol for message consumption.
|
||||
*/
|
||||
|
||||
const PERMISSION_CALLBACK_TIMEOUT = 30000;
|
||||
const MCP_REQUEST_TIMEOUT = 30000;
|
||||
const CONTROL_REQUEST_TIMEOUT = 30000;
|
||||
const STREAM_CLOSE_TIMEOUT = 10000;
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type {
|
||||
CLIMessage,
|
||||
CLIUserMessage,
|
||||
CLIControlRequest,
|
||||
CLIControlResponse,
|
||||
ControlCancelRequest,
|
||||
PermissionSuggestion,
|
||||
} from '../types/protocol.js';
|
||||
import {
|
||||
isCLIUserMessage,
|
||||
isCLIAssistantMessage,
|
||||
isCLISystemMessage,
|
||||
isCLIResultMessage,
|
||||
isCLIPartialAssistantMessage,
|
||||
isControlRequest,
|
||||
isControlResponse,
|
||||
isControlCancel,
|
||||
} from '../types/protocol.js';
|
||||
import type { Transport } from '../transport/Transport.js';
|
||||
import { type QueryOptions } from '../types/queryOptionsSchema.js';
|
||||
import { Stream } from '../utils/Stream.js';
|
||||
import { serializeJsonLine } from '../utils/jsonLines.js';
|
||||
import { AbortError } from '../types/errors.js';
|
||||
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { SdkControlServerTransport } from '../mcp/SdkControlServerTransport.js';
|
||||
import { ControlRequestType } from '../types/protocol.js';
|
||||
|
||||
interface PendingControlRequest {
|
||||
resolve: (response: Record<string, unknown> | null) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: NodeJS.Timeout;
|
||||
abortController: AbortController;
|
||||
}
|
||||
|
||||
interface TransportWithEndInput extends Transport {
|
||||
endInput(): void;
|
||||
}
|
||||
|
||||
export class Query implements AsyncIterable<CLIMessage> {
|
||||
private transport: Transport;
|
||||
private options: QueryOptions;
|
||||
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();
|
||||
readonly initialized: Promise<void>;
|
||||
private closed = false;
|
||||
private messageRouterStarted = false;
|
||||
|
||||
private firstResultReceivedPromise?: Promise<void>;
|
||||
private firstResultReceivedResolve?: () => void;
|
||||
|
||||
private readonly isSingleTurn: boolean;
|
||||
|
||||
constructor(
|
||||
transport: Transport,
|
||||
options: QueryOptions,
|
||||
singleTurn: boolean = false,
|
||||
) {
|
||||
this.transport = transport;
|
||||
this.options = options;
|
||||
this.sessionId = randomUUID();
|
||||
this.inputStream = new Stream<CLIMessage>();
|
||||
this.abortController = options.abortController ?? new AbortController();
|
||||
this.isSingleTurn = singleTurn;
|
||||
|
||||
/**
|
||||
* Create async generator proxy to ensure stream.next() is called at least once.
|
||||
* The generator will start iterating when the user begins iteration.
|
||||
* This ensures readResolve/readReject are set up as soon as iteration starts.
|
||||
* If errors occur before iteration starts, they'll be stored in hasError and
|
||||
* properly rejected when the user starts iterating.
|
||||
*/
|
||||
this.sdkMessages = this.readSdkMessages();
|
||||
|
||||
this.firstResultReceivedPromise = new Promise((resolve) => {
|
||||
this.firstResultReceivedResolve = resolve;
|
||||
});
|
||||
|
||||
/**
|
||||
* Handle abort signal if controller is provided and already aborted or will be aborted.
|
||||
* If already aborted, set error immediately. Otherwise, listen for abort events
|
||||
* and set abort error on the stream before closing.
|
||||
*/
|
||||
if (this.abortController.signal.aborted) {
|
||||
this.inputStream.error(new AbortError('Query aborted by user'));
|
||||
this.close().catch((err) => {
|
||||
console.error('[Query] Error during abort cleanup:', err);
|
||||
});
|
||||
} else {
|
||||
this.abortController.signal.addEventListener('abort', () => {
|
||||
this.inputStream.error(new AbortError('Query aborted by user'));
|
||||
this.close().catch((err) => {
|
||||
console.error('[Query] Error during abort cleanup:', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.initialized = this.initialize();
|
||||
this.initialized.catch(() => {});
|
||||
|
||||
this.startMessageRouter();
|
||||
}
|
||||
|
||||
private async initialize(): Promise<void> {
|
||||
try {
|
||||
await this.setupSdkMcpServers();
|
||||
|
||||
const sdkMcpServerNames = Array.from(this.sdkMcpTransports.keys());
|
||||
|
||||
await this.sendControlRequest(ControlRequestType.INITIALIZE, {
|
||||
hooks: null,
|
||||
sdkMcpServers:
|
||||
sdkMcpServerNames.length > 0 ? sdkMcpServerNames : undefined,
|
||||
mcpServers: this.options.mcpServers,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Query] Initialization error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async setupSdkMcpServers(): Promise<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);
|
||||
}
|
||||
}
|
||||
|
||||
private startMessageRouter(): void {
|
||||
if (this.messageRouterStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.messageRouterStarted = true;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
for await (const message of this.transport.readMessages()) {
|
||||
await this.routeMessage(message);
|
||||
|
||||
if (this.closed) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.abortController.signal.aborted) {
|
||||
this.inputStream.error(new AbortError('Query aborted'));
|
||||
} else {
|
||||
this.inputStream.done();
|
||||
}
|
||||
} catch (error) {
|
||||
this.inputStream.error(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
private async routeMessage(message: unknown): Promise<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']) {
|
||||
console.warn('[Query] Unknown message type:', message);
|
||||
}
|
||||
this.inputStream.enqueue(message as CLIMessage);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'mcp_message':
|
||||
response = await this.handleMcpMessage(
|
||||
payload.server_name,
|
||||
payload.message as unknown as JSONRPCMessage,
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown control request subtype: ${payload.subtype}`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.sendControlResponse(request_id, true, response);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
await this.sendControlResponse(request_id, false, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePermissionRequest(
|
||||
toolName: string,
|
||||
toolInput: Record<string, unknown>,
|
||||
permissionSuggestions: PermissionSuggestion[] | null,
|
||||
signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
/* Default deny all wildcard tool requests */
|
||||
if (!this.options.canUseTool) {
|
||||
return { behavior: 'deny', message: 'Denied' };
|
||||
}
|
||||
|
||||
try {
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(
|
||||
() => reject(new Error('Permission callback timeout')),
|
||||
PERMISSION_CALLBACK_TIMEOUT,
|
||||
);
|
||||
});
|
||||
|
||||
const result = await Promise.race([
|
||||
Promise.resolve(
|
||||
this.options.canUseTool(toolName, toolInput, {
|
||||
signal,
|
||||
suggestions: permissionSuggestions,
|
||||
}),
|
||||
),
|
||||
timeoutPromise,
|
||||
]);
|
||||
|
||||
// Handle boolean return (backward compatibility)
|
||||
if (typeof result === 'boolean') {
|
||||
return result
|
||||
? { behavior: 'allow', updatedInput: toolInput }
|
||||
: { behavior: 'deny', message: 'Denied' };
|
||||
}
|
||||
|
||||
// Handle PermissionResult format
|
||||
const permissionResult = result as {
|
||||
behavior: 'allow' | 'deny';
|
||||
updatedInput?: Record<string, unknown>;
|
||||
message?: string;
|
||||
interrupt?: boolean;
|
||||
};
|
||||
|
||||
if (permissionResult.behavior === 'allow') {
|
||||
return {
|
||||
behavior: 'allow',
|
||||
updatedInput: permissionResult.updatedInput ?? toolInput,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
behavior: 'deny',
|
||||
message: permissionResult.message ?? 'Denied',
|
||||
...(permissionResult.interrupt !== undefined
|
||||
? { interrupt: permissionResult.interrupt }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
/**
|
||||
* Timeout or error → deny (fail-safe).
|
||||
* This ensures that any issues with the permission callback
|
||||
* result in a safe default of denying access.
|
||||
*/
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
console.warn(
|
||||
'[Query] Permission callback error (denying by default):',
|
||||
errorMessage,
|
||||
);
|
||||
return {
|
||||
behavior: 'deny',
|
||||
message: `Permission check failed: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMcpMessage(
|
||||
serverName: string,
|
||||
message: JSONRPCMessage,
|
||||
): Promise<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 } };
|
||||
}
|
||||
}
|
||||
|
||||
private handleMcpRequest(
|
||||
_serverName: string,
|
||||
message: JSONRPCMessage,
|
||||
transport: SdkControlServerTransport,
|
||||
): Promise<JSONRPCMessage> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('MCP request timeout'));
|
||||
}, MCP_REQUEST_TIMEOUT);
|
||||
|
||||
const messageId = 'id' in message ? message.id : null;
|
||||
|
||||
/**
|
||||
* Hook into transport to capture response.
|
||||
* Temporarily replace sendToQuery to intercept the response message
|
||||
* matching this request's ID, then restore the original handler.
|
||||
*/
|
||||
const originalSend = transport.sendToQuery;
|
||||
transport.sendToQuery = async (responseMessage: JSONRPCMessage) => {
|
||||
if ('id' in responseMessage && responseMessage.id === messageId) {
|
||||
clearTimeout(timeout);
|
||||
transport.sendToQuery = originalSend;
|
||||
resolve(responseMessage);
|
||||
}
|
||||
return originalSend(responseMessage);
|
||||
};
|
||||
|
||||
transport.handleMessage(message);
|
||||
});
|
||||
}
|
||||
|
||||
private handleControlResponse(response: CLIControlResponse): void {
|
||||
const { response: payload } = response;
|
||||
const request_id = payload.request_id;
|
||||
|
||||
const pending = this.pendingControlRequests.get(request_id);
|
||||
if (!pending) {
|
||||
console.warn(
|
||||
'[Query] Received response for unknown request:',
|
||||
request_id,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(pending.timeout);
|
||||
this.pendingControlRequests.delete(request_id);
|
||||
|
||||
if (payload.subtype === 'success') {
|
||||
pending.resolve(payload.response as Record<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));
|
||||
}
|
||||
}
|
||||
|
||||
private handleControlCancelRequest(request: ControlCancelRequest): void {
|
||||
const { request_id } = request;
|
||||
|
||||
if (!request_id) {
|
||||
console.warn('[Query] Received cancel request without request_id');
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = this.pendingControlRequests.get(request_id);
|
||||
if (pending) {
|
||||
pending.abortController.abort();
|
||||
clearTimeout(pending.timeout);
|
||||
this.pendingControlRequests.delete(request_id);
|
||||
pending.reject(new AbortError('Request cancelled'));
|
||||
}
|
||||
}
|
||||
|
||||
private async sendControlRequest(
|
||||
subtype: string,
|
||||
data: Record<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}`));
|
||||
}, CONTROL_REQUEST_TIMEOUT);
|
||||
|
||||
this.pendingControlRequests.set(requestId, {
|
||||
resolve,
|
||||
reject,
|
||||
timeout,
|
||||
abortController,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
this.transport.write(serializeJsonLine(request));
|
||||
return responsePromise;
|
||||
}
|
||||
|
||||
private async sendControlResponse(
|
||||
requestId: string,
|
||||
success: boolean,
|
||||
responseOrError: Record<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));
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
await this.transport.close();
|
||||
|
||||
/**
|
||||
* Complete input stream - check if aborted first.
|
||||
* Only set error/done if stream doesn't already have an error state.
|
||||
*/
|
||||
if (this.inputStream.hasError === undefined) {
|
||||
if (this.abortController.signal.aborted) {
|
||||
this.inputStream.error(new AbortError('Query aborted'));
|
||||
} else {
|
||||
this.inputStream.done();
|
||||
}
|
||||
}
|
||||
|
||||
for (const transport of this.sdkMcpTransports.values()) {
|
||||
try {
|
||||
await transport.close();
|
||||
} catch (error) {
|
||||
console.error('[Query] Error closing MCP transport:', error);
|
||||
}
|
||||
}
|
||||
this.sdkMcpTransports.clear();
|
||||
}
|
||||
|
||||
private async *readSdkMessages(): AsyncGenerator<CLIMessage> {
|
||||
for await (const message of this.inputStream) {
|
||||
yield message;
|
||||
}
|
||||
}
|
||||
|
||||
async next(...args: [] | [unknown]): Promise<IteratorResult<CLIMessage>> {
|
||||
return this.sdkMessages.next(...args);
|
||||
}
|
||||
|
||||
async return(value?: unknown): Promise<IteratorResult<CLIMessage>> {
|
||||
return this.sdkMessages.return(value);
|
||||
}
|
||||
|
||||
async throw(e?: unknown): Promise<IteratorResult<CLIMessage>> {
|
||||
return this.sdkMessages.throw(e);
|
||||
}
|
||||
|
||||
[Symbol.asyncIterator](): AsyncIterator<CLIMessage> {
|
||||
return this.sdkMessages;
|
||||
}
|
||||
|
||||
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
|
||||
) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
endInput(): void {
|
||||
if (this.closed) {
|
||||
throw new Error('Query is closed');
|
||||
}
|
||||
|
||||
if (
|
||||
'endInput' in this.transport &&
|
||||
typeof this.transport.endInput === 'function'
|
||||
) {
|
||||
(this.transport as TransportWithEndInput).endInput();
|
||||
}
|
||||
}
|
||||
|
||||
async interrupt(): Promise<void> {
|
||||
if (this.closed) {
|
||||
throw new Error('Query is closed');
|
||||
}
|
||||
|
||||
await this.sendControlRequest(ControlRequestType.INTERRUPT);
|
||||
}
|
||||
|
||||
async setPermissionMode(mode: string): Promise<void> {
|
||||
if (this.closed) {
|
||||
throw new Error('Query is closed');
|
||||
}
|
||||
|
||||
await this.sendControlRequest(ControlRequestType.SET_PERMISSION_MODE, {
|
||||
mode,
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
getSessionId(): string {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
isClosed(): boolean {
|
||||
return this.closed;
|
||||
}
|
||||
}
|
||||
139
packages/sdk-typescript/src/query/createQuery.ts
Normal file
139
packages/sdk-typescript/src/query/createQuery.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Factory function for creating Query instances.
|
||||
*/
|
||||
|
||||
import type { CLIUserMessage } from '../types/protocol.js';
|
||||
import { serializeJsonLine } from '../utils/jsonLines.js';
|
||||
import { ProcessTransport } from '../transport/ProcessTransport.js';
|
||||
import { parseExecutableSpec } from '../utils/cliPath.js';
|
||||
import { Query } from './Query.js';
|
||||
import {
|
||||
QueryOptionsSchema,
|
||||
type QueryOptions,
|
||||
} from '../types/queryOptionsSchema.js';
|
||||
|
||||
export type { QueryOptions };
|
||||
|
||||
export function query({
|
||||
prompt,
|
||||
options = {},
|
||||
}: {
|
||||
prompt: string | AsyncIterable<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';
|
||||
|
||||
// Resolve CLI specification while preserving explicit runtime directives
|
||||
const pathToQwenExecutable =
|
||||
options.pathToQwenExecutable ?? parsedExecutable.executablePath;
|
||||
|
||||
// Use provided abortController or create a new one
|
||||
const abortController = options.abortController ?? new AbortController();
|
||||
|
||||
// Create transport with abortController
|
||||
const transport = new ProcessTransport({
|
||||
pathToQwenExecutable,
|
||||
cwd: options.cwd,
|
||||
model: options.model,
|
||||
permissionMode: options.permissionMode,
|
||||
mcpServers: options.mcpServers,
|
||||
env: options.env,
|
||||
abortController,
|
||||
debug: options.debug,
|
||||
stderr: options.stderr,
|
||||
maxSessionTurns: options.maxSessionTurns,
|
||||
coreTools: options.coreTools,
|
||||
excludeTools: options.excludeTools,
|
||||
authType: options.authType,
|
||||
});
|
||||
|
||||
// Build query options with abortController
|
||||
const queryOptions: QueryOptions = {
|
||||
...options,
|
||||
abortController,
|
||||
};
|
||||
|
||||
// Create Query
|
||||
const queryInstance = new Query(transport, queryOptions, isSingleTurn);
|
||||
|
||||
// Handle prompt based on type
|
||||
if (isSingleTurn) {
|
||||
// For single-turn queries, send the prompt directly via transport
|
||||
const stringPrompt = prompt as string;
|
||||
const message: CLIUserMessage = {
|
||||
type: 'user',
|
||||
session_id: queryInstance.getSessionId(),
|
||||
message: {
|
||||
role: 'user',
|
||||
content: stringPrompt,
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await queryInstance.initialized;
|
||||
transport.write(serializeJsonLine(message));
|
||||
} catch (err) {
|
||||
console.error('[query] Error sending single-turn prompt:', err);
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
queryInstance
|
||||
.streamInput(prompt as AsyncIterable<CLIUserMessage>)
|
||||
.catch((err) => {
|
||||
console.error('[query] Error streaming input:', err);
|
||||
});
|
||||
}
|
||||
|
||||
return queryInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward compatibility alias
|
||||
* @deprecated Use query() instead
|
||||
*/
|
||||
export const createQuery = query;
|
||||
|
||||
function validateOptions(
|
||||
options: QueryOptions,
|
||||
): ReturnType<typeof parseExecutableSpec> {
|
||||
// Validate options using Zod schema
|
||||
const validationResult = QueryOptionsSchema.safeParse(options);
|
||||
if (!validationResult.success) {
|
||||
const errors = validationResult.error.errors
|
||||
.map((err) => `${err.path.join('.')}: ${err.message}`)
|
||||
.join('; ');
|
||||
throw new Error(`Invalid QueryOptions: ${errors}`);
|
||||
}
|
||||
|
||||
// Validate executable path early to provide clear error messages
|
||||
let parsedExecutable: ReturnType<typeof parseExecutableSpec>;
|
||||
try {
|
||||
parsedExecutable = parseExecutableSpec(options.pathToQwenExecutable);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Invalid pathToQwenExecutable: ${errorMessage}`);
|
||||
}
|
||||
|
||||
// Validate no MCP server name conflicts (cross-field validation not easily expressible in Zod)
|
||||
if (options.mcpServers && options.sdkMcpServers) {
|
||||
const externalNames = Object.keys(options.mcpServers);
|
||||
const sdkNames = Object.keys(options.sdkMcpServers);
|
||||
|
||||
const conflicts = externalNames.filter((name) => sdkNames.includes(name));
|
||||
if (conflicts.length > 0) {
|
||||
throw new Error(
|
||||
`MCP server name conflicts between mcpServers and sdkMcpServers: ${conflicts.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return parsedExecutable;
|
||||
}
|
||||
392
packages/sdk-typescript/src/transport/ProcessTransport.ts
Normal file
392
packages/sdk-typescript/src/transport/ProcessTransport.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import * as readline from 'node:readline';
|
||||
import type { Writable, Readable } from 'node:stream';
|
||||
import type { TransportOptions } from '../types/types.js';
|
||||
import type { Transport } from './Transport.js';
|
||||
import { parseJsonLinesStream } from '../utils/jsonLines.js';
|
||||
import { prepareSpawnInfo } from '../utils/cliPath.js';
|
||||
import { AbortError } from '../types/errors.js';
|
||||
|
||||
type ExitListener = {
|
||||
callback: (error?: Error) => void;
|
||||
handler: (code: number | null, signal: NodeJS.Signals | null) => void;
|
||||
};
|
||||
|
||||
export class ProcessTransport implements Transport {
|
||||
private childProcess: ChildProcess | null = null;
|
||||
private childStdin: Writable | null = null;
|
||||
private childStdout: Readable | null = null;
|
||||
private options: TransportOptions;
|
||||
private ready = false;
|
||||
private _exitError: Error | null = null;
|
||||
private closed = false;
|
||||
private abortController: AbortController;
|
||||
private exitListeners: ExitListener[] = [];
|
||||
private processExitHandler: (() => void) | null = null;
|
||||
private abortHandler: (() => void) | null = null;
|
||||
|
||||
constructor(options: TransportOptions) {
|
||||
this.options = options;
|
||||
this.abortController =
|
||||
this.options.abortController ?? new AbortController();
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
private initialize(): void {
|
||||
try {
|
||||
if (this.abortController.signal.aborted) {
|
||||
throw new AbortError('Transport start aborted');
|
||||
}
|
||||
|
||||
const cliArgs = this.buildCliArguments();
|
||||
const cwd = this.options.cwd ?? process.cwd();
|
||||
const env = { ...process.env, ...this.options.env };
|
||||
|
||||
const spawnInfo = prepareSpawnInfo(this.options.pathToQwenExecutable);
|
||||
|
||||
const stderrMode =
|
||||
this.options.debug || this.options.stderr ? 'pipe' : 'ignore';
|
||||
|
||||
this.logForDebugging(
|
||||
`Spawning CLI (${spawnInfo.type}): ${spawnInfo.command} ${[...spawnInfo.args, ...cliArgs].join(' ')}`,
|
||||
);
|
||||
|
||||
this.childProcess = spawn(
|
||||
spawnInfo.command,
|
||||
[...spawnInfo.args, ...cliArgs],
|
||||
{
|
||||
cwd,
|
||||
env,
|
||||
stdio: ['pipe', 'pipe', stderrMode],
|
||||
signal: this.abortController.signal,
|
||||
},
|
||||
);
|
||||
|
||||
this.childStdin = this.childProcess.stdin;
|
||||
this.childStdout = this.childProcess.stdout;
|
||||
|
||||
if (this.options.debug || this.options.stderr) {
|
||||
this.childProcess.stderr?.on('data', (data) => {
|
||||
this.logForDebugging(data.toString());
|
||||
});
|
||||
}
|
||||
|
||||
const cleanup = (): void => {
|
||||
if (this.childProcess && !this.childProcess.killed) {
|
||||
this.childProcess.kill('SIGTERM');
|
||||
}
|
||||
};
|
||||
|
||||
this.processExitHandler = cleanup;
|
||||
this.abortHandler = cleanup;
|
||||
process.on('exit', this.processExitHandler);
|
||||
this.abortController.signal.addEventListener('abort', this.abortHandler);
|
||||
|
||||
this.setupEventHandlers();
|
||||
|
||||
this.ready = true;
|
||||
} catch (error) {
|
||||
this.ready = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private setupEventHandlers(): void {
|
||||
if (!this.childProcess) return;
|
||||
|
||||
this.childProcess.on('error', (error) => {
|
||||
this.ready = false;
|
||||
if (this.abortController.signal.aborted) {
|
||||
this._exitError = new AbortError('CLI process aborted by user');
|
||||
} else {
|
||||
this._exitError = new Error(`CLI process error: ${error.message}`);
|
||||
this.logForDebugging(this._exitError.message);
|
||||
}
|
||||
});
|
||||
|
||||
this.childProcess.on('close', (code, signal) => {
|
||||
this.ready = false;
|
||||
if (this.abortController.signal.aborted) {
|
||||
this._exitError = new AbortError('CLI process aborted by user');
|
||||
} else {
|
||||
const error = this.getProcessExitError(code, signal);
|
||||
if (error) {
|
||||
this._exitError = error;
|
||||
this.logForDebugging(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
const error = this._exitError;
|
||||
for (const listener of this.exitListeners) {
|
||||
try {
|
||||
listener.callback(error || undefined);
|
||||
} catch (err) {
|
||||
this.logForDebugging(`Exit listener error: ${err}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getProcessExitError(
|
||||
code: number | null,
|
||||
signal: NodeJS.Signals | null,
|
||||
): Error | undefined {
|
||||
if (code !== 0 && code !== null) {
|
||||
return new Error(`CLI process exited with code ${code}`);
|
||||
} else if (signal) {
|
||||
return new Error(`CLI process terminated by signal ${signal}`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
private buildCliArguments(): string[] {
|
||||
const args: string[] = [
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
];
|
||||
|
||||
if (this.options.model) {
|
||||
args.push('--model', this.options.model);
|
||||
}
|
||||
|
||||
if (this.options.permissionMode) {
|
||||
args.push('--approval-mode', this.options.permissionMode);
|
||||
}
|
||||
|
||||
if (this.options.maxSessionTurns !== undefined) {
|
||||
args.push('--max-session-turns', String(this.options.maxSessionTurns));
|
||||
}
|
||||
|
||||
if (this.options.coreTools && this.options.coreTools.length > 0) {
|
||||
args.push('--core-tools', this.options.coreTools.join(','));
|
||||
}
|
||||
|
||||
if (this.options.excludeTools && this.options.excludeTools.length > 0) {
|
||||
args.push('--exclude-tools', this.options.excludeTools.join(','));
|
||||
}
|
||||
|
||||
if (this.options.authType) {
|
||||
args.push('--auth-type', this.options.authType);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
async close(): Promise<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.childStdin.writableEnded) {
|
||||
throw new Error('Cannot write to ended stream');
|
||||
}
|
||||
|
||||
if (this.childProcess?.killed || this.childProcess?.exitCode !== null) {
|
||||
throw new Error('Cannot write to terminated process');
|
||||
}
|
||||
|
||||
if (this._exitError) {
|
||||
throw new Error(
|
||||
`Cannot write to process that exited with error: ${this._exitError.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (process.env['DEBUG']) {
|
||||
this.logForDebugging(
|
||||
`[ProcessTransport] Writing to stdin (${message.length} bytes): ${message.substring(0, 100)}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const written = this.childStdin.write(message);
|
||||
if (!written) {
|
||||
this.logForDebugging(
|
||||
`[ProcessTransport] Write buffer full (${message.length} bytes), data queued. Waiting for drain event...`,
|
||||
);
|
||||
} else if (process.env['DEBUG']) {
|
||||
this.logForDebugging(
|
||||
`[ProcessTransport] Write successful (${message.length} bytes)`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.ready = false;
|
||||
throw new Error(
|
||||
`Failed to write to stdin: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async *readMessages(): AsyncGenerator<unknown, void, unknown> {
|
||||
if (!this.childStdout) {
|
||||
throw new Error('Cannot read messages: process not started');
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: this.childStdout,
|
||||
crlfDelay: Infinity,
|
||||
terminal: false,
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const message of parseJsonLinesStream(
|
||||
rl,
|
||||
'ProcessTransport',
|
||||
)) {
|
||||
yield message;
|
||||
}
|
||||
|
||||
await this.waitForExit();
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
get isReady(): boolean {
|
||||
return this.ready;
|
||||
}
|
||||
|
||||
get exitError(): Error | null {
|
||||
return this._exitError;
|
||||
}
|
||||
|
||||
onExit(callback: (error?: Error) => void): () => void {
|
||||
if (!this.childProcess) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const handler = (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
const error = this.getProcessExitError(code, signal);
|
||||
callback(error);
|
||||
};
|
||||
|
||||
this.childProcess.on('close', handler);
|
||||
this.exitListeners.push({ callback, handler });
|
||||
|
||||
return () => {
|
||||
if (this.childProcess) {
|
||||
this.childProcess.off('close', handler);
|
||||
}
|
||||
const index = this.exitListeners.findIndex((l) => l.handler === handler);
|
||||
if (index !== -1) {
|
||||
this.exitListeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
endInput(): void {
|
||||
if (this.childStdin) {
|
||||
this.childStdin.end();
|
||||
}
|
||||
}
|
||||
|
||||
getInputStream(): Writable | undefined {
|
||||
return this.childStdin || undefined;
|
||||
}
|
||||
|
||||
getOutputStream(): Readable | undefined {
|
||||
return this.childStdout || undefined;
|
||||
}
|
||||
|
||||
private logForDebugging(message: string): void {
|
||||
if (this.options.debug || process.env['DEBUG']) {
|
||||
process.stderr.write(`[ProcessTransport] ${message}\n`);
|
||||
}
|
||||
if (this.options.stderr) {
|
||||
this.options.stderr(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
22
packages/sdk-typescript/src/transport/Transport.ts
Normal file
22
packages/sdk-typescript/src/transport/Transport.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Transport interface for SDK-CLI communication
|
||||
*
|
||||
* The Transport abstraction enables communication between SDK and CLI via different mechanisms:
|
||||
* - ProcessTransport: Local subprocess via stdin/stdout (initial implementation)
|
||||
* - HttpTransport: Remote CLI via HTTP (future)
|
||||
* - WebSocketTransport: Remote CLI via WebSocket (future)
|
||||
*/
|
||||
|
||||
export interface Transport {
|
||||
close(): Promise<void>;
|
||||
|
||||
waitForExit(): Promise<void>;
|
||||
|
||||
write(message: string): void;
|
||||
|
||||
readMessages(): AsyncGenerator<unknown, void, unknown>;
|
||||
|
||||
readonly isReady: boolean;
|
||||
|
||||
readonly exitError: Error | null;
|
||||
}
|
||||
17
packages/sdk-typescript/src/types/errors.ts
Normal file
17
packages/sdk-typescript/src/types/errors.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export class AbortError extends Error {
|
||||
constructor(message = 'Operation aborted') {
|
||||
super(message);
|
||||
this.name = 'AbortError';
|
||||
Object.setPrototypeOf(this, AbortError.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
export function isAbortError(error: unknown): error is AbortError {
|
||||
return (
|
||||
error instanceof AbortError ||
|
||||
(typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'name' in error &&
|
||||
error.name === 'AbortError')
|
||||
);
|
||||
}
|
||||
560
packages/sdk-typescript/src/types/protocol.ts
Normal file
560
packages/sdk-typescript/src/types/protocol.ts
Normal file
@@ -0,0 +1,560 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export interface Annotation {
|
||||
type: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface Usage {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_creation_input_tokens?: number;
|
||||
cache_read_input_tokens?: number;
|
||||
total_tokens?: number;
|
||||
}
|
||||
|
||||
export interface ExtendedUsage extends Usage {
|
||||
server_tool_use?: {
|
||||
web_search_requests: number;
|
||||
};
|
||||
service_tier?: string;
|
||||
cache_creation?: {
|
||||
ephemeral_1h_input_tokens: number;
|
||||
ephemeral_5m_input_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ModelUsage {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadInputTokens: number;
|
||||
cacheCreationInputTokens: number;
|
||||
webSearchRequests: number;
|
||||
contextWindow: number;
|
||||
}
|
||||
|
||||
export interface CLIPermissionDenial {
|
||||
tool_name: string;
|
||||
tool_use_id: string;
|
||||
tool_input: unknown;
|
||||
}
|
||||
|
||||
export interface TextBlock {
|
||||
type: 'text';
|
||||
text: string;
|
||||
annotations?: Annotation[];
|
||||
}
|
||||
|
||||
export interface ThinkingBlock {
|
||||
type: 'thinking';
|
||||
thinking: string;
|
||||
signature?: string;
|
||||
annotations?: Annotation[];
|
||||
}
|
||||
|
||||
export interface ToolUseBlock {
|
||||
type: 'tool_use';
|
||||
id: string;
|
||||
name: string;
|
||||
input: unknown;
|
||||
annotations?: Annotation[];
|
||||
}
|
||||
|
||||
export interface ToolResultBlock {
|
||||
type: 'tool_result';
|
||||
tool_use_id: string;
|
||||
content?: string | ContentBlock[];
|
||||
is_error?: boolean;
|
||||
annotations?: Annotation[];
|
||||
}
|
||||
|
||||
export type ContentBlock =
|
||||
| TextBlock
|
||||
| ThinkingBlock
|
||||
| ToolUseBlock
|
||||
| ToolResultBlock;
|
||||
|
||||
export interface APIUserMessage {
|
||||
role: 'user';
|
||||
content: string | ContentBlock[];
|
||||
}
|
||||
|
||||
export interface APIAssistantMessage {
|
||||
id: string;
|
||||
type: 'message';
|
||||
role: 'assistant';
|
||||
model: string;
|
||||
content: ContentBlock[];
|
||||
stop_reason?: string | null;
|
||||
usage: Usage;
|
||||
}
|
||||
|
||||
export interface CLIUserMessage {
|
||||
type: 'user';
|
||||
uuid?: string;
|
||||
session_id: string;
|
||||
message: APIUserMessage;
|
||||
parent_tool_use_id: string | null;
|
||||
options?: Record<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;
|
||||
|
||||
export interface MessageStartStreamEvent {
|
||||
type: 'message_start';
|
||||
message: {
|
||||
id: string;
|
||||
role: 'assistant';
|
||||
model: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContentBlockStartEvent {
|
||||
type: 'content_block_start';
|
||||
index: number;
|
||||
content_block: ContentBlock;
|
||||
}
|
||||
|
||||
export type ContentBlockDelta =
|
||||
| {
|
||||
type: 'text_delta';
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type: 'thinking_delta';
|
||||
thinking: string;
|
||||
}
|
||||
| {
|
||||
type: 'input_json_delta';
|
||||
partial_json: string;
|
||||
};
|
||||
|
||||
export interface ContentBlockDeltaEvent {
|
||||
type: 'content_block_delta';
|
||||
index: number;
|
||||
delta: ContentBlockDelta;
|
||||
}
|
||||
|
||||
export interface ContentBlockStopEvent {
|
||||
type: 'content_block_stop';
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface MessageStopStreamEvent {
|
||||
type: 'message_stop';
|
||||
}
|
||||
|
||||
export type StreamEvent =
|
||||
| MessageStartStreamEvent
|
||||
| ContentBlockStartEvent
|
||||
| ContentBlockDeltaEvent
|
||||
| ContentBlockStopEvent
|
||||
| MessageStopStreamEvent;
|
||||
|
||||
export interface CLIPartialAssistantMessage {
|
||||
type: 'stream_event';
|
||||
uuid: string;
|
||||
session_id: string;
|
||||
event: StreamEvent;
|
||||
parent_tool_use_id: string | null;
|
||||
}
|
||||
|
||||
export type PermissionMode = 'default' | 'plan' | 'auto-edit' | 'yolo';
|
||||
|
||||
/**
|
||||
* TODO: Align with `ToolCallConfirmationDetails`
|
||||
*/
|
||||
export interface PermissionSuggestion {
|
||||
type: 'allow' | 'deny' | 'modify';
|
||||
label: string;
|
||||
description?: string;
|
||||
modifiedInput?: unknown;
|
||||
}
|
||||
|
||||
export interface HookRegistration {
|
||||
event: string;
|
||||
callback_id: string;
|
||||
}
|
||||
|
||||
export interface HookCallbackResult {
|
||||
shouldSkip?: boolean;
|
||||
shouldInterrupt?: boolean;
|
||||
suppressOutput?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface CLIControlInterruptRequest {
|
||||
subtype: 'interrupt';
|
||||
}
|
||||
|
||||
export interface CLIControlPermissionRequest {
|
||||
subtype: 'can_use_tool';
|
||||
tool_name: string;
|
||||
tool_use_id: string;
|
||||
input: unknown;
|
||||
permission_suggestions: PermissionSuggestion[] | null;
|
||||
blocked_path: string | null;
|
||||
}
|
||||
|
||||
export enum AuthProviderType {
|
||||
DYNAMIC_DISCOVERY = 'dynamic_discovery',
|
||||
GOOGLE_CREDENTIALS = 'google_credentials',
|
||||
SERVICE_ACCOUNT_IMPERSONATION = 'service_account_impersonation',
|
||||
}
|
||||
|
||||
export interface MCPServerConfig {
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
cwd?: string;
|
||||
url?: string;
|
||||
httpUrl?: string;
|
||||
headers?: Record<string, string>;
|
||||
tcp?: string;
|
||||
timeout?: number;
|
||||
trust?: boolean;
|
||||
description?: string;
|
||||
includeTools?: string[];
|
||||
excludeTools?: string[];
|
||||
extensionName?: string;
|
||||
oauth?: Record<string, unknown>;
|
||||
authProviderType?: AuthProviderType;
|
||||
targetAudience?: string;
|
||||
targetServiceAccount?: string;
|
||||
}
|
||||
|
||||
export interface CLIControlInitializeRequest {
|
||||
subtype: 'initialize';
|
||||
hooks?: HookRegistration[] | null;
|
||||
sdkMcpServers?: Record<string, MCPServerConfig>;
|
||||
mcpServers?: Record<string, MCPServerConfig>;
|
||||
agents?: SubagentConfig[];
|
||||
}
|
||||
|
||||
export interface CLIControlSetPermissionModeRequest {
|
||||
subtype: 'set_permission_mode';
|
||||
mode: PermissionMode;
|
||||
}
|
||||
|
||||
export interface CLIHookCallbackRequest {
|
||||
subtype: 'hook_callback';
|
||||
callback_id: string;
|
||||
input: unknown;
|
||||
tool_use_id: string | null;
|
||||
}
|
||||
|
||||
export interface CLIControlMcpMessageRequest {
|
||||
subtype: 'mcp_message';
|
||||
server_name: string;
|
||||
message: {
|
||||
jsonrpc?: string;
|
||||
method: string;
|
||||
params?: Record<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;
|
||||
}
|
||||
|
||||
export interface PermissionApproval {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
modifiedInput?: unknown;
|
||||
}
|
||||
|
||||
export interface ControlResponse {
|
||||
subtype: 'success';
|
||||
request_id: string;
|
||||
response: unknown;
|
||||
}
|
||||
|
||||
export interface ControlErrorResponse {
|
||||
subtype: 'error';
|
||||
request_id: string;
|
||||
error: string | { message: string; [key: string]: unknown };
|
||||
}
|
||||
|
||||
export interface CLIControlResponse {
|
||||
type: 'control_response';
|
||||
response: ControlResponse | ControlErrorResponse;
|
||||
}
|
||||
|
||||
export interface ControlCancelRequest {
|
||||
type: 'control_cancel_request';
|
||||
request_id?: string;
|
||||
}
|
||||
|
||||
export type ControlMessage =
|
||||
| CLIControlRequest
|
||||
| CLIControlResponse
|
||||
| ControlCancelRequest;
|
||||
|
||||
/**
|
||||
* Union of all CLI message types
|
||||
*/
|
||||
export type CLIMessage =
|
||||
| CLIUserMessage
|
||||
| CLIAssistantMessage
|
||||
| CLISystemMessage
|
||||
| CLIResultMessage
|
||||
| CLIPartialAssistantMessage;
|
||||
|
||||
export function isCLIUserMessage(msg: any): msg is CLIUserMessage {
|
||||
return (
|
||||
msg && typeof msg === 'object' && msg.type === 'user' && 'message' in msg
|
||||
);
|
||||
}
|
||||
|
||||
export function isCLIAssistantMessage(msg: any): msg is CLIAssistantMessage {
|
||||
return (
|
||||
msg &&
|
||||
typeof msg === 'object' &&
|
||||
msg.type === 'assistant' &&
|
||||
'uuid' in msg &&
|
||||
'message' in msg &&
|
||||
'session_id' in msg &&
|
||||
'parent_tool_use_id' in msg
|
||||
);
|
||||
}
|
||||
|
||||
export function isCLISystemMessage(msg: any): msg is CLISystemMessage {
|
||||
return (
|
||||
msg &&
|
||||
typeof msg === 'object' &&
|
||||
msg.type === 'system' &&
|
||||
'subtype' in msg &&
|
||||
'uuid' in msg &&
|
||||
'session_id' in msg
|
||||
);
|
||||
}
|
||||
|
||||
export function isCLIResultMessage(msg: any): msg is CLIResultMessage {
|
||||
return (
|
||||
msg &&
|
||||
typeof msg === 'object' &&
|
||||
msg.type === 'result' &&
|
||||
'subtype' in msg &&
|
||||
'duration_ms' in msg &&
|
||||
'is_error' in msg &&
|
||||
'uuid' in msg &&
|
||||
'session_id' in msg
|
||||
);
|
||||
}
|
||||
|
||||
export function isCLIPartialAssistantMessage(
|
||||
msg: any,
|
||||
): msg is CLIPartialAssistantMessage {
|
||||
return (
|
||||
msg &&
|
||||
typeof msg === 'object' &&
|
||||
msg.type === 'stream_event' &&
|
||||
'uuid' in msg &&
|
||||
'session_id' in msg &&
|
||||
'event' in msg &&
|
||||
'parent_tool_use_id' in msg
|
||||
);
|
||||
}
|
||||
|
||||
export function isControlRequest(msg: any): msg is CLIControlRequest {
|
||||
return (
|
||||
msg &&
|
||||
typeof msg === 'object' &&
|
||||
msg.type === 'control_request' &&
|
||||
'request_id' in msg &&
|
||||
'request' in msg
|
||||
);
|
||||
}
|
||||
|
||||
export function isControlResponse(msg: any): msg is CLIControlResponse {
|
||||
return (
|
||||
msg &&
|
||||
typeof msg === 'object' &&
|
||||
msg.type === 'control_response' &&
|
||||
'response' in msg
|
||||
);
|
||||
}
|
||||
|
||||
export function isControlCancel(msg: any): msg is ControlCancelRequest {
|
||||
return (
|
||||
msg &&
|
||||
typeof msg === 'object' &&
|
||||
msg.type === 'control_cancel_request' &&
|
||||
'request_id' in msg
|
||||
);
|
||||
}
|
||||
|
||||
export function isTextBlock(block: any): block is TextBlock {
|
||||
return block && typeof block === 'object' && block.type === 'text';
|
||||
}
|
||||
|
||||
export function isThinkingBlock(block: any): block is ThinkingBlock {
|
||||
return block && typeof block === 'object' && block.type === 'thinking';
|
||||
}
|
||||
|
||||
export function isToolUseBlock(block: any): block is ToolUseBlock {
|
||||
return block && typeof block === 'object' && block.type === 'tool_use';
|
||||
}
|
||||
|
||||
export function isToolResultBlock(block: any): block is ToolResultBlock {
|
||||
return block && typeof block === 'object' && block.type === 'tool_result';
|
||||
}
|
||||
|
||||
export type SubagentLevel = 'session';
|
||||
|
||||
export interface ModelConfig {
|
||||
model?: string;
|
||||
temp?: number;
|
||||
top_p?: number;
|
||||
}
|
||||
|
||||
export interface RunConfig {
|
||||
max_time_minutes?: number;
|
||||
max_turns?: number;
|
||||
}
|
||||
|
||||
export interface SubagentConfig {
|
||||
name: string;
|
||||
description: string;
|
||||
tools?: string[];
|
||||
systemPrompt: string;
|
||||
level: SubagentLevel;
|
||||
filePath: string;
|
||||
modelConfig?: Partial<ModelConfig>;
|
||||
runConfig?: Partial<RunConfig>;
|
||||
color?: string;
|
||||
readonly isBuiltin?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Control Request Types
|
||||
*
|
||||
* Centralized enum for all control request subtypes supported by the CLI.
|
||||
* This enum should be kept in sync with the controllers in:
|
||||
* - packages/cli/src/services/control/controllers/systemController.ts
|
||||
* - packages/cli/src/services/control/controllers/permissionController.ts
|
||||
* - packages/cli/src/services/control/controllers/mcpController.ts
|
||||
* - packages/cli/src/services/control/controllers/hookController.ts
|
||||
*/
|
||||
export enum ControlRequestType {
|
||||
// SystemController requests
|
||||
INITIALIZE = 'initialize',
|
||||
INTERRUPT = 'interrupt',
|
||||
SET_MODEL = 'set_model',
|
||||
SUPPORTED_COMMANDS = 'supported_commands',
|
||||
|
||||
// PermissionController requests
|
||||
CAN_USE_TOOL = 'can_use_tool',
|
||||
SET_PERMISSION_MODE = 'set_permission_mode',
|
||||
|
||||
// MCPController requests
|
||||
MCP_MESSAGE = 'mcp_message',
|
||||
MCP_SERVER_STATUS = 'mcp_server_status',
|
||||
|
||||
// HookController requests
|
||||
HOOK_CALLBACK = 'hook_callback',
|
||||
}
|
||||
86
packages/sdk-typescript/src/types/queryOptionsSchema.ts
Normal file
86
packages/sdk-typescript/src/types/queryOptionsSchema.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { z } from 'zod';
|
||||
import type { CanUseTool } from './types.js';
|
||||
import type { SubagentConfig } from './protocol.js';
|
||||
|
||||
export const ExternalMcpServerConfigSchema = z.object({
|
||||
command: z.string().min(1, 'Command must be a non-empty string'),
|
||||
args: z.array(z.string()).optional(),
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
export const SdkMcpServerConfigSchema = z.object({
|
||||
connect: z.custom<(transport: unknown) => Promise<void>>(
|
||||
(val) => typeof val === 'function',
|
||||
{ message: 'connect must be a function' },
|
||||
),
|
||||
});
|
||||
|
||||
export const ModelConfigSchema = z.object({
|
||||
model: z.string().optional(),
|
||||
temp: z.number().optional(),
|
||||
top_p: z.number().optional(),
|
||||
});
|
||||
|
||||
export const RunConfigSchema = z.object({
|
||||
max_time_minutes: z.number().optional(),
|
||||
max_turns: z.number().optional(),
|
||||
});
|
||||
|
||||
export const SubagentConfigSchema = z.object({
|
||||
name: z.string().min(1, 'Name must be a non-empty string'),
|
||||
description: z.string().min(1, 'Description must be a non-empty string'),
|
||||
tools: z.array(z.string()).optional(),
|
||||
systemPrompt: z.string().min(1, 'System prompt must be a non-empty string'),
|
||||
filePath: z.string().min(1, 'File path must be a non-empty string'),
|
||||
modelConfig: ModelConfigSchema.partial().optional(),
|
||||
runConfig: RunConfigSchema.partial().optional(),
|
||||
color: z.string().optional(),
|
||||
isBuiltin: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const QueryOptionsSchema = z
|
||||
.object({
|
||||
cwd: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
pathToQwenExecutable: z.string().optional(),
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
permissionMode: z.enum(['default', 'plan', 'auto-edit', 'yolo']).optional(),
|
||||
canUseTool: z
|
||||
.custom<CanUseTool>((val) => typeof val === 'function', {
|
||||
message: 'canUseTool must be a function',
|
||||
})
|
||||
.optional(),
|
||||
mcpServers: z.record(z.string(), ExternalMcpServerConfigSchema).optional(),
|
||||
sdkMcpServers: z.record(z.string(), SdkMcpServerConfigSchema).optional(),
|
||||
abortController: z.instanceof(AbortController).optional(),
|
||||
debug: z.boolean().optional(),
|
||||
stderr: z
|
||||
.custom<
|
||||
(message: string) => void
|
||||
>((val) => typeof val === 'function', { message: 'stderr must be a function' })
|
||||
.optional(),
|
||||
maxSessionTurns: z.number().optional(),
|
||||
coreTools: z.array(z.string()).optional(),
|
||||
excludeTools: z.array(z.string()).optional(),
|
||||
authType: z.enum(['openai', 'qwen-oauth']).optional(),
|
||||
agents: z
|
||||
.array(
|
||||
z.custom<SubagentConfig>(
|
||||
(val) =>
|
||||
val &&
|
||||
typeof val === 'object' &&
|
||||
'name' in val &&
|
||||
'description' in val &&
|
||||
'systemPrompt' in val &&
|
||||
'filePath' in val,
|
||||
{ message: 'agents must be an array of SubagentConfig objects' },
|
||||
),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type ExternalMcpServerConfig = z.infer<
|
||||
typeof ExternalMcpServerConfigSchema
|
||||
>;
|
||||
export type QueryOptions = z.infer<typeof QueryOptionsSchema>;
|
||||
57
packages/sdk-typescript/src/types/types.ts
Normal file
57
packages/sdk-typescript/src/types/types.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { PermissionMode, PermissionSuggestion } from './protocol.js';
|
||||
import type { ExternalMcpServerConfig } from './queryOptionsSchema.js';
|
||||
|
||||
export type { PermissionMode };
|
||||
|
||||
export type JSONSchema = {
|
||||
type: string;
|
||||
properties?: Record<string, unknown>;
|
||||
required?: string[];
|
||||
description?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type ToolDefinition<TInput = unknown, TOutput = unknown> = {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: JSONSchema;
|
||||
handler: (input: TInput) => Promise<TOutput>;
|
||||
};
|
||||
|
||||
export type TransportOptions = {
|
||||
pathToQwenExecutable: string;
|
||||
cwd?: string;
|
||||
model?: string;
|
||||
permissionMode?: PermissionMode;
|
||||
mcpServers?: Record<string, ExternalMcpServerConfig>;
|
||||
env?: Record<string, string>;
|
||||
abortController?: AbortController;
|
||||
debug?: boolean;
|
||||
stderr?: (message: string) => void;
|
||||
maxSessionTurns?: number;
|
||||
coreTools?: string[];
|
||||
excludeTools?: string[];
|
||||
authType?: string;
|
||||
};
|
||||
|
||||
type ToolInput = Record<string, unknown>;
|
||||
|
||||
export type CanUseTool = (
|
||||
toolName: string,
|
||||
input: ToolInput,
|
||||
options: {
|
||||
signal: AbortSignal;
|
||||
suggestions?: PermissionSuggestion[] | null;
|
||||
},
|
||||
) => Promise<PermissionResult>;
|
||||
|
||||
export type PermissionResult =
|
||||
| {
|
||||
behavior: 'allow';
|
||||
updatedInput: ToolInput;
|
||||
}
|
||||
| {
|
||||
behavior: 'deny';
|
||||
message: string;
|
||||
interrupt?: boolean;
|
||||
};
|
||||
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 });
|
||||
}
|
||||
}
|
||||
365
packages/sdk-typescript/src/utils/cliPath.ts
Normal file
365
packages/sdk-typescript/src/utils/cliPath.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* CLI path auto-detection and subprocess spawning utilities
|
||||
*
|
||||
* Supports multiple execution modes:
|
||||
* 1. Native binary: 'qwen' (production)
|
||||
* 2. Node.js bundle: 'node /path/to/cli.js' (production validation)
|
||||
* 3. Bun bundle: 'bun /path/to/cli.js' (alternative runtime)
|
||||
* 4. TypeScript source: 'tsx /path/to/index.ts' (development)
|
||||
*
|
||||
* Auto-detection locations for native binary:
|
||||
* 1. QWEN_CODE_CLI_PATH environment variable
|
||||
* 2. ~/.volta/bin/qwen
|
||||
* 3. ~/.npm-global/bin/qwen
|
||||
* 4. /usr/local/bin/qwen
|
||||
* 5. ~/.local/bin/qwen
|
||||
* 6. ~/node_modules/.bin/qwen
|
||||
* 7. ~/.yarn/bin/qwen
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
/**
|
||||
* Executable types supported by the SDK
|
||||
*/
|
||||
export type ExecutableType = 'native' | 'node' | 'bun' | 'tsx' | 'deno';
|
||||
|
||||
/**
|
||||
* Spawn information for CLI process
|
||||
*/
|
||||
export type SpawnInfo = {
|
||||
/** Command to execute (e.g., 'qwen', 'node', 'bun', 'tsx') */
|
||||
command: string;
|
||||
/** Arguments to pass to command */
|
||||
args: string[];
|
||||
/** Type of executable detected */
|
||||
type: ExecutableType;
|
||||
/** Original input that was resolved */
|
||||
originalInput: string;
|
||||
};
|
||||
|
||||
export function findNativeCliPath(): string {
|
||||
const homeDir = process.env['HOME'] || process.env['USERPROFILE'] || '';
|
||||
|
||||
const candidates: Array<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" })',
|
||||
);
|
||||
}
|
||||
|
||||
function isCommandAvailable(command: string): boolean {
|
||||
try {
|
||||
// Use 'which' on Unix-like systems, 'where' on Windows
|
||||
const whichCommand = process.platform === 'win32' ? 'where' : 'which';
|
||||
execSync(`${whichCommand} ${command}`, {
|
||||
stdio: 'ignore',
|
||||
timeout: 5000, // 5 second timeout
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateRuntimeAvailability(runtime: string): boolean {
|
||||
// Node.js is always available since we're running in Node.js
|
||||
if (runtime === 'node') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the runtime command is available in PATH
|
||||
return isCommandAvailable(runtime);
|
||||
}
|
||||
|
||||
function validateFileExtensionForRuntime(
|
||||
filePath: string,
|
||||
runtime: string,
|
||||
): boolean {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
|
||||
switch (runtime) {
|
||||
case 'node':
|
||||
case 'bun':
|
||||
return ['.js', '.mjs', '.cjs'].includes(ext);
|
||||
case 'tsx':
|
||||
return ['.ts', '.tsx'].includes(ext);
|
||||
case 'deno':
|
||||
return ['.ts', '.tsx', '.js', '.mjs'].includes(ext);
|
||||
default:
|
||||
return true; // Unknown runtime, let it pass
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse executable specification into components with comprehensive validation
|
||||
*
|
||||
* Supports multiple formats:
|
||||
* - 'qwen' -> native binary (auto-detected)
|
||||
* - '/path/to/qwen' -> native binary (explicit path)
|
||||
* - '/path/to/cli.js' -> Node.js bundle (default for .js files)
|
||||
* - '/path/to/index.ts' -> TypeScript source (requires tsx)
|
||||
*
|
||||
* Advanced runtime specification (for overriding defaults):
|
||||
* - 'bun:/path/to/cli.js' -> Force Bun runtime
|
||||
* - 'node:/path/to/cli.js' -> Force Node.js runtime
|
||||
* - 'tsx:/path/to/index.ts' -> Force tsx runtime
|
||||
* - 'deno:/path/to/cli.ts' -> Force Deno runtime
|
||||
*
|
||||
* @param executableSpec - Executable specification
|
||||
* @returns Parsed executable information
|
||||
* @throws Error if specification is invalid or files don't exist
|
||||
*/
|
||||
export function parseExecutableSpec(executableSpec?: string): {
|
||||
runtime?: string;
|
||||
executablePath: string;
|
||||
isExplicitRuntime: boolean;
|
||||
} {
|
||||
// Handle empty string case first (before checking for undefined/null)
|
||||
if (
|
||||
executableSpec === '' ||
|
||||
(executableSpec && executableSpec.trim() === '')
|
||||
) {
|
||||
throw new Error('Command name cannot be empty');
|
||||
}
|
||||
|
||||
if (!executableSpec) {
|
||||
// Auto-detect native CLI
|
||||
return {
|
||||
executablePath: findNativeCliPath(),
|
||||
isExplicitRuntime: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for runtime prefix (e.g., 'bun:/path/to/cli.js')
|
||||
const runtimeMatch = executableSpec.match(/^([^:]+):(.+)$/);
|
||||
if (runtimeMatch) {
|
||||
const [, runtime, filePath] = runtimeMatch;
|
||||
if (!runtime || !filePath) {
|
||||
throw new Error(`Invalid runtime specification: '${executableSpec}'`);
|
||||
}
|
||||
|
||||
// Validate runtime is supported
|
||||
const supportedRuntimes = ['node', 'bun', 'tsx', 'deno'];
|
||||
if (!supportedRuntimes.includes(runtime)) {
|
||||
throw new Error(
|
||||
`Unsupported runtime '${runtime}'. Supported runtimes: ${supportedRuntimes.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Validate runtime availability
|
||||
if (!validateRuntimeAvailability(runtime)) {
|
||||
throw new Error(
|
||||
`Runtime '${runtime}' is not available on this system. Please install it first.`,
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
|
||||
// Validate file exists
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
throw new Error(
|
||||
`Executable file not found at '${resolvedPath}' for runtime '${runtime}'. ` +
|
||||
'Please check the file path and ensure the file exists.',
|
||||
);
|
||||
}
|
||||
|
||||
// Validate file extension matches runtime
|
||||
if (!validateFileExtensionForRuntime(resolvedPath, runtime)) {
|
||||
const ext = path.extname(resolvedPath);
|
||||
throw new Error(
|
||||
`File extension '${ext}' is not compatible with runtime '${runtime}'. ` +
|
||||
`Expected extensions for ${runtime}: ${getExpectedExtensions(runtime).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
runtime,
|
||||
executablePath: resolvedPath,
|
||||
isExplicitRuntime: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if it's a command name (no path separators) or a file path
|
||||
const isCommandName =
|
||||
!executableSpec.includes('/') && !executableSpec.includes('\\');
|
||||
|
||||
if (isCommandName) {
|
||||
// It's a command name like 'qwen' - validate it's a reasonable command name
|
||||
if (!executableSpec || executableSpec.trim() === '') {
|
||||
throw new Error('Command name cannot be empty');
|
||||
}
|
||||
|
||||
// Basic validation for command names
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(executableSpec)) {
|
||||
throw new Error(
|
||||
`Invalid command name '${executableSpec}'. Command names should only contain letters, numbers, dots, hyphens, and underscores.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
executablePath: executableSpec,
|
||||
isExplicitRuntime: false,
|
||||
};
|
||||
}
|
||||
|
||||
// It's a file path - validate and resolve
|
||||
const resolvedPath = path.resolve(executableSpec);
|
||||
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
throw new Error(
|
||||
`Executable file not found at '${resolvedPath}'. ` +
|
||||
'Please check the file path and ensure the file exists. ' +
|
||||
'You can also:\n' +
|
||||
' • Set QWEN_CODE_CLI_PATH environment variable\n' +
|
||||
' • Install qwen globally: npm install -g qwen\n' +
|
||||
' • For TypeScript files, ensure tsx is installed: npm install -g tsx\n' +
|
||||
' • Force specific runtime: bun:/path/to/cli.js or tsx:/path/to/index.ts',
|
||||
);
|
||||
}
|
||||
|
||||
// Additional validation for file paths
|
||||
const stats = fs.statSync(resolvedPath);
|
||||
if (!stats.isFile()) {
|
||||
throw new Error(
|
||||
`Path '${resolvedPath}' exists but is not a file. Please provide a path to an executable file.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
executablePath: resolvedPath,
|
||||
isExplicitRuntime: false,
|
||||
};
|
||||
}
|
||||
|
||||
function getExpectedExtensions(runtime: string): string[] {
|
||||
switch (runtime) {
|
||||
case 'node':
|
||||
case 'bun':
|
||||
return ['.js', '.mjs', '.cjs'];
|
||||
case 'tsx':
|
||||
return ['.ts', '.tsx'];
|
||||
case 'deno':
|
||||
return ['.ts', '.tsx', '.js', '.mjs'];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use parseExecutableSpec and prepareSpawnInfo instead
|
||||
*/
|
||||
export function resolveCliPath(explicitPath?: string): string {
|
||||
const parsed = parseExecutableSpec(explicitPath);
|
||||
return parsed.executablePath;
|
||||
}
|
||||
|
||||
function detectRuntimeFromExtension(filePath: string): string | undefined {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
|
||||
if (['.js', '.mjs', '.cjs'].includes(ext)) {
|
||||
// Default to Node.js for JavaScript files
|
||||
return 'node';
|
||||
}
|
||||
|
||||
if (['.ts', '.tsx'].includes(ext)) {
|
||||
// Check if tsx is available for TypeScript files
|
||||
if (isCommandAvailable('tsx')) {
|
||||
return 'tsx';
|
||||
}
|
||||
// If tsx is not available, suggest it in error message
|
||||
throw new Error(
|
||||
`TypeScript file '${filePath}' requires 'tsx' runtime, but it's not available. ` +
|
||||
'Please install tsx: npm install -g tsx, or use explicit runtime: tsx:/path/to/file.ts',
|
||||
);
|
||||
}
|
||||
|
||||
// Native executable or unknown extension
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function prepareSpawnInfo(executableSpec?: string): SpawnInfo {
|
||||
const parsed = parseExecutableSpec(executableSpec);
|
||||
const { runtime, executablePath, isExplicitRuntime } = parsed;
|
||||
|
||||
// If runtime is explicitly specified, use it
|
||||
if (isExplicitRuntime && runtime) {
|
||||
const runtimeCommand = runtime === 'node' ? process.execPath : runtime;
|
||||
|
||||
return {
|
||||
command: runtimeCommand,
|
||||
args: [executablePath],
|
||||
type: runtime as ExecutableType,
|
||||
originalInput: executableSpec || '',
|
||||
};
|
||||
}
|
||||
|
||||
// If no explicit runtime, try to detect from file extension
|
||||
const detectedRuntime = detectRuntimeFromExtension(executablePath);
|
||||
|
||||
if (detectedRuntime) {
|
||||
const runtimeCommand =
|
||||
detectedRuntime === 'node' ? process.execPath : detectedRuntime;
|
||||
|
||||
return {
|
||||
command: runtimeCommand,
|
||||
args: [executablePath],
|
||||
type: detectedRuntime as ExecutableType,
|
||||
originalInput: executableSpec || '',
|
||||
};
|
||||
}
|
||||
|
||||
// Native executable or command name - use it directly
|
||||
return {
|
||||
command: executablePath,
|
||||
args: [],
|
||||
type: 'native',
|
||||
originalInput: executableSpec || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use prepareSpawnInfo() instead
|
||||
*/
|
||||
export function findCliPath(): string {
|
||||
return findNativeCliPath();
|
||||
}
|
||||
65
packages/sdk-typescript/src/utils/jsonLines.ts
Normal file
65
packages/sdk-typescript/src/utils/jsonLines.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
export function serializeJsonLine(message: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(message) + '\n';
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to serialize message to JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseJsonLineSafe(
|
||||
line: string,
|
||||
context = 'JsonLines',
|
||||
): unknown | null {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[${context}] Failed to parse JSON line, skipping:`,
|
||||
line.substring(0, 100),
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidMessage(message: unknown): boolean {
|
||||
return (
|
||||
message !== null &&
|
||||
typeof message === 'object' &&
|
||||
'type' in message &&
|
||||
typeof (message as { type: unknown }).type === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export async function* parseJsonLinesStream(
|
||||
lines: AsyncIterable<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;
|
||||
}
|
||||
}
|
||||
466
packages/sdk-typescript/test/e2e/abort-and-lifecycle.test.ts
Normal file
466
packages/sdk-typescript/test/e2e/abort-and-lifecycle.test.ts
Normal file
@@ -0,0 +1,466 @@
|
||||
/**
|
||||
* E2E tests based on abort-and-lifecycle.ts example
|
||||
* Tests AbortController integration and process lifecycle management
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
query,
|
||||
AbortError,
|
||||
isAbortError,
|
||||
isCLIAssistantMessage,
|
||||
type TextBlock,
|
||||
type ContentBlock,
|
||||
} from '../../src/index.js';
|
||||
|
||||
const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!;
|
||||
|
||||
const SHARED_TEST_OPTIONS = {
|
||||
pathToQwenExecutable: TEST_CLI_PATH,
|
||||
};
|
||||
|
||||
describe('AbortController and Process Lifecycle (E2E)', () => {
|
||||
describe('Basic AbortController Usage', () => {
|
||||
/* TODO: Currently query does not throw AbortError when aborted */
|
||||
it('should support AbortController cancellation', async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
// Abort after 5 seconds
|
||||
setTimeout(() => {
|
||||
controller.abort();
|
||||
}, 5000);
|
||||
|
||||
const q = query({
|
||||
prompt: 'Write a very long story about TypeScript programming',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
abortController: controller,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
const textBlocks = message.message.content.filter(
|
||||
(block): block is TextBlock => block.type === 'text',
|
||||
);
|
||||
const text = textBlocks
|
||||
.map((b) => b.text)
|
||||
.join('')
|
||||
.slice(0, 100);
|
||||
|
||||
// Should receive some content before abort
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Should not reach here - query should be aborted
|
||||
expect(false).toBe(true);
|
||||
} catch (error) {
|
||||
expect(isAbortError(error)).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle abort during query execution', async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
abortController: controller,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let receivedFirstMessage = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
if (!receivedFirstMessage) {
|
||||
// Abort immediately after receiving first assistant message
|
||||
receivedFirstMessage = true;
|
||||
controller.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
expect(isAbortError(error)).toBe(true);
|
||||
expect(error instanceof AbortError).toBe(true);
|
||||
// Should have received at least one message before abort
|
||||
expect(receivedFirstMessage).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle abort immediately after query starts', async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const q = query({
|
||||
prompt: 'Write a very long essay',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
abortController: controller,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Abort immediately after query initialization
|
||||
setTimeout(() => {
|
||||
controller.abort();
|
||||
}, 200);
|
||||
|
||||
try {
|
||||
for await (const _message of q) {
|
||||
// May or may not receive messages before abort
|
||||
}
|
||||
} catch (error) {
|
||||
expect(isAbortError(error)).toBe(true);
|
||||
expect(error instanceof AbortError).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Process Lifecycle Monitoring', () => {
|
||||
it('should handle normal process completion', async () => {
|
||||
const q = query({
|
||||
prompt: 'Why do we choose to go to the moon?',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let completedSuccessfully = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
const textBlocks = message.message.content.filter(
|
||||
(block): block is TextBlock => block.type === 'text',
|
||||
);
|
||||
const text = textBlocks
|
||||
.map((b) => b.text)
|
||||
.join('')
|
||||
.slice(0, 100);
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
completedSuccessfully = true;
|
||||
} catch (error) {
|
||||
// Should not throw for normal completion
|
||||
expect(false).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
expect(completedSuccessfully).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle process cleanup after error', async () => {
|
||||
const q = query({
|
||||
prompt: 'Hello world',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
const textBlocks = message.message.content.filter(
|
||||
(block): block is TextBlock => block.type === 'text',
|
||||
);
|
||||
const text = textBlocks
|
||||
.map((b) => b.text)
|
||||
.join('')
|
||||
.slice(0, 50);
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Expected to potentially have errors
|
||||
} finally {
|
||||
// Should cleanup successfully even after error
|
||||
await q.close();
|
||||
expect(true).toBe(true); // Cleanup completed
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Input Stream Control', () => {
|
||||
it('should support endInput() method', async () => {
|
||||
const q = query({
|
||||
prompt: 'What is 2 + 2?',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let receivedResponse = false;
|
||||
let endInputCalled = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isCLIAssistantMessage(message) && !endInputCalled) {
|
||||
const textBlocks = message.message.content.filter(
|
||||
(block: ContentBlock): block is TextBlock =>
|
||||
block.type === 'text',
|
||||
);
|
||||
const text = textBlocks.map((b: TextBlock) => b.text).join('');
|
||||
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
receivedResponse = true;
|
||||
|
||||
// End input after receiving first response
|
||||
q.endInput();
|
||||
endInputCalled = true;
|
||||
}
|
||||
}
|
||||
|
||||
expect(receivedResponse).toBe(true);
|
||||
expect(endInputCalled).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling and Recovery', () => {
|
||||
it('should handle invalid executable path', async () => {
|
||||
try {
|
||||
const q = query({
|
||||
prompt: 'Hello world',
|
||||
options: {
|
||||
pathToQwenExecutable: '/nonexistent/path/to/cli',
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Should not reach here - query() should throw immediately
|
||||
for await (const _message of q) {
|
||||
// Should not reach here
|
||||
}
|
||||
|
||||
// Should not reach here
|
||||
expect(false).toBe(true);
|
||||
} catch (error) {
|
||||
expect(error instanceof Error).toBe(true);
|
||||
expect((error as Error).message).toBeDefined();
|
||||
expect((error as Error).message).toContain(
|
||||
'Invalid pathToQwenExecutable',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw AbortError with correct properties', async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const q = query({
|
||||
prompt: 'Explain the concept of async programming',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
abortController: controller,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Abort after allowing query to start
|
||||
setTimeout(() => controller.abort(), 1000);
|
||||
|
||||
try {
|
||||
for await (const _message of q) {
|
||||
// May receive some messages before abort
|
||||
}
|
||||
} catch (error) {
|
||||
// Verify error type and helper functions
|
||||
expect(isAbortError(error)).toBe(true);
|
||||
expect(error instanceof AbortError).toBe(true);
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toBeDefined();
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Debugging with stderr callback', () => {
|
||||
it('should capture stderr messages when debug is enabled', async () => {
|
||||
const stderrMessages: string[] = [];
|
||||
|
||||
const q = query({
|
||||
prompt: 'Why do we choose to go to the moon?',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: true,
|
||||
stderr: (msg: string) => {
|
||||
stderrMessages.push(msg);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
const textBlocks = message.message.content.filter(
|
||||
(block): block is TextBlock => block.type === 'text',
|
||||
);
|
||||
const text = textBlocks
|
||||
.map((b) => b.text)
|
||||
.join('')
|
||||
.slice(0, 50);
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await q.close();
|
||||
expect(stderrMessages.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not capture stderr when debug is disabled', async () => {
|
||||
const stderrMessages: string[] = [];
|
||||
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
stderr: (msg: string) => {
|
||||
stderrMessages.push(msg);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const _message of q) {
|
||||
// Consume all messages
|
||||
}
|
||||
} finally {
|
||||
await q.close();
|
||||
// Should have minimal or no stderr output when debug is false
|
||||
expect(stderrMessages.length).toBeLessThan(10);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Abort with Cleanup', () => {
|
||||
it('should cleanup properly after abort', async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const q = query({
|
||||
prompt: 'Write a very long essay about programming',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
abortController: controller,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Abort immediately
|
||||
setTimeout(() => controller.abort(), 100);
|
||||
|
||||
try {
|
||||
for await (const _message of q) {
|
||||
// May receive some messages before abort
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AbortError) {
|
||||
expect(true).toBe(true); // Expected abort error
|
||||
} else {
|
||||
throw error; // Unexpected error
|
||||
}
|
||||
} finally {
|
||||
await q.close();
|
||||
expect(true).toBe(true); // Cleanup completed after abort
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle multiple abort calls gracefully', async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const q = query({
|
||||
prompt: 'Count to 100',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
abortController: controller,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Multiple abort calls
|
||||
setTimeout(() => controller.abort(), 100);
|
||||
setTimeout(() => controller.abort(), 200);
|
||||
setTimeout(() => controller.abort(), 300);
|
||||
|
||||
try {
|
||||
for await (const _message of q) {
|
||||
// Should be interrupted
|
||||
}
|
||||
} catch (error) {
|
||||
expect(isAbortError(error)).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource Management Edge Cases', () => {
|
||||
it('should handle close() called multiple times', async () => {
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Start the query
|
||||
const iterator = q[Symbol.asyncIterator]();
|
||||
await iterator.next();
|
||||
|
||||
// Close multiple times
|
||||
await q.close();
|
||||
await q.close();
|
||||
await q.close();
|
||||
|
||||
// Should not throw
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle abort after close', async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
abortController: controller,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Start and close immediately
|
||||
const iterator = q[Symbol.asyncIterator]();
|
||||
await iterator.next();
|
||||
await q.close();
|
||||
|
||||
// Abort after close
|
||||
controller.abort();
|
||||
|
||||
// Should not throw
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
254
packages/sdk-typescript/test/e2e/control.test.ts
Normal file
254
packages/sdk-typescript/test/e2e/control.test.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { query } from '../../src/index.js';
|
||||
import {
|
||||
isCLIAssistantMessage,
|
||||
isCLIResultMessage,
|
||||
isCLISystemMessage,
|
||||
type CLIUserMessage,
|
||||
} from '../../src/types/protocol.js';
|
||||
|
||||
const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!;
|
||||
|
||||
const SHARED_TEST_OPTIONS = {
|
||||
pathToQwenExecutable: TEST_CLI_PATH,
|
||||
};
|
||||
|
||||
/**
|
||||
* Factory function that creates a streaming input with a control point.
|
||||
* After the first message is yielded, the generator waits for a resume signal,
|
||||
* allowing the test code to call query instance methods like setModel or setPermissionMode.
|
||||
*
|
||||
* @param firstMessage - The first user message to send
|
||||
* @param secondMessage - The second user message to send after control operations
|
||||
* @returns Object containing the async generator and a resume function
|
||||
*/
|
||||
function createStreamingInputWithControlPoint(
|
||||
firstMessage: string,
|
||||
secondMessage: string,
|
||||
): {
|
||||
generator: AsyncIterable<CLIUserMessage>;
|
||||
resume: () => void;
|
||||
} {
|
||||
let resumeResolve: (() => void) | null = null;
|
||||
const resumePromise = new Promise<void>((resolve) => {
|
||||
resumeResolve = resolve;
|
||||
});
|
||||
|
||||
const generator = (async function* () {
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: firstMessage,
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as CLIUserMessage;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
await resumePromise;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: secondMessage,
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as CLIUserMessage;
|
||||
})();
|
||||
|
||||
const resume = () => {
|
||||
if (resumeResolve) {
|
||||
resumeResolve();
|
||||
}
|
||||
};
|
||||
|
||||
return { generator, resume };
|
||||
}
|
||||
|
||||
describe('Control Request/Response (E2E)', () => {
|
||||
describe('System Controller Scope', () => {
|
||||
it('should set model via control request during streaming input', async () => {
|
||||
const { generator, resume } = createStreamingInputWithControlPoint(
|
||||
'Tell me the model name.',
|
||||
'Tell me the model name now again.',
|
||||
);
|
||||
|
||||
const q = query({
|
||||
prompt: generator,
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
model: 'qwen3-max',
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const resolvers: {
|
||||
first?: () => void;
|
||||
second?: () => void;
|
||||
} = {};
|
||||
const firstResponsePromise = new Promise<void>((resolve) => {
|
||||
resolvers.first = resolve;
|
||||
});
|
||||
const secondResponsePromise = new Promise<void>((resolve) => {
|
||||
resolvers.second = resolve;
|
||||
});
|
||||
|
||||
let firstResponseReceived = false;
|
||||
let secondResponseReceived = false;
|
||||
const systemMessages: Array<{ model?: string }> = [];
|
||||
|
||||
// Consume messages in a single loop
|
||||
(async () => {
|
||||
for await (const message of q) {
|
||||
if (isCLISystemMessage(message)) {
|
||||
systemMessages.push({ model: message.model });
|
||||
}
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
if (!firstResponseReceived) {
|
||||
firstResponseReceived = true;
|
||||
resolvers.first?.();
|
||||
} else if (!secondResponseReceived) {
|
||||
secondResponseReceived = true;
|
||||
resolvers.second?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
// Wait for first response
|
||||
await Promise.race([
|
||||
firstResponsePromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('Timeout waiting for first response')),
|
||||
10000,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(firstResponseReceived).toBe(true);
|
||||
|
||||
// Perform control operation: set model
|
||||
await q.setModel('qwen3-vl-plus');
|
||||
|
||||
// Resume the input stream
|
||||
resume();
|
||||
|
||||
// Wait for second response
|
||||
await Promise.race([
|
||||
secondResponsePromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('Timeout waiting for second response')),
|
||||
10000,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(secondResponseReceived).toBe(true);
|
||||
|
||||
// Verify system messages - model should change from qwen3-max to qwen3-vl-plus
|
||||
expect(systemMessages.length).toBeGreaterThanOrEqual(2);
|
||||
expect(systemMessages[0].model).toBeOneOf(['qwen3-max', 'coder-model']);
|
||||
expect(systemMessages[1].model).toBe('qwen3-vl-plus');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Permission Controller Scope', () => {
|
||||
it('should set permission mode via control request during streaming input', async () => {
|
||||
const { generator, resume } = createStreamingInputWithControlPoint(
|
||||
'What is 1 + 1?',
|
||||
'What is 2 + 2?',
|
||||
);
|
||||
|
||||
const q = query({
|
||||
prompt: generator,
|
||||
options: {
|
||||
pathToQwenExecutable: TEST_CLI_PATH,
|
||||
permissionMode: 'default',
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const resolvers: {
|
||||
first?: () => void;
|
||||
second?: () => void;
|
||||
} = {};
|
||||
const firstResponsePromise = new Promise<void>((resolve) => {
|
||||
resolvers.first = resolve;
|
||||
});
|
||||
const secondResponsePromise = new Promise<void>((resolve) => {
|
||||
resolvers.second = resolve;
|
||||
});
|
||||
|
||||
let firstResponseReceived = false;
|
||||
let permissionModeChanged = false;
|
||||
let secondResponseReceived = false;
|
||||
|
||||
// Consume messages in a single loop
|
||||
(async () => {
|
||||
for await (const message of q) {
|
||||
if (isCLIAssistantMessage(message) || isCLIResultMessage(message)) {
|
||||
if (!firstResponseReceived) {
|
||||
firstResponseReceived = true;
|
||||
resolvers.first?.();
|
||||
} else if (!secondResponseReceived) {
|
||||
secondResponseReceived = true;
|
||||
resolvers.second?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
// Wait for first response
|
||||
await Promise.race([
|
||||
firstResponsePromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('Timeout waiting for first response')),
|
||||
10000,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(firstResponseReceived).toBe(true);
|
||||
|
||||
// Perform control operation: set permission mode
|
||||
await q.setPermissionMode('yolo');
|
||||
permissionModeChanged = true;
|
||||
|
||||
// Resume the input stream
|
||||
resume();
|
||||
|
||||
// Wait for second response
|
||||
await Promise.race([
|
||||
secondResponsePromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('Timeout waiting for second response')),
|
||||
10000,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(permissionModeChanged).toBe(true);
|
||||
expect(secondResponseReceived).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
56
packages/sdk-typescript/test/e2e/globalSetup.ts
Normal file
56
packages/sdk-typescript/test/e2e/globalSetup.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { mkdir, readdir, rm } from 'node:fs/promises';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, '../..');
|
||||
const e2eTestsDir = join(rootDir, '.integration-tests');
|
||||
let runDir = '';
|
||||
|
||||
export async function setup() {
|
||||
runDir = join(e2eTestsDir, `${Date.now()}`);
|
||||
await mkdir(runDir, { recursive: true });
|
||||
|
||||
// Clean up old test runs, but keep the latest few for debugging
|
||||
try {
|
||||
const testRuns = await readdir(e2eTestsDir);
|
||||
if (testRuns.length > 5) {
|
||||
const oldRuns = testRuns.sort().slice(0, testRuns.length - 5);
|
||||
await Promise.all(
|
||||
oldRuns.map((oldRun) =>
|
||||
rm(join(e2eTestsDir, oldRun), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error cleaning up old test runs:', e);
|
||||
}
|
||||
|
||||
process.env['E2E_TEST_FILE_DIR'] = runDir;
|
||||
process.env['QWEN_CLI_E2E_TEST'] = 'true';
|
||||
process.env['TEST_CLI_PATH'] = join(rootDir, '../../dist/cli.js');
|
||||
|
||||
if (process.env['KEEP_OUTPUT']) {
|
||||
console.log(`Keeping output for test run in: ${runDir}`);
|
||||
}
|
||||
process.env['VERBOSE'] = process.env['VERBOSE'] ?? 'false';
|
||||
|
||||
console.log(`\nE2E test output directory: ${runDir}`);
|
||||
console.log(`CLI path: ${process.env['TEST_CLI_PATH']}`);
|
||||
}
|
||||
|
||||
export async function teardown() {
|
||||
// Cleanup the test run directory unless KEEP_OUTPUT is set
|
||||
if (process.env['KEEP_OUTPUT'] !== 'true' && runDir) {
|
||||
await rm(runDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
610
packages/sdk-typescript/test/e2e/mcp-server.test.ts
Normal file
610
packages/sdk-typescript/test/e2e/mcp-server.test.ts
Normal file
@@ -0,0 +1,610 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* E2E tests for MCP (Model Context Protocol) server integration via SDK
|
||||
* Tests that the SDK can properly interact with MCP servers configured in qwen-code
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { query } from '../../src/index.js';
|
||||
import {
|
||||
isCLIAssistantMessage,
|
||||
isCLIResultMessage,
|
||||
isCLISystemMessage,
|
||||
isCLIUserMessage,
|
||||
type TextBlock,
|
||||
type ContentBlock,
|
||||
type CLIMessage,
|
||||
type ToolUseBlock,
|
||||
type CLISystemMessage,
|
||||
} from '../../src/types/protocol.js';
|
||||
import { writeFileSync, mkdirSync, chmodSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!;
|
||||
const E2E_TEST_FILE_DIR = process.env['E2E_TEST_FILE_DIR']!;
|
||||
|
||||
const SHARED_TEST_OPTIONS = {
|
||||
pathToQwenExecutable: TEST_CLI_PATH,
|
||||
permissionMode: 'yolo' as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to extract text from ContentBlock array
|
||||
*/
|
||||
function extractText(content: ContentBlock[]): string {
|
||||
return content
|
||||
.filter((block): block is TextBlock => block.type === 'text')
|
||||
.map((block) => block.text)
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal MCP server implementation that doesn't require external dependencies
|
||||
* This implements the MCP protocol directly using Node.js built-ins
|
||||
*/
|
||||
const MCP_SERVER_SCRIPT = `#!/usr/bin/env node
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
const readline = require('readline');
|
||||
const fs = require('fs');
|
||||
|
||||
// Debug logging to stderr (only when MCP_DEBUG or VERBOSE is set)
|
||||
const debugEnabled = process.env['MCP_DEBUG'] === 'true' || process.env['VERBOSE'] === 'true';
|
||||
function debug(msg) {
|
||||
if (debugEnabled) {
|
||||
fs.writeSync(2, \`[MCP-DEBUG] \${msg}\\n\`);
|
||||
}
|
||||
}
|
||||
|
||||
debug('MCP server starting...');
|
||||
|
||||
// Simple JSON-RPC implementation for MCP
|
||||
class SimpleJSONRPC {
|
||||
constructor() {
|
||||
this.handlers = new Map();
|
||||
this.rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: false
|
||||
});
|
||||
|
||||
this.rl.on('line', (line) => {
|
||||
debug(\`Received line: \${line}\`);
|
||||
try {
|
||||
const message = JSON.parse(line);
|
||||
debug(\`Parsed message: \${JSON.stringify(message)}\`);
|
||||
this.handleMessage(message);
|
||||
} catch (e) {
|
||||
debug(\`Parse error: \${e.message}\`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
send(message) {
|
||||
const msgStr = JSON.stringify(message);
|
||||
debug(\`Sending message: \${msgStr}\`);
|
||||
process.stdout.write(msgStr + '\\n');
|
||||
}
|
||||
|
||||
async handleMessage(message) {
|
||||
if (message.method && this.handlers.has(message.method)) {
|
||||
try {
|
||||
const result = await this.handlers.get(message.method)(message.params || {});
|
||||
if (message.id !== undefined) {
|
||||
this.send({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
result
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (message.id !== undefined) {
|
||||
this.send({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
error: {
|
||||
code: -32603,
|
||||
message: error.message
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (message.id !== undefined) {
|
||||
this.send({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: 'Method not found'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
on(method, handler) {
|
||||
this.handlers.set(method, handler);
|
||||
}
|
||||
}
|
||||
|
||||
// Create MCP server
|
||||
const rpc = new SimpleJSONRPC();
|
||||
|
||||
// Handle initialize
|
||||
rpc.on('initialize', async (params) => {
|
||||
debug('Handling initialize request');
|
||||
return {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {
|
||||
tools: {}
|
||||
},
|
||||
serverInfo: {
|
||||
name: 'test-math-server',
|
||||
version: '1.0.0'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Handle tools/list
|
||||
rpc.on('tools/list', async () => {
|
||||
debug('Handling tools/list request');
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: 'add',
|
||||
description: 'Add two numbers together',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
a: { type: 'number', description: 'First number' },
|
||||
b: { type: 'number', description: 'Second number' }
|
||||
},
|
||||
required: ['a', 'b']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'multiply',
|
||||
description: 'Multiply two numbers together',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
a: { type: 'number', description: 'First number' },
|
||||
b: { type: 'number', description: 'Second number' }
|
||||
},
|
||||
required: ['a', 'b']
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
// Handle tools/call
|
||||
rpc.on('tools/call', async (params) => {
|
||||
debug(\`Handling tools/call request for tool: \${params.name}\`);
|
||||
|
||||
if (params.name === 'add') {
|
||||
const { a, b } = params.arguments;
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: String(a + b)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
if (params.name === 'multiply') {
|
||||
const { a, b } = params.arguments;
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: String(a * b)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Unknown tool: ' + params.name);
|
||||
});
|
||||
|
||||
// Send initialization notification
|
||||
rpc.send({
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialized'
|
||||
});
|
||||
`;
|
||||
|
||||
describe('MCP Server Integration (E2E)', () => {
|
||||
let testDir: string;
|
||||
let serverScriptPath: string;
|
||||
|
||||
beforeAll(() => {
|
||||
// Use the centralized E2E test directory from globalSetup
|
||||
testDir = join(E2E_TEST_FILE_DIR, 'mcp-server-test');
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
|
||||
// Write MCP server script
|
||||
serverScriptPath = join(testDir, 'mcp-server.cjs');
|
||||
writeFileSync(serverScriptPath, MCP_SERVER_SCRIPT);
|
||||
|
||||
// Make script executable on Unix-like systems
|
||||
if (process.platform !== 'win32') {
|
||||
chmodSync(serverScriptPath, 0o755);
|
||||
}
|
||||
});
|
||||
|
||||
describe('Basic MCP Tool Usage', () => {
|
||||
it('should use MCP add tool to add two numbers', async () => {
|
||||
const q = query({
|
||||
prompt:
|
||||
'Use the add tool to calculate 5 + 10. Just give me the result.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
mcpServers: {
|
||||
'test-math-server': {
|
||||
command: 'node',
|
||||
args: [serverScriptPath],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: CLIMessage[] = [];
|
||||
let assistantText = '';
|
||||
let foundToolUse = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
const toolUseBlock = message.message.content.find(
|
||||
(block: ContentBlock): block is ToolUseBlock =>
|
||||
block.type === 'tool_use',
|
||||
);
|
||||
if (toolUseBlock && toolUseBlock.name === 'add') {
|
||||
foundToolUse = true;
|
||||
}
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate tool was called
|
||||
expect(foundToolUse).toBe(true);
|
||||
|
||||
// Validate result contains expected answer
|
||||
expect(assistantText).toMatch(/15/);
|
||||
|
||||
// Validate successful completion
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(isCLIResultMessage(lastMessage)).toBe(true);
|
||||
if (isCLIResultMessage(lastMessage)) {
|
||||
expect(lastMessage.subtype).toBe('success');
|
||||
}
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should use MCP multiply tool to multiply two numbers', async () => {
|
||||
const q = query({
|
||||
prompt:
|
||||
'Use the multiply tool to calculate 6 * 7. Just give me the result.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
mcpServers: {
|
||||
'test-math-server': {
|
||||
command: 'node',
|
||||
args: [serverScriptPath],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: CLIMessage[] = [];
|
||||
let assistantText = '';
|
||||
let foundToolUse = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
const toolUseBlock = message.message.content.find(
|
||||
(block: ContentBlock): block is ToolUseBlock =>
|
||||
block.type === 'tool_use',
|
||||
);
|
||||
if (toolUseBlock && toolUseBlock.name === 'multiply') {
|
||||
foundToolUse = true;
|
||||
}
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate tool was called
|
||||
expect(foundToolUse).toBe(true);
|
||||
|
||||
// Validate result contains expected answer
|
||||
expect(assistantText).toMatch(/42/);
|
||||
|
||||
// Validate successful completion
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(isCLIResultMessage(lastMessage)).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP Server Discovery', () => {
|
||||
it('should list MCP servers in system init message', async () => {
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
mcpServers: {
|
||||
'test-math-server': {
|
||||
command: 'node',
|
||||
args: [serverScriptPath],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let systemMessage: CLISystemMessage | null = null;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isCLISystemMessage(message) && message.subtype === 'init') {
|
||||
systemMessage = message;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate MCP server is listed
|
||||
expect(systemMessage).not.toBeNull();
|
||||
expect(systemMessage!.mcp_servers).toBeDefined();
|
||||
expect(Array.isArray(systemMessage!.mcp_servers)).toBe(true);
|
||||
|
||||
// Find our test server
|
||||
const testServer = systemMessage!.mcp_servers?.find(
|
||||
(server) => server.name === 'test-math-server',
|
||||
);
|
||||
expect(testServer).toBeDefined();
|
||||
|
||||
// Note: tools are not exposed in the mcp_servers array in system message
|
||||
// They are available through the MCP protocol but not in the init message
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex MCP Operations', () => {
|
||||
it('should chain multiple MCP tool calls', async () => {
|
||||
const q = query({
|
||||
prompt:
|
||||
'First use add to calculate 10 + 5, then multiply the result by 2. Give me the final answer.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
mcpServers: {
|
||||
'test-math-server': {
|
||||
command: 'node',
|
||||
args: [serverScriptPath],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: CLIMessage[] = [];
|
||||
let assistantText = '';
|
||||
const toolCalls: string[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
const toolUseBlocks = message.message.content.filter(
|
||||
(block: ContentBlock): block is ToolUseBlock =>
|
||||
block.type === 'tool_use',
|
||||
);
|
||||
toolUseBlocks.forEach((block) => {
|
||||
toolCalls.push(block.name);
|
||||
});
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate both tools were called
|
||||
expect(toolCalls).toContain('add');
|
||||
expect(toolCalls).toContain('multiply');
|
||||
|
||||
// Validate result: (10 + 5) * 2 = 30
|
||||
expect(assistantText).toMatch(/30/);
|
||||
|
||||
// Validate successful completion
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(isCLIResultMessage(lastMessage)).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle multiple calls to the same MCP tool', async () => {
|
||||
const q = query({
|
||||
prompt:
|
||||
'Use the add tool twice: first add 1 + 2, then add 3 + 4. Tell me both results.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
mcpServers: {
|
||||
'test-math-server': {
|
||||
command: 'node',
|
||||
args: [serverScriptPath],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: CLIMessage[] = [];
|
||||
let assistantText = '';
|
||||
const addToolCalls: ToolUseBlock[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
const toolUseBlocks = message.message.content.filter(
|
||||
(block: ContentBlock): block is ToolUseBlock =>
|
||||
block.type === 'tool_use',
|
||||
);
|
||||
toolUseBlocks.forEach((block) => {
|
||||
if (block.name === 'add') {
|
||||
addToolCalls.push(block);
|
||||
}
|
||||
});
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate add tool was called at least twice
|
||||
expect(addToolCalls.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Validate results contain expected answers: 3 and 7
|
||||
expect(assistantText).toMatch(/3/);
|
||||
expect(assistantText).toMatch(/7/);
|
||||
|
||||
// Validate successful completion
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(isCLIResultMessage(lastMessage)).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP Tool Message Flow', () => {
|
||||
it('should receive proper message sequence for MCP tool usage', async () => {
|
||||
const q = query({
|
||||
prompt: 'Use add to calculate 2 + 3',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
mcpServers: {
|
||||
'test-math-server': {
|
||||
command: 'node',
|
||||
args: [serverScriptPath],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messageTypes: string[] = [];
|
||||
let foundToolUse = false;
|
||||
let foundToolResult = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messageTypes.push(message.type);
|
||||
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
const toolUseBlock = message.message.content.find(
|
||||
(block: ContentBlock): block is ToolUseBlock =>
|
||||
block.type === 'tool_use',
|
||||
);
|
||||
if (toolUseBlock) {
|
||||
foundToolUse = true;
|
||||
expect(toolUseBlock.name).toBe('add');
|
||||
expect(toolUseBlock.input).toBeDefined();
|
||||
}
|
||||
}
|
||||
|
||||
if (isCLIUserMessage(message)) {
|
||||
const content = message.message.content;
|
||||
const contentArray = Array.isArray(content)
|
||||
? content
|
||||
: [{ type: 'text', text: content }];
|
||||
const toolResultBlock = contentArray.find(
|
||||
(block) => block.type === 'tool_result',
|
||||
);
|
||||
if (toolResultBlock) {
|
||||
foundToolResult = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate message flow
|
||||
expect(foundToolUse).toBe(true);
|
||||
expect(foundToolResult).toBe(true);
|
||||
expect(messageTypes).toContain('system');
|
||||
expect(messageTypes).toContain('assistant');
|
||||
expect(messageTypes).toContain('user');
|
||||
expect(messageTypes).toContain('result');
|
||||
|
||||
// Result should be last message
|
||||
expect(messageTypes[messageTypes.length - 1]).toBe('result');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle gracefully when MCP tool is not available', async () => {
|
||||
const q = query({
|
||||
prompt: 'Use the subtract tool to calculate 10 - 5',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
mcpServers: {
|
||||
'test-math-server': {
|
||||
command: 'node',
|
||||
args: [serverScriptPath],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: CLIMessage[] = [];
|
||||
let assistantText = '';
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Should complete without crashing
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(isCLIResultMessage(lastMessage)).toBe(true);
|
||||
|
||||
// Assistant should indicate tool is not available or provide alternative
|
||||
expect(assistantText.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
479
packages/sdk-typescript/test/e2e/multi-turn.test.ts
Normal file
479
packages/sdk-typescript/test/e2e/multi-turn.test.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
/**
|
||||
* E2E tests based on multi-turn.ts example
|
||||
* Tests multi-turn conversation functionality with real CLI
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { query } from '../../src/index.js';
|
||||
import {
|
||||
isCLIUserMessage,
|
||||
isCLIAssistantMessage,
|
||||
isCLISystemMessage,
|
||||
isCLIResultMessage,
|
||||
isCLIPartialAssistantMessage,
|
||||
isControlRequest,
|
||||
isControlResponse,
|
||||
isControlCancel,
|
||||
type CLIUserMessage,
|
||||
type CLIAssistantMessage,
|
||||
type TextBlock,
|
||||
type ContentBlock,
|
||||
type CLIMessage,
|
||||
type ControlMessage,
|
||||
type ToolUseBlock,
|
||||
} from '../../src/types/protocol.js';
|
||||
const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!;
|
||||
|
||||
const SHARED_TEST_OPTIONS = {
|
||||
pathToQwenExecutable: TEST_CLI_PATH,
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine the message type using protocol type guards
|
||||
*/
|
||||
function getMessageType(message: CLIMessage | ControlMessage): string {
|
||||
if (isCLIUserMessage(message)) {
|
||||
return '🧑 USER';
|
||||
} else if (isCLIAssistantMessage(message)) {
|
||||
return '🤖 ASSISTANT';
|
||||
} else if (isCLISystemMessage(message)) {
|
||||
return `🖥️ SYSTEM(${message.subtype})`;
|
||||
} else if (isCLIResultMessage(message)) {
|
||||
return `✅ RESULT(${message.subtype})`;
|
||||
} else if (isCLIPartialAssistantMessage(message)) {
|
||||
return '⏳ STREAM_EVENT';
|
||||
} else if (isControlRequest(message)) {
|
||||
return `🎮 CONTROL_REQUEST(${message.request.subtype})`;
|
||||
} else if (isControlResponse(message)) {
|
||||
return `📭 CONTROL_RESPONSE(${message.response.subtype})`;
|
||||
} else if (isControlCancel(message)) {
|
||||
return '🛑 CONTROL_CANCEL';
|
||||
} else {
|
||||
return '❓ UNKNOWN';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to extract text from ContentBlock array
|
||||
*/
|
||||
function extractText(content: ContentBlock[]): string {
|
||||
return content
|
||||
.filter((block): block is TextBlock => block.type === 'text')
|
||||
.map((block) => block.text)
|
||||
.join('');
|
||||
}
|
||||
|
||||
describe('Multi-Turn Conversations (E2E)', () => {
|
||||
describe('AsyncIterable Prompt Support', () => {
|
||||
it('should handle multi-turn conversation using AsyncIterable prompt', async () => {
|
||||
// Create multi-turn conversation generator
|
||||
async function* createMultiTurnConversation(): AsyncIterable<CLIUserMessage> {
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'What is 1 + 1?',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as CLIUserMessage;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'What is 2 + 2?',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as CLIUserMessage;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'What is 3 + 3?',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as CLIUserMessage;
|
||||
}
|
||||
|
||||
// Create multi-turn query using AsyncIterable prompt
|
||||
const q = query({
|
||||
prompt: createMultiTurnConversation(),
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: CLIMessage[] = [];
|
||||
const assistantMessages: CLIAssistantMessage[] = [];
|
||||
const assistantTexts: string[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
assistantMessages.push(message);
|
||||
const text = extractText(message.message.content);
|
||||
assistantTexts.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
expect(assistantMessages.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// Validate content of responses
|
||||
expect(assistantTexts[0]).toMatch(/2/);
|
||||
expect(assistantTexts[1]).toMatch(/4/);
|
||||
expect(assistantTexts[2]).toMatch(/6/);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should maintain session context across turns', async () => {
|
||||
async function* createContextualConversation(): AsyncIterable<CLIUserMessage> {
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content:
|
||||
'Suppose we have 3 rabbits and 4 carrots. How many animals are there?',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as CLIUserMessage;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'How many animals are there? Only output the number',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as CLIUserMessage;
|
||||
}
|
||||
|
||||
const q = query({
|
||||
prompt: createContextualConversation(),
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const assistantMessages: CLIAssistantMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
assistantMessages.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
expect(assistantMessages.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// The second response should reference the color blue
|
||||
const secondResponse = extractText(
|
||||
assistantMessages[1].message.content,
|
||||
);
|
||||
expect(secondResponse.toLowerCase()).toContain('3');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool Usage in Multi-Turn', () => {
|
||||
it('should handle tool usage across multiple turns', async () => {
|
||||
async function* createToolConversation(): AsyncIterable<CLIUserMessage> {
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'Create a file named test.txt with content "hello"',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as CLIUserMessage;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'Now read the test.txt file',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as CLIUserMessage;
|
||||
}
|
||||
|
||||
const q = query({
|
||||
prompt: createToolConversation(),
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
permissionMode: 'yolo',
|
||||
cwd: '/tmp',
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: CLIMessage[] = [];
|
||||
let toolUseCount = 0;
|
||||
const assistantMessages: CLIAssistantMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
assistantMessages.push(message);
|
||||
const hasToolUseBlock = message.message.content.some(
|
||||
(block: ContentBlock): block is ToolUseBlock =>
|
||||
block.type === 'tool_use',
|
||||
);
|
||||
if (hasToolUseBlock) {
|
||||
toolUseCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
expect(toolUseCount).toBeGreaterThan(0);
|
||||
expect(assistantMessages.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Validate second response mentions the file content
|
||||
const secondResponse = extractText(
|
||||
assistantMessages[assistantMessages.length - 1].message.content,
|
||||
);
|
||||
expect(secondResponse.toLowerCase()).toMatch(/hello|test\.txt/);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message Flow and Sequencing', () => {
|
||||
it('should process messages in correct sequence', async () => {
|
||||
async function* createSequentialConversation(): AsyncIterable<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();
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Should handle empty conversation without crashing
|
||||
expect(true).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle conversation with delays', async () => {
|
||||
async function* createDelayedConversation(): AsyncIterable<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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
676
packages/sdk-typescript/test/e2e/permission-control.test.ts
Normal file
676
packages/sdk-typescript/test/e2e/permission-control.test.ts
Normal file
@@ -0,0 +1,676 @@
|
||||
/**
|
||||
* E2E tests for permission control features:
|
||||
* - canUseTool callback parameter
|
||||
* - setPermissionMode API
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { query } from '../../src/index.js';
|
||||
import {
|
||||
isCLIAssistantMessage,
|
||||
isCLIResultMessage,
|
||||
isCLIUserMessage,
|
||||
type CLIUserMessage,
|
||||
type ToolUseBlock,
|
||||
type ContentBlock,
|
||||
} from '../../src/types/protocol.js';
|
||||
const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!;
|
||||
const TEST_TIMEOUT = 30000;
|
||||
|
||||
const SHARED_TEST_OPTIONS = {
|
||||
pathToQwenExecutable: TEST_CLI_PATH,
|
||||
debug: false,
|
||||
env: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* Factory function that creates a streaming input with a control point.
|
||||
* After the first message is yielded, the generator waits for a resume signal,
|
||||
* allowing the test code to call query instance methods like setPermissionMode.
|
||||
*/
|
||||
function createStreamingInputWithControlPoint(
|
||||
firstMessage: string,
|
||||
secondMessage: string,
|
||||
): {
|
||||
generator: AsyncIterable<CLIUserMessage>;
|
||||
resume: () => void;
|
||||
} {
|
||||
let resumeResolve: (() => void) | null = null;
|
||||
const resumePromise = new Promise<void>((resolve) => {
|
||||
resumeResolve = resolve;
|
||||
});
|
||||
|
||||
const generator = (async function* () {
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: firstMessage,
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as CLIUserMessage;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
await resumePromise;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: secondMessage,
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as CLIUserMessage;
|
||||
})();
|
||||
|
||||
const resume = () => {
|
||||
if (resumeResolve) {
|
||||
resumeResolve();
|
||||
}
|
||||
};
|
||||
|
||||
return { generator, resume };
|
||||
}
|
||||
|
||||
describe('Permission Control (E2E)', () => {
|
||||
beforeAll(() => {
|
||||
//process.env['DEBUG'] = '1';
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env['DEBUG'];
|
||||
});
|
||||
|
||||
describe('canUseTool callback parameter', () => {
|
||||
it('should invoke canUseTool callback when tool is requested', async () => {
|
||||
const toolCalls: Array<{
|
||||
toolName: string;
|
||||
input: Record<string, unknown>;
|
||||
}> = [];
|
||||
|
||||
const q = query({
|
||||
prompt: 'Write a js hello world to file.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
permissionMode: 'default',
|
||||
|
||||
canUseTool: async (toolName, input) => {
|
||||
toolCalls.push({ toolName, input });
|
||||
/*
|
||||
{
|
||||
behavior: 'allow',
|
||||
updatedInput: input,
|
||||
};
|
||||
*/
|
||||
return {
|
||||
behavior: 'deny',
|
||||
message: 'Tool execution denied by user.',
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
let hasToolUse = false;
|
||||
for await (const message of q) {
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
const toolUseBlock = message.message.content.find(
|
||||
(block: ContentBlock): block is ToolUseBlock =>
|
||||
block.type === 'tool_use',
|
||||
);
|
||||
if (toolUseBlock) {
|
||||
hasToolUse = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(hasToolUse).toBe(true);
|
||||
expect(toolCalls.length).toBeGreaterThan(0);
|
||||
expect(toolCalls[0].toolName).toBeDefined();
|
||||
expect(toolCalls[0].input).toBeDefined();
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should allow tool execution when canUseTool returns allow', async () => {
|
||||
let callbackInvoked = false;
|
||||
|
||||
const q = query({
|
||||
prompt: 'Create a file named hello.txt with content "world"',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
permissionMode: 'default',
|
||||
cwd: '/tmp',
|
||||
canUseTool: async (toolName, input) => {
|
||||
callbackInvoked = true;
|
||||
return {
|
||||
behavior: 'allow',
|
||||
updatedInput: input,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
let hasToolResult = false;
|
||||
for await (const message of q) {
|
||||
if (isCLIUserMessage(message)) {
|
||||
if (
|
||||
Array.isArray(message.message.content) &&
|
||||
message.message.content.some(
|
||||
(block) => block.type === 'tool_result',
|
||||
)
|
||||
) {
|
||||
hasToolResult = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(callbackInvoked).toBe(true);
|
||||
expect(hasToolResult).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should deny tool execution when canUseTool returns deny', async () => {
|
||||
let callbackInvoked = false;
|
||||
|
||||
const q = query({
|
||||
prompt: 'Create a file named test.txt',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
permissionMode: 'default',
|
||||
canUseTool: async () => {
|
||||
callbackInvoked = true;
|
||||
return {
|
||||
behavior: 'deny',
|
||||
message: 'Tool execution denied by test',
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const _message of q) {
|
||||
// Consume all messages
|
||||
}
|
||||
|
||||
expect(callbackInvoked).toBe(true);
|
||||
// Tool use might still appear, but execution should be denied
|
||||
// The exact behavior depends on CLI implementation
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should pass suggestions to canUseTool callback', async () => {
|
||||
let receivedSuggestions: unknown = null;
|
||||
|
||||
const q = query({
|
||||
prompt: 'Create a file named data.txt',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
permissionMode: 'default',
|
||||
cwd: '/tmp',
|
||||
canUseTool: async (toolName, input, options) => {
|
||||
receivedSuggestions = options?.suggestions;
|
||||
return {
|
||||
behavior: 'allow',
|
||||
updatedInput: input,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const _message of q) {
|
||||
// Consume all messages
|
||||
}
|
||||
|
||||
// Suggestions may be null or an array, depending on CLI implementation
|
||||
expect(receivedSuggestions !== undefined).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should pass abort signal to canUseTool callback', async () => {
|
||||
let receivedSignal: AbortSignal | undefined = undefined;
|
||||
|
||||
const q = query({
|
||||
prompt: 'Create a file named signal.txt',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
permissionMode: 'default',
|
||||
cwd: '/tmp',
|
||||
canUseTool: async (toolName, input, options) => {
|
||||
receivedSignal = options?.signal;
|
||||
return {
|
||||
behavior: 'allow',
|
||||
updatedInput: input,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const _message of q) {
|
||||
// Consume all messages
|
||||
}
|
||||
|
||||
expect(receivedSignal).toBeDefined();
|
||||
expect(receivedSignal).toBeInstanceOf(AbortSignal);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should allow updatedInput modification in canUseTool callback', async () => {
|
||||
const originalInputs: Record<string, unknown>[] = [];
|
||||
const updatedInputs: Record<string, unknown>[] = [];
|
||||
|
||||
const q = query({
|
||||
prompt: 'Create a file named modified.txt',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
permissionMode: 'default',
|
||||
cwd: '/tmp',
|
||||
canUseTool: async (toolName, input) => {
|
||||
originalInputs.push({ ...input });
|
||||
const updatedInput = {
|
||||
...input,
|
||||
modified: true,
|
||||
testKey: 'testValue',
|
||||
};
|
||||
updatedInputs.push(updatedInput);
|
||||
return {
|
||||
behavior: 'allow',
|
||||
updatedInput,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const _message of q) {
|
||||
// Consume all messages
|
||||
}
|
||||
|
||||
expect(originalInputs.length).toBeGreaterThan(0);
|
||||
expect(updatedInputs.length).toBeGreaterThan(0);
|
||||
expect(updatedInputs[0]?.['modified']).toBe(true);
|
||||
expect(updatedInputs[0]?.['testKey']).toBe('testValue');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should default to deny when canUseTool is not provided', async () => {
|
||||
const q = query({
|
||||
prompt: 'Create a file named default.txt',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
permissionMode: 'default',
|
||||
cwd: '/tmp',
|
||||
// canUseTool not provided
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
// When canUseTool is not provided, tools should be denied by default
|
||||
// The exact behavior depends on CLI implementation
|
||||
for await (const _message of q) {
|
||||
// Consume all messages
|
||||
}
|
||||
// Test passes if no errors occur
|
||||
expect(true).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('setPermissionMode API', () => {
|
||||
it('should change permission mode from default to yolo', async () => {
|
||||
const { generator, resume } = createStreamingInputWithControlPoint(
|
||||
'What is 1 + 1?',
|
||||
'What is 2 + 2?',
|
||||
);
|
||||
|
||||
const q = query({
|
||||
prompt: generator,
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
permissionMode: 'default',
|
||||
debug: true,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const resolvers: {
|
||||
first?: () => void;
|
||||
second?: () => void;
|
||||
} = {};
|
||||
const firstResponsePromise = new Promise<void>((resolve) => {
|
||||
resolvers.first = resolve;
|
||||
});
|
||||
const secondResponsePromise = new Promise<void>((resolve) => {
|
||||
resolvers.second = resolve;
|
||||
});
|
||||
|
||||
let firstResponseReceived = false;
|
||||
let secondResponseReceived = false;
|
||||
|
||||
(async () => {
|
||||
for await (const message of q) {
|
||||
if (isCLIAssistantMessage(message) || isCLIResultMessage(message)) {
|
||||
if (!firstResponseReceived) {
|
||||
firstResponseReceived = true;
|
||||
resolvers.first?.();
|
||||
} else if (!secondResponseReceived) {
|
||||
secondResponseReceived = true;
|
||||
resolvers.second?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
await Promise.race([
|
||||
firstResponsePromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('Timeout waiting for first response')),
|
||||
40000,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(firstResponseReceived).toBe(true);
|
||||
|
||||
await q.setPermissionMode('yolo');
|
||||
|
||||
resume();
|
||||
|
||||
await Promise.race([
|
||||
secondResponsePromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('Timeout waiting for second response')),
|
||||
40000,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(secondResponseReceived).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should change permission mode from yolo to plan', async () => {
|
||||
const { generator, resume } = createStreamingInputWithControlPoint(
|
||||
'What is 3 + 3?',
|
||||
'What is 4 + 4?',
|
||||
);
|
||||
|
||||
const q = query({
|
||||
prompt: generator,
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
permissionMode: 'yolo',
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const resolvers: {
|
||||
first?: () => void;
|
||||
second?: () => void;
|
||||
} = {};
|
||||
const firstResponsePromise = new Promise<void>((resolve) => {
|
||||
resolvers.first = resolve;
|
||||
});
|
||||
const secondResponsePromise = new Promise<void>((resolve) => {
|
||||
resolvers.second = resolve;
|
||||
});
|
||||
|
||||
let firstResponseReceived = false;
|
||||
let secondResponseReceived = false;
|
||||
|
||||
(async () => {
|
||||
for await (const message of q) {
|
||||
if (isCLIAssistantMessage(message) || isCLIResultMessage(message)) {
|
||||
if (!firstResponseReceived) {
|
||||
firstResponseReceived = true;
|
||||
resolvers.first?.();
|
||||
} else if (!secondResponseReceived) {
|
||||
secondResponseReceived = true;
|
||||
resolvers.second?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
await Promise.race([
|
||||
firstResponsePromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('Timeout waiting for first response')),
|
||||
10000,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(firstResponseReceived).toBe(true);
|
||||
|
||||
await q.setPermissionMode('plan');
|
||||
|
||||
resume();
|
||||
|
||||
await Promise.race([
|
||||
secondResponsePromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('Timeout waiting for second response')),
|
||||
10000,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(secondResponseReceived).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should change permission mode to auto-edit', async () => {
|
||||
const { generator, resume } = createStreamingInputWithControlPoint(
|
||||
'What is 5 + 5?',
|
||||
'What is 6 + 6?',
|
||||
);
|
||||
|
||||
const q = query({
|
||||
prompt: generator,
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
permissionMode: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const resolvers: {
|
||||
first?: () => void;
|
||||
second?: () => void;
|
||||
} = {};
|
||||
const firstResponsePromise = new Promise<void>((resolve) => {
|
||||
resolvers.first = resolve;
|
||||
});
|
||||
const secondResponsePromise = new Promise<void>((resolve) => {
|
||||
resolvers.second = resolve;
|
||||
});
|
||||
|
||||
let firstResponseReceived = false;
|
||||
let secondResponseReceived = false;
|
||||
|
||||
(async () => {
|
||||
for await (const message of q) {
|
||||
if (isCLIAssistantMessage(message) || isCLIResultMessage(message)) {
|
||||
if (!firstResponseReceived) {
|
||||
firstResponseReceived = true;
|
||||
resolvers.first?.();
|
||||
} else if (!secondResponseReceived) {
|
||||
secondResponseReceived = true;
|
||||
resolvers.second?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
await Promise.race([
|
||||
firstResponsePromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('Timeout waiting for first response')),
|
||||
10000,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(firstResponseReceived).toBe(true);
|
||||
|
||||
await q.setPermissionMode('auto-edit');
|
||||
|
||||
resume();
|
||||
|
||||
await Promise.race([
|
||||
secondResponsePromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('Timeout waiting for second response')),
|
||||
10000,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(secondResponseReceived).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw error when setPermissionMode is called on closed query', async () => {
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
permissionMode: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
await q.close();
|
||||
|
||||
await expect(q.setPermissionMode('yolo')).rejects.toThrow(
|
||||
'Query is closed',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canUseTool and setPermissionMode integration', () => {
|
||||
it('should work together - canUseTool callback with dynamic permission mode change', async () => {
|
||||
const toolCalls: Array<{
|
||||
toolName: string;
|
||||
input: Record<string, unknown>;
|
||||
}> = [];
|
||||
|
||||
const { generator, resume } = createStreamingInputWithControlPoint(
|
||||
'Create a file named first.txt',
|
||||
'Create a file named second.txt',
|
||||
);
|
||||
|
||||
const q = query({
|
||||
prompt: generator,
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
permissionMode: 'default',
|
||||
cwd: '/tmp',
|
||||
canUseTool: async (toolName, input) => {
|
||||
toolCalls.push({ toolName, input });
|
||||
return {
|
||||
behavior: 'allow',
|
||||
updatedInput: input,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const resolvers: {
|
||||
first?: () => void;
|
||||
second?: () => void;
|
||||
} = {};
|
||||
const firstResponsePromise = new Promise<void>((resolve) => {
|
||||
resolvers.first = resolve;
|
||||
});
|
||||
const secondResponsePromise = new Promise<void>((resolve) => {
|
||||
resolvers.second = resolve;
|
||||
});
|
||||
|
||||
let firstResponseReceived = false;
|
||||
let secondResponseReceived = false;
|
||||
|
||||
(async () => {
|
||||
for await (const message of q) {
|
||||
if (isCLIResultMessage(message)) {
|
||||
if (!firstResponseReceived) {
|
||||
firstResponseReceived = true;
|
||||
resolvers.first?.();
|
||||
} else if (!secondResponseReceived) {
|
||||
secondResponseReceived = true;
|
||||
resolvers.second?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
await Promise.race([
|
||||
firstResponsePromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('Timeout waiting for first response')),
|
||||
TEST_TIMEOUT,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(firstResponseReceived).toBe(true);
|
||||
expect(toolCalls.length).toBeGreaterThan(0);
|
||||
|
||||
await q.setPermissionMode('yolo');
|
||||
|
||||
resume();
|
||||
|
||||
await Promise.race([
|
||||
secondResponsePromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('Timeout waiting for second response')),
|
||||
TEST_TIMEOUT,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(secondResponseReceived).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
479
packages/sdk-typescript/test/e2e/single-turn.test.ts
Normal file
479
packages/sdk-typescript/test/e2e/single-turn.test.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
/**
|
||||
* E2E tests for single-turn query execution
|
||||
* Tests basic query patterns with simple prompts and clear output expectations
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { query } from '../../src/index.js';
|
||||
import {
|
||||
isCLIAssistantMessage,
|
||||
isCLISystemMessage,
|
||||
isCLIResultMessage,
|
||||
type TextBlock,
|
||||
type ContentBlock,
|
||||
type CLIMessage,
|
||||
type CLISystemMessage,
|
||||
type CLIAssistantMessage,
|
||||
} from '../../src/types/protocol.js';
|
||||
const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!;
|
||||
|
||||
const SHARED_TEST_OPTIONS = {
|
||||
pathToQwenExecutable: TEST_CLI_PATH,
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to extract text from ContentBlock array
|
||||
*/
|
||||
function extractText(content: ContentBlock[]): string {
|
||||
return content
|
||||
.filter((block): block is TextBlock => block.type === 'text')
|
||||
.map((block) => block.text)
|
||||
.join('');
|
||||
}
|
||||
|
||||
describe('Single-Turn Query (E2E)', () => {
|
||||
describe('Simple Text Queries', () => {
|
||||
it('should answer basic arithmetic question', async () => {
|
||||
const q = query({
|
||||
prompt: 'What is 2 + 2? Just give me the number.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: CLIMessage[] = [];
|
||||
let assistantText = '';
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate we got messages
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
|
||||
// Validate assistant response content
|
||||
expect(assistantText.length).toBeGreaterThan(0);
|
||||
expect(assistantText).toMatch(/4/);
|
||||
|
||||
// Validate message flow ends with success
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(isCLIResultMessage(lastMessage)).toBe(true);
|
||||
if (isCLIResultMessage(lastMessage)) {
|
||||
expect(lastMessage.subtype).toBe('success');
|
||||
}
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should answer simple factual question', async () => {
|
||||
const q = query({
|
||||
prompt: 'What is the capital of France? One word answer.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: CLIMessage[] = [];
|
||||
let assistantText = '';
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate content
|
||||
expect(assistantText.length).toBeGreaterThan(0);
|
||||
expect(assistantText.toLowerCase()).toContain('paris');
|
||||
|
||||
// Validate completion
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(isCLIResultMessage(lastMessage)).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle greeting and self-description', async () => {
|
||||
const q = query({
|
||||
prompt: 'Say hello and tell me your name in one sentence.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: CLIMessage[] = [];
|
||||
let assistantText = '';
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
assistantText += extractText(message.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate content contains greeting
|
||||
expect(assistantText.length).toBeGreaterThan(0);
|
||||
expect(assistantText.toLowerCase()).toMatch(/hello|hi|greetings/);
|
||||
|
||||
// Validate message types
|
||||
const assistantMessages = messages.filter(isCLIAssistantMessage);
|
||||
expect(assistantMessages.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('System Initialization', () => {
|
||||
it('should receive system message with initialization info', async () => {
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: CLIMessage[] = [];
|
||||
let systemMessage: CLISystemMessage | null = null;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isCLISystemMessage(message) && message.subtype === 'init') {
|
||||
systemMessage = message;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate system message exists and has required fields
|
||||
expect(systemMessage).not.toBeNull();
|
||||
expect(systemMessage!.type).toBe('system');
|
||||
expect(systemMessage!.subtype).toBe('init');
|
||||
expect(systemMessage!.uuid).toBeDefined();
|
||||
expect(systemMessage!.session_id).toBeDefined();
|
||||
expect(systemMessage!.cwd).toBeDefined();
|
||||
expect(systemMessage!.tools).toBeDefined();
|
||||
expect(Array.isArray(systemMessage!.tools)).toBe(true);
|
||||
expect(systemMessage!.mcp_servers).toBeDefined();
|
||||
expect(Array.isArray(systemMessage!.mcp_servers)).toBe(true);
|
||||
expect(systemMessage!.model).toBeDefined();
|
||||
expect(systemMessage!.permissionMode).toBeDefined();
|
||||
expect(systemMessage!.qwen_code_version).toBeDefined();
|
||||
|
||||
// Validate system message appears early in sequence
|
||||
const systemMessageIndex = messages.findIndex(
|
||||
(msg) => isCLISystemMessage(msg) && msg.subtype === 'init',
|
||||
);
|
||||
expect(systemMessageIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(systemMessageIndex).toBeLessThan(3);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should maintain session ID consistency', async () => {
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let systemMessage: CLISystemMessage | null = null;
|
||||
const sessionId = q.getSessionId();
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isCLISystemMessage(message) && message.subtype === 'init') {
|
||||
systemMessage = message;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate session IDs are consistent
|
||||
expect(sessionId).toBeDefined();
|
||||
expect(systemMessage).not.toBeNull();
|
||||
expect(systemMessage!.session_id).toBeDefined();
|
||||
expect(systemMessage!.uuid).toBeDefined();
|
||||
expect(systemMessage!.session_id).toBe(systemMessage!.uuid);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message Flow', () => {
|
||||
it('should follow expected message sequence', async () => {
|
||||
const q = query({
|
||||
prompt: 'Say hi',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messageTypes: string[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messageTypes.push(message.type);
|
||||
}
|
||||
|
||||
// Validate message sequence
|
||||
expect(messageTypes.length).toBeGreaterThan(0);
|
||||
expect(messageTypes).toContain('assistant');
|
||||
expect(messageTypes[messageTypes.length - 1]).toBe('result');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should complete iteration naturally', async () => {
|
||||
const q = query({
|
||||
prompt: 'Say goodbye',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let completedNaturally = false;
|
||||
let messageCount = 0;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messageCount++;
|
||||
|
||||
if (isCLIResultMessage(message)) {
|
||||
completedNaturally = true;
|
||||
expect(message.subtype).toBe('success');
|
||||
}
|
||||
}
|
||||
|
||||
expect(messageCount).toBeGreaterThan(0);
|
||||
expect(completedNaturally).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Configuration Options', () => {
|
||||
it('should respect debug option and capture stderr', async () => {
|
||||
const stderrMessages: string[] = [];
|
||||
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: true,
|
||||
stderr: (msg: string) => {
|
||||
stderrMessages.push(msg);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const _message of q) {
|
||||
// Consume all messages
|
||||
}
|
||||
|
||||
// Debug mode should produce stderr output
|
||||
expect(stderrMessages.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should respect cwd option', async () => {
|
||||
const testDir = process.cwd();
|
||||
|
||||
const q = query({
|
||||
prompt: 'What is 1 + 1?',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let hasResponse = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
hasResponse = true;
|
||||
}
|
||||
}
|
||||
|
||||
expect(hasResponse).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message Type Recognition', () => {
|
||||
it('should correctly identify all message types', async () => {
|
||||
const q = query({
|
||||
prompt: 'What is 5 + 5?',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: CLIMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Validate type guards work correctly
|
||||
const assistantMessages = messages.filter(isCLIAssistantMessage);
|
||||
const resultMessages = messages.filter(isCLIResultMessage);
|
||||
const systemMessages = messages.filter(isCLISystemMessage);
|
||||
|
||||
expect(assistantMessages.length).toBeGreaterThan(0);
|
||||
expect(resultMessages.length).toBeGreaterThan(0);
|
||||
expect(systemMessages.length).toBeGreaterThan(0);
|
||||
|
||||
// Validate assistant message structure
|
||||
const firstAssistant = assistantMessages[0];
|
||||
expect(firstAssistant.message.content).toBeDefined();
|
||||
expect(Array.isArray(firstAssistant.message.content)).toBe(true);
|
||||
|
||||
// Validate result message structure
|
||||
const resultMessage = resultMessages[0];
|
||||
expect(resultMessage.subtype).toBe('success');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should extract text content from assistant messages', async () => {
|
||||
const q = query({
|
||||
prompt: 'Count from 1 to 3',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let assistantMessage: CLIAssistantMessage | null = null;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
assistantMessage = message;
|
||||
}
|
||||
}
|
||||
|
||||
expect(assistantMessage).not.toBeNull();
|
||||
expect(assistantMessage!.message.content).toBeDefined();
|
||||
|
||||
// Extract text blocks
|
||||
const textBlocks = assistantMessage!.message.content.filter(
|
||||
(block: ContentBlock): block is TextBlock => block.type === 'text',
|
||||
);
|
||||
|
||||
expect(textBlocks.length).toBeGreaterThan(0);
|
||||
expect(textBlocks[0].text).toBeDefined();
|
||||
expect(textBlocks[0].text.length).toBeGreaterThan(0);
|
||||
|
||||
// Validate content contains expected numbers
|
||||
const text = extractText(assistantMessage!.message.content);
|
||||
expect(text).toMatch(/1/);
|
||||
expect(text).toMatch(/2/);
|
||||
expect(text).toMatch(/3/);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should throw if CLI not found', async () => {
|
||||
try {
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
pathToQwenExecutable: '/nonexistent/path/to/cli',
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
for await (const _message of q) {
|
||||
// Should not reach here
|
||||
}
|
||||
|
||||
expect(false).toBe(true); // Should have thrown
|
||||
} catch (error) {
|
||||
expect(error).toBeDefined();
|
||||
expect(error instanceof Error).toBe(true);
|
||||
expect((error as Error).message).toContain(
|
||||
'Invalid pathToQwenExecutable',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource Management', () => {
|
||||
it('should cleanup subprocess on close()', async () => {
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Start and immediately close
|
||||
const iterator = q[Symbol.asyncIterator]();
|
||||
await iterator.next();
|
||||
|
||||
// Should close without error
|
||||
await q.close();
|
||||
expect(true).toBe(true); // Cleanup completed
|
||||
});
|
||||
|
||||
it('should handle close() called multiple times', async () => {
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Start the query
|
||||
const iterator = q[Symbol.asyncIterator]();
|
||||
await iterator.next();
|
||||
|
||||
// Close multiple times
|
||||
await q.close();
|
||||
await q.close();
|
||||
await q.close();
|
||||
|
||||
// Should not throw
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
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", ".integration-tests"]
|
||||
}
|
||||
40
packages/sdk-typescript/vitest.config.ts
Normal file
40
packages/sdk-typescript/vitest.config.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import * as path from 'path';
|
||||
|
||||
const timeoutMinutes = Number(process.env['E2E_TIMEOUT_MINUTES'] || '3');
|
||||
const testTimeoutMs = timeoutMinutes * 60 * 1000;
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: 'node',
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
'dist/',
|
||||
'test/',
|
||||
'**/*.d.ts',
|
||||
'**/*.config.*',
|
||||
'**/index.ts', // Export-only files
|
||||
],
|
||||
thresholds: {
|
||||
lines: 80,
|
||||
functions: 80,
|
||||
branches: 75,
|
||||
statements: 80,
|
||||
},
|
||||
},
|
||||
include: ['test/**/*.test.ts'],
|
||||
exclude: ['node_modules/', 'dist/'],
|
||||
testTimeout: testTimeoutMs,
|
||||
hookTimeout: 10000,
|
||||
globalSetup: './test/e2e/globalSetup.ts',
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -6,6 +6,7 @@ export default defineConfig({
|
||||
'packages/cli',
|
||||
'packages/core',
|
||||
'packages/vscode-ide-companion',
|
||||
'packages/sdk-typescript',
|
||||
'integration-tests',
|
||||
'scripts',
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user