Compare commits

..

1 Commits

Author SHA1 Message Date
mingholy.lmh
e8448bcca2 feat: sdk draft 2025-11-06 10:08:02 +08:00
43 changed files with 8783 additions and 1254 deletions

View File

@@ -68,66 +68,72 @@ Qwen Code provides a comprehensive suite of tools for interacting with the local
- **File:** `glob.ts`
- **Parameters:**
- `pattern` (string, required): The glob pattern to match against (e.g., `"*.py"`, `"src/**/*.js"`).
- `path` (string, optional): The directory to search in. If not specified, the current working directory will be used.
- `path` (string, optional): The absolute path to the directory to search within. If omitted, searches the tool's root directory.
- `case_sensitive` (boolean, optional): Whether the search should be case-sensitive. Defaults to `false`.
- `respect_git_ignore` (boolean, optional): Whether to respect .gitignore patterns when finding files. Defaults to `true`.
- **Behavior:**
- Searches for files matching the glob pattern within the specified directory.
- Returns a list of absolute paths, sorted with the most recently modified files first.
- Respects .gitignore and .qwenignore patterns by default.
- Limits results to 100 files to prevent context overflow.
- **Output (`llmContent`):** A message like: `Found 5 file(s) matching "*.ts" within /path/to/search/dir, sorted by modification time (newest first):\n---\n/path/to/file1.ts\n/path/to/subdir/file2.ts\n---\n[95 files truncated] ...`
- Ignores common nuisance directories like `node_modules` and `.git` by default.
- **Output (`llmContent`):** A message like: `Found 5 file(s) matching "*.ts" within src, sorted by modification time (newest first):\nsrc/file1.ts\nsrc/subdir/file2.ts...`
- **Confirmation:** No.
## 5. `grep_search` (Grep)
## 5. `search_file_content` (SearchText)
`grep_search` searches for a regular expression pattern within the content of files in a specified directory. Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers.
`search_file_content` searches for a regular expression pattern within the content of files in a specified directory. Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers.
- **Tool name:** `grep_search`
- **Display name:** Grep
- **File:** `ripGrep.ts` (with `grep.ts` as fallback)
- **Tool name:** `search_file_content`
- **Display name:** SearchText
- **File:** `grep.ts`
- **Parameters:**
- `pattern` (string, required): The regular expression pattern to search for in file contents (e.g., `"function\\s+myFunction"`, `"log.*Error"`).
- `path` (string, optional): File or directory to search in. Defaults to current working directory.
- `glob` (string, optional): Glob pattern to filter files (e.g. `"*.js"`, `"src/**/*.{ts,tsx}"`).
- `limit` (number, optional): Limit output to first N matching lines. Optional - shows all matches if not specified.
- `pattern` (string, required): The regular expression (regex) to search for (e.g., `"function\s+myFunction"`).
- `path` (string, optional): The absolute path to the directory to search within. Defaults to the current working directory.
- `include` (string, optional): A glob pattern to filter which files are searched (e.g., `"*.js"`, `"src/**/*.{ts,tsx}"`). If omitted, searches most files (respecting common ignores).
- `maxResults` (number, optional): Maximum number of matches to return to prevent context overflow (default: 20, max: 100). Use lower values for broad searches, higher for specific searches.
- **Behavior:**
- Uses ripgrep for fast search when available; otherwise falls back to a JavaScript-based search implementation.
- Returns matching lines with file paths and line numbers.
- Case-insensitive by default.
- Respects .gitignore and .qwenignore patterns.
- Limits output to prevent context overflow.
- Uses `git grep` if available in a Git repository for speed; otherwise, falls back to system `grep` or a JavaScript-based search.
- Returns a list of matching lines, each prefixed with its file path (relative to the search directory) and line number.
- Limits results to a maximum of 20 matches by default to prevent context overflow. When results are truncated, shows a clear warning with guidance on refining searches.
- **Output (`llmContent`):** A formatted string of matches, e.g.:
```
Found 3 matches for pattern "myFunction" in path "." (filter: "*.ts"):
---
src/utils.ts:15:export function myFunction() {
src/utils.ts:22: myFunction.call();
src/index.ts:5:import { myFunction } from './utils';
File: src/utils.ts
L15: export function myFunction() {
L22: myFunction.call();
---
File: src/index.ts
L5: import { myFunction } from './utils';
---
[0 lines truncated] ...
WARNING: Results truncated to prevent context overflow. To see more results:
- Use a more specific pattern to reduce matches
- Add file filters with the 'include' parameter (e.g., "*.js", "src/**")
- Specify a narrower 'path' to search in a subdirectory
- Increase 'maxResults' parameter if you need more matches (current: 20)
```
- **Confirmation:** No.
### `grep_search` examples
### `search_file_content` examples
Search for a pattern with default result limiting:
```
grep_search(pattern="function\\s+myFunction", path="src")
search_file_content(pattern="function\s+myFunction", path="src")
```
Search for a pattern with custom result limiting:
```
grep_search(pattern="function", path="src", limit=50)
search_file_content(pattern="function", path="src", maxResults=50)
```
Search for a pattern with file filtering and custom result limiting:
```
grep_search(pattern="function", glob="*.js", limit=10)
search_file_content(pattern="function", include="*.js", maxResults=10)
```
## 6. `edit` (Edit)

View File

@@ -12,12 +12,6 @@ import type {
GeminiChat,
ToolCallConfirmationDetails,
ToolResult,
SubAgentEventEmitter,
SubAgentToolCallEvent,
SubAgentToolResultEvent,
SubAgentApprovalRequestEvent,
AnyDeclarativeTool,
AnyToolInvocation,
} from '@qwen-code/qwen-code-core';
import {
AuthType,
@@ -34,10 +28,6 @@ import {
getErrorStatus,
isWithinRoot,
isNodeError,
SubAgentEventType,
TaskTool,
Kind,
TodoWriteTool,
} from '@qwen-code/qwen-code-core';
import * as acp from './acp.js';
import { AcpFileSystemService } from './fileSystemService.js';
@@ -413,34 +403,9 @@ class Session {
);
}
// Detect TodoWriteTool early - route to plan updates instead of tool_call events
const isTodoWriteTool =
fc.name === TodoWriteTool.Name || tool.name === TodoWriteTool.Name;
// Declare subAgentToolEventListeners outside try block for cleanup in catch
let subAgentToolEventListeners: Array<() => void> = [];
try {
const invocation = tool.build(args);
// Detect TaskTool and set up sub-agent tool tracking
const isTaskTool = tool.name === TaskTool.Name;
if (isTaskTool && 'eventEmitter' in invocation) {
// Access eventEmitter from TaskTool invocation
const taskEventEmitter = (
invocation as {
eventEmitter: SubAgentEventEmitter;
}
).eventEmitter;
// Set up sub-agent tool tracking
subAgentToolEventListeners = this.setupSubAgentToolTracking(
taskEventEmitter,
abortSignal,
);
}
const confirmationDetails =
await invocation.shouldConfirmExecute(abortSignal);
@@ -495,8 +460,7 @@ class Session {
throw new Error(`Unexpected: ${resultOutcome}`);
}
}
} else if (!isTodoWriteTool) {
// Skip tool_call event for TodoWriteTool
} else {
await this.sendUpdate({
sessionUpdate: 'tool_call',
toolCallId: callId,
@@ -509,61 +473,14 @@ class Session {
}
const toolResult: ToolResult = await invocation.execute(abortSignal);
const content = toToolCallContent(toolResult);
// Clean up event listeners
subAgentToolEventListeners.forEach((cleanup) => cleanup());
// Handle TodoWriteTool: extract todos and send plan update
if (isTodoWriteTool) {
// Extract todos from args (initial state)
let todos: Array<{
id: string;
content: string;
status: 'pending' | 'in_progress' | 'completed';
}> = [];
if (Array.isArray(args['todos'])) {
todos = args['todos'] as Array<{
id: string;
content: string;
status: 'pending' | 'in_progress' | 'completed';
}>;
}
// If returnDisplay has todos (e.g., modified by user), use those instead
if (
toolResult.returnDisplay &&
typeof toolResult.returnDisplay === 'object' &&
'type' in toolResult.returnDisplay &&
toolResult.returnDisplay.type === 'todo_list' &&
'todos' in toolResult.returnDisplay &&
Array.isArray(toolResult.returnDisplay.todos)
) {
todos = toolResult.returnDisplay.todos;
}
// Convert todos to plan entries and send plan update
if (todos.length > 0 || Array.isArray(args['todos'])) {
const planEntries = convertTodosToPlanEntries(todos);
await this.sendUpdate({
sessionUpdate: 'plan',
entries: planEntries,
});
}
// Skip tool_call_update event for TodoWriteTool
// Still log and return function response for LLM
} else {
// Normal tool handling: send tool_call_update
const content = toToolCallContent(toolResult);
await this.sendUpdate({
sessionUpdate: 'tool_call_update',
toolCallId: callId,
status: 'completed',
content: content ? [content] : [],
});
}
await this.sendUpdate({
sessionUpdate: 'tool_call_update',
toolCallId: callId,
status: 'completed',
content: content ? [content] : [],
});
const durationMs = Date.now() - startTime;
logToolCall(this.config, {
@@ -583,9 +500,6 @@ class Session {
return convertToFunctionResponse(fc.name, callId, toolResult.llmContent);
} catch (e) {
// Ensure cleanup on error
subAgentToolEventListeners.forEach((cleanup) => cleanup());
const error = e instanceof Error ? e : new Error(String(e));
await this.sendUpdate({
@@ -601,300 +515,6 @@ class Session {
}
}
/**
* Sets up event listeners to track sub-agent tool calls within a TaskTool execution.
* Converts subagent tool call events into zedIntegration session updates.
*
* @param eventEmitter - The SubAgentEventEmitter from TaskTool
* @param abortSignal - Signal to abort tracking if parent is cancelled
* @returns Array of cleanup functions to remove event listeners
*/
private setupSubAgentToolTracking(
eventEmitter: SubAgentEventEmitter,
abortSignal: AbortSignal,
): Array<() => void> {
const cleanupFunctions: Array<() => void> = [];
const toolRegistry = this.config.getToolRegistry();
// Track subagent tool call states
const subAgentToolStates = new Map<
string,
{
tool?: AnyDeclarativeTool;
invocation?: AnyToolInvocation;
args?: Record<string, unknown>;
}
>();
// Listen for tool call start
const onToolCall = (...args: unknown[]) => {
const event = args[0] as SubAgentToolCallEvent;
if (abortSignal.aborted) return;
const subAgentTool = toolRegistry.getTool(event.name);
let subAgentInvocation: AnyToolInvocation | undefined;
let toolKind: acp.ToolKind = 'other';
let locations: acp.ToolCallLocation[] = [];
if (subAgentTool) {
try {
subAgentInvocation = subAgentTool.build(event.args);
toolKind = this.mapToolKind(subAgentTool.kind);
locations = subAgentInvocation.toolLocations().map((loc) => ({
path: loc.path,
line: loc.line ?? null,
}));
} catch (e) {
// If building fails, continue with defaults
console.warn(`Failed to build subagent tool ${event.name}:`, e);
}
}
// Save state for subsequent updates
subAgentToolStates.set(event.callId, {
tool: subAgentTool,
invocation: subAgentInvocation,
args: event.args,
});
// Check if this is TodoWriteTool - if so, skip sending tool_call event
// Plan update will be sent in onToolResult when we have the final state
if (event.name === TodoWriteTool.Name) {
return;
}
// Send tool call start update with rawInput
void this.sendUpdate({
sessionUpdate: 'tool_call',
toolCallId: event.callId,
status: 'in_progress',
title: event.description || event.name,
content: [],
locations,
kind: toolKind,
rawInput: event.args,
});
};
// Listen for tool call result
const onToolResult = (...args: unknown[]) => {
const event = args[0] as SubAgentToolResultEvent;
if (abortSignal.aborted) return;
const state = subAgentToolStates.get(event.callId);
// Check if this is TodoWriteTool - if so, route to plan updates
if (event.name === TodoWriteTool.Name) {
let todos:
| Array<{
id: string;
content: string;
status: 'pending' | 'in_progress' | 'completed';
}>
| undefined;
// Try to extract todos from resultDisplay first (final state)
if (event.resultDisplay) {
try {
// resultDisplay might be a JSON stringified object
const parsed =
typeof event.resultDisplay === 'string'
? JSON.parse(event.resultDisplay)
: event.resultDisplay;
if (
typeof parsed === 'object' &&
parsed !== null &&
'type' in parsed &&
parsed.type === 'todo_list' &&
'todos' in parsed &&
Array.isArray(parsed.todos)
) {
todos = parsed.todos;
}
} catch {
// If parsing fails, ignore - resultDisplay might not be JSON
}
}
// Fallback to args if resultDisplay doesn't have todos
if (!todos && state?.args && Array.isArray(state.args['todos'])) {
todos = state.args['todos'] as Array<{
id: string;
content: string;
status: 'pending' | 'in_progress' | 'completed';
}>;
}
// Send plan update if we have todos
if (todos) {
const planEntries = convertTodosToPlanEntries(todos);
void this.sendUpdate({
sessionUpdate: 'plan',
entries: planEntries,
});
}
// Skip sending tool_call_update event for TodoWriteTool
// Clean up state
subAgentToolStates.delete(event.callId);
return;
}
let content: acp.ToolCallContent[] = [];
// If there's a result display, try to convert to ToolCallContent
if (event.resultDisplay && state?.invocation) {
// resultDisplay is typically a string
if (typeof event.resultDisplay === 'string') {
content = [
{
type: 'content',
content: {
type: 'text',
text: event.resultDisplay,
},
},
];
}
}
// Send tool call completion update
void this.sendUpdate({
sessionUpdate: 'tool_call_update',
toolCallId: event.callId,
status: event.success ? 'completed' : 'failed',
content: content.length > 0 ? content : [],
title: state?.invocation?.getDescription() ?? event.name,
kind: state?.tool ? this.mapToolKind(state.tool.kind) : null,
locations:
state?.invocation?.toolLocations().map((loc) => ({
path: loc.path,
line: loc.line ?? null,
})) ?? null,
rawInput: state?.args,
});
// Clean up state
subAgentToolStates.delete(event.callId);
};
// Listen for permission requests
const onToolWaitingApproval = async (...args: unknown[]) => {
const event = args[0] as SubAgentApprovalRequestEvent;
if (abortSignal.aborted) return;
const state = subAgentToolStates.get(event.callId);
const content: acp.ToolCallContent[] = [];
// Handle different confirmation types
if (event.confirmationDetails.type === 'edit') {
const editDetails = event.confirmationDetails as unknown as {
type: 'edit';
fileName: string;
originalContent: string | null;
newContent: string;
};
content.push({
type: 'diff',
path: editDetails.fileName,
oldText: editDetails.originalContent ?? '',
newText: editDetails.newContent,
});
}
// Build permission request options from confirmation details
// event.confirmationDetails already contains all fields except onConfirm,
// which we add here to satisfy the type requirement for toPermissionOptions
const fullConfirmationDetails = {
...event.confirmationDetails,
onConfirm: async () => {
// This is a placeholder - the actual response is handled via event.respond
},
} as unknown as ToolCallConfirmationDetails;
const params: acp.RequestPermissionRequest = {
sessionId: this.id,
options: toPermissionOptions(fullConfirmationDetails),
toolCall: {
toolCallId: event.callId,
status: 'pending',
title: event.description || event.name,
content,
locations:
state?.invocation?.toolLocations().map((loc) => ({
path: loc.path,
line: loc.line ?? null,
})) ?? [],
kind: state?.tool ? this.mapToolKind(state.tool.kind) : 'other',
rawInput: state?.args,
},
};
try {
// Request permission from zed client
const output = await this.client.requestPermission(params);
const outcome =
output.outcome.outcome === 'cancelled'
? ToolConfirmationOutcome.Cancel
: z
.nativeEnum(ToolConfirmationOutcome)
.parse(output.outcome.optionId);
// Respond to subagent with the outcome
await event.respond(outcome);
} catch (error) {
// If permission request fails, cancel the tool call
console.error(
`Permission request failed for subagent tool ${event.name}:`,
error,
);
await event.respond(ToolConfirmationOutcome.Cancel);
}
};
// Register event listeners
eventEmitter.on(SubAgentEventType.TOOL_CALL, onToolCall);
eventEmitter.on(SubAgentEventType.TOOL_RESULT, onToolResult);
eventEmitter.on(
SubAgentEventType.TOOL_WAITING_APPROVAL,
onToolWaitingApproval,
);
// Return cleanup functions
cleanupFunctions.push(() => {
eventEmitter.off(SubAgentEventType.TOOL_CALL, onToolCall);
eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult);
eventEmitter.off(
SubAgentEventType.TOOL_WAITING_APPROVAL,
onToolWaitingApproval,
);
});
return cleanupFunctions;
}
/**
* Maps core Tool Kind enum to ACP ToolKind string literals.
*
* @param kind - The core Kind enum value
* @returns The corresponding ACP ToolKind string literal
*/
private mapToolKind(kind: Kind): acp.ToolKind {
const kindMap: Record<Kind, acp.ToolKind> = {
[Kind.Read]: 'read',
[Kind.Edit]: 'edit',
[Kind.Delete]: 'delete',
[Kind.Move]: 'move',
[Kind.Search]: 'search',
[Kind.Execute]: 'execute',
[Kind.Think]: 'think',
[Kind.Fetch]: 'fetch',
[Kind.Other]: 'other',
};
return kindMap[kind] ?? 'other';
}
async #resolvePrompt(
message: acp.ContentBlock[],
abortSignal: AbortSignal,
@@ -1239,27 +859,6 @@ class Session {
}
}
/**
* Converts todo items to plan entries format for zed integration.
* Maps todo status to plan status and assigns a default priority.
*
* @param todos - Array of todo items with id, content, and status
* @returns Array of plan entries with content, priority, and status
*/
function convertTodosToPlanEntries(
todos: Array<{
id: string;
content: string;
status: 'pending' | 'in_progress' | 'completed';
}>,
): acp.PlanEntry[] {
return todos.map((todo) => ({
content: todo.content,
priority: 'medium' as const, // Default priority since todos don't have priority
status: todo.status,
}));
}
function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null {
if (toolResult.error?.message) {
throw new Error(toolResult.error.message);
@@ -1271,6 +870,26 @@ function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null {
type: 'content',
content: { type: 'text', text: toolResult.returnDisplay },
};
} else if (
'type' in toolResult.returnDisplay &&
toolResult.returnDisplay.type === 'todo_list'
) {
// Handle TodoResultDisplay - convert to text representation
const todoText = toolResult.returnDisplay.todos
.map((todo) => {
const statusIcon = {
pending: '○',
in_progress: '◐',
completed: '●',
}[todo.status];
return `${statusIcon} ${todo.content}`;
})
.join('\n');
return {
type: 'content',
content: { type: 'text', text: todoText },
};
} else if (
'type' in toolResult.returnDisplay &&
toolResult.returnDisplay.type === 'plan_summary'

View File

@@ -102,8 +102,6 @@ export * from './tools/web-search/index.js';
export * from './tools/read-many-files.js';
export * from './tools/mcp-client.js';
export * from './tools/mcp-tool.js';
export * from './tools/task.js';
export * from './tools/todoWrite.js';
// MCP OAuth
export { MCPOAuthProvider } from './mcp/oauth-provider.js';

View File

@@ -62,10 +62,9 @@ export type {
SubAgentToolResultEvent,
SubAgentFinishEvent,
SubAgentErrorEvent,
SubAgentApprovalRequestEvent,
} from './subagent-events.js';
export { SubAgentEventEmitter, SubAgentEventType } from './subagent-events.js';
export { SubAgentEventEmitter } from './subagent-events.js';
// Statistics and formatting
export type {

View File

@@ -88,6 +88,17 @@ describe('GlobTool', () => {
expect(result.returnDisplay).toBe('Found 2 matching file(s)');
});
it('should find files case-sensitively when case_sensitive is true', async () => {
const params: GlobToolParams = { pattern: '*.txt', case_sensitive: true };
const invocation = globTool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.llmContent).toContain('Found 1 file(s)');
expect(result.llmContent).toContain(path.join(tempRootDir, 'fileA.txt'));
expect(result.llmContent).not.toContain(
path.join(tempRootDir, 'FileB.TXT'),
);
});
it('should find files case-insensitively by default (pattern: *.TXT)', async () => {
const params: GlobToolParams = { pattern: '*.TXT' };
const invocation = globTool.build(params);
@@ -97,6 +108,18 @@ describe('GlobTool', () => {
expect(result.llmContent).toContain(path.join(tempRootDir, 'FileB.TXT'));
});
it('should find files case-insensitively when case_sensitive is false (pattern: *.TXT)', async () => {
const params: GlobToolParams = {
pattern: '*.TXT',
case_sensitive: false,
};
const invocation = globTool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.llmContent).toContain('Found 2 file(s)');
expect(result.llmContent).toContain(path.join(tempRootDir, 'fileA.txt'));
expect(result.llmContent).toContain(path.join(tempRootDir, 'FileB.TXT'));
});
it('should find files using a pattern that includes a subdirectory', async () => {
const params: GlobToolParams = { pattern: 'sub/*.md' };
const invocation = globTool.build(params);
@@ -184,7 +207,7 @@ describe('GlobTool', () => {
const filesListed = llmContent
.trim()
.split(/\r?\n/)
.slice(2)
.slice(1)
.map((line) => line.trim())
.filter(Boolean);
@@ -197,13 +220,14 @@ describe('GlobTool', () => {
);
});
it('should return error if path is outside workspace', async () => {
it('should return a PATH_NOT_IN_WORKSPACE error if path is outside workspace', async () => {
// Bypassing validation to test execute method directly
vi.spyOn(globTool, 'validateToolParams').mockReturnValue(null);
const params: GlobToolParams = { pattern: '*.txt', path: '/etc' };
const invocation = globTool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.returnDisplay).toBe('Error: Path is not within workspace');
expect(result.error?.type).toBe(ToolErrorType.PATH_NOT_IN_WORKSPACE);
expect(result.returnDisplay).toBe('Path is not within workspace');
});
it('should return a GLOB_EXECUTION_ERROR on glob failure', async () => {
@@ -231,6 +255,15 @@ describe('GlobTool', () => {
expect(globTool.validateToolParams(params)).toBeNull();
});
it('should return null for valid parameters (pattern, path, and case_sensitive)', () => {
const params: GlobToolParams = {
pattern: '*.js',
path: 'sub',
case_sensitive: true,
};
expect(globTool.validateToolParams(params)).toBeNull();
});
it('should return error if pattern is missing (schema validation)', () => {
// Need to correctly define this as an object without pattern
const params = { path: '.' };
@@ -264,6 +297,16 @@ describe('GlobTool', () => {
);
});
it('should return error if case_sensitive is provided but is not a boolean', () => {
const params = {
pattern: '*.ts',
case_sensitive: 'true',
} as unknown as GlobToolParams; // Force incorrect type
expect(globTool.validateToolParams(params)).toBe(
'params/case_sensitive must be boolean',
);
});
it("should return error if search path resolves outside the tool's root directory", () => {
// Create a globTool instance specifically for this test, with a deeper root
tempRootDir = path.join(tempRootDir, 'sub');
@@ -276,7 +319,7 @@ describe('GlobTool', () => {
path: '../../../../../../../../../../tmp', // Definitely outside
};
expect(specificGlobTool.validateToolParams(paramsOutside)).toContain(
'Path is not within workspace',
'resolves outside the allowed workspace directories',
);
});
@@ -286,14 +329,14 @@ describe('GlobTool', () => {
path: 'nonexistent_subdir',
};
expect(globTool.validateToolParams(params)).toContain(
'Path does not exist',
'Search path does not exist',
);
});
it('should return error if specified search path is a file, not a directory', async () => {
const params: GlobToolParams = { pattern: '*.txt', path: 'fileA.txt' };
expect(globTool.validateToolParams(params)).toContain(
'Path is not a directory',
'Search path is not a directory',
);
});
});
@@ -305,10 +348,20 @@ describe('GlobTool', () => {
expect(globTool.validateToolParams(validPath)).toBeNull();
expect(globTool.validateToolParams(invalidPath)).toContain(
'Path is not within workspace',
'resolves outside the allowed workspace directories',
);
});
it('should provide clear error messages when path is outside workspace', () => {
const invalidPath = { pattern: '*.ts', path: '/etc' };
const error = globTool.validateToolParams(invalidPath);
expect(error).toContain(
'resolves outside the allowed workspace directories',
);
expect(error).toContain(tempRootDir);
});
it('should work with paths in workspace subdirectories', async () => {
const params: GlobToolParams = { pattern: '*.md', path: 'sub' };
const invocation = globTool.build(params);
@@ -364,123 +417,47 @@ describe('GlobTool', () => {
expect(result.llmContent).toContain('Found 3 file(s)'); // fileA.txt, FileB.TXT, b.notignored.txt
expect(result.llmContent).not.toContain('a.qwenignored.txt');
});
});
describe('file count truncation', () => {
it('should truncate results when more than 100 files are found', async () => {
// Create 150 test files
for (let i = 1; i <= 150; i++) {
await fs.writeFile(
path.join(tempRootDir, `file${i}.trunctest`),
`content${i}`,
);
}
const params: GlobToolParams = { pattern: '*.trunctest' };
const invocation = globTool.build(params);
const result = await invocation.execute(abortSignal);
const llmContent = partListUnionToString(result.llmContent);
// Should report all 150 files found
expect(llmContent).toContain('Found 150 file(s)');
// Should include truncation notice
expect(llmContent).toContain('[50 files truncated] ...');
// Count the number of .trunctest files mentioned in the output
const fileMatches = llmContent.match(/file\d+\.trunctest/g);
expect(fileMatches).toBeDefined();
expect(fileMatches?.length).toBe(100);
// returnDisplay should indicate truncation
expect(result.returnDisplay).toBe(
'Found 150 matching file(s) (truncated)',
it('should not respect .gitignore when respect_git_ignore is false', async () => {
await fs.writeFile(path.join(tempRootDir, '.gitignore'), '*.ignored.txt');
await fs.writeFile(
path.join(tempRootDir, 'a.ignored.txt'),
'ignored content',
);
});
it('should not truncate when exactly 100 files are found', async () => {
// Create exactly 100 test files
for (let i = 1; i <= 100; i++) {
await fs.writeFile(
path.join(tempRootDir, `exact${i}.trunctest`),
`content${i}`,
);
}
const params: GlobToolParams = { pattern: '*.trunctest' };
const params: GlobToolParams = {
pattern: '*.txt',
respect_git_ignore: false,
};
const invocation = globTool.build(params);
const result = await invocation.execute(abortSignal);
// Should report all 100 files found
expect(result.llmContent).toContain('Found 100 file(s)');
// Should NOT include truncation notice
expect(result.llmContent).not.toContain('truncated');
// Should show all 100 files
expect(result.llmContent).toContain('exact1.trunctest');
expect(result.llmContent).toContain('exact100.trunctest');
// returnDisplay should NOT indicate truncation
expect(result.returnDisplay).toBe('Found 100 matching file(s)');
expect(result.llmContent).toContain('Found 3 file(s)'); // fileA.txt, FileB.TXT, a.ignored.txt
expect(result.llmContent).toContain('a.ignored.txt');
});
it('should not truncate when fewer than 100 files are found', async () => {
// Create 50 test files
for (let i = 1; i <= 50; i++) {
await fs.writeFile(
path.join(tempRootDir, `small${i}.trunctest`),
`content${i}`,
);
}
it('should not respect .qwenignore when respect_qwen_ignore is false', async () => {
await fs.writeFile(
path.join(tempRootDir, '.qwenignore'),
'*.qwenignored.txt',
);
await fs.writeFile(
path.join(tempRootDir, 'a.qwenignored.txt'),
'ignored content',
);
const params: GlobToolParams = { pattern: '*.trunctest' };
// Recreate the tool to pick up the new .qwenignore file
globTool = new GlobTool(mockConfig);
const params: GlobToolParams = {
pattern: '*.txt',
respect_qwen_ignore: false,
};
const invocation = globTool.build(params);
const result = await invocation.execute(abortSignal);
// Should report all 50 files found
expect(result.llmContent).toContain('Found 50 file(s)');
// Should NOT include truncation notice
expect(result.llmContent).not.toContain('truncated');
// returnDisplay should NOT indicate truncation
expect(result.returnDisplay).toBe('Found 50 matching file(s)');
});
it('should use correct singular/plural in truncation message for 1 file truncated', async () => {
// Create 101 test files (will truncate 1 file)
for (let i = 1; i <= 101; i++) {
await fs.writeFile(
path.join(tempRootDir, `singular${i}.trunctest`),
`content${i}`,
);
}
const params: GlobToolParams = { pattern: '*.trunctest' };
const invocation = globTool.build(params);
const result = await invocation.execute(abortSignal);
// Should use singular "file" for 1 truncated file
expect(result.llmContent).toContain('[1 file truncated] ...');
expect(result.llmContent).not.toContain('[1 files truncated]');
});
it('should use correct plural in truncation message for multiple files truncated', async () => {
// Create 105 test files (will truncate 5 files)
for (let i = 1; i <= 105; i++) {
await fs.writeFile(
path.join(tempRootDir, `plural${i}.trunctest`),
`content${i}`,
);
}
const params: GlobToolParams = { pattern: '*.trunctest' };
const invocation = globTool.build(params);
const result = await invocation.execute(abortSignal);
// Should use plural "files" for multiple truncated files
expect(result.llmContent).toContain('[5 files truncated] ...');
expect(result.llmContent).toContain('Found 3 file(s)'); // fileA.txt, FileB.TXT, a.qwenignored.txt
expect(result.llmContent).toContain('a.qwenignored.txt');
});
});
});

View File

@@ -10,17 +10,10 @@ import { glob, escape } from 'glob';
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolNames } from './tool-names.js';
import { resolveAndValidatePath } from '../utils/paths.js';
import { shortenPath, makeRelative } from '../utils/paths.js';
import { type Config } from '../config/config.js';
import {
DEFAULT_FILE_FILTERING_OPTIONS,
type FileFilteringOptions,
} from '../config/constants.js';
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
import { ToolErrorType } from './tool-error.js';
import { getErrorMessage } from '../utils/errors.js';
import type { FileDiscoveryService } from '../services/fileDiscoveryService.js';
const MAX_FILE_COUNT = 100;
// Subset of 'Path' interface provided by 'glob' that we can implement for testing
export interface GlobPath {
@@ -71,68 +64,118 @@ export interface GlobToolParams {
* The directory to search in (optional, defaults to current directory)
*/
path?: string;
/**
* Whether the search should be case-sensitive (optional, defaults to false)
*/
case_sensitive?: boolean;
/**
* Whether to respect .gitignore patterns (optional, defaults to true)
*/
respect_git_ignore?: boolean;
/**
* Whether to respect .qwenignore patterns (optional, defaults to true)
*/
respect_qwen_ignore?: boolean;
}
class GlobToolInvocation extends BaseToolInvocation<
GlobToolParams,
ToolResult
> {
private fileService: FileDiscoveryService;
constructor(
private config: Config,
params: GlobToolParams,
) {
super(params);
this.fileService = config.getFileService();
}
getDescription(): string {
let description = `'${this.params.pattern}'`;
if (this.params.path) {
description += ` in path '${this.params.path}'`;
const searchDir = path.resolve(
this.config.getTargetDir(),
this.params.path || '.',
);
const relativePath = makeRelative(searchDir, this.config.getTargetDir());
description += ` within ${shortenPath(relativePath)}`;
}
return description;
}
async execute(signal: AbortSignal): Promise<ToolResult> {
try {
// Default to target directory if no path is provided
const searchDirAbs = resolveAndValidatePath(
this.config,
this.params.path,
);
const searchLocationDescription = this.params.path
? `within ${searchDirAbs}`
: `in the workspace directory`;
const workspaceContext = this.config.getWorkspaceContext();
const workspaceDirectories = workspaceContext.getDirectories();
// Collect entries from the search directory
let pattern = this.params.pattern;
const fullPath = path.join(searchDirAbs, pattern);
if (fs.existsSync(fullPath)) {
pattern = escape(pattern);
// If a specific path is provided, resolve it and check if it's within workspace
let searchDirectories: readonly string[];
if (this.params.path) {
const searchDirAbsolute = path.resolve(
this.config.getTargetDir(),
this.params.path,
);
if (!workspaceContext.isPathWithinWorkspace(searchDirAbsolute)) {
const rawError = `Error: Path "${this.params.path}" is not within any workspace directory`;
return {
llmContent: rawError,
returnDisplay: `Path is not within workspace`,
error: {
message: rawError,
type: ToolErrorType.PATH_NOT_IN_WORKSPACE,
},
};
}
searchDirectories = [searchDirAbsolute];
} else {
// Search across all workspace directories
searchDirectories = workspaceDirectories;
}
const allEntries = (await glob(pattern, {
cwd: searchDirAbs,
withFileTypes: true,
nodir: true,
stat: true,
nocase: true,
dot: true,
follow: false,
signal,
})) as GlobPath[];
// Get centralized file discovery service
const fileDiscovery = this.config.getFileService();
// Collect entries from all search directories
const allEntries: GlobPath[] = [];
for (const searchDir of searchDirectories) {
let pattern = this.params.pattern;
const fullPath = path.join(searchDir, pattern);
if (fs.existsSync(fullPath)) {
pattern = escape(pattern);
}
const entries = (await glob(pattern, {
cwd: searchDir,
withFileTypes: true,
nodir: true,
stat: true,
nocase: !this.params.case_sensitive,
dot: true,
ignore: this.config.getFileExclusions().getGlobExcludes(),
follow: false,
signal,
})) as GlobPath[];
allEntries.push(...entries);
}
const relativePaths = allEntries.map((p) =>
path.relative(this.config.getTargetDir(), p.fullpath()),
);
const { filteredPaths } = this.fileService.filterFilesWithReport(
relativePaths,
this.getFileFilteringOptions(),
);
const { filteredPaths, gitIgnoredCount, qwenIgnoredCount } =
fileDiscovery.filterFilesWithReport(relativePaths, {
respectGitIgnore:
this.params?.respect_git_ignore ??
this.config.getFileFilteringOptions().respectGitIgnore ??
DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore,
respectQwenIgnore:
this.params?.respect_qwen_ignore ??
this.config.getFileFilteringOptions().respectQwenIgnore ??
DEFAULT_FILE_FILTERING_OPTIONS.respectQwenIgnore,
});
const filteredAbsolutePaths = new Set(
filteredPaths.map((p) => path.resolve(this.config.getTargetDir(), p)),
@@ -143,8 +186,20 @@ class GlobToolInvocation extends BaseToolInvocation<
);
if (!filteredEntries || filteredEntries.length === 0) {
let message = `No files found matching pattern "${this.params.pattern}"`;
if (searchDirectories.length === 1) {
message += ` within ${searchDirectories[0]}`;
} else {
message += ` within ${searchDirectories.length} workspace directories`;
}
if (gitIgnoredCount > 0) {
message += ` (${gitIgnoredCount} files were git-ignored)`;
}
if (qwenIgnoredCount > 0) {
message += ` (${qwenIgnoredCount} files were qwen-ignored)`;
}
return {
llmContent: `No files found matching pattern "${this.params.pattern}" ${searchLocationDescription}`,
llmContent: message,
returnDisplay: `No files found`,
};
}
@@ -160,32 +215,29 @@ class GlobToolInvocation extends BaseToolInvocation<
oneDayInMs,
);
const totalFileCount = sortedEntries.length;
const truncated = totalFileCount > MAX_FILE_COUNT;
// Limit to MAX_FILE_COUNT if needed
const entriesToShow = truncated
? sortedEntries.slice(0, MAX_FILE_COUNT)
: sortedEntries;
const sortedAbsolutePaths = entriesToShow.map((entry) =>
const sortedAbsolutePaths = sortedEntries.map((entry) =>
entry.fullpath(),
);
const fileListDescription = sortedAbsolutePaths.join('\n');
const fileCount = sortedAbsolutePaths.length;
let resultMessage = `Found ${totalFileCount} file(s) matching "${this.params.pattern}" ${searchLocationDescription}`;
resultMessage += `, sorted by modification time (newest first):\n---\n${fileListDescription}`;
// Add truncation notice if needed
if (truncated) {
const omittedFiles = totalFileCount - MAX_FILE_COUNT;
const fileTerm = omittedFiles === 1 ? 'file' : 'files';
resultMessage += `\n---\n[${omittedFiles} ${fileTerm} truncated] ...`;
let resultMessage = `Found ${fileCount} file(s) matching "${this.params.pattern}"`;
if (searchDirectories.length === 1) {
resultMessage += ` within ${searchDirectories[0]}`;
} else {
resultMessage += ` across ${searchDirectories.length} workspace directories`;
}
if (gitIgnoredCount > 0) {
resultMessage += ` (${gitIgnoredCount} additional files were git-ignored)`;
}
if (qwenIgnoredCount > 0) {
resultMessage += ` (${qwenIgnoredCount} additional files were qwen-ignored)`;
}
resultMessage += `, sorted by modification time (newest first):\n${fileListDescription}`;
return {
llmContent: resultMessage,
returnDisplay: `Found ${totalFileCount} matching file(s)${truncated ? ' (truncated)' : ''}`,
returnDisplay: `Found ${fileCount} matching file(s)`,
};
} catch (error) {
const errorMessage =
@@ -194,7 +246,7 @@ class GlobToolInvocation extends BaseToolInvocation<
const rawError = `Error during glob search operation: ${errorMessage}`;
return {
llmContent: rawError,
returnDisplay: `Error: ${errorMessage || 'An unexpected error occurred.'}`,
returnDisplay: `Error: An unexpected error occurred.`,
error: {
message: rawError,
type: ToolErrorType.GLOB_EXECUTION_ERROR,
@@ -202,18 +254,6 @@ class GlobToolInvocation extends BaseToolInvocation<
};
}
}
private getFileFilteringOptions(): FileFilteringOptions {
const options = this.config.getFileFilteringOptions?.();
return {
respectGitIgnore:
options?.respectGitIgnore ??
DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore,
respectQwenIgnore:
options?.respectQwenIgnore ??
DEFAULT_FILE_FILTERING_OPTIONS.respectQwenIgnore,
};
}
}
/**
@@ -226,19 +266,35 @@ export class GlobTool extends BaseDeclarativeTool<GlobToolParams, ToolResult> {
super(
GlobTool.Name,
'FindFiles',
'Fast file pattern matching tool that works with any codebase size\n- Supports glob patterns like "**/*.js" or "src/**/*.ts"\n- Returns matching file paths sorted by modification time\n- Use this tool when you need to find files by name patterns\n- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead\n- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.',
'Efficiently finds files matching specific glob patterns (e.g., `src/**/*.ts`, `**/*.md`), returning absolute paths sorted by modification time (newest first). Ideal for quickly locating files based on their name or path structure, especially in large codebases.',
Kind.Search,
{
properties: {
pattern: {
description: 'The glob pattern to match files against',
description:
"The glob pattern to match against (e.g., '**/*.py', 'docs/*.md').",
type: 'string',
},
path: {
description:
'The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.',
'Optional: The absolute path to the directory to search within. If omitted, searches the root directory.',
type: 'string',
},
case_sensitive: {
description:
'Optional: Whether the search should be case-sensitive. Defaults to false.',
type: 'boolean',
},
respect_git_ignore: {
description:
'Optional: Whether to respect .gitignore patterns when finding files. Only available in git repositories. Defaults to true.',
type: 'boolean',
},
respect_qwen_ignore: {
description:
'Optional: Whether to respect .qwenignore patterns when finding files. Defaults to true.',
type: 'boolean',
},
},
required: ['pattern'],
type: 'object',
@@ -252,6 +308,29 @@ export class GlobTool extends BaseDeclarativeTool<GlobToolParams, ToolResult> {
protected override validateToolParamValues(
params: GlobToolParams,
): string | null {
const searchDirAbsolute = path.resolve(
this.config.getTargetDir(),
params.path || '.',
);
const workspaceContext = this.config.getWorkspaceContext();
if (!workspaceContext.isPathWithinWorkspace(searchDirAbsolute)) {
const directories = workspaceContext.getDirectories();
return `Search path ("${searchDirAbsolute}") resolves outside the allowed workspace directories: ${directories.join(', ')}`;
}
const targetDir = searchDirAbsolute || this.config.getTargetDir();
try {
if (!fs.existsSync(targetDir)) {
return `Search path does not exist ${targetDir}`;
}
if (!fs.statSync(targetDir).isDirectory()) {
return `Search path is not a directory: ${targetDir}`;
}
} catch (e: unknown) {
return `Error accessing search path: ${e}`;
}
if (
!params.pattern ||
typeof params.pattern !== 'string' ||
@@ -260,15 +339,6 @@ export class GlobTool extends BaseDeclarativeTool<GlobToolParams, ToolResult> {
return "The 'pattern' parameter cannot be empty.";
}
// Only validate path if one is provided
if (params.path) {
try {
resolveAndValidatePath(this.config, params.path);
} catch (error) {
return getErrorMessage(error);
}
}
return null;
}

View File

@@ -84,11 +84,11 @@ describe('GrepTool', () => {
expect(grepTool.validateToolParams(params)).toBeNull();
});
it('should return null for valid params (pattern, path, and glob)', () => {
it('should return null for valid params (pattern, path, and include)', () => {
const params: GrepToolParams = {
pattern: 'hello',
path: '.',
glob: '*.txt',
include: '*.txt',
};
expect(grepTool.validateToolParams(params)).toBeNull();
});
@@ -111,7 +111,7 @@ describe('GrepTool', () => {
const params: GrepToolParams = { pattern: 'hello', path: 'nonexistent' };
// Check for the core error message, as the full path might vary
expect(grepTool.validateToolParams(params)).toContain(
'Path does not exist:',
'Failed to access path stats for',
);
expect(grepTool.validateToolParams(params)).toContain('nonexistent');
});
@@ -155,8 +155,8 @@ describe('GrepTool', () => {
expect(result.returnDisplay).toBe('Found 1 match');
});
it('should find matches with a glob filter', async () => {
const params: GrepToolParams = { pattern: 'hello', glob: '*.js' };
it('should find matches with an include glob', async () => {
const params: GrepToolParams = { pattern: 'hello', include: '*.js' };
const invocation = grepTool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.llmContent).toContain(
@@ -169,7 +169,7 @@ describe('GrepTool', () => {
expect(result.returnDisplay).toBe('Found 1 match');
});
it('should find matches with a glob filter and path', async () => {
it('should find matches with an include glob and path', async () => {
await fs.writeFile(
path.join(tempRootDir, 'sub', 'another.js'),
'const greeting = "hello";',
@@ -177,7 +177,7 @@ describe('GrepTool', () => {
const params: GrepToolParams = {
pattern: 'hello',
path: 'sub',
glob: '*.js',
include: '*.js',
};
const invocation = grepTool.build(params);
const result = await invocation.execute(abortSignal);
@@ -244,23 +244,59 @@ describe('GrepTool', () => {
describe('multi-directory workspace', () => {
it('should search across all workspace directories when no path is specified', async () => {
// The new implementation searches only in the target directory (first workspace directory)
// when no path is specified, not across all workspace directories
const params: GrepToolParams = { pattern: 'world' };
const invocation = grepTool.build(params);
const result = await invocation.execute(abortSignal);
// Should find matches in the target directory only
expect(result.llmContent).toContain(
'Found 3 matches for pattern "world" in the workspace directory',
// Create additional directory with test files
const secondDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'grep-tool-second-'),
);
await fs.writeFile(
path.join(secondDir, 'other.txt'),
'hello from second directory\nworld in second',
);
await fs.writeFile(
path.join(secondDir, 'another.js'),
'function world() { return "test"; }',
);
// Matches from target directory
// Create a mock config with multiple directories
const multiDirConfig = {
getTargetDir: () => tempRootDir,
getWorkspaceContext: () =>
createMockWorkspaceContext(tempRootDir, [secondDir]),
getFileExclusions: () => ({
getGlobExcludes: () => [],
}),
} as unknown as Config;
const multiDirGrepTool = new GrepTool(multiDirConfig);
const params: GrepToolParams = { pattern: 'world' };
const invocation = multiDirGrepTool.build(params);
const result = await invocation.execute(abortSignal);
// Should find matches in both directories
expect(result.llmContent).toContain(
'Found 5 matches for pattern "world"',
);
// Matches from first directory
expect(result.llmContent).toContain('fileA.txt');
expect(result.llmContent).toContain('L1: hello world');
expect(result.llmContent).toContain('L2: second line with world');
expect(result.llmContent).toContain('fileC.txt');
expect(result.llmContent).toContain('L1: another world in sub dir');
// Matches from second directory (with directory name prefix)
const secondDirName = path.basename(secondDir);
expect(result.llmContent).toContain(
`File: ${path.join(secondDirName, 'other.txt')}`,
);
expect(result.llmContent).toContain('L2: world in second');
expect(result.llmContent).toContain(
`File: ${path.join(secondDirName, 'another.js')}`,
);
expect(result.llmContent).toContain('L1: function world()');
// Clean up
await fs.rm(secondDir, { recursive: true, force: true });
});
it('should search only specified path within workspace directories', async () => {
@@ -310,18 +346,16 @@ describe('GrepTool', () => {
it('should generate correct description with pattern only', () => {
const params: GrepToolParams = { pattern: 'testPattern' };
const invocation = grepTool.build(params);
expect(invocation.getDescription()).toBe("'testPattern' in path './'");
expect(invocation.getDescription()).toBe("'testPattern'");
});
it('should generate correct description with pattern and glob', () => {
it('should generate correct description with pattern and include', () => {
const params: GrepToolParams = {
pattern: 'testPattern',
glob: '*.ts',
include: '*.ts',
};
const invocation = grepTool.build(params);
expect(invocation.getDescription()).toBe(
"'testPattern' in path './' (filter: '*.ts')",
);
expect(invocation.getDescription()).toBe("'testPattern' in *.ts");
});
it('should generate correct description with pattern and path', async () => {
@@ -332,37 +366,49 @@ describe('GrepTool', () => {
path: path.join('src', 'app'),
};
const invocation = grepTool.build(params);
expect(invocation.getDescription()).toContain(
"'testPattern' in path 'src",
);
expect(invocation.getDescription()).toContain("app'");
// The path will be relative to the tempRootDir, so we check for containment.
expect(invocation.getDescription()).toContain("'testPattern' within");
expect(invocation.getDescription()).toContain(path.join('src', 'app'));
});
it('should indicate searching workspace directory when no path specified', () => {
it('should indicate searching across all workspace directories when no path specified', () => {
// Create a mock config with multiple directories
const multiDirConfig = {
getTargetDir: () => tempRootDir,
getWorkspaceContext: () =>
createMockWorkspaceContext(tempRootDir, ['/another/dir']),
getFileExclusions: () => ({
getGlobExcludes: () => [],
}),
} as unknown as Config;
const multiDirGrepTool = new GrepTool(multiDirConfig);
const params: GrepToolParams = { pattern: 'testPattern' };
const invocation = grepTool.build(params);
expect(invocation.getDescription()).toBe("'testPattern' in path './'");
const invocation = multiDirGrepTool.build(params);
expect(invocation.getDescription()).toBe(
"'testPattern' across all workspace directories",
);
});
it('should generate correct description with pattern, glob, and path', async () => {
it('should generate correct description with pattern, include, and path', async () => {
const dirPath = path.join(tempRootDir, 'src', 'app');
await fs.mkdir(dirPath, { recursive: true });
const params: GrepToolParams = {
pattern: 'testPattern',
glob: '*.ts',
include: '*.ts',
path: path.join('src', 'app'),
};
const invocation = grepTool.build(params);
expect(invocation.getDescription()).toContain(
"'testPattern' in path 'src",
"'testPattern' in *.ts within",
);
expect(invocation.getDescription()).toContain("(filter: '*.ts')");
expect(invocation.getDescription()).toContain(path.join('src', 'app'));
});
it('should use ./ for root path in description', () => {
const params: GrepToolParams = { pattern: 'testPattern', path: '.' };
const invocation = grepTool.build(params);
expect(invocation.getDescription()).toBe("'testPattern' in path '.'");
expect(invocation.getDescription()).toBe("'testPattern' within ./");
});
});
@@ -376,50 +422,67 @@ describe('GrepTool', () => {
}
});
it('should show all results when no limit is specified', async () => {
it('should limit results to default 20 matches', async () => {
const params: GrepToolParams = { pattern: 'testword' };
const invocation = grepTool.build(params);
const result = await invocation.execute(abortSignal);
// New implementation shows all matches when limit is not specified
expect(result.llmContent).toContain('Found 30 matches');
expect(result.llmContent).not.toContain('truncated');
expect(result.returnDisplay).toBe('Found 30 matches');
expect(result.llmContent).toContain('Found 20 matches');
expect(result.llmContent).toContain(
'showing first 20 of 30+ total matches',
);
expect(result.llmContent).toContain('WARNING: Results truncated');
expect(result.returnDisplay).toContain(
'Found 20 matches (truncated from 30+)',
);
});
it('should respect custom limit parameter', async () => {
const params: GrepToolParams = { pattern: 'testword', limit: 5 };
it('should respect custom maxResults parameter', async () => {
const params: GrepToolParams = { pattern: 'testword', maxResults: 5 };
const invocation = grepTool.build(params);
const result = await invocation.execute(abortSignal);
// Should find 30 total but limit to 5
expect(result.llmContent).toContain('Found 30 matches');
expect(result.llmContent).toContain('25 lines truncated');
expect(result.returnDisplay).toContain('Found 30 matches (truncated)');
expect(result.llmContent).toContain('Found 5 matches');
expect(result.llmContent).toContain(
'showing first 5 of 30+ total matches',
);
expect(result.llmContent).toContain('current: 5');
expect(result.returnDisplay).toContain(
'Found 5 matches (truncated from 30+)',
);
});
it('should not show truncation warning when all results fit', async () => {
const params: GrepToolParams = { pattern: 'testword', limit: 50 };
const params: GrepToolParams = { pattern: 'testword', maxResults: 50 };
const invocation = grepTool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.llmContent).toContain('Found 30 matches');
expect(result.llmContent).not.toContain('truncated');
expect(result.llmContent).not.toContain('WARNING: Results truncated');
expect(result.llmContent).not.toContain('showing first');
expect(result.returnDisplay).toBe('Found 30 matches');
});
it('should not validate limit parameter', () => {
// limit parameter has no validation constraints in the new implementation
const params = { pattern: 'test', limit: 5 };
const error = grepTool.validateToolParams(params as GrepToolParams);
expect(error).toBeNull();
it('should validate maxResults parameter', () => {
const invalidParams = [
{ pattern: 'test', maxResults: 0 },
{ pattern: 'test', maxResults: 101 },
{ pattern: 'test', maxResults: -1 },
{ pattern: 'test', maxResults: 1.5 },
];
invalidParams.forEach((params) => {
const error = grepTool.validateToolParams(params as GrepToolParams);
expect(error).toBeTruthy(); // Just check that validation fails
expect(error).toMatch(/maxResults|must be/); // Check it's about maxResults validation
});
});
it('should accept valid limit parameter', () => {
it('should accept valid maxResults parameter', () => {
const validParams = [
{ pattern: 'test', limit: 1 },
{ pattern: 'test', limit: 50 },
{ pattern: 'test', limit: 100 },
{ pattern: 'test', maxResults: 1 },
{ pattern: 'test', maxResults: 50 },
{ pattern: 'test', maxResults: 100 },
];
validParams.forEach((params) => {

View File

@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
import path from 'node:path';
import { EOL } from 'node:os';
@@ -12,15 +13,13 @@ import { globStream } from 'glob';
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolNames } from './tool-names.js';
import { resolveAndValidatePath } from '../utils/paths.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
import { getErrorMessage, isNodeError } from '../utils/errors.js';
import { isGitRepository } from '../utils/gitUtils.js';
import type { Config } from '../config/config.js';
import type { FileExclusions } from '../utils/ignorePatterns.js';
import { ToolErrorType } from './tool-error.js';
const MAX_LLM_CONTENT_LENGTH = 20_000;
// --- Interfaces ---
/**
@@ -38,14 +37,14 @@ export interface GrepToolParams {
path?: string;
/**
* Glob pattern to filter files (e.g. "*.js", "*.{ts,tsx}")
* File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")
*/
glob?: string;
include?: string;
/**
* Maximum number of matching lines to return (optional, shows all if not specified)
* Maximum number of matches to return (optional, defaults to 20)
*/
limit?: number;
maxResults?: number;
}
/**
@@ -71,57 +70,121 @@ class GrepToolInvocation extends BaseToolInvocation<
this.fileExclusions = config.getFileExclusions();
}
/**
* Checks if a path is within the root directory and resolves it.
* @param relativePath Path relative to the root directory (or undefined for root).
* @returns The absolute path if valid and exists, or null if no path specified (to search all directories).
* @throws {Error} If path is outside root, doesn't exist, or isn't a directory.
*/
private resolveAndValidatePath(relativePath?: string): string | null {
// If no path specified, return null to indicate searching all workspace directories
if (!relativePath) {
return null;
}
const targetPath = path.resolve(this.config.getTargetDir(), relativePath);
// Security Check: Ensure the resolved path is within workspace boundaries
const workspaceContext = this.config.getWorkspaceContext();
if (!workspaceContext.isPathWithinWorkspace(targetPath)) {
const directories = workspaceContext.getDirectories();
throw new Error(
`Path validation failed: Attempted path "${relativePath}" resolves outside the allowed workspace directories: ${directories.join(', ')}`,
);
}
// Check existence and type after resolving
try {
const stats = fs.statSync(targetPath);
if (!stats.isDirectory()) {
throw new Error(`Path is not a directory: ${targetPath}`);
}
} catch (error: unknown) {
if (isNodeError(error) && error.code !== 'ENOENT') {
throw new Error(`Path does not exist: ${targetPath}`);
}
throw new Error(
`Failed to access path stats for ${targetPath}: ${error}`,
);
}
return targetPath;
}
async execute(signal: AbortSignal): Promise<ToolResult> {
try {
// Default to target directory if no path is provided
const searchDirAbs = resolveAndValidatePath(
this.config,
this.params.path,
);
const workspaceContext = this.config.getWorkspaceContext();
const searchDirAbs = this.resolveAndValidatePath(this.params.path);
const searchDirDisplay = this.params.path || '.';
// Perform grep search
const rawMatches = await this.performGrepSearch({
pattern: this.params.pattern,
path: searchDirAbs,
glob: this.params.glob,
signal,
});
// Determine which directories to search
let searchDirectories: readonly string[];
if (searchDirAbs === null) {
// No path specified - search all workspace directories
searchDirectories = workspaceContext.getDirectories();
} else {
// Specific path provided - search only that directory
searchDirectories = [searchDirAbs];
}
// Build search description
const searchLocationDescription = this.params.path
? `in path "${searchDirDisplay}"`
: `in the workspace directory`;
// Collect matches from all search directories
let allMatches: GrepMatch[] = [];
const maxResults = this.params.maxResults ?? 20; // Default to 20 results
let totalMatchesFound = 0;
let searchTruncated = false;
const filterDescription = this.params.glob
? ` (filter: "${this.params.glob}")`
: '';
for (const searchDir of searchDirectories) {
const matches = await this.performGrepSearch({
pattern: this.params.pattern,
path: searchDir,
include: this.params.include,
signal,
});
// Check if we have any matches
if (rawMatches.length === 0) {
const noMatchMsg = `No matches found for pattern "${this.params.pattern}" ${searchLocationDescription}${filterDescription}.`;
totalMatchesFound += matches.length;
// Add directory prefix if searching multiple directories
if (searchDirectories.length > 1) {
const dirName = path.basename(searchDir);
matches.forEach((match) => {
match.filePath = path.join(dirName, match.filePath);
});
}
// Apply result limiting
const remainingSlots = maxResults - allMatches.length;
if (remainingSlots <= 0) {
searchTruncated = true;
break;
}
if (matches.length > remainingSlots) {
allMatches = allMatches.concat(matches.slice(0, remainingSlots));
searchTruncated = true;
break;
} else {
allMatches = allMatches.concat(matches);
}
}
let searchLocationDescription: string;
if (searchDirAbs === null) {
const numDirs = workspaceContext.getDirectories().length;
searchLocationDescription =
numDirs > 1
? `across ${numDirs} workspace directories`
: `in the workspace directory`;
} else {
searchLocationDescription = `in path "${searchDirDisplay}"`;
}
if (allMatches.length === 0) {
const noMatchMsg = `No matches found for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}.`;
return { llmContent: noMatchMsg, returnDisplay: `No matches found` };
}
// Apply line limit if specified
let truncatedByLineLimit = false;
let matchesToInclude = rawMatches;
if (
this.params.limit !== undefined &&
rawMatches.length > this.params.limit
) {
matchesToInclude = rawMatches.slice(0, this.params.limit);
truncatedByLineLimit = true;
}
const totalMatches = rawMatches.length;
const matchTerm = totalMatches === 1 ? 'match' : 'matches';
// Build header
const header = `Found ${totalMatches} ${matchTerm} for pattern "${this.params.pattern}" ${searchLocationDescription}${filterDescription}:\n---\n`;
// Group matches by file
const matchesByFile = matchesToInclude.reduce(
const matchesByFile = allMatches.reduce(
(acc, match) => {
const fileKey = match.filePath;
if (!acc[fileKey]) {
@@ -134,51 +197,46 @@ class GrepToolInvocation extends BaseToolInvocation<
{} as Record<string, GrepMatch[]>,
);
// Build grep output
let grepOutput = '';
const matchCount = allMatches.length;
const matchTerm = matchCount === 1 ? 'match' : 'matches';
// Build the header with truncation info if needed
let headerText = `Found ${matchCount} ${matchTerm} for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}`;
if (searchTruncated) {
headerText += ` (showing first ${matchCount} of ${totalMatchesFound}+ total matches)`;
}
let llmContent = `${headerText}:
---
`;
for (const filePath in matchesByFile) {
grepOutput += `File: ${filePath}\n`;
llmContent += `File: ${filePath}\n`;
matchesByFile[filePath].forEach((match) => {
const trimmedLine = match.line.trim();
grepOutput += `L${match.lineNumber}: ${trimmedLine}\n`;
llmContent += `L${match.lineNumber}: ${trimmedLine}\n`;
});
grepOutput += '---\n';
llmContent += '---\n';
}
// Apply character limit as safety net
let truncatedByCharLimit = false;
if (grepOutput.length > MAX_LLM_CONTENT_LENGTH) {
grepOutput = grepOutput.slice(0, MAX_LLM_CONTENT_LENGTH) + '...';
truncatedByCharLimit = true;
// Add truncation guidance if results were limited
if (searchTruncated) {
llmContent += `\nWARNING: Results truncated to prevent context overflow. To see more results:
- Use a more specific pattern to reduce matches
- Add file filters with the 'include' parameter (e.g., "*.js", "src/**")
- Specify a narrower 'path' to search in a subdirectory
- Increase 'maxResults' parameter if you need more matches (current: ${maxResults})`;
}
// Count how many lines we actually included after character truncation
const finalLines = grepOutput
.split('\n')
.filter(
(line) =>
line.trim() && !line.startsWith('File:') && !line.startsWith('---'),
);
const includedLines = finalLines.length;
// Build result
let llmContent = header + grepOutput;
// Add truncation notice if needed
if (truncatedByLineLimit || truncatedByCharLimit) {
const omittedMatches = totalMatches - includedLines;
llmContent += ` [${omittedMatches} ${omittedMatches === 1 ? 'line' : 'lines'} truncated] ...`;
}
// Build display message
let displayMessage = `Found ${totalMatches} ${matchTerm}`;
if (truncatedByLineLimit || truncatedByCharLimit) {
displayMessage += ` (truncated)`;
let displayText = `Found ${matchCount} ${matchTerm}`;
if (searchTruncated) {
displayText += ` (truncated from ${totalMatchesFound}+)`;
}
return {
llmContent: llmContent.trim(),
returnDisplay: displayMessage,
returnDisplay: displayText,
};
} catch (error) {
console.error(`Error during GrepLogic execution: ${error}`);
@@ -271,26 +329,50 @@ class GrepToolInvocation extends BaseToolInvocation<
* @returns A string describing the grep
*/
getDescription(): string {
let description = `'${this.params.pattern}' in path '${this.params.path || './'}'`;
if (this.params.glob) {
description += ` (filter: '${this.params.glob}')`;
let description = `'${this.params.pattern}'`;
if (this.params.include) {
description += ` in ${this.params.include}`;
}
if (this.params.path) {
const resolvedPath = path.resolve(
this.config.getTargetDir(),
this.params.path,
);
if (
resolvedPath === this.config.getTargetDir() ||
this.params.path === '.'
) {
description += ` within ./`;
} else {
const relativePath = makeRelative(
resolvedPath,
this.config.getTargetDir(),
);
description += ` within ${shortenPath(relativePath)}`;
}
} else {
// When no path is specified, indicate searching all workspace directories
const workspaceContext = this.config.getWorkspaceContext();
const directories = workspaceContext.getDirectories();
if (directories.length > 1) {
description += ` across all workspace directories`;
}
}
return description;
}
/**
* Performs the actual search using the prioritized strategies.
* @param options Search options including pattern, absolute path, and glob filter.
* @param options Search options including pattern, absolute path, and include glob.
* @returns A promise resolving to an array of match objects.
*/
private async performGrepSearch(options: {
pattern: string;
path: string; // Expects absolute path
glob?: string;
include?: string;
signal: AbortSignal;
}): Promise<GrepMatch[]> {
const { pattern, path: absolutePath, glob } = options;
const { pattern, path: absolutePath, include } = options;
let strategyUsed = 'none';
try {
@@ -308,8 +390,8 @@ class GrepToolInvocation extends BaseToolInvocation<
'--ignore-case',
pattern,
];
if (glob) {
gitArgs.push('--', glob);
if (include) {
gitArgs.push('--', include);
}
try {
@@ -375,8 +457,8 @@ class GrepToolInvocation extends BaseToolInvocation<
})
.filter((dir): dir is string => !!dir);
commonExcludes.forEach((dir) => grepArgs.push(`--exclude-dir=${dir}`));
if (glob) {
grepArgs.push(`--include=${glob}`);
if (include) {
grepArgs.push(`--include=${include}`);
}
grepArgs.push(pattern);
grepArgs.push('.');
@@ -455,7 +537,7 @@ class GrepToolInvocation extends BaseToolInvocation<
'GrepLogic: Falling back to JavaScript grep implementation.',
);
strategyUsed = 'javascript fallback';
const globPattern = glob ? glob : '**/*';
const globPattern = include ? include : '**/*';
const ignorePatterns = this.fileExclusions.getGlobExcludes();
const filesIterator = globStream(globPattern, {
@@ -521,30 +603,32 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
constructor(private readonly config: Config) {
super(
GrepTool.Name,
'Grep',
'A powerful search tool for finding patterns in files\n\n Usage:\n - ALWAYS use Grep for search tasks. NEVER invoke `grep` or `rg` as a Bash command. The Grep tool has been optimized for correct permissions and access.\n - Supports full regex syntax (e.g., "log.*Error", "function\\s+\\w+")\n - Filter files with glob parameter (e.g., "*.js", "**/*.tsx")\n - Case-insensitive by default\n - Use Task tool for open-ended searches requiring multiple rounds\n',
'SearchText',
'Searches for a regular expression pattern within the content of files in a specified directory (or current working directory). Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers.',
Kind.Search,
{
properties: {
pattern: {
type: 'string',
description:
'The regular expression pattern to search for in file contents',
},
glob: {
"The regular expression (regex) pattern to search for within file contents (e.g., 'function\\s+myFunction', 'import\\s+\\{.*\\}\\s+from\\s+.*').",
type: 'string',
description:
'Glob pattern to filter files (e.g. "*.js", "*.{ts,tsx}")',
},
path: {
description:
'Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.',
type: 'string',
description:
'File or directory to search in. Defaults to current working directory.',
},
limit: {
type: 'number',
include: {
description:
'Limit output to first N matching lines. Optional - shows all matches if not specified.',
"Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).",
type: 'string',
},
maxResults: {
description:
'Optional: Maximum number of matches to return to prevent context overflow (default: 20, max: 100). Use lower values for broad searches, higher for specific searches.',
type: 'number',
minimum: 1,
maximum: 100,
},
},
required: ['pattern'],
@@ -553,6 +637,47 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
);
}
/**
* Checks if a path is within the root directory and resolves it.
* @param relativePath Path relative to the root directory (or undefined for root).
* @returns The absolute path if valid and exists, or null if no path specified (to search all directories).
* @throws {Error} If path is outside root, doesn't exist, or isn't a directory.
*/
private resolveAndValidatePath(relativePath?: string): string | null {
// If no path specified, return null to indicate searching all workspace directories
if (!relativePath) {
return null;
}
const targetPath = path.resolve(this.config.getTargetDir(), relativePath);
// Security Check: Ensure the resolved path is within workspace boundaries
const workspaceContext = this.config.getWorkspaceContext();
if (!workspaceContext.isPathWithinWorkspace(targetPath)) {
const directories = workspaceContext.getDirectories();
throw new Error(
`Path validation failed: Attempted path "${relativePath}" resolves outside the allowed workspace directories: ${directories.join(', ')}`,
);
}
// Check existence and type after resolving
try {
const stats = fs.statSync(targetPath);
if (!stats.isDirectory()) {
throw new Error(`Path is not a directory: ${targetPath}`);
}
} catch (error: unknown) {
if (isNodeError(error) && error.code !== 'ENOENT') {
throw new Error(`Path does not exist: ${targetPath}`);
}
throw new Error(
`Failed to access path stats for ${targetPath}: ${error}`,
);
}
return targetPath;
}
/**
* Validates the parameters for the tool
* @param params Parameters to validate
@@ -561,17 +686,27 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
protected override validateToolParamValues(
params: GrepToolParams,
): string | null {
// Validate pattern is a valid regex
try {
new RegExp(params.pattern);
} catch (error) {
return `Invalid regular expression pattern: ${params.pattern}. Error: ${getErrorMessage(error)}`;
return `Invalid regular expression pattern provided: ${params.pattern}. Error: ${getErrorMessage(error)}`;
}
// Validate maxResults if provided
if (params.maxResults !== undefined) {
if (
!Number.isInteger(params.maxResults) ||
params.maxResults < 1 ||
params.maxResults > 100
) {
return `maxResults must be an integer between 1 and 100, got: ${params.maxResults}`;
}
}
// Only validate path if one is provided
if (params.path) {
try {
resolveAndValidatePath(this.config, params.path);
this.resolveAndValidatePath(params.path);
} catch (error) {
return getErrorMessage(error);
}

View File

@@ -184,15 +184,17 @@ describe('RipGrepTool', () => {
};
// Check for the core error message, as the full path might vary
expect(grepTool.validateToolParams(params)).toContain(
'Path does not exist:',
'Failed to access path stats for',
);
expect(grepTool.validateToolParams(params)).toContain('nonexistent');
});
it('should allow path to be a file', () => {
it('should return error if path is a file, not a directory', async () => {
const filePath = path.join(tempRootDir, 'fileA.txt');
const params: RipGrepToolParams = { pattern: 'hello', path: filePath };
expect(grepTool.validateToolParams(params)).toBeNull();
expect(grepTool.validateToolParams(params)).toContain(
`Path is not a directory: ${filePath}`,
);
});
});
@@ -430,7 +432,7 @@ describe('RipGrepTool', () => {
const invocation = grepTool.build(params);
const result = await invocation.execute(abortSignal);
expect(String(result.llmContent).length).toBeLessThanOrEqual(21_000);
expect(String(result.llmContent).length).toBeLessThanOrEqual(20_000);
expect(result.llmContent).toMatch(/\[\d+ lines? truncated\] \.\.\./);
expect(result.returnDisplay).toContain('truncated');
});
@@ -565,26 +567,6 @@ describe('RipGrepTool', () => {
);
});
it('should search within a single file when path is a file', async () => {
mockSpawn.mockImplementationOnce(
createMockSpawn({
outputData: `fileA.txt:1:hello world${EOL}fileA.txt:2:second line with world${EOL}`,
exitCode: 0,
}),
);
const params: RipGrepToolParams = {
pattern: 'world',
path: path.join(tempRootDir, 'fileA.txt'),
};
const invocation = grepTool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.llmContent).toContain('Found 2 matches');
expect(result.llmContent).toContain('fileA.txt:1:hello world');
expect(result.llmContent).toContain('fileA.txt:2:second line with world');
expect(result.returnDisplay).toBe('Found 2 matches');
});
it('should throw an error if ripgrep is not available', async () => {
// Make ensureRipgrepBinary throw
(ensureRipgrepPath as Mock).mockRejectedValue(
@@ -666,9 +648,7 @@ describe('RipGrepTool', () => {
describe('error handling and edge cases', () => {
it('should handle workspace boundary violations', () => {
const params: RipGrepToolParams = { pattern: 'test', path: '../outside' };
expect(() => grepTool.build(params)).toThrow(
/Path is not within workspace/,
);
expect(() => grepTool.build(params)).toThrow(/Path validation failed/);
});
it('should handle empty directories gracefully', async () => {
@@ -1152,9 +1132,7 @@ describe('RipGrepTool', () => {
glob: '*.ts',
};
const invocation = grepTool.build(params);
expect(invocation.getDescription()).toBe(
"'testPattern' (filter: '*.ts')",
);
expect(invocation.getDescription()).toBe("'testPattern' in *.ts");
});
it('should generate correct description with pattern and path', async () => {
@@ -1165,10 +1143,9 @@ describe('RipGrepTool', () => {
path: path.join('src', 'app'),
};
const invocation = grepTool.build(params);
expect(invocation.getDescription()).toContain(
"'testPattern' in path 'src",
);
expect(invocation.getDescription()).toContain("app'");
// The path will be relative to the tempRootDir, so we check for containment.
expect(invocation.getDescription()).toContain("'testPattern' within");
expect(invocation.getDescription()).toContain(path.join('src', 'app'));
});
it('should generate correct description with default search path', () => {
@@ -1187,15 +1164,15 @@ describe('RipGrepTool', () => {
};
const invocation = grepTool.build(params);
expect(invocation.getDescription()).toContain(
"'testPattern' in path 'src",
"'testPattern' in *.ts within",
);
expect(invocation.getDescription()).toContain("(filter: '*.ts')");
expect(invocation.getDescription()).toContain(path.join('src', 'app'));
});
it('should use path when specified in description', () => {
it('should use ./ for root path in description', () => {
const params: RipGrepToolParams = { pattern: 'testPattern', path: '.' };
const invocation = grepTool.build(params);
expect(invocation.getDescription()).toBe("'testPattern' in path '.'");
expect(invocation.getDescription()).toBe("'testPattern' within ./");
});
});
});

View File

@@ -11,8 +11,8 @@ import { spawn } from 'node:child_process';
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolNames } from './tool-names.js';
import { resolveAndValidatePath } from '../utils/paths.js';
import { getErrorMessage } from '../utils/errors.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
import { getErrorMessage, isNodeError } from '../utils/errors.js';
import type { Config } from '../config/config.js';
import { ensureRipgrepPath } from '../utils/ripgrepUtils.js';
import { SchemaValidator } from '../utils/schemaValidator.js';
@@ -57,13 +57,50 @@ class GrepToolInvocation extends BaseToolInvocation<
super(params);
}
/**
* Checks if a path is within the root directory and resolves it.
* @param relativePath Path relative to the root directory (or undefined for root).
* @returns The absolute path to search within.
* @throws {Error} If path is outside root, doesn't exist, or isn't a directory.
*/
private resolveAndValidatePath(relativePath?: string): string {
const targetDir = this.config.getTargetDir();
const targetPath = relativePath
? path.resolve(targetDir, relativePath)
: targetDir;
const workspaceContext = this.config.getWorkspaceContext();
if (!workspaceContext.isPathWithinWorkspace(targetPath)) {
const directories = workspaceContext.getDirectories();
throw new Error(
`Path validation failed: Attempted path "${relativePath}" resolves outside the allowed workspace directories: ${directories.join(', ')}`,
);
}
return this.ensureDirectory(targetPath);
}
private ensureDirectory(targetPath: string): string {
try {
const stats = fs.statSync(targetPath);
if (!stats.isDirectory()) {
throw new Error(`Path is not a directory: ${targetPath}`);
}
} catch (error: unknown) {
if (isNodeError(error) && error.code !== 'ENOENT') {
throw new Error(`Path does not exist: ${targetPath}`);
}
throw new Error(
`Failed to access path stats for ${targetPath}: ${error}`,
);
}
return targetPath;
}
async execute(signal: AbortSignal): Promise<ToolResult> {
try {
const searchDirAbs = resolveAndValidatePath(
this.config,
this.params.path,
{ allowFiles: true },
);
const searchDirAbs = this.resolveAndValidatePath(this.params.path);
const searchDirDisplay = this.params.path || '.';
// Get raw ripgrep output
@@ -96,6 +133,9 @@ class GrepToolInvocation extends BaseToolInvocation<
// Build header early to calculate available space
const header = `Found ${totalMatches} ${matchTerm} for pattern "${this.params.pattern}" ${searchLocationDescription}${filterDescription}:\n---\n`;
const maxTruncationNoticeLength = 100; // "[... N more matches truncated]"
const maxGrepOutputLength =
MAX_LLM_CONTENT_LENGTH - header.length - maxTruncationNoticeLength;
// Apply line limit first (if specified)
let truncatedByLineLimit = false;
@@ -108,32 +148,19 @@ class GrepToolInvocation extends BaseToolInvocation<
truncatedByLineLimit = true;
}
// Build output and track how many lines we include, respecting character limit
const parts: string[] = [];
let includedLines = 0;
// Join lines back into grep output
let grepOutput = linesToInclude.join(EOL);
// Apply character limit as safety net
let truncatedByCharLimit = false;
let currentLength = 0;
for (const line of linesToInclude) {
const sep = includedLines > 0 ? 1 : 0;
includedLines++;
if (currentLength + line.length <= MAX_LLM_CONTENT_LENGTH) {
parts.push(line);
currentLength = currentLength + line.length + sep;
} else {
const remaining = Math.max(
MAX_LLM_CONTENT_LENGTH - currentLength - sep,
10,
);
parts.push(line.slice(0, remaining) + '...');
truncatedByCharLimit = true;
break;
}
if (grepOutput.length > maxGrepOutputLength) {
grepOutput = grepOutput.slice(0, maxGrepOutputLength) + '...';
truncatedByCharLimit = true;
}
const grepOutput = parts.join('\n');
// Count how many lines we actually included after character truncation
const finalLines = grepOutput.split(EOL).filter((line) => line.trim());
const includedLines = finalLines.length;
// Build result
let llmContent = header + grepOutput;
@@ -141,7 +168,7 @@ class GrepToolInvocation extends BaseToolInvocation<
// Add truncation notice if needed
if (truncatedByLineLimit || truncatedByCharLimit) {
const omittedMatches = totalMatches - includedLines;
llmContent += `\n---\n[${omittedMatches} ${omittedMatches === 1 ? 'line' : 'lines'} truncated] ...`;
llmContent += ` [${omittedMatches} ${omittedMatches === 1 ? 'line' : 'lines'} truncated] ...`;
}
// Build display message (show real count, not truncated)
@@ -166,7 +193,7 @@ class GrepToolInvocation extends BaseToolInvocation<
private async performRipgrepSearch(options: {
pattern: string;
path: string; // Can be a file or directory
path: string;
glob?: string;
signal: AbortSignal;
}): Promise<string> {
@@ -275,13 +302,34 @@ class GrepToolInvocation extends BaseToolInvocation<
*/
getDescription(): string {
let description = `'${this.params.pattern}'`;
if (this.params.path) {
description += ` in path '${this.params.path}'`;
}
if (this.params.glob) {
description += ` (filter: '${this.params.glob}')`;
description += ` in ${this.params.glob}`;
}
if (this.params.path) {
const resolvedPath = path.resolve(
this.config.getTargetDir(),
this.params.path,
);
if (
resolvedPath === this.config.getTargetDir() ||
this.params.path === '.'
) {
description += ` within ./`;
} else {
const relativePath = makeRelative(
resolvedPath,
this.config.getTargetDir(),
);
description += ` within ${shortenPath(relativePath)}`;
}
} else {
// When no path is specified, indicate searching all workspace directories
const workspaceContext = this.config.getWorkspaceContext();
const directories = workspaceContext.getDirectories();
if (directories.length > 1) {
description += ` across all workspace directories`;
}
}
return description;
}
}
@@ -330,6 +378,47 @@ export class RipGrepTool extends BaseDeclarativeTool<
);
}
/**
* Checks if a path is within the root directory and resolves it.
* @param relativePath Path relative to the root directory (or undefined for root).
* @returns The absolute path to search within.
* @throws {Error} If path is outside root, doesn't exist, or isn't a directory.
*/
private resolveAndValidatePath(relativePath?: string): string {
// If no path specified, search within the workspace root directory
if (!relativePath) {
return this.config.getTargetDir();
}
const targetPath = path.resolve(this.config.getTargetDir(), relativePath);
// Security Check: Ensure the resolved path is within workspace boundaries
const workspaceContext = this.config.getWorkspaceContext();
if (!workspaceContext.isPathWithinWorkspace(targetPath)) {
const directories = workspaceContext.getDirectories();
throw new Error(
`Path validation failed: Attempted path "${relativePath}" resolves outside the allowed workspace directories: ${directories.join(', ')}`,
);
}
// Check existence and type after resolving
try {
const stats = fs.statSync(targetPath);
if (!stats.isDirectory()) {
throw new Error(`Path is not a directory: ${targetPath}`);
}
} catch (error: unknown) {
if (isNodeError(error) && error.code !== 'ENOENT') {
throw new Error(`Path does not exist: ${targetPath}`);
}
throw new Error(
`Failed to access path stats for ${targetPath}: ${error}`,
);
}
return targetPath;
}
/**
* Validates the parameters for the tool
* @param params Parameters to validate
@@ -356,7 +445,7 @@ export class RipGrepTool extends BaseDeclarativeTool<
// Only validate path if one is provided
if (params.path) {
try {
resolveAndValidatePath(this.config, params.path, { allowFiles: true });
this.resolveAndValidatePath(params.path);
} catch (error) {
return getErrorMessage(error);
}

View File

@@ -4,53 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import {
escapePath,
resolvePath,
validatePath,
resolveAndValidatePath,
unescapePath,
isSubpath,
} from './paths.js';
import type { Config } from '../config/config.js';
function createConfigStub({
targetDir,
allowedDirectories,
}: {
targetDir: string;
allowedDirectories: string[];
}): Config {
const resolvedTargetDir = path.resolve(targetDir);
const resolvedDirectories = allowedDirectories.map((dir) =>
path.resolve(dir),
);
const workspaceContext = {
isPathWithinWorkspace(testPath: string) {
const resolvedPath = path.resolve(testPath);
return resolvedDirectories.some((dir) => {
const relative = path.relative(dir, resolvedPath);
return (
relative === '' ||
(!relative.startsWith('..') && !path.isAbsolute(relative))
);
});
},
getDirectories() {
return resolvedDirectories;
},
};
return {
getTargetDir: () => resolvedTargetDir,
getWorkspaceContext: () => workspaceContext,
} as unknown as Config;
}
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { escapePath, unescapePath, isSubpath } from './paths.js';
describe('escapePath', () => {
it('should escape spaces', () => {
@@ -359,240 +314,3 @@ describe('isSubpath on Windows', () => {
expect(isSubpath('Users\\Test\\file.txt', 'Users\\Test')).toBe(false);
});
});
describe('resolvePath', () => {
it('resolves relative paths against the provided base directory', () => {
const result = resolvePath('/home/user/project', 'src/main.ts');
expect(result).toBe(path.resolve('/home/user/project', 'src/main.ts'));
});
it('resolves relative paths against cwd when baseDir is undefined', () => {
const cwd = process.cwd();
const result = resolvePath(undefined, 'src/main.ts');
expect(result).toBe(path.resolve(cwd, 'src/main.ts'));
});
it('returns absolute paths unchanged', () => {
const absolutePath = '/absolute/path/to/file.ts';
const result = resolvePath('/some/base', absolutePath);
expect(result).toBe(absolutePath);
});
it('expands tilde to home directory', () => {
const homeDir = os.homedir();
const result = resolvePath(undefined, '~');
expect(result).toBe(homeDir);
});
it('expands tilde-prefixed paths to home directory', () => {
const homeDir = os.homedir();
const result = resolvePath(undefined, '~/documents/file.txt');
expect(result).toBe(path.join(homeDir, 'documents/file.txt'));
});
it('uses baseDir when provided for relative paths', () => {
const baseDir = '/custom/base';
const result = resolvePath(baseDir, './relative/path');
expect(result).toBe(path.resolve(baseDir, './relative/path'));
});
it('handles tilde expansion regardless of baseDir', () => {
const homeDir = os.homedir();
const result = resolvePath('/some/base', '~/file.txt');
expect(result).toBe(path.join(homeDir, 'file.txt'));
});
it('handles dot paths correctly', () => {
const result = resolvePath('/base/dir', '.');
expect(result).toBe(path.resolve('/base/dir', '.'));
});
it('handles parent directory references', () => {
const result = resolvePath('/base/dir/subdir', '..');
expect(result).toBe(path.resolve('/base/dir/subdir', '..'));
});
});
describe('validatePath', () => {
let workspaceRoot: string;
let config: Config;
beforeAll(() => {
workspaceRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'validate-path-test-'),
);
fs.mkdirSync(path.join(workspaceRoot, 'subdir'));
config = createConfigStub({
targetDir: workspaceRoot,
allowedDirectories: [workspaceRoot],
});
});
afterAll(() => {
fs.rmSync(workspaceRoot, { recursive: true, force: true });
});
it('validates paths within workspace boundaries', () => {
const validPath = path.join(workspaceRoot, 'subdir');
expect(() => validatePath(config, validPath)).not.toThrow();
});
it('throws when path is outside workspace boundaries', () => {
const outsidePath = path.join(os.tmpdir(), 'outside');
expect(() => validatePath(config, outsidePath)).toThrowError(
/Path is not within workspace/,
);
});
it('throws when path does not exist', () => {
const nonExistentPath = path.join(workspaceRoot, 'nonexistent');
expect(() => validatePath(config, nonExistentPath)).toThrowError(
/Path does not exist:/,
);
});
it('throws when path is a file, not a directory (default behavior)', () => {
const filePath = path.join(workspaceRoot, 'test-file.txt');
fs.writeFileSync(filePath, 'content');
try {
expect(() => validatePath(config, filePath)).toThrowError(
/Path is not a directory/,
);
} finally {
fs.rmSync(filePath);
}
});
it('allows files when allowFiles option is true', () => {
const filePath = path.join(workspaceRoot, 'test-file.txt');
fs.writeFileSync(filePath, 'content');
try {
expect(() =>
validatePath(config, filePath, { allowFiles: true }),
).not.toThrow();
} finally {
fs.rmSync(filePath);
}
});
it('validates paths at workspace root', () => {
expect(() => validatePath(config, workspaceRoot)).not.toThrow();
});
it('validates paths in allowed directories', () => {
const extraDir = fs.mkdtempSync(path.join(os.tmpdir(), 'validate-extra-'));
try {
const configWithExtra = createConfigStub({
targetDir: workspaceRoot,
allowedDirectories: [workspaceRoot, extraDir],
});
expect(() => validatePath(configWithExtra, extraDir)).not.toThrow();
} finally {
fs.rmSync(extraDir, { recursive: true, force: true });
}
});
});
describe('resolveAndValidatePath', () => {
let workspaceRoot: string;
let config: Config;
beforeAll(() => {
workspaceRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'resolve-and-validate-'),
);
fs.mkdirSync(path.join(workspaceRoot, 'subdir'));
config = createConfigStub({
targetDir: workspaceRoot,
allowedDirectories: [workspaceRoot],
});
});
afterAll(() => {
fs.rmSync(workspaceRoot, { recursive: true, force: true });
});
it('returns the target directory when no path is provided', () => {
expect(resolveAndValidatePath(config)).toBe(workspaceRoot);
});
it('resolves relative paths within the workspace', () => {
const expected = path.join(workspaceRoot, 'subdir');
expect(resolveAndValidatePath(config, 'subdir')).toBe(expected);
});
it('allows absolute paths that are permitted by the workspace context', () => {
const extraDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'resolve-and-validate-extra-'),
);
try {
const configWithExtra = createConfigStub({
targetDir: workspaceRoot,
allowedDirectories: [workspaceRoot, extraDir],
});
expect(resolveAndValidatePath(configWithExtra, extraDir)).toBe(extraDir);
} finally {
fs.rmSync(extraDir, { recursive: true, force: true });
}
});
it('expands tilde-prefixed paths using the home directory', () => {
const fakeHome = fs.mkdtempSync(
path.join(os.tmpdir(), 'resolve-and-validate-home-'),
);
const homeSubdir = path.join(fakeHome, 'project');
fs.mkdirSync(homeSubdir);
const homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(fakeHome);
try {
const configWithHome = createConfigStub({
targetDir: workspaceRoot,
allowedDirectories: [workspaceRoot, fakeHome],
});
expect(resolveAndValidatePath(configWithHome, '~/project')).toBe(
homeSubdir,
);
expect(resolveAndValidatePath(configWithHome, '~')).toBe(fakeHome);
} finally {
homedirSpy.mockRestore();
fs.rmSync(fakeHome, { recursive: true, force: true });
}
});
it('throws when the path resolves outside of the workspace', () => {
expect(() => resolveAndValidatePath(config, '../outside')).toThrowError(
/Path is not within workspace/,
);
});
it('throws when the path does not exist', () => {
expect(() => resolveAndValidatePath(config, 'missing')).toThrowError(
/Path does not exist:/,
);
});
it('throws when the path points to a file (default behavior)', () => {
const filePath = path.join(workspaceRoot, 'file.txt');
fs.writeFileSync(filePath, 'content');
try {
expect(() => resolveAndValidatePath(config, 'file.txt')).toThrowError(
`Path is not a directory: ${filePath}`,
);
} finally {
fs.rmSync(filePath);
}
});
it('allows file paths when allowFiles option is true', () => {
const filePath = path.join(workspaceRoot, 'file.txt');
fs.writeFileSync(filePath, 'content');
try {
const result = resolveAndValidatePath(config, 'file.txt', {
allowFiles: true,
});
expect(result).toBe(filePath);
} finally {
fs.rmSync(filePath);
}
});
});

View File

@@ -4,12 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import * as crypto from 'node:crypto';
import type { Config } from '../config/config.js';
import { isNodeError } from './errors.js';
export const QWEN_DIR = '.qwen';
export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json';
@@ -194,93 +191,3 @@ export function isSubpath(parentPath: string, childPath: string): boolean {
!pathModule.isAbsolute(relative)
);
}
/**
* Resolves a path with tilde (~) expansion and relative path resolution.
* Handles tilde expansion for home directory and resolves relative paths
* against the provided base directory or current working directory.
*
* @param baseDir The base directory to resolve relative paths against (defaults to current working directory)
* @param relativePath The path to resolve (can be relative, absolute, or tilde-prefixed)
* @returns The resolved absolute path
*/
export function resolvePath(
baseDir: string | undefined = process.cwd(),
relativePath: string,
): string {
const homeDir = os.homedir();
if (relativePath === '~') {
return homeDir;
} else if (relativePath.startsWith('~/')) {
return path.join(homeDir, relativePath.slice(2));
} else if (path.isAbsolute(relativePath)) {
return relativePath;
} else {
return path.resolve(baseDir, relativePath);
}
}
export interface PathValidationOptions {
/**
* If true, allows both files and directories. If false (default), only allows directories.
*/
allowFiles?: boolean;
}
/**
* Validates that a resolved path exists within the workspace boundaries.
*
* @param config The configuration object containing workspace context
* @param resolvedPath The absolute path to validate
* @param options Validation options
* @throws Error if the path is outside workspace boundaries, doesn't exist, or is not a directory (when allowFiles is false)
*/
export function validatePath(
config: Config,
resolvedPath: string,
options: PathValidationOptions = {},
): void {
const { allowFiles = false } = options;
const workspaceContext = config.getWorkspaceContext();
if (!workspaceContext.isPathWithinWorkspace(resolvedPath)) {
throw new Error('Path is not within workspace');
}
try {
const stats = fs.statSync(resolvedPath);
if (!allowFiles && !stats.isDirectory()) {
throw new Error(`Path is not a directory: ${resolvedPath}`);
}
} catch (error: unknown) {
if (isNodeError(error) && error.code === 'ENOENT') {
throw new Error(`Path does not exist: ${resolvedPath}`);
}
throw error;
}
}
/**
* Resolves a path relative to the workspace root and verifies that it exists
* within the workspace boundaries defined in the config.
*
* @param config The configuration object
* @param relativePath The relative path to resolve (optional, defaults to target directory)
* @param options Validation options (e.g., allowFiles to permit file paths)
*/
export function resolveAndValidatePath(
config: Config,
relativePath?: string,
options: PathValidationOptions = {},
): string {
const targetDir = config.getTargetDir();
if (!relativePath) {
return targetDir;
}
const resolvedPath = resolvePath(targetDir, relativePath);
validatePath(config, resolvedPath, options);
return resolvedPath;
}

View File

@@ -0,0 +1,69 @@
{
"name": "@qwen-code/sdk-typescript",
"version": "0.1.0",
"description": "TypeScript SDK for programmatic access to qwen-code CLI",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.js"
},
"./package.json": "./package.json"
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"scripts": {
"build": "tsc",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "eslint src test",
"lint:fix": "eslint src test --fix",
"clean": "rm -rf dist",
"prepublishOnly": "npm run clean && npm run build"
},
"keywords": [
"qwen",
"qwen-code",
"ai",
"code-assistant",
"sdk",
"typescript"
],
"author": "Qwen Team",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4",
"@qwen-code/qwen-code": "file:../../cli"
},
"devDependencies": {
"@types/node": "^20.14.0",
"@typescript-eslint/eslint-plugin": "^7.13.0",
"@typescript-eslint/parser": "^7.13.0",
"@vitest/coverage-v8": "^1.6.0",
"eslint": "^8.57.0",
"typescript": "^5.4.5",
"vitest": "^1.6.0"
},
"peerDependencies": {
"typescript": ">=5.0.0"
},
"repository": {
"type": "git",
"url": "https://github.com/qwen-ai/qwen-code.git",
"directory": "packages/sdk/typescript"
},
"bugs": {
"url": "https://github.com/qwen-ai/qwen-code/issues"
},
"homepage": "https://github.com/qwen-ai/qwen-code#readme"
}

View File

@@ -0,0 +1,108 @@
/**
* TypeScript SDK for programmatic access to qwen-code CLI
*
* @example
* ```typescript
* import { query } from '@qwen-code/sdk-typescript';
*
* const q = query({
* prompt: 'What files are in this directory?',
* options: { cwd: process.cwd() },
* });
*
* for await (const message of q) {
* if (message.type === 'assistant') {
* console.log(message.message.content);
* }
* }
*
* await q.close();
* ```
*/
// Main API
export { query } from './query/createQuery.js';
/** @deprecated Use query() instead */
export { createQuery } from './query/createQuery.js';
export { Query } from './query/Query.js';
// Configuration types
export type {
CreateQueryOptions,
PermissionMode,
PermissionCallback,
ExternalMcpServerConfig,
TransportOptions,
} from './types/config.js';
export type { QueryOptions } from './query/createQuery.js';
// Protocol types
export type {
ContentBlock,
TextBlock,
ThinkingBlock,
ToolUseBlock,
ToolResultBlock,
CLIUserMessage,
CLIAssistantMessage,
CLISystemMessage,
CLIResultMessage,
CLIPartialAssistantMessage,
CLIMessage,
} from './types/protocol.js';
export {
isCLIUserMessage,
isCLIAssistantMessage,
isCLISystemMessage,
isCLIResultMessage,
isCLIPartialAssistantMessage,
} from './types/protocol.js';
export type { JSONSchema } from './types/mcp.js';
export { AbortError, isAbortError } from './types/errors.js';
// Control Request Types
export {
ControlRequestType,
getAllControlRequestTypes,
isValidControlRequestType,
} from './types/controlRequests.js';
// Transport
export { ProcessTransport } from './transport/ProcessTransport.js';
export type { Transport } from './transport/Transport.js';
// Utilities
export { Stream } from './utils/Stream.js';
export {
serializeJsonLine,
parseJsonLine,
parseJsonLineSafe,
isValidMessage,
parseJsonLinesStream,
} from './utils/jsonLines.js';
export {
findCliPath,
resolveCliPath,
prepareSpawnInfo,
} from './utils/cliPath.js';
export type { SpawnInfo } from './utils/cliPath.js';
// MCP helpers
export {
createSdkMcpServer,
createSimpleMcpServer,
} from './mcp/createSdkMcpServer.js';
export {
tool,
createTool,
validateToolName,
validateInputSchema,
} from './mcp/tool.js';
export type { ToolDefinition } from './types/config.js';

View File

@@ -0,0 +1,153 @@
/**
* SdkControlServerTransport - bridges MCP Server with Query's control plane
*
* Implements @modelcontextprotocol/sdk Transport interface to enable
* SDK-embedded MCP servers. Messages flow bidirectionally:
*
* MCP Server → send() → Query → control_request (mcp_message) → CLI
* CLI → control_request (mcp_message) → Query → handleMessage() → MCP Server
*/
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
/**
* Callback type for sending messages to Query
*/
export type SendToQueryCallback = (message: JSONRPCMessage) => Promise<void>;
/**
* SdkControlServerTransport options
*/
export interface SdkControlServerTransportOptions {
sendToQuery: SendToQueryCallback;
serverName: string;
}
/**
* Transport adapter that bridges MCP Server with Query's control plane
*/
export class SdkControlServerTransport {
public sendToQuery: SendToQueryCallback;
private serverName: string;
private started = false;
/**
* Callbacks set by MCP Server
*/
onmessage?: (message: JSONRPCMessage) => void;
onerror?: (error: Error) => void;
onclose?: () => void;
constructor(options: SdkControlServerTransportOptions) {
this.sendToQuery = options.sendToQuery;
this.serverName = options.serverName;
}
/**
* Start the transport
*/
async start(): Promise<void> {
this.started = true;
}
/**
* Send message from MCP Server to CLI via Query's control plane
*
* @param message - JSON-RPC message from MCP Server
*/
async send(message: JSONRPCMessage): Promise<void> {
if (!this.started) {
throw new Error(
`SdkControlServerTransport (${this.serverName}) not started. Call start() first.`,
);
}
try {
// Send via Query's control plane
await this.sendToQuery(message);
} catch (error) {
// Invoke error callback if set
if (this.onerror) {
this.onerror(error instanceof Error ? error : new Error(String(error)));
}
throw error;
}
}
/**
* Close the transport
*/
async close(): Promise<void> {
if (!this.started) {
return; // Already closed
}
this.started = false;
// Notify MCP Server
if (this.onclose) {
this.onclose();
}
}
/**
* Handle incoming message from CLI
*
* @param message - JSON-RPC message from CLI
*/
handleMessage(message: JSONRPCMessage): void {
if (!this.started) {
console.warn(
`[SdkControlServerTransport] Received message for closed transport (${this.serverName})`,
);
return;
}
if (this.onmessage) {
this.onmessage(message);
} else {
console.warn(
`[SdkControlServerTransport] No onmessage handler set for ${this.serverName}`,
);
}
}
/**
* Handle incoming error from CLI
*
* @param error - Error from CLI
*/
handleError(error: Error): void {
if (this.onerror) {
this.onerror(error);
} else {
console.error(
`[SdkControlServerTransport] Error for ${this.serverName}:`,
error,
);
}
}
/**
* Check if transport is started
*/
isStarted(): boolean {
return this.started;
}
/**
* Get server name
*/
getServerName(): string {
return this.serverName;
}
}
/**
* Create SdkControlServerTransport instance
*/
export function createSdkControlServerTransport(
options: SdkControlServerTransportOptions,
): SdkControlServerTransport {
return new SdkControlServerTransport(options);
}

View File

@@ -0,0 +1,177 @@
/**
* Factory function to create SDK-embedded MCP servers
*
* Creates MCP Server instances that run in the user's Node.js process
* and are proxied to the CLI via the control plane.
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import {
ListToolsRequestSchema,
CallToolRequestSchema,
CallToolResult,
} from '@modelcontextprotocol/sdk/types.js';
import type { ToolDefinition } from '../types/config.js';
import { formatToolResult, formatToolError } from './formatters.js';
import { validateToolName } from './tool.js';
/**
* Create an SDK-embedded MCP server with custom tools
*
* The server runs in your Node.js process and is proxied to the CLI.
*
* @param name - Server name (must be unique)
* @param version - Server version
* @param tools - Array of tool definitions
* @returns MCP Server instance
*
* @example
* ```typescript
* const server = createSdkMcpServer('database', '1.0.0', [
* tool({
* name: 'query_db',
* description: 'Query the database',
* inputSchema: {
* type: 'object',
* properties: { query: { type: 'string' } },
* required: ['query']
* },
* handler: async (input) => db.query(input.query)
* })
* ]);
* ```
*/
export function createSdkMcpServer(
name: string,
version: string,
tools: ToolDefinition[],
): Server {
// Validate server name
if (!name || typeof name !== 'string') {
throw new Error('MCP server name must be a non-empty string');
}
if (!version || typeof version !== 'string') {
throw new Error('MCP server version must be a non-empty string');
}
if (!Array.isArray(tools)) {
throw new Error('Tools must be an array');
}
// Validate tool names are unique
const toolNames = new Set<string>();
for (const tool of tools) {
validateToolName(tool.name);
if (toolNames.has(tool.name)) {
throw new Error(
`Duplicate tool name '${tool.name}' in MCP server '${name}'`,
);
}
toolNames.add(tool.name);
}
// Create MCP Server instance
const server = new Server(
{
name,
version,
},
{
capabilities: {
tools: {},
},
},
);
// Create tool map for fast lookup
const toolMap = new Map<string, ToolDefinition>();
for (const tool of tools) {
toolMap.set(tool.name, tool);
}
// Register list_tools handler
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: tools.map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
})),
};
});
// Register call_tool handler
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name: toolName, arguments: toolArgs } = request.params;
// Find tool
const tool = toolMap.get(toolName);
if (!tool) {
return formatToolError(
new Error(`Tool '${toolName}' not found in server '${name}'`),
) as CallToolResult;
}
try {
// Invoke tool handler
const result = await tool.handler(toolArgs);
// Format result
return formatToolResult(result) as CallToolResult;
} catch (error) {
// Handle tool execution error
return formatToolError(
error instanceof Error
? error
: new Error(`Tool '${toolName}' failed: ${String(error)}`),
) as CallToolResult;
}
});
return server;
}
/**
* Create MCP server with inline tool definitions
*
* @param name - Server name
* @param version - Server version
* @param toolDefinitions - Object mapping tool names to definitions
* @returns MCP Server instance
*
* @example
* ```typescript
* const server = createSimpleMcpServer('utils', '1.0.0', {
* greeting: {
* description: 'Generate a greeting',
* inputSchema: {
* type: 'object',
* properties: { name: { type: 'string' } },
* required: ['name']
* },
* handler: async ({ name }) => `Hello, ${name}!`
* }
* });
* ```
*/
export function createSimpleMcpServer(
name: string,
version: string,
toolDefinitions: Record<
string,
Omit<ToolDefinition, 'name'> & { name?: string }
>,
): Server {
const tools: ToolDefinition[] = Object.entries(toolDefinitions).map(
([toolName, def]) => ({
name: def.name || toolName,
description: def.description,
inputSchema: def.inputSchema,
handler: def.handler,
}),
);
return createSdkMcpServer(name, version, tools);
}

View File

@@ -0,0 +1,247 @@
/**
* Tool result formatting utilities for MCP responses
*
* Converts various output types to MCP content blocks.
*/
/**
* MCP content block types
*/
export type McpContentBlock =
| { type: 'text'; text: string }
| { type: 'image'; data: string; mimeType: string }
| { type: 'resource'; uri: string; mimeType?: string; text?: string };
/**
* Tool result structure
*/
export interface ToolResult {
content: McpContentBlock[];
isError?: boolean;
}
/**
* Format tool result for MCP response
*
* Converts any value to MCP content blocks (strings, objects, errors, etc.)
*
* @param result - Tool handler output or error
* @returns Formatted tool result
*
* @example
* ```typescript
* formatToolResult('Hello')
* // → { content: [{ type: 'text', text: 'Hello' }] }
*
* formatToolResult({ temperature: 72 })
* // → { content: [{ type: 'text', text: '{"temperature":72}' }] }
* ```
*/
export function formatToolResult(result: unknown): ToolResult {
// Handle Error objects
if (result instanceof Error) {
return {
content: [
{
type: 'text',
text: result.message || 'Unknown error',
},
],
isError: true,
};
}
// Handle null/undefined
if (result === null || result === undefined) {
return {
content: [
{
type: 'text',
text: '',
},
],
};
}
// Handle string
if (typeof result === 'string') {
return {
content: [
{
type: 'text',
text: result,
},
],
};
}
// Handle number
if (typeof result === 'number') {
return {
content: [
{
type: 'text',
text: String(result),
},
],
};
}
// Handle boolean
if (typeof result === 'boolean') {
return {
content: [
{
type: 'text',
text: String(result),
},
],
};
}
// Handle object (including arrays)
if (typeof result === 'object') {
try {
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2),
},
],
};
} catch {
// JSON.stringify failed
return {
content: [
{
type: 'text',
text: String(result),
},
],
};
}
}
// Fallback: convert to string
return {
content: [
{
type: 'text',
text: String(result),
},
],
};
}
/**
* Format error for MCP response
*
* @param error - Error object or string
* @returns Tool result with error flag
*/
export function formatToolError(error: Error | string): ToolResult {
const message = error instanceof Error ? error.message : error;
return {
content: [
{
type: 'text',
text: message,
},
],
isError: true,
};
}
/**
* Format text content for MCP response
*
* @param text - Text content
* @returns Tool result with text content
*/
export function formatTextResult(text: string): ToolResult {
return {
content: [
{
type: 'text',
text,
},
],
};
}
/**
* Format JSON content for MCP response
*
* @param data - Data to serialize as JSON
* @returns Tool result with JSON text content
*/
export function formatJsonResult(data: unknown): ToolResult {
return {
content: [
{
type: 'text',
text: JSON.stringify(data, null, 2),
},
],
};
}
/**
* Merge multiple tool results into a single result
*
* @param results - Array of tool results
* @returns Merged tool result
*/
export function mergeToolResults(results: ToolResult[]): ToolResult {
const mergedContent: McpContentBlock[] = [];
let hasError = false;
for (const result of results) {
mergedContent.push(...result.content);
if (result.isError) {
hasError = true;
}
}
return {
content: mergedContent,
isError: hasError,
};
}
/**
* Validate MCP content block
*
* @param block - Content block to validate
* @returns True if valid
*/
export function isValidContentBlock(block: unknown): block is McpContentBlock {
if (!block || typeof block !== 'object') {
return false;
}
const blockObj = block as Record<string, unknown>;
if (!blockObj.type || typeof blockObj.type !== 'string') {
return false;
}
switch (blockObj.type) {
case 'text':
return typeof blockObj.text === 'string';
case 'image':
return (
typeof blockObj.data === 'string' &&
typeof blockObj.mimeType === 'string'
);
case 'resource':
return typeof blockObj.uri === 'string';
default:
return false;
}
}

View File

@@ -0,0 +1,140 @@
/**
* Tool definition helper for SDK-embedded MCP servers
*
* Provides type-safe tool definitions with generic input/output types.
*/
import type { ToolDefinition } from '../types/config.js';
/**
* Create a type-safe tool definition
*
* Validates the tool definition and provides type inference for input/output types.
*
* @param def - Tool definition with handler
* @returns The same tool definition (for type safety)
*
* @example
* ```typescript
* const weatherTool = tool<{ location: string }, { temperature: number }>({
* name: 'get_weather',
* description: 'Get weather for a location',
* inputSchema: {
* type: 'object',
* properties: {
* location: { type: 'string' }
* },
* required: ['location']
* },
* handler: async (input) => {
* return { temperature: await fetchWeather(input.location) };
* }
* });
* ```
*/
export function tool<TInput = unknown, TOutput = unknown>(
def: ToolDefinition<TInput, TOutput>,
): ToolDefinition<TInput, TOutput> {
// Validate tool definition
if (!def.name || typeof def.name !== 'string') {
throw new Error('Tool definition must have a name (string)');
}
if (!def.description || typeof def.description !== 'string') {
throw new Error(
`Tool definition for '${def.name}' must have a description (string)`,
);
}
if (!def.inputSchema || typeof def.inputSchema !== 'object') {
throw new Error(
`Tool definition for '${def.name}' must have an inputSchema (object)`,
);
}
if (!def.handler || typeof def.handler !== 'function') {
throw new Error(
`Tool definition for '${def.name}' must have a handler (function)`,
);
}
// Return definition (pass-through for type safety)
return def;
}
/**
* Validate tool name
*
* Tool names must:
* - Start with a letter
* - Contain only letters, numbers, and underscores
* - Be between 1 and 64 characters
*
* @param name - Tool name to validate
* @throws Error if name is invalid
*/
export function validateToolName(name: string): void {
if (!name) {
throw new Error('Tool name cannot be empty');
}
if (name.length > 64) {
throw new Error(
`Tool name '${name}' is too long (max 64 characters): ${name.length}`,
);
}
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(name)) {
throw new Error(
`Tool name '${name}' is invalid. Must start with a letter and contain only letters, numbers, and underscores.`,
);
}
}
/**
* Validate tool input schema (JSON Schema compliance)
*
* @param schema - Input schema to validate
* @throws Error if schema is invalid
*/
export function validateInputSchema(schema: unknown): void {
if (!schema || typeof schema !== 'object') {
throw new Error('Input schema must be an object');
}
const schemaObj = schema as Record<string, unknown>;
if (!schemaObj.type) {
throw new Error('Input schema must have a type field');
}
// For object schemas, validate properties
if (schemaObj.type === 'object') {
if (schemaObj.properties && typeof schemaObj.properties !== 'object') {
throw new Error('Input schema properties must be an object');
}
if (schemaObj.required && !Array.isArray(schemaObj.required)) {
throw new Error('Input schema required must be an array');
}
}
}
/**
* Create tool definition with strict validation
*
* @param def - Tool definition
* @returns Validated tool definition
*/
export function createTool<TInput = unknown, TOutput = unknown>(
def: ToolDefinition<TInput, TOutput>,
): ToolDefinition<TInput, TOutput> {
// Validate via tool() function
const validated = tool(def);
// Additional validation
validateToolName(validated.name);
validateInputSchema(validated.inputSchema);
return validated;
}

View File

@@ -0,0 +1,895 @@
/**
* Query class - Main orchestrator for SDK
*
* Manages SDK workflow, routes messages, and handles lifecycle.
* Implements AsyncIterator protocol for message consumption.
*/
import { randomUUID } from 'node:crypto';
import type {
CLIMessage,
CLIUserMessage,
CLIControlRequest,
CLIControlResponse,
ControlCancelRequest,
PermissionApproval,
PermissionSuggestion,
} from '../types/protocol.js';
import {
isCLIUserMessage,
isCLIAssistantMessage,
isCLISystemMessage,
isCLIResultMessage,
isCLIPartialAssistantMessage,
isControlRequest,
isControlResponse,
isControlCancel,
} from '../types/protocol.js';
import type { Transport } from '../transport/Transport.js';
import type { CreateQueryOptions } from '../types/config.js';
import { Stream } from '../utils/Stream.js';
import { serializeJsonLine } from '../utils/jsonLines.js';
import { AbortError } from '../types/errors.js';
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
import type { SdkControlServerTransport } from '../mcp/SdkControlServerTransport.js';
import { ControlRequestType } from '../types/controlRequests.js';
/**
* Pending control request tracking
*/
interface PendingControlRequest {
resolve: (response: Record<string, unknown> | null) => void;
reject: (error: Error) => void;
timeout: NodeJS.Timeout;
abortController: AbortController;
}
/**
* Hook configuration for SDK initialization
*/
interface HookRegistration {
matcher: Record<string, unknown>;
hookCallbackIds: string[];
}
/**
* Transport with input stream control (e.g., ProcessTransport)
*/
interface TransportWithEndInput extends Transport {
endInput(): void;
}
/**
* Query class
*
* Main entry point for SDK users. Orchestrates communication with CLI,
* routes messages, handles control plane, and manages lifecycle.
*/
export class Query implements AsyncIterable<CLIMessage> {
private transport: Transport;
private options: CreateQueryOptions;
private sessionId: string;
private inputStream: Stream<CLIMessage>;
private abortController: AbortController;
private pendingControlRequests: Map<string, PendingControlRequest> =
new Map();
private sdkMcpTransports: Map<string, SdkControlServerTransport> = new Map();
private initialized: Promise<void> | null = null;
private closed = false;
private messageRouterStarted = false;
// First result tracking for MCP servers
private firstResultReceivedPromise?: Promise<void>;
private firstResultReceivedResolve?: () => void;
// Hook callbacks tracking
private hookCallbacks = new Map<
string,
(
input: unknown,
toolUseId: string | null,
options: { signal: AbortSignal },
) => Promise<unknown>
>();
private nextCallbackId = 0;
// Single-turn mode flag
private readonly isSingleTurn: boolean;
constructor(transport: Transport, options: CreateQueryOptions) {
this.transport = transport;
this.options = options;
this.sessionId = randomUUID();
this.inputStream = new Stream<CLIMessage>();
// Use provided abortController or create a new one
this.abortController = options.abortController ?? new AbortController();
this.isSingleTurn = options.singleTurn ?? false;
// Setup first result tracking
this.firstResultReceivedPromise = new Promise((resolve) => {
this.firstResultReceivedResolve = resolve;
});
// Handle abort signal if controller is provided and already aborted or will be aborted
if (this.abortController.signal.aborted) {
// Already aborted - set error immediately
this.inputStream.setError(new AbortError('Query aborted by user'));
this.close().catch((err) => {
console.error('[Query] Error during abort cleanup:', err);
});
} else {
// Listen for abort events on the controller's signal
this.abortController.signal.addEventListener('abort', () => {
// Set abort error on the stream before closing
this.inputStream.setError(new AbortError('Query aborted by user'));
this.close().catch((err) => {
console.error('[Query] Error during abort cleanup:', err);
});
});
}
// Initialize immediately (no lazy initialization)
this.initialize();
}
/**
* Initialize the query
*/
private initialize(): void {
// Initialize asynchronously but don't block constructor
// Capture the promise immediately so other code can wait for initialization
this.initialized = (async () => {
try {
// Start transport
await this.transport.start();
// Setup SDK-embedded MCP servers
await this.setupSdkMcpServers();
// Prepare hooks configuration
let hooks: Record<string, HookRegistration[]> | undefined;
if (this.options.hooks) {
hooks = {};
for (const [event, matchers] of Object.entries(this.options.hooks)) {
if (matchers.length > 0) {
hooks[event] = matchers.map((matcher) => {
const callbackIds: string[] = [];
for (const callback of matcher.hooks) {
const callbackId = `hook_${this.nextCallbackId++}`;
this.hookCallbacks.set(callbackId, callback);
callbackIds.push(callbackId);
}
return {
matcher: matcher.matcher,
hookCallbackIds: callbackIds,
};
});
}
}
}
// Start message router in background
this.startMessageRouter();
// Send initialize control request
const sdkMcpServerNames = Array.from(this.sdkMcpTransports.keys());
await this.sendControlRequest(ControlRequestType.INITIALIZE, {
hooks: hooks ? Object.values(hooks).flat() : null,
sdkMcpServers:
sdkMcpServerNames.length > 0 ? sdkMcpServerNames : undefined,
});
// Note: Single-turn prompts are sent directly via transport in createQuery.ts
} catch (error) {
console.error('[Query] Initialization error:', error);
throw error;
}
})();
}
/**
* Setup SDK-embedded MCP servers
*/
private async setupSdkMcpServers(): Promise<void> {
if (!this.options.sdkMcpServers) {
return;
}
// Validate no name conflicts with external MCP servers
const externalNames = Object.keys(this.options.mcpServers ?? {});
const sdkNames = Object.keys(this.options.sdkMcpServers);
const conflicts = sdkNames.filter((name) => externalNames.includes(name));
if (conflicts.length > 0) {
throw new Error(
`MCP server name conflicts between mcpServers and sdkMcpServers: ${conflicts.join(', ')}`,
);
}
// Import SdkControlServerTransport (dynamic to avoid circular deps)
const { SdkControlServerTransport } = await import(
'../mcp/SdkControlServerTransport.js'
);
// Create SdkControlServerTransport for each server
for (const [name, server] of Object.entries(this.options.sdkMcpServers)) {
// Create transport that sends MCP messages via control plane
const transport = new SdkControlServerTransport({
serverName: name,
sendToQuery: async (message: JSONRPCMessage) => {
// Send MCP message to CLI via control request
await this.sendControlRequest(ControlRequestType.MCP_MESSAGE, {
server_name: name,
message,
});
},
});
// Start transport
await transport.start();
// Connect server to transport
await server.connect(transport);
// Store transport for cleanup
this.sdkMcpTransports.set(name, transport);
}
}
/**
* Start message router (background task)
*/
private startMessageRouter(): void {
if (this.messageRouterStarted) {
return;
}
this.messageRouterStarted = true;
// Route messages from transport to input stream
(async () => {
try {
for await (const message of this.transport.readMessages()) {
await this.routeMessage(message);
// Stop if closed
if (this.closed) {
break;
}
}
// Transport completed - check if aborted first
if (this.abortController.signal.aborted) {
this.inputStream.setError(new AbortError('Query aborted'));
} else {
this.inputStream.done();
}
} catch (error) {
// Transport error - propagate to stream
this.inputStream.setError(
error instanceof Error ? error : new Error(String(error)),
);
}
})().catch((err) => {
console.error('[Query] Message router error:', err);
this.inputStream.setError(
err instanceof Error ? err : new Error(String(err)),
);
});
}
/**
* Route incoming message
*/
private async routeMessage(message: unknown): Promise<void> {
// Check control messages first
if (isControlRequest(message)) {
// CLI asking SDK for something (permission, MCP message, hook callback)
await this.handleControlRequest(message);
return;
}
if (isControlResponse(message)) {
// Response to SDK's control request
this.handleControlResponse(message);
return;
}
if (isControlCancel(message)) {
// Cancel pending control request
this.handleControlCancelRequest(message);
return;
}
// Check data messages
if (isCLISystemMessage(message)) {
// SystemMessage - contains session info (cwd, tools, model, etc.) that should be passed to user
this.inputStream.enqueue(message);
return;
}
if (isCLIResultMessage(message)) {
// Result message - trigger first result received
if (this.firstResultReceivedResolve) {
this.firstResultReceivedResolve();
}
// In single-turn mode, automatically close input after receiving result
if (this.isSingleTurn && 'endInput' in this.transport) {
(this.transport as TransportWithEndInput).endInput();
}
// Pass to user
this.inputStream.enqueue(message);
return;
}
if (
isCLIAssistantMessage(message) ||
isCLIUserMessage(message) ||
isCLIPartialAssistantMessage(message)
) {
// Pass to user
this.inputStream.enqueue(message);
return;
}
// Unknown message - log and pass through
if (process.env['DEBUG_SDK']) {
console.warn('[Query] Unknown message type:', message);
}
this.inputStream.enqueue(message as CLIMessage);
}
/**
* Handle control request from CLI
*/
private async handleControlRequest(
request: CLIControlRequest,
): Promise<void> {
const { request_id, request: payload } = request;
// Create abort controller for this request
const requestAbortController = new AbortController();
try {
let response: Record<string, unknown> | null = null;
switch (payload.subtype) {
case 'can_use_tool':
response = (await this.handlePermissionRequest(
payload.tool_name,
payload.input as Record<string, unknown>,
payload.permission_suggestions,
requestAbortController.signal,
)) as unknown as Record<string, unknown>;
break;
case 'mcp_message':
response = await this.handleMcpMessage(
payload.server_name,
payload.message as unknown as JSONRPCMessage,
);
break;
case 'hook_callback':
response = await this.handleHookCallback(
payload.callback_id,
payload.input,
payload.tool_use_id,
requestAbortController.signal,
);
break;
default:
throw new Error(
`Unknown control request subtype: ${payload.subtype}`,
);
}
// Send success response
await this.sendControlResponse(request_id, true, response);
} catch (error) {
// Send error response
const errorMessage =
error instanceof Error ? error.message : String(error);
await this.sendControlResponse(request_id, false, errorMessage);
}
}
/**
* Handle permission request (can_use_tool)
*/
private async handlePermissionRequest(
toolName: string,
toolInput: Record<string, unknown>,
permissionSuggestions: PermissionSuggestion[] | null,
signal: AbortSignal,
): Promise<PermissionApproval> {
// Default: allow if no callback provided
if (!this.options.canUseTool) {
return { allowed: true };
}
try {
// Invoke callback with timeout
const timeoutMs = 30000; // 30 seconds
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(
() => reject(new Error('Permission callback timeout')),
timeoutMs,
);
});
// Call with signal and suggestions
const result = await Promise.race([
Promise.resolve(
this.options.canUseTool(toolName, toolInput, {
signal,
suggestions: permissionSuggestions,
}),
),
timeoutPromise,
]);
// Support both boolean and object return values
if (typeof result === 'boolean') {
return { allowed: result };
}
// Ensure result is a valid PermissionApproval
return result as PermissionApproval;
} catch (error) {
// Timeout or error → deny (fail-safe)
console.warn(
'[Query] Permission callback error (denying by default):',
error instanceof Error ? error.message : String(error),
);
return { allowed: false };
}
}
/**
* Handle MCP message routing
*/
private async handleMcpMessage(
serverName: string,
message: JSONRPCMessage,
): Promise<Record<string, unknown>> {
// Get transport for this server
const transport = this.sdkMcpTransports.get(serverName);
if (!transport) {
throw new Error(
`MCP server '${serverName}' not found in SDK-embedded servers`,
);
}
// Check if this is a request (has method and id) or notification
const isRequest =
'method' in message && 'id' in message && message.id !== null;
if (isRequest) {
// Request message - wait for response from MCP server
const response = await this.handleMcpRequest(
serverName,
message,
transport,
);
return { mcp_response: response };
} else {
// Notification or response - just route it
transport.handleMessage(message);
// Return acknowledgment for notifications
return { mcp_response: { jsonrpc: '2.0', result: {}, id: 0 } };
}
}
/**
* Handle MCP request and wait for response
*/
private handleMcpRequest(
_serverName: string,
message: JSONRPCMessage,
transport: SdkControlServerTransport,
): Promise<JSONRPCMessage> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('MCP request timeout'));
}, 30000); // 30 seconds
// Store message ID for matching
const messageId = 'id' in message ? message.id : null;
// Hook into transport to capture response
const originalSend = transport.sendToQuery;
transport.sendToQuery = async (responseMessage: JSONRPCMessage) => {
if ('id' in responseMessage && responseMessage.id === messageId) {
clearTimeout(timeout);
// Restore original send
transport.sendToQuery = originalSend;
resolve(responseMessage);
}
// Forward to original handler
return originalSend(responseMessage);
};
// Send message to MCP server
transport.handleMessage(message);
});
}
/**
* Handle control response from CLI
*/
private handleControlResponse(response: CLIControlResponse): void {
const { response: payload } = response;
const request_id = payload.request_id;
const pending = this.pendingControlRequests.get(request_id);
if (!pending) {
console.warn(
'[Query] Received response for unknown request:',
request_id,
);
return;
}
// Clear timeout
clearTimeout(pending.timeout);
this.pendingControlRequests.delete(request_id);
// Resolve or reject based on response type
if (payload.subtype === 'success') {
pending.resolve(payload.response as Record<string, unknown> | null);
} else {
// Extract error message from error field (can be string or object)
const errorMessage =
typeof payload.error === 'string'
? payload.error
: (payload.error?.message ?? 'Unknown error');
pending.reject(new Error(errorMessage));
}
}
/**
* Handle control cancel request from CLI
*/
private handleControlCancelRequest(request: ControlCancelRequest): void {
const { request_id } = request;
if (!request_id) {
console.warn('[Query] Received cancel request without request_id');
return;
}
const pending = this.pendingControlRequests.get(request_id);
if (pending) {
// Abort the request
pending.abortController.abort();
// Clean up
clearTimeout(pending.timeout);
this.pendingControlRequests.delete(request_id);
// Reject with abort error
pending.reject(new AbortError('Request cancelled'));
}
}
/**
* Handle hook callback request
*/
private async handleHookCallback(
callbackId: string,
input: unknown,
toolUseId: string | null,
signal: AbortSignal,
): Promise<Record<string, unknown>> {
const callback = this.hookCallbacks.get(callbackId);
if (!callback) {
throw new Error(`No hook callback found for ID: ${callbackId}`);
}
// Invoke callback with signal
const result = await callback(input, toolUseId, { signal });
return result as Record<string, unknown>;
}
/**
* Send control request to CLI
*/
private async sendControlRequest(
subtype: string,
data: Record<string, unknown> = {},
): Promise<Record<string, unknown> | null> {
const requestId = randomUUID();
const request: CLIControlRequest = {
type: 'control_request',
request_id: requestId,
request: {
subtype: subtype as never, // Type assertion needed for dynamic subtype
...data,
} as CLIControlRequest['request'],
};
// Create promise for response
const responsePromise = new Promise<Record<string, unknown> | null>(
(resolve, reject) => {
const abortController = new AbortController();
const timeout = setTimeout(() => {
this.pendingControlRequests.delete(requestId);
reject(new Error(`Control request timeout: ${subtype}`));
}, 300000); // 30 seconds
this.pendingControlRequests.set(requestId, {
resolve,
reject,
timeout,
abortController,
});
},
);
// Send request
this.transport.write(serializeJsonLine(request));
// Wait for response
return responsePromise;
}
/**
* Send control response to CLI
*/
private async sendControlResponse(
requestId: string,
success: boolean,
responseOrError: Record<string, unknown> | null | string,
): Promise<void> {
const response: CLIControlResponse = {
type: 'control_response',
response: success
? {
subtype: 'success',
request_id: requestId,
response: responseOrError as Record<string, unknown> | null,
}
: {
subtype: 'error',
request_id: requestId,
error: responseOrError as string,
},
};
this.transport.write(serializeJsonLine(response));
}
/**
* Close the query and cleanup resources
*
* Idempotent - safe to call multiple times.
*/
async close(): Promise<void> {
if (this.closed) {
return; // Already closed
}
this.closed = true;
// Cancel pending control requests
for (const pending of this.pendingControlRequests.values()) {
pending.abortController.abort();
clearTimeout(pending.timeout);
}
this.pendingControlRequests.clear();
// Clear hook callbacks
this.hookCallbacks.clear();
// Close transport
await this.transport.close();
// Complete input stream - check if aborted first
if (!this.inputStream.hasError) {
if (this.abortController.signal.aborted) {
this.inputStream.setError(new AbortError('Query aborted'));
} else {
this.inputStream.done();
}
}
// Cleanup MCP transports
for (const transport of this.sdkMcpTransports.values()) {
try {
await transport.close();
} catch (error) {
console.error('[Query] Error closing MCP transport:', error);
}
}
this.sdkMcpTransports.clear();
}
/**
* AsyncIterator protocol: next()
*/
async next(): Promise<IteratorResult<CLIMessage>> {
// Wait for initialization to complete if still in progress
if (this.initialized) {
await this.initialized;
}
return this.inputStream.next();
}
/**
* AsyncIterable protocol: Symbol.asyncIterator
*/
[Symbol.asyncIterator](): AsyncIterator<CLIMessage> {
return this;
}
/**
* Send follow-up messages for multi-turn conversations
*
* @param messages - Async iterable of user messages to send
* @throws Error if query is closed
*/
async streamInput(messages: AsyncIterable<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
if (this.initialized) {
await this.initialized;
}
// Send all messages
for await (const message of messages) {
// Check if aborted
if (this.abortController.signal.aborted) {
break;
}
this.transport.write(serializeJsonLine(message));
}
// In multi-turn mode with MCP servers, wait for first result
// to ensure MCP servers have time to process before next input
if (
!this.isSingleTurn &&
this.sdkMcpTransports.size > 0 &&
this.firstResultReceivedPromise
) {
const STREAM_CLOSE_TIMEOUT = 10000; // 10 seconds
await Promise.race([
this.firstResultReceivedPromise,
new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, STREAM_CLOSE_TIMEOUT);
}),
]);
}
this.endInput();
} catch (error) {
// Check if aborted - if so, set abort error on stream
if (this.abortController.signal.aborted) {
console.log('[Query] Aborted during input streaming');
this.inputStream.setError(
new AbortError('Query aborted during input streaming'),
);
return;
}
throw error;
}
}
/**
* End input stream (close stdin to CLI)
*
* @throws Error if query is closed
*/
endInput(): void {
if (this.closed) {
throw new Error('Query is closed');
}
if (
'endInput' in this.transport &&
typeof this.transport.endInput === 'function'
) {
(this.transport as TransportWithEndInput).endInput();
}
}
/**
* Interrupt the current operation
*
* @throws Error if query is closed
*/
async interrupt(): Promise<void> {
if (this.closed) {
throw new Error('Query is closed');
}
await this.sendControlRequest(ControlRequestType.INTERRUPT);
}
/**
* Set the permission mode for tool execution
*
* @param mode - Permission mode ('default' | 'plan' | 'auto-edit' | 'yolo')
* @throws Error if query is closed
*/
async setPermissionMode(mode: string): Promise<void> {
if (this.closed) {
throw new Error('Query is closed');
}
await this.sendControlRequest(ControlRequestType.SET_PERMISSION_MODE, {
mode,
});
}
/**
* Set the model for the current query
*
* @param model - Model name (e.g., 'qwen-2.5-coder-32b-instruct')
* @throws Error if query is closed
*/
async setModel(model: string): Promise<void> {
if (this.closed) {
throw new Error('Query is closed');
}
await this.sendControlRequest(ControlRequestType.SET_MODEL, { model });
}
/**
* Get list of control commands supported by the CLI
*
* @returns Promise resolving to list of supported command names
* @throws Error if query is closed
*/
async supportedCommands(): Promise<Record<string, unknown> | null> {
if (this.closed) {
throw new Error('Query is closed');
}
return this.sendControlRequest(ControlRequestType.SUPPORTED_COMMANDS);
}
/**
* Get the status of MCP servers
*
* @returns Promise resolving to MCP server status information
* @throws Error if query is closed
*/
async mcpServerStatus(): Promise<Record<string, unknown> | null> {
if (this.closed) {
throw new Error('Query is closed');
}
return this.sendControlRequest(ControlRequestType.MCP_SERVER_STATUS);
}
/**
* Get the session ID for this query
*
* @returns UUID session identifier
*/
getSessionId(): string {
return this.sessionId;
}
/**
* Check if the query has been closed
*
* @returns true if query is closed, false otherwise
*/
isClosed(): boolean {
return this.closed;
}
}

View File

@@ -0,0 +1,206 @@
/**
* Factory function for creating Query instances.
*/
import type { CLIUserMessage } from '../types/protocol.js';
import { serializeJsonLine } from '../utils/jsonLines.js';
import type {
CreateQueryOptions,
PermissionMode,
PermissionCallback,
ExternalMcpServerConfig,
} from '../types/config.js';
import { ProcessTransport } from '../transport/ProcessTransport.js';
import { parseExecutableSpec } from '../utils/cliPath.js';
import { Query } from './Query.js';
/**
* Configuration options for creating a Query.
*/
export type QueryOptions = {
cwd?: string;
model?: string;
pathToQwenExecutable?: string;
env?: Record<string, string>;
permissionMode?: PermissionMode;
canUseTool?: PermissionCallback;
mcpServers?: Record<string, ExternalMcpServerConfig>;
sdkMcpServers?: Record<
string,
{ connect: (transport: unknown) => Promise<void> }
>;
abortController?: AbortController;
debug?: boolean;
stderr?: (message: string) => void;
};
/**
* Create a Query instance for interacting with the Qwen CLI.
*
* Supports both single-turn (string) and multi-turn (AsyncIterable) prompts.
*
* @example
* ```typescript
* const q = query({
* prompt: 'What files are in this directory?',
* options: { cwd: process.cwd() },
* });
*
* for await (const msg of q) {
* if (msg.type === 'assistant') {
* console.log(msg.message.content);
* }
* }
* ```
*/
export function query({
prompt,
options = {},
}: {
prompt: string | AsyncIterable<CLIUserMessage>;
options?: QueryOptions;
}): Query {
// Validate options and obtain normalized executable metadata
const parsedExecutable = validateOptions(options);
// Determine if this is a single-turn or multi-turn query
// Single-turn: string prompt (simple Q&A)
// Multi-turn: AsyncIterable prompt (streaming conversation)
const isSingleTurn = typeof prompt === 'string';
// Build CreateQueryOptions
const queryOptions: CreateQueryOptions = {
...options,
singleTurn: isSingleTurn,
};
// Resolve CLI specification while preserving explicit runtime directives
const pathToQwenExecutable =
options.pathToQwenExecutable ?? parsedExecutable.executablePath;
// Use provided abortController or create a new one
const abortController = options.abortController ?? new AbortController();
// Create transport with abortController
const transport = new ProcessTransport({
pathToQwenExecutable,
cwd: options.cwd,
model: options.model,
permissionMode: options.permissionMode,
mcpServers: options.mcpServers,
env: options.env,
abortController,
debug: options.debug,
stderr: options.stderr,
});
// Build query options with abortController
const finalQueryOptions: CreateQueryOptions = {
...queryOptions,
abortController,
};
// Create Query
const queryInstance = new Query(transport, finalQueryOptions);
// Handle prompt based on type
if (isSingleTurn) {
// For single-turn queries, send the prompt directly via transport
const stringPrompt = prompt as string;
const message: CLIUserMessage = {
type: 'user',
session_id: queryInstance.getSessionId(),
message: {
role: 'user',
content: stringPrompt,
},
parent_tool_use_id: null,
};
(async () => {
try {
await new Promise((resolve) => setTimeout(resolve, 0));
transport.write(serializeJsonLine(message));
} catch (err) {
console.error('[query] Error sending single-turn prompt:', err);
}
})();
} else {
// For multi-turn queries, stream the input
queryInstance
.streamInput(prompt as AsyncIterable<CLIUserMessage>)
.catch((err) => {
console.error('[query] Error streaming input:', err);
});
}
return queryInstance;
}
/**
* Backward compatibility alias
* @deprecated Use query() instead
*/
export const createQuery = query;
/**
* Validate query configuration options and normalize CLI executable details.
*
* Performs strict validation for each supported option, including
* permission mode, callbacks, AbortController usage, and executable spec.
* Returns the parsed executable description so callers can retain
* explicit runtime directives (e.g., `bun:/path/to/cli.js`) while still
* benefiting from early validation and auto-detection fallbacks when the
* specification is omitted.
*/
function validateOptions(
options: QueryOptions,
): ReturnType<typeof parseExecutableSpec> {
let parsedExecutable: ReturnType<typeof parseExecutableSpec>;
// Validate permission mode if provided
if (options.permissionMode) {
const validModes = ['default', 'plan', 'auto-edit', 'yolo'];
if (!validModes.includes(options.permissionMode)) {
throw new Error(
`Invalid permissionMode: ${options.permissionMode}. Valid values are: ${validModes.join(', ')}`,
);
}
}
// Validate canUseTool is a function if provided
if (options.canUseTool && typeof options.canUseTool !== 'function') {
throw new Error('canUseTool must be a function');
}
// Validate abortController is AbortController if provided
if (
options.abortController &&
!(options.abortController instanceof AbortController)
) {
throw new Error('abortController must be an AbortController instance');
}
// Validate executable path early to provide clear error messages
try {
parsedExecutable = parseExecutableSpec(options.pathToQwenExecutable);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Invalid pathToQwenExecutable: ${errorMessage}`);
}
// Validate no MCP server name conflicts
if (options.mcpServers && options.sdkMcpServers) {
const externalNames = Object.keys(options.mcpServers);
const sdkNames = Object.keys(options.sdkMcpServers);
const conflicts = externalNames.filter((name) => sdkNames.includes(name));
if (conflicts.length > 0) {
throw new Error(
`MCP server name conflicts between mcpServers and sdkMcpServers: ${conflicts.join(', ')}`,
);
}
}
return parsedExecutable;
}

View File

@@ -0,0 +1,480 @@
/**
* ProcessTransport - Subprocess-based transport for SDK-CLI communication
*
* Manages CLI subprocess lifecycle and provides IPC via stdin/stdout using JSON Lines protocol.
*/
import { spawn, type ChildProcess } from 'node:child_process';
import * as readline from 'node:readline';
import type { Writable, Readable } from 'node:stream';
import type { TransportOptions } from '../types/config.js';
import type { Transport } from './Transport.js';
import { parseJsonLinesStream } from '../utils/jsonLines.js';
import { prepareSpawnInfo } from '../utils/cliPath.js';
import { AbortError } from '../types/errors.js';
/**
* Exit listener type
*/
type ExitListener = {
callback: (error?: Error) => void;
handler: (code: number | null, signal: NodeJS.Signals | null) => void;
};
/**
* ProcessTransport implementation
*
* Lifecycle:
* 1. Created with options
* 2. start() spawns subprocess
* 3. isReady becomes true
* 4. write() sends messages to stdin
* 5. readMessages() yields messages from stdout
* 6. close() gracefully shuts down (SIGTERM → SIGKILL)
* 7. waitForExit() resolves when cleanup complete
*/
export class ProcessTransport implements Transport {
private childProcess: ChildProcess | null = null;
private options: TransportOptions;
private _isReady = false;
private _exitError: Error | null = null;
private exitPromise: Promise<void> | null = null;
private exitResolve: (() => void) | null = null;
private cleanupCallbacks: Array<() => void> = [];
private closed = false;
private abortController: AbortController | null = null;
private exitListeners: ExitListener[] = [];
constructor(options: TransportOptions) {
this.options = options;
}
/**
* Start the transport by spawning CLI subprocess
*/
async start(): Promise<void> {
if (this.childProcess) {
return; // Already started
}
// Use provided abortController or create a new one
this.abortController =
this.options.abortController ?? new AbortController();
// Check if already aborted
if (this.abortController.signal.aborted) {
throw new AbortError('Transport start aborted');
}
const cliArgs = this.buildCliArguments();
const cwd = this.options.cwd ?? process.cwd();
const env = { ...process.env, ...this.options.env };
// Setup abort handler
this.abortController.signal.addEventListener('abort', () => {
this.logForDebugging('Transport aborted by user');
this._exitError = new AbortError('Operation aborted by user');
this._isReady = false;
void this.close();
});
// Create exit promise
this.exitPromise = new Promise<void>((resolve) => {
this.exitResolve = resolve;
});
try {
// Detect executable type and prepare spawn info
const spawnInfo = prepareSpawnInfo(this.options.pathToQwenExecutable);
const stderrMode =
this.options.debug || this.options.stderr ? 'pipe' : 'ignore';
this.logForDebugging(
`Spawning CLI (${spawnInfo.type}): ${spawnInfo.command} ${[...spawnInfo.args, ...cliArgs].join(' ')}`,
);
// Spawn CLI subprocess with appropriate command and args
this.childProcess = spawn(
spawnInfo.command,
[...spawnInfo.args, ...cliArgs],
{
cwd,
env,
stdio: ['pipe', 'pipe', stderrMode],
// Use AbortController signal
signal: this.abortController.signal,
},
);
// Handle stderr for debugging
if (this.options.debug || this.options.stderr) {
this.childProcess.stderr?.on('data', (data) => {
this.logForDebugging(data.toString());
});
}
// Setup event handlers
this.setupEventHandlers();
// Mark as ready
this._isReady = true;
// Register cleanup on parent process exit
this.registerParentExitHandler();
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
throw new Error(`Failed to spawn CLI process: ${errorMessage}`);
}
}
/**
* Setup event handlers for child process
*/
private setupEventHandlers(): void {
if (!this.childProcess) return;
// Handle process errors
this.childProcess.on('error', (error) => {
if (this.abortController?.signal.aborted) {
this._exitError = new AbortError('CLI process aborted by user');
} else {
this._exitError = new Error(`CLI process error: ${error.message}`);
}
this._isReady = false;
this.logForDebugging(`Process error: ${error.message}`);
});
// Handle process exit
this.childProcess.on('exit', (code, signal) => {
this._isReady = false;
// Check if aborted
if (this.abortController?.signal.aborted) {
this._exitError = new AbortError('CLI process aborted by user');
} else if (code !== null && code !== 0 && !this.closed) {
this._exitError = new Error(`CLI process exited with code ${code}`);
this.logForDebugging(`Process exited with code ${code}`);
} else if (signal && !this.closed) {
this._exitError = new Error(`CLI process killed by signal ${signal}`);
this.logForDebugging(`Process killed by signal ${signal}`);
}
// Notify exit listeners
const error = this._exitError;
for (const listener of this.exitListeners) {
try {
listener.callback(error || undefined);
} catch (err) {
this.logForDebugging(`Exit listener error: ${err}`);
}
}
// Resolve exit promise
if (this.exitResolve) {
this.exitResolve();
}
});
}
/**
* Register cleanup handler on parent process exit
*/
private registerParentExitHandler(): void {
const cleanup = (): void => {
if (this.childProcess && !this.childProcess.killed) {
this.childProcess.kill('SIGKILL');
}
};
process.on('exit', cleanup);
this.cleanupCallbacks.push(() => {
process.off('exit', cleanup);
});
}
/**
* Build CLI command-line arguments
*/
private buildCliArguments(): string[] {
const args: string[] = [
'--input-format',
'stream-json',
'--output-format',
'stream-json',
];
// Add model if specified
if (this.options.model) {
args.push('--model', this.options.model);
}
// Add permission mode if specified
if (this.options.permissionMode) {
args.push('--approval-mode', this.options.permissionMode);
}
// Add MCP servers if specified
if (this.options.mcpServers) {
for (const [name, config] of Object.entries(this.options.mcpServers)) {
args.push('--mcp-server', JSON.stringify({ name, ...config }));
}
}
return args;
}
/**
* Close the transport gracefully
*/
async close(): Promise<void> {
if (this.closed || !this.childProcess) {
return; // Already closed or never started
}
this.closed = true;
this._isReady = false;
// Clean up exit listeners
for (const { handler } of this.exitListeners) {
this.childProcess?.off('exit', handler);
}
this.exitListeners = [];
// Send SIGTERM for graceful shutdown
this.childProcess.kill('SIGTERM');
// Wait 5 seconds, then force kill if still alive
const forceKillTimeout = setTimeout(() => {
if (this.childProcess && !this.childProcess.killed) {
this.childProcess.kill('SIGKILL');
}
}, 5000);
// Wait for exit
await this.waitForExit();
// Clear timeout
clearTimeout(forceKillTimeout);
// Run cleanup callbacks
for (const callback of this.cleanupCallbacks) {
callback();
}
this.cleanupCallbacks = [];
}
/**
* Wait for process to fully exit
*/
async waitForExit(): Promise<void> {
if (this.exitPromise) {
await this.exitPromise;
}
}
/**
* Write a message to stdin
*/
write(message: string): void {
// Check abort status
if (this.abortController?.signal.aborted) {
throw new AbortError('Cannot write: operation aborted');
}
if (!this._isReady || !this.childProcess?.stdin) {
throw new Error('Transport not ready for writing');
}
if (this.closed) {
throw new Error('Cannot write to closed transport');
}
if (this.childProcess?.killed || this.childProcess?.exitCode !== null) {
throw new Error('Cannot write to terminated process');
}
if (this._exitError) {
throw new Error(
`Cannot write to process that exited with error: ${this._exitError.message}`,
);
}
if (process.env['DEBUG_SDK']) {
this.logForDebugging(
`[ProcessTransport] Writing to stdin: ${message.substring(0, 100)}`,
);
}
try {
const written = this.childProcess.stdin.write(message + '\n', (err) => {
if (err) {
throw new Error(`Failed to write to stdin: ${err.message}`);
}
});
if (!written && process.env['DEBUG_SDK']) {
this.logForDebugging(
'[ProcessTransport] Write buffer full, data queued',
);
}
} catch (error) {
this._isReady = false;
throw new Error(
`Failed to write to stdin: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Read messages from stdout as async generator
*/
async *readMessages(): AsyncGenerator<unknown, void, unknown> {
if (!this.childProcess?.stdout) {
throw new Error('Cannot read messages: process not started');
}
const rl = readline.createInterface({
input: this.childProcess.stdout,
crlfDelay: Infinity,
});
try {
// Use JSON Lines parser
for await (const message of parseJsonLinesStream(
rl,
'ProcessTransport',
)) {
yield message;
}
await this.waitForExit();
} finally {
rl.close();
}
}
/**
* Check if transport is ready for I/O
*/
get isReady(): boolean {
return this._isReady;
}
/**
* Get exit error (if any)
*/
get exitError(): Error | null {
return this._exitError;
}
/**
* Get child process (for testing)
*/
get process(): ChildProcess | null {
return this.childProcess;
}
/**
* Get path to qwen executable
*/
get pathToQwenExecutable(): string {
return this.options.pathToQwenExecutable;
}
/**
* Get CLI arguments
*/
get cliArgs(): readonly string[] {
return this.buildCliArguments();
}
/**
* Get working directory
*/
get cwd(): string {
return this.options.cwd ?? process.cwd();
}
/**
* Register a callback to be invoked when the process exits
*
* @param callback - Function to call on exit, receives error if abnormal exit
* @returns Cleanup function to remove the listener
*/
onExit(callback: (error?: Error) => void): () => void {
if (!this.childProcess) {
return () => {}; // No-op if process not started
}
const handler = (code: number | null, signal: NodeJS.Signals | null) => {
let error: Error | undefined;
if (this.abortController?.signal.aborted) {
error = new AbortError('Process aborted by user');
} else if (code !== null && code !== 0) {
error = new Error(`Process exited with code ${code}`);
} else if (signal) {
error = new Error(`Process killed by signal ${signal}`);
}
callback(error);
};
this.childProcess.on('exit', handler);
this.exitListeners.push({ callback, handler });
// Return cleanup function
return () => {
if (this.childProcess) {
this.childProcess.off('exit', handler);
}
const index = this.exitListeners.findIndex((l) => l.handler === handler);
if (index !== -1) {
this.exitListeners.splice(index, 1);
}
};
}
/**
* End input stream (close stdin)
* Useful when you want to signal no more input will be sent
*/
endInput(): void {
if (this.childProcess?.stdin) {
this.childProcess.stdin.end();
}
}
/**
* Get direct access to stdin stream
* Use with caution - prefer write() method for normal use
*
* @returns Writable stream for stdin, or undefined if not available
*/
getInputStream(): Writable | undefined {
return this.childProcess?.stdin || undefined;
}
/**
* Get direct access to stdout stream
* Use with caution - prefer readMessages() for normal use
*
* @returns Readable stream for stdout, or undefined if not available
*/
getOutputStream(): Readable | undefined {
return this.childProcess?.stdout || undefined;
}
/**
* Log message for debugging (if debug enabled)
*/
private logForDebugging(message: string): void {
if (this.options.debug || process.env['DEBUG']) {
process.stderr.write(`[ProcessTransport] ${message}\n`);
}
if (this.options.stderr) {
this.options.stderr(message);
}
}
}

View File

@@ -0,0 +1,102 @@
/**
* Transport interface for SDK-CLI communication
*
* The Transport abstraction enables communication between SDK and CLI via different mechanisms:
* - ProcessTransport: Local subprocess via stdin/stdout (initial implementation)
* - HttpTransport: Remote CLI via HTTP (future)
* - WebSocketTransport: Remote CLI via WebSocket (future)
*/
/**
* Abstract Transport interface
*
* Provides bidirectional communication with lifecycle management.
* Implements async generator pattern for reading messages with automatic backpressure.
*/
export interface Transport {
/**
* Initialize and start the transport.
*
* For ProcessTransport: spawns CLI subprocess
* For HttpTransport: establishes HTTP connection
* For WebSocketTransport: opens WebSocket connection
*
* Must be called before write() or readMessages().
*
* @throws Error if transport cannot be started
*/
start(): Promise<void>;
/**
* Close the transport gracefully.
*
* For ProcessTransport: sends SIGTERM, waits 5s, then SIGKILL
* For HttpTransport: sends close request, closes connection
* For WebSocketTransport: sends close frame
*
* Idempotent - safe to call multiple times.
*/
close(): Promise<void>;
/**
* Wait for transport to fully exit and cleanup.
*
* Resolves when all resources are cleaned up:
* - Process has exited (ProcessTransport)
* - Connection is closed (Http/WebSocketTransport)
* - All cleanup callbacks have run
*
* @returns Promise that resolves when exit is complete
*/
waitForExit(): Promise<void>;
/**
* Write a message to the transport.
*
* For ProcessTransport: writes to stdin
* For HttpTransport: sends HTTP request
* For WebSocketTransport: sends WebSocket message
*
* Message format: JSON Lines (one JSON object per line)
*
* @param message - Serialized JSON message (without trailing newline)
* @throws Error if transport is not ready or closed
*/
write(message: string): void;
/**
* Read messages from transport as async generator.
*
* Yields messages as they arrive, supporting natural backpressure via async iteration.
* Generator completes when transport closes.
*
* For ProcessTransport: reads from stdout using readline
* For HttpTransport: reads from chunked HTTP response
* For WebSocketTransport: reads from WebSocket messages
*
* Message format: JSON Lines (one JSON object per line)
* Malformed JSON lines are logged and skipped.
*
* @yields Parsed JSON messages
* @throws Error if transport encounters fatal error
*/
readMessages(): AsyncGenerator<unknown, void, unknown>;
/**
* Whether transport is ready for I/O operations.
*
* true: write() and readMessages() can be called
* false: transport not started or has failed
*/
readonly isReady: boolean;
/**
* Error that caused transport to exit unexpectedly (if any).
*
* null: transport exited normally or still running
* Error: transport failed with this error
*
* Useful for diagnostics when transport closes unexpectedly.
*/
readonly exitError: Error | null;
}

View File

@@ -0,0 +1,145 @@
/**
* Configuration types for SDK
*/
import type { ToolDefinition as ToolDef } from './mcp.js';
import type { PermissionMode } from './protocol.js';
export type { ToolDef as ToolDefinition };
export type { PermissionMode };
/**
* Permission callback function
* Called before each tool execution to determine if it should be allowed
*
* @param toolName - Name of the tool being executed
* @param input - Input parameters for the tool
* @param options - Additional options (signal for cancellation, suggestions)
* @returns Promise<boolean|unknown> or boolean|unknown - true to allow, false to deny, or custom response
*/
export type PermissionCallback = (
toolName: string,
input: Record<string, unknown>,
options?: {
signal?: AbortSignal;
suggestions?: unknown;
},
) => Promise<boolean | unknown> | boolean | unknown;
/**
* Hook callback function
* Called at specific points in tool execution lifecycle
*
* @param input - Hook input data
* @param toolUseId - Tool execution ID (null if not associated with a tool)
* @param options - Options including abort signal
* @returns Promise with hook result
*/
export type HookCallback = (
input: unknown,
toolUseId: string | null,
options: { signal: AbortSignal },
) => Promise<unknown>;
/**
* Hook matcher configuration
*/
export interface HookMatcher {
matcher: Record<string, unknown>;
hooks: HookCallback[];
}
/**
* Hook configuration by event type
*/
export type HookConfig = {
[event: string]: HookMatcher[];
};
/**
* External MCP server configuration (spawned by CLI)
*/
export type ExternalMcpServerConfig = {
/** Command to execute (e.g., 'mcp-server-filesystem') */
command: string;
/** Command-line arguments */
args?: string[];
/** Environment variables */
env?: Record<string, string>;
};
/**
* Options for creating a Query instance
*/
export type CreateQueryOptions = {
// Basic configuration
/** Working directory for CLI execution */
cwd?: string;
/** Model name (e.g., 'qwen-2.5-coder-32b-instruct') */
model?: string;
// Transport configuration
/** Path to qwen executable (auto-detected if omitted) */
pathToQwenExecutable?: string;
/** Environment variables for CLI process */
env?: Record<string, string>;
// Permission control
/** Permission mode ('default' | 'plan' | 'auto-edit' | 'yolo') */
permissionMode?: PermissionMode;
/** Callback invoked before each tool execution */
canUseTool?: PermissionCallback;
// Hook system
/** Hook configuration for tool execution lifecycle */
hooks?: HookConfig;
// MCP server configuration
/** External MCP servers (spawned by CLI) */
mcpServers?: Record<string, ExternalMcpServerConfig>;
/** SDK-embedded MCP servers (run in Node.js process) */
sdkMcpServers?: Record<
string,
{ connect: (transport: unknown) => Promise<void> }
>; // Server from @modelcontextprotocol/sdk
// Conversation mode
/**
* Single-turn mode: automatically close input after receiving result
* Multi-turn mode: keep input open for follow-up messages
* @default false (multi-turn)
*/
singleTurn?: boolean;
// Advanced options
/** AbortController for cancellation support */
abortController?: AbortController;
/** Enable debug output (inherits stderr) */
debug?: boolean;
/** Callback for stderr output */
stderr?: (message: string) => void;
};
/**
* Transport options for ProcessTransport
*/
export type TransportOptions = {
/** Path to qwen executable */
pathToQwenExecutable: string;
/** Working directory for CLI execution */
cwd?: string;
/** Model name */
model?: string;
/** Permission mode */
permissionMode?: PermissionMode;
/** External MCP servers */
mcpServers?: Record<string, ExternalMcpServerConfig>;
/** Environment variables */
env?: Record<string, string>;
/** AbortController for cancellation support */
abortController?: AbortController;
/** Enable debug output */
debug?: boolean;
/** Callback for stderr output */
stderr?: (message: string) => void;
};

View File

@@ -0,0 +1,50 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Control Request Types
*
* Centralized enum for all control request subtypes supported by the CLI.
* This enum should be kept in sync with the controllers in:
* - packages/cli/src/services/control/controllers/systemController.ts
* - packages/cli/src/services/control/controllers/permissionController.ts
* - packages/cli/src/services/control/controllers/mcpController.ts
* - packages/cli/src/services/control/controllers/hookController.ts
*/
export enum ControlRequestType {
// SystemController requests
INITIALIZE = 'initialize',
INTERRUPT = 'interrupt',
SET_MODEL = 'set_model',
SUPPORTED_COMMANDS = 'supported_commands',
// PermissionController requests
CAN_USE_TOOL = 'can_use_tool',
SET_PERMISSION_MODE = 'set_permission_mode',
// MCPController requests
MCP_MESSAGE = 'mcp_message',
MCP_SERVER_STATUS = 'mcp_server_status',
// HookController requests
HOOK_CALLBACK = 'hook_callback',
}
/**
* Get all available control request types as a string array
*/
export function getAllControlRequestTypes(): string[] {
return Object.values(ControlRequestType);
}
/**
* Check if a string is a valid control request type
*/
export function isValidControlRequestType(
type: string,
): type is ControlRequestType {
return getAllControlRequestTypes().includes(type);
}

View File

@@ -0,0 +1,27 @@
/**
* Error types for SDK
*/
/**
* Error thrown when an operation is aborted via AbortSignal
*/
export class AbortError extends Error {
constructor(message = 'Operation aborted') {
super(message);
this.name = 'AbortError';
Object.setPrototypeOf(this, AbortError.prototype);
}
}
/**
* Check if an error is an AbortError
*/
export function isAbortError(error: unknown): error is AbortError {
return (
error instanceof AbortError ||
(typeof error === 'object' &&
error !== null &&
'name' in error &&
error.name === 'AbortError')
);
}

View File

@@ -0,0 +1,32 @@
/**
* MCP integration types for SDK
*/
/**
* JSON Schema definition
* Used for tool input validation
*/
export type JSONSchema = {
type: string;
properties?: Record<string, unknown>;
required?: string[];
description?: string;
[key: string]: unknown;
};
/**
* Tool definition for SDK-embedded MCP servers
*
* @template TInput - Type of tool input (inferred from handler)
* @template TOutput - Type of tool output (inferred from handler return)
*/
export type ToolDefinition<TInput = unknown, TOutput = unknown> = {
/** Unique tool name */
name: string;
/** Human-readable description (helps agent decide when to use it) */
description: string;
/** JSON Schema for input validation */
inputSchema: JSONSchema;
/** Async handler function that executes the tool */
handler: (input: TInput) => Promise<TOutput>;
};

View File

@@ -0,0 +1,50 @@
/**
* Protocol types for SDK-CLI communication
*
* Re-exports protocol types from CLI package to ensure SDK and CLI use identical types.
*/
export type {
ContentBlock,
TextBlock,
ThinkingBlock,
ToolUseBlock,
ToolResultBlock,
CLIUserMessage,
CLIAssistantMessage,
CLISystemMessage,
CLIResultMessage,
CLIPartialAssistantMessage,
CLIMessage,
PermissionMode,
PermissionSuggestion,
PermissionApproval,
HookRegistration,
CLIControlInterruptRequest,
CLIControlPermissionRequest,
CLIControlInitializeRequest,
CLIControlSetPermissionModeRequest,
CLIHookCallbackRequest,
CLIControlMcpMessageRequest,
CLIControlSetModelRequest,
CLIControlMcpStatusRequest,
CLIControlSupportedCommandsRequest,
ControlRequestPayload,
CLIControlRequest,
ControlResponse,
ControlErrorResponse,
CLIControlResponse,
ControlCancelRequest,
ControlMessage,
} from '@qwen-code/qwen-code/protocol';
export {
isCLIUserMessage,
isCLIAssistantMessage,
isCLISystemMessage,
isCLIResultMessage,
isCLIPartialAssistantMessage,
isControlRequest,
isControlResponse,
isControlCancel,
} from '@qwen-code/qwen-code/protocol';

View File

@@ -0,0 +1,157 @@
/**
* Async iterable queue for streaming messages between producer and consumer.
*/
export class Stream<T> implements AsyncIterable<T> {
private queue: T[] = [];
private isDone = false;
private streamError: Error | null = null;
private readResolve: ((result: IteratorResult<T>) => void) | null = null;
private readReject: ((error: Error) => void) | null = null;
private maxQueueSize: number = 10000; // Prevent memory leaks
private droppedMessageCount = 0;
/**
* Add a value to the stream.
*/
enqueue(value: T): void {
if (this.isDone) {
throw new Error('Cannot enqueue to completed stream');
}
if (this.streamError) {
throw new Error('Cannot enqueue to stream with error');
}
// Fast path: consumer is waiting
if (this.readResolve) {
this.readResolve({ value, done: false });
this.readResolve = null;
this.readReject = null;
} else {
// Slow path: buffer in queue (with size limit)
if (this.queue.length >= this.maxQueueSize) {
// Drop oldest message to prevent memory leak
this.queue.shift();
this.droppedMessageCount++;
// Warn about dropped messages (but don't throw)
if (this.droppedMessageCount % 100 === 1) {
console.warn(
`[Stream] Queue full, dropped ${this.droppedMessageCount} messages. ` +
`Consumer may be too slow.`,
);
}
}
this.queue.push(value);
}
}
/**
* Mark the stream as complete.
*/
done(): void {
if (this.isDone) {
return; // Already done, no-op
}
this.isDone = true;
// If consumer is waiting, signal completion
if (this.readResolve) {
this.readResolve({ done: true, value: undefined });
this.readResolve = null;
this.readReject = null;
}
}
/**
* Set an error state for the stream.
*/
setError(err: Error): void {
if (this.streamError) {
return; // Already has error, no-op
}
this.streamError = err;
// If consumer is waiting, reject immediately
if (this.readReject) {
this.readReject(err);
this.readResolve = null;
this.readReject = null;
}
}
/**
* Get the next value from the stream.
*/
async next(): Promise<IteratorResult<T>> {
// Fast path: queue has values
if (this.queue.length > 0) {
const value = this.queue.shift()!;
return { value, done: false };
}
// Error path: stream has error
if (this.streamError) {
throw this.streamError;
}
// Done path: stream is complete
if (this.isDone) {
return { done: true, value: undefined };
}
// Wait path: no values yet, wait for producer
return new Promise<IteratorResult<T>>((resolve, reject) => {
this.readResolve = resolve;
this.readReject = reject;
// Producer will call resolve/reject when value/done/error occurs
});
}
/**
* Enable async iteration with `for await` syntax.
*/
[Symbol.asyncIterator](): AsyncIterator<T> {
return this;
}
get queueSize(): number {
return this.queue.length;
}
get isComplete(): boolean {
return this.isDone;
}
get hasError(): boolean {
return this.streamError !== null;
}
get droppedMessages(): number {
return this.droppedMessageCount;
}
/**
* Set the maximum queue size.
*/
setMaxQueueSize(size: number): void {
if (size < 1) {
throw new Error('Max queue size must be at least 1');
}
this.maxQueueSize = size;
}
get maxSize(): number {
return this.maxQueueSize;
}
/**
* Clear all buffered messages. Use only during cleanup or error recovery.
*/
clear(): void {
this.queue = [];
}
}

View File

@@ -0,0 +1,438 @@
/**
* CLI path auto-detection and subprocess spawning utilities
*
* Supports multiple execution modes:
* 1. Native binary: 'qwen' (production)
* 2. Node.js bundle: 'node /path/to/cli.js' (production validation)
* 3. Bun bundle: 'bun /path/to/cli.js' (alternative runtime)
* 4. TypeScript source: 'tsx /path/to/index.ts' (development)
*
* Auto-detection locations for native binary:
* 1. QWEN_CODE_CLI_PATH environment variable
* 2. ~/.volta/bin/qwen
* 3. ~/.npm-global/bin/qwen
* 4. /usr/local/bin/qwen
* 5. ~/.local/bin/qwen
* 6. ~/node_modules/.bin/qwen
* 7. ~/.yarn/bin/qwen
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { execSync } from 'node:child_process';
/**
* Executable types supported by the SDK
*/
export type ExecutableType = 'native' | 'node' | 'bun' | 'tsx' | 'deno';
/**
* Spawn information for CLI process
*/
export type SpawnInfo = {
/** Command to execute (e.g., 'qwen', 'node', 'bun', 'tsx') */
command: string;
/** Arguments to pass to command */
args: string[];
/** Type of executable detected */
type: ExecutableType;
/** Original input that was resolved */
originalInput: string;
};
/**
* Find native CLI executable path
*
* Searches global installation locations in order of priority.
* Only looks for native 'qwen' binary, not JS/TS files.
*
* @returns Absolute path to CLI executable
* @throws Error if CLI not found
*/
export function findNativeCliPath(): string {
const homeDir = process.env['HOME'] || process.env['USERPROFILE'] || '';
const candidates: Array<string | undefined> = [
// 1. Environment variable (highest priority)
process.env['QWEN_CODE_CLI_PATH'],
// 2. Volta bin
path.join(homeDir, '.volta', 'bin', 'qwen'),
// 3. Global npm installations
path.join(homeDir, '.npm-global', 'bin', 'qwen'),
// 4. Common Unix binary locations
'/usr/local/bin/qwen',
// 5. User local bin
path.join(homeDir, '.local', 'bin', 'qwen'),
// 6. Node modules bin in home directory
path.join(homeDir, 'node_modules', '.bin', 'qwen'),
// 7. Yarn global bin
path.join(homeDir, '.yarn', 'bin', 'qwen'),
];
// Find first existing candidate
for (const candidate of candidates) {
if (candidate && fs.existsSync(candidate)) {
return path.resolve(candidate);
}
}
// Not found - throw helpful error
throw new Error(
'qwen CLI not found. Please:\n' +
' 1. Install qwen globally: npm install -g qwen\n' +
' 2. Or provide explicit executable: query({ pathToQwenExecutable: "/path/to/qwen" })\n' +
' 3. Or set environment variable: QWEN_CODE_CLI_PATH="/path/to/qwen"\n' +
'\n' +
'For development/testing, you can also use:\n' +
' • TypeScript source: query({ pathToQwenExecutable: "/path/to/index.ts" })\n' +
' • Node.js bundle: query({ pathToQwenExecutable: "/path/to/cli.js" })\n' +
' • Force specific runtime: query({ pathToQwenExecutable: "bun:/path/to/cli.js" })',
);
}
/**
* Check if a command is available in the system PATH
*
* @param command - Command to check (e.g., 'bun', 'tsx', 'deno')
* @returns true if command is available
*/
function isCommandAvailable(command: string): boolean {
try {
// Use 'which' on Unix-like systems, 'where' on Windows
const whichCommand = process.platform === 'win32' ? 'where' : 'which';
execSync(`${whichCommand} ${command}`, {
stdio: 'ignore',
timeout: 5000, // 5 second timeout
});
return true;
} catch {
return false;
}
}
/**
* Validate that a runtime is available on the system
*
* @param runtime - Runtime to validate (node, bun, tsx, deno)
* @returns true if runtime is available
*/
function validateRuntimeAvailability(runtime: string): boolean {
// Node.js is always available since we're running in Node.js
if (runtime === 'node') {
return true;
}
// Check if the runtime command is available in PATH
return isCommandAvailable(runtime);
}
/**
* Validate file extension matches expected runtime
*
* @param filePath - Path to the file
* @param runtime - Expected runtime
* @returns true if extension is compatible
*/
function validateFileExtensionForRuntime(
filePath: string,
runtime: string,
): boolean {
const ext = path.extname(filePath).toLowerCase();
switch (runtime) {
case 'node':
case 'bun':
return ['.js', '.mjs', '.cjs'].includes(ext);
case 'tsx':
return ['.ts', '.tsx'].includes(ext);
case 'deno':
return ['.ts', '.tsx', '.js', '.mjs'].includes(ext);
default:
return true; // Unknown runtime, let it pass
}
}
/**
* Parse executable specification into components with comprehensive validation
*
* Supports multiple formats:
* - 'qwen' -> native binary (auto-detected)
* - '/path/to/qwen' -> native binary (explicit path)
* - '/path/to/cli.js' -> Node.js bundle (default for .js files)
* - '/path/to/index.ts' -> TypeScript source (requires tsx)
*
* Advanced runtime specification (for overriding defaults):
* - 'bun:/path/to/cli.js' -> Force Bun runtime
* - 'node:/path/to/cli.js' -> Force Node.js runtime
* - 'tsx:/path/to/index.ts' -> Force tsx runtime
* - 'deno:/path/to/cli.ts' -> Force Deno runtime
*
* @param executableSpec - Executable specification
* @returns Parsed executable information
* @throws Error if specification is invalid or files don't exist
*/
export function parseExecutableSpec(executableSpec?: string): {
runtime?: string;
executablePath: string;
isExplicitRuntime: boolean;
} {
// Handle empty string case first (before checking for undefined/null)
if (
executableSpec === '' ||
(executableSpec && executableSpec.trim() === '')
) {
throw new Error('Command name cannot be empty');
}
if (!executableSpec) {
// Auto-detect native CLI
return {
executablePath: findNativeCliPath(),
isExplicitRuntime: false,
};
}
// Check for runtime prefix (e.g., 'bun:/path/to/cli.js')
const runtimeMatch = executableSpec.match(/^([^:]+):(.+)$/);
if (runtimeMatch) {
const [, runtime, filePath] = runtimeMatch;
if (!runtime || !filePath) {
throw new Error(`Invalid runtime specification: '${executableSpec}'`);
}
// Validate runtime is supported
const supportedRuntimes = ['node', 'bun', 'tsx', 'deno'];
if (!supportedRuntimes.includes(runtime)) {
throw new Error(
`Unsupported runtime '${runtime}'. Supported runtimes: ${supportedRuntimes.join(', ')}`,
);
}
// Validate runtime availability
if (!validateRuntimeAvailability(runtime)) {
throw new Error(
`Runtime '${runtime}' is not available on this system. Please install it first.`,
);
}
const resolvedPath = path.resolve(filePath);
// Validate file exists
if (!fs.existsSync(resolvedPath)) {
throw new Error(
`Executable file not found at '${resolvedPath}' for runtime '${runtime}'. ` +
'Please check the file path and ensure the file exists.',
);
}
// Validate file extension matches runtime
if (!validateFileExtensionForRuntime(resolvedPath, runtime)) {
const ext = path.extname(resolvedPath);
throw new Error(
`File extension '${ext}' is not compatible with runtime '${runtime}'. ` +
`Expected extensions for ${runtime}: ${getExpectedExtensions(runtime).join(', ')}`,
);
}
return {
runtime,
executablePath: resolvedPath,
isExplicitRuntime: true,
};
}
// Check if it's a command name (no path separators) or a file path
const isCommandName =
!executableSpec.includes('/') && !executableSpec.includes('\\');
if (isCommandName) {
// It's a command name like 'qwen' - validate it's a reasonable command name
if (!executableSpec || executableSpec.trim() === '') {
throw new Error('Command name cannot be empty');
}
// Basic validation for command names
if (!/^[a-zA-Z0-9._-]+$/.test(executableSpec)) {
throw new Error(
`Invalid command name '${executableSpec}'. Command names should only contain letters, numbers, dots, hyphens, and underscores.`,
);
}
return {
executablePath: executableSpec,
isExplicitRuntime: false,
};
}
// It's a file path - validate and resolve
const resolvedPath = path.resolve(executableSpec);
if (!fs.existsSync(resolvedPath)) {
throw new Error(
`Executable file not found at '${resolvedPath}'. ` +
'Please check the file path and ensure the file exists. ' +
'You can also:\n' +
' • Set QWEN_CODE_CLI_PATH environment variable\n' +
' • Install qwen globally: npm install -g qwen\n' +
' • For TypeScript files, ensure tsx is installed: npm install -g tsx\n' +
' • Force specific runtime: bun:/path/to/cli.js or tsx:/path/to/index.ts',
);
}
// Additional validation for file paths
const stats = fs.statSync(resolvedPath);
if (!stats.isFile()) {
throw new Error(
`Path '${resolvedPath}' exists but is not a file. Please provide a path to an executable file.`,
);
}
return {
executablePath: resolvedPath,
isExplicitRuntime: false,
};
}
/**
* Get expected file extensions for a runtime
*/
function getExpectedExtensions(runtime: string): string[] {
switch (runtime) {
case 'node':
case 'bun':
return ['.js', '.mjs', '.cjs'];
case 'tsx':
return ['.ts', '.tsx'];
case 'deno':
return ['.ts', '.tsx', '.js', '.mjs'];
default:
return [];
}
}
/**
* Resolve CLI path from options (backward compatibility)
*
* @param explicitPath - Optional explicit CLI path or command name
* @returns Resolved CLI path
* @throws Error if CLI not found
* @deprecated Use parseExecutableSpec and prepareSpawnInfo instead
*/
export function resolveCliPath(explicitPath?: string): string {
const parsed = parseExecutableSpec(explicitPath);
return parsed.executablePath;
}
/**
* Detect runtime for file based on extension
*
* Uses sensible defaults:
* - JavaScript files (.js, .mjs, .cjs) -> Node.js (default choice)
* - TypeScript files (.ts, .tsx) -> tsx (if available)
*
* @param filePath - Path to the file
* @returns Suggested runtime or undefined for native executables
*/
function detectRuntimeFromExtension(filePath: string): string | undefined {
const ext = path.extname(filePath).toLowerCase();
if (['.js', '.mjs', '.cjs'].includes(ext)) {
// Default to Node.js for JavaScript files
return 'node';
}
if (['.ts', '.tsx'].includes(ext)) {
// Check if tsx is available for TypeScript files
if (isCommandAvailable('tsx')) {
return 'tsx';
}
// If tsx is not available, suggest it in error message
throw new Error(
`TypeScript file '${filePath}' requires 'tsx' runtime, but it's not available. ` +
'Please install tsx: npm install -g tsx, or use explicit runtime: tsx:/path/to/file.ts',
);
}
// Native executable or unknown extension
return undefined;
}
/**
* Prepare spawn information for CLI process
*
* Handles all supported executable formats with clear separation of concerns:
* 1. Parse the executable specification
* 2. Determine the appropriate runtime
* 3. Build the spawn command and arguments
*
* @param executableSpec - Executable specification (path, command, or runtime:path)
* @returns SpawnInfo with command and args for spawning
*
* @example
* ```typescript
* // Native binary (production)
* prepareSpawnInfo('qwen') // -> { command: 'qwen', args: [], type: 'native' }
*
* // Node.js bundle (default for .js files)
* prepareSpawnInfo('/path/to/cli.js') // -> { command: 'node', args: ['/path/to/cli.js'], type: 'node' }
*
* // TypeScript source (development, requires tsx)
* prepareSpawnInfo('/path/to/index.ts') // -> { command: 'tsx', args: ['/path/to/index.ts'], type: 'tsx' }
*
* // Advanced: Force specific runtime
* prepareSpawnInfo('bun:/path/to/cli.js') // -> { command: 'bun', args: ['/path/to/cli.js'], type: 'bun' }
* ```
*/
export function prepareSpawnInfo(executableSpec?: string): SpawnInfo {
const parsed = parseExecutableSpec(executableSpec);
const { runtime, executablePath, isExplicitRuntime } = parsed;
// If runtime is explicitly specified, use it
if (isExplicitRuntime && runtime) {
const runtimeCommand = runtime === 'node' ? process.execPath : runtime;
return {
command: runtimeCommand,
args: [executablePath],
type: runtime as ExecutableType,
originalInput: executableSpec || '',
};
}
// If no explicit runtime, try to detect from file extension
const detectedRuntime = detectRuntimeFromExtension(executablePath);
if (detectedRuntime) {
const runtimeCommand =
detectedRuntime === 'node' ? process.execPath : detectedRuntime;
return {
command: runtimeCommand,
args: [executablePath],
type: detectedRuntime as ExecutableType,
originalInput: executableSpec || '',
};
}
// Native executable or command name - use it directly
return {
command: executablePath,
args: [],
type: 'native',
originalInput: executableSpec || '',
};
}
/**
* Legacy function for backward compatibility
* @deprecated Use prepareSpawnInfo() instead
*/
export function findCliPath(): string {
return findNativeCliPath();
}

View File

@@ -0,0 +1,137 @@
/**
* JSON Lines protocol utilities
*
* JSON Lines format: one JSON object per line, newline-delimited
* Example:
* {"type":"user","message":{...}}
* {"type":"assistant","message":{...}}
*
* Used for SDK-CLI communication over stdin/stdout streams.
*/
/**
* Serialize a message to JSON Lines format
*
* Converts object to JSON and appends newline.
*
* @param message - Object to serialize
* @returns JSON string with trailing newline
* @throws Error if JSON serialization fails
*/
export function serializeJsonLine(message: unknown): string {
try {
return JSON.stringify(message) + '\n';
} catch (error) {
throw new Error(
`Failed to serialize message to JSON: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Parse a JSON Lines message
*
* Parses single line of JSON (without newline).
*
* @param line - JSON string (without trailing newline)
* @returns Parsed object
* @throws Error if JSON parsing fails
*/
export function parseJsonLine(line: string): unknown {
try {
return JSON.parse(line);
} catch (error) {
throw new Error(
`Failed to parse JSON line: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Parse JSON Lines with error handling
*
* Attempts to parse JSON line, logs warning and returns null on failure.
* Useful for robust parsing where malformed messages should be skipped.
*
* @param line - JSON string (without trailing newline)
* @param context - Context string for error logging (e.g., 'Transport')
* @returns Parsed object or null if parsing fails
*/
export function parseJsonLineSafe(
line: string,
context = 'JsonLines',
): unknown | null {
try {
return JSON.parse(line);
} catch (error) {
console.warn(
`[${context}] Failed to parse JSON line, skipping:`,
line.substring(0, 100),
error instanceof Error ? error.message : String(error),
);
return null;
}
}
/**
* Validate message has required type field
*
* Ensures message conforms to basic message protocol.
*
* @param message - Parsed message object
* @returns true if valid, false otherwise
*/
export function isValidMessage(message: unknown): boolean {
return (
message !== null &&
typeof message === 'object' &&
'type' in message &&
typeof (message as { type: unknown }).type === 'string'
);
}
/**
* Async generator that yields parsed JSON Lines from async iterable of strings
*
* Usage:
* ```typescript
* const lines = readline.createInterface({ input: stream });
* for await (const message of parseJsonLinesStream(lines)) {
* console.log(message);
* }
* ```
*
* @param lines - AsyncIterable of line strings
* @param context - Context string for error logging
* @yields Parsed message objects (skips malformed lines)
*/
export async function* parseJsonLinesStream(
lines: AsyncIterable<string>,
context = 'JsonLines',
): AsyncGenerator<unknown, void, unknown> {
for await (const line of lines) {
// Skip empty lines
if (line.trim().length === 0) {
continue;
}
// Parse with error handling
const message = parseJsonLineSafe(line, context);
// Skip malformed messages
if (message === null) {
continue;
}
// Validate message structure
if (!isValidMessage(message)) {
console.warn(
`[${context}] Invalid message structure (missing 'type' field), skipping:`,
line.substring(0, 100),
);
continue;
}
yield message;
}
}

View File

@@ -0,0 +1,489 @@
/**
* E2E tests based on abort-and-lifecycle.ts example
* Tests AbortController integration and process lifecycle management
*/
/* eslint-disable @typescript-eslint/no-unused-vars */
import { describe, it, expect } from 'vitest';
import {
query,
AbortError,
isAbortError,
isCLIAssistantMessage,
type TextBlock,
type ContentBlock,
} from '../../src/index.js';
// Test configuration
const TEST_CLI_PATH =
'/Users/mingholy/Work/Projects/qwen-code/packages/cli/index.ts';
const TEST_TIMEOUT = 30000;
// Shared test options with permissionMode to allow all tools
const SHARED_TEST_OPTIONS = {
pathToQwenExecutable: TEST_CLI_PATH,
permissionMode: 'yolo' as const,
};
describe('AbortController and Process Lifecycle (E2E)', () => {
describe('Basic AbortController Usage', () => {
/* TODO: Currently query does not throw AbortError when aborted */
it(
'should support AbortController cancellation',
async () => {
const controller = new AbortController();
// Abort after 5 seconds
setTimeout(() => {
controller.abort();
}, 5000);
const q = query({
prompt: 'Write a very long story about TypeScript programming',
options: {
...SHARED_TEST_OPTIONS,
abortController: controller,
debug: false,
},
});
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
const textBlocks = message.message.content.filter(
(block): block is TextBlock => block.type === 'text',
);
const text = textBlocks
.map((b) => b.text)
.join('')
.slice(0, 100);
// Should receive some content before abort
expect(text.length).toBeGreaterThan(0);
}
}
// Should not reach here - query should be aborted
expect(false).toBe(true);
} catch (error) {
expect(isAbortError(error)).toBe(true);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should handle immediate abort',
async () => {
const controller = new AbortController();
const q = query({
prompt: 'Write a very long essay',
options: {
...SHARED_TEST_OPTIONS,
abortController: controller,
debug: true,
},
});
// Abort immediately
setTimeout(() => {
controller.abort();
console.log('Aborted!');
}, 300);
try {
for await (const _message of q) {
// May receive some messages before abort
}
} catch (error) {
expect(error instanceof AbortError).toBe(true);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
});
describe('Process Lifecycle Monitoring', () => {
it(
'should handle normal process completion',
async () => {
const q = query({
prompt: 'Why do we choose to go to the moon?',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
let completedSuccessfully = false;
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
const textBlocks = message.message.content.filter(
(block): block is TextBlock => block.type === 'text',
);
const text = textBlocks
.map((b) => b.text)
.join('')
.slice(0, 100);
expect(text.length).toBeGreaterThan(0);
}
}
completedSuccessfully = true;
} catch (error) {
// Should not throw for normal completion
expect(false).toBe(true);
} finally {
await q.close();
expect(completedSuccessfully).toBe(true);
}
},
TEST_TIMEOUT,
);
it(
'should handle process cleanup after error',
async () => {
const q = query({
prompt: 'Hello world',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
const textBlocks = message.message.content.filter(
(block): block is TextBlock => block.type === 'text',
);
const text = textBlocks
.map((b) => b.text)
.join('')
.slice(0, 50);
expect(text.length).toBeGreaterThan(0);
}
}
} catch (error) {
// Expected to potentially have errors
} finally {
// Should cleanup successfully even after error
await q.close();
expect(true).toBe(true); // Cleanup completed
}
},
TEST_TIMEOUT,
);
});
describe('Input Stream Control', () => {
it(
'should support endInput() method',
async () => {
const q = query({
prompt: 'What is 2 + 2?',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
let receivedResponse = false;
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
const textBlocks = message.message.content.filter(
(block: ContentBlock): block is TextBlock =>
block.type === 'text',
);
const text = textBlocks
.map((b: TextBlock) => b.text)
.join('')
.slice(0, 100);
expect(text.length).toBeGreaterThan(0);
receivedResponse = true;
// End input after receiving first response
q.endInput();
break;
}
}
expect(receivedResponse).toBe(true);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
});
describe('Error Handling and Recovery', () => {
it(
'should handle invalid executable path',
async () => {
try {
const q = query({
prompt: 'Hello world',
options: {
pathToQwenExecutable: '/nonexistent/path/to/cli',
debug: false,
},
});
// Should not reach here - query() should throw immediately
for await (const _message of q) {
// Should not reach here
}
// Should not reach here
expect(false).toBe(true);
} catch (error) {
expect(error instanceof Error).toBe(true);
expect((error as Error).message).toBeDefined();
expect((error as Error).message).toContain(
'Invalid pathToQwenExecutable',
);
}
},
TEST_TIMEOUT,
);
it(
'should handle AbortError correctly',
async () => {
const controller = new AbortController();
const q = query({
prompt: 'Write a long story',
options: {
...SHARED_TEST_OPTIONS,
abortController: controller,
debug: false,
},
});
// Abort after short delay
setTimeout(() => controller.abort(), 500);
try {
for await (const _message of q) {
// May receive some messages
}
} catch (error) {
expect(isAbortError(error)).toBe(true);
expect(error instanceof AbortError).toBe(true);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
});
describe('Debugging with stderr callback', () => {
it(
'should capture stderr messages when debug is enabled',
async () => {
const stderrMessages: string[] = [];
const q = query({
prompt: 'Why do we choose to go to the moon?',
options: {
...SHARED_TEST_OPTIONS,
debug: true,
stderr: (message: string) => {
stderrMessages.push(message);
},
},
});
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
const textBlocks = message.message.content.filter(
(block): block is TextBlock => block.type === 'text',
);
const text = textBlocks
.map((b) => b.text)
.join('')
.slice(0, 50);
expect(text.length).toBeGreaterThan(0);
}
}
} finally {
await q.close();
expect(stderrMessages.length).toBeGreaterThan(0);
}
},
TEST_TIMEOUT,
);
it(
'should not capture stderr when debug is disabled',
async () => {
const stderrMessages: string[] = [];
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
stderr: (message: string) => {
stderrMessages.push(message);
},
},
});
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
break;
}
}
} finally {
await q.close();
// Should have minimal or no stderr output when debug is false
expect(stderrMessages.length).toBeLessThan(10);
}
},
TEST_TIMEOUT,
);
});
describe('Abort with Cleanup', () => {
it(
'should cleanup properly after abort',
async () => {
const controller = new AbortController();
const q = query({
prompt: 'Write a very long essay about programming',
options: {
...SHARED_TEST_OPTIONS,
abortController: controller,
debug: false,
},
});
// Abort immediately
setTimeout(() => controller.abort(), 100);
try {
for await (const _message of q) {
// May receive some messages before abort
}
} catch (error) {
if (error instanceof AbortError) {
expect(true).toBe(true); // Expected abort error
} else {
throw error; // Unexpected error
}
} finally {
await q.close();
expect(true).toBe(true); // Cleanup completed after abort
}
},
TEST_TIMEOUT,
);
it(
'should handle multiple abort calls gracefully',
async () => {
const controller = new AbortController();
const q = query({
prompt: 'Count to 100',
options: {
...SHARED_TEST_OPTIONS,
abortController: controller,
debug: false,
},
});
// Multiple abort calls
setTimeout(() => controller.abort(), 100);
setTimeout(() => controller.abort(), 200);
setTimeout(() => controller.abort(), 300);
try {
for await (const _message of q) {
// Should be interrupted
}
} catch (error) {
expect(isAbortError(error)).toBe(true);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
});
describe('Resource Management Edge Cases', () => {
it(
'should handle close() called multiple times',
async () => {
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
// Start the query
const iterator = q[Symbol.asyncIterator]();
await iterator.next();
// Close multiple times
await q.close();
await q.close();
await q.close();
// Should not throw
expect(true).toBe(true);
},
TEST_TIMEOUT,
);
it(
'should handle abort after close',
async () => {
const controller = new AbortController();
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
abortController: controller,
debug: false,
},
});
// Start and close immediately
const iterator = q[Symbol.asyncIterator]();
await iterator.next();
await q.close();
// Abort after close
controller.abort();
// Should not throw
expect(true).toBe(true);
},
TEST_TIMEOUT,
);
});
});

View File

@@ -0,0 +1,515 @@
/**
* E2E tests based on basic-usage.ts example
* Tests message type recognition and basic query patterns
*/
import { describe, it, expect } from 'vitest';
import { query } from '../../src/index.js';
import {
isCLIUserMessage,
isCLIAssistantMessage,
isCLISystemMessage,
isCLIResultMessage,
isCLIPartialAssistantMessage,
isControlRequest,
isControlResponse,
isControlCancel,
type TextBlock,
type ContentBlock,
type CLIMessage,
type ControlMessage,
type CLISystemMessage,
type CLIUserMessage,
type CLIAssistantMessage,
type ToolUseBlock,
type ToolResultBlock,
} from '../../src/types/protocol.js';
// Test configuration
const TEST_CLI_PATH =
'/Users/mingholy/Work/Projects/qwen-code/packages/cli/index.ts';
const TEST_TIMEOUT = 30000;
// Shared test options with permissionMode to allow all tools
const SHARED_TEST_OPTIONS = {
pathToQwenExecutable: TEST_CLI_PATH,
permissionMode: 'yolo' as const,
};
/**
* Determine the message type using protocol type guards
*/
function getMessageType(message: CLIMessage | ControlMessage): string {
if (isCLIUserMessage(message)) {
return '🧑 USER';
} else if (isCLIAssistantMessage(message)) {
return '🤖 ASSISTANT';
} else if (isCLISystemMessage(message)) {
return `🖥️ SYSTEM(${message.subtype})`;
} else if (isCLIResultMessage(message)) {
return `✅ RESULT(${message.subtype})`;
} else if (isCLIPartialAssistantMessage(message)) {
return '⏳ STREAM_EVENT';
} else if (isControlRequest(message)) {
return `🎮 CONTROL_REQUEST(${message.request.subtype})`;
} else if (isControlResponse(message)) {
return `📭 CONTROL_RESPONSE(${message.response.subtype})`;
} else if (isControlCancel(message)) {
return '🛑 CONTROL_CANCEL';
} else {
return '❓ UNKNOWN';
}
}
describe('Basic Usage (E2E)', () => {
describe('Message Type Recognition', () => {
it('should correctly identify message types using type guards', async () => {
const q = query({
prompt:
'What files are in the current directory? List only the top-level files and folders.',
options: {
...SHARED_TEST_OPTIONS,
cwd: process.cwd(),
debug: true,
},
});
const messages: CLIMessage[] = [];
const messageTypes: string[] = [];
try {
for await (const message of q) {
messages.push(message);
const messageType = getMessageType(message);
messageTypes.push(messageType);
if (isCLIResultMessage(message)) {
break;
}
}
expect(messages.length).toBeGreaterThan(0);
expect(messageTypes.length).toBe(messages.length);
// Should have at least assistant and result messages
expect(messageTypes.some((type) => type.includes('ASSISTANT'))).toBe(
true,
);
expect(messageTypes.some((type) => type.includes('RESULT'))).toBe(true);
// Verify type guards work correctly
const assistantMessages = messages.filter(isCLIAssistantMessage);
const resultMessages = messages.filter(isCLIResultMessage);
expect(assistantMessages.length).toBeGreaterThan(0);
expect(resultMessages.length).toBeGreaterThan(0);
} finally {
await q.close();
}
});
it(
'should handle message content extraction',
async () => {
const q = query({
prompt: 'Say hello and explain what you are',
options: {
...SHARED_TEST_OPTIONS,
debug: true,
},
});
let assistantMessage: CLIAssistantMessage | null = null;
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
assistantMessage = message;
break;
}
}
expect(assistantMessage).not.toBeNull();
expect(assistantMessage!.message.content).toBeDefined();
// Extract text blocks
const textBlocks = assistantMessage!.message.content.filter(
(block: ContentBlock): block is TextBlock => block.type === 'text',
);
expect(textBlocks.length).toBeGreaterThan(0);
expect(textBlocks[0].text).toBeDefined();
expect(textBlocks[0].text.length).toBeGreaterThan(0);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
});
describe('Basic Query Patterns', () => {
it(
'should handle simple question-answer pattern',
async () => {
const q = query({
prompt: 'What is 2 + 2?',
options: {
...SHARED_TEST_OPTIONS,
debug: true,
},
});
const messages: CLIMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
expect(messages.length).toBeGreaterThan(0);
// Should have assistant response
const assistantMessages = messages.filter(isCLIAssistantMessage);
expect(assistantMessages.length).toBeGreaterThan(0);
// Should end with result
const lastMessage = messages[messages.length - 1];
expect(isCLIResultMessage(lastMessage)).toBe(true);
if (isCLIResultMessage(lastMessage)) {
expect(lastMessage.subtype).toBe('success');
}
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should handle file system query pattern',
async () => {
const q = query({
prompt:
'What files are in the current directory? List only the top-level files and folders.',
options: {
...SHARED_TEST_OPTIONS,
cwd: process.cwd(),
debug: true,
},
});
const messages: CLIMessage[] = [];
let hasToolUse = false;
let hasToolResult = false;
try {
for await (const message of q) {
messages.push(message);
if (isCLIAssistantMessage(message)) {
const toolUseBlock = message.message.content.find(
(block: ContentBlock): block is ToolUseBlock =>
block.type === 'tool_use',
);
if (toolUseBlock) {
hasToolUse = true;
expect(toolUseBlock.name).toBeDefined();
expect(toolUseBlock.id).toBeDefined();
}
}
if (isCLIUserMessage(message)) {
// Tool results are sent as user messages with ToolResultBlock[] content
if (Array.isArray(message.message.content)) {
const toolResultBlock = message.message.content.find(
(block: ToolResultBlock): block is ToolResultBlock =>
block.type === 'tool_result',
);
if (toolResultBlock) {
hasToolResult = true;
expect(toolResultBlock.tool_use_id).toBeDefined();
expect(toolResultBlock.content).toBeDefined();
}
}
}
if (isCLIResultMessage(message)) {
break;
}
}
expect(messages.length).toBeGreaterThan(0);
expect(hasToolUse).toBe(true);
expect(hasToolResult).toBe(true);
// Should have assistant response after tool execution
const assistantMessages = messages.filter(isCLIAssistantMessage);
expect(assistantMessages.length).toBeGreaterThan(0);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
});
describe('Configuration and Options', () => {
it(
'should respect debug option',
async () => {
const stderrMessages: string[] = [];
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
debug: true,
stderr: (message: string) => {
stderrMessages.push(message);
},
},
});
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
break;
}
}
// Debug mode should produce stderr output
expect(stderrMessages.length).toBeGreaterThan(0);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should respect cwd option',
async () => {
const q = query({
prompt: 'List files in current directory',
options: {
...SHARED_TEST_OPTIONS,
cwd: process.cwd(),
debug: false,
},
});
let hasResponse = false;
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
hasResponse = true;
break;
}
}
expect(hasResponse).toBe(true);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
});
describe('SDK-CLI Handshaking Process', () => {
it(
'should receive system message after initialization',
async () => {
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
const messages: CLIMessage[] = [];
let systemMessage: CLISystemMessage | null = null;
try {
for await (const message of q) {
messages.push(message);
// Capture system message
if (isCLISystemMessage(message) && message.subtype === 'init') {
systemMessage = message;
break; // Exit early once we get the system message
}
// Stop after getting assistant response to avoid long execution
if (isCLIAssistantMessage(message)) {
break;
}
}
// Verify system message was received after initialization
expect(systemMessage).not.toBeNull();
expect(systemMessage!.type).toBe('system');
expect(systemMessage!.subtype).toBe('init');
// Validate system message structure matches sendSystemMessage()
expect(systemMessage!.uuid).toBeDefined();
expect(systemMessage!.session_id).toBeDefined();
expect(systemMessage!.cwd).toBeDefined();
expect(systemMessage!.tools).toBeDefined();
expect(Array.isArray(systemMessage!.tools)).toBe(true);
expect(systemMessage!.mcp_servers).toBeDefined();
expect(Array.isArray(systemMessage!.mcp_servers)).toBe(true);
expect(systemMessage!.model).toBeDefined();
expect(systemMessage!.permissionMode).toBeDefined();
expect(systemMessage!.slash_commands).toBeDefined();
expect(Array.isArray(systemMessage!.slash_commands)).toBe(true);
expect(systemMessage!.apiKeySource).toBeDefined();
expect(systemMessage!.qwen_code_version).toBeDefined();
expect(systemMessage!.output_style).toBeDefined();
expect(systemMessage!.agents).toBeDefined();
expect(Array.isArray(systemMessage!.agents)).toBe(true);
expect(systemMessage!.skills).toBeDefined();
expect(Array.isArray(systemMessage!.skills)).toBe(true);
// Verify system message appears early in the message sequence
const systemMessageIndex = messages.findIndex(
(msg) => isCLISystemMessage(msg) && msg.subtype === 'init',
);
expect(systemMessageIndex).toBeGreaterThanOrEqual(0);
expect(systemMessageIndex).toBeLessThan(3); // Should be one of the first few messages
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should handle initialization with session ID consistency',
async () => {
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
let systemMessage: CLISystemMessage | null = null;
let userMessage: CLIUserMessage | null = null;
const sessionId = q.getSessionId();
try {
for await (const message of q) {
// Capture system message
if (isCLISystemMessage(message) && message.subtype === 'init') {
systemMessage = message;
}
// Capture user message
if (isCLIUserMessage(message)) {
userMessage = message;
}
// Stop after getting assistant response to avoid long execution
if (isCLIAssistantMessage(message)) {
break;
}
}
// Verify session IDs are consistent within the system
expect(sessionId).toBeDefined();
expect(systemMessage).not.toBeNull();
expect(systemMessage!.session_id).toBeDefined();
expect(systemMessage!.uuid).toBeDefined();
// System message should have consistent session_id and uuid
expect(systemMessage!.session_id).toBe(systemMessage!.uuid);
if (userMessage) {
expect(userMessage.session_id).toBeDefined();
// User message should have the same session_id as system message
expect(userMessage.session_id).toBe(systemMessage!.session_id);
}
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
});
describe('Message Flow Validation', () => {
it(
'should follow expected message sequence',
async () => {
const q = query({
prompt: 'What is the current time?',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
const messageSequence: string[] = [];
try {
for await (const message of q) {
messageSequence.push(message.type);
if (isCLIResultMessage(message)) {
break;
}
}
expect(messageSequence.length).toBeGreaterThan(0);
// Should end with result
expect(messageSequence[messageSequence.length - 1]).toBe('result');
// Should have at least one assistant message
expect(messageSequence).toContain('assistant');
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should handle graceful completion',
async () => {
const q = query({
prompt: 'Say goodbye',
options: {
...SHARED_TEST_OPTIONS,
debug: true,
},
});
let completedNaturally = false;
let messageCount = 0;
try {
for await (const message of q) {
messageCount++;
if (isCLIResultMessage(message)) {
completedNaturally = true;
expect(message.subtype).toBe('success');
}
}
expect(messageCount).toBeGreaterThan(0);
expect(completedNaturally).toBe(true);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
});
});

View File

@@ -0,0 +1,517 @@
/**
* E2E tests based on multi-turn.ts example
* Tests multi-turn conversation functionality with real CLI
*/
import { describe, it, expect } from 'vitest';
import { query } from '../../src/index.js';
import {
isCLIUserMessage,
isCLIAssistantMessage,
isCLISystemMessage,
isCLIResultMessage,
isCLIPartialAssistantMessage,
isControlRequest,
isControlResponse,
isControlCancel,
type CLIUserMessage,
type CLIAssistantMessage,
type TextBlock,
type ContentBlock,
type CLIMessage,
type ControlMessage,
type ToolUseBlock,
} from '../../src/types/protocol.js';
// Test configuration
const TEST_CLI_PATH =
'/Users/mingholy/Work/Projects/qwen-code/packages/cli/index.ts';
const TEST_TIMEOUT = 60000; // Longer timeout for multi-turn conversations
// Shared test options with permissionMode to allow all tools
const SHARED_TEST_OPTIONS = {
pathToQwenExecutable: TEST_CLI_PATH,
permissionMode: 'yolo' as const,
};
/**
* Determine the message type using protocol type guards
*/
function getMessageType(message: CLIMessage | ControlMessage): string {
if (isCLIUserMessage(message)) {
return '🧑 USER';
} else if (isCLIAssistantMessage(message)) {
return '🤖 ASSISTANT';
} else if (isCLISystemMessage(message)) {
return `🖥️ SYSTEM(${message.subtype})`;
} else if (isCLIResultMessage(message)) {
return `✅ RESULT(${message.subtype})`;
} else if (isCLIPartialAssistantMessage(message)) {
return '⏳ STREAM_EVENT';
} else if (isControlRequest(message)) {
return `🎮 CONTROL_REQUEST(${message.request.subtype})`;
} else if (isControlResponse(message)) {
return `📭 CONTROL_RESPONSE(${message.response.subtype})`;
} else if (isControlCancel(message)) {
return '🛑 CONTROL_CANCEL';
} else {
return '❓ UNKNOWN';
}
}
/**
* Helper to extract text from ContentBlock array
*/
function extractText(content: ContentBlock[]): string {
return content
.filter((block): block is TextBlock => block.type === 'text')
.map((block) => block.text)
.join('');
}
describe('Multi-Turn Conversations (E2E)', () => {
describe('AsyncIterable Prompt Support', () => {
it(
'should handle multi-turn conversation using AsyncIterable prompt',
async () => {
// Create multi-turn conversation generator
async function* createMultiTurnConversation(): AsyncIterable<CLIUserMessage> {
const sessionId = crypto.randomUUID();
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content:
'What is the name of this project? Check the package.json file.',
},
parent_tool_use_id: null,
} as CLIUserMessage;
// Wait a bit to simulate user thinking time
await new Promise((resolve) => setTimeout(resolve, 100));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'What version is it currently on?',
},
parent_tool_use_id: null,
} as CLIUserMessage;
await new Promise((resolve) => setTimeout(resolve, 100));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'What are the main dependencies?',
},
parent_tool_use_id: null,
} as CLIUserMessage;
}
// Create multi-turn query using AsyncIterable prompt
const q = query({
prompt: createMultiTurnConversation(),
options: {
...SHARED_TEST_OPTIONS,
cwd: process.cwd(),
debug: false,
},
});
const messages: CLIMessage[] = [];
const assistantMessages: CLIAssistantMessage[] = [];
let turnCount = 0;
try {
for await (const message of q) {
messages.push(message);
if (isCLIAssistantMessage(message)) {
assistantMessages.push(message);
turnCount++;
}
}
expect(messages.length).toBeGreaterThan(0);
expect(assistantMessages.length).toBeGreaterThanOrEqual(3); // Should have responses to all 3 questions
expect(turnCount).toBeGreaterThanOrEqual(3);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should maintain session context across turns',
async () => {
async function* createContextualConversation(): AsyncIterable<CLIUserMessage> {
const sessionId = crypto.randomUUID();
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content:
'My name is Alice. Remember this during our current conversation.',
},
parent_tool_use_id: null,
} as CLIUserMessage;
await new Promise((resolve) => setTimeout(resolve, 200));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'What is my name?',
},
parent_tool_use_id: null,
} as CLIUserMessage;
}
const q = query({
prompt: createContextualConversation(),
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
const assistantMessages: CLIAssistantMessage[] = [];
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
assistantMessages.push(message);
}
}
expect(assistantMessages.length).toBeGreaterThanOrEqual(2);
// The second response should reference the name Alice
const secondResponse = extractText(
assistantMessages[1].message.content,
);
expect(secondResponse.toLowerCase()).toContain('alice');
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
});
describe('Tool Usage in Multi-Turn', () => {
it(
'should handle tool usage across multiple turns',
async () => {
async function* createToolConversation(): AsyncIterable<CLIUserMessage> {
const sessionId = crypto.randomUUID();
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'List the files in the current directory',
},
parent_tool_use_id: null,
} as CLIUserMessage;
await new Promise((resolve) => setTimeout(resolve, 200));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'Now tell me about the package.json file specifically',
},
parent_tool_use_id: null,
} as CLIUserMessage;
}
const q = query({
prompt: createToolConversation(),
options: {
...SHARED_TEST_OPTIONS,
cwd: process.cwd(),
debug: false,
},
});
const messages: CLIMessage[] = [];
let toolUseCount = 0;
let assistantCount = 0;
try {
for await (const message of q) {
messages.push(message);
if (isCLIAssistantMessage(message)) {
const hasToolUseBlock = message.message.content.some(
(block: ContentBlock): block is ToolUseBlock =>
block.type === 'tool_use',
);
if (hasToolUseBlock) {
toolUseCount++;
}
}
if (isCLIAssistantMessage(message)) {
assistantCount++;
}
if (isCLIResultMessage(message)) {
break;
}
}
expect(messages.length).toBeGreaterThan(0);
expect(toolUseCount).toBeGreaterThan(0); // Should use tools
expect(assistantCount).toBeGreaterThanOrEqual(2); // Should have responses to both questions
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
});
describe('Message Flow and Sequencing', () => {
it(
'should process messages in correct sequence',
async () => {
async function* createSequentialConversation(): AsyncIterable<CLIUserMessage> {
const sessionId = crypto.randomUUID();
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'First question: What is 1 + 1?',
},
parent_tool_use_id: null,
} as CLIUserMessage;
await new Promise((resolve) => setTimeout(resolve, 100));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'Second question: What is 2 + 2?',
},
parent_tool_use_id: null,
} as CLIUserMessage;
}
const q = query({
prompt: createSequentialConversation(),
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
const messageSequence: string[] = [];
const assistantResponses: string[] = [];
try {
for await (const message of q) {
const messageType = getMessageType(message);
messageSequence.push(messageType);
if (isCLIAssistantMessage(message)) {
const text = extractText(message.message.content);
assistantResponses.push(text);
}
}
expect(messageSequence.length).toBeGreaterThan(0);
expect(assistantResponses.length).toBeGreaterThanOrEqual(2);
// Should end with result
expect(messageSequence[messageSequence.length - 1]).toContain(
'RESULT',
);
// Should have assistant responses
expect(
messageSequence.some((type) => type.includes('ASSISTANT')),
).toBe(true);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should handle conversation completion correctly',
async () => {
async function* createSimpleConversation(): AsyncIterable<CLIUserMessage> {
const sessionId = crypto.randomUUID();
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'Hello',
},
parent_tool_use_id: null,
} as CLIUserMessage;
await new Promise((resolve) => setTimeout(resolve, 100));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'Goodbye',
},
parent_tool_use_id: null,
} as CLIUserMessage;
}
const q = query({
prompt: createSimpleConversation(),
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
let completedNaturally = false;
let messageCount = 0;
try {
for await (const message of q) {
messageCount++;
if (isCLIResultMessage(message)) {
completedNaturally = true;
expect(message.subtype).toBe('success');
}
}
expect(messageCount).toBeGreaterThan(0);
expect(completedNaturally).toBe(true);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
});
describe('Error Handling in Multi-Turn', () => {
it(
'should handle empty conversation gracefully',
async () => {
async function* createEmptyConversation(): AsyncIterable<CLIUserMessage> {
// Generator that yields nothing
/* eslint-disable no-constant-condition */
if (false) {
yield {} as CLIUserMessage; // Unreachable, but satisfies TypeScript
}
}
const q = query({
prompt: createEmptyConversation(),
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
const messages: CLIMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
if (isCLIResultMessage(message)) {
break;
}
}
// Should handle empty conversation without crashing
expect(true).toBe(true);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should handle conversation with delays',
async () => {
async function* createDelayedConversation(): AsyncIterable<CLIUserMessage> {
const sessionId = crypto.randomUUID();
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'First message',
},
parent_tool_use_id: null,
} as CLIUserMessage;
// Longer delay to test patience
await new Promise((resolve) => setTimeout(resolve, 500));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'Second message after delay',
},
parent_tool_use_id: null,
} as CLIUserMessage;
}
const q = query({
prompt: createDelayedConversation(),
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
const assistantMessages: CLIAssistantMessage[] = [];
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
assistantMessages.push(message);
}
}
expect(assistantMessages.length).toBeGreaterThanOrEqual(2);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
});
});

View File

@@ -0,0 +1,744 @@
/**
* End-to-End tests for simple query execution with real CLI
* Tests the complete SDK workflow with actual CLI subprocess
*/
/* eslint-disable @typescript-eslint/no-unused-vars */
import { describe, it, expect } from 'vitest';
import {
query,
AbortError,
isAbortError,
isCLIAssistantMessage,
isCLIUserMessage,
isCLIResultMessage,
type TextBlock,
type ToolUseBlock,
type ToolResultBlock,
type ContentBlock,
type CLIMessage,
type CLIAssistantMessage,
} from '../../src/index.js';
// Test configuration
const TEST_CLI_PATH =
'/Users/mingholy/Work/Projects/qwen-code/packages/cli/index.ts';
const TEST_TIMEOUT = 30000;
// Shared test options with permissionMode to allow all tools
const SHARED_TEST_OPTIONS = {
pathToQwenExecutable: TEST_CLI_PATH,
permissionMode: 'yolo' as const,
};
describe('Simple Query Execution (E2E)', () => {
describe('Basic Query Flow', () => {
it(
'should execute simple text query',
async () => {
const q = query({
prompt: 'What is 2 + 2?',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
const messages: CLIMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
if (isCLIResultMessage(message)) {
break;
}
}
expect(messages.length).toBeGreaterThan(0);
// Should have at least one assistant message
const assistantMessages = messages.filter(isCLIAssistantMessage);
expect(assistantMessages.length).toBeGreaterThan(0);
// Should end with result message
const lastMessage = messages[messages.length - 1];
expect(isCLIResultMessage(lastMessage)).toBe(true);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should receive assistant response',
async () => {
const q = query({
prompt: 'Say hello',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
let hasAssistantMessage = false;
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
hasAssistantMessage = true;
const textBlocks = message.message.content.filter(
(block): block is TextBlock => block.type === 'text',
);
expect(textBlocks.length).toBeGreaterThan(0);
expect(textBlocks[0].text.length).toBeGreaterThan(0);
break;
}
}
expect(hasAssistantMessage).toBe(true);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should receive result message at end',
async () => {
const q = query({
prompt: 'Simple test',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
const messages: CLIMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
expect(messages.length).toBeGreaterThan(0);
const lastMessage = messages[messages.length - 1];
expect(isCLIResultMessage(lastMessage)).toBe(true);
if (isCLIResultMessage(lastMessage)) {
expect(lastMessage.subtype).toBe('success');
}
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should complete iteration after result',
async () => {
const q = query({
prompt: 'Hello, who are you?',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
let messageCount = 0;
let completedNaturally = false;
try {
for await (const message of q) {
messageCount++;
if (isCLIResultMessage(message)) {
// Should be the last message
completedNaturally = true;
}
}
expect(messageCount).toBeGreaterThan(0);
expect(completedNaturally).toBe(true);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
});
describe('Query with Tool Usage', () => {
it(
'should handle query requiring tool execution',
async () => {
const q = query({
prompt:
'What files are in the current directory? List only the top-level files and folders.',
options: {
...SHARED_TEST_OPTIONS,
cwd: process.cwd(),
debug: false,
},
});
const messages: CLIMessage[] = [];
let hasToolUse = false;
let hasAssistantResponse = false;
try {
for await (const message of q) {
messages.push(message);
if (isCLIAssistantMessage(message)) {
hasAssistantResponse = true;
const hasToolUseBlock = message.message.content.some(
(block: ContentBlock) => block.type === 'tool_use',
);
if (hasToolUseBlock) {
hasToolUse = true;
}
}
if (isCLIResultMessage(message)) {
break;
}
}
expect(messages.length).toBeGreaterThan(0);
expect(hasToolUse).toBe(true);
expect(hasAssistantResponse).toBe(true);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should yield tool_use messages',
async () => {
const q = query({
prompt: 'List files in current directory',
options: {
...SHARED_TEST_OPTIONS,
cwd: process.cwd(),
debug: false,
},
});
let toolUseMessage: ToolUseBlock | null = null;
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
const toolUseBlock = message.message.content.find(
(block: ContentBlock): block is ToolUseBlock =>
block.type === 'tool_use',
);
if (toolUseBlock) {
toolUseMessage = toolUseBlock;
expect(toolUseBlock.name).toBeDefined();
expect(toolUseBlock.id).toBeDefined();
expect(toolUseBlock.input).toBeDefined();
break;
}
}
}
expect(toolUseMessage).not.toBeNull();
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should yield tool_result messages',
async () => {
const q = query({
prompt: 'List files in current directory',
options: {
...SHARED_TEST_OPTIONS,
cwd: process.cwd(),
debug: false,
},
});
let toolResultMessage: ToolResultBlock | null = null;
try {
for await (const message of q) {
if (isCLIUserMessage(message)) {
// Tool results are sent as user messages with ToolResultBlock[] content
if (Array.isArray(message.message.content)) {
const toolResultBlock = message.message.content.find(
(block: ContentBlock): block is ToolResultBlock =>
block.type === 'tool_result',
);
if (toolResultBlock) {
toolResultMessage = toolResultBlock;
expect(toolResultBlock.tool_use_id).toBeDefined();
expect(toolResultBlock.content).toBeDefined();
// Content should not be a simple string but structured data
expect(typeof toolResultBlock.content).not.toBe('undefined');
break;
}
}
}
}
expect(toolResultMessage).not.toBeNull();
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should yield final assistant response',
async () => {
const q = query({
prompt: 'List files in current directory and tell me what you found',
options: {
...SHARED_TEST_OPTIONS,
cwd: process.cwd(),
debug: false,
},
});
const assistantMessages: CLIAssistantMessage[] = [];
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
assistantMessages.push(message);
}
if (isCLIResultMessage(message)) {
break;
}
}
expect(assistantMessages.length).toBeGreaterThan(0);
// Final assistant message should contain summary
const finalAssistant =
assistantMessages[assistantMessages.length - 1];
const textBlocks = finalAssistant.message.content.filter(
(block: ContentBlock): block is TextBlock => block.type === 'text',
);
expect(textBlocks.length).toBeGreaterThan(0);
expect(textBlocks[0].text.length).toBeGreaterThan(0);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
});
describe('Configuration Options', () => {
it(
'should respect cwd option',
async () => {
const testDir = '/tmp';
const q = query({
prompt: 'What is the current working directory?',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
},
});
let hasResponse = false;
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
hasResponse = true;
// Should execute in specified directory
break;
}
}
expect(hasResponse).toBe(true);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should use explicit CLI path when provided',
async () => {
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
let hasResponse = false;
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
hasResponse = true;
break;
}
}
expect(hasResponse).toBe(true);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
});
describe('Resource Management', () => {
it(
'should cleanup subprocess on close()',
async () => {
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
// Start and immediately close
const iterator = q[Symbol.asyncIterator]();
await iterator.next();
// Should close without error
await q.close();
expect(true).toBe(true); // Cleanup completed
},
TEST_TIMEOUT,
);
});
describe('Error Handling', () => {
it(
'should throw if CLI not found',
async () => {
try {
const q = query({
prompt: 'Hello',
options: {
pathToQwenExecutable: '/nonexistent/path/to/cli',
debug: false,
},
});
// Should not reach here - query() should throw immediately
for await (const _message of q) {
// Should not reach here
}
expect(false).toBe(true); // Should have thrown
} catch (error) {
expect(error).toBeDefined();
expect(error instanceof Error).toBe(true);
expect((error as Error).message).toContain(
'Invalid pathToQwenExecutable',
);
}
},
TEST_TIMEOUT,
);
});
describe('Timeout and Cancellation', () => {
it(
'should support AbortSignal cancellation',
async () => {
const controller = new AbortController();
// Abort after 2 seconds
setTimeout(() => {
controller.abort();
}, 2000);
const q = query({
prompt: 'Write a very long story about TypeScript',
options: {
...SHARED_TEST_OPTIONS,
abortController: controller,
debug: false,
},
});
try {
for await (const _message of q) {
// Should be interrupted by abort
}
// Should not reach here
expect(false).toBe(true);
} catch (error) {
expect(isAbortError(error)).toBe(true);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should cleanup on cancellation',
async () => {
const controller = new AbortController();
const q = query({
prompt: 'Write a very long essay',
options: {
...SHARED_TEST_OPTIONS,
abortController: controller,
debug: false,
},
});
// Abort immediately
setTimeout(() => controller.abort(), 100);
try {
for await (const _message of q) {
// Should be interrupted
}
} catch (error) {
expect(error instanceof AbortError).toBe(true);
} finally {
// Should cleanup successfully even after abort
await q.close();
expect(true).toBe(true); // Cleanup completed
}
},
TEST_TIMEOUT,
);
});
describe('Message Collection Patterns', () => {
it(
'should collect all messages in array',
async () => {
const q = query({
prompt: 'What is 2 + 2?',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
const messages: CLIMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
expect(messages.length).toBeGreaterThan(0);
// Should have various message types
const messageTypes = messages.map((m) => m.type);
expect(messageTypes).toContain('assistant');
expect(messageTypes).toContain('result');
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should extract final answer',
async () => {
const q = query({
prompt: 'What is the capital of France?',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
const messages: CLIMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// Get last assistant message content
const assistantMessages = messages.filter(isCLIAssistantMessage);
expect(assistantMessages.length).toBeGreaterThan(0);
const lastAssistant = assistantMessages[assistantMessages.length - 1];
const textBlocks = lastAssistant.message.content.filter(
(block: ContentBlock): block is TextBlock => block.type === 'text',
);
expect(textBlocks.length).toBeGreaterThan(0);
expect(textBlocks[0].text).toContain('Paris');
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should track tool usage',
async () => {
const q = query({
prompt: 'List files in current directory',
options: {
...SHARED_TEST_OPTIONS,
cwd: process.cwd(),
debug: false,
},
});
const messages: CLIMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// Count tool_use blocks in assistant messages and tool_result blocks in user messages
let toolUseCount = 0;
let toolResultCount = 0;
messages.forEach((message) => {
if (isCLIAssistantMessage(message)) {
message.message.content.forEach((block: ContentBlock) => {
if (block.type === 'tool_use') {
toolUseCount++;
}
});
} else if (isCLIUserMessage(message)) {
// Tool results are in user messages
if (Array.isArray(message.message.content)) {
message.message.content.forEach((block: ContentBlock) => {
if (block.type === 'tool_result') {
toolResultCount++;
}
});
}
}
});
expect(toolUseCount).toBeGreaterThan(0);
expect(toolResultCount).toBeGreaterThan(0);
// Each tool_use should have a corresponding tool_result
expect(toolResultCount).toBeGreaterThanOrEqual(toolUseCount);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
});
describe('Real-World Scenarios', () => {
it(
'should handle code analysis query',
async () => {
const q = query({
prompt:
'What is the main export of the package.json file in this directory?',
options: {
...SHARED_TEST_OPTIONS,
cwd: process.cwd(),
debug: false,
},
});
let hasAnalysis = false;
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
const textBlocks = message.message.content.filter(
(block: ContentBlock): block is TextBlock =>
block.type === 'text',
);
if (textBlocks.length > 0 && textBlocks[0].text.length > 0) {
hasAnalysis = true;
break;
}
}
}
expect(hasAnalysis).toBe(true);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should handle multi-step query',
async () => {
const q = query({
prompt:
'List the files in this directory and tell me what type of project this is',
options: {
...SHARED_TEST_OPTIONS,
cwd: process.cwd(),
debug: false,
},
});
let hasToolUse = false;
let hasAnalysis = false;
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
const hasToolUseBlock = message.message.content.some(
(block: ContentBlock) => block.type === 'tool_use',
);
if (hasToolUseBlock) {
hasToolUse = true;
}
}
if (isCLIAssistantMessage(message)) {
const textBlocks = message.message.content.filter(
(block: ContentBlock): block is TextBlock =>
block.type === 'text',
);
if (textBlocks.length > 0 && textBlocks[0].text.length > 0) {
hasAnalysis = true;
}
}
if (isCLIResultMessage(message)) {
break;
}
}
expect(hasToolUse).toBe(true);
expect(hasAnalysis).toBe(true);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
});
});

View 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 on start()', async () => {
// Should call child_process.spawn
expect(true).toBe(true); // Placeholder
});
it('should set isReady to true after successful start', async () => {
// isReady should be true after start() completes
expect(true).toBe(true); // Placeholder
});
it('should throw if subprocess fails to spawn', async () => {
// Should throw Error if ENOENT or spawn fails
expect(true).toBe(true); // Placeholder
});
it('should close subprocess gracefully with SIGTERM', async () => {
// Should send SIGTERM first
expect(true).toBe(true); // Placeholder
});
it('should force kill with SIGKILL after timeout', async () => {
// Should send SIGKILL after 5s if process doesn\'t exit
expect(true).toBe(true); // Placeholder
});
it('should be idempotent when calling close() multiple times', async () => {
// Multiple close() calls should not error
expect(true).toBe(true); // Placeholder
});
it('should wait for process exit in waitForExit()', async () => {
// Should resolve when process exits
expect(true).toBe(true); // Placeholder
});
});
describe('Message Reading', () => {
it('should read JSON Lines from stdout', async () => {
// Should use readline to read lines and parse JSON
expect(true).toBe(true); // Placeholder
});
it('should yield parsed messages via readMessages()', async () => {
// Should yield messages as async generator
expect(true).toBe(true); // Placeholder
});
it('should skip malformed JSON lines with warning', async () => {
// Should log warning and continue on parse error
expect(true).toBe(true); // Placeholder
});
it('should complete generator when process exits', async () => {
// readMessages() should complete when stdout closes
expect(true).toBe(true); // Placeholder
});
it('should set exitError on unexpected process crash', async () => {
// exitError should be set if process crashes
expect(true).toBe(true); // Placeholder
});
});
describe('Message Writing', () => {
it('should write JSON Lines to stdin', () => {
// Should write JSON + newline to stdin
expect(true).toBe(true); // Placeholder
});
it('should throw if writing before transport is ready', () => {
// write() should throw if isReady is false
expect(true).toBe(true); // Placeholder
});
it('should throw if writing to closed transport', () => {
// write() should throw if transport is closed
expect(true).toBe(true); // Placeholder
});
});
describe('Error Handling', () => {
it('should handle process spawn errors', async () => {
// Should throw descriptive error on spawn failure
expect(true).toBe(true); // Placeholder
});
it('should handle process exit with non-zero code', async () => {
// Should set exitError when process exits with error
expect(true).toBe(true); // Placeholder
});
it('should handle write errors to closed stdin', () => {
// Should throw if stdin is closed
expect(true).toBe(true); // Placeholder
});
});
describe('Resource Cleanup', () => {
it('should register cleanup on parent process exit', () => {
// Should register process.on(\'exit\') handler
expect(true).toBe(true); // Placeholder
});
it('should kill subprocess on parent exit', () => {
// Cleanup should kill child process
expect(true).toBe(true); // Placeholder
});
it('should remove event listeners on close', async () => {
// Should clean up all event listeners
expect(true).toBe(true); // Placeholder
});
});
describe('CLI Arguments', () => {
it('should include --input-format stream-json', () => {
// Args should always include input format flag
expect(true).toBe(true); // Placeholder
});
it('should include --output-format stream-json', () => {
// Args should always include output format flag
expect(true).toBe(true); // Placeholder
});
it('should include --model if provided', () => {
// Args should include model flag if specified
expect(true).toBe(true); // Placeholder
});
it('should include --permission-mode if provided', () => {
// Args should include permission mode flag if specified
expect(true).toBe(true); // Placeholder
});
it('should include --mcp-server for external MCP servers', () => {
// Args should include MCP server configs
expect(true).toBe(true); // Placeholder
});
});
describe('Working Directory', () => {
it('should spawn process in specified cwd', async () => {
// Should use cwd option for child_process.spawn
expect(true).toBe(true); // Placeholder
});
it('should default to process.cwd() if not specified', async () => {
// Should use current working directory by default
expect(true).toBe(true); // Placeholder
});
});
describe('Environment Variables', () => {
it('should pass environment variables to subprocess', async () => {
// Should merge env with process.env
expect(true).toBe(true); // Placeholder
});
it('should inherit parent env by default', async () => {
// Should use process.env if no env option
expect(true).toBe(true); // Placeholder
});
});
describe('Debug Mode', () => {
it('should inherit stderr when debug is true', async () => {
// Should set stderr: \'inherit\' if debug flag set
expect(true).toBe(true); // Placeholder
});
it('should ignore stderr when debug is false', async () => {
// Should set stderr: \'ignore\' if debug flag not set
expect(true).toBe(true); // Placeholder
});
});
});

View 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
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -0,0 +1,247 @@
/**
* Unit tests for Stream class
* Tests producer-consumer patterns and async iteration
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { Stream } from '../../src/utils/Stream.js';
describe('Stream', () => {
let stream: Stream<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 (idempotent)', async () => {
stream.done();
stream.done();
stream.done();
const result = await stream.next();
expect(result).toEqual({ done: true, value: undefined });
});
it('should throw when enqueuing to completed stream', () => {
stream.done();
expect(() => stream.enqueue('value')).toThrow(
'Cannot enqueue to completed stream',
);
});
it('should deliver buffered values before completion', async () => {
stream.enqueue('first');
stream.enqueue('second');
stream.done();
expect(await stream.next()).toEqual({ value: 'first', done: false });
expect(await stream.next()).toEqual({ value: 'second', done: false });
expect(await stream.next()).toEqual({ done: true, value: undefined });
});
});
describe('Error Handling', () => {
it('should propagate error to waiting consumer', async () => {
const consumerPromise = stream.next();
const error = new Error('Stream error');
stream.setError(error);
await expect(consumerPromise).rejects.toThrow('Stream error');
});
it('should throw error on next read after error is set', async () => {
const error = new Error('Test error');
stream.setError(error);
await expect(stream.next()).rejects.toThrow('Test error');
});
it('should throw when enqueuing to stream with error', () => {
stream.setError(new Error('Error'));
expect(() => stream.enqueue('value')).toThrow(
'Cannot enqueue to stream with error',
);
});
it('should only store first error (idempotent)', async () => {
const firstError = new Error('First');
const secondError = new Error('Second');
stream.setError(firstError);
stream.setError(secondError);
await expect(stream.next()).rejects.toThrow('First');
});
it('should deliver buffered values before throwing error', async () => {
stream.enqueue('buffered');
stream.setError(new Error('Stream error'));
expect(await stream.next()).toEqual({ value: 'buffered', done: false });
await expect(stream.next()).rejects.toThrow('Stream error');
});
});
describe('State Properties', () => {
it('should track queue size correctly', () => {
expect(stream.queueSize).toBe(0);
stream.enqueue('a');
expect(stream.queueSize).toBe(1);
stream.enqueue('b');
expect(stream.queueSize).toBe(2);
});
it('should track completion state', () => {
expect(stream.isComplete).toBe(false);
stream.done();
expect(stream.isComplete).toBe(true);
});
it('should track error state', () => {
expect(stream.hasError).toBe(false);
stream.setError(new Error('Test'));
expect(stream.hasError).toBe(true);
});
});
describe('Edge Cases', () => {
it('should handle empty stream', async () => {
stream.done();
const result = await stream.next();
expect(result.done).toBe(true);
});
it('should handle single value', async () => {
stream.enqueue('only');
stream.done();
expect(await stream.next()).toEqual({ value: 'only', done: false });
expect(await stream.next()).toEqual({ done: true, value: undefined });
});
it('should handle rapid enqueue-dequeue cycles', async () => {
const iterations = 100;
const values: number[] = [];
const producer = async (): Promise<void> => {
for (let i = 0; i < iterations; i++) {
stream.enqueue(i);
await new Promise((resolve) => setImmediate(resolve));
}
stream.done();
};
const consumer = async (): Promise<void> => {
for await (const value of stream) {
values.push(value);
}
};
await Promise.all([producer(), consumer()]);
expect(values).toHaveLength(iterations);
expect(values[0]).toBe(0);
expect(values[iterations - 1]).toBe(iterations - 1);
});
});
describe('TypeScript Types', () => {
it('should handle different value types', async () => {
const numberStream = new Stream<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' });
});
});
});

View 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',
);
});
});
});
});

View 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();
});
});
});

View 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"]
}

View File

@@ -0,0 +1,36 @@
import { defineConfig } from 'vitest/config';
import * as path from 'path';
export default defineConfig({
test: {
globals: false,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'dist/',
'test/',
'**/*.d.ts',
'**/*.config.*',
'**/index.ts', // Export-only files
],
thresholds: {
lines: 80,
functions: 80,
branches: 75,
statements: 80,
},
},
include: ['test/**/*.test.ts'],
exclude: ['node_modules/', 'dist/'],
testTimeout: 30000,
hookTimeout: 10000,
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});

View File

@@ -6,6 +6,7 @@ export default defineConfig({
'packages/cli',
'packages/core',
'packages/vscode-ide-companion',
'packages/sdk/typescript',
'integration-tests',
'scripts',
],