Compare commits

...

36 Commits

Author SHA1 Message Date
mingholy.lmh
e8448bcca2 feat: sdk draft 2025-11-06 10:08:02 +08:00
pomelo
decb04efc4 Merge pull request #974 from QwenLM/chore/v0.1.5
chore: pump version to 0.1.5
2025-11-05 21:40:15 +08:00
pomelo-nwu
3bd0cb36c4 chore: pump version to 0.1.5 2025-11-05 19:35:17 +08:00
pomelo
553a36302a Merge pull request #972 from QwenLM/custom-logging-dir
feat: support for custom OpenAI logging directory configuration
2025-11-05 19:22:46 +08:00
pomelo
498d7a083a Merge pull request #970 from seems20/fix-kimi2-token-limits
Fix kimi2 token limits
2025-11-05 19:22:30 +08:00
pomelo-nwu
3a69931791 feat: add docs for logging dir configuration 2025-11-05 18:58:53 +08:00
pomelo-nwu
d4ab328671 feat: support for custom OpenAI logging directory configuration 2025-11-05 18:49:04 +08:00
chenhuanjie
90500ea67b Merge branch 'main' into fix-kimi2-token-limits 2025-11-05 17:36:02 +08:00
pomelo
335e765df0 Merge pull request #936 from QwenLM/fix-AbortError
fix: handle AbortError gracefully when loading commands
2025-11-05 16:38:14 +08:00
pomelo-nwu
448e30bf88 feat: support custom working directory for child process in start.js 2025-11-05 16:06:35 +08:00
chenhuanjie
26215b6d0a Merge branch 'main' into fix-kimi2-token-limits 2025-11-05 15:44:39 +08:00
chenhuanjie
f6f76a17e6 fix 2025-11-05 15:12:20 +08:00
chenhuanjie
55a3b69a8e fix 2025-11-05 15:10:52 +08:00
pomelo
22bd108775 Merge pull request #885 from QwenLM/web-search
chore: Web Search Tool Refactoring with Multi-Provider Support
2025-11-05 14:51:40 +08:00
pomelo-nwu
7ff07fd88c fix(web-search): handle unconfigured state and improve tests 2025-11-05 11:37:56 +08:00
pomelo-nwu
2967bec11c feat: update code 2025-11-05 11:23:27 +08:00
pomelo-nwu
6357a5c87e feat(web-search): enable DashScope provider only for Qwen OAuth auth type 2025-11-04 19:59:19 +08:00
tanzhenxin
7e827833bf chore: pump version to 0.1.4 (#962) 2025-11-04 19:22:37 +08:00
pomelo-nwu
d1507e73fe feat(web-search): use resource_url from credentials for DashScope endpoint 2025-11-04 16:59:30 +08:00
tanzhenxin
45f1000dea fix (#958) 2025-11-04 15:53:31 +08:00
tanzhenxin
04f0996327 fix: /ide install failed to run on Windows (#957) 2025-11-04 15:53:03 +08:00
tanzhenxin
d8cc0a1f04 fix: #923 missing macos seatbelt files in npm package (#949) 2025-11-04 15:52:46 +08:00
pomelo-nwu
512c91a969 Merge branch 'main' into web-search 2025-11-03 17:34:03 +08:00
tanzhenxin
ff8a8ac693 chore: pump version to 0.1.3 (#939) 2025-10-31 19:22:18 +08:00
pomelo-nwu
50d5cc2f6a fix: handle AbortError gracefully when loading commands 2025-10-31 17:00:28 +08:00
pomelo-nwu
40d82a2b25 feat: add docs for web search tool 2025-10-31 10:19:44 +08:00
pomelo-nwu
a40479d40a feat: adjust the description of the web search tool 2025-10-30 20:21:30 +08:00
pomelo-nwu
7cb068ceb2 Merge branch 'main' into web-search 2025-10-30 19:42:00 +08:00
pomelo-nwu
864bf03fee docs: add DashScope quota limits to web search documentation
- Add quota information (200 requests/minute, 2000 requests/day) to DashScope provider description
- Update provider details section with quota limits
2025-10-30 19:06:46 +08:00
pomelo-nwu
9a41db612a Add unit tests for web search core logic 2025-10-30 16:18:41 +08:00
pomelo-nwu
4781736f99 Improve web search fallback with snippet and web_fetch hint 2025-10-30 16:15:42 +08:00
pomelo-nwu
799d2bf0db feat: add oauth credit token 2025-10-27 19:59:13 +08:00
pomelo-nwu
741eaf91c2 feat: add web_search docs 2025-10-27 17:05:47 +08:00
pomelo-nwu
79b4821499 feat: Optimize the code 2025-10-27 11:24:38 +08:00
pomelo-nwu
b1ece177b7 feat: Optimize the code 2025-10-27 11:01:48 +08:00
pomelo-nwu
f9f6eb52dd feat: add multi websearch provider 2025-10-24 17:16:14 +08:00
74 changed files with 10250 additions and 501 deletions

View File

@@ -309,7 +309,8 @@ If you are experiencing performance issues with file searching (e.g., with `@` c
```
- **`tavilyApiKey`** (string):
- **Description:** API key for Tavily web search service. Required to enable the `web_search` tool functionality. If not configured, the web search tool will be disabled and skipped.
- **Description:** API key for Tavily web search service. Used to enable the `web_search` tool functionality.
- **Note:** This is a legacy configuration format. For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers using the new `webSearch` configuration format.
- **Default:** `undefined` (web search disabled)
- **Example:** `"tavilyApiKey": "tvly-your-api-key-here"`
- **`chatCompression`** (object):
@@ -465,8 +466,8 @@ The CLI automatically loads environment variables from an `.env` file. The loadi
- This is useful for development and testing.
- **`TAVILY_API_KEY`**:
- Your API key for the Tavily web search service.
- Required to enable the `web_search` tool functionality.
- If not configured, the web search tool will be disabled and skipped.
- Used to enable the `web_search` tool functionality.
- **Note:** For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers to enable web search.
- Example: `export TAVILY_API_KEY="tvly-your-api-key-here"`
## Command-Line Arguments
@@ -540,6 +541,9 @@ Arguments passed directly when running the CLI can override other configurations
- Displays the version of the CLI.
- **`--openai-logging`**:
- Enables logging of OpenAI API calls for debugging and analysis. This flag overrides the `enableOpenAILogging` setting in `settings.json`.
- **`--openai-logging-dir <directory>`**:
- Sets a custom directory path for OpenAI API logs. This flag overrides the `openAILoggingDir` setting in `settings.json`. Supports absolute paths, relative paths, and `~` expansion.
- **Example:** `qwen --openai-logging-dir "~/qwen-logs" --openai-logging`
- **`--tavily-api-key <api_key>`**:
- Sets the Tavily API key for web search functionality for this session.
- Example: `qwen --tavily-api-key tvly-your-api-key-here`

View File

@@ -171,6 +171,18 @@ Settings are organized into categories. All settings should be placed within the
- **Description:** Disables loop detection checks. Loop detection prevents infinite loops in AI responses but can generate false positives that interrupt legitimate workflows. Enable this option if you experience frequent false positive loop detection interruptions.
- **Default:** `false`
- **`model.enableOpenAILogging`** (boolean):
- **Description:** Enables logging of OpenAI API calls for debugging and analysis. When enabled, API requests and responses are logged to JSON files.
- **Default:** `false`
- **`model.openAILoggingDir`** (string):
- **Description:** Custom directory path for OpenAI API logs. If not specified, defaults to `logs/openai` in the current working directory. Supports absolute paths, relative paths (resolved from current working directory), and `~` expansion (home directory).
- **Default:** `undefined`
- **Examples:**
- `"~/qwen-logs"` - Logs to `~/qwen-logs` directory
- `"./custom-logs"` - Logs to `./custom-logs` relative to current directory
- `"/tmp/openai-logs"` - Logs to absolute path `/tmp/openai-logs`
#### `context`
- **`context.fileName`** (string or array of strings):
@@ -305,7 +317,8 @@ Settings are organized into categories. All settings should be placed within the
- **Default:** `undefined`
- **`advanced.tavilyApiKey`** (string):
- **Description:** API key for Tavily web search service. Required to enable the `web_search` tool functionality. If not configured, the web search tool will be disabled and skipped.
- **Description:** API key for Tavily web search service. Used to enable the `web_search` tool functionality.
- **Note:** This is a legacy configuration format. For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers using the new `webSearch` configuration format.
- **Default:** `undefined`
#### `mcpServers`
@@ -386,6 +399,8 @@ Here is an example of a `settings.json` file with the nested structure, new as o
"model": {
"name": "qwen3-coder-plus",
"maxSessionTurns": 10,
"enableOpenAILogging": false,
"openAILoggingDir": "~/qwen-logs",
"summarizeToolOutput": {
"run_shell_command": {
"tokenBudget": 100
@@ -474,8 +489,8 @@ The CLI automatically loads environment variables from an `.env` file. The loadi
- Set to a string to customize the title of the CLI.
- **`TAVILY_API_KEY`**:
- Your API key for the Tavily web search service.
- Required to enable the `web_search` tool functionality.
- If not configured, the web search tool will be disabled and skipped.
- Used to enable the `web_search` tool functionality.
- **Note:** For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers to enable web search.
- Example: `export TAVILY_API_KEY="tvly-your-api-key-here"`
## Command-Line Arguments
@@ -556,6 +571,9 @@ Arguments passed directly when running the CLI can override other configurations
- Displays the version of the CLI.
- **`--openai-logging`**:
- Enables logging of OpenAI API calls for debugging and analysis. This flag overrides the `enableOpenAILogging` setting in `settings.json`.
- **`--openai-logging-dir <directory>`**:
- Sets a custom directory path for OpenAI API logs. This flag overrides the `openAILoggingDir` setting in `settings.json`. Supports absolute paths, relative paths, and `~` expansion.
- **Example:** `qwen --openai-logging-dir "~/qwen-logs" --openai-logging`
- **`--tavily-api-key <api_key>`**:
- Sets the Tavily API key for web search functionality for this session.
- Example: `qwen --tavily-api-key tvly-your-api-key-here`

View File

@@ -1,43 +1,186 @@
# Web Search Tool (`web_search`)
This document describes the `web_search` tool.
This document describes the `web_search` tool for performing web searches using multiple providers.
## Description
Use `web_search` to perform a web search using the Tavily API. The tool returns a concise answer with sources when possible.
Use `web_search` to perform a web search and get information from the internet. The tool supports multiple search providers and returns a concise answer with source citations when available.
### Supported Providers
1. **DashScope** (Official, Free) - Automatically available for Qwen OAuth users (200 requests/minute, 2000 requests/day)
2. **Tavily** - High-quality search API with built-in answer generation
3. **Google Custom Search** - Google's Custom Search JSON API
### Arguments
`web_search` takes one argument:
`web_search` takes two arguments:
- `query` (string, required): The search query.
- `query` (string, required): The search query
- `provider` (string, optional): Specific provider to use ("dashscope", "tavily", "google")
- If not specified, uses the default provider from configuration
## How to use `web_search`
## Configuration
`web_search` calls the Tavily API directly. You must configure the `TAVILY_API_KEY` through one of the following methods:
### Method 1: Settings File (Recommended)
1. **Settings file**: Add `"tavilyApiKey": "your-key-here"` to your `settings.json`
2. **Environment variable**: Set `TAVILY_API_KEY` in your environment or `.env` file
3. **Command line**: Use `--tavily-api-key your-key-here` when running the CLI
Add to your `settings.json`:
If the key is not configured, the tool will be disabled and skipped.
Usage:
```
web_search(query="Your query goes here.")
```json
{
"webSearch": {
"provider": [
{ "type": "dashscope" },
{ "type": "tavily", "apiKey": "tvly-xxxxx" },
{
"type": "google",
"apiKey": "your-google-api-key",
"searchEngineId": "your-search-engine-id"
}
],
"default": "dashscope"
}
}
```
## `web_search` examples
**Notes:**
Get information on a topic:
- DashScope doesn't require an API key (official, free service)
- **Qwen OAuth users:** DashScope is automatically added to your provider list, even if not explicitly configured
- Configure additional providers (Tavily, Google) if you want to use them alongside DashScope
- Set `default` to specify which provider to use by default (if not set, priority order: Tavily > Google > DashScope)
```
web_search(query="latest advancements in AI-powered code generation")
### Method 2: Environment Variables
Set environment variables in your shell or `.env` file:
```bash
# Tavily
export TAVILY_API_KEY="tvly-xxxxx"
# Google
export GOOGLE_API_KEY="your-api-key"
export GOOGLE_SEARCH_ENGINE_ID="your-engine-id"
```
## Important notes
### Method 3: Command Line Arguments
- **Response returned:** The `web_search` tool returns a concise answer when available, with a list of source links.
- **Citations:** Source links are appended as a numbered list.
- **API key:** Configure `TAVILY_API_KEY` via settings.json, environment variables, .env files, or command line arguments. If not configured, the tool is not registered.
Pass API keys when running Qwen Code:
```bash
# Tavily
qwen --tavily-api-key tvly-xxxxx
# Google
qwen --google-api-key your-key --google-search-engine-id your-id
# Specify default provider
qwen --web-search-default tavily
```
### Backward Compatibility (Deprecated)
⚠️ **DEPRECATED:** The legacy `tavilyApiKey` configuration is still supported for backward compatibility but is deprecated:
```json
{
"advanced": {
"tavilyApiKey": "tvly-xxxxx" // ⚠️ Deprecated
}
}
```
**Important:** This configuration is deprecated and will be removed in a future version. Please migrate to the new `webSearch` configuration format shown above. The old configuration will automatically configure Tavily as a provider, but we strongly recommend updating your configuration.
## Disabling Web Search
If you want to disable the web search functionality, you can exclude the `web_search` tool in your `settings.json`:
```json
{
"tools": {
"exclude": ["web_search"]
}
}
```
**Note:** This setting requires a restart of Qwen Code to take effect. Once disabled, the `web_search` tool will not be available to the model, even if web search providers are configured.
## Usage Examples
### Basic search (using default provider)
```
web_search(query="latest advancements in AI")
```
### Search with specific provider
```
web_search(query="latest advancements in AI", provider="tavily")
```
### Real-world examples
```
web_search(query="weather in San Francisco today")
web_search(query="latest Node.js LTS version", provider="google")
web_search(query="best practices for React 19", provider="dashscope")
```
## Provider Details
### DashScope (Official)
- **Cost:** Free
- **Authentication:** Automatically available when using Qwen OAuth authentication
- **Configuration:** No API key required, automatically added to provider list for Qwen OAuth users
- **Quota:** 200 requests/minute, 2000 requests/day
- **Best for:** General queries, always available as fallback for Qwen OAuth users
- **Auto-registration:** If you're using Qwen OAuth, DashScope is automatically added to your provider list even if you don't configure it explicitly
### Tavily
- **Cost:** Requires API key (paid service with free tier)
- **Sign up:** https://tavily.com
- **Features:** High-quality results with AI-generated answers
- **Best for:** Research, comprehensive answers with citations
### Google Custom Search
- **Cost:** Free tier available (100 queries/day)
- **Setup:**
1. Enable Custom Search API in Google Cloud Console
2. Create a Custom Search Engine at https://programmablesearchengine.google.com
- **Features:** Google's search quality
- **Best for:** Specific, factual queries
## Important Notes
- **Response format:** Returns a concise answer with numbered source citations
- **Citations:** Source links are appended as a numbered list: [1], [2], etc.
- **Multiple providers:** If one provider fails, manually specify another using the `provider` parameter
- **DashScope availability:** Automatically available for Qwen OAuth users, no configuration needed
- **Default provider selection:** The system automatically selects a default provider based on availability:
1. Your explicit `default` configuration (highest priority)
2. CLI argument `--web-search-default`
3. First available provider by priority: Tavily > Google > DashScope
## Troubleshooting
**Tool not available?**
- **For Qwen OAuth users:** The tool is automatically registered with DashScope provider, no configuration needed
- **For other authentication types:** Ensure at least one provider (Tavily or Google) is configured
- For Tavily/Google: Verify your API keys are correct
**Provider-specific errors?**
- Use the `provider` parameter to try a different search provider
- Check your API quotas and rate limits
- Verify API keys are properly set in configuration
**Need help?**
- Check your configuration: Run `qwen` and use the settings dialog
- View your current settings in `~/.qwen-code/settings.json` (macOS/Linux) or `%USERPROFILE%\.qwen-code\settings.json` (Windows)

View File

@@ -9,14 +9,53 @@ import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
describe('web_search', () => {
it('should be able to search the web', async () => {
// Skip if Tavily key is not configured
if (!process.env['TAVILY_API_KEY']) {
console.warn('Skipping web search test: TAVILY_API_KEY not set');
// Check if any web search provider is available
const hasTavilyKey = !!process.env['TAVILY_API_KEY'];
const hasGoogleKey =
!!process.env['GOOGLE_API_KEY'] &&
!!process.env['GOOGLE_SEARCH_ENGINE_ID'];
// Skip if no provider is configured
// Note: DashScope provider is automatically available for Qwen OAuth users,
// but we can't easily detect that in tests without actual OAuth credentials
if (!hasTavilyKey && !hasGoogleKey) {
console.warn(
'Skipping web search test: No web search provider configured. ' +
'Set TAVILY_API_KEY or GOOGLE_API_KEY+GOOGLE_SEARCH_ENGINE_ID environment variables.',
);
return;
}
const rig = new TestRig();
await rig.setup('should be able to search the web');
// Configure web search in settings if provider keys are available
const webSearchSettings: Record<string, unknown> = {};
const providers: Array<{
type: string;
apiKey?: string;
searchEngineId?: string;
}> = [];
if (hasTavilyKey) {
providers.push({ type: 'tavily', apiKey: process.env['TAVILY_API_KEY'] });
}
if (hasGoogleKey) {
providers.push({
type: 'google',
apiKey: process.env['GOOGLE_API_KEY'],
searchEngineId: process.env['GOOGLE_SEARCH_ENGINE_ID'],
});
}
if (providers.length > 0) {
webSearchSettings.webSearch = {
provider: providers,
default: providers[0]?.type,
};
}
await rig.setup('should be able to search the web', {
settings: webSearchSettings,
});
let result;
try {

55
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.1.2",
"version": "0.1.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
"version": "0.1.2",
"version": "0.1.5",
"workspaces": [
"packages/*"
],
@@ -555,6 +555,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -578,6 +579,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2118,6 +2120,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -3279,6 +3282,7 @@
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -3717,6 +3721,7 @@
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -3727,6 +3732,7 @@
"integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.0.0"
}
@@ -3932,6 +3938,7 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
@@ -4700,6 +4707,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5054,8 +5062,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/array-includes": {
"version": "3.1.9",
@@ -6220,7 +6227,6 @@
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"safe-buffer": "5.2.1"
},
@@ -7254,6 +7260,7 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -7723,7 +7730,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -7785,7 +7791,6 @@
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -7795,7 +7800,6 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"peer": true,
"dependencies": {
"ms": "2.0.0"
}
@@ -7805,7 +7809,6 @@
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -7972,7 +7975,6 @@
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
@@ -7991,7 +7993,6 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"peer": true,
"dependencies": {
"ms": "2.0.0"
}
@@ -8000,15 +8001,13 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/finalhandler/node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -9047,6 +9046,7 @@
"resolved": "https://registry.npmjs.org/ink/-/ink-6.2.3.tgz",
"integrity": "sha512-fQkfEJjKbLXIcVWEE3MvpYSnwtbbmRsmeNDNz1pIuOFlwE+UF2gsy228J36OXKZGWJWZJKUigphBSqCNMcARtg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.0",
"ansi-escapes": "^7.0.0",
@@ -10950,7 +10950,6 @@
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -12158,8 +12157,7 @@
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/path-type": {
"version": "3.0.0",
@@ -12663,6 +12661,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -12673,6 +12672,7 @@
"integrity": "sha512-cq/o30z9W2Wb4rzBefjv5fBalHU0rJGZCHAkf/RHSBWSSYwh8PlQTqqOJmgIIbBtpj27T6FIPXeomIjZtCNVqA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -12706,6 +12706,7 @@
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@@ -14515,6 +14516,7 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -14688,7 +14690,8 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
"license": "0BSD",
"peer": true
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -14696,6 +14699,7 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -14880,6 +14884,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -15149,7 +15154,6 @@
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.4.0"
}
@@ -15205,6 +15209,7 @@
"integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.6",
@@ -15318,6 +15323,7 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -15331,6 +15337,7 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -16009,6 +16016,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -16024,7 +16032,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
"version": "0.1.2",
"version": "0.1.5",
"dependencies": {
"@google/genai": "1.16.0",
"@iarna/toml": "^2.2.5",
@@ -16139,7 +16147,7 @@
},
"packages/core": {
"name": "@qwen-code/qwen-code-core",
"version": "0.1.2",
"version": "0.1.5",
"hasInstallScript": true,
"dependencies": {
"@google/genai": "1.16.0",
@@ -16269,6 +16277,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16278,7 +16287,7 @@
},
"packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.1.2",
"version": "0.1.5",
"dev": true,
"license": "Apache-2.0",
"devDependencies": {
@@ -16290,7 +16299,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
"version": "0.1.2",
"version": "0.1.5",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.15.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.1.2",
"version": "0.1.5",
"engines": {
"node": ">=20.0.0"
},
@@ -13,7 +13,7 @@
"url": "git+https://github.com/QwenLM/qwen-code.git"
},
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.1.2"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.1.5"
},
"scripts": {
"start": "cross-env node scripts/start.js",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.1.2",
"version": "0.1.5",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -25,7 +25,7 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.1.2"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.1.5"
},
"dependencies": {
"@google/genai": "1.16.0",

View File

@@ -42,6 +42,7 @@ import { mcpCommand } from '../commands/mcp.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import type { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
import { buildWebSearchConfig } from './webSearch.js';
// Simple console logger for now - replace with actual logger if available
const logger = {
@@ -113,9 +114,13 @@ export interface CliArgs {
openaiLogging: boolean | undefined;
openaiApiKey: string | undefined;
openaiBaseUrl: string | undefined;
openaiLoggingDir: string | undefined;
proxy: string | undefined;
includeDirectories: string[] | undefined;
tavilyApiKey: string | undefined;
googleApiKey: string | undefined;
googleSearchEngineId: string | undefined;
webSearchDefault: string | undefined;
screenReader: boolean | undefined;
vlmSwitchMode: string | undefined;
useSmartEdit: boolean | undefined;
@@ -313,6 +318,11 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
description:
'Enable logging of OpenAI API calls for debugging and analysis',
})
.option('openai-logging-dir', {
type: 'string',
description:
'Custom directory path for OpenAI API logs. Overrides settings files.',
})
.option('openai-api-key', {
type: 'string',
description: 'OpenAI API key to use for authentication',
@@ -323,7 +333,20 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
})
.option('tavily-api-key', {
type: 'string',
description: 'Tavily API key for web search functionality',
description: 'Tavily API key for web search',
})
.option('google-api-key', {
type: 'string',
description: 'Google Custom Search API key',
})
.option('google-search-engine-id', {
type: 'string',
description: 'Google Custom Search Engine ID',
})
.option('web-search-default', {
type: 'string',
description:
'Default web search provider (dashscope, tavily, google)',
})
.option('screen-reader', {
type: 'boolean',
@@ -747,12 +770,15 @@ export async function loadCliConfig(
(typeof argv.openaiLogging === 'undefined'
? settings.model?.enableOpenAILogging
: argv.openaiLogging) ?? false,
openAILoggingDir:
argv.openaiLoggingDir || settings.model?.openAILoggingDir,
},
cliVersion: await getCliVersion(),
tavilyApiKey:
argv.tavilyApiKey ||
settings.advanced?.tavilyApiKey ||
process.env['TAVILY_API_KEY'],
webSearch: buildWebSearchConfig(
argv,
settings,
settings.security?.auth?.selectedType,
),
summarizeToolOutput: settings.model?.summarizeToolOutput,
ideMode,
chatCompression: settings.model?.chatCompression,

View File

@@ -558,6 +558,16 @@ const SETTINGS_SCHEMA = {
description: 'Enable OpenAI logging.',
showInDialog: true,
},
openAILoggingDir: {
type: 'string',
label: 'OpenAI Logging Directory',
category: 'Model',
requiresRestart: false,
default: undefined as string | undefined,
description:
'Custom directory path for OpenAI API logs. If not specified, defaults to logs/openai in the current working directory.',
showInDialog: true,
},
generationConfig: {
type: 'object',
label: 'Generation Configuration',
@@ -1072,17 +1082,36 @@ const SETTINGS_SCHEMA = {
},
tavilyApiKey: {
type: 'string',
label: 'Tavily API Key',
label: 'Tavily API Key (Deprecated)',
category: 'Advanced',
requiresRestart: false,
default: undefined as string | undefined,
description:
'The API key for the Tavily API. Required to enable the web_search tool functionality.',
'⚠️ DEPRECATED: Please use webSearch.provider configuration instead. Legacy API key for the Tavily API.',
showInDialog: false,
},
},
},
webSearch: {
type: 'object',
label: 'Web Search',
category: 'Advanced',
requiresRestart: true,
default: undefined as
| {
provider: Array<{
type: 'tavily' | 'google' | 'dashscope';
apiKey?: string;
searchEngineId?: string;
}>;
default: string;
}
| undefined,
description: 'Configuration for web search providers.',
showInDialog: false,
},
experimental: {
type: 'object',
label: 'Experimental',

View File

@@ -0,0 +1,121 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { AuthType } from '@qwen-code/qwen-code-core';
import type { WebSearchProviderConfig } from '@qwen-code/qwen-code-core';
import type { Settings } from './settings.js';
/**
* CLI arguments related to web search configuration
*/
export interface WebSearchCliArgs {
tavilyApiKey?: string;
googleApiKey?: string;
googleSearchEngineId?: string;
webSearchDefault?: string;
}
/**
* Web search configuration structure
*/
export interface WebSearchConfig {
provider: WebSearchProviderConfig[];
default: string;
}
/**
* Build webSearch configuration from multiple sources with priority:
* 1. settings.json (new format) - highest priority
* 2. Command line args + environment variables
* 3. Legacy tavilyApiKey (backward compatibility)
*
* @param argv - Command line arguments
* @param settings - User settings from settings.json
* @param authType - Authentication type (e.g., 'qwen-oauth')
* @returns WebSearch configuration or undefined if no providers available
*/
export function buildWebSearchConfig(
argv: WebSearchCliArgs,
settings: Settings,
authType?: string,
): WebSearchConfig | undefined {
const isQwenOAuth = authType === AuthType.QWEN_OAUTH;
// Step 1: Collect providers from settings or command line/env
let providers: WebSearchProviderConfig[] = [];
let userDefault: string | undefined;
if (settings.webSearch) {
// Use providers from settings.json
providers = [...settings.webSearch.provider];
userDefault = settings.webSearch.default;
} else {
// Build providers from command line args and environment variables
const tavilyKey =
argv.tavilyApiKey ||
settings.advanced?.tavilyApiKey ||
process.env['TAVILY_API_KEY'];
if (tavilyKey) {
providers.push({
type: 'tavily',
apiKey: tavilyKey,
} as WebSearchProviderConfig);
}
const googleKey = argv.googleApiKey || process.env['GOOGLE_API_KEY'];
const googleEngineId =
argv.googleSearchEngineId || process.env['GOOGLE_SEARCH_ENGINE_ID'];
if (googleKey && googleEngineId) {
providers.push({
type: 'google',
apiKey: googleKey,
searchEngineId: googleEngineId,
} as WebSearchProviderConfig);
}
}
// Step 2: Ensure dashscope is available for qwen-oauth users
if (isQwenOAuth) {
const hasDashscope = providers.some((p) => p.type === 'dashscope');
if (!hasDashscope) {
providers.push({ type: 'dashscope' } as WebSearchProviderConfig);
}
}
// Step 3: If no providers available, return undefined
if (providers.length === 0) {
return undefined;
}
// Step 4: Determine default provider
// Priority: user explicit config > CLI arg > first available provider (tavily > google > dashscope)
const providerPriority: Array<'tavily' | 'google' | 'dashscope'> = [
'tavily',
'google',
'dashscope',
];
// Determine default provider based on availability
let defaultProvider = userDefault || argv.webSearchDefault;
if (!defaultProvider) {
// Find first available provider by priority order
for (const providerType of providerPriority) {
if (providers.some((p) => p.type === providerType)) {
defaultProvider = providerType;
break;
}
}
// Fallback to first available provider if none found in priority list
if (!defaultProvider) {
defaultProvider = providers[0]?.type || 'dashscope';
}
}
return {
provider: providers,
default: defaultProvider,
};
}

View File

@@ -327,9 +327,13 @@ describe('gemini.tsx main function kitty protocol', () => {
openaiLogging: undefined,
openaiApiKey: undefined,
openaiBaseUrl: undefined,
openaiLoggingDir: undefined,
proxy: undefined,
includeDirectories: undefined,
tavilyApiKey: undefined,
googleApiKey: undefined,
googleSearchEngineId: undefined,
webSearchDefault: undefined,
screenReader: undefined,
vlmSwitchMode: undefined,
useSmartEdit: undefined,

View File

@@ -1227,4 +1227,28 @@ describe('FileCommandLoader', () => {
expect(commands).toHaveLength(0);
});
});
describe('AbortError handling', () => {
it('should silently ignore AbortError when operation is cancelled', async () => {
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'test1.toml': 'prompt = "Prompt 1"',
'test2.toml': 'prompt = "Prompt 2"',
},
});
const loader = new FileCommandLoader(null);
const controller = new AbortController();
const signal = controller.signal;
// Start loading and immediately abort
const loadPromise = loader.loadCommands(signal);
controller.abort();
// Should not throw or print errors
const commands = await loadPromise;
expect(commands).toHaveLength(0);
});
});
});

View File

@@ -120,7 +120,11 @@ export class FileCommandLoader implements ICommandLoader {
// Add all commands without deduplication
allCommands.push(...commands);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
// Ignore ENOENT (directory doesn't exist) and AbortError (operation was cancelled)
const isEnoent = (error as NodeJS.ErrnoException).code === 'ENOENT';
const isAbortError =
error instanceof Error && error.name === 'AbortError';
if (!isEnoent && !isAbortError) {
console.error(
`[FileCommandLoader] Error loading commands from ${dirInfo.path}:`,
error,

View File

@@ -916,17 +916,9 @@ export const AppContainer = (props: AppContainerProps) => {
(result: IdeIntegrationNudgeResult) => {
if (result.userSelection === 'yes') {
handleSlashCommand('/ide install');
settings.setValue(
SettingScope.User,
'hasSeenIdeIntegrationNudge',
true,
);
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
} else if (result.userSelection === 'dismiss') {
settings.setValue(
SettingScope.User,
'hasSeenIdeIntegrationNudge',
true,
);
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
}
setIdePromptAnswered(true);
},

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-core",
"version": "0.1.2",
"version": "0.1.5",
"description": "Qwen Code Core",
"repository": {
"type": "git",

View File

@@ -57,7 +57,7 @@ import { TaskTool } from '../tools/task.js';
import { TodoWriteTool } from '../tools/todoWrite.js';
import { ToolRegistry } from '../tools/tool-registry.js';
import { WebFetchTool } from '../tools/web-fetch.js';
import { WebSearchTool } from '../tools/web-search.js';
import { WebSearchTool } from '../tools/web-search/index.js';
import { WriteFileTool } from '../tools/write-file.js';
// Other modules
@@ -262,7 +262,14 @@ export interface ConfigParameters {
cliVersion?: string;
loadMemoryFromIncludeDirectories?: boolean;
// Web search providers
tavilyApiKey?: string;
webSearch?: {
provider: Array<{
type: 'tavily' | 'google' | 'dashscope';
apiKey?: string;
searchEngineId?: string;
}>;
default: string;
};
chatCompression?: ChatCompressionSettings;
interactive?: boolean;
trustedFolder?: boolean;
@@ -351,7 +358,14 @@ export class Config {
private readonly cliVersion?: string;
private readonly experimentalZedIntegration: boolean = false;
private readonly loadMemoryFromIncludeDirectories: boolean = false;
private readonly tavilyApiKey?: string;
private readonly webSearch?: {
provider: Array<{
type: 'tavily' | 'google' | 'dashscope';
apiKey?: string;
searchEngineId?: string;
}>;
default: string;
};
private readonly chatCompression: ChatCompressionSettings | undefined;
private readonly interactive: boolean;
private readonly trustedFolder: boolean | undefined;
@@ -457,7 +471,7 @@ export class Config {
this.skipLoopDetection = params.skipLoopDetection ?? false;
// Web search
this.tavilyApiKey = params.tavilyApiKey;
this.webSearch = params.webSearch;
this.useRipgrep = params.useRipgrep ?? true;
this.useBuiltinRipgrep = params.useBuiltinRipgrep ?? true;
this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? false;
@@ -912,8 +926,8 @@ export class Config {
}
// Web search provider configuration
getTavilyApiKey(): string | undefined {
return this.tavilyApiKey;
getWebSearchConfig() {
return this.webSearch;
}
getIdeMode(): boolean {
@@ -1152,8 +1166,10 @@ export class Config {
registerCoreTool(TodoWriteTool, this);
registerCoreTool(ExitPlanModeTool, this);
registerCoreTool(WebFetchTool, this);
// Conditionally register web search tool only if Tavily API key is set
if (this.getTavilyApiKey()) {
// Conditionally register web search tool if web search provider is configured
// buildWebSearchConfig ensures qwen-oauth users get dashscope provider, so
// if tool is registered, config must exist
if (this.getWebSearchConfig()) {
registerCoreTool(WebSearchTool, this);
}

View File

@@ -21,6 +21,9 @@ vi.mock('../../telemetry/loggers.js', () => ({
}));
vi.mock('../../utils/openaiLogger.js', () => ({
OpenAILogger: vi.fn().mockImplementation(() => ({
logInteraction: vi.fn(),
})),
openaiLogger: {
logInteraction: vi.fn(),
},

View File

@@ -58,6 +58,7 @@ export type ContentGeneratorConfig = {
vertexai?: boolean;
authType?: AuthType | undefined;
enableOpenAILogging?: boolean;
openAILoggingDir?: string;
// Timeout configuration in milliseconds
timeout?: number;
// Maximum retries for failed requests

View File

@@ -32,6 +32,7 @@ export class OpenAIContentGenerator implements ContentGenerator {
telemetryService: new DefaultTelemetryService(
cliConfig,
contentGeneratorConfig.enableOpenAILogging,
contentGeneratorConfig.openAILoggingDir,
),
errorHandler: new EnhancedErrorHandler(
(error: unknown, request: GenerateContentParameters) =>

View File

@@ -7,7 +7,7 @@
import type { Config } from '../../config/config.js';
import { logApiError, logApiResponse } from '../../telemetry/loggers.js';
import { ApiErrorEvent, ApiResponseEvent } from '../../telemetry/types.js';
import { openaiLogger } from '../../utils/openaiLogger.js';
import { OpenAILogger } from '../../utils/openaiLogger.js';
import type { GenerateContentResponse } from '@google/genai';
import type OpenAI from 'openai';
@@ -43,10 +43,17 @@ export interface TelemetryService {
}
export class DefaultTelemetryService implements TelemetryService {
private logger: OpenAILogger;
constructor(
private config: Config,
private enableOpenAILogging: boolean = false,
) {}
openAILoggingDir?: string,
) {
// Always create a new logger instance to ensure correct working directory
// If no custom directory is provided, undefined will use the default path
this.logger = new OpenAILogger(openAILoggingDir);
}
async logSuccess(
context: RequestContext,
@@ -68,7 +75,7 @@ export class DefaultTelemetryService implements TelemetryService {
// Log interaction if enabled
if (this.enableOpenAILogging && openaiRequest && openaiResponse) {
await openaiLogger.logInteraction(openaiRequest, openaiResponse);
await this.logger.logInteraction(openaiRequest, openaiResponse);
}
}
@@ -97,7 +104,7 @@ export class DefaultTelemetryService implements TelemetryService {
// Log error interaction if enabled
if (this.enableOpenAILogging && openaiRequest) {
await openaiLogger.logInteraction(
await this.logger.logInteraction(
openaiRequest,
undefined,
error as Error,
@@ -137,7 +144,7 @@ export class DefaultTelemetryService implements TelemetryService {
openaiChunks.length > 0
) {
const combinedResponse = this.combineOpenAIChunksForLogging(openaiChunks);
await openaiLogger.logInteraction(openaiRequest, combinedResponse);
await this.logger.logInteraction(openaiRequest, combinedResponse);
}
}

View File

@@ -64,6 +64,12 @@ describe('normalize', () => {
expect(normalize('qwen-vl-max-latest')).toBe('qwen-vl-max-latest');
});
it('should preserve date suffixes for Kimi K2 models', () => {
expect(normalize('kimi-k2-0905-preview')).toBe('kimi-k2-0905');
expect(normalize('kimi-k2-0711-preview')).toBe('kimi-k2-0711');
expect(normalize('kimi-k2-turbo-preview')).toBe('kimi-k2-turbo');
});
it('should remove date like suffixes', () => {
expect(normalize('deepseek-r1-0528')).toBe('deepseek-r1');
});
@@ -213,7 +219,7 @@ describe('tokenLimit', () => {
});
});
describe('Other models', () => {
describe('DeepSeek', () => {
it('should return the correct limit for deepseek-r1', () => {
expect(tokenLimit('deepseek-r1')).toBe(131072);
});
@@ -226,9 +232,27 @@ describe('tokenLimit', () => {
it('should return the correct limit for deepseek-v3.2', () => {
expect(tokenLimit('deepseek-v3.2-exp')).toBe(131072);
});
it('should return the correct limit for kimi-k2-instruct', () => {
expect(tokenLimit('kimi-k2-instruct')).toBe(131072);
});
describe('Moonshot Kimi', () => {
it('should return the correct limit for kimi-k2-0905-preview', () => {
expect(tokenLimit('kimi-k2-0905-preview')).toBe(262144); // 256K
expect(tokenLimit('kimi-k2-0905')).toBe(262144);
});
it('should return the correct limit for kimi-k2-turbo-preview', () => {
expect(tokenLimit('kimi-k2-turbo-preview')).toBe(262144); // 256K
expect(tokenLimit('kimi-k2-turbo')).toBe(262144);
});
it('should return the correct limit for kimi-k2-0711-preview', () => {
expect(tokenLimit('kimi-k2-0711-preview')).toBe(131072); // 128K
expect(tokenLimit('kimi-k2-0711')).toBe(131072);
});
it('should return the correct limit for kimi-k2-instruct', () => {
expect(tokenLimit('kimi-k2-instruct')).toBe(131072); // 128K
});
});
describe('Other models', () => {
it('should return the correct limit for gpt-oss', () => {
expect(tokenLimit('gpt-oss')).toBe(131072);
});

View File

@@ -47,8 +47,13 @@ export function normalize(model: string): string {
// remove trailing build / date / revision suffixes:
// - dates (e.g., -20250219), -v1, version numbers, 'latest', 'preview' etc.
s = s.replace(/-preview/g, '');
// Special handling for Qwen model names that include "-latest" as part of the model name
if (!s.match(/^qwen-(?:plus|flash|vl-max)-latest$/)) {
// Special handling for model names that include date/version as part of the model identifier
// - Qwen models: qwen-plus-latest, qwen-flash-latest, qwen-vl-max-latest
// - Kimi models: kimi-k2-0905, kimi-k2-0711, etc. (keep date for version distinction)
if (
!s.match(/^qwen-(?:plus|flash|vl-max)-latest$/) &&
!s.match(/^kimi-k2-\d{4}$/)
) {
// Regex breakdown:
// -(?:...)$ - Non-capturing group for suffixes at the end of the string
// The following patterns are matched within the group:
@@ -165,9 +170,16 @@ const PATTERNS: Array<[RegExp, TokenCount]> = [
[/^deepseek-v3(?:\.\d+)?(?:-.*)?$/, LIMITS['128k']],
// -------------------
// GPT-OSS / Kimi / Llama & Mistral examples
// Moonshot / Kimi
// -------------------
[/^kimi-k2-0905$/, LIMITS['256k']], // Kimi-k2-0905-preview: 256K context
[/^kimi-k2-turbo.*$/, LIMITS['256k']], // Kimi-k2-turbo-preview: 256K context
[/^kimi-k2-0711$/, LIMITS['128k']], // Kimi-k2-0711-preview: 128K context
[/^kimi-k2-instruct.*$/, LIMITS['128k']], // Kimi-k2-instruct: 128K context
// -------------------
// GPT-OSS / Llama & Mistral examples
// -------------------
[/^kimi-k2-instruct.*$/, LIMITS['128k']],
[/^gpt-oss.*$/, LIMITS['128k']],
[/^llama-4-scout.*$/, LIMITS['10m']],
[/^mistral-large-2.*$/, LIMITS['128k']],

View File

@@ -113,7 +113,7 @@ describe('IdeClient', () => {
'utf8',
);
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
new URL('http://localhost:8080/mcp'),
new URL('http://127.0.0.1:8080/mcp'),
expect.any(Object),
);
expect(mockClient.connect).toHaveBeenCalledWith(mockHttpTransport);
@@ -181,7 +181,7 @@ describe('IdeClient', () => {
await ideClient.connect();
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
new URL('http://localhost:9090/mcp'),
new URL('http://127.0.0.1:9090/mcp'),
expect.any(Object),
);
expect(mockClient.connect).toHaveBeenCalledWith(mockHttpTransport);
@@ -230,7 +230,7 @@ describe('IdeClient', () => {
await ideClient.connect();
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
new URL('http://localhost:8080/mcp'),
new URL('http://127.0.0.1:8080/mcp'),
expect.any(Object),
);
expect(ideClient.getConnectionStatus().status).toBe(
@@ -665,7 +665,7 @@ describe('IdeClient', () => {
await ideClient.connect();
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
new URL('http://localhost:8080/mcp'),
new URL('http://127.0.0.1:8080/mcp'),
expect.objectContaining({
requestInit: {
headers: {

View File

@@ -667,10 +667,10 @@ export class IdeClient {
}
private createProxyAwareFetch() {
// ignore proxy for 'localhost' by deafult to allow connecting to the ide mcp server
// ignore proxy for '127.0.0.1' by deafult to allow connecting to the ide mcp server
const existingNoProxy = process.env['NO_PROXY'] || '';
const agent = new EnvHttpProxyAgent({
noProxy: [existingNoProxy, 'localhost'].filter(Boolean).join(','),
noProxy: [existingNoProxy, '127.0.0.1'].filter(Boolean).join(','),
});
const undiciPromise = import('undici');
return async (url: string | URL, init?: RequestInit): Promise<Response> => {
@@ -851,5 +851,5 @@ export class IdeClient {
function getIdeServerHost() {
const isInContainer =
fs.existsSync('/.dockerenv') || fs.existsSync('/run/.containerenv');
return isInContainer ? 'host.docker.internal' : 'localhost';
return isInContainer ? 'host.docker.internal' : '127.0.0.1';
}

View File

@@ -112,14 +112,19 @@ describe('ide-installer', () => {
platform: 'linux',
});
await installer.install();
// Note: The implementation uses process.platform, not the mocked platform
const isActuallyWindows = process.platform === 'win32';
const expectedCommand = isActuallyWindows ? '"code"' : 'code';
expect(child_process.spawnSync).toHaveBeenCalledWith(
'code',
expectedCommand,
[
'--install-extension',
'qwenlm.qwen-code-vscode-ide-companion',
'--force',
],
{ stdio: 'pipe' },
{ stdio: 'pipe', shell: isActuallyWindows },
);
});

View File

@@ -117,15 +117,16 @@ class VsCodeInstaller implements IdeInstaller {
};
}
const isWindows = process.platform === 'win32';
try {
const result = child_process.spawnSync(
commandPath,
isWindows ? `"${commandPath}"` : commandPath,
[
'--install-extension',
'qwenlm.qwen-code-vscode-ide-companion',
'--force',
],
{ stdio: 'pipe' },
{ stdio: 'pipe', shell: isWindows },
);
if (result.status !== 0) {

View File

@@ -98,7 +98,7 @@ export * from './tools/write-file.js';
export * from './tools/web-fetch.js';
export * from './tools/memoryTool.js';
export * from './tools/shell.js';
export * from './tools/web-search.js';
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';

View File

@@ -1,166 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { WebSearchTool, type WebSearchToolParams } from './web-search.js';
import type { Config } from '../config/config.js';
import { GeminiClient } from '../core/client.js';
// Mock GeminiClient and Config constructor
vi.mock('../core/client.js');
vi.mock('../config/config.js');
// Mock global fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe('WebSearchTool', () => {
const abortSignal = new AbortController().signal;
let mockGeminiClient: GeminiClient;
let tool: WebSearchTool;
beforeEach(() => {
vi.clearAllMocks();
const mockConfigInstance = {
getGeminiClient: () => mockGeminiClient,
getProxy: () => undefined,
getTavilyApiKey: () => 'test-api-key', // Add the missing method
} as unknown as Config;
mockGeminiClient = new GeminiClient(mockConfigInstance);
tool = new WebSearchTool(mockConfigInstance);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('build', () => {
it('should return an invocation for a valid query', () => {
const params: WebSearchToolParams = { query: 'test query' };
const invocation = tool.build(params);
expect(invocation).toBeDefined();
expect(invocation.params).toEqual(params);
});
it('should throw an error for an empty query', () => {
const params: WebSearchToolParams = { query: '' };
expect(() => tool.build(params)).toThrow(
"The 'query' parameter cannot be empty.",
);
});
it('should throw an error for a query with only whitespace', () => {
const params: WebSearchToolParams = { query: ' ' };
expect(() => tool.build(params)).toThrow(
"The 'query' parameter cannot be empty.",
);
});
});
describe('getDescription', () => {
it('should return a description of the search', () => {
const params: WebSearchToolParams = { query: 'test query' };
const invocation = tool.build(params);
expect(invocation.getDescription()).toBe(
'Searching the web for: "test query"',
);
});
});
describe('execute', () => {
it('should return search results for a successful query', async () => {
const params: WebSearchToolParams = { query: 'successful query' };
// Mock the fetch response
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
answer: 'Here are your results.',
results: [],
}),
} as Response);
const invocation = tool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.llmContent).toBe(
'Web search results for "successful query":\n\nHere are your results.',
);
expect(result.returnDisplay).toBe(
'Search results for "successful query" returned.',
);
expect(result.sources).toEqual([]);
});
it('should handle no search results found', async () => {
const params: WebSearchToolParams = { query: 'no results query' };
// Mock the fetch response
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
answer: '',
results: [],
}),
} as Response);
const invocation = tool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.llmContent).toBe(
'No search results or information found for query: "no results query"',
);
expect(result.returnDisplay).toBe('No information found.');
});
it('should handle API errors gracefully', async () => {
const params: WebSearchToolParams = { query: 'error query' };
// Mock the fetch to reject
mockFetch.mockRejectedValueOnce(new Error('API Failure'));
const invocation = tool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.llmContent).toContain('Error:');
expect(result.llmContent).toContain('API Failure');
expect(result.returnDisplay).toBe('Error performing web search.');
});
it('should correctly format results with sources', async () => {
const params: WebSearchToolParams = { query: 'grounding query' };
// Mock the fetch response
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
answer: 'This is a test response.',
results: [
{ title: 'Example Site', url: 'https://example.com' },
{ title: 'Google', url: 'https://google.com' },
],
}),
} as Response);
const invocation = tool.build(params);
const result = await invocation.execute(abortSignal);
const expectedLlmContent = `Web search results for "grounding query":
This is a test response.
Sources:
[1] Example Site (https://example.com)
[2] Google (https://google.com)`;
expect(result.llmContent).toBe(expectedLlmContent);
expect(result.returnDisplay).toBe(
'Search results for "grounding query" returned.',
);
expect(result.sources).toHaveLength(2);
});
});
});

View File

@@ -1,218 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
type ToolInvocation,
type ToolResult,
type ToolCallConfirmationDetails,
type ToolInfoConfirmationDetails,
ToolConfirmationOutcome,
} from './tools.js';
import type { Config } from '../config/config.js';
import { ApprovalMode } from '../config/config.js';
import { getErrorMessage } from '../utils/errors.js';
interface TavilyResultItem {
title: string;
url: string;
content?: string;
score?: number;
published_date?: string;
}
interface TavilySearchResponse {
query: string;
answer?: string;
results: TavilyResultItem[];
}
/**
* Parameters for the WebSearchTool.
*/
export interface WebSearchToolParams {
/**
* The search query.
*/
query: string;
}
/**
* Extends ToolResult to include sources for web search.
*/
export interface WebSearchToolResult extends ToolResult {
sources?: Array<{ title: string; url: string }>;
}
class WebSearchToolInvocation extends BaseToolInvocation<
WebSearchToolParams,
WebSearchToolResult
> {
constructor(
private readonly config: Config,
params: WebSearchToolParams,
) {
super(params);
}
override getDescription(): string {
return `Searching the web for: "${this.params.query}"`;
}
override async shouldConfirmExecute(
_abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {
if (this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT) {
return false;
}
const confirmationDetails: ToolInfoConfirmationDetails = {
type: 'info',
title: 'Confirm Web Search',
prompt: `Search the web for: "${this.params.query}"`,
onConfirm: async (outcome: ToolConfirmationOutcome) => {
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
this.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
}
},
};
return confirmationDetails;
}
async execute(signal: AbortSignal): Promise<WebSearchToolResult> {
const apiKey = this.config.getTavilyApiKey();
if (!apiKey) {
return {
llmContent:
'Web search is disabled because TAVILY_API_KEY is not configured. Please set it in your settings.json, .env file, or via --tavily-api-key command line argument to enable web search.',
returnDisplay:
'Web search disabled. Configure TAVILY_API_KEY to enable Tavily search.',
};
}
try {
const response = await fetch('https://api.tavily.com/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
api_key: apiKey,
query: this.params.query,
search_depth: 'advanced',
max_results: 5,
include_answer: true,
}),
signal,
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(
`Tavily API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`,
);
}
const data = (await response.json()) as TavilySearchResponse;
const sources = (data.results || []).map((r) => ({
title: r.title,
url: r.url,
}));
const sourceListFormatted = sources.map(
(s, i) => `[${i + 1}] ${s.title || 'Untitled'} (${s.url})`,
);
let content = data.answer?.trim() || '';
if (!content) {
// Fallback: build a concise summary from top results
content = sources
.slice(0, 3)
.map((s, i) => `${i + 1}. ${s.title} - ${s.url}`)
.join('\n');
}
if (sourceListFormatted.length > 0) {
content += `\n\nSources:\n${sourceListFormatted.join('\n')}`;
}
if (!content.trim()) {
return {
llmContent: `No search results or information found for query: "${this.params.query}"`,
returnDisplay: 'No information found.',
};
}
return {
llmContent: `Web search results for "${this.params.query}":\n\n${content}`,
returnDisplay: `Search results for "${this.params.query}" returned.`,
sources,
};
} catch (error: unknown) {
const errorMessage = `Error during web search for query "${this.params.query}": ${getErrorMessage(
error,
)}`;
console.error(errorMessage, error);
return {
llmContent: `Error: ${errorMessage}`,
returnDisplay: `Error performing web search.`,
};
}
}
}
/**
* A tool to perform web searches using Tavily Search via the Tavily API.
*/
export class WebSearchTool extends BaseDeclarativeTool<
WebSearchToolParams,
WebSearchToolResult
> {
static readonly Name: string = 'web_search';
constructor(private readonly config: Config) {
super(
WebSearchTool.Name,
'WebSearch',
'Performs a web search using the Tavily API and returns a concise answer with sources. Requires the TAVILY_API_KEY environment variable.',
Kind.Search,
{
type: 'object',
properties: {
query: {
type: 'string',
description: 'The search query to find information on the web.',
},
},
required: ['query'],
},
);
}
/**
* Validates the parameters for the WebSearchTool.
* @param params The parameters to validate
* @returns An error message string if validation fails, null if valid
*/
protected override validateToolParamValues(
params: WebSearchToolParams,
): string | null {
if (!params.query || params.query.trim() === '') {
return "The 'query' parameter cannot be empty.";
}
return null;
}
protected createInvocation(
params: WebSearchToolParams,
): ToolInvocation<WebSearchToolParams, WebSearchToolResult> {
return new WebSearchToolInvocation(this.config, params);
}
}

View File

@@ -0,0 +1,58 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type { WebSearchProvider, WebSearchResult } from './types.js';
/**
* Base implementation for web search providers.
* Provides common functionality for error handling.
*/
export abstract class BaseWebSearchProvider implements WebSearchProvider {
abstract readonly name: string;
/**
* Check if the provider is available (has required configuration).
*/
abstract isAvailable(): boolean;
/**
* Perform the actual search implementation.
* @param query The search query
* @param signal Abort signal for cancellation
* @returns Promise resolving to search results
*/
protected abstract performSearch(
query: string,
signal: AbortSignal,
): Promise<WebSearchResult>;
/**
* Execute a web search with error handling.
* @param query The search query
* @param signal Abort signal for cancellation
* @returns Promise resolving to search results
*/
async search(query: string, signal: AbortSignal): Promise<WebSearchResult> {
if (!this.isAvailable()) {
throw new Error(
`[${this.name}] Provider is not available. Please check your configuration.`,
);
}
try {
return await this.performSearch(query, signal);
} catch (error: unknown) {
if (
error instanceof Error &&
error.message.startsWith(`[${this.name}]`)
) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
throw new Error(`[${this.name}] Search failed: ${message}`);
}
}
}

View File

@@ -0,0 +1,312 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { WebSearchTool } from './index.js';
import type { Config } from '../../config/config.js';
import type { WebSearchConfig } from './types.js';
import { ApprovalMode } from '../../config/config.js';
describe('WebSearchTool', () => {
let mockConfig: Config;
beforeEach(() => {
vi.resetAllMocks();
mockConfig = {
getApprovalMode: vi.fn(() => ApprovalMode.AUTO_EDIT),
setApprovalMode: vi.fn(),
getWebSearchConfig: vi.fn(),
} as unknown as Config;
});
describe('formatSearchResults', () => {
it('should use answer when available and append sources', async () => {
const webSearchConfig: WebSearchConfig = {
provider: [
{
type: 'tavily',
apiKey: 'test-key',
},
],
default: 'tavily',
};
(
mockConfig.getWebSearchConfig as ReturnType<typeof vi.fn>
).mockReturnValue(webSearchConfig);
// Mock fetch to return search results with answer
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
query: 'test query',
answer: 'This is a concise answer from the search provider.',
results: [
{
title: 'Result 1',
url: 'https://example.com/1',
content: 'Content 1',
},
{
title: 'Result 2',
url: 'https://example.com/2',
content: 'Content 2',
},
],
}),
});
const tool = new WebSearchTool(mockConfig);
const invocation = tool.build({ query: 'test query' });
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toContain(
'This is a concise answer from the search provider.',
);
expect(result.llmContent).toContain('Sources:');
expect(result.llmContent).toContain(
'[1] Result 1 (https://example.com/1)',
);
expect(result.llmContent).toContain(
'[2] Result 2 (https://example.com/2)',
);
});
it('should build informative summary when answer is not available', async () => {
const webSearchConfig: WebSearchConfig = {
provider: [
{
type: 'google',
apiKey: 'test-key',
searchEngineId: 'test-engine',
},
],
default: 'google',
};
(
mockConfig.getWebSearchConfig as ReturnType<typeof vi.fn>
).mockReturnValue(webSearchConfig);
// Mock fetch to return search results without answer
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
items: [
{
title: 'Google Result 1',
link: 'https://example.com/1',
snippet: 'This is a helpful snippet from the first result.',
},
{
title: 'Google Result 2',
link: 'https://example.com/2',
snippet: 'This is a helpful snippet from the second result.',
},
],
}),
});
const tool = new WebSearchTool(mockConfig);
const invocation = tool.build({ query: 'test query' });
const result = await invocation.execute(new AbortController().signal);
// Should contain formatted results with title, snippet, and source
expect(result.llmContent).toContain('1. **Google Result 1**');
expect(result.llmContent).toContain(
'This is a helpful snippet from the first result.',
);
expect(result.llmContent).toContain('Source: https://example.com/1');
expect(result.llmContent).toContain('2. **Google Result 2**');
expect(result.llmContent).toContain(
'This is a helpful snippet from the second result.',
);
expect(result.llmContent).toContain('Source: https://example.com/2');
// Should include web_fetch hint
expect(result.llmContent).toContain('web_fetch tool');
});
it('should include optional fields when available', async () => {
const webSearchConfig: WebSearchConfig = {
provider: [
{
type: 'tavily',
apiKey: 'test-key',
},
],
default: 'tavily',
};
(
mockConfig.getWebSearchConfig as ReturnType<typeof vi.fn>
).mockReturnValue(webSearchConfig);
// Mock fetch to return results with score and publishedDate
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
query: 'test query',
results: [
{
title: 'Result with metadata',
url: 'https://example.com',
content: 'Content with metadata',
score: 0.95,
published_date: '2024-01-15',
},
],
}),
});
const tool = new WebSearchTool(mockConfig);
const invocation = tool.build({ query: 'test query' });
const result = await invocation.execute(new AbortController().signal);
// Should include relevance score
expect(result.llmContent).toContain('Relevance: 95%');
// Should include published date
expect(result.llmContent).toContain('Published: 2024-01-15');
});
it('should handle empty results gracefully', async () => {
const webSearchConfig: WebSearchConfig = {
provider: [
{
type: 'google',
apiKey: 'test-key',
searchEngineId: 'test-engine',
},
],
default: 'google',
};
(
mockConfig.getWebSearchConfig as ReturnType<typeof vi.fn>
).mockReturnValue(webSearchConfig);
// Mock fetch to return empty results
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
items: [],
}),
});
const tool = new WebSearchTool(mockConfig);
const invocation = tool.build({ query: 'test query' });
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toContain('No search results found');
});
it('should limit to top 5 results in fallback mode', async () => {
const webSearchConfig: WebSearchConfig = {
provider: [
{
type: 'google',
apiKey: 'test-key',
searchEngineId: 'test-engine',
},
],
default: 'google',
};
(
mockConfig.getWebSearchConfig as ReturnType<typeof vi.fn>
).mockReturnValue(webSearchConfig);
// Mock fetch to return 10 results
const items = Array.from({ length: 10 }, (_, i) => ({
title: `Result ${i + 1}`,
link: `https://example.com/${i + 1}`,
snippet: `Snippet ${i + 1}`,
}));
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ items }),
});
const tool = new WebSearchTool(mockConfig);
const invocation = tool.build({ query: 'test query' });
const result = await invocation.execute(new AbortController().signal);
// Should only contain first 5 results
expect(result.llmContent).toContain('1. **Result 1**');
expect(result.llmContent).toContain('5. **Result 5**');
expect(result.llmContent).not.toContain('6. **Result 6**');
expect(result.llmContent).not.toContain('10. **Result 10**');
});
});
describe('validation', () => {
it('should throw validation error when query is empty', () => {
const tool = new WebSearchTool(mockConfig);
expect(() => tool.build({ query: '' })).toThrow(
"The 'query' parameter cannot be empty",
);
});
it('should throw validation error when provider is empty string', () => {
const tool = new WebSearchTool(mockConfig);
expect(() => tool.build({ query: 'test', provider: '' })).toThrow(
"The 'provider' parameter cannot be empty",
);
});
});
describe('configuration', () => {
it('should return error when web search is not configured', async () => {
(
mockConfig.getWebSearchConfig as ReturnType<typeof vi.fn>
).mockReturnValue(null);
const tool = new WebSearchTool(mockConfig);
const invocation = tool.build({ query: 'test query' });
const result = await invocation.execute(new AbortController().signal);
expect(result.error?.message).toContain('Web search is disabled');
expect(result.llmContent).toContain('Web search is disabled');
});
it('should return descriptive message in getDescription when web search is not configured', () => {
(
mockConfig.getWebSearchConfig as ReturnType<typeof vi.fn>
).mockReturnValue(null);
const tool = new WebSearchTool(mockConfig);
const invocation = tool.build({ query: 'test query' });
const description = invocation.getDescription();
expect(description).toBe(
' (Web search is disabled - configure a provider in settings.json)',
);
});
it('should return provider name in getDescription when web search is configured', () => {
const webSearchConfig: WebSearchConfig = {
provider: [
{
type: 'tavily',
apiKey: 'test-key',
},
],
default: 'tavily',
};
(
mockConfig.getWebSearchConfig as ReturnType<typeof vi.fn>
).mockReturnValue(webSearchConfig);
const tool = new WebSearchTool(mockConfig);
const invocation = tool.build({ query: 'test query' });
const description = invocation.getDescription();
expect(description).toBe(' (Searching the web via tavily)');
});
});
});

View File

@@ -0,0 +1,336 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import {
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
type ToolInvocation,
type ToolCallConfirmationDetails,
type ToolInfoConfirmationDetails,
ToolConfirmationOutcome,
} from '../tools.js';
import { ToolErrorType } from '../tool-error.js';
import type { Config } from '../../config/config.js';
import { ApprovalMode } from '../../config/config.js';
import { getErrorMessage } from '../../utils/errors.js';
import { buildContentWithSources } from './utils.js';
import { TavilyProvider } from './providers/tavily-provider.js';
import { GoogleProvider } from './providers/google-provider.js';
import { DashScopeProvider } from './providers/dashscope-provider.js';
import type {
WebSearchToolParams,
WebSearchToolResult,
WebSearchProvider,
WebSearchResultItem,
WebSearchProviderConfig,
DashScopeProviderConfig,
} from './types.js';
class WebSearchToolInvocation extends BaseToolInvocation<
WebSearchToolParams,
WebSearchToolResult
> {
constructor(
private readonly config: Config,
params: WebSearchToolParams,
) {
super(params);
}
override getDescription(): string {
const webSearchConfig = this.config.getWebSearchConfig();
if (!webSearchConfig) {
return ' (Web search is disabled - configure a provider in settings.json)';
}
const provider = this.params.provider || webSearchConfig.default;
return ` (Searching the web via ${provider})`;
}
override async shouldConfirmExecute(
_abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {
if (this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT) {
return false;
}
const confirmationDetails: ToolInfoConfirmationDetails = {
type: 'info',
title: 'Confirm Web Search',
prompt: `Search the web for: "${this.params.query}"`,
onConfirm: async (outcome: ToolConfirmationOutcome) => {
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
this.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
}
},
};
return confirmationDetails;
}
/**
* Create a provider instance from configuration.
*/
private createProvider(config: WebSearchProviderConfig): WebSearchProvider {
switch (config.type) {
case 'tavily':
return new TavilyProvider(config);
case 'google':
return new GoogleProvider(config);
case 'dashscope': {
// Pass auth type to DashScope provider for availability check
const authType = this.config.getAuthType();
const dashscopeConfig: DashScopeProviderConfig = {
...config,
authType: authType as string | undefined,
};
return new DashScopeProvider(dashscopeConfig);
}
default:
throw new Error('Unknown provider type');
}
}
/**
* Create all configured providers.
*/
private createProviders(
configs: WebSearchProviderConfig[],
): Map<string, WebSearchProvider> {
const providers = new Map<string, WebSearchProvider>();
for (const config of configs) {
try {
const provider = this.createProvider(config);
if (provider.isAvailable()) {
providers.set(config.type, provider);
}
} catch (error) {
console.warn(`Failed to create ${config.type} provider:`, error);
}
}
return providers;
}
/**
* Select the appropriate provider based on configuration and parameters.
* Throws error if provider not found.
*/
private selectProvider(
providers: Map<string, WebSearchProvider>,
requestedProvider?: string,
defaultProvider?: string,
): WebSearchProvider {
// Use requested provider if specified
if (requestedProvider) {
const provider = providers.get(requestedProvider);
if (!provider) {
const available = Array.from(providers.keys()).join(', ');
throw new Error(
`The specified provider "${requestedProvider}" is not available. Available: ${available}`,
);
}
return provider;
}
// Use default provider if specified and available
if (defaultProvider && providers.has(defaultProvider)) {
return providers.get(defaultProvider)!;
}
// Fallback to first available provider
const firstProvider = providers.values().next().value;
if (!firstProvider) {
throw new Error('No web search providers are available.');
}
return firstProvider;
}
/**
* Format search results into a content string.
*/
private formatSearchResults(searchResult: {
answer?: string;
results: WebSearchResultItem[];
}): {
content: string;
sources: Array<{ title: string; url: string }>;
} {
const sources = searchResult.results.map((r) => ({
title: r.title,
url: r.url,
}));
let content = searchResult.answer?.trim() || '';
if (!content) {
// Fallback: Build an informative summary with title + snippet + source link
// This provides enough context for the LLM while keeping token usage efficient
content = searchResult.results
.slice(0, 5) // Top 5 results
.map((r, i) => {
const parts = [`${i + 1}. **${r.title}**`];
// Include snippet/content if available
if (r.content?.trim()) {
parts.push(` ${r.content.trim()}`);
}
// Always include the source URL
parts.push(` Source: ${r.url}`);
// Optionally include relevance score if available
if (r.score !== undefined) {
parts.push(` Relevance: ${(r.score * 100).toFixed(0)}%`);
}
// Optionally include publish date if available
if (r.publishedDate) {
parts.push(` Published: ${r.publishedDate}`);
}
return parts.join('\n');
})
.join('\n\n');
// Add a note about using web_fetch for detailed content
if (content) {
content +=
'\n\n*Note: For detailed content from any source above, use the web_fetch tool with the URL.*';
}
} else {
// When answer is available, append sources section
content = buildContentWithSources(content, sources);
}
return { content, sources };
}
async execute(signal: AbortSignal): Promise<WebSearchToolResult> {
// Check if web search is configured
const webSearchConfig = this.config.getWebSearchConfig();
if (!webSearchConfig) {
return {
llmContent:
'Web search is disabled. Please configure a web search provider in your settings.',
returnDisplay: 'Web search is disabled.',
error: {
message: 'Web search is disabled',
type: ToolErrorType.EXECUTION_FAILED,
},
};
}
try {
// Create and select provider
const providers = this.createProviders(webSearchConfig.provider);
const provider = this.selectProvider(
providers,
this.params.provider,
webSearchConfig.default,
);
// Perform search
const searchResult = await provider.search(this.params.query, signal);
const { content, sources } = this.formatSearchResults(searchResult);
// Guard: Check if we got results
if (!content.trim()) {
return {
llmContent: `No search results found for query: "${this.params.query}" (via ${provider.name})`,
returnDisplay: `No information found for "${this.params.query}".`,
};
}
// Success result
return {
llmContent: `Web search results for "${this.params.query}" (via ${provider.name}):\n\n${content}`,
returnDisplay: `Search results for "${this.params.query}".`,
sources,
};
} catch (error: unknown) {
const errorMessage = `Error during web search: ${getErrorMessage(error)}`;
console.error(errorMessage, error);
return {
llmContent: errorMessage,
returnDisplay: 'Error performing web search.',
error: {
message: errorMessage,
type: ToolErrorType.EXECUTION_FAILED,
},
};
}
}
}
/**
* A tool to perform web searches using configurable providers.
*/
export class WebSearchTool extends BaseDeclarativeTool<
WebSearchToolParams,
WebSearchToolResult
> {
static readonly Name: string = 'web_search';
constructor(private readonly config: Config) {
super(
WebSearchTool.Name,
'WebSearch',
'Allows searching the web and using results to inform responses. Provides up-to-date information for current events and recent data beyond the training data cutoff. Returns search results formatted with concise answers and source links. Use this tool when accessing information that may be outdated or beyond the knowledge cutoff.',
Kind.Search,
{
type: 'object',
properties: {
query: {
type: 'string',
description: 'The search query to find information on the web.',
},
provider: {
type: 'string',
description:
'Optional provider to use for the search (e.g., "tavily", "google", "dashscope"). IMPORTANT: Only specify this parameter if you explicitly know which provider to use. Otherwise, omit this parameter entirely and let the system automatically select the appropriate provider based on availability and configuration. The system will choose the best available provider automatically.',
},
},
required: ['query'],
},
);
}
/**
* Validates the parameters for the WebSearchTool.
* @param params The parameters to validate
* @returns An error message string if validation fails, null if valid
*/
protected override validateToolParamValues(
params: WebSearchToolParams,
): string | null {
if (!params.query || params.query.trim() === '') {
return "The 'query' parameter cannot be empty.";
}
// Validate provider parameter if provided
if (params.provider !== undefined && params.provider.trim() === '') {
return "The 'provider' parameter cannot be empty if specified.";
}
return null;
}
protected createInvocation(
params: WebSearchToolParams,
): ToolInvocation<WebSearchToolParams, WebSearchToolResult> {
return new WebSearchToolInvocation(this.config, params);
}
}
// Re-export types for external use
export type {
WebSearchToolParams,
WebSearchToolResult,
WebSearchConfig,
WebSearchProviderConfig,
} from './types.js';

View File

@@ -0,0 +1,199 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { promises as fs } from 'node:fs';
import * as os from 'os';
import * as path from 'path';
import { BaseWebSearchProvider } from '../base-provider.js';
import type {
WebSearchResult,
WebSearchResultItem,
DashScopeProviderConfig,
} from '../types.js';
import type { QwenCredentials } from '../../../qwen/qwenOAuth2.js';
interface DashScopeSearchItem {
_id: string;
snippet: string;
title: string;
url: string;
timestamp: number;
timestamp_format: string;
hostname: string;
hostlogo?: string;
web_main_body?: string;
_score?: number;
}
interface DashScopeSearchResponse {
headers: Record<string, unknown>;
rid: string;
status: number;
message: string | null;
data: {
total: number;
totalDistinct: number;
docs: DashScopeSearchItem[];
keywords?: string[];
qpInfos?: Array<{
query: string;
cleanQuery: string;
sensitive: boolean;
spellchecked: string;
spellcheck: boolean;
tokenized: string[];
stopWords: string[];
synonymWords: string[];
recognitions: unknown[];
rewrite: string;
operator: string;
}>;
aggs?: unknown;
extras?: Record<string, unknown>;
};
debug?: unknown;
success: boolean;
}
// File System Configuration
const QWEN_DIR = '.qwen';
const QWEN_CREDENTIAL_FILENAME = 'oauth_creds.json';
/**
* Get the path to the cached OAuth credentials file.
*/
function getQwenCachedCredentialPath(): string {
return path.join(os.homedir(), QWEN_DIR, QWEN_CREDENTIAL_FILENAME);
}
/**
* Load cached Qwen OAuth credentials from disk.
*/
async function loadQwenCredentials(): Promise<QwenCredentials | null> {
try {
const keyFile = getQwenCachedCredentialPath();
const creds = await fs.readFile(keyFile, 'utf-8');
return JSON.parse(creds) as QwenCredentials;
} catch {
return null;
}
}
/**
* Web search provider using Alibaba Cloud DashScope API.
*/
export class DashScopeProvider extends BaseWebSearchProvider {
readonly name = 'DashScope';
constructor(private readonly config: DashScopeProviderConfig) {
super();
}
isAvailable(): boolean {
// DashScope provider is only available when auth type is QWEN_OAUTH
// This ensures it's only used when OAuth credentials are available
return this.config.authType === 'qwen-oauth';
}
/**
* Get the access token and API endpoint for authentication and web search.
* Tries OAuth credentials first, falls back to apiKey if OAuth is not available.
* Returns both token and endpoint to avoid loading credentials multiple times.
*/
private async getAuthConfig(): Promise<{
accessToken: string | null;
apiEndpoint: string;
}> {
// Load credentials once
const credentials = await loadQwenCredentials();
// Get access token: try OAuth credentials first, fallback to apiKey
let accessToken: string | null = null;
if (credentials?.access_token) {
// Check if token is not expired
if (credentials.expiry_date && credentials.expiry_date > Date.now()) {
accessToken = credentials.access_token;
}
}
if (!accessToken) {
accessToken = this.config.apiKey || null;
}
// Get API endpoint: use resource_url from credentials
if (!credentials?.resource_url) {
throw new Error(
'No resource_url found in credentials. Please authenticate using OAuth',
);
}
// Normalize the URL: add protocol if missing
const baseUrl = credentials.resource_url.startsWith('http')
? credentials.resource_url
: `https://${credentials.resource_url}`;
// Remove trailing slash if present
const normalizedBaseUrl = baseUrl.replace(/\/$/, '');
const apiEndpoint = `${normalizedBaseUrl}/api/v1/indices/plugin/web_search`;
return { accessToken, apiEndpoint };
}
protected async performSearch(
query: string,
signal: AbortSignal,
): Promise<WebSearchResult> {
// Get access token and API endpoint (loads credentials once)
const { accessToken, apiEndpoint } = await this.getAuthConfig();
if (!accessToken) {
throw new Error(
'No access token available. Please authenticate using OAuth',
);
}
const requestBody = {
uq: query,
page: 1,
rows: this.config.maxResults || 10,
};
const response = await fetch(apiEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(requestBody),
signal,
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(
`API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`,
);
}
const data = (await response.json()) as DashScopeSearchResponse;
if (data.status !== 0) {
throw new Error(`API error: ${data.message || 'Unknown error'}`);
}
const results: WebSearchResultItem[] = (data.data?.docs || []).map(
(item) => ({
title: item.title,
url: item.url,
content: item.snippet,
score: item._score,
publishedDate: item.timestamp_format,
}),
);
return {
query,
results,
};
}
}

View File

@@ -0,0 +1,91 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { BaseWebSearchProvider } from '../base-provider.js';
import type {
WebSearchResult,
WebSearchResultItem,
GoogleProviderConfig,
} from '../types.js';
interface GoogleSearchItem {
title: string;
link: string;
snippet?: string;
displayLink?: string;
formattedUrl?: string;
}
interface GoogleSearchResponse {
items?: GoogleSearchItem[];
searchInformation?: {
totalResults?: string;
searchTime?: number;
};
}
/**
* Web search provider using Google Custom Search API.
*/
export class GoogleProvider extends BaseWebSearchProvider {
readonly name = 'Google';
constructor(private readonly config: GoogleProviderConfig) {
super();
}
isAvailable(): boolean {
return !!(this.config.apiKey && this.config.searchEngineId);
}
protected async performSearch(
query: string,
signal: AbortSignal,
): Promise<WebSearchResult> {
const params = new URLSearchParams({
key: this.config.apiKey!,
cx: this.config.searchEngineId!,
q: query,
num: String(this.config.maxResults || 10),
safe: this.config.safeSearch || 'medium',
});
if (this.config.language) {
params.append('lr', `lang_${this.config.language}`);
}
if (this.config.country) {
params.append('cr', `country${this.config.country}`);
}
const url = `https://www.googleapis.com/customsearch/v1?${params.toString()}`;
const response = await fetch(url, {
method: 'GET',
signal,
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(
`API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`,
);
}
const data = (await response.json()) as GoogleSearchResponse;
const results: WebSearchResultItem[] = (data.items || []).map((item) => ({
title: item.title,
url: item.link,
content: item.snippet,
}));
return {
query,
results,
};
}
}

View File

@@ -0,0 +1,84 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { BaseWebSearchProvider } from '../base-provider.js';
import type {
WebSearchResult,
WebSearchResultItem,
TavilyProviderConfig,
} from '../types.js';
interface TavilyResultItem {
title: string;
url: string;
content?: string;
score?: number;
published_date?: string;
}
interface TavilySearchResponse {
query: string;
answer?: string;
results: TavilyResultItem[];
}
/**
* Web search provider using Tavily API.
*/
export class TavilyProvider extends BaseWebSearchProvider {
readonly name = 'Tavily';
constructor(private readonly config: TavilyProviderConfig) {
super();
}
isAvailable(): boolean {
return !!this.config.apiKey;
}
protected async performSearch(
query: string,
signal: AbortSignal,
): Promise<WebSearchResult> {
const response = await fetch('https://api.tavily.com/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
api_key: this.config.apiKey,
query,
search_depth: this.config.searchDepth || 'advanced',
max_results: this.config.maxResults || 5,
include_answer: this.config.includeAnswer !== false,
}),
signal,
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(
`API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`,
);
}
const data = (await response.json()) as TavilySearchResponse;
const results: WebSearchResultItem[] = (data.results || []).map((r) => ({
title: r.title,
url: r.url,
content: r.content,
score: r.score,
publishedDate: r.published_date,
}));
return {
query,
answer: data.answer?.trim(),
results,
};
}
}

View File

@@ -0,0 +1,156 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type { ToolResult } from '../tools.js';
/**
* Common interface for all web search providers.
*/
export interface WebSearchProvider {
/**
* The name of the provider.
*/
readonly name: string;
/**
* Whether the provider is available (has required configuration).
*/
isAvailable(): boolean;
/**
* Perform a web search with the given query.
* @param query The search query
* @param signal Abort signal for cancellation
* @returns Promise resolving to search results
*/
search(query: string, signal: AbortSignal): Promise<WebSearchResult>;
}
/**
* Result item from a web search.
*/
export interface WebSearchResultItem {
title: string;
url: string;
content?: string;
score?: number;
publishedDate?: string;
}
/**
* Result from a web search operation.
*/
export interface WebSearchResult {
/**
* The search query that was executed.
*/
query: string;
/**
* A concise answer if available from the provider.
*/
answer?: string;
/**
* List of search result items.
*/
results: WebSearchResultItem[];
/**
* Provider-specific metadata.
*/
metadata?: Record<string, unknown>;
}
/**
* Extended tool result that includes sources for web search.
*/
export interface WebSearchToolResult extends ToolResult {
sources?: Array<{ title: string; url: string }>;
}
/**
* Parameters for the WebSearchTool.
*/
export interface WebSearchToolParams {
/**
* The search query.
*/
query: string;
/**
* Optional provider to use for the search.
* If not specified, the default provider will be used.
*/
provider?: string;
}
/**
* Configuration for web search providers.
*/
export interface WebSearchConfig {
/**
* List of available providers with their configurations.
*/
provider: WebSearchProviderConfig[];
/**
* The default provider to use.
*/
default: string;
}
/**
* Base configuration for Tavily provider.
*/
export interface TavilyProviderConfig {
type: 'tavily';
apiKey?: string;
searchDepth?: 'basic' | 'advanced';
maxResults?: number;
includeAnswer?: boolean;
}
/**
* Base configuration for Google provider.
*/
export interface GoogleProviderConfig {
type: 'google';
apiKey?: string;
searchEngineId?: string;
maxResults?: number;
safeSearch?: 'off' | 'medium' | 'high';
language?: string;
country?: string;
}
/**
* Base configuration for DashScope provider.
*/
export interface DashScopeProviderConfig {
type: 'dashscope';
apiKey?: string;
uid?: string;
appId?: string;
maxResults?: number;
scene?: string;
timeout?: number;
/**
* Optional auth type to determine provider availability.
* If set to 'qwen-oauth', the provider will be available.
* If set to other values or undefined, the provider will check auth type dynamically.
*/
authType?: string;
}
/**
* Discriminated union type for web search provider configurations.
* This ensures type safety when working with different provider configs.
*/
export type WebSearchProviderConfig =
| TavilyProviderConfig
| GoogleProviderConfig
| DashScopeProviderConfig;

View File

@@ -0,0 +1,42 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Utility functions for web search formatting and processing.
*/
/**
* Build content string with appended sources section.
* @param content Main content text
* @param sources Array of source objects
* @returns Combined content with sources
*/
export function buildContentWithSources(
content: string,
sources: Array<{ title: string; url: string }>,
): string {
if (!sources.length) return content;
const sourceList = sources
.map((s, i) => `[${i + 1}] ${s.title || 'Untitled'} (${s.url})`)
.join('\n');
return `${content}\n\nSources:\n${sourceList}`;
}
/**
* Build a concise summary from top search results.
* @param sources Array of source objects
* @param maxResults Maximum number of results to include
* @returns Concise summary string
*/
export function buildSummary(
sources: Array<{ title: string; url: string }>,
maxResults: number = 3,
): string {
return sources
.slice(0, maxResults)
.map((s, i) => `${i + 1}. ${s.title} - ${s.url}`)
.join('\n');
}

View File

@@ -0,0 +1,381 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as path from 'node:path';
import * as os from 'os';
import { promises as fs } from 'node:fs';
import { OpenAILogger } from './openaiLogger.js';
describe('OpenAILogger', () => {
let originalCwd: string;
let testTempDir: string;
const createdDirs: string[] = [];
beforeEach(() => {
originalCwd = process.cwd();
testTempDir = path.join(os.tmpdir(), `openai-logger-test-${Date.now()}`);
createdDirs.length = 0; // Clear array
});
afterEach(async () => {
// Clean up all created directories
const cleanupPromises = [
testTempDir,
...createdDirs,
path.resolve(process.cwd(), 'relative-logs'),
path.resolve(process.cwd(), 'custom-logs'),
path.resolve(process.cwd(), 'test-relative-logs'),
path.join(os.homedir(), 'custom-logs'),
path.join(os.homedir(), 'test-openai-logs'),
].map(async (dir) => {
try {
await fs.rm(dir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
await Promise.all(cleanupPromises);
process.chdir(originalCwd);
});
describe('constructor', () => {
it('should use default directory when no custom directory is provided', () => {
const logger = new OpenAILogger();
// We can't directly access private logDir, but we can verify behavior
expect(logger).toBeInstanceOf(OpenAILogger);
});
it('should accept absolute path as custom directory', () => {
const customDir = '/absolute/path/to/logs';
const logger = new OpenAILogger(customDir);
expect(logger).toBeInstanceOf(OpenAILogger);
});
it('should resolve relative path to absolute path', async () => {
const relativeDir = 'custom-logs';
const logger = new OpenAILogger(relativeDir);
const expectedDir = path.resolve(process.cwd(), relativeDir);
createdDirs.push(expectedDir);
expect(logger).toBeInstanceOf(OpenAILogger);
});
it('should expand ~ to home directory', () => {
const customDir = '~/custom-logs';
const logger = new OpenAILogger(customDir);
expect(logger).toBeInstanceOf(OpenAILogger);
});
it('should expand ~/ to home directory', () => {
const customDir = '~/custom-logs';
const logger = new OpenAILogger(customDir);
expect(logger).toBeInstanceOf(OpenAILogger);
});
it('should handle just ~ as home directory', () => {
const customDir = '~';
const logger = new OpenAILogger(customDir);
expect(logger).toBeInstanceOf(OpenAILogger);
});
});
describe('initialize', () => {
it('should create directory if it does not exist', async () => {
const logger = new OpenAILogger(testTempDir);
await logger.initialize();
const dirExists = await fs
.access(testTempDir)
.then(() => true)
.catch(() => false);
expect(dirExists).toBe(true);
});
it('should create nested directories recursively', async () => {
const nestedDir = path.join(testTempDir, 'nested', 'deep', 'path');
const logger = new OpenAILogger(nestedDir);
await logger.initialize();
const dirExists = await fs
.access(nestedDir)
.then(() => true)
.catch(() => false);
expect(dirExists).toBe(true);
});
it('should not throw if directory already exists', async () => {
await fs.mkdir(testTempDir, { recursive: true });
const logger = new OpenAILogger(testTempDir);
await expect(logger.initialize()).resolves.not.toThrow();
});
});
describe('logInteraction', () => {
it('should create log file with correct format', async () => {
const logger = new OpenAILogger(testTempDir);
await logger.initialize();
const request = {
model: 'gpt-4',
messages: [{ role: 'user', content: 'test' }],
};
const response = { id: 'test-id', choices: [] };
const logPath = await logger.logInteraction(request, response);
expect(logPath).toContain(testTempDir);
expect(logPath).toMatch(
/openai-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{3}Z-[a-f0-9]{8}\.json/,
);
const fileExists = await fs
.access(logPath)
.then(() => true)
.catch(() => false);
expect(fileExists).toBe(true);
});
it('should write correct log data structure', async () => {
const logger = new OpenAILogger(testTempDir);
await logger.initialize();
const request = {
model: 'gpt-4',
messages: [{ role: 'user', content: 'test' }],
};
const response = { id: 'test-id', choices: [] };
const logPath = await logger.logInteraction(request, response);
const logContent = JSON.parse(await fs.readFile(logPath, 'utf-8'));
expect(logContent).toHaveProperty('timestamp');
expect(logContent).toHaveProperty('request', request);
expect(logContent).toHaveProperty('response', response);
expect(logContent).toHaveProperty('error', null);
expect(logContent).toHaveProperty('system');
expect(logContent.system).toHaveProperty('hostname');
expect(logContent.system).toHaveProperty('platform');
expect(logContent.system).toHaveProperty('release');
expect(logContent.system).toHaveProperty('nodeVersion');
});
it('should log error when provided', async () => {
const logger = new OpenAILogger(testTempDir);
await logger.initialize();
const request = {
model: 'gpt-4',
messages: [{ role: 'user', content: 'test' }],
};
const error = new Error('Test error');
const logPath = await logger.logInteraction(request, undefined, error);
const logContent = JSON.parse(await fs.readFile(logPath, 'utf-8'));
expect(logContent).toHaveProperty('error');
expect(logContent.error).toHaveProperty('message', 'Test error');
expect(logContent.error).toHaveProperty('stack');
expect(logContent.response).toBeNull();
});
it('should use custom directory when provided', async () => {
const customDir = path.join(testTempDir, 'custom-logs');
const logger = new OpenAILogger(customDir);
await logger.initialize();
const request = {
model: 'gpt-4',
messages: [{ role: 'user', content: 'test' }],
};
const response = { id: 'test-id', choices: [] };
const logPath = await logger.logInteraction(request, response);
expect(logPath).toContain(customDir);
expect(logPath.startsWith(customDir)).toBe(true);
});
it('should resolve relative path correctly', async () => {
const relativeDir = 'relative-logs';
const logger = new OpenAILogger(relativeDir);
await logger.initialize();
const request = {
model: 'gpt-4',
messages: [{ role: 'user', content: 'test' }],
};
const response = { id: 'test-id', choices: [] };
const logPath = await logger.logInteraction(request, response);
const expectedDir = path.resolve(process.cwd(), relativeDir);
createdDirs.push(expectedDir);
expect(logPath).toContain(expectedDir);
});
it('should expand ~ correctly', async () => {
const customDir = '~/test-openai-logs';
const logger = new OpenAILogger(customDir);
await logger.initialize();
const request = {
model: 'gpt-4',
messages: [{ role: 'user', content: 'test' }],
};
const response = { id: 'test-id', choices: [] };
const logPath = await logger.logInteraction(request, response);
const expectedDir = path.join(os.homedir(), 'test-openai-logs');
createdDirs.push(expectedDir);
expect(logPath).toContain(expectedDir);
});
});
describe('getLogFiles', () => {
it('should return empty array when directory does not exist', async () => {
const logger = new OpenAILogger(testTempDir);
const files = await logger.getLogFiles();
expect(files).toEqual([]);
});
it('should return log files after initialization', async () => {
const logger = new OpenAILogger(testTempDir);
await logger.initialize();
const request = {
model: 'gpt-4',
messages: [{ role: 'user', content: 'test' }],
};
const response = { id: 'test-id', choices: [] };
await logger.logInteraction(request, response);
const files = await logger.getLogFiles();
expect(files.length).toBeGreaterThan(0);
expect(files[0]).toMatch(/openai-.*\.json$/);
});
it('should return only log files matching pattern', async () => {
const logger = new OpenAILogger(testTempDir);
await logger.initialize();
// Create a log file
await logger.logInteraction({ test: 'request' }, { test: 'response' });
// Create a non-log file
await fs.writeFile(path.join(testTempDir, 'other-file.txt'), 'content');
const files = await logger.getLogFiles();
expect(files.length).toBe(1);
expect(files[0]).toMatch(/openai-.*\.json$/);
});
it('should respect limit parameter', async () => {
const logger = new OpenAILogger(testTempDir);
await logger.initialize();
// Create multiple log files
for (let i = 0; i < 5; i++) {
await logger.logInteraction(
{ test: `request-${i}` },
{ test: `response-${i}` },
);
// Small delay to ensure different timestamps
await new Promise((resolve) => setTimeout(resolve, 10));
}
const allFiles = await logger.getLogFiles();
expect(allFiles.length).toBe(5);
const limitedFiles = await logger.getLogFiles(3);
expect(limitedFiles.length).toBe(3);
});
it('should return files sorted by most recent first', async () => {
const logger = new OpenAILogger(testTempDir);
await logger.initialize();
const files: string[] = [];
for (let i = 0; i < 3; i++) {
const logPath = await logger.logInteraction(
{ test: `request-${i}` },
{ test: `response-${i}` },
);
files.push(logPath);
await new Promise((resolve) => setTimeout(resolve, 10));
}
const retrievedFiles = await logger.getLogFiles();
expect(retrievedFiles[0]).toBe(files[2]); // Most recent first
expect(retrievedFiles[1]).toBe(files[1]);
expect(retrievedFiles[2]).toBe(files[0]);
});
});
describe('readLogFile', () => {
it('should read and parse log file correctly', async () => {
const logger = new OpenAILogger(testTempDir);
await logger.initialize();
const request = {
model: 'gpt-4',
messages: [{ role: 'user', content: 'test' }],
};
const response = { id: 'test-id', choices: [] };
const logPath = await logger.logInteraction(request, response);
const logData = await logger.readLogFile(logPath);
expect(logData).toHaveProperty('timestamp');
expect(logData).toHaveProperty('request', request);
expect(logData).toHaveProperty('response', response);
});
it('should throw error when file does not exist', async () => {
const logger = new OpenAILogger(testTempDir);
const nonExistentPath = path.join(testTempDir, 'non-existent.json');
await expect(logger.readLogFile(nonExistentPath)).rejects.toThrow();
});
});
describe('path resolution', () => {
it('should normalize absolute paths', () => {
const absolutePath = '/tmp/test/logs';
const logger = new OpenAILogger(absolutePath);
expect(logger).toBeInstanceOf(OpenAILogger);
});
it('should resolve relative paths based on current working directory', async () => {
const relativePath = 'test-relative-logs';
const logger = new OpenAILogger(relativePath);
await logger.initialize();
const request = { test: 'request' };
const response = { test: 'response' };
const logPath = await logger.logInteraction(request, response);
const expectedBaseDir = path.resolve(process.cwd(), relativePath);
createdDirs.push(expectedBaseDir);
expect(logPath).toContain(expectedBaseDir);
});
it('should handle paths with special characters', async () => {
const specialPath = path.join(testTempDir, 'logs-with-special-chars');
const logger = new OpenAILogger(specialPath);
await logger.initialize();
const request = { test: 'request' };
const response = { test: 'response' };
const logPath = await logger.logInteraction(request, response);
expect(logPath).toContain(specialPath);
});
});
});

View File

@@ -18,10 +18,23 @@ export class OpenAILogger {
/**
* Creates a new OpenAI logger
* @param customLogDir Optional custom log directory path
* @param customLogDir Optional custom log directory path (supports relative paths, absolute paths, and ~ expansion)
*/
constructor(customLogDir?: string) {
this.logDir = customLogDir || path.join(process.cwd(), 'logs', 'openai');
if (customLogDir) {
// Resolve relative paths to absolute paths
// Handle ~ expansion
let resolvedPath = customLogDir;
if (customLogDir === '~' || customLogDir.startsWith('~/')) {
resolvedPath = path.join(os.homedir(), customLogDir.slice(1));
} else if (!path.isAbsolute(customLogDir)) {
// If it's a relative path, resolve it relative to current working directory
resolvedPath = path.resolve(process.cwd(), customLogDir);
}
this.logDir = path.normalize(resolvedPath);
} else {
this.logDir = path.join(process.cwd(), 'logs', 'openai');
}
}
/**

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

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.1.2",
"version": "0.1.5",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",

View File

@@ -2,7 +2,7 @@
"name": "qwen-code-vscode-ide-companion",
"displayName": "Qwen Code Companion",
"description": "Enable Qwen Code with direct access to your VS Code workspace.",
"version": "0.1.2",
"version": "0.1.5",
"publisher": "qwenlm",
"icon": "assets/icon.png",
"repository": {

View File

@@ -85,7 +85,7 @@ const distPackageJson = {
bin: {
qwen: 'cli.js',
},
files: ['cli.js', 'vendor', 'README.md', 'LICENSE'],
files: ['cli.js', 'vendor', '*.sb', 'README.md', 'LICENSE'],
config: rootPackageJson.config,
dependencies: runtimeDependencies,
optionalDependencies: {

View File

@@ -69,7 +69,14 @@ if (process.env.DEBUG) {
// than the relaunched process making it harder to debug.
env.GEMINI_CLI_NO_RELAUNCH = 'true';
}
const child = spawn('node', nodeArgs, { stdio: 'inherit', env });
// Use process.cwd() to inherit the working directory from launch.json cwd setting
// This allows debugging from a specific directory (e.g., .todo)
const workingDir = process.env.QWEN_WORKING_DIR || process.cwd();
const child = spawn('node', nodeArgs, {
stdio: 'inherit',
env,
cwd: workingDir,
});
child.on('close', (code) => {
process.exit(code);

View File

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