Compare commits

...

47 Commits

Author SHA1 Message Date
pomelo-nwu
3a69931791 feat: add docs for logging dir configuration 2025-11-05 18:58:53 +08:00
pomelo-nwu
d4ab328671 feat: support for custom OpenAI logging directory configuration 2025-11-05 18:49:04 +08:00
pomelo
335e765df0 Merge pull request #936 from QwenLM/fix-AbortError
fix: handle AbortError gracefully when loading commands
2025-11-05 16:38:14 +08:00
pomelo-nwu
448e30bf88 feat: support custom working directory for child process in start.js 2025-11-05 16:06:35 +08:00
pomelo
22bd108775 Merge pull request #885 from QwenLM/web-search
chore: Web Search Tool Refactoring with Multi-Provider Support
2025-11-05 14:51:40 +08:00
pomelo-nwu
7ff07fd88c fix(web-search): handle unconfigured state and improve tests 2025-11-05 11:37:56 +08:00
pomelo-nwu
2967bec11c feat: update code 2025-11-05 11:23:27 +08:00
pomelo-nwu
6357a5c87e feat(web-search): enable DashScope provider only for Qwen OAuth auth type 2025-11-04 19:59:19 +08:00
tanzhenxin
7e827833bf chore: pump version to 0.1.4 (#962) 2025-11-04 19:22:37 +08:00
pomelo-nwu
d1507e73fe feat(web-search): use resource_url from credentials for DashScope endpoint 2025-11-04 16:59:30 +08:00
tanzhenxin
45f1000dea fix (#958) 2025-11-04 15:53:31 +08:00
tanzhenxin
04f0996327 fix: /ide install failed to run on Windows (#957) 2025-11-04 15:53:03 +08:00
tanzhenxin
d8cc0a1f04 fix: #923 missing macos seatbelt files in npm package (#949) 2025-11-04 15:52:46 +08:00
pomelo-nwu
512c91a969 Merge branch 'main' into web-search 2025-11-03 17:34:03 +08:00
tanzhenxin
ff8a8ac693 chore: pump version to 0.1.3 (#939) 2025-10-31 19:22:18 +08:00
tanzhenxin
908ac5e1b0 fix: partial settings migration (#937) 2025-10-31 18:12:59 +08:00
tanzhenxin
ea4a7a2368 fix: compression tool (#935) 2025-10-31 18:09:08 +08:00
pomelo-nwu
50d5cc2f6a fix: handle AbortError gracefully when loading commands 2025-10-31 17:00:28 +08:00
pomelo
5386099559 Merge pull request #933 from vinhnx/tool-name-title-in-ToolsList
fix: update tool name from Gemini to Qwen Code in ToolsList component…
2025-10-31 16:21:57 +08:00
Huarong
495a9d6d92 change Launch Gemini CLI to Qwen Code CLI in help information (#929) 2025-10-31 11:36:31 +08:00
Vinh Nguyen
db58aaff3a fix: update tool name from Gemini to Qwen Code in ToolsList component and snapshots 2025-10-31 10:25:49 +07:00
tanzhenxin
817218f1cf feat: Refactor and Enhance Ripgrep Tool (#930) 2025-10-31 10:53:13 +08:00
pomelo
7843de882a feat: fix sessionId (#927) 2025-10-31 10:23:09 +08:00
pomelo-nwu
40d82a2b25 feat: add docs for web search tool 2025-10-31 10:19:44 +08:00
pomelo-nwu
a40479d40a feat: adjust the description of the web search tool 2025-10-30 20:21:30 +08:00
pomelo-nwu
7cb068ceb2 Merge branch 'main' into web-search 2025-10-30 19:42:00 +08:00
pomelo-nwu
864bf03fee docs: add DashScope quota limits to web search documentation
- Add quota information (200 requests/minute, 2000 requests/day) to DashScope provider description
- Update provider details section with quota limits
2025-10-30 19:06:46 +08:00
pomelo-nwu
9a41db612a Add unit tests for web search core logic 2025-10-30 16:18:41 +08:00
pomelo-nwu
4781736f99 Improve web search fallback with snippet and web_fetch hint 2025-10-30 16:15:42 +08:00
yjw0628
ced79cf4e3 fixbug: fix qwen help des (#915) 2025-10-29 19:09:17 +08:00
tanzhenxin
33e22713a0 chore: pump version to v0.1.2 (#907) 2025-10-29 15:15:05 +08:00
tanzhenxin
92245f0f00 Merge branch 'main' of https://github.com/QwenLM/qwen-code 2025-10-29 14:25:31 +08:00
tanzhenxin
4f35f7431a fix: e2e test on cloud build 2025-10-29 14:25:15 +08:00
pomelo
84957bbb50 Merge pull request #904 from Willam2004/fix/docs
[to #12345678] docs: update excludeTools documentation in extensions …
2025-10-29 14:03:18 +08:00
tanzhenxin
c1164bdd7e fix: e2e test (#905) 2025-10-29 13:58:41 +08:00
tanzhenxin
f8be8a61c8 🐛 Bug Fixes Release v0.1.1 (#898) 2025-10-29 12:25:50 +08:00
家娃
c884dc080b [to #12345678] docs: update excludeTools documentation in extensions guide
- Added clarification that tools specified in excludeTools will be disabled for the entire conversation context
- Added note that excludeTools configuration affects all subsequent queries in the current session

This change improves documentation clarity for extension developers by better explaining the scope and impact of the excludeTools configuration.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-10-29 10:55:57 +08:00
pomelo
32a71986d5 Merge pull request #892 from UncleFB/main
fix input filter
2025-10-29 10:33:40 +08:00
UncleFB
6da6bc0dfd fix input filter 2025-10-28 14:38:46 +08:00
pomelo-nwu
7ccba75621 test: update /chat list test to match plain text output
Updated the test expectations to match the new plain text format
without ANSI escape codes.
2025-10-28 09:15:07 +08:00
pomelo-nwu
e0e5fa5084 fix: remove hardcoded ANSI escape codes in /chat list command
The /chat list command was displaying raw ANSI escape codes instead of
colored text. This was caused by the escapeAnsiCtrlCodes function in
HistoryItemDisplay that escapes all ANSI control characters.

Changed to plain text format for better compatibility and cleaner output.
2025-10-28 09:14:00 +08:00
pomelo-nwu
799d2bf0db feat: add oauth credit token 2025-10-27 19:59:13 +08:00
tanzhenxin
65cf80f4ab chore: pump version to 0.1.1 (#883) 2025-10-27 19:32:52 +08:00
pomelo-nwu
741eaf91c2 feat: add web_search docs 2025-10-27 17:05:47 +08:00
pomelo-nwu
79b4821499 feat: Optimize the code 2025-10-27 11:24:38 +08:00
pomelo-nwu
b1ece177b7 feat: Optimize the code 2025-10-27 11:01:48 +08:00
pomelo-nwu
f9f6eb52dd feat: add multi websearch provider 2025-10-24 17:16:14 +08:00
89 changed files with 4316 additions and 1676 deletions

View File

@@ -309,7 +309,8 @@ If you are experiencing performance issues with file searching (e.g., with `@` c
``` ```
- **`tavilyApiKey`** (string): - **`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) - **Default:** `undefined` (web search disabled)
- **Example:** `"tavilyApiKey": "tvly-your-api-key-here"` - **Example:** `"tavilyApiKey": "tvly-your-api-key-here"`
- **`chatCompression`** (object): - **`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. - This is useful for development and testing.
- **`TAVILY_API_KEY`**: - **`TAVILY_API_KEY`**:
- Your API key for the Tavily web search service. - Your API key for the Tavily web search service.
- Required to enable the `web_search` tool functionality. - Used to enable the `web_search` tool functionality.
- If not configured, the web search tool will be disabled and skipped. - **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"` - Example: `export TAVILY_API_KEY="tvly-your-api-key-here"`
## Command-Line Arguments ## Command-Line Arguments
@@ -540,6 +541,9 @@ Arguments passed directly when running the CLI can override other configurations
- Displays the version of the CLI. - Displays the version of the CLI.
- **`--openai-logging`**: - **`--openai-logging`**:
- Enables logging of OpenAI API calls for debugging and analysis. This flag overrides the `enableOpenAILogging` setting in `settings.json`. - 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>`**: - **`--tavily-api-key <api_key>`**:
- Sets the Tavily API key for web search functionality for this session. - Sets the Tavily API key for web search functionality for this session.
- Example: `qwen --tavily-api-key tvly-your-api-key-here` - Example: `qwen --tavily-api-key tvly-your-api-key-here`

View File

@@ -171,6 +171,18 @@ Settings are organized into categories. All settings should be placed within the
- **Description:** Disables loop detection checks. Loop detection prevents infinite loops in AI responses but can generate false positives that interrupt legitimate workflows. Enable this option if you experience frequent false positive loop detection interruptions. - **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` - **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`
- **`context.fileName`** (string or array of strings): - **`context.fileName`** (string or array of strings):
@@ -246,6 +258,14 @@ Settings are organized into categories. All settings should be placed within the
- It must return function output as JSON on `stdout`, analogous to [`functionResponse.response.content`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functionresponse). - It must return function output as JSON on `stdout`, analogous to [`functionResponse.response.content`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functionresponse).
- **Default:** `undefined` - **Default:** `undefined`
- **`tools.useRipgrep`** (boolean):
- **Description:** Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance.
- **Default:** `true`
- **`tools.useBuiltinRipgrep`** (boolean):
- **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`
#### `mcp` #### `mcp`
- **`mcp.serverCommand`** (string): - **`mcp.serverCommand`** (string):
@@ -297,7 +317,8 @@ Settings are organized into categories. All settings should be placed within the
- **Default:** `undefined` - **Default:** `undefined`
- **`advanced.tavilyApiKey`** (string): - **`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` - **Default:** `undefined`
#### `mcpServers` #### `mcpServers`
@@ -378,6 +399,8 @@ Here is an example of a `settings.json` file with the nested structure, new as o
"model": { "model": {
"name": "qwen3-coder-plus", "name": "qwen3-coder-plus",
"maxSessionTurns": 10, "maxSessionTurns": 10,
"enableOpenAILogging": false,
"openAILoggingDir": "~/qwen-logs",
"summarizeToolOutput": { "summarizeToolOutput": {
"run_shell_command": { "run_shell_command": {
"tokenBudget": 100 "tokenBudget": 100
@@ -466,8 +489,8 @@ The CLI automatically loads environment variables from an `.env` file. The loadi
- Set to a string to customize the title of the CLI. - Set to a string to customize the title of the CLI.
- **`TAVILY_API_KEY`**: - **`TAVILY_API_KEY`**:
- Your API key for the Tavily web search service. - Your API key for the Tavily web search service.
- Required to enable the `web_search` tool functionality. - Used to enable the `web_search` tool functionality.
- If not configured, the web search tool will be disabled and skipped. - **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"` - Example: `export TAVILY_API_KEY="tvly-your-api-key-here"`
## Command-Line Arguments ## Command-Line Arguments
@@ -548,6 +571,9 @@ Arguments passed directly when running the CLI can override other configurations
- Displays the version of the CLI. - Displays the version of the CLI.
- **`--openai-logging`**: - **`--openai-logging`**:
- Enables logging of OpenAI API calls for debugging and analysis. This flag overrides the `enableOpenAILogging` setting in `settings.json`. - 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>`**: - **`--tavily-api-key <api_key>`**:
- Sets the Tavily API key for web search functionality for this session. - Sets the Tavily API key for web search functionality for this session.
- Example: `qwen --tavily-api-key tvly-your-api-key-here` - Example: `qwen --tavily-api-key tvly-your-api-key-here`

View File

@@ -107,7 +107,7 @@ The `qwen-extension.json` file contains the configuration for the extension. The
- `mcpServers`: A map of MCP servers to configure. The key is the name of the server, and the value is the server configuration. These servers will be loaded on startup just like MCP servers configured in a [`settings.json` file](./cli/configuration.md). If both an extension and a `settings.json` file configure an MCP server with the same name, the server defined in the `settings.json` file takes precedence. - `mcpServers`: A map of MCP servers to configure. The key is the name of the server, and the value is the server configuration. These servers will be loaded on startup just like MCP servers configured in a [`settings.json` file](./cli/configuration.md). If both an extension and a `settings.json` file configure an MCP server with the same name, the server defined in the `settings.json` file takes precedence.
- Note that all MCP server configuration options are supported except for `trust`. - Note that all MCP server configuration options are supported except for `trust`.
- `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the extension directory. If this property is not used but a `QWEN.md` file is present in your extension directory, then that file will be loaded. - `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the extension directory. If this property is not used but a `QWEN.md` file is present in your extension directory, then that file will be loaded.
- `excludeTools`: An array of tool names to exclude from the model. You can also specify command-specific restrictions for tools that support it, like the `run_shell_command` tool. For example, `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf` command. Note that this differs from the MCP server `excludeTools` functionality, which can be listed in the MCP server config. - `excludeTools`: An array of tool names to exclude from the model. You can also specify command-specific restrictions for tools that support it, like the `run_shell_command` tool. For example, `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf` command. Note that this differs from the MCP server `excludeTools` functionality, which can be listed in the MCP server config. **Important:** Tools specified in `excludeTools` will be disabled for the entire conversation context and will affect all subsequent queries in the current session.
When Qwen Code starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes precedence. When Qwen Code starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes precedence.

View File

@@ -1,43 +1,186 @@
# Web Search Tool (`web_search`) # 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 ## 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 ### 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` Add 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
If the key is not configured, the tool will be disabled and skipped. ```json
{
Usage: "webSearch": {
"provider": [
``` { "type": "dashscope" },
web_search(query="Your query goes here.") { "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)
``` ### Method 2: Environment Variables
web_search(query="latest advancements in AI-powered code generation")
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. Pass API keys when running Qwen Code:
- **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. ```bash
# Tavily
qwen --tavily-api-key tvly-xxxxx
# Google
qwen --google-api-key your-key --google-search-engine-id your-id
# Specify default provider
qwen --web-search-default tavily
```
### Backward Compatibility (Deprecated)
⚠️ **DEPRECATED:** The legacy `tavilyApiKey` configuration is still supported for backward compatibility but is deprecated:
```json
{
"advanced": {
"tavilyApiKey": "tvly-xxxxx" // ⚠️ Deprecated
}
}
```
**Important:** This configuration is deprecated and will be removed in a future version. Please migrate to the new `webSearch` configuration format shown above. The old configuration will automatically configure Tavily as a provider, but we strongly recommend updating your configuration.
## Disabling Web Search
If you want to disable the web search functionality, you can exclude the `web_search` tool in your `settings.json`:
```json
{
"tools": {
"exclude": ["web_search"]
}
}
```
**Note:** This setting requires a restart of Qwen Code to take effect. Once disabled, the `web_search` tool will not be available to the model, even if web search providers are configured.
## Usage Examples
### Basic search (using default provider)
```
web_search(query="latest advancements in AI")
```
### Search with specific provider
```
web_search(query="latest advancements in AI", provider="tavily")
```
### Real-world examples
```
web_search(query="weather in San Francisco today")
web_search(query="latest Node.js LTS version", provider="google")
web_search(query="best practices for React 19", provider="dashscope")
```
## Provider Details
### DashScope (Official)
- **Cost:** Free
- **Authentication:** Automatically available when using Qwen OAuth authentication
- **Configuration:** No API key required, automatically added to provider list for Qwen OAuth users
- **Quota:** 200 requests/minute, 2000 requests/day
- **Best for:** General queries, always available as fallback for Qwen OAuth users
- **Auto-registration:** If you're using Qwen OAuth, DashScope is automatically added to your provider list even if you don't configure it explicitly
### Tavily
- **Cost:** Requires API key (paid service with free tier)
- **Sign up:** https://tavily.com
- **Features:** High-quality results with AI-generated answers
- **Best for:** Research, comprehensive answers with citations
### Google Custom Search
- **Cost:** Free tier available (100 queries/day)
- **Setup:**
1. Enable Custom Search API in Google Cloud Console
2. Create a Custom Search Engine at https://programmablesearchengine.google.com
- **Features:** Google's search quality
- **Best for:** Specific, factual queries
## Important Notes
- **Response format:** Returns a concise answer with numbered source citations
- **Citations:** Source links are appended as a numbered list: [1], [2], etc.
- **Multiple providers:** If one provider fails, manually specify another using the `provider` parameter
- **DashScope availability:** Automatically available for Qwen OAuth users, no configuration needed
- **Default provider selection:** The system automatically selects a default provider based on availability:
1. Your explicit `default` configuration (highest priority)
2. CLI argument `--web-search-default`
3. First available provider by priority: Tavily > Google > DashScope
## Troubleshooting
**Tool not available?**
- **For Qwen OAuth users:** The tool is automatically registered with DashScope provider, no configuration needed
- **For other authentication types:** Ensure at least one provider (Tavily or Google) is configured
- For Tavily/Google: Verify your API keys are correct
**Provider-specific errors?**
- Use the `provider` parameter to try a different search provider
- Check your API quotas and rate limits
- Verify API keys are properly set in configuration
**Need help?**
- Check your configuration: Run `qwen` and use the settings dialog
- View your current settings in `~/.qwen-code/settings.json` (macOS/Linux) or `%USERPROFILE%\.qwen-code\settings.json` (Windows)

View File

@@ -7,7 +7,7 @@
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import { writeFileSync } from 'node:fs'; import { writeFileSync, rmSync } from 'node:fs';
let esbuild; let esbuild;
try { try {
@@ -22,6 +22,9 @@ const __dirname = path.dirname(__filename);
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const pkg = require(path.resolve(__dirname, 'package.json')); const pkg = require(path.resolve(__dirname, 'package.json'));
// Clean dist directory (cross-platform)
rmSync(path.resolve(__dirname, 'dist'), { recursive: true, force: true });
const external = [ const external = [
'@lydell/node-pty', '@lydell/node-pty',
'node-pty', 'node-pty',

View File

@@ -36,10 +36,10 @@ describe('JSON output', () => {
}); });
it('should return a JSON error for enforced auth mismatch before running', async () => { it('should return a JSON error for enforced auth mismatch before running', async () => {
process.env['GOOGLE_GENAI_USE_GCA'] = 'true'; process.env['OPENAI_API_KEY'] = 'test-key';
await rig.setup('json-output-auth-mismatch', { await rig.setup('json-output-auth-mismatch', {
settings: { settings: {
security: { auth: { enforcedType: 'gemini-api-key' } }, security: { auth: { enforcedType: 'qwen-oauth' } },
}, },
}); });
@@ -50,7 +50,7 @@ describe('JSON output', () => {
} catch (e) { } catch (e) {
thrown = e as Error; thrown = e as Error;
} finally { } finally {
delete process.env['GOOGLE_GENAI_USE_GCA']; delete process.env['OPENAI_API_KEY'];
} }
expect(thrown).toBeDefined(); expect(thrown).toBeDefined();
@@ -80,10 +80,8 @@ describe('JSON output', () => {
expect(payload.error.type).toBe('Error'); expect(payload.error.type).toBe('Error');
expect(payload.error.code).toBe(1); expect(payload.error.code).toBe(1);
expect(payload.error.message).toContain( expect(payload.error.message).toContain(
'configured auth type is gemini-api-key', 'configured auth type is qwen-oauth',
);
expect(payload.error.message).toContain(
'current auth type is oauth-personal',
); );
expect(payload.error.message).toContain('current auth type is openai');
}); });
}); });

View File

@@ -9,7 +9,6 @@ import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
import { join, dirname } from 'node:path'; import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { env } from 'node:process'; import { env } from 'node:process';
import { DEFAULT_QWEN_MODEL } from '../packages/core/src/config/models.js';
import fs from 'node:fs'; import fs from 'node:fs';
import { EOL } from 'node:os'; import { EOL } from 'node:os';
import * as pty from '@lydell/node-pty'; import * as pty from '@lydell/node-pty';
@@ -182,7 +181,6 @@ export class TestRig {
otlpEndpoint: '', otlpEndpoint: '',
outfile: telemetryPath, outfile: telemetryPath,
}, },
model: DEFAULT_QWEN_MODEL,
sandbox: env.GEMINI_SANDBOX !== 'false' ? env.GEMINI_SANDBOX : false, sandbox: env.GEMINI_SANDBOX !== 'false' ? env.GEMINI_SANDBOX : false,
...options.settings, // Allow tests to override/add settings ...options.settings, // Allow tests to override/add settings
}; };

View File

@@ -9,14 +9,53 @@ import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
describe('web_search', () => { describe('web_search', () => {
it('should be able to search the web', async () => { it('should be able to search the web', async () => {
// Skip if Tavily key is not configured // Check if any web search provider is available
if (!process.env['TAVILY_API_KEY']) { const hasTavilyKey = !!process.env['TAVILY_API_KEY'];
console.warn('Skipping web search test: TAVILY_API_KEY not set'); 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; return;
} }
const rig = new TestRig(); 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; let result;
try { try {

12
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@qwen-code/qwen-code", "name": "@qwen-code/qwen-code",
"version": "0.1.0", "version": "0.1.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@qwen-code/qwen-code", "name": "@qwen-code/qwen-code",
"version": "0.1.0", "version": "0.1.4",
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],
@@ -16024,7 +16024,7 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@qwen-code/qwen-code", "name": "@qwen-code/qwen-code",
"version": "0.1.0", "version": "0.1.4",
"dependencies": { "dependencies": {
"@google/genai": "1.16.0", "@google/genai": "1.16.0",
"@iarna/toml": "^2.2.5", "@iarna/toml": "^2.2.5",
@@ -16139,7 +16139,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@qwen-code/qwen-code-core", "name": "@qwen-code/qwen-code-core",
"version": "0.1.0", "version": "0.1.4",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@google/genai": "1.16.0", "@google/genai": "1.16.0",
@@ -16278,7 +16278,7 @@
}, },
"packages/test-utils": { "packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils", "name": "@qwen-code/qwen-code-test-utils",
"version": "0.1.0", "version": "0.1.4",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"devDependencies": { "devDependencies": {
@@ -16290,7 +16290,7 @@
}, },
"packages/vscode-ide-companion": { "packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion", "name": "qwen-code-vscode-ide-companion",
"version": "0.1.0", "version": "0.1.4",
"license": "LICENSE", "license": "LICENSE",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.15.1", "@modelcontextprotocol/sdk": "^1.15.1",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@qwen-code/qwen-code", "name": "@qwen-code/qwen-code",
"version": "0.1.0", "version": "0.1.4",
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
}, },
@@ -13,7 +13,7 @@
"url": "git+https://github.com/QwenLM/qwen-code.git" "url": "git+https://github.com/QwenLM/qwen-code.git"
}, },
"config": { "config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.1.0" "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.1.4"
}, },
"scripts": { "scripts": {
"start": "cross-env node scripts/start.js", "start": "cross-env node scripts/start.js",
@@ -28,7 +28,7 @@
"build:all": "npm run build && npm run build:sandbox && npm run build:vscode", "build:all": "npm run build && npm run build:sandbox && npm run build:vscode",
"build:packages": "npm run build --workspaces", "build:packages": "npm run build --workspaces",
"build:sandbox": "node scripts/build_sandbox.js", "build:sandbox": "node scripts/build_sandbox.js",
"bundle": "rm -rf dist && npm run generate && node esbuild.config.js && node scripts/copy_bundle_assets.js", "bundle": "npm run generate && node esbuild.config.js && node scripts/copy_bundle_assets.js",
"test": "npm run test --workspaces --if-present --parallel", "test": "npm run test --workspaces --if-present --parallel",
"test:ci": "npm run test:ci --workspaces --if-present --parallel && npm run test:scripts", "test:ci": "npm run test:ci --workspaces --if-present --parallel && npm run test:scripts",
"test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts", "test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts",

View File

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

View File

@@ -18,60 +18,26 @@ vi.mock('./settings.js', () => ({
describe('validateAuthMethod', () => { describe('validateAuthMethod', () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
vi.stubEnv('GEMINI_API_KEY', undefined);
vi.stubEnv('GOOGLE_CLOUD_PROJECT', undefined);
vi.stubEnv('GOOGLE_CLOUD_LOCATION', undefined);
vi.stubEnv('GOOGLE_API_KEY', undefined);
}); });
afterEach(() => { afterEach(() => {
vi.unstubAllEnvs(); vi.unstubAllEnvs();
}); });
it('should return null for LOGIN_WITH_GOOGLE', () => { it('should return null for USE_OPENAI', () => {
expect(validateAuthMethod(AuthType.LOGIN_WITH_GOOGLE)).toBeNull(); process.env['OPENAI_API_KEY'] = 'fake-key';
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBeNull();
}); });
it('should return null for CLOUD_SHELL', () => { it('should return an error message for USE_OPENAI if OPENAI_API_KEY is not set', () => {
expect(validateAuthMethod(AuthType.CLOUD_SHELL)).toBeNull(); delete process.env['OPENAI_API_KEY'];
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBe(
'OPENAI_API_KEY environment variable not found. You can enter it interactively or add it to your .env file.',
);
}); });
describe('USE_GEMINI', () => { it('should return null for QWEN_OAUTH', () => {
it('should return null if GEMINI_API_KEY is set', () => { expect(validateAuthMethod(AuthType.QWEN_OAUTH)).toBeNull();
vi.stubEnv('GEMINI_API_KEY', 'test-key');
expect(validateAuthMethod(AuthType.USE_GEMINI)).toBeNull();
});
it('should return an error message if GEMINI_API_KEY is not set', () => {
vi.stubEnv('GEMINI_API_KEY', undefined);
expect(validateAuthMethod(AuthType.USE_GEMINI)).toBe(
'GEMINI_API_KEY environment variable not found. Add that to your environment and try again (no reload needed if using .env)!',
);
});
});
describe('USE_VERTEX_AI', () => {
it('should return null if GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION are set', () => {
vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'test-project');
vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'test-location');
expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBeNull();
});
it('should return null if GOOGLE_API_KEY is set', () => {
vi.stubEnv('GOOGLE_API_KEY', 'test-api-key');
expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBeNull();
});
it('should return an error message if no required environment variables are set', () => {
vi.stubEnv('GOOGLE_CLOUD_PROJECT', undefined);
vi.stubEnv('GOOGLE_CLOUD_LOCATION', undefined);
expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBe(
'When using Vertex AI, you must specify either:\n' +
'• GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION environment variables.\n' +
'• GOOGLE_API_KEY environment variable (if using express mode).\n' +
'Update your environment and try again (no reload needed if using .env)!',
);
});
}); });
it('should return an error message for an invalid auth method', () => { it('should return an error message for an invalid auth method', () => {

View File

@@ -8,39 +8,13 @@ import { AuthType } from '@qwen-code/qwen-code-core';
import { loadEnvironment, loadSettings } from './settings.js'; import { loadEnvironment, loadSettings } from './settings.js';
export function validateAuthMethod(authMethod: string): string | null { export function validateAuthMethod(authMethod: string): string | null {
loadEnvironment(loadSettings().merged); const settings = loadSettings();
if ( loadEnvironment(settings.merged);
authMethod === AuthType.LOGIN_WITH_GOOGLE ||
authMethod === AuthType.CLOUD_SHELL
) {
return null;
}
if (authMethod === AuthType.USE_GEMINI) {
if (!process.env['GEMINI_API_KEY']) {
return 'GEMINI_API_KEY environment variable not found. Add that to your environment and try again (no reload needed if using .env)!';
}
return null;
}
if (authMethod === AuthType.USE_VERTEX_AI) {
const hasVertexProjectLocationConfig =
!!process.env['GOOGLE_CLOUD_PROJECT'] &&
!!process.env['GOOGLE_CLOUD_LOCATION'];
const hasGoogleApiKey = !!process.env['GOOGLE_API_KEY'];
if (!hasVertexProjectLocationConfig && !hasGoogleApiKey) {
return (
'When using Vertex AI, you must specify either:\n' +
'• GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION environment variables.\n' +
'• GOOGLE_API_KEY environment variable (if using express mode).\n' +
'Update your environment and try again (no reload needed if using .env)!'
);
}
return null;
}
if (authMethod === AuthType.USE_OPENAI) { if (authMethod === AuthType.USE_OPENAI) {
if (!process.env['OPENAI_API_KEY']) { const hasApiKey =
process.env['OPENAI_API_KEY'] || settings.merged.security?.auth?.apiKey;
if (!hasApiKey) {
return 'OPENAI_API_KEY environment variable not found. You can enter it interactively or add it to your .env file.'; return 'OPENAI_API_KEY environment variable not found. You can enter it interactively or add it to your .env file.';
} }
return null; return null;
@@ -54,15 +28,3 @@ export function validateAuthMethod(authMethod: string): string | null {
return 'Invalid auth method selected.'; return 'Invalid auth method selected.';
} }
export const setOpenAIApiKey = (apiKey: string): void => {
process.env['OPENAI_API_KEY'] = apiKey;
};
export const setOpenAIBaseUrl = (baseUrl: string): void => {
process.env['OPENAI_BASE_URL'] = baseUrl;
};
export const setOpenAIModel = (model: string): void => {
process.env['OPENAI_MODEL'] = model;
};

View File

@@ -2399,6 +2399,73 @@ describe('loadCliConfig useRipgrep', () => {
}); });
}); });
describe('loadCliConfig useBuiltinRipgrep', () => {
const originalArgv = process.argv;
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
});
afterEach(() => {
process.argv = originalArgv;
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('should be true by default when useBuiltinRipgrep is not set in settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {};
const config = await loadCliConfig(
settings,
[],
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getUseBuiltinRipgrep()).toBe(true);
});
it('should be false when useBuiltinRipgrep is set to false in settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = { tools: { useBuiltinRipgrep: false } };
const config = await loadCliConfig(
settings,
[],
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getUseBuiltinRipgrep()).toBe(false);
});
it('should be true when useBuiltinRipgrep is explicitly set to true in settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = { tools: { useBuiltinRipgrep: true } };
const config = await loadCliConfig(
settings,
[],
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getUseBuiltinRipgrep()).toBe(true);
});
});
describe('screenReader configuration', () => { describe('screenReader configuration', () => {
const originalArgv = process.argv; const originalArgv = process.argv;

View File

@@ -13,7 +13,6 @@ import { extensionsCommand } from '../commands/extensions.js';
import { import {
ApprovalMode, ApprovalMode,
Config, Config,
DEFAULT_QWEN_MODEL,
DEFAULT_QWEN_EMBEDDING_MODEL, DEFAULT_QWEN_EMBEDDING_MODEL,
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
EditTool, EditTool,
@@ -43,6 +42,7 @@ import { mcpCommand } from '../commands/mcp.js';
import { isWorkspaceTrusted } from './trustedFolders.js'; import { isWorkspaceTrusted } from './trustedFolders.js';
import type { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; import type { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
import { buildWebSearchConfig } from './webSearch.js';
// Simple console logger for now - replace with actual logger if available // Simple console logger for now - replace with actual logger if available
const logger = { const logger = {
@@ -114,9 +114,13 @@ export interface CliArgs {
openaiLogging: boolean | undefined; openaiLogging: boolean | undefined;
openaiApiKey: string | undefined; openaiApiKey: string | undefined;
openaiBaseUrl: string | undefined; openaiBaseUrl: string | undefined;
openaiLoggingDir: string | undefined;
proxy: string | undefined; proxy: string | undefined;
includeDirectories: string[] | undefined; includeDirectories: string[] | undefined;
tavilyApiKey: string | undefined; tavilyApiKey: string | undefined;
googleApiKey: string | undefined;
googleSearchEngineId: string | undefined;
webSearchDefault: string | undefined;
screenReader: boolean | undefined; screenReader: boolean | undefined;
vlmSwitchMode: string | undefined; vlmSwitchMode: string | undefined;
useSmartEdit: boolean | undefined; useSmartEdit: boolean | undefined;
@@ -194,14 +198,13 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
}) })
.option('proxy', { .option('proxy', {
type: 'string', type: 'string',
description: description: 'Proxy for Qwen Code, like schema://user:password@host:port',
'Proxy for gemini client, like schema://user:password@host:port',
}) })
.deprecateOption( .deprecateOption(
'proxy', 'proxy',
'Use the "proxy" setting in settings.json instead. This flag will be removed in a future version.', 'Use the "proxy" setting in settings.json instead. This flag will be removed in a future version.',
) )
.command('$0 [query..]', 'Launch Gemini CLI', (yargsInstance: Argv) => .command('$0 [query..]', 'Launch Qwen Code CLI', (yargsInstance: Argv) =>
yargsInstance yargsInstance
.positional('query', { .positional('query', {
description: description:
@@ -315,6 +318,11 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
description: description:
'Enable logging of OpenAI API calls for debugging and analysis', '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', { .option('openai-api-key', {
type: 'string', type: 'string',
description: 'OpenAI API key to use for authentication', description: 'OpenAI API key to use for authentication',
@@ -325,7 +333,20 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
}) })
.option('tavily-api-key', { .option('tavily-api-key', {
type: 'string', 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', { .option('screen-reader', {
type: 'boolean', type: 'boolean',
@@ -669,13 +690,11 @@ export async function loadCliConfig(
); );
} }
const defaultModel = DEFAULT_QWEN_MODEL; const resolvedModel =
const resolvedModel: string =
argv.model || argv.model ||
process.env['OPENAI_MODEL'] || process.env['OPENAI_MODEL'] ||
process.env['QWEN_MODEL'] || process.env['QWEN_MODEL'] ||
settings.model?.name || settings.model?.name;
defaultModel;
const sandboxConfig = await loadSandboxConfig(settings, argv); const sandboxConfig = await loadSandboxConfig(settings, argv);
const screenReader = const screenReader =
@@ -739,18 +758,27 @@ export async function loadCliConfig(
generationConfig: { generationConfig: {
...(settings.model?.generationConfig || {}), ...(settings.model?.generationConfig || {}),
model: resolvedModel, model: resolvedModel,
apiKey: argv.openaiApiKey || process.env['OPENAI_API_KEY'], apiKey:
baseUrl: argv.openaiBaseUrl || process.env['OPENAI_BASE_URL'], argv.openaiApiKey ||
process.env['OPENAI_API_KEY'] ||
settings.security?.auth?.apiKey,
baseUrl:
argv.openaiBaseUrl ||
process.env['OPENAI_BASE_URL'] ||
settings.security?.auth?.baseUrl,
enableOpenAILogging: enableOpenAILogging:
(typeof argv.openaiLogging === 'undefined' (typeof argv.openaiLogging === 'undefined'
? settings.model?.enableOpenAILogging ? settings.model?.enableOpenAILogging
: argv.openaiLogging) ?? false, : argv.openaiLogging) ?? false,
openAILoggingDir:
argv.openaiLoggingDir || settings.model?.openAILoggingDir,
}, },
cliVersion: await getCliVersion(), cliVersion: await getCliVersion(),
tavilyApiKey: webSearch: buildWebSearchConfig(
argv.tavilyApiKey || argv,
settings.advanced?.tavilyApiKey || settings,
process.env['TAVILY_API_KEY'], settings.security?.auth?.selectedType,
),
summarizeToolOutput: settings.model?.summarizeToolOutput, summarizeToolOutput: settings.model?.summarizeToolOutput,
ideMode, ideMode,
chatCompression: settings.model?.chatCompression, chatCompression: settings.model?.chatCompression,
@@ -758,6 +786,7 @@ export async function loadCliConfig(
interactive, interactive,
trustedFolder, trustedFolder,
useRipgrep: settings.tools?.useRipgrep, useRipgrep: settings.tools?.useRipgrep,
useBuiltinRipgrep: settings.tools?.useBuiltinRipgrep,
shouldUseNodePtyShell: settings.tools?.shell?.enableInteractiveShell, shouldUseNodePtyShell: settings.tools?.shell?.enableInteractiveShell,
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck, skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
enablePromptCompletion: settings.general?.enablePromptCompletion ?? false, enablePromptCompletion: settings.general?.enablePromptCompletion ?? false,

View File

@@ -66,6 +66,8 @@ import {
loadEnvironment, loadEnvironment,
migrateDeprecatedSettings, migrateDeprecatedSettings,
SettingScope, SettingScope,
SETTINGS_VERSION,
SETTINGS_VERSION_KEY,
} from './settings.js'; } from './settings.js';
import { FatalConfigError, QWEN_DIR } from '@qwen-code/qwen-code-core'; import { FatalConfigError, QWEN_DIR } from '@qwen-code/qwen-code-core';
@@ -94,6 +96,7 @@ vi.mock('fs', async (importOriginal) => {
existsSync: vi.fn(), existsSync: vi.fn(),
readFileSync: vi.fn(), readFileSync: vi.fn(),
writeFileSync: vi.fn(), writeFileSync: vi.fn(),
renameSync: vi.fn(),
mkdirSync: vi.fn(), mkdirSync: vi.fn(),
realpathSync: (p: string) => p, realpathSync: (p: string) => p,
}; };
@@ -171,11 +174,15 @@ describe('Settings Loading and Merging', () => {
getSystemSettingsPath(), getSystemSettingsPath(),
'utf-8', 'utf-8',
); );
expect(settings.system.settings).toEqual(systemSettingsContent); expect(settings.system.settings).toEqual({
...systemSettingsContent,
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
});
expect(settings.user.settings).toEqual({}); expect(settings.user.settings).toEqual({});
expect(settings.workspace.settings).toEqual({}); expect(settings.workspace.settings).toEqual({});
expect(settings.merged).toEqual({ expect(settings.merged).toEqual({
...systemSettingsContent, ...systemSettingsContent,
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
}); });
}); });
@@ -207,10 +214,14 @@ describe('Settings Loading and Merging', () => {
expectedUserSettingsPath, expectedUserSettingsPath,
'utf-8', 'utf-8',
); );
expect(settings.user.settings).toEqual(userSettingsContent); expect(settings.user.settings).toEqual({
...userSettingsContent,
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
});
expect(settings.workspace.settings).toEqual({}); expect(settings.workspace.settings).toEqual({});
expect(settings.merged).toEqual({ expect(settings.merged).toEqual({
...userSettingsContent, ...userSettingsContent,
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
}); });
}); });
@@ -241,9 +252,13 @@ describe('Settings Loading and Merging', () => {
'utf-8', 'utf-8',
); );
expect(settings.user.settings).toEqual({}); expect(settings.user.settings).toEqual({});
expect(settings.workspace.settings).toEqual(workspaceSettingsContent); expect(settings.workspace.settings).toEqual({
...workspaceSettingsContent,
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
});
expect(settings.merged).toEqual({ expect(settings.merged).toEqual({
...workspaceSettingsContent, ...workspaceSettingsContent,
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
}); });
}); });
@@ -304,10 +319,20 @@ describe('Settings Loading and Merging', () => {
const settings = loadSettings(MOCK_WORKSPACE_DIR); const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.system.settings).toEqual(systemSettingsContent); expect(settings.system.settings).toEqual({
expect(settings.user.settings).toEqual(userSettingsContent); ...systemSettingsContent,
expect(settings.workspace.settings).toEqual(workspaceSettingsContent); [SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
});
expect(settings.user.settings).toEqual({
...userSettingsContent,
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
});
expect(settings.workspace.settings).toEqual({
...workspaceSettingsContent,
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
});
expect(settings.merged).toEqual({ expect(settings.merged).toEqual({
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
ui: { ui: {
theme: 'system-theme', theme: 'system-theme',
}, },
@@ -361,6 +386,7 @@ describe('Settings Loading and Merging', () => {
const settings = loadSettings(MOCK_WORKSPACE_DIR); const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged).toEqual({ expect(settings.merged).toEqual({
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
ui: { ui: {
theme: 'legacy-dark', theme: 'legacy-dark',
}, },
@@ -413,6 +439,132 @@ describe('Settings Loading and Merging', () => {
expect((settings.merged as TestSettings)['allowedTools']).toBeUndefined(); expect((settings.merged as TestSettings)['allowedTools']).toBeUndefined();
}); });
it('should add version field to migrated settings file', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const legacySettingsContent = {
theme: 'dark',
model: 'qwen-coder',
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(legacySettingsContent);
return '{}';
},
);
loadSettings(MOCK_WORKSPACE_DIR);
// Verify that fs.writeFileSync was called with migrated settings including version
expect(fs.writeFileSync).toHaveBeenCalled();
const writeCall = (fs.writeFileSync as Mock).mock.calls[0];
const writtenContent = JSON.parse(writeCall[1] as string);
expect(writtenContent[SETTINGS_VERSION_KEY]).toBe(SETTINGS_VERSION);
});
it('should not re-migrate settings that have version field', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const migratedSettingsContent = {
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
ui: {
theme: 'dark',
},
model: {
name: 'qwen-coder',
},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(migratedSettingsContent);
return '{}';
},
);
loadSettings(MOCK_WORKSPACE_DIR);
// Verify that fs.renameSync and fs.writeFileSync were NOT called
// (because no migration was needed)
expect(fs.renameSync).not.toHaveBeenCalled();
expect(fs.writeFileSync).not.toHaveBeenCalled();
});
it('should add version field to V2 settings without version and write to disk', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
// V2 format but no version field
const v2SettingsWithoutVersion = {
ui: {
theme: 'dark',
},
model: {
name: 'qwen-coder',
},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(v2SettingsWithoutVersion);
return '{}';
},
);
loadSettings(MOCK_WORKSPACE_DIR);
// Verify that fs.writeFileSync was called (to add version)
// but NOT fs.renameSync (no backup needed, just adding version)
expect(fs.renameSync).not.toHaveBeenCalled();
expect(fs.writeFileSync).toHaveBeenCalledTimes(1);
const writeCall = (fs.writeFileSync as Mock).mock.calls[0];
const writtenPath = writeCall[0];
const writtenContent = JSON.parse(writeCall[1] as string);
expect(writtenPath).toBe(USER_SETTINGS_PATH);
expect(writtenContent[SETTINGS_VERSION_KEY]).toBe(SETTINGS_VERSION);
expect(writtenContent.ui?.theme).toBe('dark');
expect(writtenContent.model?.name).toBe('qwen-coder');
});
it('should correctly handle partially migrated settings without version field', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
// Edge case: model already in V2 format (object), but autoAccept in V1 format
const partiallyMigratedContent = {
model: {
name: 'qwen-coder',
},
autoAccept: false, // V1 key
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(partiallyMigratedContent);
return '{}';
},
);
loadSettings(MOCK_WORKSPACE_DIR);
// Verify that the migrated settings preserve the model object correctly
expect(fs.writeFileSync).toHaveBeenCalled();
const writeCall = (fs.writeFileSync as Mock).mock.calls[0];
const writtenContent = JSON.parse(writeCall[1] as string);
// Model should remain as an object, not double-nested
expect(writtenContent.model).toEqual({ name: 'qwen-coder' });
// autoAccept should be migrated to tools.autoAccept
expect(writtenContent.tools?.autoAccept).toBe(false);
// Version field should be added
expect(writtenContent[SETTINGS_VERSION_KEY]).toBe(SETTINGS_VERSION);
});
it('should correctly merge and migrate legacy array properties from multiple scopes', () => { it('should correctly merge and migrate legacy array properties from multiple scopes', () => {
(mockFsExistsSync as Mock).mockReturnValue(true); (mockFsExistsSync as Mock).mockReturnValue(true);
const legacyUserSettings = { const legacyUserSettings = {
@@ -515,11 +667,24 @@ describe('Settings Loading and Merging', () => {
const settings = loadSettings(MOCK_WORKSPACE_DIR); const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.systemDefaults.settings).toEqual(systemDefaultsContent); expect(settings.systemDefaults.settings).toEqual({
expect(settings.system.settings).toEqual(systemSettingsContent); ...systemDefaultsContent,
expect(settings.user.settings).toEqual(userSettingsContent); [SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
expect(settings.workspace.settings).toEqual(workspaceSettingsContent); });
expect(settings.system.settings).toEqual({
...systemSettingsContent,
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
});
expect(settings.user.settings).toEqual({
...userSettingsContent,
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
});
expect(settings.workspace.settings).toEqual({
...workspaceSettingsContent,
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
});
expect(settings.merged).toEqual({ expect(settings.merged).toEqual({
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
context: { context: {
fileName: 'WORKSPACE_CONTEXT.md', fileName: 'WORKSPACE_CONTEXT.md',
includeDirectories: [ includeDirectories: [
@@ -866,8 +1031,14 @@ describe('Settings Loading and Merging', () => {
const settings = loadSettings(MOCK_WORKSPACE_DIR); const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.user.settings).toEqual(userSettingsContent); expect(settings.user.settings).toEqual({
expect(settings.workspace.settings).toEqual(workspaceSettingsContent); ...userSettingsContent,
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
});
expect(settings.workspace.settings).toEqual({
...workspaceSettingsContent,
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
});
expect(settings.merged.mcpServers).toEqual({ expect(settings.merged.mcpServers).toEqual({
'user-server': { 'user-server': {
command: 'user-command', command: 'user-command',
@@ -1696,9 +1867,13 @@ describe('Settings Loading and Merging', () => {
'utf-8', 'utf-8',
); );
expect(settings.system.path).toBe(MOCK_ENV_SYSTEM_SETTINGS_PATH); expect(settings.system.path).toBe(MOCK_ENV_SYSTEM_SETTINGS_PATH);
expect(settings.system.settings).toEqual(systemSettingsContent); expect(settings.system.settings).toEqual({
...systemSettingsContent,
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
});
expect(settings.merged).toEqual({ expect(settings.merged).toEqual({
...systemSettingsContent, ...systemSettingsContent,
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
}); });
}); });
}); });
@@ -2248,6 +2423,44 @@ describe('Settings Loading and Merging', () => {
customWittyPhrases: ['test phrase'], customWittyPhrases: ['test phrase'],
}); });
}); });
it('should remove version field when migrating to V1', () => {
const v2Settings = {
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
ui: {
theme: 'dark',
},
model: {
name: 'qwen-coder',
},
};
const v1Settings = migrateSettingsToV1(v2Settings);
// Version field should not be present in V1 settings
expect(v1Settings[SETTINGS_VERSION_KEY]).toBeUndefined();
// Other fields should be properly migrated
expect(v1Settings).toEqual({
theme: 'dark',
model: 'qwen-coder',
});
});
it('should handle version field in unrecognized properties', () => {
const v2Settings = {
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
general: {
vimMode: true,
},
someUnrecognizedKey: 'value',
};
const v1Settings = migrateSettingsToV1(v2Settings);
// Version field should be filtered out
expect(v1Settings[SETTINGS_VERSION_KEY]).toBeUndefined();
// Unrecognized keys should be preserved
expect(v1Settings['someUnrecognizedKey']).toBe('value');
expect(v1Settings['vimMode']).toBe(true);
});
}); });
describe('loadEnvironment', () => { describe('loadEnvironment', () => {
@@ -2368,6 +2581,73 @@ describe('Settings Loading and Merging', () => {
}; };
expect(needsMigration(settings)).toBe(false); expect(needsMigration(settings)).toBe(false);
}); });
describe('with version field', () => {
it('should return false when version field indicates current or newer version', () => {
const settingsWithVersion = {
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
theme: 'dark', // Even though this is a V1 key, version field takes precedence
};
expect(needsMigration(settingsWithVersion)).toBe(false);
});
it('should return false when version field indicates a newer version', () => {
const settingsWithNewerVersion = {
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION + 1,
theme: 'dark',
};
expect(needsMigration(settingsWithNewerVersion)).toBe(false);
});
it('should return true when version field indicates an older version', () => {
const settingsWithOldVersion = {
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION - 1,
theme: 'dark',
};
expect(needsMigration(settingsWithOldVersion)).toBe(true);
});
it('should use fallback logic when version field is not a number', () => {
const settingsWithInvalidVersion = {
[SETTINGS_VERSION_KEY]: 'not-a-number',
theme: 'dark',
};
expect(needsMigration(settingsWithInvalidVersion)).toBe(true);
});
it('should use fallback logic when version field is missing', () => {
const settingsWithoutVersion = {
theme: 'dark',
};
expect(needsMigration(settingsWithoutVersion)).toBe(true);
});
});
describe('edge case: partially migrated settings', () => {
it('should return true for partially migrated settings without version field', () => {
// This simulates the dangerous edge case: model already in V2 format,
// but other fields in V1 format
const partiallyMigrated = {
model: {
name: 'qwen-coder',
},
autoAccept: false, // V1 key
};
expect(needsMigration(partiallyMigrated)).toBe(true);
});
it('should return false for partially migrated settings WITH version field', () => {
// With version field, we trust that it's been properly migrated
const partiallyMigratedWithVersion = {
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
model: {
name: 'qwen-coder',
},
autoAccept: false, // This would look like V1 but version says it's V2
};
expect(needsMigration(partiallyMigratedWithVersion)).toBe(false);
});
});
}); });
describe('migrateDeprecatedSettings', () => { describe('migrateDeprecatedSettings', () => {

View File

@@ -56,6 +56,10 @@ export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
const MIGRATE_V2_OVERWRITE = true; const MIGRATE_V2_OVERWRITE = true;
// Settings version to track migration state
export const SETTINGS_VERSION = 2;
export const SETTINGS_VERSION_KEY = '$version';
const MIGRATION_MAP: Record<string, string> = { const MIGRATION_MAP: Record<string, string> = {
accessibility: 'ui.accessibility', accessibility: 'ui.accessibility',
allowedTools: 'tools.allowed', allowedTools: 'tools.allowed',
@@ -216,8 +220,16 @@ function setNestedProperty(
} }
export function needsMigration(settings: Record<string, unknown>): boolean { export function needsMigration(settings: Record<string, unknown>): boolean {
// A file needs migration if it contains any top-level key that is moved to a // Check version field first - if present and matches current version, no migration needed
// nested location in V2. if (SETTINGS_VERSION_KEY in settings) {
const version = settings[SETTINGS_VERSION_KEY];
if (typeof version === 'number' && version >= SETTINGS_VERSION) {
return false;
}
}
// Fallback to legacy detection: A file needs migration if it contains any
// top-level key that is moved to a nested location in V2.
const hasV1Keys = Object.entries(MIGRATION_MAP).some(([v1Key, v2Path]) => { const hasV1Keys = Object.entries(MIGRATION_MAP).some(([v1Key, v2Path]) => {
if (v1Key === v2Path || !(v1Key in settings)) { if (v1Key === v2Path || !(v1Key in settings)) {
return false; return false;
@@ -250,6 +262,21 @@ function migrateSettingsToV2(
for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) { for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) {
if (flatKeys.has(oldKey)) { if (flatKeys.has(oldKey)) {
// Safety check: If this key is a V2 container (like 'model') and it's
// already an object, it's likely already in V2 format. Skip migration
// to prevent double-nesting (e.g., model.name.name).
if (
KNOWN_V2_CONTAINERS.has(oldKey) &&
typeof flatSettings[oldKey] === 'object' &&
flatSettings[oldKey] !== null &&
!Array.isArray(flatSettings[oldKey])
) {
// This is already a V2 container, carry it over as-is
v2Settings[oldKey] = flatSettings[oldKey];
flatKeys.delete(oldKey);
continue;
}
setNestedProperty(v2Settings, newPath, flatSettings[oldKey]); setNestedProperty(v2Settings, newPath, flatSettings[oldKey]);
flatKeys.delete(oldKey); flatKeys.delete(oldKey);
} }
@@ -287,6 +314,9 @@ function migrateSettingsToV2(
} }
} }
// Set version field to indicate this is a V2 settings file
v2Settings[SETTINGS_VERSION_KEY] = SETTINGS_VERSION;
return v2Settings; return v2Settings;
} }
@@ -336,6 +366,11 @@ export function migrateSettingsToV1(
// Carry over any unrecognized keys // Carry over any unrecognized keys
for (const remainingKey of v2Keys) { for (const remainingKey of v2Keys) {
// Skip the version field - it's only for V2 format
if (remainingKey === SETTINGS_VERSION_KEY) {
continue;
}
const value = v2Settings[remainingKey]; const value = v2Settings[remainingKey];
if (value === undefined) { if (value === undefined) {
continue; continue;
@@ -621,6 +656,22 @@ export function loadSettings(
} }
settingsObject = migratedSettings; settingsObject = migratedSettings;
} }
} else if (!(SETTINGS_VERSION_KEY in settingsObject)) {
// No migration needed, but version field is missing - add it for future optimizations
settingsObject[SETTINGS_VERSION_KEY] = SETTINGS_VERSION;
if (MIGRATE_V2_OVERWRITE) {
try {
fs.writeFileSync(
filePath,
JSON.stringify(settingsObject, null, 2),
'utf-8',
);
} catch (e) {
console.error(
`Error adding version to settings file: ${getErrorMessage(e)}`,
);
}
}
} }
return { settings: settingsObject as Settings, rawJson: content }; return { settings: settingsObject as Settings, rawJson: content };
} }

View File

@@ -558,6 +558,16 @@ const SETTINGS_SCHEMA = {
description: 'Enable OpenAI logging.', description: 'Enable OpenAI logging.',
showInDialog: true, 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: { generationConfig: {
type: 'object', type: 'object',
label: 'Generation Configuration', label: 'Generation Configuration',
@@ -847,6 +857,16 @@ const SETTINGS_SCHEMA = {
'Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance.', 'Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance.',
showInDialog: true, showInDialog: true,
}, },
useBuiltinRipgrep: {
type: 'boolean',
label: 'Use Builtin Ripgrep',
category: 'Tools',
requiresRestart: false,
default: true,
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 useRipgrep is true.',
showInDialog: true,
},
enableToolOutputTruncation: { enableToolOutputTruncation: {
type: 'boolean', type: 'boolean',
label: 'Enable Tool Output Truncation', label: 'Enable Tool Output Truncation',
@@ -991,6 +1011,24 @@ const SETTINGS_SCHEMA = {
description: 'Whether to use an external authentication flow.', description: 'Whether to use an external authentication flow.',
showInDialog: false, showInDialog: false,
}, },
apiKey: {
type: 'string',
label: 'API Key',
category: 'Security',
requiresRestart: true,
default: undefined as string | undefined,
description: 'API key for OpenAI compatible authentication.',
showInDialog: false,
},
baseUrl: {
type: 'string',
label: 'Base URL',
category: 'Security',
requiresRestart: true,
default: undefined as string | undefined,
description: 'Base URL for OpenAI compatible API.',
showInDialog: false,
},
}, },
}, },
}, },
@@ -1044,17 +1082,36 @@ const SETTINGS_SCHEMA = {
}, },
tavilyApiKey: { tavilyApiKey: {
type: 'string', type: 'string',
label: 'Tavily API Key', label: 'Tavily API Key (Deprecated)',
category: 'Advanced', category: 'Advanced',
requiresRestart: false, requiresRestart: false,
default: undefined as string | undefined, default: undefined as string | undefined,
description: 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, 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: { experimental: {
type: 'object', type: 'object',
label: 'Experimental', label: 'Experimental',

View File

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

View File

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

View File

@@ -17,11 +17,7 @@ import dns from 'node:dns';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { start_sandbox } from './utils/sandbox.js'; import { start_sandbox } from './utils/sandbox.js';
import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js'; import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js';
import { import { loadSettings, migrateDeprecatedSettings } from './config/settings.js';
loadSettings,
migrateDeprecatedSettings,
SettingScope,
} from './config/settings.js';
import { themeManager } from './ui/themes/theme-manager.js'; import { themeManager } from './ui/themes/theme-manager.js';
import { getStartupWarnings } from './utils/startupWarnings.js'; import { getStartupWarnings } from './utils/startupWarnings.js';
import { getUserStartupWarnings } from './utils/userStartupWarnings.js'; import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
@@ -233,17 +229,6 @@ export async function main() {
validateDnsResolutionOrder(settings.merged.advanced?.dnsResolutionOrder), validateDnsResolutionOrder(settings.merged.advanced?.dnsResolutionOrder),
); );
// Set a default auth type if one isn't set.
if (!settings.merged.security?.auth?.selectedType) {
if (process.env['CLOUD_SHELL'] === 'true') {
settings.setValue(
SettingScope.User,
'selectedAuthType',
AuthType.CLOUD_SHELL,
);
}
}
// Load custom themes from settings // Load custom themes from settings
themeManager.loadCustomThemes(settings.merged.ui?.customThemes); themeManager.loadCustomThemes(settings.merged.ui?.customThemes);
@@ -402,7 +387,11 @@ export async function main() {
let input = config.getQuestion(); let input = config.getQuestion();
const startupWarnings = [ const startupWarnings = [
...(await getStartupWarnings()), ...(await getStartupWarnings()),
...(await getUserStartupWarnings()), ...(await getUserStartupWarnings({
workspaceRoot: process.cwd(),
useRipgrep: settings.merged.tools?.useRipgrep ?? true,
useBuiltinRipgrep: settings.merged.tools?.useBuiltinRipgrep ?? true,
})),
]; ];
// Render UI, passing necessary config values. Check that there is no command line question. // Render UI, passing necessary config values. Check that there is no command line question.

View File

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

View File

@@ -120,7 +120,11 @@ export class FileCommandLoader implements ICommandLoader {
// Add all commands without deduplication // Add all commands without deduplication
allCommands.push(...commands); allCommands.push(...commands);
} catch (error) { } 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( console.error(
`[FileCommandLoader] Error loading commands from ${dirInfo.path}:`, `[FileCommandLoader] Error loading commands from ${dirInfo.path}:`,
error, error,

View File

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

View File

@@ -8,12 +8,7 @@ import type React from 'react';
import { useState } from 'react'; import { useState } from 'react';
import { AuthType } from '@qwen-code/qwen-code-core'; import { AuthType } from '@qwen-code/qwen-code-core';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { import { validateAuthMethod } from '../../config/auth.js';
setOpenAIApiKey,
setOpenAIBaseUrl,
setOpenAIModel,
validateAuthMethod,
} from '../../config/auth.js';
import { type LoadedSettings, SettingScope } from '../../config/settings.js'; import { type LoadedSettings, SettingScope } from '../../config/settings.js';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js'; import { useKeypress } from '../hooks/useKeypress.js';
@@ -21,7 +16,15 @@ import { OpenAIKeyPrompt } from '../components/OpenAIKeyPrompt.js';
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
interface AuthDialogProps { interface AuthDialogProps {
onSelect: (authMethod: AuthType | undefined, scope: SettingScope) => void; onSelect: (
authMethod: AuthType | undefined,
scope: SettingScope,
credentials?: {
apiKey?: string;
baseUrl?: string;
model?: string;
},
) => void;
settings: LoadedSettings; settings: LoadedSettings;
initialErrorMessage?: string | null; initialErrorMessage?: string | null;
} }
@@ -70,11 +73,7 @@ export function AuthDialog({
return item.value === defaultAuthType; return item.value === defaultAuthType;
} }
if (process.env['GEMINI_API_KEY']) { return item.value === AuthType.QWEN_OAUTH;
return item.value === AuthType.USE_GEMINI;
}
return item.value === AuthType.LOGIN_WITH_GOOGLE;
}), }),
); );
@@ -101,11 +100,12 @@ export function AuthDialog({
baseUrl: string, baseUrl: string,
model: string, model: string,
) => { ) => {
setOpenAIApiKey(apiKey);
setOpenAIBaseUrl(baseUrl);
setOpenAIModel(model);
setShowOpenAIKeyPrompt(false); setShowOpenAIKeyPrompt(false);
onSelect(AuthType.USE_OPENAI, SettingScope.User); onSelect(AuthType.USE_OPENAI, SettingScope.User, {
apiKey,
baseUrl,
model,
});
}; };
const handleOpenAIKeyCancel = () => { const handleOpenAIKeyCancel = () => {

View File

@@ -6,12 +6,11 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import type { LoadedSettings, SettingScope } from '../../config/settings.js'; import type { LoadedSettings, SettingScope } from '../../config/settings.js';
import { AuthType, type Config } from '@qwen-code/qwen-code-core'; import type { AuthType, Config } from '@qwen-code/qwen-code-core';
import { import {
clearCachedCredentialFile, clearCachedCredentialFile,
getErrorMessage, getErrorMessage,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { runExitCleanup } from '../../utils/cleanup.js';
import { AuthState } from '../types.js'; import { AuthState } from '../types.js';
import { validateAuthMethod } from '../../config/auth.js'; import { validateAuthMethod } from '../../config/auth.js';
@@ -47,6 +46,7 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
setAuthError(error); setAuthError(error);
if (error) { if (error) {
setAuthState(AuthState.Updating); setAuthState(AuthState.Updating);
setIsAuthDialogOpen(true);
} }
}, },
[setAuthError, setAuthState], [setAuthError, setAuthState],
@@ -87,24 +87,49 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
// Handle auth selection from dialog // Handle auth selection from dialog
const handleAuthSelect = useCallback( const handleAuthSelect = useCallback(
async (authType: AuthType | undefined, scope: SettingScope) => { async (
authType: AuthType | undefined,
scope: SettingScope,
credentials?: {
apiKey?: string;
baseUrl?: string;
model?: string;
},
) => {
if (authType) { if (authType) {
await clearCachedCredentialFile(); await clearCachedCredentialFile();
settings.setValue(scope, 'security.auth.selectedType', authType); // Save OpenAI credentials if provided
if (credentials) {
// Update Config's internal generationConfig before calling refreshAuth
// This ensures refreshAuth has access to the new credentials
config.updateCredentials({
apiKey: credentials.apiKey,
baseUrl: credentials.baseUrl,
model: credentials.model,
});
if ( // Also set environment variables for compatibility with other parts of the code
authType === AuthType.LOGIN_WITH_GOOGLE && if (credentials.apiKey) {
config.isBrowserLaunchSuppressed() settings.setValue(
) { scope,
await runExitCleanup(); 'security.auth.apiKey',
console.log(` credentials.apiKey,
---------------------------------------------------------------- );
Logging in with Google... Please restart Gemini CLI to continue. }
---------------------------------------------------------------- if (credentials.baseUrl) {
`); settings.setValue(
process.exit(0); scope,
'security.auth.baseUrl',
credentials.baseUrl,
);
}
if (credentials.model) {
settings.setValue(scope, 'model.name', credentials.model);
}
} }
settings.setValue(scope, 'security.auth.selectedType', authType);
} }
setIsAuthDialogOpen(false); setIsAuthDialogOpen(false);

View File

@@ -11,6 +11,7 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js
import { getCliVersion } from '../../utils/version.js'; import { getCliVersion } from '../../utils/version.js';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import { formatMemoryUsage } from '../utils/formatters.js'; import { formatMemoryUsage } from '../utils/formatters.js';
import { AuthType } from '@qwen-code/qwen-code-core';
// Mock dependencies // Mock dependencies
vi.mock('open'); vi.mock('open');
@@ -26,7 +27,6 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
getDetectedIdeDisplayName: vi.fn().mockReturnValue('VSCode'), getDetectedIdeDisplayName: vi.fn().mockReturnValue('VSCode'),
}), }),
}, },
sessionId: 'test-session-id',
}; };
}); });
vi.mock('node:process', () => ({ vi.mock('node:process', () => ({
@@ -58,6 +58,16 @@ describe('bugCommand', () => {
getModel: () => 'qwen3-coder-plus', getModel: () => 'qwen3-coder-plus',
getBugCommand: () => undefined, getBugCommand: () => undefined,
getIdeMode: () => true, getIdeMode: () => true,
getSessionId: () => 'test-session-id',
},
settings: {
merged: {
security: {
auth: {
selectedType: undefined,
},
},
},
}, },
}, },
}); });
@@ -71,6 +81,7 @@ describe('bugCommand', () => {
* **Session ID:** test-session-id * **Session ID:** test-session-id
* **Operating System:** test-platform v20.0.0 * **Operating System:** test-platform v20.0.0
* **Sandbox Environment:** test * **Sandbox Environment:** test
* **Auth Type:**
* **Model Version:** qwen3-coder-plus * **Model Version:** qwen3-coder-plus
* **Memory Usage:** 100 MB * **Memory Usage:** 100 MB
* **IDE Client:** VSCode * **IDE Client:** VSCode
@@ -91,6 +102,16 @@ describe('bugCommand', () => {
getModel: () => 'qwen3-coder-plus', getModel: () => 'qwen3-coder-plus',
getBugCommand: () => ({ urlTemplate: customTemplate }), getBugCommand: () => ({ urlTemplate: customTemplate }),
getIdeMode: () => true, getIdeMode: () => true,
getSessionId: () => 'test-session-id',
},
settings: {
merged: {
security: {
auth: {
selectedType: undefined,
},
},
},
}, },
}, },
}); });
@@ -104,6 +125,7 @@ describe('bugCommand', () => {
* **Session ID:** test-session-id * **Session ID:** test-session-id
* **Operating System:** test-platform v20.0.0 * **Operating System:** test-platform v20.0.0
* **Sandbox Environment:** test * **Sandbox Environment:** test
* **Auth Type:**
* **Model Version:** qwen3-coder-plus * **Model Version:** qwen3-coder-plus
* **Memory Usage:** 100 MB * **Memory Usage:** 100 MB
* **IDE Client:** VSCode * **IDE Client:** VSCode
@@ -114,4 +136,50 @@ describe('bugCommand', () => {
expect(open).toHaveBeenCalledWith(expectedUrl); expect(open).toHaveBeenCalledWith(expectedUrl);
}); });
it('should include Base URL when auth type is OpenAI', async () => {
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,
},
},
},
},
},
});
if (!bugCommand.action) throw new Error('Action is not defined');
await bugCommand.action(mockContext, 'OpenAI bug');
const expectedInfo = `
* **CLI Version:** 0.1.0
* **Git Commit:** ${GIT_COMMIT_INFO}
* **Session ID:** test-session-id
* **Operating System:** test-platform v20.0.0
* **Sandbox Environment:** test
* **Auth Type:** ${AuthType.USE_OPENAI}
* **Base URL:** https://api.openai.com/v1
* **Model Version:** qwen3-coder-plus
* **Memory Usage:** 100 MB
* **IDE Client:** VSCode
`;
const expectedUrl =
'https://github.com/QwenLM/qwen-code/issues/new?template=bug_report.yml&title=OpenAI%20bug&info=' +
encodeURIComponent(expectedInfo);
expect(open).toHaveBeenCalledWith(expectedUrl);
});
}); });

View File

@@ -15,7 +15,7 @@ import { MessageType } from '../types.js';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import { formatMemoryUsage } from '../utils/formatters.js'; import { formatMemoryUsage } from '../utils/formatters.js';
import { getCliVersion } from '../../utils/version.js'; import { getCliVersion } from '../../utils/version.js';
import { IdeClient, sessionId } from '@qwen-code/qwen-code-core'; import { IdeClient, AuthType } from '@qwen-code/qwen-code-core';
export const bugCommand: SlashCommand = { export const bugCommand: SlashCommand = {
name: 'bug', name: 'bug',
@@ -38,13 +38,24 @@ export const bugCommand: SlashCommand = {
const cliVersion = await getCliVersion(); const cliVersion = await getCliVersion();
const memoryUsage = formatMemoryUsage(process.memoryUsage().rss); const memoryUsage = formatMemoryUsage(process.memoryUsage().rss);
const ideClient = await getIdeClientName(context); const ideClient = await getIdeClientName(context);
const selectedAuthType =
context.services.settings.merged.security?.auth?.selectedType || '';
const baseUrl =
selectedAuthType === AuthType.USE_OPENAI
? config?.getContentGeneratorConfig()?.baseUrl
: undefined;
let info = ` let info = `
* **CLI Version:** ${cliVersion} * **CLI Version:** ${cliVersion}
* **Git Commit:** ${GIT_COMMIT_INFO} * **Git Commit:** ${GIT_COMMIT_INFO}
* **Session ID:** ${sessionId} * **Session ID:** ${config?.getSessionId() || 'unknown'}
* **Operating System:** ${osVersion} * **Operating System:** ${osVersion}
* **Sandbox Environment:** ${sandboxEnv} * **Sandbox Environment:** ${sandboxEnv}
* **Auth Type:** ${selectedAuthType}`;
if (baseUrl) {
info += `\n* **Base URL:** ${baseUrl}`;
}
info += `
* **Model Version:** ${modelVersion} * **Model Version:** ${modelVersion}
* **Memory Usage:** ${memoryUsage} * **Memory Usage:** ${memoryUsage}
`; `;

View File

@@ -139,8 +139,8 @@ describe('chatCommand', () => {
.match(/(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/); .match(/(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/);
const formattedDate = isoDate ? `${isoDate[1]} ${isoDate[2]}` : ''; const formattedDate = isoDate ? `${isoDate[1]} ${isoDate[2]}` : '';
expect(content).toContain(formattedDate); expect(content).toContain(formattedDate);
const index1 = content.indexOf('- \u001b[36mtest1\u001b[0m'); const index1 = content.indexOf('- test1');
const index2 = content.indexOf('- \u001b[36mtest2\u001b[0m'); const index2 = content.indexOf('- test2');
expect(index1).toBeGreaterThanOrEqual(0); expect(index1).toBeGreaterThanOrEqual(0);
expect(index2).toBeGreaterThan(index1); expect(index2).toBeGreaterThan(index1);
}); });

View File

@@ -89,9 +89,9 @@ const listCommand: SlashCommand = {
const isoString = chat.mtime.toISOString(); const isoString = chat.mtime.toISOString();
const match = isoString.match(/(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/); const match = isoString.match(/(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/);
const formattedDate = match ? `${match[1]} ${match[2]}` : 'Invalid Date'; const formattedDate = match ? `${match[1]} ${match[2]}` : 'Invalid Date';
message += ` - \u001b[36m${paddedName}\u001b[0m \u001b[90m(saved on ${formattedDate})\u001b[0m\n`; message += ` - ${paddedName} (saved on ${formattedDate})\n`;
} }
message += `\n\u001b[90mNote: Newest last, oldest first\u001b[0m`; message += `\nNote: Newest last, oldest first`;
return { return {
type: 'message', type: 'message',
messageType: 'info', messageType: 'info',

View File

@@ -130,7 +130,7 @@ export function OpenAIKeyPrompt({
} }
// Handle regular character input // Handle regular character input
if (key.sequence && !key.ctrl && !key.meta && !key.name) { if (key.sequence && !key.ctrl && !key.meta) {
// Filter control characters // Filter control characters
const cleanInput = key.sequence const cleanInput = key.sequence
.split('') .split('')

View File

@@ -12,6 +12,7 @@ import type {
Config, Config,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { renderWithProviders } from '../../../test-utils/render.js'; import { renderWithProviders } from '../../../test-utils/render.js';
import type { LoadedSettings } from '../../../config/settings.js';
describe('ToolConfirmationMessage', () => { describe('ToolConfirmationMessage', () => {
const mockConfig = { const mockConfig = {
@@ -187,4 +188,63 @@ describe('ToolConfirmationMessage', () => {
}); });
}); });
}); });
describe('external editor option', () => {
const editConfirmationDetails: ToolCallConfirmationDetails = {
type: 'edit',
title: 'Confirm Edit',
fileName: 'test.txt',
filePath: '/test.txt',
fileDiff: '...diff...',
originalContent: 'a',
newContent: 'b',
onConfirm: vi.fn(),
};
it('should show "Modify with external editor" when preferredEditor is set', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
} as unknown as Config;
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
confirmationDetails={editConfirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
{
settings: {
merged: { general: { preferredEditor: 'vscode' } },
} as unknown as LoadedSettings,
},
);
expect(lastFrame()).toContain('Modify with external editor');
});
it('should NOT show "Modify with external editor" when preferredEditor is not set', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
} as unknown as Config;
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
confirmationDetails={editConfirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
{
settings: {
merged: { general: {} },
} as unknown as LoadedSettings,
},
);
expect(lastFrame()).not.toContain('Modify with external editor');
});
});
}); });

View File

@@ -15,12 +15,14 @@ import type {
ToolExecuteConfirmationDetails, ToolExecuteConfirmationDetails,
ToolMcpConfirmationDetails, ToolMcpConfirmationDetails,
Config, Config,
EditorType,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { IdeClient, ToolConfirmationOutcome } from '@qwen-code/qwen-code-core'; import { IdeClient, ToolConfirmationOutcome } from '@qwen-code/qwen-code-core';
import type { RadioSelectItem } from '../shared/RadioButtonSelect.js'; import type { RadioSelectItem } from '../shared/RadioButtonSelect.js';
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js'; import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js'; import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { useKeypress } from '../../hooks/useKeypress.js'; import { useKeypress } from '../../hooks/useKeypress.js';
import { useSettings } from '../../contexts/SettingsContext.js';
import { theme } from '../../semantic-colors.js'; import { theme } from '../../semantic-colors.js';
export interface ToolConfirmationMessageProps { export interface ToolConfirmationMessageProps {
@@ -45,6 +47,11 @@ export const ToolConfirmationMessage: React.FC<
const { onConfirm } = confirmationDetails; const { onConfirm } = confirmationDetails;
const childWidth = terminalWidth - 2; // 2 for padding const childWidth = terminalWidth - 2; // 2 for padding
const settings = useSettings();
const preferredEditor = settings.merged.general?.preferredEditor as
| EditorType
| undefined;
const [ideClient, setIdeClient] = useState<IdeClient | null>(null); const [ideClient, setIdeClient] = useState<IdeClient | null>(null);
const [isDiffingEnabled, setIsDiffingEnabled] = useState(false); const [isDiffingEnabled, setIsDiffingEnabled] = useState(false);
@@ -199,7 +206,7 @@ export const ToolConfirmationMessage: React.FC<
key: 'Yes, allow always', key: 'Yes, allow always',
}); });
} }
if (!config.getIdeMode() || !isDiffingEnabled) { if ((!config.getIdeMode() || !isDiffingEnabled) && preferredEditor) {
options.push({ options.push({
label: 'Modify with external editor', label: 'Modify with external editor',
value: ToolConfirmationOutcome.ModifyWithEditor, value: ToolConfirmationOutcome.ModifyWithEditor,

View File

@@ -23,7 +23,7 @@ export const ToolsList: React.FC<ToolsListProps> = ({
}) => ( }) => (
<Box flexDirection="column" marginBottom={1}> <Box flexDirection="column" marginBottom={1}>
<Text bold color={theme.text.primary}> <Text bold color={theme.text.primary}>
Available Gemini CLI tools: Available Qwen Code CLI tools:
</Text> </Text>
<Box height={1} /> <Box height={1} />
{tools.length > 0 ? ( {tools.length > 0 ? (

View File

@@ -1,7 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ToolsList /> > renders correctly with descriptions 1`] = ` exports[`<ToolsList /> > renders correctly with descriptions 1`] = `
"Available Gemini CLI tools: "Available Qwen Code CLI tools:
- Test Tool One (test-tool-one) - Test Tool One (test-tool-one)
This is the first test tool. This is the first test tool.
@@ -16,14 +16,14 @@ exports[`<ToolsList /> > renders correctly with descriptions 1`] = `
`; `;
exports[`<ToolsList /> > renders correctly with no tools 1`] = ` exports[`<ToolsList /> > renders correctly with no tools 1`] = `
"Available Gemini CLI tools: "Available Qwen Code CLI tools:
No tools available No tools available
" "
`; `;
exports[`<ToolsList /> > renders correctly without descriptions 1`] = ` exports[`<ToolsList /> > renders correctly without descriptions 1`] = `
"Available Gemini CLI tools: "Available Qwen Code CLI tools:
- Test Tool One - Test Tool One
- Test Tool Two - Test Tool Two

View File

@@ -109,7 +109,7 @@ describe('useEditorSettings', () => {
expect(mockLoadedSettings.setValue).toHaveBeenCalledWith( expect(mockLoadedSettings.setValue).toHaveBeenCalledWith(
scope, scope,
'preferredEditor', 'general.preferredEditor',
editorType, editorType,
); );
@@ -139,7 +139,7 @@ describe('useEditorSettings', () => {
expect(mockLoadedSettings.setValue).toHaveBeenCalledWith( expect(mockLoadedSettings.setValue).toHaveBeenCalledWith(
scope, scope,
'preferredEditor', 'general.preferredEditor',
undefined, undefined,
); );
@@ -170,7 +170,7 @@ describe('useEditorSettings', () => {
expect(mockLoadedSettings.setValue).toHaveBeenCalledWith( expect(mockLoadedSettings.setValue).toHaveBeenCalledWith(
scope, scope,
'preferredEditor', 'general.preferredEditor',
editorType, editorType,
); );
@@ -199,7 +199,7 @@ describe('useEditorSettings', () => {
expect(mockLoadedSettings.setValue).toHaveBeenCalledWith( expect(mockLoadedSettings.setValue).toHaveBeenCalledWith(
scope, scope,
'preferredEditor', 'general.preferredEditor',
editorType, editorType,
); );

View File

@@ -45,7 +45,7 @@ export const useEditorSettings = (
} }
try { try {
loadedSettings.setValue(scope, 'preferredEditor', editorType); loadedSettings.setValue(scope, 'general.preferredEditor', editorType);
addItem( addItem(
{ {
type: MessageType.INFO, type: MessageType.INFO,

View File

@@ -22,12 +22,22 @@ vi.mock('os', async (importOriginal) => {
describe('getUserStartupWarnings', () => { describe('getUserStartupWarnings', () => {
let testRootDir: string; let testRootDir: string;
let homeDir: string; let homeDir: string;
let startupOptions: {
workspaceRoot: string;
useRipgrep: boolean;
useBuiltinRipgrep: boolean;
};
beforeEach(async () => { beforeEach(async () => {
testRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'warnings-test-')); testRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'warnings-test-'));
homeDir = path.join(testRootDir, 'home'); homeDir = path.join(testRootDir, 'home');
await fs.mkdir(homeDir, { recursive: true }); await fs.mkdir(homeDir, { recursive: true });
vi.mocked(os.homedir).mockReturnValue(homeDir); vi.mocked(os.homedir).mockReturnValue(homeDir);
startupOptions = {
workspaceRoot: testRootDir,
useRipgrep: true,
useBuiltinRipgrep: true,
};
}); });
afterEach(async () => { afterEach(async () => {
@@ -37,7 +47,10 @@ describe('getUserStartupWarnings', () => {
describe('home directory check', () => { describe('home directory check', () => {
it('should return a warning when running in home directory', async () => { it('should return a warning when running in home directory', async () => {
const warnings = await getUserStartupWarnings(homeDir); const warnings = await getUserStartupWarnings({
...startupOptions,
workspaceRoot: homeDir,
});
expect(warnings).toContainEqual( expect(warnings).toContainEqual(
expect.stringContaining('home directory'), expect.stringContaining('home directory'),
); );
@@ -46,7 +59,10 @@ describe('getUserStartupWarnings', () => {
it('should not return a warning when running in a project directory', async () => { it('should not return a warning when running in a project directory', async () => {
const projectDir = path.join(testRootDir, 'project'); const projectDir = path.join(testRootDir, 'project');
await fs.mkdir(projectDir); await fs.mkdir(projectDir);
const warnings = await getUserStartupWarnings(projectDir); const warnings = await getUserStartupWarnings({
...startupOptions,
workspaceRoot: projectDir,
});
expect(warnings).not.toContainEqual( expect(warnings).not.toContainEqual(
expect.stringContaining('home directory'), expect.stringContaining('home directory'),
); );
@@ -56,7 +72,10 @@ describe('getUserStartupWarnings', () => {
describe('root directory check', () => { describe('root directory check', () => {
it('should return a warning when running in a root directory', async () => { it('should return a warning when running in a root directory', async () => {
const rootDir = path.parse(testRootDir).root; const rootDir = path.parse(testRootDir).root;
const warnings = await getUserStartupWarnings(rootDir); const warnings = await getUserStartupWarnings({
...startupOptions,
workspaceRoot: rootDir,
});
expect(warnings).toContainEqual( expect(warnings).toContainEqual(
expect.stringContaining('root directory'), expect.stringContaining('root directory'),
); );
@@ -68,7 +87,10 @@ describe('getUserStartupWarnings', () => {
it('should not return a warning when running in a non-root directory', async () => { it('should not return a warning when running in a non-root directory', async () => {
const projectDir = path.join(testRootDir, 'project'); const projectDir = path.join(testRootDir, 'project');
await fs.mkdir(projectDir); await fs.mkdir(projectDir);
const warnings = await getUserStartupWarnings(projectDir); const warnings = await getUserStartupWarnings({
...startupOptions,
workspaceRoot: projectDir,
});
expect(warnings).not.toContainEqual( expect(warnings).not.toContainEqual(
expect.stringContaining('root directory'), expect.stringContaining('root directory'),
); );
@@ -78,7 +100,10 @@ describe('getUserStartupWarnings', () => {
describe('error handling', () => { describe('error handling', () => {
it('should handle errors when checking directory', async () => { it('should handle errors when checking directory', async () => {
const nonExistentPath = path.join(testRootDir, 'non-existent'); const nonExistentPath = path.join(testRootDir, 'non-existent');
const warnings = await getUserStartupWarnings(nonExistentPath); const warnings = await getUserStartupWarnings({
...startupOptions,
workspaceRoot: nonExistentPath,
});
const expectedWarning = const expectedWarning =
'Could not verify the current directory due to a file system error.'; 'Could not verify the current directory due to a file system error.';
expect(warnings).toEqual([expectedWarning, expectedWarning]); expect(warnings).toEqual([expectedWarning, expectedWarning]);

View File

@@ -7,19 +7,26 @@
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import * as os from 'node:os'; import * as os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { canUseRipgrep } from '@qwen-code/qwen-code-core';
type WarningCheckOptions = {
workspaceRoot: string;
useRipgrep: boolean;
useBuiltinRipgrep: boolean;
};
type WarningCheck = { type WarningCheck = {
id: string; id: string;
check: (workspaceRoot: string) => Promise<string | null>; check: (options: WarningCheckOptions) => Promise<string | null>;
}; };
// Individual warning checks // Individual warning checks
const homeDirectoryCheck: WarningCheck = { const homeDirectoryCheck: WarningCheck = {
id: 'home-directory', id: 'home-directory',
check: async (workspaceRoot: string) => { check: async (options: WarningCheckOptions) => {
try { try {
const [workspaceRealPath, homeRealPath] = await Promise.all([ const [workspaceRealPath, homeRealPath] = await Promise.all([
fs.realpath(workspaceRoot), fs.realpath(options.workspaceRoot),
fs.realpath(os.homedir()), fs.realpath(os.homedir()),
]); ]);
@@ -35,9 +42,9 @@ const homeDirectoryCheck: WarningCheck = {
const rootDirectoryCheck: WarningCheck = { const rootDirectoryCheck: WarningCheck = {
id: 'root-directory', id: 'root-directory',
check: async (workspaceRoot: string) => { check: async (options: WarningCheckOptions) => {
try { try {
const workspaceRealPath = await fs.realpath(workspaceRoot); const workspaceRealPath = await fs.realpath(options.workspaceRoot);
const errorMessage = const errorMessage =
'Warning: You are running Qwen Code in the root directory. Your entire folder structure will be used for context. It is strongly recommended to run in a project-specific directory.'; 'Warning: You are running Qwen Code in the root directory. Your entire folder structure will be used for context. It is strongly recommended to run in a project-specific directory.';
@@ -53,17 +60,33 @@ const rootDirectoryCheck: WarningCheck = {
}, },
}; };
const ripgrepAvailabilityCheck: WarningCheck = {
id: 'ripgrep-availability',
check: async (options: WarningCheckOptions) => {
if (!options.useRipgrep) {
return null;
}
const isAvailable = await canUseRipgrep(options.useBuiltinRipgrep);
if (!isAvailable) {
return 'Ripgrep not available: Please install ripgrep globally to enable faster file content search. Falling back to built-in grep.';
}
return null;
},
};
// All warning checks // All warning checks
const WARNING_CHECKS: readonly WarningCheck[] = [ const WARNING_CHECKS: readonly WarningCheck[] = [
homeDirectoryCheck, homeDirectoryCheck,
rootDirectoryCheck, rootDirectoryCheck,
ripgrepAvailabilityCheck,
]; ];
export async function getUserStartupWarnings( export async function getUserStartupWarnings(
workspaceRoot: string = process.cwd(), options: WarningCheckOptions,
): Promise<string[]> { ): Promise<string[]> {
const results = await Promise.all( const results = await Promise.all(
WARNING_CHECKS.map((check) => check.check(workspaceRoot)), WARNING_CHECKS.map((check) => check.check(options)),
); );
return results.filter((msg) => msg !== null); return results.filter((msg) => msg !== null);
} }

View File

@@ -105,34 +105,6 @@ describe('validateNonInterActiveAuth', () => {
expect(processExitSpy).toHaveBeenCalledWith(1); expect(processExitSpy).toHaveBeenCalledWith(1);
}); });
it('uses LOGIN_WITH_GOOGLE if GOOGLE_GENAI_USE_GCA is set', async () => {
process.env['GOOGLE_GENAI_USE_GCA'] = 'true';
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
} as unknown as Config;
await validateNonInteractiveAuth(
undefined,
undefined,
nonInteractiveConfig,
mockSettings,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.LOGIN_WITH_GOOGLE);
});
it('uses USE_GEMINI if GEMINI_API_KEY is set', async () => {
process.env['GEMINI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
} as unknown as Config;
await validateNonInteractiveAuth(
undefined,
undefined,
nonInteractiveConfig,
mockSettings,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_GEMINI);
});
it('uses USE_OPENAI if OPENAI_API_KEY is set', async () => { it('uses USE_OPENAI if OPENAI_API_KEY is set', async () => {
process.env['OPENAI_API_KEY'] = 'fake-openai-key'; process.env['OPENAI_API_KEY'] = 'fake-openai-key';
const nonInteractiveConfig = { const nonInteractiveConfig = {
@@ -168,104 +140,6 @@ describe('validateNonInterActiveAuth', () => {
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.QWEN_OAUTH); expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.QWEN_OAUTH);
}); });
it('uses USE_VERTEX_AI if GOOGLE_GENAI_USE_VERTEXAI is true (with GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION)', async () => {
process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true';
process.env['GOOGLE_CLOUD_PROJECT'] = 'test-project';
process.env['GOOGLE_CLOUD_LOCATION'] = 'us-central1';
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
} as unknown as Config;
await validateNonInteractiveAuth(
undefined,
undefined,
nonInteractiveConfig,
mockSettings,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_VERTEX_AI);
});
it('uses USE_VERTEX_AI if GOOGLE_GENAI_USE_VERTEXAI is true and GOOGLE_API_KEY is set', async () => {
process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true';
process.env['GOOGLE_API_KEY'] = 'vertex-api-key';
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
} as unknown as Config;
await validateNonInteractiveAuth(
undefined,
undefined,
nonInteractiveConfig,
mockSettings,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_VERTEX_AI);
});
it('uses LOGIN_WITH_GOOGLE if GOOGLE_GENAI_USE_GCA is set, even with other env vars', async () => {
process.env['GOOGLE_GENAI_USE_GCA'] = 'true';
process.env['GEMINI_API_KEY'] = 'fake-key';
process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true';
process.env['GOOGLE_CLOUD_PROJECT'] = 'test-project';
process.env['GOOGLE_CLOUD_LOCATION'] = 'us-central1';
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
} as unknown as Config;
await validateNonInteractiveAuth(
undefined,
undefined,
nonInteractiveConfig,
mockSettings,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.LOGIN_WITH_GOOGLE);
});
it('uses USE_VERTEX_AI if both GEMINI_API_KEY and GOOGLE_GENAI_USE_VERTEXAI are set', async () => {
process.env['GEMINI_API_KEY'] = 'fake-key';
process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true';
process.env['GOOGLE_CLOUD_PROJECT'] = 'test-project';
process.env['GOOGLE_CLOUD_LOCATION'] = 'us-central1';
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
} as unknown as Config;
await validateNonInteractiveAuth(
undefined,
undefined,
nonInteractiveConfig,
mockSettings,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_VERTEX_AI);
});
it('uses USE_GEMINI if GOOGLE_GENAI_USE_VERTEXAI is false, GEMINI_API_KEY is set, and project/location are available', async () => {
process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'false';
process.env['GEMINI_API_KEY'] = 'fake-key';
process.env['GOOGLE_CLOUD_PROJECT'] = 'test-project';
process.env['GOOGLE_CLOUD_LOCATION'] = 'us-central1';
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
} as unknown as Config;
await validateNonInteractiveAuth(
undefined,
undefined,
nonInteractiveConfig,
mockSettings,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_GEMINI);
});
it('uses configuredAuthType if provided', async () => {
// Set required env var for USE_GEMINI
process.env['GEMINI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
} as unknown as Config;
await validateNonInteractiveAuth(
AuthType.USE_GEMINI,
undefined,
nonInteractiveConfig,
mockSettings,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_GEMINI);
});
it('exits if validateAuthMethod returns error', async () => { it('exits if validateAuthMethod returns error', async () => {
// Mock validateAuthMethod to return error // Mock validateAuthMethod to return error
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!'); vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!');
@@ -317,26 +191,25 @@ describe('validateNonInterActiveAuth', () => {
}); });
it('uses enforcedAuthType if provided', async () => { it('uses enforcedAuthType if provided', async () => {
mockSettings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI; mockSettings.merged.security!.auth!.enforcedType = AuthType.USE_OPENAI;
mockSettings.merged.security!.auth!.selectedType = AuthType.USE_GEMINI; mockSettings.merged.security!.auth!.selectedType = AuthType.USE_OPENAI;
// Set required env var for USE_GEMINI to ensure enforcedAuthType takes precedence // Set required env var for USE_OPENAI to ensure enforcedAuthType takes precedence
process.env['GEMINI_API_KEY'] = 'fake-key'; process.env['OPENAI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = { const nonInteractiveConfig = {
refreshAuth: refreshAuthMock, refreshAuth: refreshAuthMock,
} as unknown as Config; } as unknown as Config;
await validateNonInteractiveAuth( await validateNonInteractiveAuth(
AuthType.USE_GEMINI, AuthType.USE_OPENAI,
undefined, undefined,
nonInteractiveConfig, nonInteractiveConfig,
mockSettings, mockSettings,
); );
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_GEMINI); expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.USE_OPENAI);
}); });
it('exits if currentAuthType does not match enforcedAuthType', async () => { it('exits if currentAuthType does not match enforcedAuthType', async () => {
mockSettings.merged.security!.auth!.enforcedType = mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH;
AuthType.LOGIN_WITH_GOOGLE; process.env['OPENAI_API_KEY'] = 'fake-key';
process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true';
const nonInteractiveConfig = { const nonInteractiveConfig = {
refreshAuth: refreshAuthMock, refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT), getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
@@ -346,7 +219,7 @@ describe('validateNonInterActiveAuth', () => {
} as unknown as Config; } as unknown as Config;
try { try {
await validateNonInteractiveAuth( await validateNonInteractiveAuth(
AuthType.USE_GEMINI, AuthType.USE_OPENAI,
undefined, undefined,
nonInteractiveConfig, nonInteractiveConfig,
mockSettings, mockSettings,
@@ -356,7 +229,7 @@ describe('validateNonInterActiveAuth', () => {
expect((e as Error).message).toContain('process.exit(1) called'); expect((e as Error).message).toContain('process.exit(1) called');
} }
expect(consoleErrorSpy).toHaveBeenCalledWith( expect(consoleErrorSpy).toHaveBeenCalledWith(
'The configured auth type is oauth-personal, but the current auth type is vertex-ai. Please re-authenticate with the correct type.', 'The configured auth type is qwen-oauth, but the current auth type is openai. Please re-authenticate with the correct type.',
); );
expect(processExitSpy).toHaveBeenCalledWith(1); expect(processExitSpy).toHaveBeenCalledWith(1);
}); });
@@ -394,8 +267,8 @@ describe('validateNonInterActiveAuth', () => {
}); });
it('prints JSON error when enforced auth mismatches current auth and exits with code 1', async () => { it('prints JSON error when enforced auth mismatches current auth and exits with code 1', async () => {
mockSettings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI; mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH;
process.env['GOOGLE_GENAI_USE_GCA'] = 'true'; process.env['OPENAI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = { const nonInteractiveConfig = {
refreshAuth: refreshAuthMock, refreshAuth: refreshAuthMock,
@@ -424,14 +297,14 @@ describe('validateNonInterActiveAuth', () => {
expect(payload.error.type).toBe('Error'); expect(payload.error.type).toBe('Error');
expect(payload.error.code).toBe(1); expect(payload.error.code).toBe(1);
expect(payload.error.message).toContain( expect(payload.error.message).toContain(
'The configured auth type is gemini-api-key, but the current auth type is oauth-personal.', 'The configured auth type is qwen-oauth, but the current auth type is openai.',
); );
} }
}); });
it('prints JSON error when validateAuthMethod fails and exits with code 1', async () => { it('prints JSON error when validateAuthMethod fails and exits with code 1', async () => {
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!'); vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!');
process.env['GEMINI_API_KEY'] = 'fake-key'; process.env['OPENAI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = { const nonInteractiveConfig = {
refreshAuth: refreshAuthMock, refreshAuth: refreshAuthMock,
@@ -444,7 +317,7 @@ describe('validateNonInterActiveAuth', () => {
let thrown: Error | undefined; let thrown: Error | undefined;
try { try {
await validateNonInteractiveAuth( await validateNonInteractiveAuth(
AuthType.USE_GEMINI, AuthType.USE_OPENAI,
undefined, undefined,
nonInteractiveConfig, nonInteractiveConfig,
mockSettings, mockSettings,

View File

@@ -12,21 +12,13 @@ import { type LoadedSettings } from './config/settings.js';
import { handleError } from './utils/errors.js'; import { handleError } from './utils/errors.js';
function getAuthTypeFromEnv(): AuthType | undefined { function getAuthTypeFromEnv(): AuthType | undefined {
if (process.env['GOOGLE_GENAI_USE_GCA'] === 'true') {
return AuthType.LOGIN_WITH_GOOGLE;
}
if (process.env['GOOGLE_GENAI_USE_VERTEXAI'] === 'true') {
return AuthType.USE_VERTEX_AI;
}
if (process.env['GEMINI_API_KEY']) {
return AuthType.USE_GEMINI;
}
if (process.env['OPENAI_API_KEY']) { if (process.env['OPENAI_API_KEY']) {
return AuthType.USE_OPENAI; return AuthType.USE_OPENAI;
} }
if (process.env['QWEN_OAUTH']) { if (process.env['QWEN_OAUTH']) {
return AuthType.QWEN_OAUTH; return AuthType.QWEN_OAUTH;
} }
return undefined; return undefined;
} }

View File

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

View File

@@ -16,6 +16,7 @@ import {
QwenLogger, QwenLogger,
} from '../telemetry/index.js'; } from '../telemetry/index.js';
import type { ContentGeneratorConfig } from '../core/contentGenerator.js'; import type { ContentGeneratorConfig } from '../core/contentGenerator.js';
import { DEFAULT_DASHSCOPE_BASE_URL } from '../core/openaiContentGenerator/constants.js';
import { import {
AuthType, AuthType,
createContentGeneratorConfig, createContentGeneratorConfig,
@@ -153,6 +154,11 @@ vi.mock('../core/tokenLimits.js', () => ({
describe('Server Config (config.ts)', () => { describe('Server Config (config.ts)', () => {
const MODEL = 'qwen3-coder-plus'; const MODEL = 'qwen3-coder-plus';
// Default mock for canUseRipgrep to return true (tests that care about ripgrep will override this)
beforeEach(() => {
vi.mocked(canUseRipgrep).mockResolvedValue(true);
});
const SANDBOX: SandboxConfig = { const SANDBOX: SandboxConfig = {
command: 'docker', command: 'docker',
image: 'qwen-code-sandbox', image: 'qwen-code-sandbox',
@@ -250,6 +256,7 @@ describe('Server Config (config.ts)', () => {
authType, authType,
{ {
model: MODEL, model: MODEL,
baseUrl: DEFAULT_DASHSCOPE_BASE_URL,
}, },
); );
// Verify that contentGeneratorConfig is updated // Verify that contentGeneratorConfig is updated
@@ -576,6 +583,40 @@ describe('Server Config (config.ts)', () => {
}); });
}); });
describe('UseBuiltinRipgrep Configuration', () => {
it('should default useBuiltinRipgrep to true when not provided', () => {
const config = new Config(baseParams);
expect(config.getUseBuiltinRipgrep()).toBe(true);
});
it('should set useBuiltinRipgrep to false when provided as false', () => {
const paramsWithBuiltinRipgrep: ConfigParameters = {
...baseParams,
useBuiltinRipgrep: false,
};
const config = new Config(paramsWithBuiltinRipgrep);
expect(config.getUseBuiltinRipgrep()).toBe(false);
});
it('should set useBuiltinRipgrep to true when explicitly provided as true', () => {
const paramsWithBuiltinRipgrep: ConfigParameters = {
...baseParams,
useBuiltinRipgrep: true,
};
const config = new Config(paramsWithBuiltinRipgrep);
expect(config.getUseBuiltinRipgrep()).toBe(true);
});
it('should default useBuiltinRipgrep to true when undefined', () => {
const paramsWithUndefinedBuiltinRipgrep: ConfigParameters = {
...baseParams,
useBuiltinRipgrep: undefined,
};
const config = new Config(paramsWithUndefinedBuiltinRipgrep);
expect(config.getUseBuiltinRipgrep()).toBe(true);
});
});
describe('createToolRegistry', () => { describe('createToolRegistry', () => {
it('should register a tool if coreTools contains an argument-specific pattern', async () => { it('should register a tool if coreTools contains an argument-specific pattern', async () => {
const params: ConfigParameters = { const params: ConfigParameters = {
@@ -823,10 +864,60 @@ describe('setApprovalMode with folder trust', () => {
expect(wasRipGrepRegistered).toBe(true); expect(wasRipGrepRegistered).toBe(true);
expect(wasGrepRegistered).toBe(false); expect(wasGrepRegistered).toBe(false);
expect(logRipgrepFallback).not.toHaveBeenCalled(); expect(canUseRipgrep).toHaveBeenCalledWith(true);
}); });
it('should register GrepTool as a fallback when useRipgrep is true but it is not available', async () => { it('should register RipGrepTool with system ripgrep when useBuiltinRipgrep is false', async () => {
(canUseRipgrep as Mock).mockResolvedValue(true);
const config = new Config({
...baseParams,
useRipgrep: true,
useBuiltinRipgrep: false,
});
await config.initialize();
const calls = (ToolRegistry.prototype.registerTool as Mock).mock.calls;
const wasRipGrepRegistered = calls.some(
(call) => call[0] instanceof vi.mocked(RipGrepTool),
);
const wasGrepRegistered = calls.some(
(call) => call[0] instanceof vi.mocked(GrepTool),
);
expect(wasRipGrepRegistered).toBe(true);
expect(wasGrepRegistered).toBe(false);
expect(canUseRipgrep).toHaveBeenCalledWith(false);
});
it('should fall back to GrepTool and log error when useBuiltinRipgrep is false but system ripgrep is not available', async () => {
(canUseRipgrep as Mock).mockResolvedValue(false);
const config = new Config({
...baseParams,
useRipgrep: true,
useBuiltinRipgrep: false,
});
await config.initialize();
const calls = (ToolRegistry.prototype.registerTool as Mock).mock.calls;
const wasRipGrepRegistered = calls.some(
(call) => call[0] instanceof vi.mocked(RipGrepTool),
);
const wasGrepRegistered = calls.some(
(call) => call[0] instanceof vi.mocked(GrepTool),
);
expect(wasRipGrepRegistered).toBe(false);
expect(wasGrepRegistered).toBe(true);
expect(canUseRipgrep).toHaveBeenCalledWith(false);
expect(logRipgrepFallback).toHaveBeenCalledWith(
config,
expect.any(RipgrepFallbackEvent),
);
const event = (logRipgrepFallback as Mock).mock.calls[0][1];
expect(event.error).toContain('Ripgrep is not available');
});
it('should fall back to GrepTool and log error when useRipgrep is true and builtin ripgrep is not available', async () => {
(canUseRipgrep as Mock).mockResolvedValue(false); (canUseRipgrep as Mock).mockResolvedValue(false);
const config = new Config({ ...baseParams, useRipgrep: true }); const config = new Config({ ...baseParams, useRipgrep: true });
await config.initialize(); await config.initialize();
@@ -841,15 +932,16 @@ describe('setApprovalMode with folder trust', () => {
expect(wasRipGrepRegistered).toBe(false); expect(wasRipGrepRegistered).toBe(false);
expect(wasGrepRegistered).toBe(true); expect(wasGrepRegistered).toBe(true);
expect(canUseRipgrep).toHaveBeenCalledWith(true);
expect(logRipgrepFallback).toHaveBeenCalledWith( expect(logRipgrepFallback).toHaveBeenCalledWith(
config, config,
expect.any(RipgrepFallbackEvent), expect.any(RipgrepFallbackEvent),
); );
const event = (logRipgrepFallback as Mock).mock.calls[0][1]; const event = (logRipgrepFallback as Mock).mock.calls[0][1];
expect(event.error).toBeUndefined(); expect(event.error).toContain('Ripgrep is not available');
}); });
it('should register GrepTool as a fallback when canUseRipgrep throws an error', async () => { it('should fall back to GrepTool and log error when canUseRipgrep throws an error', async () => {
const error = new Error('ripGrep check failed'); const error = new Error('ripGrep check failed');
(canUseRipgrep as Mock).mockRejectedValue(error); (canUseRipgrep as Mock).mockRejectedValue(error);
const config = new Config({ ...baseParams, useRipgrep: true }); const config = new Config({ ...baseParams, useRipgrep: true });
@@ -888,7 +980,6 @@ describe('setApprovalMode with folder trust', () => {
expect(wasRipGrepRegistered).toBe(false); expect(wasRipGrepRegistered).toBe(false);
expect(wasGrepRegistered).toBe(true); expect(wasGrepRegistered).toBe(true);
expect(canUseRipgrep).not.toHaveBeenCalled(); expect(canUseRipgrep).not.toHaveBeenCalled();
expect(logRipgrepFallback).not.toHaveBeenCalled();
}); });
}); });
}); });

View File

@@ -57,7 +57,7 @@ import { TaskTool } from '../tools/task.js';
import { TodoWriteTool } from '../tools/todoWrite.js'; import { TodoWriteTool } from '../tools/todoWrite.js';
import { ToolRegistry } from '../tools/tool-registry.js'; import { ToolRegistry } from '../tools/tool-registry.js';
import { WebFetchTool } from '../tools/web-fetch.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'; import { WriteFileTool } from '../tools/write-file.js';
// Other modules // Other modules
@@ -88,8 +88,9 @@ import {
DEFAULT_FILE_FILTERING_OPTIONS, DEFAULT_FILE_FILTERING_OPTIONS,
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
} from './constants.js'; } from './constants.js';
import { DEFAULT_QWEN_EMBEDDING_MODEL } from './models.js'; import { DEFAULT_QWEN_EMBEDDING_MODEL, DEFAULT_QWEN_MODEL } from './models.js';
import { Storage } from './storage.js'; import { Storage } from './storage.js';
import { DEFAULT_DASHSCOPE_BASE_URL } from '../core/openaiContentGenerator/constants.js';
// Re-export types // Re-export types
export type { AnyToolInvocation, FileFilteringOptions, MCPOAuthConfig }; export type { AnyToolInvocation, FileFilteringOptions, MCPOAuthConfig };
@@ -243,7 +244,7 @@ export interface ConfigParameters {
fileDiscoveryService?: FileDiscoveryService; fileDiscoveryService?: FileDiscoveryService;
includeDirectories?: string[]; includeDirectories?: string[];
bugCommand?: BugCommandSettings; bugCommand?: BugCommandSettings;
model: string; model?: string;
extensionContextFilePaths?: string[]; extensionContextFilePaths?: string[];
maxSessionTurns?: number; maxSessionTurns?: number;
sessionTokenLimit?: number; sessionTokenLimit?: number;
@@ -261,11 +262,19 @@ export interface ConfigParameters {
cliVersion?: string; cliVersion?: string;
loadMemoryFromIncludeDirectories?: boolean; loadMemoryFromIncludeDirectories?: boolean;
// Web search providers // Web search providers
tavilyApiKey?: string; webSearch?: {
provider: Array<{
type: 'tavily' | 'google' | 'dashscope';
apiKey?: string;
searchEngineId?: string;
}>;
default: string;
};
chatCompression?: ChatCompressionSettings; chatCompression?: ChatCompressionSettings;
interactive?: boolean; interactive?: boolean;
trustedFolder?: boolean; trustedFolder?: boolean;
useRipgrep?: boolean; useRipgrep?: boolean;
useBuiltinRipgrep?: boolean;
shouldUseNodePtyShell?: boolean; shouldUseNodePtyShell?: boolean;
skipNextSpeakerCheck?: boolean; skipNextSpeakerCheck?: boolean;
shellExecutionConfig?: ShellExecutionConfig; shellExecutionConfig?: ShellExecutionConfig;
@@ -289,7 +298,7 @@ export class Config {
private fileSystemService: FileSystemService; private fileSystemService: FileSystemService;
private contentGeneratorConfig!: ContentGeneratorConfig; private contentGeneratorConfig!: ContentGeneratorConfig;
private contentGenerator!: ContentGenerator; private contentGenerator!: ContentGenerator;
private readonly _generationConfig: ContentGeneratorConfig; private _generationConfig: Partial<ContentGeneratorConfig>;
private readonly embeddingModel: string; private readonly embeddingModel: string;
private readonly sandbox: SandboxConfig | undefined; private readonly sandbox: SandboxConfig | undefined;
private readonly targetDir: string; private readonly targetDir: string;
@@ -349,11 +358,19 @@ export class Config {
private readonly cliVersion?: string; private readonly cliVersion?: string;
private readonly experimentalZedIntegration: boolean = false; private readonly experimentalZedIntegration: boolean = false;
private readonly loadMemoryFromIncludeDirectories: 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 chatCompression: ChatCompressionSettings | undefined;
private readonly interactive: boolean; private readonly interactive: boolean;
private readonly trustedFolder: boolean | undefined; private readonly trustedFolder: boolean | undefined;
private readonly useRipgrep: boolean; private readonly useRipgrep: boolean;
private readonly useBuiltinRipgrep: boolean;
private readonly shouldUseNodePtyShell: boolean; private readonly shouldUseNodePtyShell: boolean;
private readonly skipNextSpeakerCheck: boolean; private readonly skipNextSpeakerCheck: boolean;
private shellExecutionConfig: ShellExecutionConfig; private shellExecutionConfig: ShellExecutionConfig;
@@ -440,8 +457,10 @@ export class Config {
this._generationConfig = { this._generationConfig = {
model: params.model, model: params.model,
...(params.generationConfig || {}), ...(params.generationConfig || {}),
baseUrl: params.generationConfig?.baseUrl || DEFAULT_DASHSCOPE_BASE_URL,
}; };
this.contentGeneratorConfig = this._generationConfig; this.contentGeneratorConfig = this
._generationConfig as ContentGeneratorConfig;
this.cliVersion = params.cliVersion; this.cliVersion = params.cliVersion;
this.loadMemoryFromIncludeDirectories = this.loadMemoryFromIncludeDirectories =
@@ -449,13 +468,12 @@ export class Config {
this.chatCompression = params.chatCompression; this.chatCompression = params.chatCompression;
this.interactive = params.interactive ?? false; this.interactive = params.interactive ?? false;
this.trustedFolder = params.trustedFolder; this.trustedFolder = params.trustedFolder;
this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? false;
this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? false;
this.skipLoopDetection = params.skipLoopDetection ?? false; this.skipLoopDetection = params.skipLoopDetection ?? false;
// Web search // Web search
this.tavilyApiKey = params.tavilyApiKey; this.webSearch = params.webSearch;
this.useRipgrep = params.useRipgrep ?? true; this.useRipgrep = params.useRipgrep ?? true;
this.useBuiltinRipgrep = params.useBuiltinRipgrep ?? true;
this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? false; this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? false;
this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? true; this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? true;
this.shellExecutionConfig = { this.shellExecutionConfig = {
@@ -520,6 +538,26 @@ export class Config {
return this.contentGenerator; return this.contentGenerator;
} }
/**
* Updates the credentials in the generation config.
* This is needed when credentials are set after Config construction.
*/
updateCredentials(credentials: {
apiKey?: string;
baseUrl?: string;
model?: string;
}): void {
if (credentials.apiKey) {
this._generationConfig.apiKey = credentials.apiKey;
}
if (credentials.baseUrl) {
this._generationConfig.baseUrl = credentials.baseUrl;
}
if (credentials.model) {
this._generationConfig.model = credentials.model;
}
}
async refreshAuth(authMethod: AuthType) { async refreshAuth(authMethod: AuthType) {
// Vertex and Genai have incompatible encryption and sending history with // Vertex and Genai have incompatible encryption and sending history with
// throughtSignature from Genai to Vertex will fail, we need to strip them // throughtSignature from Genai to Vertex will fail, we need to strip them
@@ -587,7 +625,7 @@ export class Config {
} }
getModel(): string { getModel(): string {
return this.contentGeneratorConfig.model; return this.contentGeneratorConfig?.model || DEFAULT_QWEN_MODEL;
} }
async setModel( async setModel(
@@ -888,8 +926,8 @@ export class Config {
} }
// Web search provider configuration // Web search provider configuration
getTavilyApiKey(): string | undefined { getWebSearchConfig() {
return this.tavilyApiKey; return this.webSearch;
} }
getIdeMode(): boolean { getIdeMode(): boolean {
@@ -965,6 +1003,10 @@ export class Config {
return this.useRipgrep; return this.useRipgrep;
} }
getUseBuiltinRipgrep(): boolean {
return this.useBuiltinRipgrep;
}
getShouldUseNodePtyShell(): boolean { getShouldUseNodePtyShell(): boolean {
return this.shouldUseNodePtyShell; return this.shouldUseNodePtyShell;
} }
@@ -1092,13 +1134,18 @@ export class Config {
let useRipgrep = false; let useRipgrep = false;
let errorString: undefined | string = undefined; let errorString: undefined | string = undefined;
try { try {
useRipgrep = await canUseRipgrep(); useRipgrep = await canUseRipgrep(this.getUseBuiltinRipgrep());
} catch (error: unknown) { } catch (error: unknown) {
errorString = String(error); errorString = String(error);
} }
if (useRipgrep) { if (useRipgrep) {
registerCoreTool(RipGrepTool, this); registerCoreTool(RipGrepTool, this);
} else { } else {
errorString =
errorString ||
'Ripgrep is not available. Please install ripgrep globally.';
// Log for telemetry
logRipgrepFallback(this, new RipgrepFallbackEvent(errorString)); logRipgrepFallback(this, new RipgrepFallbackEvent(errorString));
registerCoreTool(GrepTool, this); registerCoreTool(GrepTool, this);
} }
@@ -1119,8 +1166,10 @@ export class Config {
registerCoreTool(TodoWriteTool, this); registerCoreTool(TodoWriteTool, this);
registerCoreTool(ExitPlanModeTool, this); registerCoreTool(ExitPlanModeTool, this);
registerCoreTool(WebFetchTool, this); registerCoreTool(WebFetchTool, this);
// Conditionally register web search tool only if Tavily API key is set // Conditionally register web search tool if web search provider is configured
if (this.getTavilyApiKey()) { // buildWebSearchConfig ensures qwen-oauth users get dashscope provider, so
// if tool is registered, config must exist
if (this.getWebSearchConfig()) {
registerCoreTool(WebSearchTool, this); registerCoreTool(WebSearchTool, this);
} }

View File

@@ -69,7 +69,7 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre
## Software Engineering Tasks ## Software Engineering Tasks
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach: When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach:
- **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know. - **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know.
- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'search_file_content', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). - **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
- **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn. - **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn.
- **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. - **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
- **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. - **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
@@ -288,7 +288,7 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre
## Software Engineering Tasks ## Software Engineering Tasks
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach: When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach:
- **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know. - **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know.
- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'search_file_content', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). - **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
- **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn. - **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn.
- **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. - **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
- **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. - **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
@@ -517,7 +517,7 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre
## Software Engineering Tasks ## Software Engineering Tasks
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach: When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach:
- **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know. - **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know.
- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'search_file_content', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). - **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
- **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn. - **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn.
- **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. - **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
- **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. - **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
@@ -731,7 +731,7 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre
## Software Engineering Tasks ## Software Engineering Tasks
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach: When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach:
- **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know. - **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know.
- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'search_file_content', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). - **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
- **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn. - **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn.
- **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. - **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
- **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. - **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
@@ -945,7 +945,7 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre
## Software Engineering Tasks ## Software Engineering Tasks
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach: When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach:
- **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know. - **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know.
- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'search_file_content', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). - **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
- **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn. - **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn.
- **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. - **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
- **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. - **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
@@ -1159,7 +1159,7 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre
## Software Engineering Tasks ## Software Engineering Tasks
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach: When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach:
- **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know. - **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know.
- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'search_file_content', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). - **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
- **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn. - **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn.
- **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. - **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
- **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. - **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
@@ -1373,7 +1373,7 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre
## Software Engineering Tasks ## Software Engineering Tasks
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach: When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach:
- **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know. - **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know.
- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'search_file_content', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). - **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
- **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn. - **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn.
- **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. - **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
- **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. - **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
@@ -1587,7 +1587,7 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre
## Software Engineering Tasks ## Software Engineering Tasks
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach: When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach:
- **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know. - **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know.
- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'search_file_content', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). - **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
- **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn. - **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn.
- **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. - **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
- **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. - **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
@@ -1801,7 +1801,7 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre
## Software Engineering Tasks ## Software Engineering Tasks
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach: When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach:
- **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know. - **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know.
- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'search_file_content', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). - **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
- **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn. - **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn.
- **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. - **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
- **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. - **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
@@ -2015,7 +2015,7 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre
## Software Engineering Tasks ## Software Engineering Tasks
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach: When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach:
- **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know. - **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know.
- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'search_file_content', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). - **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
- **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn. - **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn.
- **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. - **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
- **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. - **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
@@ -2252,7 +2252,7 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre
## Software Engineering Tasks ## Software Engineering Tasks
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach: When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach:
- **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know. - **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know.
- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'search_file_content', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). - **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
- **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn. - **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn.
- **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. - **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
- **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. - **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
@@ -2549,7 +2549,7 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre
## Software Engineering Tasks ## Software Engineering Tasks
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach: When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach:
- **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know. - **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know.
- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'search_file_content', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). - **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
- **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn. - **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn.
- **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. - **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
- **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. - **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
@@ -2786,7 +2786,7 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre
## Software Engineering Tasks ## Software Engineering Tasks
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach: When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach:
- **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know. - **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know.
- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'search_file_content', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). - **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
- **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn. - **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn.
- **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. - **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
- **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. - **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
@@ -3079,7 +3079,7 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre
## Software Engineering Tasks ## Software Engineering Tasks
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach: When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach:
- **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know. - **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know.
- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'search_file_content', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). - **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
- **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn. - **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn.
- **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. - **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
- **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. - **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
@@ -3293,7 +3293,7 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre
## Software Engineering Tasks ## Software Engineering Tasks
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach: When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this iterative approach:
- **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know. - **Plan:** After understanding the user's request, create an initial plan based on your existing knowledge and any immediately obvious context. Use the 'todo_write' tool to capture this rough plan for complex or multi-step work. Don't wait for complete understanding - start with what you know.
- **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'search_file_content', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). - **Implement:** Begin implementing the plan while gathering additional context as needed. Use 'grep_search', 'glob', 'read_file', and 'read_many_files' tools strategically when you encounter specific unknowns during implementation. Use the available tools (e.g., 'edit', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
- **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn. - **Adapt:** As you discover new information or encounter obstacles, update your plan and todos accordingly. Mark todos as in_progress when starting and completed when finishing each task. Add new todos if the scope expands. Refine your approach based on what you learn.
- **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. - **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
- **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. - **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.

View File

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

View File

@@ -16,11 +16,11 @@ import {
import type { Content, GenerateContentResponse, Part } from '@google/genai'; import type { Content, GenerateContentResponse, Part } from '@google/genai';
import { import {
findCompressSplitPoint,
isThinkingDefault, isThinkingDefault,
isThinkingSupported, isThinkingSupported,
GeminiClient, GeminiClient,
} from './client.js'; } from './client.js';
import { findCompressSplitPoint } from '../services/chatCompressionService.js';
import { import {
AuthType, AuthType,
type ContentGenerator, type ContentGenerator,
@@ -42,7 +42,6 @@ import { setSimulate429 } from '../utils/testUtils.js';
import { tokenLimit } from './tokenLimits.js'; import { tokenLimit } from './tokenLimits.js';
import { ideContextStore } from '../ide/ideContext.js'; import { ideContextStore } from '../ide/ideContext.js';
import { uiTelemetryService } from '../telemetry/uiTelemetry.js'; import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
import { QwenLogger } from '../telemetry/index.js';
// Mock fs module to prevent actual file system operations during tests // Mock fs module to prevent actual file system operations during tests
const mockFileSystem = new Map<string, string>(); const mockFileSystem = new Map<string, string>();
@@ -101,6 +100,22 @@ vi.mock('../utils/errorReporting', () => ({ reportError: vi.fn() }));
vi.mock('../utils/nextSpeakerChecker', () => ({ vi.mock('../utils/nextSpeakerChecker', () => ({
checkNextSpeaker: vi.fn().mockResolvedValue(null), checkNextSpeaker: vi.fn().mockResolvedValue(null),
})); }));
vi.mock('../utils/environmentContext', () => ({
getEnvironmentContext: vi
.fn()
.mockResolvedValue([{ text: 'Mocked env context' }]),
getInitialChatHistory: vi.fn(async (_config, extraHistory) => [
{
role: 'user',
parts: [{ text: 'Mocked env context' }],
},
{
role: 'model',
parts: [{ text: 'Got it. Thanks for the context!' }],
},
...(extraHistory ?? []),
]),
}));
vi.mock('../utils/generateContentResponseUtilities', () => ({ vi.mock('../utils/generateContentResponseUtilities', () => ({
getResponseText: (result: GenerateContentResponse) => getResponseText: (result: GenerateContentResponse) =>
result.candidates?.[0]?.content?.parts?.map((part) => part.text).join('') || result.candidates?.[0]?.content?.parts?.map((part) => part.text).join('') ||
@@ -136,6 +151,10 @@ vi.mock('../ide/ideContext.js');
vi.mock('../telemetry/uiTelemetry.js', () => ({ vi.mock('../telemetry/uiTelemetry.js', () => ({
uiTelemetryService: mockUiTelemetryService, uiTelemetryService: mockUiTelemetryService,
})); }));
vi.mock('../telemetry/loggers.js', () => ({
logChatCompression: vi.fn(),
logNextSpeakerCheck: vi.fn(),
}));
/** /**
* Array.fromAsync ponyfill, which will be available in es 2024. * Array.fromAsync ponyfill, which will be available in es 2024.
@@ -619,7 +638,8 @@ describe('Gemini Client (client.ts)', () => {
}); });
it('logs a telemetry event when compressing', async () => { it('logs a telemetry event when compressing', async () => {
vi.spyOn(QwenLogger.prototype, 'logChatCompressionEvent'); const { logChatCompression } = await import('../telemetry/loggers.js');
vi.mocked(logChatCompression).mockClear();
const MOCKED_TOKEN_LIMIT = 1000; const MOCKED_TOKEN_LIMIT = 1000;
const MOCKED_CONTEXT_PERCENTAGE_THRESHOLD = 0.5; const MOCKED_CONTEXT_PERCENTAGE_THRESHOLD = 0.5;
@@ -627,19 +647,37 @@ describe('Gemini Client (client.ts)', () => {
vi.spyOn(client['config'], 'getChatCompression').mockReturnValue({ vi.spyOn(client['config'], 'getChatCompression').mockReturnValue({
contextPercentageThreshold: MOCKED_CONTEXT_PERCENTAGE_THRESHOLD, contextPercentageThreshold: MOCKED_CONTEXT_PERCENTAGE_THRESHOLD,
}); });
const history = [{ role: 'user', parts: [{ text: '...history...' }] }]; // Need multiple history items so there's something to compress
const history = [
{ role: 'user', parts: [{ text: '...history 1...' }] },
{ role: 'model', parts: [{ text: '...history 2...' }] },
{ role: 'user', parts: [{ text: '...history 3...' }] },
{ role: 'model', parts: [{ text: '...history 4...' }] },
];
mockGetHistory.mockReturnValue(history); mockGetHistory.mockReturnValue(history);
// Token count needs to be ABOVE the threshold to trigger compression
const originalTokenCount = const originalTokenCount =
MOCKED_TOKEN_LIMIT * MOCKED_CONTEXT_PERCENTAGE_THRESHOLD; MOCKED_TOKEN_LIMIT * MOCKED_CONTEXT_PERCENTAGE_THRESHOLD + 1;
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue( vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(
originalTokenCount, originalTokenCount,
); );
// We need to control the estimated new token count. // Mock the summary response from the chat
// We mock startChat to return a chat with a known history.
const summaryText = 'This is a summary.'; const summaryText = 'This is a summary.';
mockGenerateContentFn.mockResolvedValue({
candidates: [
{
content: {
role: 'model',
parts: [{ text: summaryText }],
},
},
],
} as unknown as GenerateContentResponse);
// Mock startChat to complete the compression flow
const splitPoint = findCompressSplitPoint(history, 0.7); const splitPoint = findCompressSplitPoint(history, 0.7);
const historyToKeep = history.slice(splitPoint); const historyToKeep = history.slice(splitPoint);
const newCompressedHistory: Content[] = [ const newCompressedHistory: Content[] = [
@@ -659,52 +697,36 @@ describe('Gemini Client (client.ts)', () => {
.fn() .fn()
.mockResolvedValue(mockNewChat as GeminiChat); .mockResolvedValue(mockNewChat as GeminiChat);
const totalChars = newCompressedHistory.reduce(
(total, content) => total + JSON.stringify(content).length,
0,
);
const newTokenCount = Math.floor(totalChars / 4);
// Mock the summary response from the chat
mockGenerateContentFn.mockResolvedValue({
candidates: [
{
content: {
role: 'model',
parts: [{ text: summaryText }],
},
},
],
} as unknown as GenerateContentResponse);
await client.tryCompressChat('prompt-id-3', false); await client.tryCompressChat('prompt-id-3', false);
expect(QwenLogger.prototype.logChatCompressionEvent).toHaveBeenCalledWith( expect(logChatCompression).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ expect.objectContaining({
tokens_before: originalTokenCount, tokens_before: originalTokenCount,
tokens_after: newTokenCount,
}), }),
); );
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledWith( expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalled();
newTokenCount,
);
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledTimes(
1,
);
}); });
it('should trigger summarization if token count is at threshold with contextPercentageThreshold setting', async () => { it('should trigger summarization if token count is above threshold with contextPercentageThreshold setting', async () => {
const MOCKED_TOKEN_LIMIT = 1000; const MOCKED_TOKEN_LIMIT = 1000;
const MOCKED_CONTEXT_PERCENTAGE_THRESHOLD = 0.5; const MOCKED_CONTEXT_PERCENTAGE_THRESHOLD = 0.5;
vi.mocked(tokenLimit).mockReturnValue(MOCKED_TOKEN_LIMIT); vi.mocked(tokenLimit).mockReturnValue(MOCKED_TOKEN_LIMIT);
vi.spyOn(client['config'], 'getChatCompression').mockReturnValue({ vi.spyOn(client['config'], 'getChatCompression').mockReturnValue({
contextPercentageThreshold: MOCKED_CONTEXT_PERCENTAGE_THRESHOLD, contextPercentageThreshold: MOCKED_CONTEXT_PERCENTAGE_THRESHOLD,
}); });
const history = [{ role: 'user', parts: [{ text: '...history...' }] }]; // Need multiple history items so there's something to compress
const history = [
{ role: 'user', parts: [{ text: '...history 1...' }] },
{ role: 'model', parts: [{ text: '...history 2...' }] },
{ role: 'user', parts: [{ text: '...history 3...' }] },
{ role: 'model', parts: [{ text: '...history 4...' }] },
];
mockGetHistory.mockReturnValue(history); mockGetHistory.mockReturnValue(history);
// Token count needs to be ABOVE the threshold to trigger compression
const originalTokenCount = const originalTokenCount =
MOCKED_TOKEN_LIMIT * MOCKED_CONTEXT_PERCENTAGE_THRESHOLD; MOCKED_TOKEN_LIMIT * MOCKED_CONTEXT_PERCENTAGE_THRESHOLD + 1;
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue( vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(
originalTokenCount, originalTokenCount,
@@ -864,7 +886,13 @@ describe('Gemini Client (client.ts)', () => {
}); });
it('should always trigger summarization when force is true, regardless of token count', async () => { it('should always trigger summarization when force is true, regardless of token count', async () => {
const history = [{ role: 'user', parts: [{ text: '...history...' }] }]; // Need multiple history items so there's something to compress
const history = [
{ role: 'user', parts: [{ text: '...history 1...' }] },
{ role: 'model', parts: [{ text: '...history 2...' }] },
{ role: 'user', parts: [{ text: '...history 3...' }] },
{ role: 'model', parts: [{ text: '...history 4...' }] },
];
mockGetHistory.mockReturnValue(history); mockGetHistory.mockReturnValue(history);
const originalTokenCount = 100; // Well below threshold, but > estimated new count const originalTokenCount = 100; // Well below threshold, but > estimated new count

View File

@@ -25,13 +25,11 @@ import {
import type { ContentGenerator } from './contentGenerator.js'; import type { ContentGenerator } from './contentGenerator.js';
import { GeminiChat } from './geminiChat.js'; import { GeminiChat } from './geminiChat.js';
import { import {
getCompressionPrompt,
getCoreSystemPrompt, getCoreSystemPrompt,
getCustomSystemPrompt, getCustomSystemPrompt,
getPlanModeSystemReminder, getPlanModeSystemReminder,
getSubagentSystemReminder, getSubagentSystemReminder,
} from './prompts.js'; } from './prompts.js';
import { tokenLimit } from './tokenLimits.js';
import { import {
CompressionStatus, CompressionStatus,
GeminiEventType, GeminiEventType,
@@ -42,6 +40,11 @@ import {
// Services // Services
import { type ChatRecordingService } from '../services/chatRecordingService.js'; import { type ChatRecordingService } from '../services/chatRecordingService.js';
import {
ChatCompressionService,
COMPRESSION_PRESERVE_THRESHOLD,
COMPRESSION_TOKEN_THRESHOLD,
} from '../services/chatCompressionService.js';
import { LoopDetectionService } from '../services/loopDetectionService.js'; import { LoopDetectionService } from '../services/loopDetectionService.js';
// Tools // Tools
@@ -50,21 +53,18 @@ import { TaskTool } from '../tools/task.js';
// Telemetry // Telemetry
import { import {
NextSpeakerCheckEvent, NextSpeakerCheckEvent,
logChatCompression,
logNextSpeakerCheck, logNextSpeakerCheck,
makeChatCompressionEvent,
uiTelemetryService,
} from '../telemetry/index.js'; } from '../telemetry/index.js';
// Utilities // Utilities
import { import {
getDirectoryContextString, getDirectoryContextString,
getEnvironmentContext, getInitialChatHistory,
} from '../utils/environmentContext.js'; } from '../utils/environmentContext.js';
import { reportError } from '../utils/errorReporting.js'; import { reportError } from '../utils/errorReporting.js';
import { getErrorMessage } from '../utils/errors.js'; import { getErrorMessage } from '../utils/errors.js';
import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js'; import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js';
import { flatMapTextParts, getResponseText } from '../utils/partUtils.js'; import { flatMapTextParts } from '../utils/partUtils.js';
import { retryWithBackoff } from '../utils/retry.js'; import { retryWithBackoff } from '../utils/retry.js';
// IDE integration // IDE integration
@@ -85,68 +85,8 @@ export function isThinkingDefault(model: string) {
return model.startsWith('gemini-2.5') || model === DEFAULT_GEMINI_MODEL_AUTO; return model.startsWith('gemini-2.5') || model === DEFAULT_GEMINI_MODEL_AUTO;
} }
/**
* Returns the index of the oldest item to keep when compressing. May return
* contents.length which indicates that everything should be compressed.
*
* Exported for testing purposes.
*/
export function findCompressSplitPoint(
contents: Content[],
fraction: number,
): number {
if (fraction <= 0 || fraction >= 1) {
throw new Error('Fraction must be between 0 and 1');
}
const charCounts = contents.map((content) => JSON.stringify(content).length);
const totalCharCount = charCounts.reduce((a, b) => a + b, 0);
const targetCharCount = totalCharCount * fraction;
let lastSplitPoint = 0; // 0 is always valid (compress nothing)
let cumulativeCharCount = 0;
for (let i = 0; i < contents.length; i++) {
const content = contents[i];
if (
content.role === 'user' &&
!content.parts?.some((part) => !!part.functionResponse)
) {
if (cumulativeCharCount >= targetCharCount) {
return i;
}
lastSplitPoint = i;
}
cumulativeCharCount += charCounts[i];
}
// We found no split points after targetCharCount.
// Check if it's safe to compress everything.
const lastContent = contents[contents.length - 1];
if (
lastContent?.role === 'model' &&
!lastContent?.parts?.some((part) => part.functionCall)
) {
return contents.length;
}
// Can't compress everything so just compress at last splitpoint.
return lastSplitPoint;
}
const MAX_TURNS = 100; const MAX_TURNS = 100;
/**
* Threshold for compression token count as a fraction of the model's token limit.
* If the chat history exceeds this threshold, it will be compressed.
*/
const COMPRESSION_TOKEN_THRESHOLD = 0.7;
/**
* The fraction of the latest chat history to keep. A value of 0.3
* means that only the last 30% of the chat history will be kept after compression.
*/
const COMPRESSION_PRESERVE_THRESHOLD = 0.3;
export class GeminiClient { export class GeminiClient {
private chat?: GeminiChat; private chat?: GeminiChat;
private readonly generateContentConfig: GenerateContentConfig = { private readonly generateContentConfig: GenerateContentConfig = {
@@ -243,23 +183,13 @@ export class GeminiClient {
async startChat(extraHistory?: Content[]): Promise<GeminiChat> { async startChat(extraHistory?: Content[]): Promise<GeminiChat> {
this.forceFullIdeContext = true; this.forceFullIdeContext = true;
this.hasFailedCompressionAttempt = false; this.hasFailedCompressionAttempt = false;
const envParts = await getEnvironmentContext(this.config);
const toolRegistry = this.config.getToolRegistry(); const toolRegistry = this.config.getToolRegistry();
const toolDeclarations = toolRegistry.getFunctionDeclarations(); const toolDeclarations = toolRegistry.getFunctionDeclarations();
const tools: Tool[] = [{ functionDeclarations: toolDeclarations }]; const tools: Tool[] = [{ functionDeclarations: toolDeclarations }];
const history: Content[] = [ const history = await getInitialChatHistory(this.config, extraHistory);
{
role: 'user',
parts: envParts,
},
{
role: 'model',
parts: [{ text: 'Got it. Thanks for the context!' }],
},
...(extraHistory ?? []),
];
try { try {
const userMemory = this.config.getUserMemory(); const userMemory = this.config.getUserMemory();
const model = this.config.getModel(); const model = this.config.getModel();
@@ -503,14 +433,15 @@ export class GeminiClient {
userMemory, userMemory,
this.config.getModel(), this.config.getModel(),
); );
const environment = await getEnvironmentContext(this.config); const initialHistory = await getInitialChatHistory(this.config);
// Create a mock request content to count total tokens // Create a mock request content to count total tokens
const mockRequestContent = [ const mockRequestContent = [
{ {
role: 'system' as const, role: 'system' as const,
parts: [{ text: systemPrompt }, ...environment], parts: [{ text: systemPrompt }],
}, },
...initialHistory,
...currentHistory, ...currentHistory,
]; ];
@@ -732,127 +663,37 @@ export class GeminiClient {
prompt_id: string, prompt_id: string,
force: boolean = false, force: boolean = false,
): Promise<ChatCompressionInfo> { ): Promise<ChatCompressionInfo> {
const model = this.config.getModel(); const compressionService = new ChatCompressionService();
const curatedHistory = this.getChat().getHistory(true); const { newHistory, info } = await compressionService.compress(
this.getChat(),
prompt_id,
force,
this.config.getModel(),
this.config,
this.hasFailedCompressionAttempt,
);
// Regardless of `force`, don't do anything if the history is empty. // Handle compression result
if ( if (info.compressionStatus === CompressionStatus.COMPRESSED) {
curatedHistory.length === 0 || // Success: update chat with new compressed history
(this.hasFailedCompressionAttempt && !force) if (newHistory) {
this.chat = await this.startChat(newHistory);
this.forceFullIdeContext = true;
}
} else if (
info.compressionStatus ===
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT ||
info.compressionStatus ===
CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY
) { ) {
return { // Track failed attempts (only mark as failed if not forced)
originalTokenCount: 0, if (!force) {
newTokenCount: 0, this.hasFailedCompressionAttempt = true;
compressionStatus: CompressionStatus.NOOP,
};
}
const originalTokenCount = uiTelemetryService.getLastPromptTokenCount();
const contextPercentageThreshold =
this.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 {
originalTokenCount,
newTokenCount: originalTokenCount,
compressionStatus: CompressionStatus.NOOP,
};
} }
} }
const splitPoint = findCompressSplitPoint( return info;
curatedHistory,
1 - COMPRESSION_PRESERVE_THRESHOLD,
);
const historyToCompress = curatedHistory.slice(0, splitPoint);
const historyToKeep = curatedHistory.slice(splitPoint);
const summaryResponse = await this.config
.getContentGenerator()
.generateContent(
{
model,
contents: [
...historyToCompress,
{
role: 'user',
parts: [
{
text: 'First, reason in your scratchpad. Then, generate the <state_snapshot>.',
},
],
},
],
config: {
systemInstruction: { text: getCompressionPrompt() },
},
},
prompt_id,
);
const summary = getResponseText(summaryResponse) ?? '';
const chat = await this.startChat([
{
role: 'user',
parts: [{ text: summary }],
},
{
role: 'model',
parts: [{ text: 'Got it. Thanks for the additional context!' }],
},
...historyToKeep,
]);
this.forceFullIdeContext = true;
// Estimate token count 1 token ≈ 4 characters
const newTokenCount = Math.floor(
chat
.getHistory()
.reduce((total, content) => total + JSON.stringify(content).length, 0) /
4,
);
logChatCompression(
this.config,
makeChatCompressionEvent({
tokens_before: originalTokenCount,
tokens_after: newTokenCount,
}),
);
if (newTokenCount > originalTokenCount) {
this.hasFailedCompressionAttempt = !force && true;
return {
originalTokenCount,
newTokenCount,
compressionStatus:
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
};
} else {
this.chat = chat; // Chat compression successful, set new state.
uiTelemetryService.setLastPromptTokenCount(newTokenCount);
}
logChatCompression(
this.config,
makeChatCompressionEvent({
tokens_before: originalTokenCount,
tokens_after: newTokenCount,
}),
);
return {
originalTokenCount,
newTokenCount,
compressionStatus: CompressionStatus.COMPRESSED,
};
} }
} }

View File

@@ -4,13 +4,9 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import type { ContentGenerator } from './contentGenerator.js'; import type { ContentGenerator } from './contentGenerator.js';
import { import { createContentGenerator, AuthType } from './contentGenerator.js';
createContentGenerator,
AuthType,
createContentGeneratorConfig,
} from './contentGenerator.js';
import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js'; import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js';
import { GoogleGenAI } from '@google/genai'; import { GoogleGenAI } from '@google/genai';
import type { Config } from '../config/config.js'; import type { Config } from '../config/config.js';
@@ -110,83 +106,3 @@ describe('createContentGenerator', () => {
); );
}); });
}); });
describe('createContentGeneratorConfig', () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
setModel: vi.fn(),
flashFallbackHandler: vi.fn(),
getProxy: vi.fn(),
getEnableOpenAILogging: vi.fn().mockReturnValue(false),
getSamplingParams: vi.fn().mockReturnValue(undefined),
getContentGeneratorTimeout: vi.fn().mockReturnValue(undefined),
getContentGeneratorMaxRetries: vi.fn().mockReturnValue(undefined),
getContentGeneratorDisableCacheControl: vi.fn().mockReturnValue(undefined),
getContentGeneratorSamplingParams: vi.fn().mockReturnValue(undefined),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
} as unknown as Config;
beforeEach(() => {
// Reset modules to re-evaluate imports and environment variables
vi.resetModules();
vi.clearAllMocks();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('should configure for Gemini using GEMINI_API_KEY when set', async () => {
vi.stubEnv('GEMINI_API_KEY', 'env-gemini-key');
const config = await createContentGeneratorConfig(
mockConfig,
AuthType.USE_GEMINI,
);
expect(config.apiKey).toBe('env-gemini-key');
expect(config.vertexai).toBe(false);
});
it('should not configure for Gemini if GEMINI_API_KEY is empty', async () => {
vi.stubEnv('GEMINI_API_KEY', '');
const config = await createContentGeneratorConfig(
mockConfig,
AuthType.USE_GEMINI,
);
expect(config.apiKey).toBeUndefined();
expect(config.vertexai).toBeUndefined();
});
it('should configure for Vertex AI using GOOGLE_API_KEY when set', async () => {
vi.stubEnv('GOOGLE_API_KEY', 'env-google-key');
const config = await createContentGeneratorConfig(
mockConfig,
AuthType.USE_VERTEX_AI,
);
expect(config.apiKey).toBe('env-google-key');
expect(config.vertexai).toBe(true);
});
it('should configure for Vertex AI using GCP project and location when set', async () => {
vi.stubEnv('GOOGLE_API_KEY', undefined);
vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'env-gcp-project');
vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'env-gcp-location');
const config = await createContentGeneratorConfig(
mockConfig,
AuthType.USE_VERTEX_AI,
);
expect(config.vertexai).toBe(true);
expect(config.apiKey).toBeUndefined();
});
it('should not configure for Vertex AI if required env vars are empty', async () => {
vi.stubEnv('GOOGLE_API_KEY', '');
vi.stubEnv('GOOGLE_CLOUD_PROJECT', '');
vi.stubEnv('GOOGLE_CLOUD_LOCATION', '');
const config = await createContentGeneratorConfig(
mockConfig,
AuthType.USE_VERTEX_AI,
);
expect(config.apiKey).toBeUndefined();
expect(config.vertexai).toBeUndefined();
});
});

View File

@@ -14,8 +14,8 @@ import type {
} from '@google/genai'; } from '@google/genai';
import { GoogleGenAI } from '@google/genai'; import { GoogleGenAI } from '@google/genai';
import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js'; import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js';
import type { Config } from '../config/config.js';
import { DEFAULT_QWEN_MODEL } from '../config/models.js'; import { DEFAULT_QWEN_MODEL } from '../config/models.js';
import type { Config } from '../config/config.js';
import type { UserTierId } from '../code_assist/types.js'; import type { UserTierId } from '../code_assist/types.js';
import { InstallationManager } from '../utils/installationManager.js'; import { InstallationManager } from '../utils/installationManager.js';
@@ -58,6 +58,7 @@ export type ContentGeneratorConfig = {
vertexai?: boolean; vertexai?: boolean;
authType?: AuthType | undefined; authType?: AuthType | undefined;
enableOpenAILogging?: boolean; enableOpenAILogging?: boolean;
openAILoggingDir?: string;
// Timeout configuration in milliseconds // Timeout configuration in milliseconds
timeout?: number; timeout?: number;
// Maximum retries for failed requests // Maximum retries for failed requests
@@ -82,53 +83,37 @@ export function createContentGeneratorConfig(
authType: AuthType | undefined, authType: AuthType | undefined,
generationConfig?: Partial<ContentGeneratorConfig>, generationConfig?: Partial<ContentGeneratorConfig>,
): ContentGeneratorConfig { ): ContentGeneratorConfig {
const geminiApiKey = process.env['GEMINI_API_KEY'] || undefined; const newContentGeneratorConfig: Partial<ContentGeneratorConfig> = {
const googleApiKey = process.env['GOOGLE_API_KEY'] || undefined;
const googleCloudProject = process.env['GOOGLE_CLOUD_PROJECT'] || undefined;
const googleCloudLocation = process.env['GOOGLE_CLOUD_LOCATION'] || undefined;
const newContentGeneratorConfig: ContentGeneratorConfig = {
...(generationConfig || {}), ...(generationConfig || {}),
model: generationConfig?.model || DEFAULT_QWEN_MODEL,
authType, authType,
proxy: config?.getProxy(), proxy: config?.getProxy(),
}; };
// If we are using Google auth or we are in Cloud Shell, there is nothing else to validate for now
if (
authType === AuthType.LOGIN_WITH_GOOGLE ||
authType === AuthType.CLOUD_SHELL
) {
return newContentGeneratorConfig;
}
if (authType === AuthType.USE_GEMINI && geminiApiKey) {
newContentGeneratorConfig.apiKey = geminiApiKey;
newContentGeneratorConfig.vertexai = false;
return newContentGeneratorConfig;
}
if (
authType === AuthType.USE_VERTEX_AI &&
(googleApiKey || (googleCloudProject && googleCloudLocation))
) {
newContentGeneratorConfig.apiKey = googleApiKey;
newContentGeneratorConfig.vertexai = true;
return newContentGeneratorConfig;
}
if (authType === AuthType.QWEN_OAUTH) { if (authType === AuthType.QWEN_OAUTH) {
// For Qwen OAuth, we'll handle the API key dynamically in createContentGenerator // For Qwen OAuth, we'll handle the API key dynamically in createContentGenerator
// Set a special marker to indicate this is Qwen OAuth // Set a special marker to indicate this is Qwen OAuth
newContentGeneratorConfig.apiKey = 'QWEN_OAUTH_DYNAMIC_TOKEN'; return {
newContentGeneratorConfig.model = DEFAULT_QWEN_MODEL; ...newContentGeneratorConfig,
model: DEFAULT_QWEN_MODEL,
return newContentGeneratorConfig; apiKey: 'QWEN_OAUTH_DYNAMIC_TOKEN',
} as ContentGeneratorConfig;
} }
return newContentGeneratorConfig; if (authType === AuthType.USE_OPENAI) {
if (!newContentGeneratorConfig.apiKey) {
throw new Error('OpenAI API key is required');
}
return {
...newContentGeneratorConfig,
model: newContentGeneratorConfig?.model || 'qwen3-coder-plus',
} as ContentGeneratorConfig;
}
return {
...newContentGeneratorConfig,
model: newContentGeneratorConfig?.model || DEFAULT_QWEN_MODEL,
} as ContentGeneratorConfig;
} }
export async function createContentGenerator( export async function createContentGenerator(

View File

@@ -1,2 +1,8 @@
export const DEFAULT_TIMEOUT = 120000; export const DEFAULT_TIMEOUT = 120000;
export const DEFAULT_MAX_RETRIES = 3; export const DEFAULT_MAX_RETRIES = 3;
export const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1';
export const DEFAULT_DASHSCOPE_BASE_URL =
'https://dashscope.aliyuncs.com/compatible-mode/v1';
export const DEFAULT_DEEPSEEK_BASE_URL = 'https://api.deepseek.com/v1';
export const DEFAULT_OPEN_ROUTER_BASE_URL = 'https://openrouter.ai/api/v1';

View File

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

View File

@@ -2,7 +2,11 @@ import OpenAI from 'openai';
import type { Config } from '../../../config/config.js'; import type { Config } from '../../../config/config.js';
import type { ContentGeneratorConfig } from '../../contentGenerator.js'; import type { ContentGeneratorConfig } from '../../contentGenerator.js';
import { AuthType } from '../../contentGenerator.js'; import { AuthType } from '../../contentGenerator.js';
import { DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES } from '../constants.js'; import {
DEFAULT_TIMEOUT,
DEFAULT_MAX_RETRIES,
DEFAULT_DASHSCOPE_BASE_URL,
} from '../constants.js';
import { tokenLimit } from '../../tokenLimits.js'; import { tokenLimit } from '../../tokenLimits.js';
import type { import type {
OpenAICompatibleProvider, OpenAICompatibleProvider,
@@ -53,7 +57,7 @@ export class DashScopeOpenAICompatibleProvider
buildClient(): OpenAI { buildClient(): OpenAI {
const { const {
apiKey, apiKey,
baseUrl, baseUrl = DEFAULT_DASHSCOPE_BASE_URL,
timeout = DEFAULT_TIMEOUT, timeout = DEFAULT_TIMEOUT,
maxRetries = DEFAULT_MAX_RETRIES, maxRetries = DEFAULT_MAX_RETRIES,
} = this.contentGeneratorConfig; } = this.contentGeneratorConfig;

View File

@@ -7,7 +7,7 @@
import type { Config } from '../../config/config.js'; import type { Config } from '../../config/config.js';
import { logApiError, logApiResponse } from '../../telemetry/loggers.js'; import { logApiError, logApiResponse } from '../../telemetry/loggers.js';
import { ApiErrorEvent, ApiResponseEvent } from '../../telemetry/types.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 { GenerateContentResponse } from '@google/genai';
import type OpenAI from 'openai'; import type OpenAI from 'openai';
@@ -43,10 +43,17 @@ export interface TelemetryService {
} }
export class DefaultTelemetryService implements TelemetryService { export class DefaultTelemetryService implements TelemetryService {
private logger: OpenAILogger;
constructor( constructor(
private config: Config, private config: Config,
private enableOpenAILogging: boolean = false, 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( async logSuccess(
context: RequestContext, context: RequestContext,
@@ -68,7 +75,7 @@ export class DefaultTelemetryService implements TelemetryService {
// Log interaction if enabled // Log interaction if enabled
if (this.enableOpenAILogging && openaiRequest && openaiResponse) { 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 // Log error interaction if enabled
if (this.enableOpenAILogging && openaiRequest) { if (this.enableOpenAILogging && openaiRequest) {
await openaiLogger.logInteraction( await this.logger.logInteraction(
openaiRequest, openaiRequest,
undefined, undefined,
error as Error, error as Error,
@@ -137,7 +144,7 @@ export class DefaultTelemetryService implements TelemetryService {
openaiChunks.length > 0 openaiChunks.length > 0
) { ) {
const combinedResponse = this.combineOpenAIChunksForLogging(openaiChunks); const combinedResponse = this.combineOpenAIChunksForLogging(openaiChunks);
await openaiLogger.logInteraction(openaiRequest, combinedResponse); await this.logger.logInteraction(openaiRequest, combinedResponse);
} }
} }

View File

@@ -153,6 +153,9 @@ export enum CompressionStatus {
/** The compression failed due to an error counting tokens */ /** The compression failed due to an error counting tokens */
COMPRESSION_FAILED_TOKEN_COUNT_ERROR, COMPRESSION_FAILED_TOKEN_COUNT_ERROR,
/** The compression failed due to receiving an empty or null summary */
COMPRESSION_FAILED_EMPTY_SUMMARY,
/** The compression was not necessary and no action was taken */ /** The compression was not necessary and no action was taken */
NOOP, NOOP,
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -48,6 +48,7 @@ export * from './utils/systemEncoding.js';
export * from './utils/textUtils.js'; export * from './utils/textUtils.js';
export * from './utils/formatters.js'; export * from './utils/formatters.js';
export * from './utils/generateContentResponseUtilities.js'; export * from './utils/generateContentResponseUtilities.js';
export * from './utils/ripgrepUtils.js';
export * from './utils/filesearch/fileSearch.js'; export * from './utils/filesearch/fileSearch.js';
export * from './utils/errorParsing.js'; export * from './utils/errorParsing.js';
export * from './utils/workspaceContext.js'; export * from './utils/workspaceContext.js';
@@ -97,7 +98,7 @@ export * from './tools/write-file.js';
export * from './tools/web-fetch.js'; export * from './tools/web-fetch.js';
export * from './tools/memoryTool.js'; export * from './tools/memoryTool.js';
export * from './tools/shell.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/read-many-files.js';
export * from './tools/mcp-client.js'; export * from './tools/mcp-client.js';
export * from './tools/mcp-tool.js'; export * from './tools/mcp-tool.js';

View File

@@ -8,7 +8,7 @@ import { OpenAIContentGenerator } from '../core/openaiContentGenerator/index.js'
import { DashScopeOpenAICompatibleProvider } from '../core/openaiContentGenerator/provider/dashscope.js'; import { DashScopeOpenAICompatibleProvider } from '../core/openaiContentGenerator/provider/dashscope.js';
import type { IQwenOAuth2Client } from './qwenOAuth2.js'; import type { IQwenOAuth2Client } from './qwenOAuth2.js';
import { SharedTokenManager } from './sharedTokenManager.js'; import { SharedTokenManager } from './sharedTokenManager.js';
import type { Config } from '../config/config.js'; import { type Config } from '../config/config.js';
import type { import type {
GenerateContentParameters, GenerateContentParameters,
GenerateContentResponse, GenerateContentResponse,
@@ -18,10 +18,7 @@ import type {
EmbedContentResponse, EmbedContentResponse,
} from '@google/genai'; } from '@google/genai';
import type { ContentGeneratorConfig } from '../core/contentGenerator.js'; import type { ContentGeneratorConfig } from '../core/contentGenerator.js';
import { DEFAULT_DASHSCOPE_BASE_URL } from '../core/openaiContentGenerator/constants.js';
// Default fallback base URL if no endpoint is provided
const DEFAULT_QWEN_BASE_URL =
'https://dashscope.aliyuncs.com/compatible-mode/v1';
/** /**
* Qwen Content Generator that uses Qwen OAuth tokens with automatic refresh * Qwen Content Generator that uses Qwen OAuth tokens with automatic refresh
@@ -58,7 +55,7 @@ export class QwenContentGenerator extends OpenAIContentGenerator {
* Get the current endpoint URL with proper protocol and /v1 suffix * Get the current endpoint URL with proper protocol and /v1 suffix
*/ */
private getCurrentEndpoint(resourceUrl?: string): string { private getCurrentEndpoint(resourceUrl?: string): string {
const baseEndpoint = resourceUrl || DEFAULT_QWEN_BASE_URL; const baseEndpoint = resourceUrl || DEFAULT_DASHSCOPE_BASE_URL;
const suffix = '/v1'; const suffix = '/v1';
// Normalize the URL: add protocol if missing, ensure /v1 suffix // Normalize the URL: add protocol if missing, ensure /v1 suffix

View File

@@ -0,0 +1,372 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
ChatCompressionService,
findCompressSplitPoint,
} from './chatCompressionService.js';
import type { Content, GenerateContentResponse } from '@google/genai';
import { CompressionStatus } from '../core/turn.js';
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
import { tokenLimit } from '../core/tokenLimits.js';
import type { GeminiChat } from '../core/geminiChat.js';
import type { Config } from '../config/config.js';
import { getInitialChatHistory } from '../utils/environmentContext.js';
import type { ContentGenerator } from '../core/contentGenerator.js';
vi.mock('../telemetry/uiTelemetry.js');
vi.mock('../core/tokenLimits.js');
vi.mock('../telemetry/loggers.js');
vi.mock('../utils/environmentContext.js');
describe('findCompressSplitPoint', () => {
it('should throw an error for non-positive numbers', () => {
expect(() => findCompressSplitPoint([], 0)).toThrow(
'Fraction must be between 0 and 1',
);
});
it('should throw an error for a fraction greater than or equal to 1', () => {
expect(() => findCompressSplitPoint([], 1)).toThrow(
'Fraction must be between 0 and 1',
);
});
it('should handle an empty history', () => {
expect(findCompressSplitPoint([], 0.5)).toBe(0);
});
it('should handle a fraction in the middle', () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'This is the first message.' }] }, // JSON length: 66 (19%)
{ role: 'model', parts: [{ text: 'This is the second message.' }] }, // JSON length: 68 (40%)
{ role: 'user', parts: [{ text: 'This is the third message.' }] }, // JSON length: 66 (60%)
{ role: 'model', parts: [{ text: 'This is the fourth message.' }] }, // JSON length: 68 (80%)
{ role: 'user', parts: [{ text: 'This is the fifth message.' }] }, // JSON length: 65 (100%)
];
expect(findCompressSplitPoint(history, 0.5)).toBe(4);
});
it('should handle a fraction of last index', () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'This is the first message.' }] }, // JSON length: 66 (19%)
{ role: 'model', parts: [{ text: 'This is the second message.' }] }, // JSON length: 68 (40%)
{ role: 'user', parts: [{ text: 'This is the third message.' }] }, // JSON length: 66 (60%)
{ role: 'model', parts: [{ text: 'This is the fourth message.' }] }, // JSON length: 68 (80%)
{ role: 'user', parts: [{ text: 'This is the fifth message.' }] }, // JSON length: 65 (100%)
];
expect(findCompressSplitPoint(history, 0.9)).toBe(4);
});
it('should handle a fraction of after last index', () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'This is the first message.' }] }, // JSON length: 66 (24%)
{ role: 'model', parts: [{ text: 'This is the second message.' }] }, // JSON length: 68 (50%)
{ role: 'user', parts: [{ text: 'This is the third message.' }] }, // JSON length: 66 (74%)
{ role: 'model', parts: [{ text: 'This is the fourth message.' }] }, // JSON length: 68 (100%)
];
expect(findCompressSplitPoint(history, 0.8)).toBe(4);
});
it('should return earlier splitpoint if no valid ones are after threshhold', () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'This is the first message.' }] },
{ role: 'model', parts: [{ text: 'This is the second message.' }] },
{ role: 'user', parts: [{ text: 'This is the third message.' }] },
{ role: 'model', parts: [{ functionCall: { name: 'foo', args: {} } }] },
];
// Can't return 4 because the previous item has a function call.
expect(findCompressSplitPoint(history, 0.99)).toBe(2);
});
it('should handle a history with only one item', () => {
const historyWithEmptyParts: Content[] = [
{ role: 'user', parts: [{ text: 'Message 1' }] },
];
expect(findCompressSplitPoint(historyWithEmptyParts, 0.5)).toBe(0);
});
it('should handle history with weird parts', () => {
const historyWithEmptyParts: Content[] = [
{ role: 'user', parts: [{ text: 'Message 1' }] },
{
role: 'model',
parts: [{ fileData: { fileUri: 'derp', mimeType: 'text/plain' } }],
},
{ role: 'user', parts: [{ text: 'Message 2' }] },
];
expect(findCompressSplitPoint(historyWithEmptyParts, 0.5)).toBe(2);
});
});
describe('ChatCompressionService', () => {
let service: ChatCompressionService;
let mockChat: GeminiChat;
let mockConfig: Config;
const mockModel = 'gemini-pro';
const mockPromptId = 'test-prompt-id';
beforeEach(() => {
service = new ChatCompressionService();
mockChat = {
getHistory: vi.fn(),
} as unknown as GeminiChat;
mockConfig = {
getChatCompression: vi.fn(),
getContentGenerator: vi.fn(),
} as unknown as Config;
vi.mocked(tokenLimit).mockReturnValue(1000);
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(500);
vi.mocked(getInitialChatHistory).mockImplementation(
async (_config, extraHistory) => extraHistory || [],
);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should return NOOP if history is empty', async () => {
vi.mocked(mockChat.getHistory).mockReturnValue([]);
const result = await service.compress(
mockChat,
mockPromptId,
false,
mockModel,
mockConfig,
false,
);
expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP);
expect(result.newHistory).toBeNull();
});
it('should return NOOP if previously failed and not forced', async () => {
vi.mocked(mockChat.getHistory).mockReturnValue([
{ role: 'user', parts: [{ text: 'hi' }] },
]);
const result = await service.compress(
mockChat,
mockPromptId,
false,
mockModel,
mockConfig,
true,
);
expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP);
expect(result.newHistory).toBeNull();
});
it('should return NOOP if under token threshold and not forced', async () => {
vi.mocked(mockChat.getHistory).mockReturnValue([
{ role: 'user', parts: [{ text: 'hi' }] },
]);
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(600);
vi.mocked(tokenLimit).mockReturnValue(1000);
// Threshold is 0.7 * 1000 = 700. 600 < 700, so NOOP.
const result = await service.compress(
mockChat,
mockPromptId,
false,
mockModel,
mockConfig,
false,
);
expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP);
expect(result.newHistory).toBeNull();
});
it('should compress if over token threshold', async () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'msg1' }] },
{ role: 'model', parts: [{ text: 'msg2' }] },
{ role: 'user', parts: [{ text: 'msg3' }] },
{ role: 'model', parts: [{ text: 'msg4' }] },
];
vi.mocked(mockChat.getHistory).mockReturnValue(history);
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(800);
vi.mocked(tokenLimit).mockReturnValue(1000);
const mockGenerateContent = vi.fn().mockResolvedValue({
candidates: [
{
content: {
parts: [{ text: 'Summary' }],
},
},
],
} as unknown as GenerateContentResponse);
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.compressionStatus).toBe(CompressionStatus.COMPRESSED);
expect(result.newHistory).not.toBeNull();
expect(result.newHistory![0].parts![0].text).toBe('Summary');
expect(mockGenerateContent).toHaveBeenCalled();
});
it('should force compress even if under threshold', async () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'msg1' }] },
{ role: 'model', parts: [{ text: 'msg2' }] },
{ role: 'user', parts: [{ text: 'msg3' }] },
{ role: 'model', parts: [{ text: 'msg4' }] },
];
vi.mocked(mockChat.getHistory).mockReturnValue(history);
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(100);
vi.mocked(tokenLimit).mockReturnValue(1000);
const mockGenerateContent = vi.fn().mockResolvedValue({
candidates: [
{
content: {
parts: [{ text: 'Summary' }],
},
},
],
} as unknown as GenerateContentResponse);
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
generateContent: mockGenerateContent,
} as unknown as ContentGenerator);
const result = await service.compress(
mockChat,
mockPromptId,
true, // forced
mockModel,
mockConfig,
false,
);
expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED);
expect(result.newHistory).not.toBeNull();
});
it('should return FAILED if new token count is inflated', 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(10);
vi.mocked(tokenLimit).mockReturnValue(1000);
const longSummary = 'a'.repeat(1000); // Long summary to inflate token count
const mockGenerateContent = vi.fn().mockResolvedValue({
candidates: [
{
content: {
parts: [{ text: longSummary }],
},
},
],
} as unknown as GenerateContentResponse);
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
generateContent: mockGenerateContent,
} as unknown as ContentGenerator);
const result = await service.compress(
mockChat,
mockPromptId,
true,
mockModel,
mockConfig,
false,
);
expect(result.info.compressionStatus).toBe(
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
);
expect(result.newHistory).toBeNull();
});
it('should return FAILED if summary is empty string', 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(100);
vi.mocked(tokenLimit).mockReturnValue(1000);
const mockGenerateContent = vi.fn().mockResolvedValue({
candidates: [
{
content: {
parts: [{ text: '' }], // Empty summary
},
},
],
} as unknown as GenerateContentResponse);
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
generateContent: mockGenerateContent,
} as unknown as ContentGenerator);
const result = await service.compress(
mockChat,
mockPromptId,
true,
mockModel,
mockConfig,
false,
);
expect(result.info.compressionStatus).toBe(
CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY,
);
expect(result.newHistory).toBeNull();
expect(result.info.originalTokenCount).toBe(100);
expect(result.info.newTokenCount).toBe(100);
});
it('should return FAILED if summary is only whitespace', 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(100);
vi.mocked(tokenLimit).mockReturnValue(1000);
const mockGenerateContent = vi.fn().mockResolvedValue({
candidates: [
{
content: {
parts: [{ text: ' \n\t ' }], // Only whitespace
},
},
],
} as unknown as GenerateContentResponse);
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
generateContent: mockGenerateContent,
} as unknown as ContentGenerator);
const result = await service.compress(
mockChat,
mockPromptId,
true,
mockModel,
mockConfig,
false,
);
expect(result.info.compressionStatus).toBe(
CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY,
);
expect(result.newHistory).toBeNull();
});
});

View File

@@ -0,0 +1,235 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Content } from '@google/genai';
import type { Config } from '../config/config.js';
import type { GeminiChat } from '../core/geminiChat.js';
import { type ChatCompressionInfo, CompressionStatus } from '../core/turn.js';
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
import { tokenLimit } from '../core/tokenLimits.js';
import { getCompressionPrompt } from '../core/prompts.js';
import { getResponseText } from '../utils/partUtils.js';
import { logChatCompression } from '../telemetry/loggers.js';
import { makeChatCompressionEvent } from '../telemetry/types.js';
import { getInitialChatHistory } from '../utils/environmentContext.js';
/**
* Threshold for compression token count as a fraction of the model's token limit.
* If the chat history exceeds this threshold, it will be compressed.
*/
export const COMPRESSION_TOKEN_THRESHOLD = 0.7;
/**
* The fraction of the latest chat history to keep. A value of 0.3
* means that only the last 30% of the chat history will be kept after compression.
*/
export const COMPRESSION_PRESERVE_THRESHOLD = 0.3;
/**
* Returns the index of the oldest item to keep when compressing. May return
* contents.length which indicates that everything should be compressed.
*
* Exported for testing purposes.
*/
export function findCompressSplitPoint(
contents: Content[],
fraction: number,
): number {
if (fraction <= 0 || fraction >= 1) {
throw new Error('Fraction must be between 0 and 1');
}
const charCounts = contents.map((content) => JSON.stringify(content).length);
const totalCharCount = charCounts.reduce((a, b) => a + b, 0);
const targetCharCount = totalCharCount * fraction;
let lastSplitPoint = 0; // 0 is always valid (compress nothing)
let cumulativeCharCount = 0;
for (let i = 0; i < contents.length; i++) {
const content = contents[i];
if (
content.role === 'user' &&
!content.parts?.some((part) => !!part.functionResponse)
) {
if (cumulativeCharCount >= targetCharCount) {
return i;
}
lastSplitPoint = i;
}
cumulativeCharCount += charCounts[i];
}
// We found no split points after targetCharCount.
// Check if it's safe to compress everything.
const lastContent = contents[contents.length - 1];
if (
lastContent?.role === 'model' &&
!lastContent?.parts?.some((part) => part.functionCall)
) {
return contents.length;
}
// Can't compress everything so just compress at last splitpoint.
return lastSplitPoint;
}
export class ChatCompressionService {
async compress(
chat: GeminiChat,
promptId: string,
force: boolean,
model: string,
config: Config,
hasFailedCompressionAttempt: boolean,
): Promise<{ newHistory: Content[] | null; info: ChatCompressionInfo }> {
const curatedHistory = chat.getHistory(true);
// Regardless of `force`, don't do anything if the history is empty.
if (
curatedHistory.length === 0 ||
(hasFailedCompressionAttempt && !force)
) {
return {
newHistory: null,
info: {
originalTokenCount: 0,
newTokenCount: 0,
compressionStatus: CompressionStatus.NOOP,
},
};
}
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,
info: {
originalTokenCount,
newTokenCount: originalTokenCount,
compressionStatus: CompressionStatus.NOOP,
},
};
}
}
const splitPoint = findCompressSplitPoint(
curatedHistory,
1 - COMPRESSION_PRESERVE_THRESHOLD,
);
const historyToCompress = curatedHistory.slice(0, splitPoint);
const historyToKeep = curatedHistory.slice(splitPoint);
if (historyToCompress.length === 0) {
return {
newHistory: null,
info: {
originalTokenCount,
newTokenCount: originalTokenCount,
compressionStatus: CompressionStatus.NOOP,
},
};
}
const summaryResponse = await config.getContentGenerator().generateContent(
{
model,
contents: [
...historyToCompress,
{
role: 'user',
parts: [
{
text: 'First, reason in your scratchpad. Then, generate the <state_snapshot>.',
},
],
},
],
config: {
systemInstruction: getCompressionPrompt(),
},
},
promptId,
);
const summary = getResponseText(summaryResponse) ?? '';
const isSummaryEmpty = !summary || summary.trim().length === 0;
let newTokenCount = originalTokenCount;
let extraHistory: Content[] = [];
if (!isSummaryEmpty) {
extraHistory = [
{
role: 'user',
parts: [{ text: summary }],
},
{
role: 'model',
parts: [{ text: 'Got it. Thanks for the additional context!' }],
},
...historyToKeep,
];
// Use a shared utility to construct the initial history for an accurate token count.
const fullNewHistory = await getInitialChatHistory(config, extraHistory);
// Estimate token count 1 token ≈ 4 characters
newTokenCount = Math.floor(
fullNewHistory.reduce(
(total, content) => total + JSON.stringify(content).length,
0,
) / 4,
);
}
logChatCompression(
config,
makeChatCompressionEvent({
tokens_before: originalTokenCount,
tokens_after: newTokenCount,
}),
);
if (isSummaryEmpty) {
return {
newHistory: null,
info: {
originalTokenCount,
newTokenCount: originalTokenCount,
compressionStatus: CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY,
},
};
} else if (newTokenCount > originalTokenCount) {
return {
newHistory: null,
info: {
originalTokenCount,
newTokenCount,
compressionStatus:
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
},
};
} else {
uiTelemetryService.setLastPromptTokenCount(newTokenCount);
return {
newHistory: extraHistory,
info: {
originalTokenCount,
newTokenCount,
compressionStatus: CompressionStatus.COMPRESSED,
},
};
}
}
}

View File

@@ -32,7 +32,6 @@ import { GeminiChat } from '../core/geminiChat.js';
import { executeToolCall } from '../core/nonInteractiveToolExecutor.js'; import { executeToolCall } from '../core/nonInteractiveToolExecutor.js';
import type { ToolRegistry } from '../tools/tool-registry.js'; import type { ToolRegistry } from '../tools/tool-registry.js';
import { type AnyDeclarativeTool } from '../tools/tools.js'; import { type AnyDeclarativeTool } from '../tools/tools.js';
import { getEnvironmentContext } from '../utils/environmentContext.js';
import { ContextState, SubAgentScope } from './subagent.js'; import { ContextState, SubAgentScope } from './subagent.js';
import type { import type {
ModelConfig, ModelConfig,
@@ -44,7 +43,20 @@ import { SubagentTerminateMode } from './types.js';
vi.mock('../core/geminiChat.js'); vi.mock('../core/geminiChat.js');
vi.mock('../core/contentGenerator.js'); vi.mock('../core/contentGenerator.js');
vi.mock('../utils/environmentContext.js'); vi.mock('../utils/environmentContext.js', () => ({
getEnvironmentContext: vi.fn().mockResolvedValue([{ text: 'Env Context' }]),
getInitialChatHistory: vi.fn(async (_config, extraHistory) => [
{
role: 'user',
parts: [{ text: 'Env Context' }],
},
{
role: 'model',
parts: [{ text: 'Got it. Thanks for the context!' }],
},
...(extraHistory ?? []),
]),
}));
vi.mock('../core/nonInteractiveToolExecutor.js'); vi.mock('../core/nonInteractiveToolExecutor.js');
vi.mock('../ide/ide-client.js'); vi.mock('../ide/ide-client.js');
vi.mock('../core/client.js'); vi.mock('../core/client.js');
@@ -174,9 +186,6 @@ describe('subagent.ts', () => {
beforeEach(async () => { beforeEach(async () => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(getEnvironmentContext).mockResolvedValue([
{ text: 'Env Context' },
]);
vi.mocked(createContentGenerator).mockResolvedValue({ vi.mocked(createContentGenerator).mockResolvedValue({
getGenerativeModel: vi.fn(), getGenerativeModel: vi.fn(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@@ -16,7 +16,7 @@ import type {
ToolConfirmationOutcome, ToolConfirmationOutcome,
ToolCallConfirmationDetails, ToolCallConfirmationDetails,
} from '../tools/tools.js'; } from '../tools/tools.js';
import { getEnvironmentContext } from '../utils/environmentContext.js'; import { getInitialChatHistory } from '../utils/environmentContext.js';
import type { import type {
Content, Content,
Part, Part,
@@ -807,11 +807,7 @@ export class SubAgentScope {
); );
} }
const envParts = await getEnvironmentContext(this.runtimeContext); const envHistory = await getInitialChatHistory(this.runtimeContext);
const envHistory: Content[] = [
{ role: 'user', parts: envParts },
{ role: 'model', parts: [{ text: 'Got it. Thanks for the context!' }] },
];
const start_history = [ const start_history = [
...envHistory, ...envHistory,

View File

@@ -23,6 +23,7 @@ import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.j
import type { ChildProcess } from 'node:child_process'; import type { ChildProcess } from 'node:child_process';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { ensureRipgrepPath } from '../utils/ripgrepUtils.js'; import { ensureRipgrepPath } from '../utils/ripgrepUtils.js';
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
// Mock ripgrepUtils // Mock ripgrepUtils
vi.mock('../utils/ripgrepUtils.js', () => ({ vi.mock('../utils/ripgrepUtils.js', () => ({
@@ -42,11 +43,17 @@ function createMockSpawn(
outputData?: string; outputData?: string;
exitCode?: number; exitCode?: number;
signal?: string; signal?: string;
onCall?: (
command: string,
args: readonly string[],
spawnOptions?: unknown,
) => void;
} = {}, } = {},
) { ) {
const { outputData, exitCode = 0, signal } = options; const { outputData, exitCode = 0, signal, onCall } = options;
return () => { return (command: string, args: readonly string[], spawnOptions?: unknown) => {
onCall?.(command, args, spawnOptions);
const mockProcess = { const mockProcess = {
stdout: { stdout: {
on: vi.fn(), on: vi.fn(),
@@ -87,19 +94,29 @@ function createMockSpawn(
describe('RipGrepTool', () => { describe('RipGrepTool', () => {
let tempRootDir: string; let tempRootDir: string;
let grepTool: RipGrepTool; let grepTool: RipGrepTool;
let fileExclusionsMock: { getGlobExcludes: () => string[] };
const abortSignal = new AbortController().signal; const abortSignal = new AbortController().signal;
const mockConfig = { const mockConfig = {
getTargetDir: () => tempRootDir, getTargetDir: () => tempRootDir,
getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir), getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir),
getWorkingDir: () => tempRootDir,
getDebugMode: () => false, getDebugMode: () => false,
getUseBuiltinRipgrep: () => true,
} as unknown as Config; } as unknown as Config;
beforeEach(async () => { beforeEach(async () => {
vi.clearAllMocks(); vi.clearAllMocks();
(ensureRipgrepPath as Mock).mockResolvedValue('/mock/path/to/rg'); (ensureRipgrepPath as Mock).mockResolvedValue('/mock/path/to/rg');
mockSpawn.mockClear(); mockSpawn.mockReset();
tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grep-tool-root-')); tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grep-tool-root-'));
fileExclusionsMock = {
getGlobExcludes: vi.fn().mockReturnValue([]),
};
Object.assign(mockConfig, {
getFileExclusions: () => fileExclusionsMock,
getFileFilteringOptions: () => DEFAULT_FILE_FILTERING_OPTIONS,
});
grepTool = new RipGrepTool(mockConfig); grepTool = new RipGrepTool(mockConfig);
// Create some test files and directories // Create some test files and directories
@@ -137,11 +154,11 @@ describe('RipGrepTool', () => {
expect(grepTool.validateToolParams(params)).toBeNull(); 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: RipGrepToolParams = { const params: RipGrepToolParams = {
pattern: 'hello', pattern: 'hello',
path: '.', path: '.',
include: '*.txt', glob: '*.txt',
}; };
expect(grepTool.validateToolParams(params)).toBeNull(); expect(grepTool.validateToolParams(params)).toBeNull();
}); });
@@ -153,9 +170,11 @@ describe('RipGrepTool', () => {
); );
}); });
it('should return null for what would be an invalid regex pattern', () => { it('should surface an error for invalid regex pattern', () => {
const params: RipGrepToolParams = { pattern: '[[' }; const params: RipGrepToolParams = { pattern: '[[' };
expect(grepTool.validateToolParams(params)).toBeNull(); expect(grepTool.validateToolParams(params)).toContain(
'Invalid regular expression pattern: [[',
);
}); });
it('should return error if path does not exist', () => { it('should return error if path does not exist', () => {
@@ -194,13 +213,11 @@ describe('RipGrepTool', () => {
expect(result.llmContent).toContain( expect(result.llmContent).toContain(
'Found 3 matches for pattern "world" in the workspace directory', 'Found 3 matches for pattern "world" in the workspace directory',
); );
expect(result.llmContent).toContain('File: fileA.txt'); expect(result.llmContent).toContain('fileA.txt:1:hello world');
expect(result.llmContent).toContain('L1: hello world'); expect(result.llmContent).toContain('fileA.txt:2:second line with world');
expect(result.llmContent).toContain('L2: second line with world');
expect(result.llmContent).toContain( expect(result.llmContent).toContain(
`File: ${path.join('sub', 'fileC.txt')}`, 'sub/fileC.txt:1:another world in sub dir',
); );
expect(result.llmContent).toContain('L1: another world in sub dir');
expect(result.returnDisplay).toBe('Found 3 matches'); expect(result.returnDisplay).toBe('Found 3 matches');
}); });
@@ -219,12 +236,33 @@ describe('RipGrepTool', () => {
expect(result.llmContent).toContain( expect(result.llmContent).toContain(
'Found 1 match for pattern "world" in path "sub"', 'Found 1 match for pattern "world" in path "sub"',
); );
expect(result.llmContent).toContain('File: fileC.txt'); // Path relative to 'sub' expect(result.llmContent).toContain(
expect(result.llmContent).toContain('L1: another world in sub dir'); 'fileC.txt:1:another world in sub dir',
);
expect(result.returnDisplay).toBe('Found 1 match'); expect(result.returnDisplay).toBe('Found 1 match');
}); });
it('should find matches with an include glob', async () => { it('should use target directory when path is not provided', async () => {
mockSpawn.mockImplementationOnce(
createMockSpawn({
outputData: `fileA.txt:1:hello world${EOL}`,
exitCode: 0,
onCall: (_, args) => {
// Should search in the target directory (tempRootDir)
expect(args[args.length - 1]).toBe(tempRootDir);
},
}),
);
const params: RipGrepToolParams = { pattern: 'world' };
const invocation = grepTool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.llmContent).toContain(
'Found 1 match for pattern "world" in the workspace directory',
);
});
it('should find matches with a glob filter', async () => {
// Setup specific mock for this test // Setup specific mock for this test
mockSpawn.mockImplementationOnce( mockSpawn.mockImplementationOnce(
createMockSpawn({ createMockSpawn({
@@ -233,20 +271,19 @@ describe('RipGrepTool', () => {
}), }),
); );
const params: RipGrepToolParams = { pattern: 'hello', include: '*.js' }; const params: RipGrepToolParams = { pattern: 'hello', glob: '*.js' };
const invocation = grepTool.build(params); const invocation = grepTool.build(params);
const result = await invocation.execute(abortSignal); const result = await invocation.execute(abortSignal);
expect(result.llmContent).toContain( expect(result.llmContent).toContain(
'Found 1 match for pattern "hello" in the workspace directory (filter: "*.js"):', 'Found 1 match for pattern "hello" in the workspace directory (filter: "*.js"):',
); );
expect(result.llmContent).toContain('File: fileB.js');
expect(result.llmContent).toContain( expect(result.llmContent).toContain(
'L2: function baz() { return "hello"; }', 'fileB.js:2:function baz() { return "hello"; }',
); );
expect(result.returnDisplay).toBe('Found 1 match'); 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( await fs.writeFile(
path.join(tempRootDir, 'sub', 'another.js'), path.join(tempRootDir, 'sub', 'another.js'),
'const greeting = "hello";', 'const greeting = "hello";',
@@ -291,18 +328,115 @@ describe('RipGrepTool', () => {
const params: RipGrepToolParams = { const params: RipGrepToolParams = {
pattern: 'hello', pattern: 'hello',
path: 'sub', path: 'sub',
include: '*.js', glob: '*.js',
}; };
const invocation = grepTool.build(params); const invocation = grepTool.build(params);
const result = await invocation.execute(abortSignal); const result = await invocation.execute(abortSignal);
expect(result.llmContent).toContain( expect(result.llmContent).toContain(
'Found 1 match for pattern "hello" in path "sub" (filter: "*.js")', 'Found 1 match for pattern "hello" in path "sub" (filter: "*.js")',
); );
expect(result.llmContent).toContain('File: another.js'); expect(result.llmContent).toContain(
expect(result.llmContent).toContain('L1: const greeting = "hello";'); 'another.js:1:const greeting = "hello";',
);
expect(result.returnDisplay).toBe('Found 1 match'); expect(result.returnDisplay).toBe('Found 1 match');
}); });
it('should pass .qwenignore to ripgrep when respected', async () => {
await fs.writeFile(
path.join(tempRootDir, '.qwenignore'),
'ignored.txt\n',
);
mockSpawn.mockImplementationOnce(
createMockSpawn({
exitCode: 1,
onCall: (_, args) => {
expect(args).toContain('--ignore-file');
expect(args).toContain(path.join(tempRootDir, '.qwenignore'));
},
}),
);
const params: RipGrepToolParams = { pattern: 'secret' };
const invocation = grepTool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.llmContent).toContain(
'No matches found for pattern "secret" in the workspace directory.',
);
expect(result.returnDisplay).toBe('No matches found');
});
it('should include .qwenignore matches when disabled in config', async () => {
await fs.writeFile(path.join(tempRootDir, '.qwenignore'), 'kept.txt\n');
await fs.writeFile(path.join(tempRootDir, 'kept.txt'), 'keep me');
Object.assign(mockConfig, {
getFileFilteringOptions: () => ({
respectGitIgnore: true,
respectQwenIgnore: false,
}),
});
mockSpawn.mockImplementationOnce(
createMockSpawn({
outputData: `kept.txt:1:keep me${EOL}`,
exitCode: 0,
onCall: (_, args) => {
expect(args).not.toContain('--ignore-file');
expect(args).not.toContain(path.join(tempRootDir, '.qwenignore'));
},
}),
);
const params: RipGrepToolParams = { pattern: 'keep' };
const invocation = grepTool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.llmContent).toContain(
'Found 1 match for pattern "keep" in the workspace directory:',
);
expect(result.llmContent).toContain('kept.txt:1:keep me');
expect(result.returnDisplay).toBe('Found 1 match');
});
it('should disable gitignore when configured', async () => {
Object.assign(mockConfig, {
getFileFilteringOptions: () => ({
respectGitIgnore: false,
respectQwenIgnore: true,
}),
});
mockSpawn.mockImplementationOnce(
createMockSpawn({
exitCode: 1,
onCall: (_, args) => {
expect(args).toContain('--no-ignore-vcs');
},
}),
);
const params: RipGrepToolParams = { pattern: 'ignored' };
const invocation = grepTool.build(params);
await invocation.execute(abortSignal);
});
it('should truncate llm content when exceeding maximum length', async () => {
const longMatch = 'fileA.txt:1:' + 'a'.repeat(25_000);
mockSpawn.mockImplementationOnce(
createMockSpawn({
outputData: `${longMatch}${EOL}`,
exitCode: 0,
}),
);
const params: RipGrepToolParams = { pattern: 'a+' };
const invocation = grepTool.build(params);
const result = await invocation.execute(abortSignal);
expect(String(result.llmContent).length).toBeLessThanOrEqual(20_000);
expect(result.llmContent).toMatch(/\[\d+ lines? truncated\] \.\.\./);
expect(result.returnDisplay).toContain('truncated');
});
it('should return "No matches found" when pattern does not exist', async () => { it('should return "No matches found" when pattern does not exist', async () => {
// Setup specific mock for no matches // Setup specific mock for no matches
mockSpawn.mockImplementationOnce( mockSpawn.mockImplementationOnce(
@@ -320,19 +454,10 @@ describe('RipGrepTool', () => {
expect(result.returnDisplay).toBe('No matches found'); expect(result.returnDisplay).toBe('No matches found');
}); });
it('should return an error from ripgrep for invalid regex pattern', async () => { it('should throw validation error for invalid regex pattern', async () => {
mockSpawn.mockImplementationOnce(
createMockSpawn({
exitCode: 2,
}),
);
const params: RipGrepToolParams = { pattern: '[[' }; const params: RipGrepToolParams = { pattern: '[[' };
const invocation = grepTool.build(params); expect(() => grepTool.build(params)).toThrow(
const result = await invocation.execute(abortSignal); 'Invalid regular expression pattern: [[',
expect(result.llmContent).toContain('ripgrep exited with code 2');
expect(result.returnDisplay).toContain(
'Error: ripgrep exited with code 2',
); );
}); });
@@ -379,8 +504,7 @@ describe('RipGrepTool', () => {
expect(result.llmContent).toContain( expect(result.llmContent).toContain(
'Found 1 match for pattern "foo.*bar" in the workspace directory:', 'Found 1 match for pattern "foo.*bar" in the workspace directory:',
); );
expect(result.llmContent).toContain('File: fileB.js'); expect(result.llmContent).toContain('fileB.js:1:const foo = "bar";');
expect(result.llmContent).toContain('L1: const foo = "bar";');
}); });
it('should be case-insensitive by default (JS fallback)', async () => { it('should be case-insensitive by default (JS fallback)', async () => {
@@ -430,11 +554,9 @@ describe('RipGrepTool', () => {
expect(result.llmContent).toContain( expect(result.llmContent).toContain(
'Found 2 matches for pattern "HELLO" in the workspace directory:', 'Found 2 matches for pattern "HELLO" in the workspace directory:',
); );
expect(result.llmContent).toContain('File: fileA.txt'); expect(result.llmContent).toContain('fileA.txt:1:hello world');
expect(result.llmContent).toContain('L1: hello world');
expect(result.llmContent).toContain('File: fileB.js');
expect(result.llmContent).toContain( expect(result.llmContent).toContain(
'L2: function baz() { return "hello"; }', 'fileB.js:2:function baz() { return "hello"; }',
); );
}); });
@@ -462,191 +584,6 @@ describe('RipGrepTool', () => {
}); });
}); });
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]),
getDebugMode: () => false,
} as unknown as Config;
// Setup specific mock for this test - multi-directory search for 'world'
// Mock will be called twice - once for each directory
let callCount = 0;
mockSpawn.mockImplementation(() => {
callCount++;
const mockProcess = {
stdout: {
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
on: vi.fn(),
removeListener: vi.fn(),
},
on: vi.fn(),
removeListener: vi.fn(),
kill: vi.fn(),
};
setTimeout(() => {
const stdoutDataHandler = mockProcess.stdout.on.mock.calls.find(
(call) => call[0] === 'data',
)?.[1];
const closeHandler = mockProcess.on.mock.calls.find(
(call) => call[0] === 'close',
)?.[1];
let outputData = '';
if (callCount === 1) {
// First directory (tempRootDir)
outputData =
[
'fileA.txt:1:hello world',
'fileA.txt:2:second line with world',
'sub/fileC.txt:1:another world in sub dir',
].join(EOL) + EOL;
} else if (callCount === 2) {
// Second directory (secondDir)
outputData =
[
'other.txt:2:world in second',
'another.js:1:function world() { return "test"; }',
].join(EOL) + EOL;
}
if (stdoutDataHandler && outputData) {
stdoutDataHandler(Buffer.from(outputData));
}
if (closeHandler) {
closeHandler(0);
}
}, 0);
return mockProcess as unknown as ChildProcess;
});
const multiDirGrepTool = new RipGrepTool(multiDirConfig);
const params: RipGrepToolParams = { pattern: 'world' };
const invocation = multiDirGrepTool.build(params);
const result = await invocation.execute(abortSignal);
// Should find matches in both directories
expect(result.llmContent).toContain(
'Found 5 matches for pattern "world"',
);
// Matches from first directory
expect(result.llmContent).toContain('fileA.txt');
expect(result.llmContent).toContain('L1: hello world');
expect(result.llmContent).toContain('L2: second line with world');
expect(result.llmContent).toContain('fileC.txt');
expect(result.llmContent).toContain('L1: another world in sub dir');
// Matches from both directories
expect(result.llmContent).toContain('other.txt');
expect(result.llmContent).toContain('L2: world in second');
expect(result.llmContent).toContain('another.js');
expect(result.llmContent).toContain('L1: function world()');
// Clean up
await fs.rm(secondDir, { recursive: true, force: true });
mockSpawn.mockClear();
});
it('should search only specified path within workspace directories', async () => {
// Create additional directory
const secondDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'grep-tool-second-'),
);
await fs.mkdir(path.join(secondDir, 'sub'));
await fs.writeFile(
path.join(secondDir, 'sub', 'test.txt'),
'hello from second sub directory',
);
// Create a mock config with multiple directories
const multiDirConfig = {
getTargetDir: () => tempRootDir,
getWorkspaceContext: () =>
createMockWorkspaceContext(tempRootDir, [secondDir]),
getDebugMode: () => false,
} as unknown as Config;
// Setup specific mock for this test - searching in 'sub' should only return matches from that directory
mockSpawn.mockImplementationOnce(() => {
const mockProcess = {
stdout: {
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
on: vi.fn(),
removeListener: vi.fn(),
},
on: vi.fn(),
removeListener: vi.fn(),
kill: vi.fn(),
};
setTimeout(() => {
const onData = mockProcess.stdout.on.mock.calls.find(
(call) => call[0] === 'data',
)?.[1];
const onClose = mockProcess.on.mock.calls.find(
(call) => call[0] === 'close',
)?.[1];
if (onData) {
onData(Buffer.from(`fileC.txt:1:another world in sub dir${EOL}`));
}
if (onClose) {
onClose(0);
}
}, 0);
return mockProcess as unknown as ChildProcess;
});
const multiDirGrepTool = new RipGrepTool(multiDirConfig);
// Search only in the 'sub' directory of the first workspace
const params: RipGrepToolParams = { pattern: 'world', path: 'sub' };
const invocation = multiDirGrepTool.build(params);
const result = await invocation.execute(abortSignal);
// Should only find matches in the specified sub directory
expect(result.llmContent).toContain(
'Found 1 match for pattern "world" in path "sub"',
);
expect(result.llmContent).toContain('File: fileC.txt');
expect(result.llmContent).toContain('L1: another world in sub dir');
// Should not contain matches from second directory
expect(result.llmContent).not.toContain('test.txt');
// Clean up
await fs.rm(secondDir, { recursive: true, force: true });
});
});
describe('abort signal handling', () => { describe('abort signal handling', () => {
it('should handle AbortSignal during search', async () => { it('should handle AbortSignal during search', async () => {
const controller = new AbortController(); const controller = new AbortController();
@@ -1062,8 +999,8 @@ describe('RipGrepTool', () => {
}); });
}); });
describe('include pattern filtering', () => { describe('glob pattern filtering', () => {
it('should handle multiple file extensions in include pattern', async () => { it('should handle multiple file extensions in glob pattern', async () => {
await fs.writeFile( await fs.writeFile(
path.join(tempRootDir, 'test.ts'), path.join(tempRootDir, 'test.ts'),
'typescript content', 'typescript content',
@@ -1075,7 +1012,7 @@ describe('RipGrepTool', () => {
); );
await fs.writeFile(path.join(tempRootDir, 'test.txt'), 'text content'); await fs.writeFile(path.join(tempRootDir, 'test.txt'), 'text content');
// Setup specific mock for this test - include pattern should filter to only ts/tsx files // Setup specific mock for this test - glob pattern should filter to only ts/tsx files
mockSpawn.mockImplementationOnce(() => { mockSpawn.mockImplementationOnce(() => {
const mockProcess = { const mockProcess = {
stdout: { stdout: {
@@ -1116,7 +1053,7 @@ describe('RipGrepTool', () => {
const params: RipGrepToolParams = { const params: RipGrepToolParams = {
pattern: 'content', pattern: 'content',
include: '*.{ts,tsx}', glob: '*.{ts,tsx}',
}; };
const invocation = grepTool.build(params); const invocation = grepTool.build(params);
const result = await invocation.execute(abortSignal); const result = await invocation.execute(abortSignal);
@@ -1127,7 +1064,7 @@ describe('RipGrepTool', () => {
expect(result.llmContent).not.toContain('test.txt'); expect(result.llmContent).not.toContain('test.txt');
}); });
it('should handle directory patterns in include', async () => { it('should handle directory patterns in glob', async () => {
await fs.mkdir(path.join(tempRootDir, 'src'), { recursive: true }); await fs.mkdir(path.join(tempRootDir, 'src'), { recursive: true });
await fs.writeFile( await fs.writeFile(
path.join(tempRootDir, 'src', 'main.ts'), path.join(tempRootDir, 'src', 'main.ts'),
@@ -1135,7 +1072,7 @@ describe('RipGrepTool', () => {
); );
await fs.writeFile(path.join(tempRootDir, 'other.ts'), 'other code'); await fs.writeFile(path.join(tempRootDir, 'other.ts'), 'other code');
// Setup specific mock for this test - include pattern should filter to only src/** files // Setup specific mock for this test - glob pattern should filter to only src/** files
mockSpawn.mockImplementationOnce(() => { mockSpawn.mockImplementationOnce(() => {
const mockProcess = { const mockProcess = {
stdout: { stdout: {
@@ -1172,7 +1109,7 @@ describe('RipGrepTool', () => {
const params: RipGrepToolParams = { const params: RipGrepToolParams = {
pattern: 'code', pattern: 'code',
include: 'src/**', glob: 'src/**',
}; };
const invocation = grepTool.build(params); const invocation = grepTool.build(params);
const result = await invocation.execute(abortSignal); const result = await invocation.execute(abortSignal);
@@ -1189,10 +1126,10 @@ describe('RipGrepTool', () => {
expect(invocation.getDescription()).toBe("'testPattern'"); expect(invocation.getDescription()).toBe("'testPattern'");
}); });
it('should generate correct description with pattern and include', () => { it('should generate correct description with pattern and glob', () => {
const params: RipGrepToolParams = { const params: RipGrepToolParams = {
pattern: 'testPattern', pattern: 'testPattern',
include: '*.ts', glob: '*.ts',
}; };
const invocation = grepTool.build(params); const invocation = grepTool.build(params);
expect(invocation.getDescription()).toBe("'testPattern' in *.ts"); expect(invocation.getDescription()).toBe("'testPattern' in *.ts");
@@ -1211,29 +1148,18 @@ describe('RipGrepTool', () => {
expect(invocation.getDescription()).toContain(path.join('src', 'app')); expect(invocation.getDescription()).toContain(path.join('src', 'app'));
}); });
it('should indicate searching across all workspace directories when no path specified', () => { it('should generate correct description with default search path', () => {
// Create a mock config with multiple directories
const multiDirConfig = {
getTargetDir: () => tempRootDir,
getWorkspaceContext: () =>
createMockWorkspaceContext(tempRootDir, ['/another/dir']),
getDebugMode: () => false,
} as unknown as Config;
const multiDirGrepTool = new RipGrepTool(multiDirConfig);
const params: RipGrepToolParams = { pattern: 'testPattern' }; const params: RipGrepToolParams = { pattern: 'testPattern' };
const invocation = multiDirGrepTool.build(params); const invocation = grepTool.build(params);
expect(invocation.getDescription()).toBe( expect(invocation.getDescription()).toBe("'testPattern'");
"'testPattern' across all workspace directories",
);
}); });
it('should generate correct description with pattern, include, and path', async () => { it('should generate correct description with pattern, glob, and path', async () => {
const dirPath = path.join(tempRootDir, 'src', 'app'); const dirPath = path.join(tempRootDir, 'src', 'app');
await fs.mkdir(dirPath, { recursive: true }); await fs.mkdir(dirPath, { recursive: true });
const params: RipGrepToolParams = { const params: RipGrepToolParams = {
pattern: 'testPattern', pattern: 'testPattern',
include: '*.ts', glob: '*.ts',
path: path.join('src', 'app'), path: path.join('src', 'app'),
}; };
const invocation = grepTool.build(params); const invocation = grepTool.build(params);

View File

@@ -10,16 +10,19 @@ import { EOL } from 'node:os';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import type { ToolInvocation, ToolResult } from './tools.js'; import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { SchemaValidator } from '../utils/schemaValidator.js'; import { ToolNames } from './tool-names.js';
import { makeRelative, shortenPath } from '../utils/paths.js'; import { makeRelative, shortenPath } from '../utils/paths.js';
import { getErrorMessage, isNodeError } from '../utils/errors.js'; import { getErrorMessage, isNodeError } from '../utils/errors.js';
import type { Config } from '../config/config.js'; import type { Config } from '../config/config.js';
import { ensureRipgrepPath } from '../utils/ripgrepUtils.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 DEFAULT_TOTAL_MAX_MATCHES = 20000; const MAX_LLM_CONTENT_LENGTH = 20_000;
/** /**
* Parameters for the GrepTool * Parameters for the GrepTool (Simplified)
*/ */
export interface RipGrepToolParams { export interface RipGrepToolParams {
/** /**
@@ -33,18 +36,14 @@ export interface RipGrepToolParams {
path?: string; 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;
}
/** /**
* Result object for a single grep match * Maximum number of matching lines to return (optional, shows all if not specified)
*/ */
interface GrepMatch { limit?: number;
filePath: string;
lineNumber: number;
line: string;
} }
class GrepToolInvocation extends BaseToolInvocation< class GrepToolInvocation extends BaseToolInvocation<
@@ -61,18 +60,15 @@ class GrepToolInvocation extends BaseToolInvocation<
/** /**
* Checks if a path is within the root directory and resolves it. * Checks if a path is within the root directory and resolves it.
* @param relativePath Path relative to the root directory (or undefined for root). * @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). * @returns The absolute path to search within.
* @throws {Error} If path is outside root, doesn't exist, or isn't a directory. * @throws {Error} If path is outside root, doesn't exist, or isn't a directory.
*/ */
private resolveAndValidatePath(relativePath?: string): string | null { private resolveAndValidatePath(relativePath?: string): string {
// If no path specified, return null to indicate searching all workspace directories const targetDir = this.config.getTargetDir();
if (!relativePath) { const targetPath = relativePath
return null; ? path.resolve(targetDir, relativePath)
} : targetDir;
const targetPath = path.resolve(this.config.getTargetDir(), relativePath);
// Security Check: Ensure the resolved path is within workspace boundaries
const workspaceContext = this.config.getWorkspaceContext(); const workspaceContext = this.config.getWorkspaceContext();
if (!workspaceContext.isPathWithinWorkspace(targetPath)) { if (!workspaceContext.isPathWithinWorkspace(targetPath)) {
const directories = workspaceContext.getDirectories(); const directories = workspaceContext.getDirectories();
@@ -81,7 +77,10 @@ class GrepToolInvocation extends BaseToolInvocation<
); );
} }
// Check existence and type after resolving return this.ensureDirectory(targetPath);
}
private ensureDirectory(targetPath: string): string {
try { try {
const stats = fs.statSync(targetPath); const stats = fs.statSync(targetPath);
if (!stats.isDirectory()) { if (!stats.isDirectory()) {
@@ -101,104 +100,81 @@ class GrepToolInvocation extends BaseToolInvocation<
async execute(signal: AbortSignal): Promise<ToolResult> { async execute(signal: AbortSignal): Promise<ToolResult> {
try { try {
const workspaceContext = this.config.getWorkspaceContext();
const searchDirAbs = this.resolveAndValidatePath(this.params.path); const searchDirAbs = this.resolveAndValidatePath(this.params.path);
const searchDirDisplay = this.params.path || '.'; const searchDirDisplay = this.params.path || '.';
// Determine which directories to search // Get raw ripgrep output
let searchDirectories: readonly string[]; const rawOutput = await this.performRipgrepSearch({
if (searchDirAbs === null) { pattern: this.params.pattern,
// No path specified - search all workspace directories path: searchDirAbs,
searchDirectories = workspaceContext.getDirectories(); glob: this.params.glob,
} else { signal,
// Specific path provided - search only that directory });
searchDirectories = [searchDirAbs];
}
let allMatches: GrepMatch[] = []; // Build search description
const totalMaxMatches = DEFAULT_TOTAL_MAX_MATCHES; const searchLocationDescription = this.params.path
? `in path "${searchDirDisplay}"`
: `in the workspace directory`;
if (this.config.getDebugMode()) { const filterDescription = this.params.glob
console.log(`[GrepTool] Total result limit: ${totalMaxMatches}`); ? ` (filter: "${this.params.glob}")`
} : '';
for (const searchDir of searchDirectories) { // Check if we have any matches
const searchResult = await this.performRipgrepSearch({ if (!rawOutput.trim()) {
pattern: this.params.pattern, const noMatchMsg = `No matches found for pattern "${this.params.pattern}" ${searchLocationDescription}${filterDescription}.`;
path: searchDir,
include: this.params.include,
signal,
});
if (searchDirectories.length > 1) {
const dirName = path.basename(searchDir);
searchResult.forEach((match) => {
match.filePath = path.join(dirName, match.filePath);
});
}
allMatches = allMatches.concat(searchResult);
if (allMatches.length >= totalMaxMatches) {
allMatches = allMatches.slice(0, totalMaxMatches);
break;
}
}
let searchLocationDescription: string;
if (searchDirAbs === null) {
const numDirs = workspaceContext.getDirectories().length;
searchLocationDescription =
numDirs > 1
? `across ${numDirs} workspace directories`
: `in the workspace directory`;
} else {
searchLocationDescription = `in path "${searchDirDisplay}"`;
}
if (allMatches.length === 0) {
const noMatchMsg = `No matches found for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}.`;
return { llmContent: noMatchMsg, returnDisplay: `No matches found` }; return { llmContent: noMatchMsg, returnDisplay: `No matches found` };
} }
const wasTruncated = allMatches.length >= totalMaxMatches; // Split into lines and count total matches
const allLines = rawOutput.split(EOL).filter((line) => line.trim());
const totalMatches = allLines.length;
const matchTerm = totalMatches === 1 ? 'match' : 'matches';
const matchesByFile = allMatches.reduce( // Build header early to calculate available space
(acc, match) => { const header = `Found ${totalMatches} ${matchTerm} for pattern "${this.params.pattern}" ${searchLocationDescription}${filterDescription}:\n---\n`;
const fileKey = match.filePath; const maxTruncationNoticeLength = 100; // "[... N more matches truncated]"
if (!acc[fileKey]) { const maxGrepOutputLength =
acc[fileKey] = []; MAX_LLM_CONTENT_LENGTH - header.length - maxTruncationNoticeLength;
}
acc[fileKey].push(match);
acc[fileKey].sort((a, b) => a.lineNumber - b.lineNumber);
return acc;
},
{} as Record<string, GrepMatch[]>,
);
const matchCount = allMatches.length; // Apply line limit first (if specified)
const matchTerm = matchCount === 1 ? 'match' : 'matches'; let truncatedByLineLimit = false;
let linesToInclude = allLines;
let llmContent = `Found ${matchCount} ${matchTerm} for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}`; if (
this.params.limit !== undefined &&
if (wasTruncated) { allLines.length > this.params.limit
llmContent += ` (results limited to ${totalMaxMatches} matches for performance)`; ) {
linesToInclude = allLines.slice(0, this.params.limit);
truncatedByLineLimit = true;
} }
llmContent += `:\n---\n`; // Join lines back into grep output
let grepOutput = linesToInclude.join(EOL);
for (const filePath in matchesByFile) { // Apply character limit as safety net
llmContent += `File: ${filePath}\n`; let truncatedByCharLimit = false;
matchesByFile[filePath].forEach((match) => { if (grepOutput.length > maxGrepOutputLength) {
const trimmedLine = match.line.trim(); grepOutput = grepOutput.slice(0, maxGrepOutputLength) + '...';
llmContent += `L${match.lineNumber}: ${trimmedLine}\n`; truncatedByCharLimit = true;
});
llmContent += '---\n';
} }
let displayMessage = `Found ${matchCount} ${matchTerm}`; // Count how many lines we actually included after character truncation
if (wasTruncated) { const finalLines = grepOutput.split(EOL).filter((line) => line.trim());
displayMessage += ` (limited)`; 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 (show real count, not truncated)
let displayMessage = `Found ${totalMatches} ${matchTerm}`;
if (truncatedByLineLimit || truncatedByCharLimit) {
displayMessage += ` (truncated)`;
} }
return { return {
@@ -215,53 +191,15 @@ class GrepToolInvocation extends BaseToolInvocation<
} }
} }
private parseRipgrepOutput(output: string, basePath: string): GrepMatch[] {
const results: GrepMatch[] = [];
if (!output) return results;
const lines = output.split(EOL);
for (const line of lines) {
if (!line.trim()) continue;
const firstColonIndex = line.indexOf(':');
if (firstColonIndex === -1) continue;
const secondColonIndex = line.indexOf(':', firstColonIndex + 1);
if (secondColonIndex === -1) continue;
const filePathRaw = line.substring(0, firstColonIndex);
const lineNumberStr = line.substring(
firstColonIndex + 1,
secondColonIndex,
);
const lineContent = line.substring(secondColonIndex + 1);
const lineNumber = parseInt(lineNumberStr, 10);
if (!isNaN(lineNumber)) {
const absoluteFilePath = path.resolve(basePath, filePathRaw);
const relativeFilePath = path.relative(basePath, absoluteFilePath);
results.push({
filePath: relativeFilePath || path.basename(absoluteFilePath),
lineNumber,
line: lineContent,
});
}
}
return results;
}
private async performRipgrepSearch(options: { private async performRipgrepSearch(options: {
pattern: string; pattern: string;
path: string; path: string;
include?: string; glob?: string;
signal: AbortSignal; signal: AbortSignal;
}): Promise<GrepMatch[]> { }): Promise<string> {
const { pattern, path: absolutePath, include } = options; const { pattern, path: absolutePath, glob } = options;
const rgArgs = [ const rgArgs: string[] = [
'--line-number', '--line-number',
'--no-heading', '--no-heading',
'--with-filename', '--with-filename',
@@ -270,29 +208,34 @@ class GrepToolInvocation extends BaseToolInvocation<
pattern, pattern,
]; ];
if (include) { // Add file exclusions from .gitignore and .qwenignore
rgArgs.push('--glob', include); const filteringOptions = this.getFileFilteringOptions();
if (!filteringOptions.respectGitIgnore) {
rgArgs.push('--no-ignore-vcs');
} }
const excludes = [ if (filteringOptions.respectQwenIgnore) {
'.git', const qwenIgnorePath = path.join(
'node_modules', this.config.getTargetDir(),
'bower_components', '.qwenignore',
'*.log', );
'*.tmp', if (fs.existsSync(qwenIgnorePath)) {
'build', rgArgs.push('--ignore-file', qwenIgnorePath);
'dist', }
'coverage', }
];
excludes.forEach((exclude) => { // Add glob pattern if provided
rgArgs.push('--glob', `!${exclude}`); if (glob) {
}); rgArgs.push('--glob', glob);
}
rgArgs.push('--threads', '4'); rgArgs.push('--threads', '4');
rgArgs.push(absolutePath); rgArgs.push(absolutePath);
try { try {
const rgPath = await ensureRipgrepPath(); const rgPath = this.config.getUseBuiltinRipgrep()
? await ensureRipgrepPath()
: 'rg';
const output = await new Promise<string>((resolve, reject) => { const output = await new Promise<string>((resolve, reject) => {
const child = spawn(rgPath, rgArgs, { const child = spawn(rgPath, rgArgs, {
windowsHide: true, windowsHide: true,
@@ -334,22 +277,33 @@ class GrepToolInvocation extends BaseToolInvocation<
}); });
}); });
return this.parseRipgrepOutput(output, absolutePath); return output;
} catch (error: unknown) { } catch (error: unknown) {
console.error(`GrepLogic: ripgrep failed: ${getErrorMessage(error)}`); console.error(`GrepLogic: ripgrep failed: ${getErrorMessage(error)}`);
throw error; throw error;
} }
} }
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,
};
}
/** /**
* Gets a description of the grep operation * Gets a description of the grep operation
* @param params Parameters for the grep operation
* @returns A string describing the grep * @returns A string describing the grep
*/ */
getDescription(): string { getDescription(): string {
let description = `'${this.params.pattern}'`; let description = `'${this.params.pattern}'`;
if (this.params.include) { if (this.params.glob) {
description += ` in ${this.params.include}`; description += ` in ${this.params.glob}`;
} }
if (this.params.path) { if (this.params.path) {
const resolvedPath = path.resolve( const resolvedPath = path.resolve(
@@ -381,36 +335,41 @@ class GrepToolInvocation extends BaseToolInvocation<
} }
/** /**
* Implementation of the Grep tool logic (moved from CLI) * Implementation of the Grep tool logic
*/ */
export class RipGrepTool extends BaseDeclarativeTool< export class RipGrepTool extends BaseDeclarativeTool<
RipGrepToolParams, RipGrepToolParams,
ToolResult ToolResult
> { > {
static readonly Name = 'search_file_content'; static readonly Name = ToolNames.GREP;
constructor(private readonly config: Config) { constructor(private readonly config: Config) {
super( super(
RipGrepTool.Name, RipGrepTool.Name,
'SearchText', 'Grep',
'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. Total results limited to 20,000 matches like VSCode.', 'A powerful search tool built on ripgrep\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 - Use Task tool for open-ended searches requiring multiple rounds\n - Pattern syntax: Uses ripgrep (not grep) - special regex characters need escaping (use `interface\\{\\}` to find `interface{}` in Go code)\n',
Kind.Search, Kind.Search,
{ {
properties: { properties: {
pattern: { 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', 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}") - maps to rg --glob',
}, },
path: { path: {
description:
'Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.',
type: 'string', type: 'string',
description:
'File or directory to search in (rg PATH). Defaults to current working directory.',
}, },
include: { limit: {
type: 'number',
description: 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).", 'Limit output to first N lines/entries. Optional - shows all matches if not specified.',
type: 'string',
}, },
}, },
required: ['pattern'], required: ['pattern'],
@@ -422,13 +381,13 @@ export class RipGrepTool extends BaseDeclarativeTool<
/** /**
* Checks if a path is within the root directory and resolves it. * Checks if a path is within the root directory and resolves it.
* @param relativePath Path relative to the root directory (or undefined for root). * @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). * @returns The absolute path to search within.
* @throws {Error} If path is outside root, doesn't exist, or isn't a directory. * @throws {Error} If path is outside root, doesn't exist, or isn't a directory.
*/ */
private resolveAndValidatePath(relativePath?: string): string | null { private resolveAndValidatePath(relativePath?: string): string {
// If no path specified, return null to indicate searching all workspace directories // If no path specified, search within the workspace root directory
if (!relativePath) { if (!relativePath) {
return null; return this.config.getTargetDir();
} }
const targetPath = path.resolve(this.config.getTargetDir(), relativePath); const targetPath = path.resolve(this.config.getTargetDir(), relativePath);
@@ -465,7 +424,9 @@ export class RipGrepTool extends BaseDeclarativeTool<
* @param params Parameters to validate * @param params Parameters to validate
* @returns An error message string if invalid, null otherwise * @returns An error message string if invalid, null otherwise
*/ */
override validateToolParams(params: RipGrepToolParams): string | null { protected override validateToolParamValues(
params: RipGrepToolParams,
): string | null {
const errors = SchemaValidator.validate( const errors = SchemaValidator.validate(
this.schema.parametersJsonSchema, this.schema.parametersJsonSchema,
params, params,
@@ -474,6 +435,13 @@ export class RipGrepTool extends BaseDeclarativeTool<
return errors; return errors;
} }
// Validate pattern is a valid regex
try {
new RegExp(params.pattern);
} catch (error) {
return `Invalid regular expression pattern: ${params.pattern}. Error: ${getErrorMessage(error)}`;
}
// Only validate path if one is provided // Only validate path if one is provided
if (params.path) { if (params.path) {
try { try {

View File

@@ -14,7 +14,7 @@ export const ToolNames = {
WRITE_FILE: 'write_file', WRITE_FILE: 'write_file',
READ_FILE: 'read_file', READ_FILE: 'read_file',
READ_MANY_FILES: 'read_many_files', READ_MANY_FILES: 'read_many_files',
GREP: 'search_file_content', GREP: 'grep_search',
GLOB: 'glob', GLOB: 'glob',
SHELL: 'run_shell_command', SHELL: 'run_shell_command',
TODO_WRITE: 'todo_write', TODO_WRITE: 'todo_write',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -339,6 +339,7 @@ describe('editor utils', () => {
diffCommand.args, diffCommand.args,
{ {
stdio: 'inherit', stdio: 'inherit',
shell: process.platform === 'win32',
}, },
); );
expect(mockSpawnOn).toHaveBeenCalledWith('close', expect.any(Function)); expect(mockSpawnOn).toHaveBeenCalledWith('close', expect.any(Function));

View File

@@ -195,6 +195,7 @@ export async function openDiff(
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
const childProcess = spawn(diffCommand.command, diffCommand.args, { const childProcess = spawn(diffCommand.command, diffCommand.args, {
stdio: 'inherit', stdio: 'inherit',
shell: process.platform === 'win32',
}); });
childProcess.on('close', (code) => { childProcess.on('close', (code) => {

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import type { Part } from '@google/genai'; import type { Content, Part } from '@google/genai';
import type { Config } from '../config/config.js'; import type { Config } from '../config/config.js';
import { getFolderStructure } from './getFolderStructure.js'; import { getFolderStructure } from './getFolderStructure.js';
@@ -107,3 +107,23 @@ ${directoryContext}
return initialParts; return initialParts;
} }
export async function getInitialChatHistory(
config: Config,
extraHistory?: Content[],
): Promise<Content[]> {
const envParts = await getEnvironmentContext(config);
const envContextString = envParts.map((part) => part.text || '').join('\n\n');
return [
{
role: 'user',
parts: [{ text: envContextString }],
},
{
role: 'model',
parts: [{ text: 'Got it. Thanks for the context!' }],
},
...(extraHistory ?? []),
];
}

View File

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

View File

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

View File

@@ -152,7 +152,16 @@ describe('ripgrepUtils', () => {
}); });
describe('canUseRipgrep', () => { describe('canUseRipgrep', () => {
it('should return true if ripgrep binary exists', async () => { it('should return true if ripgrep binary exists (builtin)', async () => {
(fileExists as Mock).mockResolvedValue(true);
const result = await canUseRipgrep(true);
expect(result).toBe(true);
expect(fileExists).toHaveBeenCalledOnce();
});
it('should return true if ripgrep binary exists (default)', async () => {
(fileExists as Mock).mockResolvedValue(true); (fileExists as Mock).mockResolvedValue(true);
const result = await canUseRipgrep(); const result = await canUseRipgrep();
@@ -161,15 +170,26 @@ describe('ripgrepUtils', () => {
expect(fileExists).toHaveBeenCalledOnce(); expect(fileExists).toHaveBeenCalledOnce();
}); });
it('should return false if ripgrep binary does not exist', async () => { it('should fall back to system rg if bundled ripgrep binary does not exist', async () => {
(fileExists as Mock).mockResolvedValue(false); (fileExists as Mock).mockResolvedValue(false);
// When useBuiltin is true but bundled binary doesn't exist,
// it should fall back to checking system rg (which will spawn a process)
// In this test environment, system rg is likely available, so result should be true
// unless spawn fails
const result = await canUseRipgrep(); const result = await canUseRipgrep();
expect(result).toBe(false); // The test may pass or fail depending on system rg availability
// Just verify that fileExists was called to check bundled binary first
expect(fileExists).toHaveBeenCalledOnce(); expect(fileExists).toHaveBeenCalledOnce();
// Result depends on whether system rg is installed
expect(typeof result).toBe('boolean');
}); });
// Note: Tests for system ripgrep detection (useBuiltin=false) would require mocking
// the child_process spawn function, which is complex in ESM. These cases are tested
// indirectly through integration tests.
it('should return false if platform is unsupported', async () => { it('should return false if platform is unsupported', async () => {
const originalPlatform = process.platform; const originalPlatform = process.platform;

View File

@@ -85,13 +85,31 @@ export function getRipgrepPath(): string {
/** /**
* Checks if ripgrep binary is available * Checks if ripgrep binary is available
* @param useBuiltin If true, tries bundled ripgrep first, then falls back to system ripgrep.
* If false, only checks for system ripgrep.
*/ */
export async function canUseRipgrep(): Promise<boolean> { export async function canUseRipgrep(
useBuiltin: boolean = true,
): Promise<boolean> {
try { try {
const rgPath = getRipgrepPath(); if (useBuiltin) {
return await fileExists(rgPath); // Try bundled ripgrep first
const rgPath = getRipgrepPath();
if (await fileExists(rgPath)) {
return true;
}
// Fallback to system rg if bundled binary is not available
}
// Check for system ripgrep by trying to spawn 'rg --version'
const { spawn } = await import('node:child_process');
return await new Promise<boolean>((resolve) => {
const proc = spawn('rg', ['--version']);
proc.on('error', () => resolve(false));
proc.on('exit', (code) => resolve(code === 0));
});
} catch (_error) { } catch (_error) {
// Unsupported platform/arch // Unsupported platform/arch or other error
return false; return false;
} }
} }

View File

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

View File

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

View File

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

View File

@@ -69,7 +69,14 @@ if (process.env.DEBUG) {
// than the relaunched process making it harder to debug. // than the relaunched process making it harder to debug.
env.GEMINI_CLI_NO_RELAUNCH = 'true'; 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) => { child.on('close', (code) => {
process.exit(code); process.exit(code);