mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-23 10:17:50 +00:00
Compare commits
37 Commits
release/v0
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a472e4fcf | ||
|
|
5390f662fc | ||
|
|
c3d427730e | ||
|
|
21fba6eb89 | ||
|
|
d17c37af7d | ||
|
|
82170e96c6 | ||
|
|
decb04efc4 | ||
|
|
3bd0cb36c4 | ||
|
|
553a36302a | ||
|
|
498d7a083a | ||
|
|
3a69931791 | ||
|
|
d4ab328671 | ||
|
|
90500ea67b | ||
|
|
335e765df0 | ||
|
|
448e30bf88 | ||
|
|
26215b6d0a | ||
|
|
f6f76a17e6 | ||
|
|
55a3b69a8e | ||
|
|
22bd108775 | ||
|
|
7ff07fd88c | ||
|
|
2967bec11c | ||
|
|
6357a5c87e | ||
|
|
7e827833bf | ||
|
|
d1507e73fe | ||
|
|
512c91a969 | ||
|
|
50d5cc2f6a | ||
|
|
40d82a2b25 | ||
|
|
a40479d40a | ||
|
|
7cb068ceb2 | ||
|
|
864bf03fee | ||
|
|
9a41db612a | ||
|
|
4781736f99 | ||
|
|
799d2bf0db | ||
|
|
741eaf91c2 | ||
|
|
79b4821499 | ||
|
|
b1ece177b7 | ||
|
|
f9f6eb52dd |
@@ -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`
|
||||
|
||||
@@ -160,9 +160,30 @@ Settings are organized into categories. All settings should be placed within the
|
||||
- **Default:** `undefined`
|
||||
|
||||
- **`model.chatCompression.contextPercentageThreshold`** (number):
|
||||
- **Description:** Sets the threshold for chat history compression as a percentage of the model's total token limit. This is a value between 0 and 1 that applies to both automatic compression and the manual `/compress` command. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit.
|
||||
- **Description:** Sets the threshold for chat history compression as a percentage of the model's total token limit. This is a value between 0 and 1 that applies to both automatic compression and the manual `/compress` command. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit. Use `0` to disable compression entirely.
|
||||
- **Default:** `0.7`
|
||||
|
||||
- **`model.generationConfig`** (object):
|
||||
- **Description:** Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, and `disableCacheControl`, along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults.
|
||||
- **Default:** `undefined`
|
||||
- **Example:**
|
||||
|
||||
```json
|
||||
{
|
||||
"model": {
|
||||
"generationConfig": {
|
||||
"timeout": 60000,
|
||||
"disableCacheControl": false,
|
||||
"samplingParams": {
|
||||
"temperature": 0.2,
|
||||
"top_p": 0.8,
|
||||
"max_tokens": 1024
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **`model.skipNextSpeakerCheck`** (boolean):
|
||||
- **Description:** Skip the next speaker check.
|
||||
- **Default:** `false`
|
||||
@@ -171,6 +192,22 @@ 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.skipStartupContext`** (boolean):
|
||||
- **Description:** Skips sending the startup workspace context (environment summary and acknowledgement) at the beginning of each session. Enable this if you prefer to provide context manually or want to save tokens on startup.
|
||||
- **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):
|
||||
@@ -254,6 +291,21 @@ Settings are organized into categories. All settings should be placed within the
|
||||
- **Description:** Use the bundled ripgrep binary. When set to `false`, the system-level `rg` command will be used instead. This setting is only effective when `tools.useRipgrep` is `true`.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`tools.enableToolOutputTruncation`** (boolean):
|
||||
- **Description:** Enable truncation of large tool outputs.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.truncateToolOutputThreshold`** (number):
|
||||
- **Description:** Truncate tool output if it is larger than this many characters. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools.
|
||||
- **Default:** `25000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.truncateToolOutputLines`** (number):
|
||||
- **Description:** Maximum lines or entries kept when truncating tool output. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools.
|
||||
- **Default:** `1000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
#### `mcp`
|
||||
|
||||
- **`mcp.serverCommand`** (string):
|
||||
@@ -305,7 +357,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 +439,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 +529,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 +611,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`
|
||||
|
||||
@@ -68,72 +68,66 @@ Qwen Code provides a comprehensive suite of tools for interacting with the local
|
||||
- **File:** `glob.ts`
|
||||
- **Parameters:**
|
||||
- `pattern` (string, required): The glob pattern to match against (e.g., `"*.py"`, `"src/**/*.js"`).
|
||||
- `path` (string, optional): The absolute path to the directory to search within. If omitted, searches the tool's root directory.
|
||||
- `case_sensitive` (boolean, optional): Whether the search should be case-sensitive. Defaults to `false`.
|
||||
- `respect_git_ignore` (boolean, optional): Whether to respect .gitignore patterns when finding files. Defaults to `true`.
|
||||
- `path` (string, optional): The directory to search in. If not specified, the current working directory will be used.
|
||||
- **Behavior:**
|
||||
- Searches for files matching the glob pattern within the specified directory.
|
||||
- Returns a list of absolute paths, sorted with the most recently modified files first.
|
||||
- Ignores common nuisance directories like `node_modules` and `.git` by default.
|
||||
- **Output (`llmContent`):** A message like: `Found 5 file(s) matching "*.ts" within src, sorted by modification time (newest first):\nsrc/file1.ts\nsrc/subdir/file2.ts...`
|
||||
- Respects .gitignore and .qwenignore patterns by default.
|
||||
- Limits results to 100 files to prevent context overflow.
|
||||
- **Output (`llmContent`):** A message like: `Found 5 file(s) matching "*.ts" within /path/to/search/dir, sorted by modification time (newest first):\n---\n/path/to/file1.ts\n/path/to/subdir/file2.ts\n---\n[95 files truncated] ...`
|
||||
- **Confirmation:** No.
|
||||
|
||||
## 5. `search_file_content` (SearchText)
|
||||
## 5. `grep_search` (Grep)
|
||||
|
||||
`search_file_content` searches for a regular expression pattern within the content of files in a specified directory. Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers.
|
||||
`grep_search` searches for a regular expression pattern within the content of files in a specified directory. Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers.
|
||||
|
||||
- **Tool name:** `search_file_content`
|
||||
- **Display name:** SearchText
|
||||
- **File:** `grep.ts`
|
||||
- **Tool name:** `grep_search`
|
||||
- **Display name:** Grep
|
||||
- **File:** `ripGrep.ts` (with `grep.ts` as fallback)
|
||||
- **Parameters:**
|
||||
- `pattern` (string, required): The regular expression (regex) to search for (e.g., `"function\s+myFunction"`).
|
||||
- `path` (string, optional): The absolute path to the directory to search within. Defaults to the current working directory.
|
||||
- `include` (string, optional): A glob pattern to filter which files are searched (e.g., `"*.js"`, `"src/**/*.{ts,tsx}"`). If omitted, searches most files (respecting common ignores).
|
||||
- `maxResults` (number, optional): Maximum number of matches to return to prevent context overflow (default: 20, max: 100). Use lower values for broad searches, higher for specific searches.
|
||||
- `pattern` (string, required): The regular expression pattern to search for in file contents (e.g., `"function\\s+myFunction"`, `"log.*Error"`).
|
||||
- `path` (string, optional): File or directory to search in. Defaults to current working directory.
|
||||
- `glob` (string, optional): Glob pattern to filter files (e.g. `"*.js"`, `"src/**/*.{ts,tsx}"`).
|
||||
- `limit` (number, optional): Limit output to first N matching lines. Optional - shows all matches if not specified.
|
||||
- **Behavior:**
|
||||
- Uses `git grep` if available in a Git repository for speed; otherwise, falls back to system `grep` or a JavaScript-based search.
|
||||
- Returns a list of matching lines, each prefixed with its file path (relative to the search directory) and line number.
|
||||
- Limits results to a maximum of 20 matches by default to prevent context overflow. When results are truncated, shows a clear warning with guidance on refining searches.
|
||||
- Uses ripgrep for fast search when available; otherwise falls back to a JavaScript-based search implementation.
|
||||
- Returns matching lines with file paths and line numbers.
|
||||
- Case-insensitive by default.
|
||||
- Respects .gitignore and .qwenignore patterns.
|
||||
- Limits output to prevent context overflow.
|
||||
- **Output (`llmContent`):** A formatted string of matches, e.g.:
|
||||
|
||||
```
|
||||
Found 3 matches for pattern "myFunction" in path "." (filter: "*.ts"):
|
||||
---
|
||||
File: src/utils.ts
|
||||
L15: export function myFunction() {
|
||||
L22: myFunction.call();
|
||||
---
|
||||
File: src/index.ts
|
||||
L5: import { myFunction } from './utils';
|
||||
src/utils.ts:15:export function myFunction() {
|
||||
src/utils.ts:22: myFunction.call();
|
||||
src/index.ts:5:import { myFunction } from './utils';
|
||||
---
|
||||
|
||||
WARNING: Results truncated to prevent context overflow. To see more results:
|
||||
- Use a more specific pattern to reduce matches
|
||||
- Add file filters with the 'include' parameter (e.g., "*.js", "src/**")
|
||||
- Specify a narrower 'path' to search in a subdirectory
|
||||
- Increase 'maxResults' parameter if you need more matches (current: 20)
|
||||
[0 lines truncated] ...
|
||||
```
|
||||
|
||||
- **Confirmation:** No.
|
||||
|
||||
### `search_file_content` examples
|
||||
### `grep_search` examples
|
||||
|
||||
Search for a pattern with default result limiting:
|
||||
|
||||
```
|
||||
search_file_content(pattern="function\s+myFunction", path="src")
|
||||
grep_search(pattern="function\\s+myFunction", path="src")
|
||||
```
|
||||
|
||||
Search for a pattern with custom result limiting:
|
||||
|
||||
```
|
||||
search_file_content(pattern="function", path="src", maxResults=50)
|
||||
grep_search(pattern="function", path="src", limit=50)
|
||||
```
|
||||
|
||||
Search for a pattern with file filtering and custom result limiting:
|
||||
|
||||
```
|
||||
search_file_content(pattern="function", include="*.js", maxResults=10)
|
||||
grep_search(pattern="function", glob="*.js", limit=10)
|
||||
```
|
||||
|
||||
## 6. `edit` (Edit)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.1.3",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.1.3",
|
||||
"version": "0.2.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -16024,7 +16024,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.1.3",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"@google/genai": "1.16.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
@@ -16139,7 +16139,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.1.3",
|
||||
"version": "0.2.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@google/genai": "1.16.0",
|
||||
@@ -16278,7 +16278,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@qwen-code/qwen-code-test-utils",
|
||||
"version": "0.1.3",
|
||||
"version": "0.2.0",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
@@ -16290,7 +16290,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"version": "0.1.3",
|
||||
"version": "0.2.0",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.15.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.1.3",
|
||||
"version": "0.2.0",
|
||||
"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.3"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.2.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.1.3",
|
||||
"version": "0.2.0",
|
||||
"description": "Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -25,7 +25,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.1.3"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "1.16.0",
|
||||
|
||||
@@ -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,
|
||||
@@ -765,6 +791,7 @@ export async function loadCliConfig(
|
||||
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
|
||||
enablePromptCompletion: settings.general?.enablePromptCompletion ?? false,
|
||||
skipLoopDetection: settings.model?.skipLoopDetection ?? false,
|
||||
skipStartupContext: settings.model?.skipStartupContext ?? false,
|
||||
vlmSwitchMode,
|
||||
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
|
||||
truncateToolOutputLines: settings.tools?.truncateToolOutputLines,
|
||||
|
||||
@@ -131,6 +131,7 @@ const MIGRATION_MAP: Record<string, string> = {
|
||||
sessionTokenLimit: 'model.sessionTokenLimit',
|
||||
contentGenerator: 'model.generationConfig',
|
||||
skipLoopDetection: 'model.skipLoopDetection',
|
||||
skipStartupContext: 'model.skipStartupContext',
|
||||
enableOpenAILogging: 'model.enableOpenAILogging',
|
||||
tavilyApiKey: 'advanced.tavilyApiKey',
|
||||
vlmSwitchMode: 'experimental.vlmSwitchMode',
|
||||
|
||||
@@ -549,6 +549,16 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Disable all loop detection checks (streaming and LLM).',
|
||||
showInDialog: true,
|
||||
},
|
||||
skipStartupContext: {
|
||||
type: 'boolean',
|
||||
label: 'Skip Startup Context',
|
||||
category: 'Model',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Avoid sending the workspace startup context at the beginning of each session.',
|
||||
showInDialog: true,
|
||||
},
|
||||
enableOpenAILogging: {
|
||||
type: 'boolean',
|
||||
label: 'Enable OpenAI Logging',
|
||||
@@ -558,6 +568,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 +1092,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',
|
||||
|
||||
121
packages/cli/src/config/webSearch.ts
Normal file
121
packages/cli/src/config/webSearch.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -551,6 +551,11 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
[visionSwitchResolver],
|
||||
);
|
||||
|
||||
// onDebugMessage should log to console, not update footer debugMessage
|
||||
const onDebugMessage = useCallback((message: string) => {
|
||||
console.debug(message);
|
||||
}, []);
|
||||
|
||||
const performMemoryRefresh = useCallback(async () => {
|
||||
historyManager.addItem(
|
||||
{
|
||||
@@ -628,7 +633,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
historyManager.addItem,
|
||||
config,
|
||||
settings,
|
||||
setDebugMessage,
|
||||
onDebugMessage,
|
||||
handleSlashCommand,
|
||||
shellModeActive,
|
||||
() => settings.merged.general?.preferredEditor as EditorType,
|
||||
|
||||
@@ -8,38 +8,22 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { aboutCommand } from './aboutCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import * as versionUtils from '../../utils/version.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { IdeClient } from '@qwen-code/qwen-code-core';
|
||||
import * as systemInfoUtils from '../../utils/systemInfo.js';
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...actual,
|
||||
IdeClient: {
|
||||
getInstance: vi.fn().mockResolvedValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../utils/version.js', () => ({
|
||||
getCliVersion: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../utils/systemInfo.js');
|
||||
|
||||
describe('aboutCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
const originalPlatform = process.platform;
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getModel: vi.fn(),
|
||||
getModel: vi.fn().mockReturnValue('test-model'),
|
||||
getIdeMode: vi.fn().mockReturnValue(true),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
},
|
||||
settings: {
|
||||
merged: {
|
||||
@@ -56,21 +40,25 @@ describe('aboutCommand', () => {
|
||||
},
|
||||
} as unknown as CommandContext);
|
||||
|
||||
vi.mocked(versionUtils.getCliVersion).mockResolvedValue('test-version');
|
||||
vi.spyOn(mockContext.services.config!, 'getModel').mockReturnValue(
|
||||
'test-model',
|
||||
);
|
||||
process.env['GOOGLE_CLOUD_PROJECT'] = 'test-gcp-project';
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'test-os',
|
||||
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
|
||||
cliVersion: 'test-version',
|
||||
osPlatform: 'test-os',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'no sandbox',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: 'test-ide',
|
||||
sessionId: 'test-session-id',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
});
|
||||
process.env = originalEnv;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -81,30 +69,55 @@ describe('aboutCommand', () => {
|
||||
});
|
||||
|
||||
it('should call addItem with all version info', async () => {
|
||||
process.env['SANDBOX'] = '';
|
||||
if (!aboutCommand.action) {
|
||||
throw new Error('The about command must have an action.');
|
||||
}
|
||||
|
||||
await aboutCommand.action(mockContext, '');
|
||||
|
||||
expect(systemInfoUtils.getExtendedSystemInfo).toHaveBeenCalledWith(
|
||||
mockContext,
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
expect.objectContaining({
|
||||
type: MessageType.ABOUT,
|
||||
cliVersion: 'test-version',
|
||||
osVersion: 'test-os',
|
||||
sandboxEnv: 'no sandbox',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
gcpProject: 'test-gcp-project',
|
||||
ideClient: 'test-ide',
|
||||
},
|
||||
systemInfo: expect.objectContaining({
|
||||
cliVersion: 'test-version',
|
||||
osPlatform: 'test-os',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'no sandbox',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: 'test-ide',
|
||||
sessionId: 'test-session-id',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: undefined,
|
||||
}),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show the correct sandbox environment variable', async () => {
|
||||
process.env['SANDBOX'] = 'gemini-sandbox';
|
||||
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
|
||||
cliVersion: 'test-version',
|
||||
osPlatform: 'test-os',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'gemini-sandbox',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: 'test-ide',
|
||||
sessionId: 'test-session-id',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: undefined,
|
||||
});
|
||||
|
||||
if (!aboutCommand.action) {
|
||||
throw new Error('The about command must have an action.');
|
||||
}
|
||||
@@ -113,15 +126,32 @@ describe('aboutCommand', () => {
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sandboxEnv: 'gemini-sandbox',
|
||||
type: MessageType.ABOUT,
|
||||
systemInfo: expect.objectContaining({
|
||||
sandboxEnv: 'gemini-sandbox',
|
||||
}),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show sandbox-exec profile when applicable', async () => {
|
||||
process.env['SANDBOX'] = 'sandbox-exec';
|
||||
process.env['SEATBELT_PROFILE'] = 'test-profile';
|
||||
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
|
||||
cliVersion: 'test-version',
|
||||
osPlatform: 'test-os',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'sandbox-exec (test-profile)',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: 'test-ide',
|
||||
sessionId: 'test-session-id',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: undefined,
|
||||
});
|
||||
|
||||
if (!aboutCommand.action) {
|
||||
throw new Error('The about command must have an action.');
|
||||
}
|
||||
@@ -130,18 +160,31 @@ describe('aboutCommand', () => {
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sandboxEnv: 'sandbox-exec (test-profile)',
|
||||
systemInfo: expect.objectContaining({
|
||||
sandboxEnv: 'sandbox-exec (test-profile)',
|
||||
}),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not show ide client when it is not detected', async () => {
|
||||
vi.mocked(IdeClient.getInstance).mockResolvedValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as IdeClient);
|
||||
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
|
||||
cliVersion: 'test-version',
|
||||
osPlatform: 'test-os',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'no sandbox',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: '',
|
||||
sessionId: 'test-session-id',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: undefined,
|
||||
});
|
||||
|
||||
process.env['SANDBOX'] = '';
|
||||
if (!aboutCommand.action) {
|
||||
throw new Error('The about command must have an action.');
|
||||
}
|
||||
@@ -151,13 +194,87 @@ describe('aboutCommand', () => {
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ABOUT,
|
||||
cliVersion: 'test-version',
|
||||
osVersion: 'test-os',
|
||||
sandboxEnv: 'no sandbox',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
gcpProject: 'test-gcp-project',
|
||||
ideClient: '',
|
||||
systemInfo: expect.objectContaining({
|
||||
cliVersion: 'test-version',
|
||||
osPlatform: 'test-os',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'no sandbox',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: '',
|
||||
sessionId: 'test-session-id',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: undefined,
|
||||
}),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show unknown npmVersion when npm command fails', async () => {
|
||||
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
|
||||
cliVersion: 'test-version',
|
||||
osPlatform: 'test-os',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: 'unknown',
|
||||
sandboxEnv: 'no sandbox',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: 'test-ide',
|
||||
sessionId: 'test-session-id',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: undefined,
|
||||
});
|
||||
|
||||
if (!aboutCommand.action) {
|
||||
throw new Error('The about command must have an action.');
|
||||
}
|
||||
|
||||
await aboutCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
systemInfo: expect.objectContaining({
|
||||
npmVersion: 'unknown',
|
||||
}),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show unknown sessionId when config is not available', async () => {
|
||||
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
|
||||
cliVersion: 'test-version',
|
||||
osPlatform: 'test-os',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'no sandbox',
|
||||
modelVersion: 'Unknown',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: '',
|
||||
sessionId: 'unknown',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: undefined,
|
||||
});
|
||||
|
||||
if (!aboutCommand.action) {
|
||||
throw new Error('The about command must have an action.');
|
||||
}
|
||||
|
||||
await aboutCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
systemInfo: expect.objectContaining({
|
||||
sessionId: 'unknown',
|
||||
}),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
@@ -4,53 +4,23 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { getCliVersion } from '../../utils/version.js';
|
||||
import type { CommandContext, SlashCommand } from './types.js';
|
||||
import type { SlashCommand } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import process from 'node:process';
|
||||
import { MessageType, type HistoryItemAbout } from '../types.js';
|
||||
import { IdeClient } from '@qwen-code/qwen-code-core';
|
||||
import { getExtendedSystemInfo } from '../../utils/systemInfo.js';
|
||||
|
||||
export const aboutCommand: SlashCommand = {
|
||||
name: 'about',
|
||||
description: 'show version info',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context) => {
|
||||
const osVersion = process.platform;
|
||||
let sandboxEnv = 'no sandbox';
|
||||
if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') {
|
||||
sandboxEnv = process.env['SANDBOX'];
|
||||
} else if (process.env['SANDBOX'] === 'sandbox-exec') {
|
||||
sandboxEnv = `sandbox-exec (${
|
||||
process.env['SEATBELT_PROFILE'] || 'unknown'
|
||||
})`;
|
||||
}
|
||||
const modelVersion = context.services.config?.getModel() || 'Unknown';
|
||||
const cliVersion = await getCliVersion();
|
||||
const selectedAuthType =
|
||||
context.services.settings.merged.security?.auth?.selectedType || '';
|
||||
const gcpProject = process.env['GOOGLE_CLOUD_PROJECT'] || '';
|
||||
const ideClient = await getIdeClientName(context);
|
||||
const systemInfo = await getExtendedSystemInfo(context);
|
||||
|
||||
const aboutItem: Omit<HistoryItemAbout, 'id'> = {
|
||||
type: MessageType.ABOUT,
|
||||
cliVersion,
|
||||
osVersion,
|
||||
sandboxEnv,
|
||||
modelVersion,
|
||||
selectedAuthType,
|
||||
gcpProject,
|
||||
ideClient,
|
||||
systemInfo,
|
||||
};
|
||||
|
||||
context.ui.addItem(aboutItem, Date.now());
|
||||
},
|
||||
};
|
||||
|
||||
async function getIdeClientName(context: CommandContext) {
|
||||
if (!context.services.config?.getIdeMode()) {
|
||||
return '';
|
||||
}
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
return ideClient?.getDetectedIdeDisplayName() ?? '';
|
||||
}
|
||||
|
||||
@@ -8,41 +8,34 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import open from 'open';
|
||||
import { bugCommand } from './bugCommand.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { getCliVersion } from '../../utils/version.js';
|
||||
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
||||
import { formatMemoryUsage } from '../utils/formatters.js';
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import * as systemInfoUtils from '../../utils/systemInfo.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('open');
|
||||
vi.mock('../../utils/version.js');
|
||||
vi.mock('../utils/formatters.js');
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...actual,
|
||||
IdeClient: {
|
||||
getInstance: () => ({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue('VSCode'),
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock('node:process', () => ({
|
||||
default: {
|
||||
platform: 'test-platform',
|
||||
version: 'v20.0.0',
|
||||
// Keep other necessary process properties if needed by other parts of the code
|
||||
env: process.env,
|
||||
memoryUsage: () => ({ rss: 0 }),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../utils/systemInfo.js');
|
||||
|
||||
describe('bugCommand', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getCliVersion).mockResolvedValue('0.1.0');
|
||||
vi.mocked(formatMemoryUsage).mockReturnValue('100 MB');
|
||||
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
|
||||
cliVersion: '0.1.0',
|
||||
osPlatform: 'test-platform',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'test',
|
||||
modelVersion: 'qwen3-coder-plus',
|
||||
selectedAuthType: '',
|
||||
ideClient: 'VSCode',
|
||||
sessionId: 'test-session-id',
|
||||
memoryUsage: '100 MB',
|
||||
gitCommit:
|
||||
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
|
||||
? GIT_COMMIT_INFO
|
||||
: undefined,
|
||||
});
|
||||
vi.stubEnv('SANDBOX', 'qwen-test');
|
||||
});
|
||||
|
||||
@@ -55,19 +48,7 @@ describe('bugCommand', () => {
|
||||
const mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getModel: () => 'qwen3-coder-plus',
|
||||
getBugCommand: () => undefined,
|
||||
getIdeMode: () => true,
|
||||
getSessionId: () => 'test-session-id',
|
||||
},
|
||||
settings: {
|
||||
merged: {
|
||||
security: {
|
||||
auth: {
|
||||
selectedType: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -75,14 +56,21 @@ describe('bugCommand', () => {
|
||||
if (!bugCommand.action) throw new Error('Action is not defined');
|
||||
await bugCommand.action(mockContext, 'A test bug');
|
||||
|
||||
const gitCommitLine =
|
||||
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
|
||||
? `* **Git Commit:** ${GIT_COMMIT_INFO}\n`
|
||||
: '';
|
||||
const expectedInfo = `
|
||||
* **CLI Version:** 0.1.0
|
||||
* **Git Commit:** ${GIT_COMMIT_INFO}
|
||||
${gitCommitLine}* **Model:** qwen3-coder-plus
|
||||
* **Sandbox:** test
|
||||
* **OS Platform:** test-platform
|
||||
* **OS Arch:** x64
|
||||
* **OS Release:** 22.0.0
|
||||
* **Node.js Version:** v20.0.0
|
||||
* **NPM Version:** 10.0.0
|
||||
* **Session ID:** test-session-id
|
||||
* **Operating System:** test-platform v20.0.0
|
||||
* **Sandbox Environment:** test
|
||||
* **Auth Type:**
|
||||
* **Model Version:** qwen3-coder-plus
|
||||
* **Auth Method:**
|
||||
* **Memory Usage:** 100 MB
|
||||
* **IDE Client:** VSCode
|
||||
`;
|
||||
@@ -99,19 +87,7 @@ describe('bugCommand', () => {
|
||||
const mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getModel: () => 'qwen3-coder-plus',
|
||||
getBugCommand: () => ({ urlTemplate: customTemplate }),
|
||||
getIdeMode: () => true,
|
||||
getSessionId: () => 'test-session-id',
|
||||
},
|
||||
settings: {
|
||||
merged: {
|
||||
security: {
|
||||
auth: {
|
||||
selectedType: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -119,14 +95,21 @@ describe('bugCommand', () => {
|
||||
if (!bugCommand.action) throw new Error('Action is not defined');
|
||||
await bugCommand.action(mockContext, 'A custom bug');
|
||||
|
||||
const gitCommitLine =
|
||||
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
|
||||
? `* **Git Commit:** ${GIT_COMMIT_INFO}\n`
|
||||
: '';
|
||||
const expectedInfo = `
|
||||
* **CLI Version:** 0.1.0
|
||||
* **Git Commit:** ${GIT_COMMIT_INFO}
|
||||
${gitCommitLine}* **Model:** qwen3-coder-plus
|
||||
* **Sandbox:** test
|
||||
* **OS Platform:** test-platform
|
||||
* **OS Arch:** x64
|
||||
* **OS Release:** 22.0.0
|
||||
* **Node.js Version:** v20.0.0
|
||||
* **NPM Version:** 10.0.0
|
||||
* **Session ID:** test-session-id
|
||||
* **Operating System:** test-platform v20.0.0
|
||||
* **Sandbox Environment:** test
|
||||
* **Auth Type:**
|
||||
* **Model Version:** qwen3-coder-plus
|
||||
* **Auth Method:**
|
||||
* **Memory Usage:** 100 MB
|
||||
* **IDE Client:** VSCode
|
||||
`;
|
||||
@@ -138,25 +121,30 @@ describe('bugCommand', () => {
|
||||
});
|
||||
|
||||
it('should include Base URL when auth type is OpenAI', async () => {
|
||||
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
|
||||
cliVersion: '0.1.0',
|
||||
osPlatform: 'test-platform',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'test',
|
||||
modelVersion: 'qwen3-coder-plus',
|
||||
selectedAuthType: AuthType.USE_OPENAI,
|
||||
ideClient: 'VSCode',
|
||||
sessionId: 'test-session-id',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
gitCommit:
|
||||
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
|
||||
? GIT_COMMIT_INFO
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getModel: () => 'qwen3-coder-plus',
|
||||
getBugCommand: () => undefined,
|
||||
getIdeMode: () => true,
|
||||
getSessionId: () => 'test-session-id',
|
||||
getContentGeneratorConfig: () => ({
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
}),
|
||||
},
|
||||
settings: {
|
||||
merged: {
|
||||
security: {
|
||||
auth: {
|
||||
selectedType: AuthType.USE_OPENAI,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -164,15 +152,22 @@ describe('bugCommand', () => {
|
||||
if (!bugCommand.action) throw new Error('Action is not defined');
|
||||
await bugCommand.action(mockContext, 'OpenAI bug');
|
||||
|
||||
const gitCommitLine =
|
||||
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
|
||||
? `* **Git Commit:** ${GIT_COMMIT_INFO}\n`
|
||||
: '';
|
||||
const expectedInfo = `
|
||||
* **CLI Version:** 0.1.0
|
||||
* **Git Commit:** ${GIT_COMMIT_INFO}
|
||||
${gitCommitLine}* **Model:** qwen3-coder-plus
|
||||
* **Sandbox:** test
|
||||
* **OS Platform:** test-platform
|
||||
* **OS Arch:** x64
|
||||
* **OS Release:** 22.0.0
|
||||
* **Node.js Version:** v20.0.0
|
||||
* **NPM Version:** 10.0.0
|
||||
* **Session ID:** test-session-id
|
||||
* **Operating System:** test-platform v20.0.0
|
||||
* **Sandbox Environment:** test
|
||||
* **Auth Type:** ${AuthType.USE_OPENAI}
|
||||
* **Auth Method:** ${AuthType.USE_OPENAI}
|
||||
* **Base URL:** https://api.openai.com/v1
|
||||
* **Model Version:** qwen3-coder-plus
|
||||
* **Memory Usage:** 100 MB
|
||||
* **IDE Client:** VSCode
|
||||
`;
|
||||
|
||||
@@ -5,17 +5,17 @@
|
||||
*/
|
||||
|
||||
import open from 'open';
|
||||
import process from 'node:process';
|
||||
import {
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
||||
import { formatMemoryUsage } from '../utils/formatters.js';
|
||||
import { getCliVersion } from '../../utils/version.js';
|
||||
import { IdeClient, AuthType } from '@qwen-code/qwen-code-core';
|
||||
import { getExtendedSystemInfo } from '../../utils/systemInfo.js';
|
||||
import {
|
||||
getSystemInfoFields,
|
||||
getFieldValue,
|
||||
} from '../../utils/systemInfoFields.js';
|
||||
|
||||
export const bugCommand: SlashCommand = {
|
||||
name: 'bug',
|
||||
@@ -23,50 +23,20 @@ export const bugCommand: SlashCommand = {
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context: CommandContext, args?: string): Promise<void> => {
|
||||
const bugDescription = (args || '').trim();
|
||||
const { config } = context.services;
|
||||
const systemInfo = await getExtendedSystemInfo(context);
|
||||
|
||||
const osVersion = `${process.platform} ${process.version}`;
|
||||
let sandboxEnv = 'no sandbox';
|
||||
if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') {
|
||||
sandboxEnv = process.env['SANDBOX'].replace(/^qwen-(?:code-)?/, '');
|
||||
} else if (process.env['SANDBOX'] === 'sandbox-exec') {
|
||||
sandboxEnv = `sandbox-exec (${
|
||||
process.env['SEATBELT_PROFILE'] || 'unknown'
|
||||
})`;
|
||||
}
|
||||
const modelVersion = config?.getModel() || 'Unknown';
|
||||
const cliVersion = await getCliVersion();
|
||||
const memoryUsage = formatMemoryUsage(process.memoryUsage().rss);
|
||||
const ideClient = await getIdeClientName(context);
|
||||
const selectedAuthType =
|
||||
context.services.settings.merged.security?.auth?.selectedType || '';
|
||||
const baseUrl =
|
||||
selectedAuthType === AuthType.USE_OPENAI
|
||||
? config?.getContentGeneratorConfig()?.baseUrl
|
||||
: undefined;
|
||||
const fields = getSystemInfoFields(systemInfo);
|
||||
|
||||
let info = `
|
||||
* **CLI Version:** ${cliVersion}
|
||||
* **Git Commit:** ${GIT_COMMIT_INFO}
|
||||
* **Session ID:** ${config?.getSessionId() || 'unknown'}
|
||||
* **Operating System:** ${osVersion}
|
||||
* **Sandbox Environment:** ${sandboxEnv}
|
||||
* **Auth Type:** ${selectedAuthType}`;
|
||||
if (baseUrl) {
|
||||
info += `\n* **Base URL:** ${baseUrl}`;
|
||||
}
|
||||
info += `
|
||||
* **Model Version:** ${modelVersion}
|
||||
* **Memory Usage:** ${memoryUsage}
|
||||
`;
|
||||
if (ideClient) {
|
||||
info += `* **IDE Client:** ${ideClient}\n`;
|
||||
// Generate bug report info using the same field configuration
|
||||
let info = '\n';
|
||||
for (const field of fields) {
|
||||
info += `* **${field.label}:** ${getFieldValue(field, systemInfo)}\n`;
|
||||
}
|
||||
|
||||
let bugReportUrl =
|
||||
'https://github.com/QwenLM/qwen-code/issues/new?template=bug_report.yml&title={title}&info={info}';
|
||||
|
||||
const bugCommandSettings = config?.getBugCommand();
|
||||
const bugCommandSettings = context.services.config?.getBugCommand();
|
||||
if (bugCommandSettings?.urlTemplate) {
|
||||
bugReportUrl = bugCommandSettings.urlTemplate;
|
||||
}
|
||||
@@ -98,11 +68,3 @@ export const bugCommand: SlashCommand = {
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
async function getIdeClientName(context: CommandContext) {
|
||||
if (!context.services.config?.getIdeMode()) {
|
||||
return '';
|
||||
}
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
return ideClient.getDetectedIdeDisplayName() ?? '';
|
||||
}
|
||||
|
||||
@@ -7,127 +7,46 @@
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
||||
import type { ExtendedSystemInfo } from '../../utils/systemInfo.js';
|
||||
import {
|
||||
getSystemInfoFields,
|
||||
getFieldValue,
|
||||
type SystemInfoField,
|
||||
} from '../../utils/systemInfoFields.js';
|
||||
|
||||
interface AboutBoxProps {
|
||||
cliVersion: string;
|
||||
osVersion: string;
|
||||
sandboxEnv: string;
|
||||
modelVersion: string;
|
||||
selectedAuthType: string;
|
||||
gcpProject: string;
|
||||
ideClient: string;
|
||||
}
|
||||
type AboutBoxProps = ExtendedSystemInfo;
|
||||
|
||||
export const AboutBox: React.FC<AboutBoxProps> = ({
|
||||
cliVersion,
|
||||
osVersion,
|
||||
sandboxEnv,
|
||||
modelVersion,
|
||||
selectedAuthType,
|
||||
gcpProject,
|
||||
ideClient,
|
||||
}) => (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
marginY={1}
|
||||
width="100%"
|
||||
>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
About Qwen Code
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={theme.text.link}>
|
||||
CLI Version
|
||||
export const AboutBox: React.FC<AboutBoxProps> = (props) => {
|
||||
const fields = getSystemInfoFields(props);
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
marginY={1}
|
||||
width="100%"
|
||||
>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
About Qwen Code
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{cliVersion}</Text>
|
||||
</Box>
|
||||
{fields.map((field: SystemInfoField) => (
|
||||
<Box key={field.key} flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={theme.text.link}>
|
||||
{field.label}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>
|
||||
{getFieldValue(field, props)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
{GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO) && (
|
||||
<Box flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={theme.text.link}>
|
||||
Git Commit
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{GIT_COMMIT_INFO}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Box flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={theme.text.link}>
|
||||
Model
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{modelVersion}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={theme.text.link}>
|
||||
Sandbox
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{sandboxEnv}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={theme.text.link}>
|
||||
OS
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{osVersion}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={theme.text.link}>
|
||||
Auth Method
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>
|
||||
{selectedAuthType.startsWith('oauth') ? 'OAuth' : selectedAuthType}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{gcpProject && (
|
||||
<Box flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={theme.text.link}>
|
||||
GCP Project
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{gcpProject}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{ideClient && (
|
||||
<Box flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={theme.text.link}>
|
||||
IDE Client
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{ideClient}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
@@ -71,15 +71,24 @@ describe('<HistoryItemDisplay />', () => {
|
||||
|
||||
it('renders AboutBox for "about" type', () => {
|
||||
const item: HistoryItem = {
|
||||
...baseItem,
|
||||
id: 1,
|
||||
type: MessageType.ABOUT,
|
||||
cliVersion: '1.0.0',
|
||||
osVersion: 'test-os',
|
||||
sandboxEnv: 'test-env',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
gcpProject: 'test-project',
|
||||
ideClient: 'test-ide',
|
||||
systemInfo: {
|
||||
cliVersion: '1.0.0',
|
||||
osPlatform: 'test-os',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'test-env',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: 'test-ide',
|
||||
sessionId: 'test-session-id',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: undefined,
|
||||
gitCommit: undefined,
|
||||
},
|
||||
};
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<HistoryItemDisplay {...baseItem} item={item} />,
|
||||
|
||||
@@ -95,15 +95,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
||||
<ErrorMessage text={itemForDisplay.text} />
|
||||
)}
|
||||
{itemForDisplay.type === 'about' && (
|
||||
<AboutBox
|
||||
cliVersion={itemForDisplay.cliVersion}
|
||||
osVersion={itemForDisplay.osVersion}
|
||||
sandboxEnv={itemForDisplay.sandboxEnv}
|
||||
modelVersion={itemForDisplay.modelVersion}
|
||||
selectedAuthType={itemForDisplay.selectedAuthType}
|
||||
gcpProject={itemForDisplay.gcpProject}
|
||||
ideClient={itemForDisplay.ideClient}
|
||||
/>
|
||||
<AboutBox {...itemForDisplay.systemInfo} />
|
||||
)}
|
||||
{itemForDisplay.type === 'help' && commands && (
|
||||
<Help commands={commands} />
|
||||
|
||||
@@ -80,6 +80,8 @@ describe('handleAtCommand', () => {
|
||||
getReadManyFilesExcludes: () => [],
|
||||
}),
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
getTruncateToolOutputThreshold: () => 2500,
|
||||
getTruncateToolOutputLines: () => 500,
|
||||
} as unknown as Config;
|
||||
|
||||
const registry = new ToolRegistry(mockConfig);
|
||||
|
||||
@@ -138,13 +138,7 @@ export const useSlashCommandProcessor = (
|
||||
if (message.type === MessageType.ABOUT) {
|
||||
historyItemContent = {
|
||||
type: 'about',
|
||||
cliVersion: message.cliVersion,
|
||||
osVersion: message.osVersion,
|
||||
sandboxEnv: message.sandboxEnv,
|
||||
modelVersion: message.modelVersion,
|
||||
selectedAuthType: message.selectedAuthType,
|
||||
gcpProject: message.gcpProject,
|
||||
ideClient: message.ideClient,
|
||||
systemInfo: message.systemInfo,
|
||||
};
|
||||
} else if (message.type === MessageType.HELP) {
|
||||
historyItemContent = {
|
||||
|
||||
@@ -120,13 +120,22 @@ export type HistoryItemWarning = HistoryItemBase & {
|
||||
|
||||
export type HistoryItemAbout = HistoryItemBase & {
|
||||
type: 'about';
|
||||
cliVersion: string;
|
||||
osVersion: string;
|
||||
sandboxEnv: string;
|
||||
modelVersion: string;
|
||||
selectedAuthType: string;
|
||||
gcpProject: string;
|
||||
ideClient: string;
|
||||
systemInfo: {
|
||||
cliVersion: string;
|
||||
osPlatform: string;
|
||||
osArch: string;
|
||||
osRelease: string;
|
||||
nodeVersion: string;
|
||||
npmVersion: string;
|
||||
sandboxEnv: string;
|
||||
modelVersion: string;
|
||||
selectedAuthType: string;
|
||||
ideClient: string;
|
||||
sessionId: string;
|
||||
memoryUsage: string;
|
||||
baseUrl?: string;
|
||||
gitCommit?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type HistoryItemHelp = HistoryItemBase & {
|
||||
@@ -288,13 +297,22 @@ export type Message =
|
||||
| {
|
||||
type: MessageType.ABOUT;
|
||||
timestamp: Date;
|
||||
cliVersion: string;
|
||||
osVersion: string;
|
||||
sandboxEnv: string;
|
||||
modelVersion: string;
|
||||
selectedAuthType: string;
|
||||
gcpProject: string;
|
||||
ideClient: string;
|
||||
systemInfo: {
|
||||
cliVersion: string;
|
||||
osPlatform: string;
|
||||
osArch: string;
|
||||
osRelease: string;
|
||||
nodeVersion: string;
|
||||
npmVersion: string;
|
||||
sandboxEnv: string;
|
||||
modelVersion: string;
|
||||
selectedAuthType: string;
|
||||
ideClient: string;
|
||||
sessionId: string;
|
||||
memoryUsage: string;
|
||||
baseUrl?: string;
|
||||
gitCommit?: string;
|
||||
};
|
||||
content?: string; // Optional content, not really used for ABOUT
|
||||
}
|
||||
| {
|
||||
|
||||
331
packages/cli/src/utils/systemInfo.test.ts
Normal file
331
packages/cli/src/utils/systemInfo.test.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import {
|
||||
getSystemInfo,
|
||||
getExtendedSystemInfo,
|
||||
getNpmVersion,
|
||||
getSandboxEnv,
|
||||
getIdeClientName,
|
||||
} from './systemInfo.js';
|
||||
import type { CommandContext } from '../ui/commands/types.js';
|
||||
import { createMockCommandContext } from '../test-utils/mockCommandContext.js';
|
||||
import * as child_process from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import { IdeClient } from '@qwen-code/qwen-code-core';
|
||||
import * as versionUtils from './version.js';
|
||||
import type { ExecSyncOptions } from 'node:child_process';
|
||||
|
||||
vi.mock('node:child_process');
|
||||
|
||||
vi.mock('node:os', () => ({
|
||||
default: {
|
||||
release: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./version.js', () => ({
|
||||
getCliVersion: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...actual,
|
||||
IdeClient: {
|
||||
getInstance: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('systemInfo', () => {
|
||||
let mockContext: CommandContext;
|
||||
const originalPlatform = process.platform;
|
||||
const originalArch = process.arch;
|
||||
const originalVersion = process.version;
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getModel: vi.fn().mockReturnValue('test-model'),
|
||||
getIdeMode: vi.fn().mockReturnValue(true),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
baseUrl: 'https://api.openai.com',
|
||||
}),
|
||||
},
|
||||
settings: {
|
||||
merged: {
|
||||
security: {
|
||||
auth: {
|
||||
selectedType: 'test-auth',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CommandContext);
|
||||
|
||||
vi.mocked(versionUtils.getCliVersion).mockResolvedValue('test-version');
|
||||
vi.mocked(child_process.execSync).mockImplementation(
|
||||
(command: string, options?: ExecSyncOptions) => {
|
||||
if (
|
||||
options &&
|
||||
typeof options === 'object' &&
|
||||
'encoding' in options &&
|
||||
options.encoding === 'utf-8'
|
||||
) {
|
||||
return '10.0.0';
|
||||
}
|
||||
return Buffer.from('10.0.0', 'utf-8');
|
||||
},
|
||||
);
|
||||
vi.mocked(os.release).mockReturnValue('22.0.0');
|
||||
process.env['GOOGLE_CLOUD_PROJECT'] = 'test-gcp-project';
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'test-os',
|
||||
});
|
||||
Object.defineProperty(process, 'arch', {
|
||||
value: 'x64',
|
||||
});
|
||||
Object.defineProperty(process, 'version', {
|
||||
value: 'v20.0.0',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
});
|
||||
Object.defineProperty(process, 'arch', {
|
||||
value: originalArch,
|
||||
});
|
||||
Object.defineProperty(process, 'version', {
|
||||
value: originalVersion,
|
||||
});
|
||||
process.env = originalEnv;
|
||||
vi.clearAllMocks();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('getNpmVersion', () => {
|
||||
it('should return npm version when available', async () => {
|
||||
vi.mocked(child_process.execSync).mockImplementation(
|
||||
(command: string, options?: ExecSyncOptions) => {
|
||||
if (
|
||||
options &&
|
||||
typeof options === 'object' &&
|
||||
'encoding' in options &&
|
||||
options.encoding === 'utf-8'
|
||||
) {
|
||||
return '10.0.0';
|
||||
}
|
||||
return Buffer.from('10.0.0', 'utf-8');
|
||||
},
|
||||
);
|
||||
const version = await getNpmVersion();
|
||||
expect(version).toBe('10.0.0');
|
||||
});
|
||||
|
||||
it('should return unknown when npm command fails', async () => {
|
||||
vi.mocked(child_process.execSync).mockImplementation(() => {
|
||||
throw new Error('npm not found');
|
||||
});
|
||||
const version = await getNpmVersion();
|
||||
expect(version).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSandboxEnv', () => {
|
||||
it('should return "no sandbox" when SANDBOX is not set', () => {
|
||||
delete process.env['SANDBOX'];
|
||||
expect(getSandboxEnv()).toBe('no sandbox');
|
||||
});
|
||||
|
||||
it('should return sandbox-exec info when SANDBOX is sandbox-exec', () => {
|
||||
process.env['SANDBOX'] = 'sandbox-exec';
|
||||
process.env['SEATBELT_PROFILE'] = 'test-profile';
|
||||
expect(getSandboxEnv()).toBe('sandbox-exec (test-profile)');
|
||||
});
|
||||
|
||||
it('should return sandbox name without prefix when stripPrefix is true', () => {
|
||||
process.env['SANDBOX'] = 'qwen-code-test-sandbox';
|
||||
expect(getSandboxEnv(true)).toBe('test-sandbox');
|
||||
});
|
||||
|
||||
it('should return sandbox name with prefix when stripPrefix is false', () => {
|
||||
process.env['SANDBOX'] = 'qwen-code-test-sandbox';
|
||||
expect(getSandboxEnv(false)).toBe('qwen-code-test-sandbox');
|
||||
});
|
||||
|
||||
it('should handle qwen- prefix removal', () => {
|
||||
process.env['SANDBOX'] = 'qwen-custom-sandbox';
|
||||
expect(getSandboxEnv(true)).toBe('custom-sandbox');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIdeClientName', () => {
|
||||
it('should return IDE client name when IDE mode is enabled', async () => {
|
||||
vi.mocked(IdeClient.getInstance).mockResolvedValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
|
||||
} as unknown as IdeClient);
|
||||
|
||||
const ideClient = await getIdeClientName(mockContext);
|
||||
expect(ideClient).toBe('test-ide');
|
||||
});
|
||||
|
||||
it('should return empty string when IDE mode is disabled', async () => {
|
||||
vi.mocked(mockContext.services.config!.getIdeMode).mockReturnValue(false);
|
||||
|
||||
const ideClient = await getIdeClientName(mockContext);
|
||||
expect(ideClient).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string when IDE client detection fails', async () => {
|
||||
vi.mocked(IdeClient.getInstance).mockRejectedValue(
|
||||
new Error('IDE client error'),
|
||||
);
|
||||
|
||||
const ideClient = await getIdeClientName(mockContext);
|
||||
expect(ideClient).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSystemInfo', () => {
|
||||
it('should collect all system information', async () => {
|
||||
// Ensure SANDBOX is not set for this test
|
||||
delete process.env['SANDBOX'];
|
||||
vi.mocked(IdeClient.getInstance).mockResolvedValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
|
||||
} as unknown as IdeClient);
|
||||
vi.mocked(child_process.execSync).mockImplementation(
|
||||
(command: string, options?: ExecSyncOptions) => {
|
||||
if (
|
||||
options &&
|
||||
typeof options === 'object' &&
|
||||
'encoding' in options &&
|
||||
options.encoding === 'utf-8'
|
||||
) {
|
||||
return '10.0.0';
|
||||
}
|
||||
return Buffer.from('10.0.0', 'utf-8');
|
||||
},
|
||||
);
|
||||
|
||||
const systemInfo = await getSystemInfo(mockContext);
|
||||
|
||||
expect(systemInfo).toEqual({
|
||||
cliVersion: 'test-version',
|
||||
osPlatform: 'test-os',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'no sandbox',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: 'test-ide',
|
||||
sessionId: 'test-session-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing config gracefully', async () => {
|
||||
mockContext.services.config = null;
|
||||
vi.mocked(IdeClient.getInstance).mockResolvedValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue(''),
|
||||
} as unknown as IdeClient);
|
||||
|
||||
const systemInfo = await getSystemInfo(mockContext);
|
||||
|
||||
expect(systemInfo.modelVersion).toBe('Unknown');
|
||||
expect(systemInfo.sessionId).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExtendedSystemInfo', () => {
|
||||
it('should include memory usage and base URL', async () => {
|
||||
vi.mocked(IdeClient.getInstance).mockResolvedValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
|
||||
} as unknown as IdeClient);
|
||||
vi.mocked(child_process.execSync).mockImplementation(
|
||||
(command: string, options?: ExecSyncOptions) => {
|
||||
if (
|
||||
options &&
|
||||
typeof options === 'object' &&
|
||||
'encoding' in options &&
|
||||
options.encoding === 'utf-8'
|
||||
) {
|
||||
return '10.0.0';
|
||||
}
|
||||
return Buffer.from('10.0.0', 'utf-8');
|
||||
},
|
||||
);
|
||||
|
||||
const { AuthType } = await import('@qwen-code/qwen-code-core');
|
||||
// Update the mock context to use OpenAI auth
|
||||
mockContext.services.settings.merged.security!.auth!.selectedType =
|
||||
AuthType.USE_OPENAI;
|
||||
|
||||
const extendedInfo = await getExtendedSystemInfo(mockContext);
|
||||
|
||||
expect(extendedInfo.memoryUsage).toBeDefined();
|
||||
expect(extendedInfo.memoryUsage).toMatch(/\d+\.\d+ (KB|MB|GB)/);
|
||||
expect(extendedInfo.baseUrl).toBe('https://api.openai.com');
|
||||
});
|
||||
|
||||
it('should use sandbox env without prefix for bug reports', async () => {
|
||||
process.env['SANDBOX'] = 'qwen-code-test-sandbox';
|
||||
vi.mocked(IdeClient.getInstance).mockResolvedValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue(''),
|
||||
} as unknown as IdeClient);
|
||||
vi.mocked(child_process.execSync).mockImplementation(
|
||||
(command: string, options?: ExecSyncOptions) => {
|
||||
if (
|
||||
options &&
|
||||
typeof options === 'object' &&
|
||||
'encoding' in options &&
|
||||
options.encoding === 'utf-8'
|
||||
) {
|
||||
return '10.0.0';
|
||||
}
|
||||
return Buffer.from('10.0.0', 'utf-8');
|
||||
},
|
||||
);
|
||||
|
||||
const extendedInfo = await getExtendedSystemInfo(mockContext);
|
||||
|
||||
expect(extendedInfo.sandboxEnv).toBe('test-sandbox');
|
||||
});
|
||||
|
||||
it('should not include base URL for non-OpenAI auth', async () => {
|
||||
vi.mocked(IdeClient.getInstance).mockResolvedValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue(''),
|
||||
} as unknown as IdeClient);
|
||||
vi.mocked(child_process.execSync).mockImplementation(
|
||||
(command: string, options?: ExecSyncOptions) => {
|
||||
if (
|
||||
options &&
|
||||
typeof options === 'object' &&
|
||||
'encoding' in options &&
|
||||
options.encoding === 'utf-8'
|
||||
) {
|
||||
return '10.0.0';
|
||||
}
|
||||
return Buffer.from('10.0.0', 'utf-8');
|
||||
},
|
||||
);
|
||||
|
||||
const extendedInfo = await getExtendedSystemInfo(mockContext);
|
||||
|
||||
expect(extendedInfo.baseUrl).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
173
packages/cli/src/utils/systemInfo.ts
Normal file
173
packages/cli/src/utils/systemInfo.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import process from 'node:process';
|
||||
import os from 'node:os';
|
||||
import { execSync } from 'node:child_process';
|
||||
import type { CommandContext } from '../ui/commands/types.js';
|
||||
import { getCliVersion } from './version.js';
|
||||
import { IdeClient, AuthType } from '@qwen-code/qwen-code-core';
|
||||
import { formatMemoryUsage } from '../ui/utils/formatters.js';
|
||||
import { GIT_COMMIT_INFO } from '../generated/git-commit.js';
|
||||
|
||||
/**
|
||||
* System information interface containing all system-related details
|
||||
* that can be collected for debugging and reporting purposes.
|
||||
*/
|
||||
export interface SystemInfo {
|
||||
cliVersion: string;
|
||||
osPlatform: string;
|
||||
osArch: string;
|
||||
osRelease: string;
|
||||
nodeVersion: string;
|
||||
npmVersion: string;
|
||||
sandboxEnv: string;
|
||||
modelVersion: string;
|
||||
selectedAuthType: string;
|
||||
ideClient: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Additional system information for bug reports
|
||||
*/
|
||||
export interface ExtendedSystemInfo extends SystemInfo {
|
||||
memoryUsage: string;
|
||||
baseUrl?: string;
|
||||
gitCommit?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the NPM version, handling cases where npm might not be available.
|
||||
* Returns 'unknown' if npm command fails or is not found.
|
||||
*/
|
||||
export async function getNpmVersion(): Promise<string> {
|
||||
try {
|
||||
return execSync('npm --version', { encoding: 'utf-8' }).trim();
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the IDE client name if IDE mode is enabled.
|
||||
* Returns empty string if IDE mode is disabled or IDE client is not detected.
|
||||
*/
|
||||
export async function getIdeClientName(
|
||||
context: CommandContext,
|
||||
): Promise<string> {
|
||||
if (!context.services.config?.getIdeMode()) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
return ideClient?.getDetectedIdeDisplayName() ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the sandbox environment information.
|
||||
* Handles different sandbox types including sandbox-exec and custom sandbox environments.
|
||||
* For bug reports, removes 'qwen-' or 'qwen-code-' prefixes from sandbox names.
|
||||
*
|
||||
* @param stripPrefix - Whether to strip 'qwen-' prefix (used for bug reports)
|
||||
*/
|
||||
export function getSandboxEnv(stripPrefix = false): string {
|
||||
const sandbox = process.env['SANDBOX'];
|
||||
|
||||
if (!sandbox || sandbox === 'sandbox-exec') {
|
||||
if (sandbox === 'sandbox-exec') {
|
||||
const profile = process.env['SEATBELT_PROFILE'] || 'unknown';
|
||||
return `sandbox-exec (${profile})`;
|
||||
}
|
||||
return 'no sandbox';
|
||||
}
|
||||
|
||||
// For bug reports, remove qwen- prefix
|
||||
if (stripPrefix) {
|
||||
return sandbox.replace(/^qwen-(?:code-)?/, '');
|
||||
}
|
||||
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects comprehensive system information for debugging and reporting.
|
||||
* This function gathers all system-related details including OS, versions,
|
||||
* sandbox environment, authentication, and session information.
|
||||
*
|
||||
* @param context - Command context containing config and settings
|
||||
* @returns Promise resolving to SystemInfo object with all collected information
|
||||
*/
|
||||
export async function getSystemInfo(
|
||||
context: CommandContext,
|
||||
): Promise<SystemInfo> {
|
||||
const osPlatform = process.platform;
|
||||
const osArch = process.arch;
|
||||
const osRelease = os.release();
|
||||
const nodeVersion = process.version;
|
||||
const npmVersion = await getNpmVersion();
|
||||
const sandboxEnv = getSandboxEnv();
|
||||
const modelVersion = context.services.config?.getModel() || 'Unknown';
|
||||
const cliVersion = await getCliVersion();
|
||||
const selectedAuthType =
|
||||
context.services.settings.merged.security?.auth?.selectedType || '';
|
||||
const ideClient = await getIdeClientName(context);
|
||||
const sessionId = context.services.config?.getSessionId() || 'unknown';
|
||||
|
||||
return {
|
||||
cliVersion,
|
||||
osPlatform,
|
||||
osArch,
|
||||
osRelease,
|
||||
nodeVersion,
|
||||
npmVersion,
|
||||
sandboxEnv,
|
||||
modelVersion,
|
||||
selectedAuthType,
|
||||
ideClient,
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects extended system information for bug reports.
|
||||
* Includes all standard system info plus memory usage and optional base URL.
|
||||
*
|
||||
* @param context - Command context containing config and settings
|
||||
* @returns Promise resolving to ExtendedSystemInfo object
|
||||
*/
|
||||
export async function getExtendedSystemInfo(
|
||||
context: CommandContext,
|
||||
): Promise<ExtendedSystemInfo> {
|
||||
const baseInfo = await getSystemInfo(context);
|
||||
const memoryUsage = formatMemoryUsage(process.memoryUsage().rss);
|
||||
|
||||
// For bug reports, use sandbox name without prefix
|
||||
const sandboxEnv = getSandboxEnv(true);
|
||||
|
||||
// Get base URL if using OpenAI auth
|
||||
const baseUrl =
|
||||
baseInfo.selectedAuthType === AuthType.USE_OPENAI
|
||||
? context.services.config?.getContentGeneratorConfig()?.baseUrl
|
||||
: undefined;
|
||||
|
||||
// Get git commit info
|
||||
const gitCommit =
|
||||
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
|
||||
? GIT_COMMIT_INFO
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...baseInfo,
|
||||
sandboxEnv,
|
||||
memoryUsage,
|
||||
baseUrl,
|
||||
gitCommit,
|
||||
};
|
||||
}
|
||||
117
packages/cli/src/utils/systemInfoFields.ts
Normal file
117
packages/cli/src/utils/systemInfoFields.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ExtendedSystemInfo } from './systemInfo.js';
|
||||
|
||||
/**
|
||||
* Field configuration for system information display
|
||||
*/
|
||||
export interface SystemInfoField {
|
||||
label: string;
|
||||
key: keyof ExtendedSystemInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified field configuration for system information display.
|
||||
* This ensures consistent labeling between /about and /bug commands.
|
||||
*/
|
||||
export function getSystemInfoFields(
|
||||
info: ExtendedSystemInfo,
|
||||
): SystemInfoField[] {
|
||||
const allFields: SystemInfoField[] = [
|
||||
{
|
||||
label: 'CLI Version',
|
||||
key: 'cliVersion',
|
||||
},
|
||||
{
|
||||
label: 'Git Commit',
|
||||
key: 'gitCommit',
|
||||
},
|
||||
{
|
||||
label: 'Model',
|
||||
key: 'modelVersion',
|
||||
},
|
||||
{
|
||||
label: 'Sandbox',
|
||||
key: 'sandboxEnv',
|
||||
},
|
||||
{
|
||||
label: 'OS Platform',
|
||||
key: 'osPlatform',
|
||||
},
|
||||
{
|
||||
label: 'OS Arch',
|
||||
key: 'osArch',
|
||||
},
|
||||
{
|
||||
label: 'OS Release',
|
||||
key: 'osRelease',
|
||||
},
|
||||
{
|
||||
label: 'Node.js Version',
|
||||
key: 'nodeVersion',
|
||||
},
|
||||
{
|
||||
label: 'NPM Version',
|
||||
key: 'npmVersion',
|
||||
},
|
||||
{
|
||||
label: 'Session ID',
|
||||
key: 'sessionId',
|
||||
},
|
||||
{
|
||||
label: 'Auth Method',
|
||||
key: 'selectedAuthType',
|
||||
},
|
||||
{
|
||||
label: 'Base URL',
|
||||
key: 'baseUrl',
|
||||
},
|
||||
{
|
||||
label: 'Memory Usage',
|
||||
key: 'memoryUsage',
|
||||
},
|
||||
{
|
||||
label: 'IDE Client',
|
||||
key: 'ideClient',
|
||||
},
|
||||
];
|
||||
|
||||
// Filter out optional fields that are not present
|
||||
return allFields.filter((field) => {
|
||||
const value = info[field.key];
|
||||
// Optional fields: only show if they exist and are non-empty
|
||||
if (
|
||||
field.key === 'baseUrl' ||
|
||||
field.key === 'gitCommit' ||
|
||||
field.key === 'ideClient'
|
||||
) {
|
||||
return Boolean(value);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value for a field from system info
|
||||
*/
|
||||
export function getFieldValue(
|
||||
field: SystemInfoField,
|
||||
info: ExtendedSystemInfo,
|
||||
): string {
|
||||
const value = info[field.key];
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Special formatting for selectedAuthType
|
||||
if (field.key === 'selectedAuthType') {
|
||||
return String(value).startsWith('oauth') ? 'OAuth' : String(value);
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.1.3",
|
||||
"version": "0.2.0",
|
||||
"description": "Qwen Code Core",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -738,13 +738,13 @@ describe('Server Config (config.ts)', () => {
|
||||
|
||||
it('should return the calculated threshold when it is smaller than the default', () => {
|
||||
const config = new Config(baseParams);
|
||||
vi.mocked(tokenLimit).mockReturnValue(32000);
|
||||
vi.mocked(tokenLimit).mockReturnValue(8000);
|
||||
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(
|
||||
1000,
|
||||
2000,
|
||||
);
|
||||
// 4 * (32000 - 1000) = 4 * 31000 = 124000
|
||||
// default is 4_000_000
|
||||
expect(config.getTruncateToolOutputThreshold()).toBe(124000);
|
||||
// 4 * (8000 - 2000) = 4 * 6000 = 24000
|
||||
// default is 25_000
|
||||
expect(config.getTruncateToolOutputThreshold()).toBe(24000);
|
||||
});
|
||||
|
||||
it('should return the default threshold when the calculated value is larger', () => {
|
||||
@@ -754,8 +754,8 @@ describe('Server Config (config.ts)', () => {
|
||||
500_000,
|
||||
);
|
||||
// 4 * (2_000_000 - 500_000) = 4 * 1_500_000 = 6_000_000
|
||||
// default is 4_000_000
|
||||
expect(config.getTruncateToolOutputThreshold()).toBe(4_000_000);
|
||||
// default is 25_000
|
||||
expect(config.getTruncateToolOutputThreshold()).toBe(25_000);
|
||||
});
|
||||
|
||||
it('should use a custom truncateToolOutputThreshold if provided', () => {
|
||||
|
||||
@@ -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
|
||||
@@ -161,7 +161,7 @@ export interface ExtensionInstallMetadata {
|
||||
autoUpdate?: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD = 4_000_000;
|
||||
export const DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD = 25_000;
|
||||
export const DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES = 1000;
|
||||
|
||||
export class MCPServerConfig {
|
||||
@@ -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;
|
||||
@@ -281,6 +288,7 @@ export interface ConfigParameters {
|
||||
eventEmitter?: EventEmitter;
|
||||
useSmartEdit?: boolean;
|
||||
output?: OutputSettings;
|
||||
skipStartupContext?: boolean;
|
||||
}
|
||||
|
||||
export class Config {
|
||||
@@ -351,7 +359,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;
|
||||
@@ -363,6 +378,7 @@ export class Config {
|
||||
private readonly extensionManagement: boolean = true;
|
||||
private readonly enablePromptCompletion: boolean = false;
|
||||
private readonly skipLoopDetection: boolean;
|
||||
private readonly skipStartupContext: boolean;
|
||||
private readonly vlmSwitchMode: string | undefined;
|
||||
private initialized: boolean = false;
|
||||
readonly storage: Storage;
|
||||
@@ -455,9 +471,10 @@ export class Config {
|
||||
this.interactive = params.interactive ?? false;
|
||||
this.trustedFolder = params.trustedFolder;
|
||||
this.skipLoopDetection = params.skipLoopDetection ?? false;
|
||||
this.skipStartupContext = params.skipStartupContext ?? 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 +929,8 @@ export class Config {
|
||||
}
|
||||
|
||||
// Web search provider configuration
|
||||
getTavilyApiKey(): string | undefined {
|
||||
return this.tavilyApiKey;
|
||||
getWebSearchConfig() {
|
||||
return this.webSearch;
|
||||
}
|
||||
|
||||
getIdeMode(): boolean {
|
||||
@@ -1027,6 +1044,10 @@ export class Config {
|
||||
return this.skipLoopDetection;
|
||||
}
|
||||
|
||||
getSkipStartupContext(): boolean {
|
||||
return this.skipStartupContext;
|
||||
}
|
||||
|
||||
getVlmSwitchMode(): string | undefined {
|
||||
return this.vlmSwitchMode;
|
||||
}
|
||||
@@ -1036,6 +1057,13 @@ export class Config {
|
||||
}
|
||||
|
||||
getTruncateToolOutputThreshold(): number {
|
||||
if (
|
||||
!this.enableToolOutputTruncation ||
|
||||
this.truncateToolOutputThreshold <= 0
|
||||
) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
return Math.min(
|
||||
// Estimate remaining context window in characters (1 token ~= 4 chars).
|
||||
4 *
|
||||
@@ -1046,6 +1074,10 @@ export class Config {
|
||||
}
|
||||
|
||||
getTruncateToolOutputLines(): number {
|
||||
if (!this.enableToolOutputTruncation || this.truncateToolOutputLines <= 0) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
return this.truncateToolOutputLines;
|
||||
}
|
||||
|
||||
@@ -1152,8 +1184,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1540,6 +1540,268 @@ describe('CoreToolScheduler request queueing', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('CoreToolScheduler Sequential Execution', () => {
|
||||
it('should execute tool calls in a batch sequentially', async () => {
|
||||
// Arrange
|
||||
let firstCallFinished = false;
|
||||
const executeFn = vi
|
||||
.fn()
|
||||
.mockImplementation(async (args: { call: number }) => {
|
||||
if (args.call === 1) {
|
||||
// First call, wait for a bit to simulate work
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
firstCallFinished = true;
|
||||
return { llmContent: 'First call done' };
|
||||
}
|
||||
if (args.call === 2) {
|
||||
// Second call, should only happen after the first is finished
|
||||
if (!firstCallFinished) {
|
||||
throw new Error(
|
||||
'Second tool call started before the first one finished!',
|
||||
);
|
||||
}
|
||||
return { llmContent: 'Second call done' };
|
||||
}
|
||||
return { llmContent: 'default' };
|
||||
});
|
||||
|
||||
const mockTool = new MockTool({ name: 'mockTool', execute: executeFn });
|
||||
const declarativeTool = mockTool;
|
||||
|
||||
const mockToolRegistry = {
|
||||
getTool: () => declarativeTool,
|
||||
getToolByName: () => declarativeTool,
|
||||
getFunctionDeclarations: () => [],
|
||||
tools: new Map(),
|
||||
discovery: {},
|
||||
registerTool: () => {},
|
||||
getToolByDisplayName: () => declarativeTool,
|
||||
getTools: () => [],
|
||||
discoverTools: async () => {},
|
||||
getAllTools: () => [],
|
||||
getToolsByServer: () => [],
|
||||
} as unknown as ToolRegistry;
|
||||
|
||||
const onAllToolCallsComplete = vi.fn();
|
||||
const onToolCallsUpdate = vi.fn();
|
||||
|
||||
const mockConfig = {
|
||||
getSessionId: () => 'test-session-id',
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
getDebugMode: () => false,
|
||||
getApprovalMode: () => ApprovalMode.YOLO, // Use YOLO to avoid confirmation prompts
|
||||
getAllowedTools: () => [],
|
||||
getContentGeneratorConfig: () => ({
|
||||
model: 'test-model',
|
||||
authType: 'oauth-personal',
|
||||
}),
|
||||
getShellExecutionConfig: () => ({
|
||||
terminalWidth: 90,
|
||||
terminalHeight: 30,
|
||||
}),
|
||||
storage: {
|
||||
getProjectTempDir: () => '/tmp',
|
||||
},
|
||||
getToolRegistry: () => mockToolRegistry,
|
||||
getTruncateToolOutputThreshold: () =>
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
getUseSmartEdit: () => false,
|
||||
getUseModelRouter: () => false,
|
||||
getGeminiClient: () => null,
|
||||
} as unknown as Config;
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
onAllToolCallsComplete,
|
||||
onToolCallsUpdate,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
onEditorClose: vi.fn(),
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
const requests = [
|
||||
{
|
||||
callId: '1',
|
||||
name: 'mockTool',
|
||||
args: { call: 1 },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
{
|
||||
callId: '2',
|
||||
name: 'mockTool',
|
||||
args: { call: 2 },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
];
|
||||
|
||||
// Act
|
||||
await scheduler.schedule(requests, abortController.signal);
|
||||
|
||||
// Assert
|
||||
await vi.waitFor(() => {
|
||||
expect(onAllToolCallsComplete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that execute was called twice
|
||||
expect(executeFn).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Check the order of calls
|
||||
const calls = executeFn.mock.calls;
|
||||
expect(calls[0][0]).toEqual({ call: 1 });
|
||||
expect(calls[1][0]).toEqual({ call: 2 });
|
||||
|
||||
// The onAllToolCallsComplete should be called once with both results
|
||||
const completedCalls = onAllToolCallsComplete.mock
|
||||
.calls[0][0] as ToolCall[];
|
||||
expect(completedCalls).toHaveLength(2);
|
||||
expect(completedCalls[0].status).toBe('success');
|
||||
expect(completedCalls[1].status).toBe('success');
|
||||
});
|
||||
|
||||
it('should cancel subsequent tools when the signal is aborted.', async () => {
|
||||
// Arrange
|
||||
const abortController = new AbortController();
|
||||
let secondCallStarted = false;
|
||||
|
||||
const executeFn = vi
|
||||
.fn()
|
||||
.mockImplementation(async (args: { call: number }) => {
|
||||
if (args.call === 1) {
|
||||
return { llmContent: 'First call done' };
|
||||
}
|
||||
if (args.call === 2) {
|
||||
secondCallStarted = true;
|
||||
// This call will be cancelled while it's "running".
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
// It should not return a value because it will be cancelled.
|
||||
return { llmContent: 'Second call should not complete' };
|
||||
}
|
||||
if (args.call === 3) {
|
||||
return { llmContent: 'Third call done' };
|
||||
}
|
||||
return { llmContent: 'default' };
|
||||
});
|
||||
|
||||
const mockTool = new MockTool({ name: 'mockTool', execute: executeFn });
|
||||
const declarativeTool = mockTool;
|
||||
|
||||
const mockToolRegistry = {
|
||||
getTool: () => declarativeTool,
|
||||
getToolByName: () => declarativeTool,
|
||||
getFunctionDeclarations: () => [],
|
||||
tools: new Map(),
|
||||
discovery: {},
|
||||
registerTool: () => {},
|
||||
getToolByDisplayName: () => declarativeTool,
|
||||
getTools: () => [],
|
||||
discoverTools: async () => {},
|
||||
getAllTools: () => [],
|
||||
getToolsByServer: () => [],
|
||||
} as unknown as ToolRegistry;
|
||||
|
||||
const onAllToolCallsComplete = vi.fn();
|
||||
const onToolCallsUpdate = vi.fn();
|
||||
|
||||
const mockConfig = {
|
||||
getSessionId: () => 'test-session-id',
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
getDebugMode: () => false,
|
||||
getApprovalMode: () => ApprovalMode.YOLO,
|
||||
getAllowedTools: () => [],
|
||||
getContentGeneratorConfig: () => ({
|
||||
model: 'test-model',
|
||||
authType: 'oauth-personal',
|
||||
}),
|
||||
getShellExecutionConfig: () => ({
|
||||
terminalWidth: 90,
|
||||
terminalHeight: 30,
|
||||
}),
|
||||
storage: {
|
||||
getProjectTempDir: () => '/tmp',
|
||||
},
|
||||
getToolRegistry: () => mockToolRegistry,
|
||||
getTruncateToolOutputThreshold: () =>
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
getUseSmartEdit: () => false,
|
||||
getUseModelRouter: () => false,
|
||||
getGeminiClient: () => null,
|
||||
} as unknown as Config;
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
onAllToolCallsComplete,
|
||||
onToolCallsUpdate,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
onEditorClose: vi.fn(),
|
||||
});
|
||||
|
||||
const requests = [
|
||||
{
|
||||
callId: '1',
|
||||
name: 'mockTool',
|
||||
args: { call: 1 },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
{
|
||||
callId: '2',
|
||||
name: 'mockTool',
|
||||
args: { call: 2 },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
{
|
||||
callId: '3',
|
||||
name: 'mockTool',
|
||||
args: { call: 3 },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
];
|
||||
|
||||
// Act
|
||||
const schedulePromise = scheduler.schedule(
|
||||
requests,
|
||||
abortController.signal,
|
||||
);
|
||||
|
||||
// Wait for the second call to start, then abort.
|
||||
await vi.waitFor(() => {
|
||||
expect(secondCallStarted).toBe(true);
|
||||
});
|
||||
abortController.abort();
|
||||
|
||||
await schedulePromise;
|
||||
|
||||
// Assert
|
||||
await vi.waitFor(() => {
|
||||
expect(onAllToolCallsComplete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that execute was called for all three tools initially
|
||||
expect(executeFn).toHaveBeenCalledTimes(3);
|
||||
expect(executeFn).toHaveBeenCalledWith({ call: 1 });
|
||||
expect(executeFn).toHaveBeenCalledWith({ call: 2 });
|
||||
expect(executeFn).toHaveBeenCalledWith({ call: 3 });
|
||||
|
||||
const completedCalls = onAllToolCallsComplete.mock
|
||||
.calls[0][0] as ToolCall[];
|
||||
expect(completedCalls).toHaveLength(3);
|
||||
|
||||
const call1 = completedCalls.find((c) => c.request.callId === '1');
|
||||
const call2 = completedCalls.find((c) => c.request.callId === '2');
|
||||
const call3 = completedCalls.find((c) => c.request.callId === '3');
|
||||
|
||||
expect(call1?.status).toBe('success');
|
||||
expect(call2?.status).toBe('cancelled');
|
||||
expect(call3?.status).toBe('cancelled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncateAndSaveToFile', () => {
|
||||
const mockWriteFile = vi.mocked(fs.writeFile);
|
||||
const THRESHOLD = 40_000;
|
||||
@@ -1719,14 +1981,14 @@ describe('truncateAndSaveToFile', () => {
|
||||
);
|
||||
|
||||
expect(result.content).toContain(
|
||||
'read_file tool with the absolute file path above',
|
||||
'Tool output was too large and has been truncated',
|
||||
);
|
||||
expect(result.content).toContain('read_file tool with offset=0, limit=100');
|
||||
expect(result.content).toContain('The full output has been saved to:');
|
||||
expect(result.content).toContain(
|
||||
'read_file tool with offset=N to skip N lines',
|
||||
'To read the complete output, use the read_file tool with the absolute file path above',
|
||||
);
|
||||
expect(result.content).toContain(
|
||||
'read_file tool with limit=M to read only M lines',
|
||||
'The truncated output below shows the beginning and end of the content',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -299,10 +299,7 @@ export async function truncateAndSaveToFile(
|
||||
return {
|
||||
content: `Tool output was too large and has been truncated.
|
||||
The full output has been saved to: ${outputFile}
|
||||
To read the complete output, use the ${ReadFileTool.Name} tool with the absolute file path above. For large files, you can use the offset and limit parameters to read specific sections:
|
||||
- ${ReadFileTool.Name} tool with offset=0, limit=100 to see the first 100 lines
|
||||
- ${ReadFileTool.Name} tool with offset=N to skip N lines from the beginning
|
||||
- ${ReadFileTool.Name} tool with limit=M to read only M lines at a time
|
||||
To read the complete output, use the ${ReadFileTool.Name} tool with the absolute file path above.
|
||||
The truncated output below shows the beginning and end of the content. The marker '... [CONTENT TRUNCATED] ...' indicates where content was removed.
|
||||
This allows you to efficiently examine different parts of the output without loading the entire file.
|
||||
Truncated part of the output:
|
||||
@@ -846,7 +843,7 @@ export class CoreToolScheduler {
|
||||
);
|
||||
}
|
||||
}
|
||||
this.attemptExecutionOfScheduledCalls(signal);
|
||||
await this.attemptExecutionOfScheduledCalls(signal);
|
||||
void this.checkAndNotifyCompletion();
|
||||
} finally {
|
||||
this.isScheduling = false;
|
||||
@@ -921,7 +918,7 @@ export class CoreToolScheduler {
|
||||
}
|
||||
this.setStatusInternal(callId, 'scheduled');
|
||||
}
|
||||
this.attemptExecutionOfScheduledCalls(signal);
|
||||
await this.attemptExecutionOfScheduledCalls(signal);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -967,7 +964,9 @@ export class CoreToolScheduler {
|
||||
});
|
||||
}
|
||||
|
||||
private attemptExecutionOfScheduledCalls(signal: AbortSignal): void {
|
||||
private async attemptExecutionOfScheduledCalls(
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
const allCallsFinalOrScheduled = this.toolCalls.every(
|
||||
(call) =>
|
||||
call.status === 'scheduled' ||
|
||||
@@ -981,8 +980,8 @@ export class CoreToolScheduler {
|
||||
(call) => call.status === 'scheduled',
|
||||
);
|
||||
|
||||
callsToExecute.forEach((toolCall) => {
|
||||
if (toolCall.status !== 'scheduled') return;
|
||||
for (const toolCall of callsToExecute) {
|
||||
if (toolCall.status !== 'scheduled') continue;
|
||||
|
||||
const scheduledCall = toolCall;
|
||||
const { callId, name: toolName } = scheduledCall.request;
|
||||
@@ -1033,107 +1032,106 @@ export class CoreToolScheduler {
|
||||
);
|
||||
}
|
||||
|
||||
promise
|
||||
.then(async (toolResult: ToolResult) => {
|
||||
if (signal.aborted) {
|
||||
this.setStatusInternal(
|
||||
callId,
|
||||
'cancelled',
|
||||
'User cancelled tool execution.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const toolResult: ToolResult = await promise;
|
||||
if (signal.aborted) {
|
||||
this.setStatusInternal(
|
||||
callId,
|
||||
'cancelled',
|
||||
'User cancelled tool execution.',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (toolResult.error === undefined) {
|
||||
let content = toolResult.llmContent;
|
||||
let outputFile: string | undefined = undefined;
|
||||
const contentLength =
|
||||
typeof content === 'string' ? content.length : undefined;
|
||||
if (
|
||||
typeof content === 'string' &&
|
||||
toolName === ShellTool.Name &&
|
||||
this.config.getEnableToolOutputTruncation() &&
|
||||
this.config.getTruncateToolOutputThreshold() > 0 &&
|
||||
this.config.getTruncateToolOutputLines() > 0
|
||||
) {
|
||||
const originalContentLength = content.length;
|
||||
const threshold = this.config.getTruncateToolOutputThreshold();
|
||||
const lines = this.config.getTruncateToolOutputLines();
|
||||
const truncatedResult = await truncateAndSaveToFile(
|
||||
content,
|
||||
callId,
|
||||
this.config.storage.getProjectTempDir(),
|
||||
threshold,
|
||||
lines,
|
||||
);
|
||||
content = truncatedResult.content;
|
||||
outputFile = truncatedResult.outputFile;
|
||||
|
||||
if (outputFile) {
|
||||
logToolOutputTruncated(
|
||||
this.config,
|
||||
new ToolOutputTruncatedEvent(
|
||||
scheduledCall.request.prompt_id,
|
||||
{
|
||||
toolName,
|
||||
originalContentLength,
|
||||
truncatedContentLength: content.length,
|
||||
threshold,
|
||||
lines,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const response = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
if (toolResult.error === undefined) {
|
||||
let content = toolResult.llmContent;
|
||||
let outputFile: string | undefined = undefined;
|
||||
const contentLength =
|
||||
typeof content === 'string' ? content.length : undefined;
|
||||
if (
|
||||
typeof content === 'string' &&
|
||||
toolName === ShellTool.Name &&
|
||||
this.config.getEnableToolOutputTruncation() &&
|
||||
this.config.getTruncateToolOutputThreshold() > 0 &&
|
||||
this.config.getTruncateToolOutputLines() > 0
|
||||
) {
|
||||
const originalContentLength = content.length;
|
||||
const threshold = this.config.getTruncateToolOutputThreshold();
|
||||
const lines = this.config.getTruncateToolOutputLines();
|
||||
const truncatedResult = await truncateAndSaveToFile(
|
||||
content,
|
||||
);
|
||||
const successResponse: ToolCallResponseInfo = {
|
||||
callId,
|
||||
responseParts: response,
|
||||
resultDisplay: toolResult.returnDisplay,
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
outputFile,
|
||||
contentLength,
|
||||
};
|
||||
this.setStatusInternal(callId, 'success', successResponse);
|
||||
} else {
|
||||
// It is a failure
|
||||
const error = new Error(toolResult.error.message);
|
||||
const errorResponse = createErrorResponse(
|
||||
this.config.storage.getProjectTempDir(),
|
||||
threshold,
|
||||
lines,
|
||||
);
|
||||
content = truncatedResult.content;
|
||||
outputFile = truncatedResult.outputFile;
|
||||
|
||||
if (outputFile) {
|
||||
logToolOutputTruncated(
|
||||
this.config,
|
||||
new ToolOutputTruncatedEvent(
|
||||
scheduledCall.request.prompt_id,
|
||||
{
|
||||
toolName,
|
||||
originalContentLength,
|
||||
truncatedContentLength: content.length,
|
||||
threshold,
|
||||
lines,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const response = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
content,
|
||||
);
|
||||
const successResponse: ToolCallResponseInfo = {
|
||||
callId,
|
||||
responseParts: response,
|
||||
resultDisplay: toolResult.returnDisplay,
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
outputFile,
|
||||
contentLength,
|
||||
};
|
||||
this.setStatusInternal(callId, 'success', successResponse);
|
||||
} else {
|
||||
// It is a failure
|
||||
const error = new Error(toolResult.error.message);
|
||||
const errorResponse = createErrorResponse(
|
||||
scheduledCall.request,
|
||||
error,
|
||||
toolResult.error.type,
|
||||
);
|
||||
this.setStatusInternal(callId, 'error', errorResponse);
|
||||
}
|
||||
} catch (executionError: unknown) {
|
||||
if (signal.aborted) {
|
||||
this.setStatusInternal(
|
||||
callId,
|
||||
'cancelled',
|
||||
'User cancelled tool execution.',
|
||||
);
|
||||
} else {
|
||||
this.setStatusInternal(
|
||||
callId,
|
||||
'error',
|
||||
createErrorResponse(
|
||||
scheduledCall.request,
|
||||
error,
|
||||
toolResult.error.type,
|
||||
);
|
||||
this.setStatusInternal(callId, 'error', errorResponse);
|
||||
}
|
||||
})
|
||||
.catch((executionError: Error) => {
|
||||
if (signal.aborted) {
|
||||
this.setStatusInternal(
|
||||
callId,
|
||||
'cancelled',
|
||||
'User cancelled tool execution.',
|
||||
);
|
||||
} else {
|
||||
this.setStatusInternal(
|
||||
callId,
|
||||
'error',
|
||||
createErrorResponse(
|
||||
scheduledCall.request,
|
||||
executionError instanceof Error
|
||||
? executionError
|
||||
: new Error(String(executionError)),
|
||||
ToolErrorType.UNHANDLED_EXCEPTION,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
executionError instanceof Error
|
||||
? executionError
|
||||
: new Error(String(executionError)),
|
||||
ToolErrorType.UNHANDLED_EXCEPTION,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,6 @@ import { setSimulate429 } from '../utils/testUtils.js';
|
||||
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
|
||||
import { AuthType } from './contentGenerator.js';
|
||||
import { type RetryOptions } from '../utils/retry.js';
|
||||
import type { ToolRegistry } from '../tools/tool-registry.js';
|
||||
import { Kind } from '../tools/tools.js';
|
||||
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
|
||||
|
||||
// Mock fs module to prevent actual file system operations during tests
|
||||
@@ -1305,259 +1303,6 @@ describe('GeminiChat', () => {
|
||||
expect(turn4.parts[0].text).toBe('second response');
|
||||
});
|
||||
|
||||
describe('stopBeforeSecondMutator', () => {
|
||||
beforeEach(() => {
|
||||
// Common setup for these tests: mock the tool registry.
|
||||
const mockToolRegistry = {
|
||||
getTool: vi.fn((toolName: string) => {
|
||||
if (toolName === 'edit') {
|
||||
return { kind: Kind.Edit };
|
||||
}
|
||||
return { kind: Kind.Other };
|
||||
}),
|
||||
} as unknown as ToolRegistry;
|
||||
vi.mocked(mockConfig.getToolRegistry).mockReturnValue(mockToolRegistry);
|
||||
});
|
||||
|
||||
it('should stop streaming before a second mutator tool call', async () => {
|
||||
const responses = [
|
||||
{
|
||||
candidates: [
|
||||
{ content: { role: 'model', parts: [{ text: 'First part. ' }] } },
|
||||
],
|
||||
},
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ functionCall: { name: 'edit', args: {} } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ functionCall: { name: 'fetch', args: {} } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// This chunk contains the second mutator and should be clipped.
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ functionCall: { name: 'edit', args: {} } },
|
||||
{ text: 'some trailing text' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// This chunk should never be reached.
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'This should not appear.' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as GenerateContentResponse[];
|
||||
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
|
||||
(async function* () {
|
||||
for (const response of responses) {
|
||||
yield response;
|
||||
}
|
||||
})(),
|
||||
);
|
||||
|
||||
const stream = await chat.sendMessageStream(
|
||||
'test-model',
|
||||
{ message: 'test message' },
|
||||
'prompt-id-mutator-test',
|
||||
);
|
||||
for await (const _ of stream) {
|
||||
// Consume the stream to trigger history recording.
|
||||
}
|
||||
|
||||
const history = chat.getHistory();
|
||||
expect(history.length).toBe(2);
|
||||
|
||||
const modelTurn = history[1]!;
|
||||
expect(modelTurn.role).toBe('model');
|
||||
expect(modelTurn?.parts?.length).toBe(3);
|
||||
expect(modelTurn?.parts![0]!.text).toBe('First part. ');
|
||||
expect(modelTurn.parts![1]!.functionCall?.name).toBe('edit');
|
||||
expect(modelTurn.parts![2]!.functionCall?.name).toBe('fetch');
|
||||
});
|
||||
|
||||
it('should not stop streaming if only one mutator is present', async () => {
|
||||
const responses = [
|
||||
{
|
||||
candidates: [
|
||||
{ content: { role: 'model', parts: [{ text: 'Part 1. ' }] } },
|
||||
],
|
||||
},
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ functionCall: { name: 'edit', args: {} } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'Part 2.' }],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as GenerateContentResponse[];
|
||||
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
|
||||
(async function* () {
|
||||
for (const response of responses) {
|
||||
yield response;
|
||||
}
|
||||
})(),
|
||||
);
|
||||
|
||||
const stream = await chat.sendMessageStream(
|
||||
'test-model',
|
||||
{ message: 'test message' },
|
||||
'prompt-id-one-mutator',
|
||||
);
|
||||
for await (const _ of stream) {
|
||||
/* consume */
|
||||
}
|
||||
|
||||
const history = chat.getHistory();
|
||||
const modelTurn = history[1]!;
|
||||
expect(modelTurn?.parts?.length).toBe(3);
|
||||
expect(modelTurn.parts![1]!.functionCall?.name).toBe('edit');
|
||||
expect(modelTurn.parts![2]!.text).toBe('Part 2.');
|
||||
});
|
||||
|
||||
it('should clip the chunk containing the second mutator, preserving prior parts', async () => {
|
||||
const responses = [
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ functionCall: { name: 'edit', args: {} } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// This chunk has a valid part before the second mutator.
|
||||
// The valid part should be kept, the rest of the chunk discarded.
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'Keep this text. ' },
|
||||
{ functionCall: { name: 'edit', args: {} } },
|
||||
{ text: 'Discard this text.' },
|
||||
],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as GenerateContentResponse[];
|
||||
|
||||
const stream = (async function* () {
|
||||
for (const response of responses) {
|
||||
yield response;
|
||||
}
|
||||
})();
|
||||
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
|
||||
stream,
|
||||
);
|
||||
|
||||
const resultStream = await chat.sendMessageStream(
|
||||
'test-model',
|
||||
{ message: 'test' },
|
||||
'prompt-id-clip-chunk',
|
||||
);
|
||||
for await (const _ of resultStream) {
|
||||
/* consume */
|
||||
}
|
||||
|
||||
const history = chat.getHistory();
|
||||
const modelTurn = history[1]!;
|
||||
expect(modelTurn?.parts?.length).toBe(2);
|
||||
expect(modelTurn.parts![0]!.functionCall?.name).toBe('edit');
|
||||
expect(modelTurn.parts![1]!.text).toBe('Keep this text. ');
|
||||
});
|
||||
|
||||
it('should handle two mutators in the same chunk (parallel call scenario)', async () => {
|
||||
const responses = [
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'Some text. ' },
|
||||
{ functionCall: { name: 'edit', args: {} } },
|
||||
{ functionCall: { name: 'edit', args: {} } },
|
||||
],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as GenerateContentResponse[];
|
||||
|
||||
const stream = (async function* () {
|
||||
for (const response of responses) {
|
||||
yield response;
|
||||
}
|
||||
})();
|
||||
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
|
||||
stream,
|
||||
);
|
||||
|
||||
const resultStream = await chat.sendMessageStream(
|
||||
'test-model',
|
||||
{ message: 'test' },
|
||||
'prompt-id-parallel-mutators',
|
||||
);
|
||||
for await (const _ of resultStream) {
|
||||
/* consume */
|
||||
}
|
||||
|
||||
const history = chat.getHistory();
|
||||
const modelTurn = history[1]!;
|
||||
expect(modelTurn?.parts?.length).toBe(2);
|
||||
expect(modelTurn.parts![0]!.text).toBe('Some text. ');
|
||||
expect(modelTurn.parts![1]!.functionCall?.name).toBe('edit');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Model Resolution', () => {
|
||||
const mockResponse = {
|
||||
candidates: [
|
||||
|
||||
@@ -7,16 +7,15 @@
|
||||
// DISCLAIMER: This is a copied version of https://github.com/googleapis/js-genai/blob/main/src/chats.ts with the intention of working around a key bug
|
||||
// where function responses are not treated as "valid" responses: https://b.corp.google.com/issues/420354090
|
||||
|
||||
import {
|
||||
import type {
|
||||
GenerateContentResponse,
|
||||
type Content,
|
||||
type GenerateContentConfig,
|
||||
type SendMessageParameters,
|
||||
type Part,
|
||||
type Tool,
|
||||
FinishReason,
|
||||
ApiError,
|
||||
Content,
|
||||
GenerateContentConfig,
|
||||
SendMessageParameters,
|
||||
Part,
|
||||
Tool,
|
||||
} from '@google/genai';
|
||||
import { ApiError } from '@google/genai';
|
||||
import { toParts } from '../code_assist/converter.js';
|
||||
import { createUserContent } from '@google/genai';
|
||||
import { retryWithBackoff } from '../utils/retry.js';
|
||||
@@ -25,7 +24,7 @@ import {
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
getEffectiveModel,
|
||||
} from '../config/models.js';
|
||||
import { hasCycleInSchema, MUTATOR_KINDS } from '../tools/tools.js';
|
||||
import { hasCycleInSchema } from '../tools/tools.js';
|
||||
import type { StructuredError } from './turn.js';
|
||||
import {
|
||||
logContentRetry,
|
||||
@@ -511,7 +510,7 @@ export class GeminiChat {
|
||||
let hasToolCall = false;
|
||||
let hasFinishReason = false;
|
||||
|
||||
for await (const chunk of this.stopBeforeSecondMutator(streamResponse)) {
|
||||
for await (const chunk of streamResponse) {
|
||||
hasFinishReason =
|
||||
chunk?.candidates?.some((candidate) => candidate.finishReason) ?? false;
|
||||
if (isValidResponse(chunk)) {
|
||||
@@ -629,64 +628,6 @@ export class GeminiChat {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncates the chunkStream right before the second function call to a
|
||||
* function that mutates state. This may involve trimming parts from a chunk
|
||||
* as well as omtting some chunks altogether.
|
||||
*
|
||||
* We do this because it improves tool call quality if the model gets
|
||||
* feedback from one mutating function call before it makes the next one.
|
||||
*/
|
||||
private async *stopBeforeSecondMutator(
|
||||
chunkStream: AsyncGenerator<GenerateContentResponse>,
|
||||
): AsyncGenerator<GenerateContentResponse> {
|
||||
let foundMutatorFunctionCall = false;
|
||||
|
||||
for await (const chunk of chunkStream) {
|
||||
const candidate = chunk.candidates?.[0];
|
||||
const content = candidate?.content;
|
||||
if (!candidate || !content?.parts) {
|
||||
yield chunk;
|
||||
continue;
|
||||
}
|
||||
|
||||
const truncatedParts: Part[] = [];
|
||||
for (const part of content.parts) {
|
||||
if (this.isMutatorFunctionCall(part)) {
|
||||
if (foundMutatorFunctionCall) {
|
||||
// This is the second mutator call.
|
||||
// Truncate and return immedaitely.
|
||||
const newChunk = new GenerateContentResponse();
|
||||
newChunk.candidates = [
|
||||
{
|
||||
...candidate,
|
||||
content: {
|
||||
...content,
|
||||
parts: truncatedParts,
|
||||
},
|
||||
finishReason: FinishReason.STOP,
|
||||
},
|
||||
];
|
||||
yield newChunk;
|
||||
return;
|
||||
}
|
||||
foundMutatorFunctionCall = true;
|
||||
}
|
||||
truncatedParts.push(part);
|
||||
}
|
||||
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
|
||||
private isMutatorFunctionCall(part: Part): boolean {
|
||||
if (!part?.functionCall?.name) {
|
||||
return false;
|
||||
}
|
||||
const tool = this.config.getToolRegistry().getTool(part.functionCall.name);
|
||||
return !!tool && MUTATOR_KINDS.includes(tool.kind);
|
||||
}
|
||||
}
|
||||
|
||||
/** Visible for Testing */
|
||||
|
||||
@@ -32,6 +32,7 @@ export class OpenAIContentGenerator implements ContentGenerator {
|
||||
telemetryService: new DefaultTelemetryService(
|
||||
cliConfig,
|
||||
contentGeneratorConfig.enableOpenAILogging,
|
||||
contentGeneratorConfig.openAILoggingDir,
|
||||
),
|
||||
errorHandler: new EnhancedErrorHandler(
|
||||
(error: unknown, request: GenerateContentParameters) =>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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']],
|
||||
|
||||
@@ -60,7 +60,10 @@ function verifyVSCode(
|
||||
if (ide.name !== IDE_DEFINITIONS.vscode.name) {
|
||||
return ide;
|
||||
}
|
||||
if (ideProcessInfo.command.toLowerCase().includes('code')) {
|
||||
if (
|
||||
ideProcessInfo.command &&
|
||||
ideProcessInfo.command.toLowerCase().includes('code')
|
||||
) {
|
||||
return IDE_DEFINITIONS.vscode;
|
||||
}
|
||||
return IDE_DEFINITIONS.vscodefork;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -181,6 +181,56 @@ describe('ChatCompressionService', () => {
|
||||
expect(result.newHistory).toBeNull();
|
||||
});
|
||||
|
||||
it('should return NOOP when contextPercentageThreshold is 0', async () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'msg1' }] },
|
||||
{ role: 'model', parts: [{ text: 'msg2' }] },
|
||||
];
|
||||
vi.mocked(mockChat.getHistory).mockReturnValue(history);
|
||||
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(800);
|
||||
vi.mocked(mockConfig.getChatCompression).mockReturnValue({
|
||||
contextPercentageThreshold: 0,
|
||||
});
|
||||
|
||||
const mockGenerateContent = vi.fn();
|
||||
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
|
||||
generateContent: mockGenerateContent,
|
||||
} as unknown as ContentGenerator);
|
||||
|
||||
const result = await service.compress(
|
||||
mockChat,
|
||||
mockPromptId,
|
||||
false,
|
||||
mockModel,
|
||||
mockConfig,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.info).toMatchObject({
|
||||
compressionStatus: CompressionStatus.NOOP,
|
||||
originalTokenCount: 0,
|
||||
newTokenCount: 0,
|
||||
});
|
||||
expect(mockGenerateContent).not.toHaveBeenCalled();
|
||||
expect(tokenLimit).not.toHaveBeenCalled();
|
||||
|
||||
const forcedResult = await service.compress(
|
||||
mockChat,
|
||||
mockPromptId,
|
||||
true,
|
||||
mockModel,
|
||||
mockConfig,
|
||||
false,
|
||||
);
|
||||
expect(forcedResult.info).toMatchObject({
|
||||
compressionStatus: CompressionStatus.NOOP,
|
||||
originalTokenCount: 0,
|
||||
newTokenCount: 0,
|
||||
});
|
||||
expect(mockGenerateContent).not.toHaveBeenCalled();
|
||||
expect(tokenLimit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should compress if over token threshold', async () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'msg1' }] },
|
||||
|
||||
@@ -86,10 +86,14 @@ export class ChatCompressionService {
|
||||
hasFailedCompressionAttempt: boolean,
|
||||
): Promise<{ newHistory: Content[] | null; info: ChatCompressionInfo }> {
|
||||
const curatedHistory = chat.getHistory(true);
|
||||
const threshold =
|
||||
config.getChatCompression()?.contextPercentageThreshold ??
|
||||
COMPRESSION_TOKEN_THRESHOLD;
|
||||
|
||||
// Regardless of `force`, don't do anything if the history is empty.
|
||||
if (
|
||||
curatedHistory.length === 0 ||
|
||||
threshold <= 0 ||
|
||||
(hasFailedCompressionAttempt && !force)
|
||||
) {
|
||||
return {
|
||||
@@ -104,13 +108,8 @@ export class ChatCompressionService {
|
||||
|
||||
const originalTokenCount = uiTelemetryService.getLastPromptTokenCount();
|
||||
|
||||
const contextPercentageThreshold =
|
||||
config.getChatCompression()?.contextPercentageThreshold;
|
||||
|
||||
// Don't compress if not forced and we are under the limit.
|
||||
if (!force) {
|
||||
const threshold =
|
||||
contextPercentageThreshold ?? COMPRESSION_TOKEN_THRESHOLD;
|
||||
if (originalTokenCount < threshold * tokenLimit(model)) {
|
||||
return {
|
||||
newHistory: null,
|
||||
|
||||
@@ -37,6 +37,7 @@ describe('GlobTool', () => {
|
||||
getFileExclusions: () => ({
|
||||
getGlobExcludes: () => [],
|
||||
}),
|
||||
getTruncateToolOutputLines: () => 1000,
|
||||
} as unknown as Config;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -88,17 +89,6 @@ describe('GlobTool', () => {
|
||||
expect(result.returnDisplay).toBe('Found 2 matching file(s)');
|
||||
});
|
||||
|
||||
it('should find files case-sensitively when case_sensitive is true', async () => {
|
||||
const params: GlobToolParams = { pattern: '*.txt', case_sensitive: true };
|
||||
const invocation = globTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
expect(result.llmContent).toContain('Found 1 file(s)');
|
||||
expect(result.llmContent).toContain(path.join(tempRootDir, 'fileA.txt'));
|
||||
expect(result.llmContent).not.toContain(
|
||||
path.join(tempRootDir, 'FileB.TXT'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should find files case-insensitively by default (pattern: *.TXT)', async () => {
|
||||
const params: GlobToolParams = { pattern: '*.TXT' };
|
||||
const invocation = globTool.build(params);
|
||||
@@ -108,18 +98,6 @@ describe('GlobTool', () => {
|
||||
expect(result.llmContent).toContain(path.join(tempRootDir, 'FileB.TXT'));
|
||||
});
|
||||
|
||||
it('should find files case-insensitively when case_sensitive is false (pattern: *.TXT)', async () => {
|
||||
const params: GlobToolParams = {
|
||||
pattern: '*.TXT',
|
||||
case_sensitive: false,
|
||||
};
|
||||
const invocation = globTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
expect(result.llmContent).toContain('Found 2 file(s)');
|
||||
expect(result.llmContent).toContain(path.join(tempRootDir, 'fileA.txt'));
|
||||
expect(result.llmContent).toContain(path.join(tempRootDir, 'FileB.TXT'));
|
||||
});
|
||||
|
||||
it('should find files using a pattern that includes a subdirectory', async () => {
|
||||
const params: GlobToolParams = { pattern: 'sub/*.md' };
|
||||
const invocation = globTool.build(params);
|
||||
@@ -207,7 +185,7 @@ describe('GlobTool', () => {
|
||||
const filesListed = llmContent
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.slice(1)
|
||||
.slice(2)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
@@ -220,14 +198,13 @@ describe('GlobTool', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should return a PATH_NOT_IN_WORKSPACE error if path is outside workspace', async () => {
|
||||
it('should return error if path is outside workspace', async () => {
|
||||
// Bypassing validation to test execute method directly
|
||||
vi.spyOn(globTool, 'validateToolParams').mockReturnValue(null);
|
||||
const params: GlobToolParams = { pattern: '*.txt', path: '/etc' };
|
||||
const invocation = globTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
expect(result.error?.type).toBe(ToolErrorType.PATH_NOT_IN_WORKSPACE);
|
||||
expect(result.returnDisplay).toBe('Path is not within workspace');
|
||||
expect(result.returnDisplay).toBe('Error: Path is not within workspace');
|
||||
});
|
||||
|
||||
it('should return a GLOB_EXECUTION_ERROR on glob failure', async () => {
|
||||
@@ -255,15 +232,6 @@ describe('GlobTool', () => {
|
||||
expect(globTool.validateToolParams(params)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for valid parameters (pattern, path, and case_sensitive)', () => {
|
||||
const params: GlobToolParams = {
|
||||
pattern: '*.js',
|
||||
path: 'sub',
|
||||
case_sensitive: true,
|
||||
};
|
||||
expect(globTool.validateToolParams(params)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error if pattern is missing (schema validation)', () => {
|
||||
// Need to correctly define this as an object without pattern
|
||||
const params = { path: '.' };
|
||||
@@ -297,16 +265,6 @@ describe('GlobTool', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error if case_sensitive is provided but is not a boolean', () => {
|
||||
const params = {
|
||||
pattern: '*.ts',
|
||||
case_sensitive: 'true',
|
||||
} as unknown as GlobToolParams; // Force incorrect type
|
||||
expect(globTool.validateToolParams(params)).toBe(
|
||||
'params/case_sensitive must be boolean',
|
||||
);
|
||||
});
|
||||
|
||||
it("should return error if search path resolves outside the tool's root directory", () => {
|
||||
// Create a globTool instance specifically for this test, with a deeper root
|
||||
tempRootDir = path.join(tempRootDir, 'sub');
|
||||
@@ -319,7 +277,7 @@ describe('GlobTool', () => {
|
||||
path: '../../../../../../../../../../tmp', // Definitely outside
|
||||
};
|
||||
expect(specificGlobTool.validateToolParams(paramsOutside)).toContain(
|
||||
'resolves outside the allowed workspace directories',
|
||||
'Path is not within workspace',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -329,14 +287,14 @@ describe('GlobTool', () => {
|
||||
path: 'nonexistent_subdir',
|
||||
};
|
||||
expect(globTool.validateToolParams(params)).toContain(
|
||||
'Search path does not exist',
|
||||
'Path does not exist',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error if specified search path is a file, not a directory', async () => {
|
||||
const params: GlobToolParams = { pattern: '*.txt', path: 'fileA.txt' };
|
||||
expect(globTool.validateToolParams(params)).toContain(
|
||||
'Search path is not a directory',
|
||||
'Path is not a directory',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -348,20 +306,10 @@ describe('GlobTool', () => {
|
||||
|
||||
expect(globTool.validateToolParams(validPath)).toBeNull();
|
||||
expect(globTool.validateToolParams(invalidPath)).toContain(
|
||||
'resolves outside the allowed workspace directories',
|
||||
'Path is not within workspace',
|
||||
);
|
||||
});
|
||||
|
||||
it('should provide clear error messages when path is outside workspace', () => {
|
||||
const invalidPath = { pattern: '*.ts', path: '/etc' };
|
||||
const error = globTool.validateToolParams(invalidPath);
|
||||
|
||||
expect(error).toContain(
|
||||
'resolves outside the allowed workspace directories',
|
||||
);
|
||||
expect(error).toContain(tempRootDir);
|
||||
});
|
||||
|
||||
it('should work with paths in workspace subdirectories', async () => {
|
||||
const params: GlobToolParams = { pattern: '*.md', path: 'sub' };
|
||||
const invocation = globTool.build(params);
|
||||
@@ -417,47 +365,123 @@ describe('GlobTool', () => {
|
||||
expect(result.llmContent).toContain('Found 3 file(s)'); // fileA.txt, FileB.TXT, b.notignored.txt
|
||||
expect(result.llmContent).not.toContain('a.qwenignored.txt');
|
||||
});
|
||||
});
|
||||
|
||||
it('should not respect .gitignore when respect_git_ignore is false', async () => {
|
||||
await fs.writeFile(path.join(tempRootDir, '.gitignore'), '*.ignored.txt');
|
||||
await fs.writeFile(
|
||||
path.join(tempRootDir, 'a.ignored.txt'),
|
||||
'ignored content',
|
||||
);
|
||||
describe('file count truncation', () => {
|
||||
it('should truncate results when more than 100 files are found', async () => {
|
||||
// Create 150 test files
|
||||
for (let i = 1; i <= 150; i++) {
|
||||
await fs.writeFile(
|
||||
path.join(tempRootDir, `file${i}.trunctest`),
|
||||
`content${i}`,
|
||||
);
|
||||
}
|
||||
|
||||
const params: GlobToolParams = {
|
||||
pattern: '*.txt',
|
||||
respect_git_ignore: false,
|
||||
};
|
||||
const params: GlobToolParams = { pattern: '*.trunctest' };
|
||||
const invocation = globTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
const llmContent = partListUnionToString(result.llmContent);
|
||||
|
||||
expect(result.llmContent).toContain('Found 3 file(s)'); // fileA.txt, FileB.TXT, a.ignored.txt
|
||||
expect(result.llmContent).toContain('a.ignored.txt');
|
||||
// Should report all 150 files found
|
||||
expect(llmContent).toContain('Found 150 file(s)');
|
||||
|
||||
// Should include truncation notice
|
||||
expect(llmContent).toContain('[50 files truncated] ...');
|
||||
|
||||
// Count the number of .trunctest files mentioned in the output
|
||||
const fileMatches = llmContent.match(/file\d+\.trunctest/g);
|
||||
expect(fileMatches).toBeDefined();
|
||||
expect(fileMatches?.length).toBe(100);
|
||||
|
||||
// returnDisplay should indicate truncation
|
||||
expect(result.returnDisplay).toBe(
|
||||
'Found 150 matching file(s) (truncated)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not respect .qwenignore when respect_qwen_ignore is false', async () => {
|
||||
await fs.writeFile(
|
||||
path.join(tempRootDir, '.qwenignore'),
|
||||
'*.qwenignored.txt',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(tempRootDir, 'a.qwenignored.txt'),
|
||||
'ignored content',
|
||||
);
|
||||
it('should not truncate when exactly 100 files are found', async () => {
|
||||
// Create exactly 100 test files
|
||||
for (let i = 1; i <= 100; i++) {
|
||||
await fs.writeFile(
|
||||
path.join(tempRootDir, `exact${i}.trunctest`),
|
||||
`content${i}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Recreate the tool to pick up the new .qwenignore file
|
||||
globTool = new GlobTool(mockConfig);
|
||||
|
||||
const params: GlobToolParams = {
|
||||
pattern: '*.txt',
|
||||
respect_qwen_ignore: false,
|
||||
};
|
||||
const params: GlobToolParams = { pattern: '*.trunctest' };
|
||||
const invocation = globTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
expect(result.llmContent).toContain('Found 3 file(s)'); // fileA.txt, FileB.TXT, a.qwenignored.txt
|
||||
expect(result.llmContent).toContain('a.qwenignored.txt');
|
||||
// Should report all 100 files found
|
||||
expect(result.llmContent).toContain('Found 100 file(s)');
|
||||
|
||||
// Should NOT include truncation notice
|
||||
expect(result.llmContent).not.toContain('truncated');
|
||||
|
||||
// Should show all 100 files
|
||||
expect(result.llmContent).toContain('exact1.trunctest');
|
||||
expect(result.llmContent).toContain('exact100.trunctest');
|
||||
|
||||
// returnDisplay should NOT indicate truncation
|
||||
expect(result.returnDisplay).toBe('Found 100 matching file(s)');
|
||||
});
|
||||
|
||||
it('should not truncate when fewer than 100 files are found', async () => {
|
||||
// Create 50 test files
|
||||
for (let i = 1; i <= 50; i++) {
|
||||
await fs.writeFile(
|
||||
path.join(tempRootDir, `small${i}.trunctest`),
|
||||
`content${i}`,
|
||||
);
|
||||
}
|
||||
|
||||
const params: GlobToolParams = { pattern: '*.trunctest' };
|
||||
const invocation = globTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
// Should report all 50 files found
|
||||
expect(result.llmContent).toContain('Found 50 file(s)');
|
||||
|
||||
// Should NOT include truncation notice
|
||||
expect(result.llmContent).not.toContain('truncated');
|
||||
|
||||
// returnDisplay should NOT indicate truncation
|
||||
expect(result.returnDisplay).toBe('Found 50 matching file(s)');
|
||||
});
|
||||
|
||||
it('should use correct singular/plural in truncation message for 1 file truncated', async () => {
|
||||
// Create 101 test files (will truncate 1 file)
|
||||
for (let i = 1; i <= 101; i++) {
|
||||
await fs.writeFile(
|
||||
path.join(tempRootDir, `singular${i}.trunctest`),
|
||||
`content${i}`,
|
||||
);
|
||||
}
|
||||
|
||||
const params: GlobToolParams = { pattern: '*.trunctest' };
|
||||
const invocation = globTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
// Should use singular "file" for 1 truncated file
|
||||
expect(result.llmContent).toContain('[1 file truncated] ...');
|
||||
expect(result.llmContent).not.toContain('[1 files truncated]');
|
||||
});
|
||||
|
||||
it('should use correct plural in truncation message for multiple files truncated', async () => {
|
||||
// Create 105 test files (will truncate 5 files)
|
||||
for (let i = 1; i <= 105; i++) {
|
||||
await fs.writeFile(
|
||||
path.join(tempRootDir, `plural${i}.trunctest`),
|
||||
`content${i}`,
|
||||
);
|
||||
}
|
||||
|
||||
const params: GlobToolParams = { pattern: '*.trunctest' };
|
||||
const invocation = globTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
// Should use plural "files" for multiple truncated files
|
||||
expect(result.llmContent).toContain('[5 files truncated] ...');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,10 +10,17 @@ import { glob, escape } from 'glob';
|
||||
import type { ToolInvocation, ToolResult } from './tools.js';
|
||||
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
|
||||
import { ToolNames } from './tool-names.js';
|
||||
import { shortenPath, makeRelative } from '../utils/paths.js';
|
||||
import { resolveAndValidatePath } from '../utils/paths.js';
|
||||
import { type Config } from '../config/config.js';
|
||||
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
|
||||
import {
|
||||
DEFAULT_FILE_FILTERING_OPTIONS,
|
||||
type FileFilteringOptions,
|
||||
} from '../config/constants.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
import type { FileDiscoveryService } from '../services/fileDiscoveryService.js';
|
||||
|
||||
const MAX_FILE_COUNT = 100;
|
||||
|
||||
// Subset of 'Path' interface provided by 'glob' that we can implement for testing
|
||||
export interface GlobPath {
|
||||
@@ -64,118 +71,68 @@ export interface GlobToolParams {
|
||||
* The directory to search in (optional, defaults to current directory)
|
||||
*/
|
||||
path?: string;
|
||||
|
||||
/**
|
||||
* Whether the search should be case-sensitive (optional, defaults to false)
|
||||
*/
|
||||
case_sensitive?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to respect .gitignore patterns (optional, defaults to true)
|
||||
*/
|
||||
respect_git_ignore?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to respect .qwenignore patterns (optional, defaults to true)
|
||||
*/
|
||||
respect_qwen_ignore?: boolean;
|
||||
}
|
||||
|
||||
class GlobToolInvocation extends BaseToolInvocation<
|
||||
GlobToolParams,
|
||||
ToolResult
|
||||
> {
|
||||
private fileService: FileDiscoveryService;
|
||||
|
||||
constructor(
|
||||
private config: Config,
|
||||
params: GlobToolParams,
|
||||
) {
|
||||
super(params);
|
||||
this.fileService = config.getFileService();
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
let description = `'${this.params.pattern}'`;
|
||||
if (this.params.path) {
|
||||
const searchDir = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
this.params.path || '.',
|
||||
);
|
||||
const relativePath = makeRelative(searchDir, this.config.getTargetDir());
|
||||
description += ` within ${shortenPath(relativePath)}`;
|
||||
description += ` in path '${this.params.path}'`;
|
||||
}
|
||||
|
||||
return description;
|
||||
}
|
||||
|
||||
async execute(signal: AbortSignal): Promise<ToolResult> {
|
||||
try {
|
||||
const workspaceContext = this.config.getWorkspaceContext();
|
||||
const workspaceDirectories = workspaceContext.getDirectories();
|
||||
// Default to target directory if no path is provided
|
||||
const searchDirAbs = resolveAndValidatePath(
|
||||
this.config,
|
||||
this.params.path,
|
||||
);
|
||||
const searchLocationDescription = this.params.path
|
||||
? `within ${searchDirAbs}`
|
||||
: `in the workspace directory`;
|
||||
|
||||
// If a specific path is provided, resolve it and check if it's within workspace
|
||||
let searchDirectories: readonly string[];
|
||||
if (this.params.path) {
|
||||
const searchDirAbsolute = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
this.params.path,
|
||||
);
|
||||
if (!workspaceContext.isPathWithinWorkspace(searchDirAbsolute)) {
|
||||
const rawError = `Error: Path "${this.params.path}" is not within any workspace directory`;
|
||||
return {
|
||||
llmContent: rawError,
|
||||
returnDisplay: `Path is not within workspace`,
|
||||
error: {
|
||||
message: rawError,
|
||||
type: ToolErrorType.PATH_NOT_IN_WORKSPACE,
|
||||
},
|
||||
};
|
||||
}
|
||||
searchDirectories = [searchDirAbsolute];
|
||||
} else {
|
||||
// Search across all workspace directories
|
||||
searchDirectories = workspaceDirectories;
|
||||
// Collect entries from the search directory
|
||||
let pattern = this.params.pattern;
|
||||
const fullPath = path.join(searchDirAbs, pattern);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
pattern = escape(pattern);
|
||||
}
|
||||
|
||||
// Get centralized file discovery service
|
||||
const fileDiscovery = this.config.getFileService();
|
||||
|
||||
// Collect entries from all search directories
|
||||
const allEntries: GlobPath[] = [];
|
||||
for (const searchDir of searchDirectories) {
|
||||
let pattern = this.params.pattern;
|
||||
const fullPath = path.join(searchDir, pattern);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
pattern = escape(pattern);
|
||||
}
|
||||
|
||||
const entries = (await glob(pattern, {
|
||||
cwd: searchDir,
|
||||
withFileTypes: true,
|
||||
nodir: true,
|
||||
stat: true,
|
||||
nocase: !this.params.case_sensitive,
|
||||
dot: true,
|
||||
ignore: this.config.getFileExclusions().getGlobExcludes(),
|
||||
follow: false,
|
||||
signal,
|
||||
})) as GlobPath[];
|
||||
|
||||
allEntries.push(...entries);
|
||||
}
|
||||
const allEntries = (await glob(pattern, {
|
||||
cwd: searchDirAbs,
|
||||
withFileTypes: true,
|
||||
nodir: true,
|
||||
stat: true,
|
||||
nocase: true,
|
||||
dot: true,
|
||||
follow: false,
|
||||
signal,
|
||||
})) as GlobPath[];
|
||||
|
||||
const relativePaths = allEntries.map((p) =>
|
||||
path.relative(this.config.getTargetDir(), p.fullpath()),
|
||||
);
|
||||
|
||||
const { filteredPaths, gitIgnoredCount, qwenIgnoredCount } =
|
||||
fileDiscovery.filterFilesWithReport(relativePaths, {
|
||||
respectGitIgnore:
|
||||
this.params?.respect_git_ignore ??
|
||||
this.config.getFileFilteringOptions().respectGitIgnore ??
|
||||
DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore,
|
||||
respectQwenIgnore:
|
||||
this.params?.respect_qwen_ignore ??
|
||||
this.config.getFileFilteringOptions().respectQwenIgnore ??
|
||||
DEFAULT_FILE_FILTERING_OPTIONS.respectQwenIgnore,
|
||||
});
|
||||
const { filteredPaths } = this.fileService.filterFilesWithReport(
|
||||
relativePaths,
|
||||
this.getFileFilteringOptions(),
|
||||
);
|
||||
|
||||
const filteredAbsolutePaths = new Set(
|
||||
filteredPaths.map((p) => path.resolve(this.config.getTargetDir(), p)),
|
||||
@@ -186,20 +143,8 @@ class GlobToolInvocation extends BaseToolInvocation<
|
||||
);
|
||||
|
||||
if (!filteredEntries || filteredEntries.length === 0) {
|
||||
let message = `No files found matching pattern "${this.params.pattern}"`;
|
||||
if (searchDirectories.length === 1) {
|
||||
message += ` within ${searchDirectories[0]}`;
|
||||
} else {
|
||||
message += ` within ${searchDirectories.length} workspace directories`;
|
||||
}
|
||||
if (gitIgnoredCount > 0) {
|
||||
message += ` (${gitIgnoredCount} files were git-ignored)`;
|
||||
}
|
||||
if (qwenIgnoredCount > 0) {
|
||||
message += ` (${qwenIgnoredCount} files were qwen-ignored)`;
|
||||
}
|
||||
return {
|
||||
llmContent: message,
|
||||
llmContent: `No files found matching pattern "${this.params.pattern}" ${searchLocationDescription}`,
|
||||
returnDisplay: `No files found`,
|
||||
};
|
||||
}
|
||||
@@ -215,29 +160,36 @@ class GlobToolInvocation extends BaseToolInvocation<
|
||||
oneDayInMs,
|
||||
);
|
||||
|
||||
const sortedAbsolutePaths = sortedEntries.map((entry) =>
|
||||
const totalFileCount = sortedEntries.length;
|
||||
const fileLimit = Math.min(
|
||||
MAX_FILE_COUNT,
|
||||
this.config.getTruncateToolOutputLines(),
|
||||
);
|
||||
const truncated = totalFileCount > fileLimit;
|
||||
|
||||
// Limit to fileLimit if needed
|
||||
const entriesToShow = truncated
|
||||
? sortedEntries.slice(0, fileLimit)
|
||||
: sortedEntries;
|
||||
|
||||
const sortedAbsolutePaths = entriesToShow.map((entry) =>
|
||||
entry.fullpath(),
|
||||
);
|
||||
const fileListDescription = sortedAbsolutePaths.join('\n');
|
||||
const fileCount = sortedAbsolutePaths.length;
|
||||
|
||||
let resultMessage = `Found ${fileCount} file(s) matching "${this.params.pattern}"`;
|
||||
if (searchDirectories.length === 1) {
|
||||
resultMessage += ` within ${searchDirectories[0]}`;
|
||||
} else {
|
||||
resultMessage += ` across ${searchDirectories.length} workspace directories`;
|
||||
let resultMessage = `Found ${totalFileCount} file(s) matching "${this.params.pattern}" ${searchLocationDescription}`;
|
||||
resultMessage += `, sorted by modification time (newest first):\n---\n${fileListDescription}`;
|
||||
|
||||
// Add truncation notice if needed
|
||||
if (truncated) {
|
||||
const omittedFiles = totalFileCount - fileLimit;
|
||||
const fileTerm = omittedFiles === 1 ? 'file' : 'files';
|
||||
resultMessage += `\n---\n[${omittedFiles} ${fileTerm} truncated] ...`;
|
||||
}
|
||||
if (gitIgnoredCount > 0) {
|
||||
resultMessage += ` (${gitIgnoredCount} additional files were git-ignored)`;
|
||||
}
|
||||
if (qwenIgnoredCount > 0) {
|
||||
resultMessage += ` (${qwenIgnoredCount} additional files were qwen-ignored)`;
|
||||
}
|
||||
resultMessage += `, sorted by modification time (newest first):\n${fileListDescription}`;
|
||||
|
||||
return {
|
||||
llmContent: resultMessage,
|
||||
returnDisplay: `Found ${fileCount} matching file(s)`,
|
||||
returnDisplay: `Found ${totalFileCount} matching file(s)${truncated ? ' (truncated)' : ''}`,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
@@ -246,7 +198,7 @@ class GlobToolInvocation extends BaseToolInvocation<
|
||||
const rawError = `Error during glob search operation: ${errorMessage}`;
|
||||
return {
|
||||
llmContent: rawError,
|
||||
returnDisplay: `Error: An unexpected error occurred.`,
|
||||
returnDisplay: `Error: ${errorMessage || 'An unexpected error occurred.'}`,
|
||||
error: {
|
||||
message: rawError,
|
||||
type: ToolErrorType.GLOB_EXECUTION_ERROR,
|
||||
@@ -254,6 +206,18 @@ class GlobToolInvocation extends BaseToolInvocation<
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private getFileFilteringOptions(): FileFilteringOptions {
|
||||
const options = this.config.getFileFilteringOptions?.();
|
||||
return {
|
||||
respectGitIgnore:
|
||||
options?.respectGitIgnore ??
|
||||
DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore,
|
||||
respectQwenIgnore:
|
||||
options?.respectQwenIgnore ??
|
||||
DEFAULT_FILE_FILTERING_OPTIONS.respectQwenIgnore,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -266,35 +230,19 @@ export class GlobTool extends BaseDeclarativeTool<GlobToolParams, ToolResult> {
|
||||
super(
|
||||
GlobTool.Name,
|
||||
'FindFiles',
|
||||
'Efficiently finds files matching specific glob patterns (e.g., `src/**/*.ts`, `**/*.md`), returning absolute paths sorted by modification time (newest first). Ideal for quickly locating files based on their name or path structure, especially in large codebases.',
|
||||
'Fast file pattern matching tool that works with any codebase size\n- Supports glob patterns like "**/*.js" or "src/**/*.ts"\n- Returns matching file paths sorted by modification time\n- Use this tool when you need to find files by name patterns\n- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead\n- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.',
|
||||
Kind.Search,
|
||||
{
|
||||
properties: {
|
||||
pattern: {
|
||||
description:
|
||||
"The glob pattern to match against (e.g., '**/*.py', 'docs/*.md').",
|
||||
description: 'The glob pattern to match files against',
|
||||
type: 'string',
|
||||
},
|
||||
path: {
|
||||
description:
|
||||
'Optional: The absolute path to the directory to search within. If omitted, searches the root directory.',
|
||||
'The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.',
|
||||
type: 'string',
|
||||
},
|
||||
case_sensitive: {
|
||||
description:
|
||||
'Optional: Whether the search should be case-sensitive. Defaults to false.',
|
||||
type: 'boolean',
|
||||
},
|
||||
respect_git_ignore: {
|
||||
description:
|
||||
'Optional: Whether to respect .gitignore patterns when finding files. Only available in git repositories. Defaults to true.',
|
||||
type: 'boolean',
|
||||
},
|
||||
respect_qwen_ignore: {
|
||||
description:
|
||||
'Optional: Whether to respect .qwenignore patterns when finding files. Defaults to true.',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
required: ['pattern'],
|
||||
type: 'object',
|
||||
@@ -308,29 +256,6 @@ export class GlobTool extends BaseDeclarativeTool<GlobToolParams, ToolResult> {
|
||||
protected override validateToolParamValues(
|
||||
params: GlobToolParams,
|
||||
): string | null {
|
||||
const searchDirAbsolute = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
params.path || '.',
|
||||
);
|
||||
|
||||
const workspaceContext = this.config.getWorkspaceContext();
|
||||
if (!workspaceContext.isPathWithinWorkspace(searchDirAbsolute)) {
|
||||
const directories = workspaceContext.getDirectories();
|
||||
return `Search path ("${searchDirAbsolute}") resolves outside the allowed workspace directories: ${directories.join(', ')}`;
|
||||
}
|
||||
|
||||
const targetDir = searchDirAbsolute || this.config.getTargetDir();
|
||||
try {
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
return `Search path does not exist ${targetDir}`;
|
||||
}
|
||||
if (!fs.statSync(targetDir).isDirectory()) {
|
||||
return `Search path is not a directory: ${targetDir}`;
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
return `Error accessing search path: ${e}`;
|
||||
}
|
||||
|
||||
if (
|
||||
!params.pattern ||
|
||||
typeof params.pattern !== 'string' ||
|
||||
@@ -339,6 +264,15 @@ export class GlobTool extends BaseDeclarativeTool<GlobToolParams, ToolResult> {
|
||||
return "The 'pattern' parameter cannot be empty.";
|
||||
}
|
||||
|
||||
// Only validate path if one is provided
|
||||
if (params.path) {
|
||||
try {
|
||||
resolveAndValidatePath(this.config, params.path);
|
||||
} catch (error) {
|
||||
return getErrorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ describe('GrepTool', () => {
|
||||
getFileExclusions: () => ({
|
||||
getGlobExcludes: () => [],
|
||||
}),
|
||||
getTruncateToolOutputThreshold: () => 25000,
|
||||
getTruncateToolOutputLines: () => 1000,
|
||||
} as unknown as Config;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -84,11 +86,11 @@ describe('GrepTool', () => {
|
||||
expect(grepTool.validateToolParams(params)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for valid params (pattern, path, and include)', () => {
|
||||
it('should return null for valid params (pattern, path, and glob)', () => {
|
||||
const params: GrepToolParams = {
|
||||
pattern: 'hello',
|
||||
path: '.',
|
||||
include: '*.txt',
|
||||
glob: '*.txt',
|
||||
};
|
||||
expect(grepTool.validateToolParams(params)).toBeNull();
|
||||
});
|
||||
@@ -111,7 +113,7 @@ describe('GrepTool', () => {
|
||||
const params: GrepToolParams = { pattern: 'hello', path: 'nonexistent' };
|
||||
// Check for the core error message, as the full path might vary
|
||||
expect(grepTool.validateToolParams(params)).toContain(
|
||||
'Failed to access path stats for',
|
||||
'Path does not exist:',
|
||||
);
|
||||
expect(grepTool.validateToolParams(params)).toContain('nonexistent');
|
||||
});
|
||||
@@ -155,8 +157,8 @@ describe('GrepTool', () => {
|
||||
expect(result.returnDisplay).toBe('Found 1 match');
|
||||
});
|
||||
|
||||
it('should find matches with an include glob', async () => {
|
||||
const params: GrepToolParams = { pattern: 'hello', include: '*.js' };
|
||||
it('should find matches with a glob filter', async () => {
|
||||
const params: GrepToolParams = { pattern: 'hello', glob: '*.js' };
|
||||
const invocation = grepTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
expect(result.llmContent).toContain(
|
||||
@@ -169,7 +171,7 @@ describe('GrepTool', () => {
|
||||
expect(result.returnDisplay).toBe('Found 1 match');
|
||||
});
|
||||
|
||||
it('should find matches with an include glob and path', async () => {
|
||||
it('should find matches with a glob filter and path', async () => {
|
||||
await fs.writeFile(
|
||||
path.join(tempRootDir, 'sub', 'another.js'),
|
||||
'const greeting = "hello";',
|
||||
@@ -177,7 +179,7 @@ describe('GrepTool', () => {
|
||||
const params: GrepToolParams = {
|
||||
pattern: 'hello',
|
||||
path: 'sub',
|
||||
include: '*.js',
|
||||
glob: '*.js',
|
||||
};
|
||||
const invocation = grepTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
@@ -244,59 +246,23 @@ describe('GrepTool', () => {
|
||||
|
||||
describe('multi-directory workspace', () => {
|
||||
it('should search across all workspace directories when no path is specified', async () => {
|
||||
// Create additional directory with test files
|
||||
const secondDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'grep-tool-second-'),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(secondDir, 'other.txt'),
|
||||
'hello from second directory\nworld in second',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(secondDir, 'another.js'),
|
||||
'function world() { return "test"; }',
|
||||
);
|
||||
|
||||
// Create a mock config with multiple directories
|
||||
const multiDirConfig = {
|
||||
getTargetDir: () => tempRootDir,
|
||||
getWorkspaceContext: () =>
|
||||
createMockWorkspaceContext(tempRootDir, [secondDir]),
|
||||
getFileExclusions: () => ({
|
||||
getGlobExcludes: () => [],
|
||||
}),
|
||||
} as unknown as Config;
|
||||
|
||||
const multiDirGrepTool = new GrepTool(multiDirConfig);
|
||||
// The new implementation searches only in the target directory (first workspace directory)
|
||||
// when no path is specified, not across all workspace directories
|
||||
const params: GrepToolParams = { pattern: 'world' };
|
||||
const invocation = multiDirGrepTool.build(params);
|
||||
const invocation = grepTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
// Should find matches in both directories
|
||||
// Should find matches in the target directory only
|
||||
expect(result.llmContent).toContain(
|
||||
'Found 5 matches for pattern "world"',
|
||||
'Found 3 matches for pattern "world" in the workspace directory',
|
||||
);
|
||||
|
||||
// Matches from first directory
|
||||
// Matches from target directory
|
||||
expect(result.llmContent).toContain('fileA.txt');
|
||||
expect(result.llmContent).toContain('L1: hello world');
|
||||
expect(result.llmContent).toContain('L2: second line with world');
|
||||
expect(result.llmContent).toContain('fileC.txt');
|
||||
expect(result.llmContent).toContain('L1: another world in sub dir');
|
||||
|
||||
// Matches from second directory (with directory name prefix)
|
||||
const secondDirName = path.basename(secondDir);
|
||||
expect(result.llmContent).toContain(
|
||||
`File: ${path.join(secondDirName, 'other.txt')}`,
|
||||
);
|
||||
expect(result.llmContent).toContain('L2: world in second');
|
||||
expect(result.llmContent).toContain(
|
||||
`File: ${path.join(secondDirName, 'another.js')}`,
|
||||
);
|
||||
expect(result.llmContent).toContain('L1: function world()');
|
||||
|
||||
// Clean up
|
||||
await fs.rm(secondDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should search only specified path within workspace directories', async () => {
|
||||
@@ -318,6 +284,8 @@ describe('GrepTool', () => {
|
||||
getFileExclusions: () => ({
|
||||
getGlobExcludes: () => [],
|
||||
}),
|
||||
getTruncateToolOutputThreshold: () => 25000,
|
||||
getTruncateToolOutputLines: () => 1000,
|
||||
} as unknown as Config;
|
||||
|
||||
const multiDirGrepTool = new GrepTool(multiDirConfig);
|
||||
@@ -346,16 +314,18 @@ describe('GrepTool', () => {
|
||||
it('should generate correct description with pattern only', () => {
|
||||
const params: GrepToolParams = { pattern: 'testPattern' };
|
||||
const invocation = grepTool.build(params);
|
||||
expect(invocation.getDescription()).toBe("'testPattern'");
|
||||
expect(invocation.getDescription()).toBe("'testPattern' in path './'");
|
||||
});
|
||||
|
||||
it('should generate correct description with pattern and include', () => {
|
||||
it('should generate correct description with pattern and glob', () => {
|
||||
const params: GrepToolParams = {
|
||||
pattern: 'testPattern',
|
||||
include: '*.ts',
|
||||
glob: '*.ts',
|
||||
};
|
||||
const invocation = grepTool.build(params);
|
||||
expect(invocation.getDescription()).toBe("'testPattern' in *.ts");
|
||||
expect(invocation.getDescription()).toBe(
|
||||
"'testPattern' in path './' (filter: '*.ts')",
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate correct description with pattern and path', async () => {
|
||||
@@ -366,49 +336,37 @@ describe('GrepTool', () => {
|
||||
path: path.join('src', 'app'),
|
||||
};
|
||||
const invocation = grepTool.build(params);
|
||||
// The path will be relative to the tempRootDir, so we check for containment.
|
||||
expect(invocation.getDescription()).toContain("'testPattern' within");
|
||||
expect(invocation.getDescription()).toContain(path.join('src', 'app'));
|
||||
});
|
||||
|
||||
it('should indicate searching across all workspace directories when no path specified', () => {
|
||||
// Create a mock config with multiple directories
|
||||
const multiDirConfig = {
|
||||
getTargetDir: () => tempRootDir,
|
||||
getWorkspaceContext: () =>
|
||||
createMockWorkspaceContext(tempRootDir, ['/another/dir']),
|
||||
getFileExclusions: () => ({
|
||||
getGlobExcludes: () => [],
|
||||
}),
|
||||
} as unknown as Config;
|
||||
|
||||
const multiDirGrepTool = new GrepTool(multiDirConfig);
|
||||
const params: GrepToolParams = { pattern: 'testPattern' };
|
||||
const invocation = multiDirGrepTool.build(params);
|
||||
expect(invocation.getDescription()).toBe(
|
||||
"'testPattern' across all workspace directories",
|
||||
expect(invocation.getDescription()).toContain(
|
||||
"'testPattern' in path 'src",
|
||||
);
|
||||
expect(invocation.getDescription()).toContain("app'");
|
||||
});
|
||||
|
||||
it('should generate correct description with pattern, include, and path', async () => {
|
||||
it('should indicate searching workspace directory when no path specified', () => {
|
||||
const params: GrepToolParams = { pattern: 'testPattern' };
|
||||
const invocation = grepTool.build(params);
|
||||
expect(invocation.getDescription()).toBe("'testPattern' in path './'");
|
||||
});
|
||||
|
||||
it('should generate correct description with pattern, glob, and path', async () => {
|
||||
const dirPath = path.join(tempRootDir, 'src', 'app');
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
const params: GrepToolParams = {
|
||||
pattern: 'testPattern',
|
||||
include: '*.ts',
|
||||
glob: '*.ts',
|
||||
path: path.join('src', 'app'),
|
||||
};
|
||||
const invocation = grepTool.build(params);
|
||||
expect(invocation.getDescription()).toContain(
|
||||
"'testPattern' in *.ts within",
|
||||
"'testPattern' in path 'src",
|
||||
);
|
||||
expect(invocation.getDescription()).toContain(path.join('src', 'app'));
|
||||
expect(invocation.getDescription()).toContain("(filter: '*.ts')");
|
||||
});
|
||||
|
||||
it('should use ./ for root path in description', () => {
|
||||
const params: GrepToolParams = { pattern: 'testPattern', path: '.' };
|
||||
const invocation = grepTool.build(params);
|
||||
expect(invocation.getDescription()).toBe("'testPattern' within ./");
|
||||
expect(invocation.getDescription()).toBe("'testPattern' in path '.'");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -422,67 +380,50 @@ describe('GrepTool', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should limit results to default 20 matches', async () => {
|
||||
it('should show all results when no limit is specified', async () => {
|
||||
const params: GrepToolParams = { pattern: 'testword' };
|
||||
const invocation = grepTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
expect(result.llmContent).toContain('Found 20 matches');
|
||||
expect(result.llmContent).toContain(
|
||||
'showing first 20 of 30+ total matches',
|
||||
);
|
||||
expect(result.llmContent).toContain('WARNING: Results truncated');
|
||||
expect(result.returnDisplay).toContain(
|
||||
'Found 20 matches (truncated from 30+)',
|
||||
);
|
||||
// New implementation shows all matches when limit is not specified
|
||||
expect(result.llmContent).toContain('Found 30 matches');
|
||||
expect(result.llmContent).not.toContain('truncated');
|
||||
expect(result.returnDisplay).toBe('Found 30 matches');
|
||||
});
|
||||
|
||||
it('should respect custom maxResults parameter', async () => {
|
||||
const params: GrepToolParams = { pattern: 'testword', maxResults: 5 };
|
||||
it('should respect custom limit parameter', async () => {
|
||||
const params: GrepToolParams = { pattern: 'testword', limit: 5 };
|
||||
const invocation = grepTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
expect(result.llmContent).toContain('Found 5 matches');
|
||||
expect(result.llmContent).toContain(
|
||||
'showing first 5 of 30+ total matches',
|
||||
);
|
||||
expect(result.llmContent).toContain('current: 5');
|
||||
expect(result.returnDisplay).toContain(
|
||||
'Found 5 matches (truncated from 30+)',
|
||||
);
|
||||
// Should find 30 total but limit to 5
|
||||
expect(result.llmContent).toContain('Found 30 matches');
|
||||
expect(result.llmContent).toContain('25 lines truncated');
|
||||
expect(result.returnDisplay).toContain('Found 30 matches (truncated)');
|
||||
});
|
||||
|
||||
it('should not show truncation warning when all results fit', async () => {
|
||||
const params: GrepToolParams = { pattern: 'testword', maxResults: 50 };
|
||||
const params: GrepToolParams = { pattern: 'testword', limit: 50 };
|
||||
const invocation = grepTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
expect(result.llmContent).toContain('Found 30 matches');
|
||||
expect(result.llmContent).not.toContain('WARNING: Results truncated');
|
||||
expect(result.llmContent).not.toContain('showing first');
|
||||
expect(result.llmContent).not.toContain('truncated');
|
||||
expect(result.returnDisplay).toBe('Found 30 matches');
|
||||
});
|
||||
|
||||
it('should validate maxResults parameter', () => {
|
||||
const invalidParams = [
|
||||
{ pattern: 'test', maxResults: 0 },
|
||||
{ pattern: 'test', maxResults: 101 },
|
||||
{ pattern: 'test', maxResults: -1 },
|
||||
{ pattern: 'test', maxResults: 1.5 },
|
||||
];
|
||||
|
||||
invalidParams.forEach((params) => {
|
||||
const error = grepTool.validateToolParams(params as GrepToolParams);
|
||||
expect(error).toBeTruthy(); // Just check that validation fails
|
||||
expect(error).toMatch(/maxResults|must be/); // Check it's about maxResults validation
|
||||
});
|
||||
it('should not validate limit parameter', () => {
|
||||
// limit parameter has no validation constraints in the new implementation
|
||||
const params = { pattern: 'test', limit: 5 };
|
||||
const error = grepTool.validateToolParams(params as GrepToolParams);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
it('should accept valid maxResults parameter', () => {
|
||||
it('should accept valid limit parameter', () => {
|
||||
const validParams = [
|
||||
{ pattern: 'test', maxResults: 1 },
|
||||
{ pattern: 'test', maxResults: 50 },
|
||||
{ pattern: 'test', maxResults: 100 },
|
||||
{ pattern: 'test', limit: 1 },
|
||||
{ pattern: 'test', limit: 50 },
|
||||
{ pattern: 'test', limit: 100 },
|
||||
];
|
||||
|
||||
validParams.forEach((params) => {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { EOL } from 'node:os';
|
||||
@@ -13,7 +12,7 @@ import { globStream } from 'glob';
|
||||
import type { ToolInvocation, ToolResult } from './tools.js';
|
||||
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
|
||||
import { ToolNames } from './tool-names.js';
|
||||
import { makeRelative, shortenPath } from '../utils/paths.js';
|
||||
import { resolveAndValidatePath } from '../utils/paths.js';
|
||||
import { getErrorMessage, isNodeError } from '../utils/errors.js';
|
||||
import { isGitRepository } from '../utils/gitUtils.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
@@ -37,14 +36,14 @@ export interface GrepToolParams {
|
||||
path?: string;
|
||||
|
||||
/**
|
||||
* File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")
|
||||
* Glob pattern to filter files (e.g. "*.js", "*.{ts,tsx}")
|
||||
*/
|
||||
include?: string;
|
||||
glob?: string;
|
||||
|
||||
/**
|
||||
* Maximum number of matches to return (optional, defaults to 20)
|
||||
* Maximum number of matching lines to return (optional, shows all if not specified)
|
||||
*/
|
||||
maxResults?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,121 +69,60 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
this.fileExclusions = config.getFileExclusions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a path is within the root directory and resolves it.
|
||||
* @param relativePath Path relative to the root directory (or undefined for root).
|
||||
* @returns The absolute path if valid and exists, or null if no path specified (to search all directories).
|
||||
* @throws {Error} If path is outside root, doesn't exist, or isn't a directory.
|
||||
*/
|
||||
private resolveAndValidatePath(relativePath?: string): string | null {
|
||||
// If no path specified, return null to indicate searching all workspace directories
|
||||
if (!relativePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetPath = path.resolve(this.config.getTargetDir(), relativePath);
|
||||
|
||||
// Security Check: Ensure the resolved path is within workspace boundaries
|
||||
const workspaceContext = this.config.getWorkspaceContext();
|
||||
if (!workspaceContext.isPathWithinWorkspace(targetPath)) {
|
||||
const directories = workspaceContext.getDirectories();
|
||||
throw new Error(
|
||||
`Path validation failed: Attempted path "${relativePath}" resolves outside the allowed workspace directories: ${directories.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check existence and type after resolving
|
||||
try {
|
||||
const stats = fs.statSync(targetPath);
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Path is not a directory: ${targetPath}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (isNodeError(error) && error.code !== 'ENOENT') {
|
||||
throw new Error(`Path does not exist: ${targetPath}`);
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to access path stats for ${targetPath}: ${error}`,
|
||||
);
|
||||
}
|
||||
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
async execute(signal: AbortSignal): Promise<ToolResult> {
|
||||
try {
|
||||
const workspaceContext = this.config.getWorkspaceContext();
|
||||
const searchDirAbs = this.resolveAndValidatePath(this.params.path);
|
||||
// Default to target directory if no path is provided
|
||||
const searchDirAbs = resolveAndValidatePath(
|
||||
this.config,
|
||||
this.params.path,
|
||||
);
|
||||
const searchDirDisplay = this.params.path || '.';
|
||||
|
||||
// Determine which directories to search
|
||||
let searchDirectories: readonly string[];
|
||||
if (searchDirAbs === null) {
|
||||
// No path specified - search all workspace directories
|
||||
searchDirectories = workspaceContext.getDirectories();
|
||||
} else {
|
||||
// Specific path provided - search only that directory
|
||||
searchDirectories = [searchDirAbs];
|
||||
}
|
||||
// Perform grep search
|
||||
const rawMatches = await this.performGrepSearch({
|
||||
pattern: this.params.pattern,
|
||||
path: searchDirAbs,
|
||||
glob: this.params.glob,
|
||||
signal,
|
||||
});
|
||||
|
||||
// Collect matches from all search directories
|
||||
let allMatches: GrepMatch[] = [];
|
||||
const maxResults = this.params.maxResults ?? 20; // Default to 20 results
|
||||
let totalMatchesFound = 0;
|
||||
let searchTruncated = false;
|
||||
// Build search description
|
||||
const searchLocationDescription = this.params.path
|
||||
? `in path "${searchDirDisplay}"`
|
||||
: `in the workspace directory`;
|
||||
|
||||
for (const searchDir of searchDirectories) {
|
||||
const matches = await this.performGrepSearch({
|
||||
pattern: this.params.pattern,
|
||||
path: searchDir,
|
||||
include: this.params.include,
|
||||
signal,
|
||||
});
|
||||
const filterDescription = this.params.glob
|
||||
? ` (filter: "${this.params.glob}")`
|
||||
: '';
|
||||
|
||||
totalMatchesFound += matches.length;
|
||||
|
||||
// Add directory prefix if searching multiple directories
|
||||
if (searchDirectories.length > 1) {
|
||||
const dirName = path.basename(searchDir);
|
||||
matches.forEach((match) => {
|
||||
match.filePath = path.join(dirName, match.filePath);
|
||||
});
|
||||
}
|
||||
|
||||
// Apply result limiting
|
||||
const remainingSlots = maxResults - allMatches.length;
|
||||
if (remainingSlots <= 0) {
|
||||
searchTruncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (matches.length > remainingSlots) {
|
||||
allMatches = allMatches.concat(matches.slice(0, remainingSlots));
|
||||
searchTruncated = true;
|
||||
break;
|
||||
} else {
|
||||
allMatches = allMatches.concat(matches);
|
||||
}
|
||||
}
|
||||
|
||||
let searchLocationDescription: string;
|
||||
if (searchDirAbs === null) {
|
||||
const numDirs = workspaceContext.getDirectories().length;
|
||||
searchLocationDescription =
|
||||
numDirs > 1
|
||||
? `across ${numDirs} workspace directories`
|
||||
: `in the workspace directory`;
|
||||
} else {
|
||||
searchLocationDescription = `in path "${searchDirDisplay}"`;
|
||||
}
|
||||
|
||||
if (allMatches.length === 0) {
|
||||
const noMatchMsg = `No matches found for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}.`;
|
||||
// Check if we have any matches
|
||||
if (rawMatches.length === 0) {
|
||||
const noMatchMsg = `No matches found for pattern "${this.params.pattern}" ${searchLocationDescription}${filterDescription}.`;
|
||||
return { llmContent: noMatchMsg, returnDisplay: `No matches found` };
|
||||
}
|
||||
|
||||
const charLimit = this.config.getTruncateToolOutputThreshold();
|
||||
const lineLimit = Math.min(
|
||||
this.config.getTruncateToolOutputLines(),
|
||||
this.params.limit ?? Number.POSITIVE_INFINITY,
|
||||
);
|
||||
|
||||
// Apply line limit if specified
|
||||
let truncatedByLineLimit = false;
|
||||
let matchesToInclude = rawMatches;
|
||||
if (rawMatches.length > lineLimit) {
|
||||
matchesToInclude = rawMatches.slice(0, lineLimit);
|
||||
truncatedByLineLimit = true;
|
||||
}
|
||||
|
||||
const totalMatches = rawMatches.length;
|
||||
const matchTerm = totalMatches === 1 ? 'match' : 'matches';
|
||||
|
||||
// Build header
|
||||
const header = `Found ${totalMatches} ${matchTerm} for pattern "${this.params.pattern}" ${searchLocationDescription}${filterDescription}:\n---\n`;
|
||||
|
||||
// Group matches by file
|
||||
const matchesByFile = allMatches.reduce(
|
||||
const matchesByFile = matchesToInclude.reduce(
|
||||
(acc, match) => {
|
||||
const fileKey = match.filePath;
|
||||
if (!acc[fileKey]) {
|
||||
@@ -197,46 +135,51 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
{} as Record<string, GrepMatch[]>,
|
||||
);
|
||||
|
||||
const matchCount = allMatches.length;
|
||||
const matchTerm = matchCount === 1 ? 'match' : 'matches';
|
||||
|
||||
// Build the header with truncation info if needed
|
||||
let headerText = `Found ${matchCount} ${matchTerm} for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}`;
|
||||
|
||||
if (searchTruncated) {
|
||||
headerText += ` (showing first ${matchCount} of ${totalMatchesFound}+ total matches)`;
|
||||
}
|
||||
|
||||
let llmContent = `${headerText}:
|
||||
---
|
||||
`;
|
||||
|
||||
// Build grep output
|
||||
let grepOutput = '';
|
||||
for (const filePath in matchesByFile) {
|
||||
llmContent += `File: ${filePath}\n`;
|
||||
grepOutput += `File: ${filePath}\n`;
|
||||
matchesByFile[filePath].forEach((match) => {
|
||||
const trimmedLine = match.line.trim();
|
||||
llmContent += `L${match.lineNumber}: ${trimmedLine}\n`;
|
||||
grepOutput += `L${match.lineNumber}: ${trimmedLine}\n`;
|
||||
});
|
||||
llmContent += '---\n';
|
||||
grepOutput += '---\n';
|
||||
}
|
||||
|
||||
// Add truncation guidance if results were limited
|
||||
if (searchTruncated) {
|
||||
llmContent += `\nWARNING: Results truncated to prevent context overflow. To see more results:
|
||||
- Use a more specific pattern to reduce matches
|
||||
- Add file filters with the 'include' parameter (e.g., "*.js", "src/**")
|
||||
- Specify a narrower 'path' to search in a subdirectory
|
||||
- Increase 'maxResults' parameter if you need more matches (current: ${maxResults})`;
|
||||
// Apply character limit as safety net
|
||||
let truncatedByCharLimit = false;
|
||||
if (Number.isFinite(charLimit) && grepOutput.length > charLimit) {
|
||||
grepOutput = grepOutput.slice(0, charLimit) + '...';
|
||||
truncatedByCharLimit = true;
|
||||
}
|
||||
|
||||
let displayText = `Found ${matchCount} ${matchTerm}`;
|
||||
if (searchTruncated) {
|
||||
displayText += ` (truncated from ${totalMatchesFound}+)`;
|
||||
// Count how many lines we actually included after character truncation
|
||||
const finalLines = grepOutput
|
||||
.split('\n')
|
||||
.filter(
|
||||
(line) =>
|
||||
line.trim() && !line.startsWith('File:') && !line.startsWith('---'),
|
||||
);
|
||||
const includedLines = finalLines.length;
|
||||
|
||||
// Build result
|
||||
let llmContent = header + grepOutput;
|
||||
|
||||
// Add truncation notice if needed
|
||||
if (truncatedByLineLimit || truncatedByCharLimit) {
|
||||
const omittedMatches = totalMatches - includedLines;
|
||||
llmContent += ` [${omittedMatches} ${omittedMatches === 1 ? 'line' : 'lines'} truncated] ...`;
|
||||
}
|
||||
|
||||
// Build display message
|
||||
let displayMessage = `Found ${totalMatches} ${matchTerm}`;
|
||||
if (truncatedByLineLimit || truncatedByCharLimit) {
|
||||
displayMessage += ` (truncated)`;
|
||||
}
|
||||
|
||||
return {
|
||||
llmContent: llmContent.trim(),
|
||||
returnDisplay: displayText,
|
||||
returnDisplay: displayMessage,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error during GrepLogic execution: ${error}`);
|
||||
@@ -329,50 +272,26 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
* @returns A string describing the grep
|
||||
*/
|
||||
getDescription(): string {
|
||||
let description = `'${this.params.pattern}'`;
|
||||
if (this.params.include) {
|
||||
description += ` in ${this.params.include}`;
|
||||
}
|
||||
if (this.params.path) {
|
||||
const resolvedPath = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
this.params.path,
|
||||
);
|
||||
if (
|
||||
resolvedPath === this.config.getTargetDir() ||
|
||||
this.params.path === '.'
|
||||
) {
|
||||
description += ` within ./`;
|
||||
} else {
|
||||
const relativePath = makeRelative(
|
||||
resolvedPath,
|
||||
this.config.getTargetDir(),
|
||||
);
|
||||
description += ` within ${shortenPath(relativePath)}`;
|
||||
}
|
||||
} else {
|
||||
// When no path is specified, indicate searching all workspace directories
|
||||
const workspaceContext = this.config.getWorkspaceContext();
|
||||
const directories = workspaceContext.getDirectories();
|
||||
if (directories.length > 1) {
|
||||
description += ` across all workspace directories`;
|
||||
}
|
||||
let description = `'${this.params.pattern}' in path '${this.params.path || './'}'`;
|
||||
if (this.params.glob) {
|
||||
description += ` (filter: '${this.params.glob}')`;
|
||||
}
|
||||
|
||||
return description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the actual search using the prioritized strategies.
|
||||
* @param options Search options including pattern, absolute path, and include glob.
|
||||
* @param options Search options including pattern, absolute path, and glob filter.
|
||||
* @returns A promise resolving to an array of match objects.
|
||||
*/
|
||||
private async performGrepSearch(options: {
|
||||
pattern: string;
|
||||
path: string; // Expects absolute path
|
||||
include?: string;
|
||||
glob?: string;
|
||||
signal: AbortSignal;
|
||||
}): Promise<GrepMatch[]> {
|
||||
const { pattern, path: absolutePath, include } = options;
|
||||
const { pattern, path: absolutePath, glob } = options;
|
||||
let strategyUsed = 'none';
|
||||
|
||||
try {
|
||||
@@ -390,8 +309,8 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
'--ignore-case',
|
||||
pattern,
|
||||
];
|
||||
if (include) {
|
||||
gitArgs.push('--', include);
|
||||
if (glob) {
|
||||
gitArgs.push('--', glob);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -457,8 +376,8 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
})
|
||||
.filter((dir): dir is string => !!dir);
|
||||
commonExcludes.forEach((dir) => grepArgs.push(`--exclude-dir=${dir}`));
|
||||
if (include) {
|
||||
grepArgs.push(`--include=${include}`);
|
||||
if (glob) {
|
||||
grepArgs.push(`--include=${glob}`);
|
||||
}
|
||||
grepArgs.push(pattern);
|
||||
grepArgs.push('.');
|
||||
@@ -537,7 +456,7 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
'GrepLogic: Falling back to JavaScript grep implementation.',
|
||||
);
|
||||
strategyUsed = 'javascript fallback';
|
||||
const globPattern = include ? include : '**/*';
|
||||
const globPattern = glob ? glob : '**/*';
|
||||
const ignorePatterns = this.fileExclusions.getGlobExcludes();
|
||||
|
||||
const filesIterator = globStream(globPattern, {
|
||||
@@ -603,32 +522,30 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
|
||||
constructor(private readonly config: Config) {
|
||||
super(
|
||||
GrepTool.Name,
|
||||
'SearchText',
|
||||
'Searches for a regular expression pattern within the content of files in a specified directory (or current working directory). Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers.',
|
||||
'Grep',
|
||||
'A powerful search tool for finding patterns in files\n\n Usage:\n - ALWAYS use Grep for search tasks. NEVER invoke `grep` or `rg` as a Bash command. The Grep tool has been optimized for correct permissions and access.\n - Supports full regex syntax (e.g., "log.*Error", "function\\s+\\w+")\n - Filter files with glob parameter (e.g., "*.js", "**/*.tsx")\n - Case-insensitive by default\n - Use Task tool for open-ended searches requiring multiple rounds\n',
|
||||
Kind.Search,
|
||||
{
|
||||
properties: {
|
||||
pattern: {
|
||||
description:
|
||||
"The regular expression (regex) pattern to search for within file contents (e.g., 'function\\s+myFunction', 'import\\s+\\{.*\\}\\s+from\\s+.*').",
|
||||
type: 'string',
|
||||
description:
|
||||
'The regular expression pattern to search for in file contents',
|
||||
},
|
||||
glob: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Glob pattern to filter files (e.g. "*.js", "*.{ts,tsx}")',
|
||||
},
|
||||
path: {
|
||||
description:
|
||||
'Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.',
|
||||
type: 'string',
|
||||
},
|
||||
include: {
|
||||
description:
|
||||
"Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).",
|
||||
type: 'string',
|
||||
'File or directory to search in. Defaults to current working directory.',
|
||||
},
|
||||
maxResults: {
|
||||
description:
|
||||
'Optional: Maximum number of matches to return to prevent context overflow (default: 20, max: 100). Use lower values for broad searches, higher for specific searches.',
|
||||
limit: {
|
||||
type: 'number',
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
description:
|
||||
'Limit output to first N matching lines. Optional - shows all matches if not specified.',
|
||||
},
|
||||
},
|
||||
required: ['pattern'],
|
||||
@@ -637,47 +554,6 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a path is within the root directory and resolves it.
|
||||
* @param relativePath Path relative to the root directory (or undefined for root).
|
||||
* @returns The absolute path if valid and exists, or null if no path specified (to search all directories).
|
||||
* @throws {Error} If path is outside root, doesn't exist, or isn't a directory.
|
||||
*/
|
||||
private resolveAndValidatePath(relativePath?: string): string | null {
|
||||
// If no path specified, return null to indicate searching all workspace directories
|
||||
if (!relativePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetPath = path.resolve(this.config.getTargetDir(), relativePath);
|
||||
|
||||
// Security Check: Ensure the resolved path is within workspace boundaries
|
||||
const workspaceContext = this.config.getWorkspaceContext();
|
||||
if (!workspaceContext.isPathWithinWorkspace(targetPath)) {
|
||||
const directories = workspaceContext.getDirectories();
|
||||
throw new Error(
|
||||
`Path validation failed: Attempted path "${relativePath}" resolves outside the allowed workspace directories: ${directories.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check existence and type after resolving
|
||||
try {
|
||||
const stats = fs.statSync(targetPath);
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Path is not a directory: ${targetPath}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (isNodeError(error) && error.code !== 'ENOENT') {
|
||||
throw new Error(`Path does not exist: ${targetPath}`);
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to access path stats for ${targetPath}: ${error}`,
|
||||
);
|
||||
}
|
||||
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the parameters for the tool
|
||||
* @param params Parameters to validate
|
||||
@@ -686,27 +562,17 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
|
||||
protected override validateToolParamValues(
|
||||
params: GrepToolParams,
|
||||
): string | null {
|
||||
// Validate pattern is a valid regex
|
||||
try {
|
||||
new RegExp(params.pattern);
|
||||
} catch (error) {
|
||||
return `Invalid regular expression pattern provided: ${params.pattern}. Error: ${getErrorMessage(error)}`;
|
||||
}
|
||||
|
||||
// Validate maxResults if provided
|
||||
if (params.maxResults !== undefined) {
|
||||
if (
|
||||
!Number.isInteger(params.maxResults) ||
|
||||
params.maxResults < 1 ||
|
||||
params.maxResults > 100
|
||||
) {
|
||||
return `maxResults must be an integer between 1 and 100, got: ${params.maxResults}`;
|
||||
}
|
||||
return `Invalid regular expression pattern: ${params.pattern}. Error: ${getErrorMessage(error)}`;
|
||||
}
|
||||
|
||||
// Only validate path if one is provided
|
||||
if (params.path) {
|
||||
try {
|
||||
this.resolveAndValidatePath(params.path);
|
||||
resolveAndValidatePath(this.config, params.path);
|
||||
} catch (error) {
|
||||
return getErrorMessage(error);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ describe('ReadFileTool', () => {
|
||||
storage: {
|
||||
getProjectTempDir: () => path.join(tempRootDir, '.temp'),
|
||||
},
|
||||
getTruncateToolOutputThreshold: () => 2500,
|
||||
getTruncateToolOutputLines: () => 500,
|
||||
} as unknown as Config;
|
||||
tool = new ReadFileTool(mockConfigInstance);
|
||||
});
|
||||
@@ -281,11 +283,9 @@ describe('ReadFileTool', () => {
|
||||
>;
|
||||
|
||||
const result = await invocation.execute(abortSignal);
|
||||
expect(result.llmContent).toContain(
|
||||
'IMPORTANT: The file content has been truncated',
|
||||
expect(result.returnDisplay).toContain(
|
||||
'Read lines 1-2 of 3 from longlines.txt (truncated)',
|
||||
);
|
||||
expect(result.llmContent).toContain('--- FILE CONTENT (truncated) ---');
|
||||
expect(result.returnDisplay).toContain('some lines were shortened');
|
||||
});
|
||||
|
||||
it('should handle image file and return appropriate content', async () => {
|
||||
@@ -417,10 +417,7 @@ describe('ReadFileTool', () => {
|
||||
|
||||
const result = await invocation.execute(abortSignal);
|
||||
expect(result.llmContent).toContain(
|
||||
'IMPORTANT: The file content has been truncated',
|
||||
);
|
||||
expect(result.llmContent).toContain(
|
||||
'Status: Showing lines 6-8 of 20 total lines',
|
||||
'Showing lines 6-8 of 20 total lines',
|
||||
);
|
||||
expect(result.llmContent).toContain('Line 6');
|
||||
expect(result.llmContent).toContain('Line 7');
|
||||
|
||||
@@ -67,8 +67,7 @@ class ReadFileToolInvocation extends BaseToolInvocation<
|
||||
async execute(): Promise<ToolResult> {
|
||||
const result = await processSingleFileContent(
|
||||
this.params.absolute_path,
|
||||
this.config.getTargetDir(),
|
||||
this.config.getFileSystemService(),
|
||||
this.config,
|
||||
this.params.offset,
|
||||
this.params.limit,
|
||||
);
|
||||
@@ -88,16 +87,7 @@ class ReadFileToolInvocation extends BaseToolInvocation<
|
||||
if (result.isTruncated) {
|
||||
const [start, end] = result.linesShown!;
|
||||
const total = result.originalLineCount!;
|
||||
const nextOffset = this.params.offset
|
||||
? this.params.offset + end - start + 1
|
||||
: end;
|
||||
llmContent = `
|
||||
IMPORTANT: The file content has been truncated.
|
||||
Status: Showing lines ${start}-${end} of ${total} total lines.
|
||||
Action: To read more of the file, you can use the 'offset' and 'limit' parameters in a subsequent 'read_file' call. For example, to read the next section of the file, use offset: ${nextOffset}.
|
||||
|
||||
--- FILE CONTENT (truncated) ---
|
||||
${result.llmContent}`;
|
||||
llmContent = `Showing lines ${start}-${end} of ${total} total lines.\n\n---\n\n${result.llmContent}`;
|
||||
} else {
|
||||
llmContent = result.llmContent || '';
|
||||
}
|
||||
|
||||
@@ -88,6 +88,8 @@ describe('ReadManyFilesTool', () => {
|
||||
buildExcludePatterns: () => DEFAULT_FILE_EXCLUDES,
|
||||
getReadManyFilesExcludes: () => DEFAULT_FILE_EXCLUDES,
|
||||
}),
|
||||
getTruncateToolOutputThreshold: () => 2500,
|
||||
getTruncateToolOutputLines: () => 500,
|
||||
} as Partial<Config> as Config;
|
||||
tool = new ReadManyFilesTool(mockConfig);
|
||||
|
||||
@@ -500,6 +502,8 @@ describe('ReadManyFilesTool', () => {
|
||||
buildExcludePatterns: () => [],
|
||||
getReadManyFilesExcludes: () => [],
|
||||
}),
|
||||
getTruncateToolOutputThreshold: () => 2500,
|
||||
getTruncateToolOutputLines: () => 500,
|
||||
} as Partial<Config> as Config;
|
||||
tool = new ReadManyFilesTool(mockConfig);
|
||||
|
||||
@@ -552,15 +556,10 @@ describe('ReadManyFilesTool', () => {
|
||||
c.includes('large-file.txt'),
|
||||
);
|
||||
|
||||
expect(normalFileContent).not.toContain(
|
||||
'[WARNING: This file was truncated.',
|
||||
);
|
||||
expect(normalFileContent).not.toContain('Showing lines');
|
||||
expect(truncatedFileContent).toContain(
|
||||
"[WARNING: This file was truncated. To view the full content, use the 'read_file' tool on this specific file.]",
|
||||
'Showing lines 1-250 of 2500 total lines.',
|
||||
);
|
||||
// Check that the actual content is still there but truncated
|
||||
expect(truncatedFileContent).toContain('L200');
|
||||
expect(truncatedFileContent).not.toContain('L2400');
|
||||
});
|
||||
|
||||
it('should read files with special characters like [] and () in the path', async () => {
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
processSingleFileContent,
|
||||
DEFAULT_ENCODING,
|
||||
getSpecificMimeType,
|
||||
DEFAULT_MAX_LINES_TEXT_FILE,
|
||||
} from '../utils/fileUtils.js';
|
||||
import type { PartListUnion } from '@google/genai';
|
||||
import {
|
||||
@@ -278,8 +277,10 @@ ${finalExclusionPatternsForDescription
|
||||
}
|
||||
|
||||
const sortedFiles = Array.from(filesToConsider).sort();
|
||||
const file_line_limit =
|
||||
DEFAULT_MAX_LINES_TEXT_FILE / Math.max(1, sortedFiles.length);
|
||||
const truncateToolOutputLines = this.config.getTruncateToolOutputLines();
|
||||
const file_line_limit = Number.isFinite(truncateToolOutputLines)
|
||||
? Math.floor(truncateToolOutputLines / Math.max(1, sortedFiles.length))
|
||||
: undefined;
|
||||
|
||||
const fileProcessingPromises = sortedFiles.map(
|
||||
async (filePath): Promise<FileProcessingResult> => {
|
||||
@@ -316,8 +317,7 @@ ${finalExclusionPatternsForDescription
|
||||
// Use processSingleFileContent for all file types now
|
||||
const fileReadResult = await processSingleFileContent(
|
||||
filePath,
|
||||
this.config.getTargetDir(),
|
||||
this.config.getFileSystemService(),
|
||||
this.config,
|
||||
0,
|
||||
file_line_limit,
|
||||
);
|
||||
@@ -376,9 +376,12 @@ ${finalExclusionPatternsForDescription
|
||||
);
|
||||
let fileContentForLlm = '';
|
||||
if (fileReadResult.isTruncated) {
|
||||
fileContentForLlm += `[WARNING: This file was truncated. To view the full content, use the 'read_file' tool on this specific file.]\n\n`;
|
||||
const [start, end] = fileReadResult.linesShown!;
|
||||
const total = fileReadResult.originalLineCount!;
|
||||
fileContentForLlm = `Showing lines ${start}-${end} of ${total} total lines.\n---\n${fileReadResult.llmContent}`;
|
||||
} else {
|
||||
fileContentForLlm = fileReadResult.llmContent;
|
||||
}
|
||||
fileContentForLlm += fileReadResult.llmContent;
|
||||
contentParts.push(`${separator}\n\n${fileContentForLlm}\n\n`);
|
||||
} else {
|
||||
// This is a Part for image/pdf, which we don't add the separator to.
|
||||
|
||||
@@ -103,6 +103,8 @@ describe('RipGrepTool', () => {
|
||||
getWorkingDir: () => tempRootDir,
|
||||
getDebugMode: () => false,
|
||||
getUseBuiltinRipgrep: () => true,
|
||||
getTruncateToolOutputThreshold: () => 25000,
|
||||
getTruncateToolOutputLines: () => 1000,
|
||||
} as unknown as Config;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -184,17 +186,15 @@ describe('RipGrepTool', () => {
|
||||
};
|
||||
// Check for the core error message, as the full path might vary
|
||||
expect(grepTool.validateToolParams(params)).toContain(
|
||||
'Failed to access path stats for',
|
||||
'Path does not exist:',
|
||||
);
|
||||
expect(grepTool.validateToolParams(params)).toContain('nonexistent');
|
||||
});
|
||||
|
||||
it('should return error if path is a file, not a directory', async () => {
|
||||
it('should allow path to be a file', () => {
|
||||
const filePath = path.join(tempRootDir, 'fileA.txt');
|
||||
const params: RipGrepToolParams = { pattern: 'hello', path: filePath };
|
||||
expect(grepTool.validateToolParams(params)).toContain(
|
||||
`Path is not a directory: ${filePath}`,
|
||||
);
|
||||
expect(grepTool.validateToolParams(params)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -419,7 +419,7 @@ describe('RipGrepTool', () => {
|
||||
});
|
||||
|
||||
it('should truncate llm content when exceeding maximum length', async () => {
|
||||
const longMatch = 'fileA.txt:1:' + 'a'.repeat(25_000);
|
||||
const longMatch = 'fileA.txt:1:' + 'a'.repeat(30_000);
|
||||
|
||||
mockSpawn.mockImplementationOnce(
|
||||
createMockSpawn({
|
||||
@@ -432,7 +432,7 @@ describe('RipGrepTool', () => {
|
||||
const invocation = grepTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
expect(String(result.llmContent).length).toBeLessThanOrEqual(20_000);
|
||||
expect(String(result.llmContent).length).toBeLessThanOrEqual(26_000);
|
||||
expect(result.llmContent).toMatch(/\[\d+ lines? truncated\] \.\.\./);
|
||||
expect(result.returnDisplay).toContain('truncated');
|
||||
});
|
||||
@@ -567,6 +567,26 @@ describe('RipGrepTool', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should search within a single file when path is a file', async () => {
|
||||
mockSpawn.mockImplementationOnce(
|
||||
createMockSpawn({
|
||||
outputData: `fileA.txt:1:hello world${EOL}fileA.txt:2:second line with world${EOL}`,
|
||||
exitCode: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
const params: RipGrepToolParams = {
|
||||
pattern: 'world',
|
||||
path: path.join(tempRootDir, 'fileA.txt'),
|
||||
};
|
||||
const invocation = grepTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
expect(result.llmContent).toContain('Found 2 matches');
|
||||
expect(result.llmContent).toContain('fileA.txt:1:hello world');
|
||||
expect(result.llmContent).toContain('fileA.txt:2:second line with world');
|
||||
expect(result.returnDisplay).toBe('Found 2 matches');
|
||||
});
|
||||
|
||||
it('should throw an error if ripgrep is not available', async () => {
|
||||
// Make ensureRipgrepBinary throw
|
||||
(ensureRipgrepPath as Mock).mockRejectedValue(
|
||||
@@ -648,7 +668,9 @@ describe('RipGrepTool', () => {
|
||||
describe('error handling and edge cases', () => {
|
||||
it('should handle workspace boundary violations', () => {
|
||||
const params: RipGrepToolParams = { pattern: 'test', path: '../outside' };
|
||||
expect(() => grepTool.build(params)).toThrow(/Path validation failed/);
|
||||
expect(() => grepTool.build(params)).toThrow(
|
||||
/Path is not within workspace/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty directories gracefully', async () => {
|
||||
@@ -1132,7 +1154,9 @@ describe('RipGrepTool', () => {
|
||||
glob: '*.ts',
|
||||
};
|
||||
const invocation = grepTool.build(params);
|
||||
expect(invocation.getDescription()).toBe("'testPattern' in *.ts");
|
||||
expect(invocation.getDescription()).toBe(
|
||||
"'testPattern' (filter: '*.ts')",
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate correct description with pattern and path', async () => {
|
||||
@@ -1143,9 +1167,10 @@ describe('RipGrepTool', () => {
|
||||
path: path.join('src', 'app'),
|
||||
};
|
||||
const invocation = grepTool.build(params);
|
||||
// The path will be relative to the tempRootDir, so we check for containment.
|
||||
expect(invocation.getDescription()).toContain("'testPattern' within");
|
||||
expect(invocation.getDescription()).toContain(path.join('src', 'app'));
|
||||
expect(invocation.getDescription()).toContain(
|
||||
"'testPattern' in path 'src",
|
||||
);
|
||||
expect(invocation.getDescription()).toContain("app'");
|
||||
});
|
||||
|
||||
it('should generate correct description with default search path', () => {
|
||||
@@ -1164,15 +1189,15 @@ describe('RipGrepTool', () => {
|
||||
};
|
||||
const invocation = grepTool.build(params);
|
||||
expect(invocation.getDescription()).toContain(
|
||||
"'testPattern' in *.ts within",
|
||||
"'testPattern' in path 'src",
|
||||
);
|
||||
expect(invocation.getDescription()).toContain(path.join('src', 'app'));
|
||||
expect(invocation.getDescription()).toContain("(filter: '*.ts')");
|
||||
});
|
||||
|
||||
it('should use ./ for root path in description', () => {
|
||||
it('should use path when specified in description', () => {
|
||||
const params: RipGrepToolParams = { pattern: 'testPattern', path: '.' };
|
||||
const invocation = grepTool.build(params);
|
||||
expect(invocation.getDescription()).toBe("'testPattern' within ./");
|
||||
expect(invocation.getDescription()).toBe("'testPattern' in path '.'");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,16 +11,14 @@ import { spawn } from 'node:child_process';
|
||||
import type { ToolInvocation, ToolResult } from './tools.js';
|
||||
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
|
||||
import { ToolNames } from './tool-names.js';
|
||||
import { makeRelative, shortenPath } from '../utils/paths.js';
|
||||
import { getErrorMessage, isNodeError } from '../utils/errors.js';
|
||||
import { resolveAndValidatePath } from '../utils/paths.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { ensureRipgrepPath } from '../utils/ripgrepUtils.js';
|
||||
import { SchemaValidator } from '../utils/schemaValidator.js';
|
||||
import type { FileFilteringOptions } from '../config/constants.js';
|
||||
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
|
||||
|
||||
const MAX_LLM_CONTENT_LENGTH = 20_000;
|
||||
|
||||
/**
|
||||
* Parameters for the GrepTool (Simplified)
|
||||
*/
|
||||
@@ -57,50 +55,13 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
super(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a path is within the root directory and resolves it.
|
||||
* @param relativePath Path relative to the root directory (or undefined for root).
|
||||
* @returns The absolute path to search within.
|
||||
* @throws {Error} If path is outside root, doesn't exist, or isn't a directory.
|
||||
*/
|
||||
private resolveAndValidatePath(relativePath?: string): string {
|
||||
const targetDir = this.config.getTargetDir();
|
||||
const targetPath = relativePath
|
||||
? path.resolve(targetDir, relativePath)
|
||||
: targetDir;
|
||||
|
||||
const workspaceContext = this.config.getWorkspaceContext();
|
||||
if (!workspaceContext.isPathWithinWorkspace(targetPath)) {
|
||||
const directories = workspaceContext.getDirectories();
|
||||
throw new Error(
|
||||
`Path validation failed: Attempted path "${relativePath}" resolves outside the allowed workspace directories: ${directories.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.ensureDirectory(targetPath);
|
||||
}
|
||||
|
||||
private ensureDirectory(targetPath: string): string {
|
||||
try {
|
||||
const stats = fs.statSync(targetPath);
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Path is not a directory: ${targetPath}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (isNodeError(error) && error.code !== 'ENOENT') {
|
||||
throw new Error(`Path does not exist: ${targetPath}`);
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to access path stats for ${targetPath}: ${error}`,
|
||||
);
|
||||
}
|
||||
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
async execute(signal: AbortSignal): Promise<ToolResult> {
|
||||
try {
|
||||
const searchDirAbs = this.resolveAndValidatePath(this.params.path);
|
||||
const searchDirAbs = resolveAndValidatePath(
|
||||
this.config,
|
||||
this.params.path,
|
||||
{ allowFiles: true },
|
||||
);
|
||||
const searchDirDisplay = this.params.path || '.';
|
||||
|
||||
// Get raw ripgrep output
|
||||
@@ -133,34 +94,50 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
|
||||
// Build header early to calculate available space
|
||||
const header = `Found ${totalMatches} ${matchTerm} for pattern "${this.params.pattern}" ${searchLocationDescription}${filterDescription}:\n---\n`;
|
||||
const maxTruncationNoticeLength = 100; // "[... N more matches truncated]"
|
||||
const maxGrepOutputLength =
|
||||
MAX_LLM_CONTENT_LENGTH - header.length - maxTruncationNoticeLength;
|
||||
|
||||
const charLimit = this.config.getTruncateToolOutputThreshold();
|
||||
const lineLimit = Math.min(
|
||||
this.config.getTruncateToolOutputLines(),
|
||||
this.params.limit ?? Number.POSITIVE_INFINITY,
|
||||
);
|
||||
|
||||
// Apply line limit first (if specified)
|
||||
let truncatedByLineLimit = false;
|
||||
let linesToInclude = allLines;
|
||||
if (
|
||||
this.params.limit !== undefined &&
|
||||
allLines.length > this.params.limit
|
||||
) {
|
||||
linesToInclude = allLines.slice(0, this.params.limit);
|
||||
if (allLines.length > lineLimit) {
|
||||
linesToInclude = allLines.slice(0, lineLimit);
|
||||
truncatedByLineLimit = true;
|
||||
}
|
||||
|
||||
// Join lines back into grep output
|
||||
let grepOutput = linesToInclude.join(EOL);
|
||||
|
||||
// Apply character limit as safety net
|
||||
// Build output and track how many lines we include, respecting character limit
|
||||
let grepOutput = '';
|
||||
let truncatedByCharLimit = false;
|
||||
if (grepOutput.length > maxGrepOutputLength) {
|
||||
grepOutput = grepOutput.slice(0, maxGrepOutputLength) + '...';
|
||||
truncatedByCharLimit = true;
|
||||
}
|
||||
let includedLines = 0;
|
||||
if (Number.isFinite(charLimit)) {
|
||||
const parts: string[] = [];
|
||||
let currentLength = 0;
|
||||
|
||||
// Count how many lines we actually included after character truncation
|
||||
const finalLines = grepOutput.split(EOL).filter((line) => line.trim());
|
||||
const includedLines = finalLines.length;
|
||||
for (const line of linesToInclude) {
|
||||
const sep = includedLines > 0 ? 1 : 0;
|
||||
includedLines++;
|
||||
|
||||
const projectedLength = currentLength + line.length + sep;
|
||||
if (projectedLength <= charLimit) {
|
||||
parts.push(line);
|
||||
currentLength = projectedLength;
|
||||
} else {
|
||||
const remaining = Math.max(charLimit - currentLength - sep, 10);
|
||||
parts.push(line.slice(0, remaining) + '...');
|
||||
truncatedByCharLimit = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
grepOutput = parts.join('\n');
|
||||
} else {
|
||||
grepOutput = linesToInclude.join('\n');
|
||||
includedLines = linesToInclude.length;
|
||||
}
|
||||
|
||||
// Build result
|
||||
let llmContent = header + grepOutput;
|
||||
@@ -168,7 +145,7 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
// Add truncation notice if needed
|
||||
if (truncatedByLineLimit || truncatedByCharLimit) {
|
||||
const omittedMatches = totalMatches - includedLines;
|
||||
llmContent += ` [${omittedMatches} ${omittedMatches === 1 ? 'line' : 'lines'} truncated] ...`;
|
||||
llmContent += `\n---\n[${omittedMatches} ${omittedMatches === 1 ? 'line' : 'lines'} truncated] ...`;
|
||||
}
|
||||
|
||||
// Build display message (show real count, not truncated)
|
||||
@@ -193,7 +170,7 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
|
||||
private async performRipgrepSearch(options: {
|
||||
pattern: string;
|
||||
path: string;
|
||||
path: string; // Can be a file or directory
|
||||
glob?: string;
|
||||
signal: AbortSignal;
|
||||
}): Promise<string> {
|
||||
@@ -302,34 +279,13 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
*/
|
||||
getDescription(): string {
|
||||
let description = `'${this.params.pattern}'`;
|
||||
if (this.params.glob) {
|
||||
description += ` in ${this.params.glob}`;
|
||||
}
|
||||
if (this.params.path) {
|
||||
const resolvedPath = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
this.params.path,
|
||||
);
|
||||
if (
|
||||
resolvedPath === this.config.getTargetDir() ||
|
||||
this.params.path === '.'
|
||||
) {
|
||||
description += ` within ./`;
|
||||
} else {
|
||||
const relativePath = makeRelative(
|
||||
resolvedPath,
|
||||
this.config.getTargetDir(),
|
||||
);
|
||||
description += ` within ${shortenPath(relativePath)}`;
|
||||
}
|
||||
} else {
|
||||
// When no path is specified, indicate searching all workspace directories
|
||||
const workspaceContext = this.config.getWorkspaceContext();
|
||||
const directories = workspaceContext.getDirectories();
|
||||
if (directories.length > 1) {
|
||||
description += ` across all workspace directories`;
|
||||
}
|
||||
description += ` in path '${this.params.path}'`;
|
||||
}
|
||||
if (this.params.glob) {
|
||||
description += ` (filter: '${this.params.glob}')`;
|
||||
}
|
||||
|
||||
return description;
|
||||
}
|
||||
}
|
||||
@@ -378,47 +334,6 @@ export class RipGrepTool extends BaseDeclarativeTool<
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a path is within the root directory and resolves it.
|
||||
* @param relativePath Path relative to the root directory (or undefined for root).
|
||||
* @returns The absolute path to search within.
|
||||
* @throws {Error} If path is outside root, doesn't exist, or isn't a directory.
|
||||
*/
|
||||
private resolveAndValidatePath(relativePath?: string): string {
|
||||
// If no path specified, search within the workspace root directory
|
||||
if (!relativePath) {
|
||||
return this.config.getTargetDir();
|
||||
}
|
||||
|
||||
const targetPath = path.resolve(this.config.getTargetDir(), relativePath);
|
||||
|
||||
// Security Check: Ensure the resolved path is within workspace boundaries
|
||||
const workspaceContext = this.config.getWorkspaceContext();
|
||||
if (!workspaceContext.isPathWithinWorkspace(targetPath)) {
|
||||
const directories = workspaceContext.getDirectories();
|
||||
throw new Error(
|
||||
`Path validation failed: Attempted path "${relativePath}" resolves outside the allowed workspace directories: ${directories.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check existence and type after resolving
|
||||
try {
|
||||
const stats = fs.statSync(targetPath);
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Path is not a directory: ${targetPath}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (isNodeError(error) && error.code !== 'ENOENT') {
|
||||
throw new Error(`Path does not exist: ${targetPath}`);
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to access path stats for ${targetPath}: ${error}`,
|
||||
);
|
||||
}
|
||||
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the parameters for the tool
|
||||
* @param params Parameters to validate
|
||||
@@ -445,7 +360,7 @@ export class RipGrepTool extends BaseDeclarativeTool<
|
||||
// Only validate path if one is provided
|
||||
if (params.path) {
|
||||
try {
|
||||
this.resolveAndValidatePath(params.path);
|
||||
resolveAndValidatePath(this.config, params.path, { allowFiles: true });
|
||||
} catch (error) {
|
||||
return getErrorMessage(error);
|
||||
}
|
||||
|
||||
@@ -21,4 +21,6 @@ export const ToolNames = {
|
||||
MEMORY: 'save_memory',
|
||||
TASK: 'task',
|
||||
EXIT_PLAN_MODE: 'exit_plan_mode',
|
||||
WEB_FETCH: 'web_fetch',
|
||||
WEB_SEARCH: 'web_search',
|
||||
} as const;
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ToolConfirmationOutcome,
|
||||
} from './tools.js';
|
||||
import { DEFAULT_QWEN_MODEL } from '../config/models.js';
|
||||
import { ToolNames } from './tool-names.js';
|
||||
|
||||
const URL_FETCH_TIMEOUT_MS = 10000;
|
||||
const MAX_CONTENT_LENGTH = 100000;
|
||||
@@ -190,7 +191,7 @@ export class WebFetchTool extends BaseDeclarativeTool<
|
||||
WebFetchToolParams,
|
||||
ToolResult
|
||||
> {
|
||||
static readonly Name: string = 'web_fetch';
|
||||
static readonly Name: string = ToolNames.WEB_FETCH;
|
||||
|
||||
constructor(private readonly config: Config) {
|
||||
super(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
58
packages/core/src/tools/web-search/base-provider.ts
Normal file
58
packages/core/src/tools/web-search/base-provider.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
312
packages/core/src/tools/web-search/index.test.ts
Normal file
312
packages/core/src/tools/web-search/index.test.ts
Normal 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)');
|
||||
});
|
||||
});
|
||||
});
|
||||
337
packages/core/src/tools/web-search/index.ts
Normal file
337
packages/core/src/tools/web-search/index.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* @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';
|
||||
import { ToolNames } from '../tool-names.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 = ToolNames.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';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
156
packages/core/src/tools/web-search/types.ts
Normal file
156
packages/core/src/tools/web-search/types.ts
Normal 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;
|
||||
42
packages/core/src/tools/web-search/utils.ts
Normal file
42
packages/core/src/tools/web-search/utils.ts
Normal 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');
|
||||
}
|
||||
@@ -13,9 +13,11 @@ import {
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import type { Content } from '@google/genai';
|
||||
import {
|
||||
getEnvironmentContext,
|
||||
getDirectoryContextString,
|
||||
getInitialChatHistory,
|
||||
} from './environmentContext.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { getFolderStructure } from './getFolderStructure.js';
|
||||
@@ -213,3 +215,102 @@ describe('getEnvironmentContext', () => {
|
||||
expect(parts[1].text).toBe('\n--- Error reading full file context ---');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInitialChatHistory', () => {
|
||||
let mockConfig: Partial<Config>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getFolderStructure).mockResolvedValue('Mock Folder Structure');
|
||||
mockConfig = {
|
||||
getSkipStartupContext: vi.fn().mockReturnValue(false),
|
||||
getWorkspaceContext: vi.fn().mockReturnValue({
|
||||
getDirectories: vi.fn().mockReturnValue(['/test/dir']),
|
||||
}),
|
||||
getFileService: vi.fn(),
|
||||
getFullContext: vi.fn().mockReturnValue(false),
|
||||
getToolRegistry: vi.fn().mockReturnValue({ getTool: vi.fn() }),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('includes startup context when skipStartupContext is false', async () => {
|
||||
const history = await getInitialChatHistory(mockConfig as Config);
|
||||
|
||||
expect(mockConfig.getSkipStartupContext).toHaveBeenCalled();
|
||||
expect(history).toHaveLength(2);
|
||||
expect(history).toEqual([
|
||||
expect.objectContaining({
|
||||
role: 'user',
|
||||
parts: [
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining(
|
||||
"I'm currently working in the directory",
|
||||
),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'Got it. Thanks for the context!' }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns only extra history when skipStartupContext is true', async () => {
|
||||
mockConfig.getSkipStartupContext = vi.fn().mockReturnValue(true);
|
||||
mockConfig.getWorkspaceContext = vi.fn(() => {
|
||||
throw new Error(
|
||||
'getWorkspaceContext should not be called when skipping startup context',
|
||||
);
|
||||
});
|
||||
mockConfig.getFullContext = vi.fn(() => {
|
||||
throw new Error(
|
||||
'getFullContext should not be called when skipping startup context',
|
||||
);
|
||||
});
|
||||
mockConfig.getToolRegistry = vi.fn(() => {
|
||||
throw new Error(
|
||||
'getToolRegistry should not be called when skipping startup context',
|
||||
);
|
||||
});
|
||||
const extraHistory: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'custom context' }] },
|
||||
];
|
||||
|
||||
const history = await getInitialChatHistory(
|
||||
mockConfig as Config,
|
||||
extraHistory,
|
||||
);
|
||||
|
||||
expect(mockConfig.getSkipStartupContext).toHaveBeenCalled();
|
||||
expect(history).toEqual(extraHistory);
|
||||
expect(history).not.toBe(extraHistory);
|
||||
});
|
||||
|
||||
it('returns empty history when skipping startup context without extras', async () => {
|
||||
mockConfig.getSkipStartupContext = vi.fn().mockReturnValue(true);
|
||||
mockConfig.getWorkspaceContext = vi.fn(() => {
|
||||
throw new Error(
|
||||
'getWorkspaceContext should not be called when skipping startup context',
|
||||
);
|
||||
});
|
||||
mockConfig.getFullContext = vi.fn(() => {
|
||||
throw new Error(
|
||||
'getFullContext should not be called when skipping startup context',
|
||||
);
|
||||
});
|
||||
mockConfig.getToolRegistry = vi.fn(() => {
|
||||
throw new Error(
|
||||
'getToolRegistry should not be called when skipping startup context',
|
||||
);
|
||||
});
|
||||
|
||||
const history = await getInitialChatHistory(mockConfig as Config);
|
||||
|
||||
expect(history).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,6 +112,10 @@ export async function getInitialChatHistory(
|
||||
config: Config,
|
||||
extraHistory?: Content[],
|
||||
): Promise<Content[]> {
|
||||
if (config.getSkipStartupContext()) {
|
||||
return extraHistory ? [...extraHistory] : [];
|
||||
}
|
||||
|
||||
const envParts = await getEnvironmentContext(config);
|
||||
const envContextString = envParts.map((part) => part.text || '').join('\n\n');
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
readFileWithEncoding,
|
||||
fileExists,
|
||||
} from './fileUtils.js';
|
||||
import { StandardFileSystemService } from '../services/fileSystemService.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
|
||||
vi.mock('mime/lite', () => ({
|
||||
default: { getType: vi.fn() },
|
||||
@@ -50,6 +50,12 @@ describe('fileUtils', () => {
|
||||
let nonexistentFilePath: string;
|
||||
let directoryPath: string;
|
||||
|
||||
const mockConfig = {
|
||||
getTruncateToolOutputThreshold: () => 2500,
|
||||
getTruncateToolOutputLines: () => 500,
|
||||
getTargetDir: () => tempRootDir,
|
||||
} as unknown as Config;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks(); // Reset all mocks, including mime.getType
|
||||
|
||||
@@ -664,8 +670,7 @@ describe('fileUtils', () => {
|
||||
actualNodeFs.writeFileSync(testTextFilePath, content);
|
||||
const result = await processSingleFileContent(
|
||||
testTextFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
mockConfig,
|
||||
);
|
||||
expect(result.llmContent).toBe(content);
|
||||
expect(result.returnDisplay).toBe('');
|
||||
@@ -675,8 +680,7 @@ describe('fileUtils', () => {
|
||||
it('should handle file not found', async () => {
|
||||
const result = await processSingleFileContent(
|
||||
nonexistentFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
mockConfig,
|
||||
);
|
||||
expect(result.error).toContain('File not found');
|
||||
expect(result.returnDisplay).toContain('File not found');
|
||||
@@ -689,8 +693,7 @@ describe('fileUtils', () => {
|
||||
|
||||
const result = await processSingleFileContent(
|
||||
testTextFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
mockConfig,
|
||||
);
|
||||
expect(result.error).toContain('Simulated read error');
|
||||
expect(result.returnDisplay).toContain('Simulated read error');
|
||||
@@ -704,8 +707,7 @@ describe('fileUtils', () => {
|
||||
|
||||
const result = await processSingleFileContent(
|
||||
testImageFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
mockConfig,
|
||||
);
|
||||
expect(result.error).toContain('Simulated image read error');
|
||||
expect(result.returnDisplay).toContain('Simulated image read error');
|
||||
@@ -717,8 +719,7 @@ describe('fileUtils', () => {
|
||||
mockMimeGetType.mockReturnValue('image/png');
|
||||
const result = await processSingleFileContent(
|
||||
testImageFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
mockConfig,
|
||||
);
|
||||
expect(
|
||||
(result.llmContent as { inlineData: unknown }).inlineData,
|
||||
@@ -739,8 +740,7 @@ describe('fileUtils', () => {
|
||||
mockMimeGetType.mockReturnValue('application/pdf');
|
||||
const result = await processSingleFileContent(
|
||||
testPdfFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
mockConfig,
|
||||
);
|
||||
expect(
|
||||
(result.llmContent as { inlineData: unknown }).inlineData,
|
||||
@@ -768,8 +768,7 @@ describe('fileUtils', () => {
|
||||
|
||||
const result = await processSingleFileContent(
|
||||
testSvgFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
expect(result.llmContent).toBe(svgContent);
|
||||
@@ -786,8 +785,7 @@ describe('fileUtils', () => {
|
||||
|
||||
const result = await processSingleFileContent(
|
||||
testBinaryFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
mockConfig,
|
||||
);
|
||||
expect(result.llmContent).toContain(
|
||||
'Cannot display content of binary file',
|
||||
@@ -796,11 +794,7 @@ describe('fileUtils', () => {
|
||||
});
|
||||
|
||||
it('should handle path being a directory', async () => {
|
||||
const result = await processSingleFileContent(
|
||||
directoryPath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
);
|
||||
const result = await processSingleFileContent(directoryPath, mockConfig);
|
||||
expect(result.error).toContain('Path is a directory');
|
||||
expect(result.returnDisplay).toContain('Path is a directory');
|
||||
});
|
||||
@@ -811,8 +805,7 @@ describe('fileUtils', () => {
|
||||
|
||||
const result = await processSingleFileContent(
|
||||
testTextFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
mockConfig,
|
||||
5,
|
||||
5,
|
||||
); // Read lines 6-10
|
||||
@@ -832,8 +825,7 @@ describe('fileUtils', () => {
|
||||
// Read from line 11 to 20. The start is not 0, so it's truncated.
|
||||
const result = await processSingleFileContent(
|
||||
testTextFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
mockConfig,
|
||||
10,
|
||||
10,
|
||||
);
|
||||
@@ -852,8 +844,7 @@ describe('fileUtils', () => {
|
||||
|
||||
const result = await processSingleFileContent(
|
||||
testTextFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
mockConfig,
|
||||
0,
|
||||
10,
|
||||
);
|
||||
@@ -875,17 +866,16 @@ describe('fileUtils', () => {
|
||||
|
||||
const result = await processSingleFileContent(
|
||||
testTextFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
expect(result.llmContent).toContain('Short line');
|
||||
expect(result.llmContent).toContain(
|
||||
longLine.substring(0, 2000) + '... [truncated]',
|
||||
);
|
||||
expect(result.llmContent).toContain('Another short line');
|
||||
expect(result.llmContent).not.toContain('Another short line');
|
||||
expect(result.returnDisplay).toBe(
|
||||
'Read all 3 lines from test.txt (some lines were shortened)',
|
||||
'Read lines 1-2 of 3 from test.txt (truncated)',
|
||||
);
|
||||
expect(result.isTruncated).toBe(true);
|
||||
});
|
||||
@@ -897,8 +887,7 @@ describe('fileUtils', () => {
|
||||
// Read 5 lines, but there are 11 total
|
||||
const result = await processSingleFileContent(
|
||||
testTextFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
mockConfig,
|
||||
0,
|
||||
5,
|
||||
);
|
||||
@@ -916,15 +905,14 @@ describe('fileUtils', () => {
|
||||
// Read all 11 lines, including the long one
|
||||
const result = await processSingleFileContent(
|
||||
testTextFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
mockConfig,
|
||||
0,
|
||||
11,
|
||||
);
|
||||
|
||||
expect(result.isTruncated).toBe(true);
|
||||
expect(result.returnDisplay).toBe(
|
||||
'Read all 11 lines from test.txt (some lines were shortened)',
|
||||
'Read lines 1-11 of 11 from test.txt (truncated)',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -942,14 +930,13 @@ describe('fileUtils', () => {
|
||||
// Read 10 lines out of 20, including the long line
|
||||
const result = await processSingleFileContent(
|
||||
testTextFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
mockConfig,
|
||||
0,
|
||||
10,
|
||||
);
|
||||
expect(result.isTruncated).toBe(true);
|
||||
expect(result.returnDisplay).toBe(
|
||||
'Read lines 1-10 of 20 from test.txt (some lines were shortened)',
|
||||
'Read lines 1-5 of 20 from test.txt (truncated)',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -966,8 +953,7 @@ describe('fileUtils', () => {
|
||||
try {
|
||||
const result = await processSingleFileContent(
|
||||
testTextFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
expect(result.error).toContain('File size exceeds the 20MB limit');
|
||||
|
||||
@@ -9,13 +9,9 @@ import fsPromises from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { PartUnion } from '@google/genai';
|
||||
import mime from 'mime/lite';
|
||||
import type { FileSystemService } from '../services/fileSystemService.js';
|
||||
import { ToolErrorType } from '../tools/tool-error.js';
|
||||
import { BINARY_EXTENSIONS } from './ignorePatterns.js';
|
||||
|
||||
// Constants for text file processing
|
||||
export const DEFAULT_MAX_LINES_TEXT_FILE = 2000;
|
||||
const MAX_LINE_LENGTH_TEXT_FILE = 2000;
|
||||
import type { Config } from '../config/config.js';
|
||||
|
||||
// Default values for encoding and separator format
|
||||
export const DEFAULT_ENCODING: BufferEncoding = 'utf-8';
|
||||
@@ -306,18 +302,18 @@ export interface ProcessedFileReadResult {
|
||||
/**
|
||||
* Reads and processes a single file, handling text, images, and PDFs.
|
||||
* @param filePath Absolute path to the file.
|
||||
* @param rootDirectory Absolute path to the project root for relative path display.
|
||||
* @param config Config instance for truncation settings.
|
||||
* @param offset Optional offset for text files (0-based line number).
|
||||
* @param limit Optional limit for text files (number of lines to read).
|
||||
* @returns ProcessedFileReadResult object.
|
||||
*/
|
||||
export async function processSingleFileContent(
|
||||
filePath: string,
|
||||
rootDirectory: string,
|
||||
fileSystemService: FileSystemService,
|
||||
config: Config,
|
||||
offset?: number,
|
||||
limit?: number,
|
||||
): Promise<ProcessedFileReadResult> {
|
||||
const rootDirectory = config.getTargetDir();
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
// Sync check is acceptable before async read
|
||||
@@ -379,45 +375,76 @@ export async function processSingleFileContent(
|
||||
case 'text': {
|
||||
// Use BOM-aware reader to avoid leaving a BOM character in content and to support UTF-16/32 transparently
|
||||
const content = await readFileWithEncoding(filePath);
|
||||
const lines = content.split('\n');
|
||||
const lines = content.split('\n').map((line) => line.trimEnd());
|
||||
const originalLineCount = lines.length;
|
||||
|
||||
const startLine = offset || 0;
|
||||
const effectiveLimit =
|
||||
limit === undefined ? DEFAULT_MAX_LINES_TEXT_FILE : limit;
|
||||
const configLineLimit = config.getTruncateToolOutputLines();
|
||||
const configCharLimit = config.getTruncateToolOutputThreshold();
|
||||
const effectiveLimit = limit === undefined ? configLineLimit : limit;
|
||||
|
||||
// Ensure endLine does not exceed originalLineCount
|
||||
const endLine = Math.min(startLine + effectiveLimit, originalLineCount);
|
||||
// Ensure selectedLines doesn't try to slice beyond array bounds if startLine is too high
|
||||
const actualStartLine = Math.min(startLine, originalLineCount);
|
||||
const selectedLines = lines.slice(actualStartLine, endLine);
|
||||
|
||||
let linesWereTruncatedInLength = false;
|
||||
const formattedLines = selectedLines.map((line) => {
|
||||
if (line.length > MAX_LINE_LENGTH_TEXT_FILE) {
|
||||
linesWereTruncatedInLength = true;
|
||||
return (
|
||||
line.substring(0, MAX_LINE_LENGTH_TEXT_FILE) + '... [truncated]'
|
||||
);
|
||||
// Apply character limit truncation
|
||||
let llmContent = '';
|
||||
let contentLengthTruncated = false;
|
||||
let linesIncluded = 0;
|
||||
|
||||
if (Number.isFinite(configCharLimit)) {
|
||||
const formattedLines: string[] = [];
|
||||
let currentLength = 0;
|
||||
|
||||
for (const line of selectedLines) {
|
||||
const sep = linesIncluded > 0 ? 1 : 0; // newline separator
|
||||
linesIncluded++;
|
||||
|
||||
const projectedLength = currentLength + line.length + sep;
|
||||
if (projectedLength <= configCharLimit) {
|
||||
formattedLines.push(line);
|
||||
currentLength = projectedLength;
|
||||
} else {
|
||||
// Truncate the current line to fit
|
||||
const remaining = Math.max(
|
||||
configCharLimit - currentLength - sep,
|
||||
10,
|
||||
);
|
||||
formattedLines.push(
|
||||
line.substring(0, remaining) + '... [truncated]',
|
||||
);
|
||||
contentLengthTruncated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return line;
|
||||
});
|
||||
|
||||
llmContent = formattedLines.join('\n');
|
||||
} else {
|
||||
// No character limit, use all selected lines
|
||||
llmContent = selectedLines.join('\n');
|
||||
linesIncluded = selectedLines.length;
|
||||
}
|
||||
|
||||
// Calculate actual end line shown
|
||||
const actualEndLine = contentLengthTruncated
|
||||
? actualStartLine + linesIncluded
|
||||
: endLine;
|
||||
|
||||
const contentRangeTruncated =
|
||||
startLine > 0 || endLine < originalLineCount;
|
||||
const isTruncated = contentRangeTruncated || linesWereTruncatedInLength;
|
||||
const llmContent = formattedLines.join('\n');
|
||||
startLine > 0 || actualEndLine < originalLineCount;
|
||||
const isTruncated = contentRangeTruncated || contentLengthTruncated;
|
||||
|
||||
// By default, return nothing to streamline the common case of a successful read_file.
|
||||
let returnDisplay = '';
|
||||
if (contentRangeTruncated) {
|
||||
if (isTruncated) {
|
||||
returnDisplay = `Read lines ${
|
||||
actualStartLine + 1
|
||||
}-${endLine} of ${originalLineCount} from ${relativePathForDisplay}`;
|
||||
if (linesWereTruncatedInLength) {
|
||||
returnDisplay += ' (some lines were shortened)';
|
||||
}-${actualEndLine} of ${originalLineCount} from ${relativePathForDisplay}`;
|
||||
if (contentLengthTruncated) {
|
||||
returnDisplay += ' (truncated)';
|
||||
}
|
||||
} else if (linesWereTruncatedInLength) {
|
||||
returnDisplay = `Read all ${originalLineCount} lines from ${relativePathForDisplay} (some lines were shortened)`;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -425,7 +452,7 @@ export async function processSingleFileContent(
|
||||
returnDisplay,
|
||||
isTruncated,
|
||||
originalLineCount,
|
||||
linesShown: [actualStartLine + 1, endLine],
|
||||
linesShown: [actualStartLine + 1, actualEndLine],
|
||||
};
|
||||
}
|
||||
case 'image':
|
||||
|
||||
381
packages/core/src/utils/openaiLogger.test.ts
Normal file
381
packages/core/src/utils/openaiLogger.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,6 +29,8 @@ const createMockConfig = (
|
||||
getTargetDir: () => cwd,
|
||||
getFileSystemService: () => fileSystemService,
|
||||
getFileService: () => mockFileService,
|
||||
getTruncateToolOutputThreshold: () => 2500,
|
||||
getTruncateToolOutputLines: () => 500,
|
||||
} as unknown as Config;
|
||||
};
|
||||
|
||||
|
||||
@@ -83,11 +83,7 @@ export async function readPathFromWorkspace(
|
||||
for (const filePath of finalFiles) {
|
||||
const relativePathForDisplay = path.relative(absolutePath, filePath);
|
||||
allParts.push({ text: `--- ${relativePathForDisplay} ---\n` });
|
||||
const result = await processSingleFileContent(
|
||||
filePath,
|
||||
config.getTargetDir(),
|
||||
config.getFileSystemService(),
|
||||
);
|
||||
const result = await processSingleFileContent(filePath, config);
|
||||
allParts.push(result.llmContent);
|
||||
allParts.push({ text: '\n' }); // Add a newline for separation
|
||||
}
|
||||
@@ -108,11 +104,7 @@ export async function readPathFromWorkspace(
|
||||
}
|
||||
|
||||
// It's a single file, process it directly.
|
||||
const result = await processSingleFileContent(
|
||||
absolutePath,
|
||||
config.getTargetDir(),
|
||||
config.getFileSystemService(),
|
||||
);
|
||||
const result = await processSingleFileContent(absolutePath, config);
|
||||
return [result.llmContent];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,53 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { escapePath, unescapePath, isSubpath } from './paths.js';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import {
|
||||
escapePath,
|
||||
resolvePath,
|
||||
validatePath,
|
||||
resolveAndValidatePath,
|
||||
unescapePath,
|
||||
isSubpath,
|
||||
} from './paths.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
|
||||
function createConfigStub({
|
||||
targetDir,
|
||||
allowedDirectories,
|
||||
}: {
|
||||
targetDir: string;
|
||||
allowedDirectories: string[];
|
||||
}): Config {
|
||||
const resolvedTargetDir = path.resolve(targetDir);
|
||||
const resolvedDirectories = allowedDirectories.map((dir) =>
|
||||
path.resolve(dir),
|
||||
);
|
||||
|
||||
const workspaceContext = {
|
||||
isPathWithinWorkspace(testPath: string) {
|
||||
const resolvedPath = path.resolve(testPath);
|
||||
return resolvedDirectories.some((dir) => {
|
||||
const relative = path.relative(dir, resolvedPath);
|
||||
return (
|
||||
relative === '' ||
|
||||
(!relative.startsWith('..') && !path.isAbsolute(relative))
|
||||
);
|
||||
});
|
||||
},
|
||||
getDirectories() {
|
||||
return resolvedDirectories;
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
getTargetDir: () => resolvedTargetDir,
|
||||
getWorkspaceContext: () => workspaceContext,
|
||||
} as unknown as Config;
|
||||
}
|
||||
|
||||
describe('escapePath', () => {
|
||||
it('should escape spaces', () => {
|
||||
@@ -314,3 +359,240 @@ describe('isSubpath on Windows', () => {
|
||||
expect(isSubpath('Users\\Test\\file.txt', 'Users\\Test')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePath', () => {
|
||||
it('resolves relative paths against the provided base directory', () => {
|
||||
const result = resolvePath('/home/user/project', 'src/main.ts');
|
||||
expect(result).toBe(path.resolve('/home/user/project', 'src/main.ts'));
|
||||
});
|
||||
|
||||
it('resolves relative paths against cwd when baseDir is undefined', () => {
|
||||
const cwd = process.cwd();
|
||||
const result = resolvePath(undefined, 'src/main.ts');
|
||||
expect(result).toBe(path.resolve(cwd, 'src/main.ts'));
|
||||
});
|
||||
|
||||
it('returns absolute paths unchanged', () => {
|
||||
const absolutePath = '/absolute/path/to/file.ts';
|
||||
const result = resolvePath('/some/base', absolutePath);
|
||||
expect(result).toBe(absolutePath);
|
||||
});
|
||||
|
||||
it('expands tilde to home directory', () => {
|
||||
const homeDir = os.homedir();
|
||||
const result = resolvePath(undefined, '~');
|
||||
expect(result).toBe(homeDir);
|
||||
});
|
||||
|
||||
it('expands tilde-prefixed paths to home directory', () => {
|
||||
const homeDir = os.homedir();
|
||||
const result = resolvePath(undefined, '~/documents/file.txt');
|
||||
expect(result).toBe(path.join(homeDir, 'documents/file.txt'));
|
||||
});
|
||||
|
||||
it('uses baseDir when provided for relative paths', () => {
|
||||
const baseDir = '/custom/base';
|
||||
const result = resolvePath(baseDir, './relative/path');
|
||||
expect(result).toBe(path.resolve(baseDir, './relative/path'));
|
||||
});
|
||||
|
||||
it('handles tilde expansion regardless of baseDir', () => {
|
||||
const homeDir = os.homedir();
|
||||
const result = resolvePath('/some/base', '~/file.txt');
|
||||
expect(result).toBe(path.join(homeDir, 'file.txt'));
|
||||
});
|
||||
|
||||
it('handles dot paths correctly', () => {
|
||||
const result = resolvePath('/base/dir', '.');
|
||||
expect(result).toBe(path.resolve('/base/dir', '.'));
|
||||
});
|
||||
|
||||
it('handles parent directory references', () => {
|
||||
const result = resolvePath('/base/dir/subdir', '..');
|
||||
expect(result).toBe(path.resolve('/base/dir/subdir', '..'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePath', () => {
|
||||
let workspaceRoot: string;
|
||||
let config: Config;
|
||||
|
||||
beforeAll(() => {
|
||||
workspaceRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'validate-path-test-'),
|
||||
);
|
||||
fs.mkdirSync(path.join(workspaceRoot, 'subdir'));
|
||||
config = createConfigStub({
|
||||
targetDir: workspaceRoot,
|
||||
allowedDirectories: [workspaceRoot],
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(workspaceRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('validates paths within workspace boundaries', () => {
|
||||
const validPath = path.join(workspaceRoot, 'subdir');
|
||||
expect(() => validatePath(config, validPath)).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws when path is outside workspace boundaries', () => {
|
||||
const outsidePath = path.join(os.tmpdir(), 'outside');
|
||||
expect(() => validatePath(config, outsidePath)).toThrowError(
|
||||
/Path is not within workspace/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when path does not exist', () => {
|
||||
const nonExistentPath = path.join(workspaceRoot, 'nonexistent');
|
||||
expect(() => validatePath(config, nonExistentPath)).toThrowError(
|
||||
/Path does not exist:/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when path is a file, not a directory (default behavior)', () => {
|
||||
const filePath = path.join(workspaceRoot, 'test-file.txt');
|
||||
fs.writeFileSync(filePath, 'content');
|
||||
try {
|
||||
expect(() => validatePath(config, filePath)).toThrowError(
|
||||
/Path is not a directory/,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(filePath);
|
||||
}
|
||||
});
|
||||
|
||||
it('allows files when allowFiles option is true', () => {
|
||||
const filePath = path.join(workspaceRoot, 'test-file.txt');
|
||||
fs.writeFileSync(filePath, 'content');
|
||||
try {
|
||||
expect(() =>
|
||||
validatePath(config, filePath, { allowFiles: true }),
|
||||
).not.toThrow();
|
||||
} finally {
|
||||
fs.rmSync(filePath);
|
||||
}
|
||||
});
|
||||
|
||||
it('validates paths at workspace root', () => {
|
||||
expect(() => validatePath(config, workspaceRoot)).not.toThrow();
|
||||
});
|
||||
|
||||
it('validates paths in allowed directories', () => {
|
||||
const extraDir = fs.mkdtempSync(path.join(os.tmpdir(), 'validate-extra-'));
|
||||
try {
|
||||
const configWithExtra = createConfigStub({
|
||||
targetDir: workspaceRoot,
|
||||
allowedDirectories: [workspaceRoot, extraDir],
|
||||
});
|
||||
expect(() => validatePath(configWithExtra, extraDir)).not.toThrow();
|
||||
} finally {
|
||||
fs.rmSync(extraDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAndValidatePath', () => {
|
||||
let workspaceRoot: string;
|
||||
let config: Config;
|
||||
|
||||
beforeAll(() => {
|
||||
workspaceRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'resolve-and-validate-'),
|
||||
);
|
||||
fs.mkdirSync(path.join(workspaceRoot, 'subdir'));
|
||||
config = createConfigStub({
|
||||
targetDir: workspaceRoot,
|
||||
allowedDirectories: [workspaceRoot],
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(workspaceRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns the target directory when no path is provided', () => {
|
||||
expect(resolveAndValidatePath(config)).toBe(workspaceRoot);
|
||||
});
|
||||
|
||||
it('resolves relative paths within the workspace', () => {
|
||||
const expected = path.join(workspaceRoot, 'subdir');
|
||||
expect(resolveAndValidatePath(config, 'subdir')).toBe(expected);
|
||||
});
|
||||
|
||||
it('allows absolute paths that are permitted by the workspace context', () => {
|
||||
const extraDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'resolve-and-validate-extra-'),
|
||||
);
|
||||
try {
|
||||
const configWithExtra = createConfigStub({
|
||||
targetDir: workspaceRoot,
|
||||
allowedDirectories: [workspaceRoot, extraDir],
|
||||
});
|
||||
expect(resolveAndValidatePath(configWithExtra, extraDir)).toBe(extraDir);
|
||||
} finally {
|
||||
fs.rmSync(extraDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('expands tilde-prefixed paths using the home directory', () => {
|
||||
const fakeHome = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'resolve-and-validate-home-'),
|
||||
);
|
||||
const homeSubdir = path.join(fakeHome, 'project');
|
||||
fs.mkdirSync(homeSubdir);
|
||||
|
||||
const homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(fakeHome);
|
||||
try {
|
||||
const configWithHome = createConfigStub({
|
||||
targetDir: workspaceRoot,
|
||||
allowedDirectories: [workspaceRoot, fakeHome],
|
||||
});
|
||||
expect(resolveAndValidatePath(configWithHome, '~/project')).toBe(
|
||||
homeSubdir,
|
||||
);
|
||||
expect(resolveAndValidatePath(configWithHome, '~')).toBe(fakeHome);
|
||||
} finally {
|
||||
homedirSpy.mockRestore();
|
||||
fs.rmSync(fakeHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('throws when the path resolves outside of the workspace', () => {
|
||||
expect(() => resolveAndValidatePath(config, '../outside')).toThrowError(
|
||||
/Path is not within workspace/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the path does not exist', () => {
|
||||
expect(() => resolveAndValidatePath(config, 'missing')).toThrowError(
|
||||
/Path does not exist:/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the path points to a file (default behavior)', () => {
|
||||
const filePath = path.join(workspaceRoot, 'file.txt');
|
||||
fs.writeFileSync(filePath, 'content');
|
||||
try {
|
||||
expect(() => resolveAndValidatePath(config, 'file.txt')).toThrowError(
|
||||
`Path is not a directory: ${filePath}`,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(filePath);
|
||||
}
|
||||
});
|
||||
|
||||
it('allows file paths when allowFiles option is true', () => {
|
||||
const filePath = path.join(workspaceRoot, 'file.txt');
|
||||
fs.writeFileSync(filePath, 'content');
|
||||
try {
|
||||
const result = resolveAndValidatePath(config, 'file.txt', {
|
||||
allowFiles: true,
|
||||
});
|
||||
expect(result).toBe(filePath);
|
||||
} finally {
|
||||
fs.rmSync(filePath);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import * as crypto from 'node:crypto';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { isNodeError } from './errors.js';
|
||||
|
||||
export const QWEN_DIR = '.qwen';
|
||||
export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json';
|
||||
@@ -191,3 +194,93 @@ export function isSubpath(parentPath: string, childPath: string): boolean {
|
||||
!pathModule.isAbsolute(relative)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a path with tilde (~) expansion and relative path resolution.
|
||||
* Handles tilde expansion for home directory and resolves relative paths
|
||||
* against the provided base directory or current working directory.
|
||||
*
|
||||
* @param baseDir The base directory to resolve relative paths against (defaults to current working directory)
|
||||
* @param relativePath The path to resolve (can be relative, absolute, or tilde-prefixed)
|
||||
* @returns The resolved absolute path
|
||||
*/
|
||||
export function resolvePath(
|
||||
baseDir: string | undefined = process.cwd(),
|
||||
relativePath: string,
|
||||
): string {
|
||||
const homeDir = os.homedir();
|
||||
|
||||
if (relativePath === '~') {
|
||||
return homeDir;
|
||||
} else if (relativePath.startsWith('~/')) {
|
||||
return path.join(homeDir, relativePath.slice(2));
|
||||
} else if (path.isAbsolute(relativePath)) {
|
||||
return relativePath;
|
||||
} else {
|
||||
return path.resolve(baseDir, relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
export interface PathValidationOptions {
|
||||
/**
|
||||
* If true, allows both files and directories. If false (default), only allows directories.
|
||||
*/
|
||||
allowFiles?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a resolved path exists within the workspace boundaries.
|
||||
*
|
||||
* @param config The configuration object containing workspace context
|
||||
* @param resolvedPath The absolute path to validate
|
||||
* @param options Validation options
|
||||
* @throws Error if the path is outside workspace boundaries, doesn't exist, or is not a directory (when allowFiles is false)
|
||||
*/
|
||||
export function validatePath(
|
||||
config: Config,
|
||||
resolvedPath: string,
|
||||
options: PathValidationOptions = {},
|
||||
): void {
|
||||
const { allowFiles = false } = options;
|
||||
const workspaceContext = config.getWorkspaceContext();
|
||||
|
||||
if (!workspaceContext.isPathWithinWorkspace(resolvedPath)) {
|
||||
throw new Error('Path is not within workspace');
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = fs.statSync(resolvedPath);
|
||||
if (!allowFiles && !stats.isDirectory()) {
|
||||
throw new Error(`Path is not a directory: ${resolvedPath}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
throw new Error(`Path does not exist: ${resolvedPath}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a path relative to the workspace root and verifies that it exists
|
||||
* within the workspace boundaries defined in the config.
|
||||
*
|
||||
* @param config The configuration object
|
||||
* @param relativePath The relative path to resolve (optional, defaults to target directory)
|
||||
* @param options Validation options (e.g., allowFiles to permit file paths)
|
||||
*/
|
||||
export function resolveAndValidatePath(
|
||||
config: Config,
|
||||
relativePath?: string,
|
||||
options: PathValidationOptions = {},
|
||||
): string {
|
||||
const targetDir = config.getTargetDir();
|
||||
|
||||
if (!relativePath) {
|
||||
return targetDir;
|
||||
}
|
||||
|
||||
const resolvedPath = resolvePath(targetDir, relativePath);
|
||||
validatePath(config, resolvedPath, options);
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code-test-utils",
|
||||
"version": "0.1.3",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -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.3",
|
||||
"version": "0.2.0",
|
||||
"publisher": "qwenlm",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user